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 cf6808bcd..6058e53f7 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 @@ -90,7 +90,8 @@ public final class LingxingEgressGate { } private void check(EgressContext context) { if (properties.collectionMode() == CollectionMode.OFF) throw failure("EGRESS_OFF"); - if (properties.collectionMode() == CollectionMode.BUSINESS && !"production".equals(properties.environmentLabel())) + if (properties.collectionMode() == CollectionMode.BUSINESS + && !("production".equals(properties.environmentLabel()) || "test".equals(properties.environmentLabel()))) throw failure("BUSINESS_ENVIRONMENT_REJECTED"); if (paused && (!recoverableRatePause || properties.collectionMode() != CollectionMode.BUSINESS || cooldownUntil == null || clock.instant().isBefore(cooldownUntil))) throw failure("ACCOUNT_PAUSED"); 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 b0aeb8ca7..3c1a15a9b 100644 --- a/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml +++ b/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml @@ -6,7 +6,7 @@ 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. 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 manual commands and their legitimate derived work; the adapter does not schedule work. All five 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. A JVM lock is only local protection and is not proof of cross-environment account exclusivity. diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingEgressGateTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingEgressGateTest.java index 8b4f90310..82d7712b0 100644 --- a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingEgressGateTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingEgressGateTest.java @@ -37,16 +37,46 @@ class LingxingEgressGateTest { assertThat(completion.sourceCode()).isNull(); } } - @Test void defaultOffAndNonProductionBusinessNeverAuthorize() { + @Test void defaultOffAndUnknownBusinessEnvironmentsNeverAuthorize() { var defaults = new LingxingEgressProperties(null, null, null, null, null, null, null, null); assertThat(defaults.collectionMode()).isEqualTo(CollectionMode.OFF); assertThat(defaults.minStartInterval()).isEqualTo(Duration.ofSeconds(5)); var off = new LingxingEgressGate(defaults, ledger, clock, clock::advanceMillis); assertThatThrownBy(() -> off.execute(context("a"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; })).hasMessageContaining("EGRESS_OFF"); - var business = new LingxingEgressGate(props(CollectionMode.BUSINESS, "test"), ledger, clock, clock::advanceMillis); - assertThatThrownBy(() -> business.execute(context("a"), Operation.WAREHOUSES, HASH, h -> true)).hasMessageContaining("BUSINESS_ENVIRONMENT_REJECTED"); + for(String environment:List.of("dev","gray","staging","prod","TEST","")) { + var business = new LingxingEgressGate(props(CollectionMode.BUSINESS, environment), ledger, clock, clock::advanceMillis); + assertThatThrownBy(() -> business.execute(businessContext("rejected"), Operation.WAREHOUSES, HASH, h -> true)).hasMessageContaining("BUSINESS_ENVIRONMENT_REJECTED"); + } assertThat(ledger.authorizations).isZero(); } + @Test void testAndProductionBusinessUseTheSameSingleConsumedPermitAndCompletionProtocol() { + for(String environment:List.of("test","production")) { + var gate=new LingxingEgressGate(props(CollectionMode.BUSINESS,environment),ledger,clock,clock::advanceMillis); + var result=gate.execute(businessContext(environment+"-inventory"),Operation.PRODUCT_INVENTORY,HASH,handle->{handle.startHttp();return handle.metadata();}); + assertThat(result.actualHttpAttempts()).isEqualTo(1);assertThat(result.accountPaused()).isFalse(); + } + assertThat(ledger.authorizations).isEqualTo(2);assertThat(ledger.consumed).hasSize(2);assertThat(ledger.completions).hasSize(2); + assertThat(ledger.completions).allSatisfy(c->{assertThat(c.context().executionPurpose()).isEqualTo(CollectionMode.BUSINESS);assertThat(c.operation()).isEqualTo(Operation.PRODUCT_INVENTORY);assertThat(c.actualHttpAttempts()).isEqualTo(1);}); + } + @Test void testBusinessStillRejectsPurposeAccountAndGrantMismatchBeforeAuthorization() { + var gate=new LingxingEgressGate(props(CollectionMode.BUSINESS,"test"),ledger,clock,clock::advanceMillis); + assertThatThrownBy(()->gate.execute(context("wrong-purpose"),Operation.WAREHOUSES,HASH,h->true)).hasMessageContaining("EGRESS_CONTEXT_INVALID"); + assertThatThrownBy(()->gate.execute(new EgressContext("other-account",CollectionMode.BUSINESS,"root","session","wrong-account","demo-grant"),Operation.WAREHOUSES,HASH,h->true)).hasMessageContaining("EGRESS_CONTEXT_INVALID"); + assertThatThrownBy(()->gate.execute(new EgressContext("demo-account",CollectionMode.BUSINESS,"root","session","wrong-grant","other-grant"),Operation.WAREHOUSES,HASH,h->true)).hasMessageContaining("EXCLUSIVE_WINDOW_UNCONFIRMED"); + clock.advanceMillis(3600000); + assertThatThrownBy(()->gate.execute(businessContext("expired-window"),Operation.WAREHOUSES,HASH,h->true)).hasMessageContaining("EXCLUSIVE_WINDOW_UNCONFIRMED"); + assertThat(ledger.authorizations).isZero();assertThat(ledger.completions).isEmpty(); + } + @Test void testBusinessUnconfirmedCompletionStillRequiresManualReviewAfterTimePasses() { + var gate=new LingxingEgressGate(props(CollectionMode.BUSINESS,"test"),ledger,clock,clock::advanceMillis); + ledger.failComplete=true; + var failure=assertThrows(LingxingWmsException.class,()->gate.execute(businessContext("test-lost-ack"),Operation.PRODUCT_INVENTORY,HASH,h->{h.startHttp();return true;})); + assertThat(failure.code()).isEqualTo("LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED"); + assertThat(failure.egress().actualHttpAttempts()).isEqualTo(1);assertThat(failure.egress().accountPaused()).isTrue(); + clock.advanceMillis(600000);ledger.failComplete=false; + assertThatThrownBy(()->gate.execute(businessContext("test-after-uncertainty"),Operation.PRODUCT_INVENTORY,HASH,h->{h.startHttp();return true;})).hasMessageContaining("ACCOUNT_PAUSED"); + assertThat(ledger.authorizations).isEqualTo(1);assertThat(ledger.completions).hasSize(1); + } @Test void proofAndContextAreServerBoundBeforeAuthorization() { var contexts = Arrays.asList(null, new EgressContext("other", CollectionMode.VALIDATION, "root", "session", "a", "demo-grant"), new EgressContext("demo-account", CollectionMode.BUSINESS, "root", "session", "a", "demo-grant"), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopAnalyticsProperties.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopAnalyticsProperties.java index 8078209bc..0bca860e1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopAnalyticsProperties.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopAnalyticsProperties.java @@ -35,7 +35,7 @@ public class SelfopAnalyticsProperties { private int minimumBuildIntervalSeconds = 1800; /** Read-only UI projection; collectionMode is the sole runtime mode input. */ public boolean isExecutionEnabled() { - return "VALIDATION".equals(collectionMode)||("BUSINESS".equals(collectionMode)&&"production".equals(environment)); + return "VALIDATION".equals(collectionMode)||("BUSINESS".equals(collectionMode)&&SelfopCollectionPolicy.businessEnvironment(this)); } private boolean salesPublicationEnabled = false; private String integrationBaseUrl = "http://integration-service"; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopCollectionPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopCollectionPolicy.java index 90c4e6f2c..435f6e8ea 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopCollectionPolicy.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopCollectionPolicy.java @@ -19,7 +19,38 @@ public final class SelfopCollectionPolicy { public static void requireExecution(SelfopAnalyticsProperties config,String purpose) { require("VALIDATION".equals(purpose)||"BUSINESS".equals(purpose),"SELFOP_PURPOSE_INVALID"); require(purpose.equals(config.getCollectionMode()),"SELFOP_COLLECTION_MODE_REJECTED"); - if("BUSINESS".equals(purpose))require("production".equals(config.getEnvironment()),"SELFOP_BUSINESS_ENVIRONMENT_REJECTED"); + if("BUSINESS".equals(purpose))require(businessEnvironment(config),"SELFOP_BUSINESS_ENVIRONMENT_REJECTED"); + } + public static boolean businessEnvironment(SelfopAnalyticsProperties config) { + return "test".equals(config.getEnvironment())||"production".equals(config.getEnvironment()); + } + public static void requireAutomaticPlanning(SelfopAnalyticsProperties config) { + requireExecution(config,"BUSINESS"); + require("production".equals(config.getEnvironment()),"SELFOP_AUTOMATIC_PLANNING_ENVIRONMENT_REJECTED"); + } + /** TEST may poll only persisted manual roots and their existing routing/refresh descendants. */ + public static boolean controlledBusinessTask(SelfopSyncTask task,java.util.function.Function lookup) { + if(task==null)return false; + String account=task.getSourceAccount();var seen=new java.util.HashSet(); + for(int depth=0;task!=null&&depth<64;depth++) { + if(!"BUSINESS".equals(task.getExecutionPurpose())||task.getValidationRootTaskId()!=null + ||!java.util.Objects.equals(account,task.getSourceAccount())||"SCHEDULED".equals(task.getTaskType()))return false; + if(task.getId()!=null&&!seen.add(task.getId()))return false; + String key=task.getRequestKey(); + if(key!=null&&key.startsWith("manual:")&&task.getRequestedBy()!=null&&!task.getRequestedBy().isBlank())return true; + Long parent=task.getUpstreamTaskId(); + if(parent!=null) { + if(!"WAREHOUSE_SYNC".equals(task.getTaskRole())||!java.util.Objects.equals(key,"route:"+parent+":"+task.getWarehouseCode()))return false; + }else { + if(!"ACCOUNT_DISCOVERY".equals(task.getTaskRole())||!"RECHECK".equals(task.getTaskType())||key==null)return false; + Map query=read(task.getQueryScopeJson(),Map.class); + if(query==null||!(query.get("parentTaskId") instanceof Number id))return false; + parent=id.longValue(); + if(!key.startsWith("refresh:"+parent+":")&&!key.startsWith("relocate:"+parent+":"))return false; + } + task=lookup.apply(parent); + } + return false; } public static String configuredPurpose(SelfopAnalyticsProperties config) { String purpose=config.getCollectionMode();requireExecution(config,purpose);return purpose; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryService.java index 3a62e0a44..40ecaf3d6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryService.java @@ -86,7 +86,7 @@ public class CollectionQueryService { Task shared="OUTBOUND".equals(family)&&sharedWindowRelated(active,scope)?active:null; String blocked=executionBlock(warehouse,protection); boolean enabled=blocked==null; - boolean publication="BUSINESS".equals(config.getCollectionMode())&&"production".equals(config.getEnvironment()) + boolean publication="BUSINESS".equals(config.getCollectionMode())&&config.isExecutionEnabled() &&("INVENTORY".equals(family)||config.isSalesPublicationEnabled()); String initial="INVENTORY".equals(family)?published!=null&&"COMPLETE".equals(published.coverageStatus())?"COMPLETE":"UNKNOWN" :scope==null||scope.initialFromUtc()==null||scope.initialUntilUtc()==null?"UNKNOWN" @@ -97,7 +97,7 @@ public class CollectionQueryService { String freshness=published==null?"NOT_COLLECTED":scope.staleAfterSeconds()==null||scope.staleAfterSeconds()<=0?"UNKNOWN" :observation==null?"UNKNOWN":!clock.instant().isBefore(observation.plusSeconds(scope.staleAfterSeconds()))?"STALE":"FRESH"; rows.add(new CollectionResponses.Warehouse(warehouse,warehouse,family,enabled,publication,blocked, - enabled?nextSchedule(warehouse,family):null,clock.instant(),scope==null?null:utc(scope.initialFromUtc()), + enabled&&"production".equals(config.getEnvironment())?nextSchedule(warehouse,family):null,clock.instant(),scope==null?null:utc(scope.initialFromUtc()), scope==null?null:utc(scope.initialUntilUtc()),initial,published==null?null:utc(published.coveredFromUtc()), published==null?null:utc(published.coveredUntilUtc()),last==null?null:last.taskNo(),last==null||context.latest().get(last.id())==null?null:utc(context.latest().get(last.id()).finishedAtUtc()), published==null?null:utc(published.publishedAtUtc()),"INVENTORY".equals(family)&&published!=null?utc(published.fetchStartedAtUtc()):null, @@ -234,6 +234,7 @@ public class CollectionQueryService { if(!"WAREHOUSE_SYNC".equals(task.taskRole()))return "ACCOUNT_SHARED_TASK"; String disabled=executionBlock(task.warehouseCode(),protection);if(disabled!=null)return disabled; if(!"BUSINESS".equals(config.getCollectionMode()))return "COLLECTION_MODE_NOT_BUSINESS"; + if("test".equals(config.getEnvironment())&&"SCHEDULED".equals(task.taskType()))return "TEST_MANUAL_TASK_REQUIRED"; if(!Set.of("DEAD","RETRY_WAIT").contains(task.status()))return "TASK_NOT_RETRYABLE"; if(scope==null||!Objects.equals(scope.scopeHash(),task.scopeHash()))return "SCOPE_CHANGED"; if(scope.leaseOwner()!=null&&scope.leaseUntilUtc()!=null&&clock.instant().isBefore(utc(scope.leaseUntilUtc())))return "SCOPE_LEASE_BUSY"; @@ -273,7 +274,7 @@ public class CollectionQueryService { private String executionBlock(String warehouse,CollectionResponses.Protection protection) { if("OFF".equals(config.getCollectionMode()))return "COLLECTION_OFF"; if(!Set.of("BUSINESS","VALIDATION").contains(config.getCollectionMode()))return "COLLECTION_MODE_INVALID"; - if("BUSINESS".equals(config.getCollectionMode())&&!"production".equals(config.getEnvironment()))return "BUSINESS_ENVIRONMENT_REJECTED"; + if("BUSINESS".equals(config.getCollectionMode())&&!com.baiyue.lastleg.selfoperated.analytics.model.SelfopCollectionPolicy.businessEnvironment(config))return "BUSINESS_ENVIRONMENT_REJECTED"; if(!config.isExecutionEnabled())return "EXECUTION_DISABLED"; if(!config.getWarehouses().containsKey(warehouse))return "WAREHOUSE_NOT_CONFIGURED"; var w=config.getWarehouses().get(warehouse); 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 831136856..07a0b02da 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 @@ -110,7 +110,7 @@ public class SelfopEgressService { public void finishSample(Long rootTaskId,String reason){var root=mapper.lockTask(rootTaskId);var v=validation(root);if("DEAD".equals(root.getStatus()))return;stop(root,v,safeReason(reason),false,now());} private void gate(String account,String purpose,String grant,LocalDateTime now){ if("OFF".equals(config.getCollectionMode())||!Objects.equals(config.getCollectionMode(),purpose)||!("VALIDATION".equals(purpose)||"BUSINESS".equals(purpose)))throw reject("EGRESS_MODE_REJECTED"); - if("BUSINESS".equals(purpose)&&!"production".equals(config.getEnvironment()))throw reject("BUSINESS_ENVIRONMENT_REJECTED"); + if("BUSINESS".equals(purpose)&&!("production".equals(config.getEnvironment())||"test".equals(config.getEnvironment())))throw reject("BUSINESS_ENVIRONMENT_REJECTED"); if(!Objects.equals(config.getSourceAccount(),account)||account==null||account.isBlank())throw reject("SOURCE_ACCOUNT_MISMATCH"); if(grant==null||!Objects.equals(grant,config.getExclusiveGrantId())||config.getExclusiveGrantExpiresAtUtc()==null||!now.isBefore(config.getExclusiveGrantExpiresAtUtc())||!config.isExclusiveHandoffVerified()||!config.isPriorEgressClosed()||!config.isInflightDrained()||!config.isTransferredBudgetVerified())throw reject("EXCLUSIVE_HANDOFF_UNCONFIRMED"); if(config.getDailyRequestBudget()<1||config.getBusinessMinRequestIntervalMs()<1||config.getRequestBudget()<1||config.getTimeBudgetSeconds()<1||config.getMaxRateLimitFailures()<1||config.getCooldownSeconds()<1)throw reject("ACCOUNT_BUDGET_UNCONFIGURED"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandService.java index d8a9b76f0..3a60e4fb6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandService.java @@ -56,6 +56,7 @@ public class SelfopCommandService { require("WAREHOUSE_SYNC".equals(original.getTaskRole())&&c.warehouseCode().equals(original.getWarehouseCode()),"SELFOP_TASK_SCOPE_MISMATCH"); require(!"WAITING_SOURCE".equals(original.getStatus()),"SELFOP_RETRY_WAITING_SOURCE"); require("BUSINESS".equals(original.getExecutionPurpose())&&"BUSINESS".equals(config.getCollectionMode()),"SELFOP_BUSINESS_PURPOSE_REQUIRED"); + require(!"test".equals(config.getEnvironment())||SelfopCollectionPolicy.controlledBusinessTask(original,tasks::selectById),"SELFOP_TEST_MANUAL_TASK_REQUIRED"); return taskService.retry(original.getId(),c.requestKey(),operator,c.reason());} String purpose=SelfopCollectionPolicy.configuredPurpose(config); require(("VALIDATION".equals(purpose))==("VALIDATION".equals(c.action())),"SELFOP_VALIDATION_ENTRY_REQUIRED"); @@ -118,5 +119,6 @@ public class SelfopCommandService { Map.entry("SELFOP_TASK_ALREADY_SUCCEEDED","任务已成功完成,无需重试"), Map.entry("SELFOP_COLLECTION_MODE_REJECTED","当前采集模式不允许此操作"), Map.entry("SELFOP_BUSINESS_ENVIRONMENT_REJECTED","当前环境不允许正式采集"), + Map.entry("SELFOP_TEST_MANUAL_TASK_REQUIRED","测试环境仅允许重试人工采集任务及其派生任务"), Map.entry("SELFOP_BUDGET_INVALID","采集预算配置无效,请联系管理员")); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSchedulePlanner.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSchedulePlanner.java index 3752cbd17..627f230d9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSchedulePlanner.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSchedulePlanner.java @@ -34,6 +34,7 @@ public class SelfopSchedulePlanner { if("BUSINESS".equals(config.getCollectionMode()))SelfopCollectionPolicy.requireExecution(config,"BUSINESS"); List configured; if("VALIDATION".equals(config.getCollectionMode())){taskService.initializeAccount();configured=List.of();} + else if("test".equals(config.getEnvironment()))configured=List.of(); else configured=taskService.initialize(); var now=utcNow(); for(var scope:configured){ @@ -41,10 +42,14 @@ public class SelfopSchedulePlanner { if("INVENTORY".equals(scope.getDataFamily()))plan(scope,now); else if(config.getSourceQueryTimezone()!=null&&Objects.equals(scope.getSourceTimezone(),config.getSourceQueryTimezone())){plan(scope,now);review(scope,now);} } - for(var task:taskService.due(10))try{execution.execute(task.getId());}catch(IllegalStateException leaseBusy){/* Another worker owns the durable lease. */} + for(var task:taskService.due(10)){ + if("BUSINESS".equals(config.getCollectionMode())&&"test".equals(config.getEnvironment()) + &&!SelfopCollectionPolicy.controlledBusinessTask(task,tasks::selectById))continue; + try{execution.execute(task.getId());}catch(IllegalStateException leaseBusy){/* Another worker owns the durable lease. */} + } } public void plan(SelfopSyncScope preview,LocalDateTime now){ - SelfopCollectionPolicy.requireExecution(config,"BUSINESS"); + SelfopCollectionPolicy.requireAutomaticPlanning(config); tx.executeWithoutResult(s->{var scope=locks.lockScope(preview.getId());ZoneId business=ZoneId.of(scope.getBusinessTimezone()); if("OUTBOUND".equals(scope.getDataFamily())&&scope.getInitialUntilUtc()==null){ scope.setInitialUntilUtc(now);scope.setInitialFromUtc(now.atZone(ZoneOffset.UTC).withZoneSameInstant(business).minusMonths(2).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime()); @@ -91,7 +96,7 @@ public class SelfopSchedulePlanner { t.setRequestHash(hash(Arrays.asList(t.getQueryScopeJson(),t.getScopeHash())));return taskService.create(t); } public void review(SelfopSyncScope preview,LocalDateTime now){ - SelfopCollectionPolicy.requireExecution(config,"BUSINESS"); + SelfopCollectionPolicy.requireAutomaticPlanning(config); tx.executeWithoutResult(s->{var scope=locks.lockScope(preview.getId()); if(scope.getKnownOrderRoundMaxId()==null){var max=orders.selectOne(new QueryWrapper().eq("source_account",scope.getSourceAccount()).eq("warehouse_code",scope.getWarehouseCode()).orderByDesc("id").last("LIMIT 1")); scope.setKnownOrderRoundMaxId(max==null?0L:max.getId());scope.setKnownOrderCursorId(0L);scope.setKnownOrderRoundStartedAtUtc(now);} 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 518f93306..c3df64461 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 @@ -12,7 +12,7 @@ paths: requestHash 是 Integration 对 OMP 路径和有序业务 JSON 的摘要,首次消费绑定, 后续完成必须完全匹配。同 attemptId 再次消费拒绝。allowedAttempts 固定 1, expiresAt 最多 30 秒且不超过验证根截止或人工独占窗口。OFF 默认拒绝, - 非 production 环境拒绝 BUSINESS。独占交接证明及接续预算由服务端配置核验。 + BUSINESS 仅接受 production 或 test;TEST 任务入口仅允许受控人工任务及合法派生,不允许自动采集计划。独占交接证明及接续预算由服务端配置核验。 security: [{ internalServiceToken: [] }] requestBody: required: true diff --git a/lastleg-service/src/main/resources/openapi/self-operated-analytics-sync-v1.yaml b/lastleg-service/src/main/resources/openapi/self-operated-analytics-sync-v1.yaml index f8792fd24..16b2bb7ac 100644 --- a/lastleg-service/src/main/resources/openapi/self-operated-analytics-sync-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/self-operated-analytics-sync-v1.yaml @@ -10,7 +10,7 @@ paths: description: >- 仅平台账号且须 platform:self-operated-analytics:sync 权限。命令先持久化, 不在 HTTP 请求内运行历史采集。collectionMode 默认 OFF;VALIDATION 仅用于持久预算内的样本暂存, - BUSINESS 仅允许生产环境,并要求真实出口取得服务端核验的人工独占许可。 + BUSINESS 允许 production 以及受控人工执行的 test,均要求真实出口取得服务端核验的人工独占许可。TEST 不自动计划、历史复查或创建两个月首采;仅执行人工命令及合法派生任务。 出库来源时间及业务时间统一 Asia/Shanghai;首次正式目标固定为 E0 前两个滚动月。 BACKFILL 单片不超过一天。RETRY 使用 taskNo 和非空 reason 恢复原分仓逻辑任务, 保留半归并屏障及旧尝试,拒绝验证会话复活、账号共享任务伪装单仓、有效执行租约及上游未齐; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryServiceTest.java index 66be5901f..1992b45fa 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryServiceTest.java @@ -25,6 +25,18 @@ class CollectionQueryServiceTest { CollectionQuery q(Integer page,LocalDateTime upper,Long id) { return new CollectionQuery("primary","A",null,null,null,null,null,null,page,null,upper==null?null:upper.toInstant(ZoneOffset.UTC),id); } + @Test void controlledTestBusinessProjectsEnabledInventoryPublicationWithoutAutomaticSchedule() { + config.setCollectionMode("BUSINESS");config.setEnvironment("test");config.setQuantityUnit("PCS"); + config.setExclusiveHandoffVerified(true);config.setPriorEgressClosed(true);config.setInflightDrained(true);config.setTransferredBudgetVerified(true); + config.setExclusiveGrantExpiresAtUtc(now.plusHours(1));config.setDailyRequestBudget(100); + var rows=service.overview(q(null,null,null)).warehouses(); + assertFalse(rows.isEmpty());for(var row:rows){assertTrue(row.executionEnabled());assertNull(row.disabledReason());assertNull(row.nextScheduledAt()); + assertEquals("INVENTORY".equals(row.dataFamily()),row.publicationEnabled());} + config.setExclusiveGrantExpiresAtUtc(now.minusSeconds(1)); + for(var row:service.overview(q(null,null,null)).warehouses()){assertFalse(row.executionEnabled());assertEquals("EXCLUSIVE_GRANT_NOT_VALID",row.disabledReason());} + config.setEnvironment("development"); + for(var row:service.overview(q(null,null,null)).warehouses()){assertFalse(row.executionEnabled());assertEquals("BUSINESS_ENVIRONMENT_REJECTED",row.disabledReason());} + } Scope scope() { return new Scope(1L,"A","OUTBOUND","hash","Asia/Shanghai","0 0 8,20 * * *",93600, now.minusMonths(2),now,null,null,0,null,null,null,7,null,null); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java index efad1e3c9..a4d309dd3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java @@ -29,11 +29,19 @@ class SelfopEgressServiceTest { private void success(EgressContext c,Operation op){service.authorize(new AuthorizationRequest(c,op,PROVIDER));service.complete(new CompletionRequest(c,op,PROVIDER,1,null,200,200,false,null));clock.advance(5);} private SelfopApiBudget budget(){return SelfopSupport.read(account.getApiBudgetJson(),SelfopApiBudget.class);} private SelfopValidationBudget rootBudget(){return SelfopSupport.read(task.getValidationBudgetJson(),SelfopValidationBudget.class);} - @Test void offUnknownModeNonProductionBusinessAndUnverifiedHandoffFailBeforeAnyLock(){ + @Test void offUnknownModeUnknownEnvironmentBusinessAndUnverifiedHandoffFailBeforeAnyLock(){ cfg.setCollectionMode("OFF");assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EGRESS_MODE_REJECTED"); - cfg.setCollectionMode("BUSINESS");assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_ENVIRONMENT_REJECTED"); + cfg.setCollectionMode("BUSINESS");cfg.setEnvironment("unknown");assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_ENVIRONMENT_REJECTED"); cfg.setCollectionMode("VALIDATION");cfg.setPriorEgressClosed(false);assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EXCLUSIVE_HANDOFF_UNCONFIRMED");verifyNoInteractions(mapper); } + @Test void testBusinessStillChargesPersistentAccountAndRequiresLiveExclusiveGrant(){ + business();cfg.setEnvironment("test"); + var c=reserve(Operation.WAREHOUSES,1,null,null);success(c,Operation.WAREHOUSES); + assertThat(budget().reservedHttp).isEqualTo(1);assertThat(budget().confirmedHttp).isEqualTo(1); + cfg.setExclusiveGrantExpiresAtUtc(now()); + assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EXCLUSIVE_HANDOFF_UNCONFIRMED"); + assertThat(budget().reservedHttp).isEqualTo(1); + } @Test void frozenPageSizeCannotBeExpanded(){assertThatThrownBy(()->reserve(Operation.WAREHOUSES,11,null,null)).hasMessage("VALIDATION_PAGE_SIZE");assertThat(budget().reservedHttp).isZero();} @Test void allFamiliesAndFailuresConsumeOneRootAndAccountAcrossServiceRestart(){ var c=reserve(Operation.WAREHOUSES,2,null,null);success(c,Operation.WAREHOUSES); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandServiceTest.java index 3d085a894..028dbc4bd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandServiceTest.java @@ -75,17 +75,43 @@ class SelfopCommandServiceTest { task.setWarehouseCode(warehouse); task.setTaskRole("WAREHOUSE_SYNC"); task.setStatus("DEAD"); return task; } - @Test void offModeAndNonProductionBusinessDoNotAcceptNewCollectionTask() { + @Test void offModeAndUnknownEnvironmentDoNotAcceptNewCollectionTask() { configure();config.setCollectionMode("OFF"); assertThat(config.isExecutionEnabled()).isFalse(); var c=new SelfopCommandService.Command("disabled","WH-A","INVENTORY",null,null,null,null,null,"offline test"); assertThatThrownBy(()->service.request(c,"42")).hasMessage("采集功能未启用,当前环境不允许执行采集或重试"); assertThatThrownBy(()->service.request(retry(),"42")).hasMessage("采集功能未启用,当前环境不允许执行采集或重试"); - config.setCollectionMode("BUSINESS");config.setEnvironment("test"); + config.setCollectionMode("BUSINESS");config.setEnvironment("development"); assertThat(config.isExecutionEnabled()).isFalse(); assertThatThrownBy(()->service.request(c,"42")).hasMessage("采集功能未启用,当前环境不允许执行采集或重试"); verifyNoInteractions(taskService,scopes,accounts,tasks); } + @Test void testBusinessManualInventoryUsesWholeScopeAndBackfillKeepsOneDayBound() { + configure();config.setEnvironment("test");config.setSourceQueryTimezone("Asia/Shanghai"); + var scope=new SelfopSyncScope();scope.setId(10L);scope.setScopeHash("scope");scope.setScopeJson("{\"qualities\":\"ALL\"}"); + var account=new SelfopOutboundDiscoveryScope();account.setId(20L); + when(scopes.selectOne(any())).thenReturn(scope);when(accounts.selectOne(any())).thenReturn(account); + when(taskService.create(any(SelfopSyncTask.class))).thenAnswer(call->call.getArgument(0)); + var inventory=service.request(new SelfopCommandService.Command("full-inventory","WH-A","INVENTORY",null,null,null,null,null,null),"42"); + assertThat(config.isExecutionEnabled()).isTrue();assertThat(config.getEnvironment()).isEqualTo("test"); + assertThat(inventory.getExecutionPurpose()).isEqualTo("BUSINESS");assertThat(inventory.getValidationRootTaskId()).isNull(); + assertThat(inventory.getTaskRole()).isEqualTo("WAREHOUSE_SYNC");assertThat(inventory.getScopeId()).isEqualTo(10L); + assertThat(inventory.getQueryScopeJson()).isEqualTo(scope.getScopeJson());assertThat(inventory.getRequestKey()).isEqualTo("manual:full-inventory"); + var from=LocalDateTime.of(2026,10,12,0,0); + var outbound=service.request(new SelfopCommandService.Command("one-day","WH-A","BACKFILL",null,from,from.plusDays(1),null,null,null),"42"); + assertThat(outbound.getExecutionPurpose()).isEqualTo("BUSINESS");assertThat(outbound.getTaskRole()).isEqualTo("ACCOUNT_DISCOVERY"); + assertThat(outbound.getQueryUntilUtc()).isEqualTo(outbound.getQueryFromUtc().plusDays(1)); + clearInvocations(taskService); + assertThatThrownBy(()->service.request(new SelfopCommandService.Command("too-long","WH-A","BACKFILL",null,from,from.plusDays(1).plusSeconds(1),null,null,null),"42")) + .hasMessage("单次采集时间范围不能超过一天"); + verify(taskService,never()).create(any(SelfopSyncTask.class)); + } + @Test void testCannotUseRetryToReviveAutomaticScheduledWork() { + configure();config.setEnvironment("test");var original=original("synthetic-account","WH-A"); + original.setTaskType("SCHEDULED");original.setRequestKey("slot:old");when(tasks.selectOne(any())).thenReturn(original); + assertThatThrownBy(()->service.request(retry(),"42")).hasMessage("测试环境仅允许重试人工采集任务及其派生任务"); + verify(taskService,never()).retry(anyLong(),anyString(),anyString(),anyString()); + } @Test void validationModeRejectsFormalInventoryEntry() { configure();config.setCollectionMode("VALIDATION"); var c=new SelfopCommandService.Command("wrong-purpose","WH-A","INVENTORY",null,null,null,null,null,"offline test"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSchedulePlannerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSchedulePlannerTest.java index 9acb858ec..2d9e99d34 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSchedulePlannerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSchedulePlannerTest.java @@ -32,6 +32,30 @@ class SelfopSchedulePlannerTest { planner.tick(); verifyNoInteractions(taskService, execution, scopes, accounts, tasks, orders, locks, tx); } + @Test void testBusinessPollsManualRootAndRelatedChildrenWithoutAutomaticPlanningOrInitialization() { + config.setCollectionMode("BUSINESS");config.setEnvironment("test"); + var root=controlled(101L,"ACCOUNT_DISCOVERY","BACKFILL","manual:one-day");root.setRequestedBy("42"); + var child=controlled(102L,"WAREHOUSE_SYNC","BACKFILL","route:101:WH-A");child.setWarehouseCode("WH-A");child.setUpstreamTaskId(101L); + var refresh=controlled(103L,"ACCOUNT_DISCOVERY","RECHECK","refresh:101:object:4");refresh.setQueryScopeJson("{\"parentTaskId\":101}"); + var scheduled=controlled(104L,"WAREHOUSE_SYNC","SCHEDULED","slot:old"); + var orphan=controlled(105L,"WAREHOUSE_SYNC","BACKFILL","route:999:WH-A");orphan.setWarehouseCode("WH-A");orphan.setUpstreamTaskId(999L); + var other=controlled(106L,"WAREHOUSE_SYNC","BACKFILL","route:101:WH-A");other.setSourceAccount("other");other.setWarehouseCode("WH-A");other.setUpstreamTaskId(101L); + when(tasks.selectById(101L)).thenReturn(root);when(taskService.due(10)).thenReturn(List.of(root,child,refresh,scheduled,orphan,other)); + planner.tick();verify(execution).execute(101L);verify(execution).execute(102L);verify(execution).execute(103L); + verify(execution,never()).execute(104L);verify(execution,never()).execute(105L);verify(execution,never()).execute(106L); + verify(taskService,never()).initialize();verify(taskService,never()).initializeAccount();verify(taskService,never()).create(any(SelfopSyncTask.class)); + verifyNoInteractions(scopes,accounts,orders,locks,tx); + } + @Test void testRejectsDirectPlanAndReviewBeforeAnyStateOrTwoMonthWindowWrite() { + config.setCollectionMode("BUSINESS");config.setEnvironment("test");var preview=scope(10L,"WH-A","OUTBOUND"); + assertThatThrownBy(()->planner.plan(preview,LocalDateTime.of(2026,10,12,4,0))).hasMessage("SELFOP_AUTOMATIC_PLANNING_ENVIRONMENT_REJECTED"); + assertThatThrownBy(()->planner.review(preview,LocalDateTime.of(2026,10,12,4,0))).hasMessage("SELFOP_AUTOMATIC_PLANNING_ENVIRONMENT_REJECTED"); + assertThat(preview.getInitialUntilUtc()).isNull();verifyNoInteractions(taskService,execution,scopes,accounts,tasks,orders,locks,tx); + } + private SelfopSyncTask controlled(long id,String role,String type,String key) { + var task=new SelfopSyncTask();task.setId(id);task.setSourceAccount("synthetic");task.setExecutionPurpose("BUSINESS"); + task.setTaskRole(role);task.setTaskType(type);task.setRequestKey(key);return task; + } @Test void validationWithoutConfiguredWarehouseOnlyInitializesAccountAndExecutesDurableCommand() { config.setCollectionMode("VALIDATION"); var due=new SelfopSyncTask();due.setId(101L);due.setTaskType("VALIDATION");due.setExecutionPurpose("VALIDATION");