fix(selfop): allow controlled manual collection in test
This commit is contained in:
+2
-1
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
+33
-3
@@ -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"),
|
||||
|
||||
+1
-1
@@ -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";
|
||||
|
||||
+32
-1
@@ -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<Long,SelfopSyncTask> lookup) {
|
||||
if(task==null)return false;
|
||||
String account=task.getSourceAccount();var seen=new java.util.HashSet<Long>();
|
||||
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;
|
||||
|
||||
+4
-3
@@ -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);
|
||||
|
||||
+1
-1
@@ -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");
|
||||
|
||||
+2
@@ -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","采集预算配置无效,请联系管理员"));
|
||||
}
|
||||
|
||||
+8
-3
@@ -34,6 +34,7 @@ public class SelfopSchedulePlanner {
|
||||
if("BUSINESS".equals(config.getCollectionMode()))SelfopCollectionPolicy.requireExecution(config,"BUSINESS");
|
||||
List<SelfopSyncScope> 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<SelfopOutboundOrder>().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);}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 恢复原分仓逻辑任务,
|
||||
保留半归并屏障及旧尝试,拒绝验证会话复活、账号共享任务伪装单仓、有效执行租约及上游未齐;
|
||||
|
||||
+12
@@ -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);
|
||||
|
||||
+10
-2
@@ -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);
|
||||
|
||||
+28
-2
@@ -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");
|
||||
|
||||
+24
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user