feat(selfop): add bounded inventory flow diagnostic through account egress

This commit is contained in:
2026-10-15 11:37:49 +08:00
parent e19d303a76
commit d81d9cbda4
16 changed files with 365 additions and 5 deletions
@@ -33,6 +33,7 @@ import java.util.function.Function;
public class RestLingxingAnalyticsClient implements LingxingAnalyticsClient {
private static final String PREFIX = "/internal/integrations/lingxing/wms";
private static final Set<String> PROVIDER_ERROR_CODES = Set.of(
"LINGXING_WMS_FLOW_PERMISSION_DENIED",
"LINGXING_WMS_AUTHORIZATION_REJECTED", "LINGXING_WMS_SIGNATURE_REJECTED",
"LINGXING_WMS_SOURCE_REJECTED", "LINGXING_WMS_INVALID_RESPONSE", "LINGXING_WMS_RATE_LIMITED",
"LINGXING_WMS_HTTP_RATE_LIMITED", "LINGXING_WMS_SOURCE_RATE_LIMITED",
@@ -122,6 +123,13 @@ public class RestLingxingAnalyticsClient implements LingxingAnalyticsClient {
quantity(n,"wmsLength"),quantity(n,"wmsWidth"),quantity(n,"wmsHeight"),optional(n,"wmsSizeUnit")));
}
public JsonNode inventoryFlow(SelfopRequestContext context,String warehouse,String customer,String sku,
LocalDateTime start,LocalDateTime end) {
gate(context);argument(warehouse,64);argument(customer,64);argument(sku,128);
return post(context,"/inventory-flow/query",Map.of("warehouseCode",warehouse,"customerCode",customer,"sku",sku,
"startTime",DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(start),"endTime",DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(end)));
}
private Summary summary(String account, JsonNode n) {
List<SelfopPayloads.Item> items = null;
JsonNode products = n.get("productList");
@@ -160,9 +168,10 @@ public class RestLingxingAnalyticsClient implements LingxingAnalyticsClient {
case "/product-inventory/query" -> Operation.PRODUCT_INVENTORY;
case "/customers/query" -> Operation.CUSTOMERS;
case "/products/query" -> Operation.PRODUCTS;
case "/inventory-flow/query" -> Operation.INVENTORY_FLOW;
default -> throw failure("EGRESS_OPERATION_INVALID");
};
int size = fields.containsKey("size") ? ((Number)fields.get("size")).intValue() : 0;
int size = op==Operation.INVENTORY_FLOW?1000:fields.containsKey("size") ? ((Number)fields.get("size")).intValue() : 0;
String warehouse=(String)fields.get("warehouseCode");
String key=op==Operation.OUTBOUND_DETAIL ? SelfopSupport.json(List.of(fields.get("customerCode"),fields.get("deliveryNo"))) : null;
var permission = egress.reserve(context,op,size,warehouse,key,SelfopSupport.hash(fields));
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.selfoperated.analytics.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.selfoperated.analytics.sync.InventoryFlowProbeService;
import org.springframework.web.bind.annotation.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
@RestController
@RequestMapping("/api/platform/v1/last-leg/self-operated-analytics/inventory-flow-probe")
public class PlatformInventoryFlowProbeController {
private final InventoryFlowProbeService service;
public PlatformInventoryFlowProbeController(InventoryFlowProbeService service){this.service=service;}
@PostMapping
@PreAuthorize("hasAuthority('platform:self-operated-analytics:sync')")
public ApiResponse<InventoryFlowProbeService.Result> probe(@AuthenticationPrincipal AuthenticatedUser user,
@RequestBody InventoryFlowProbeService.Request request) {
if(user==null||!Long.valueOf(0).equals(user.tenantId())||!"PLATFORM".equals(user.appCode())||user.impersonated()
||!user.permissions().contains("platform:self-operated-analytics:sync"))throw new ResponseStatusException(HttpStatus.FORBIDDEN);
return ApiResponse.success(service.probe(request,user.username()));
}
}
@@ -15,7 +15,7 @@ public interface SelfopCoordinationMapper {
SelfopSyncTask lockTask(long id);
@Select("SELECT * FROM ll_selfop_sync_run WHERE id=#{id} FOR UPDATE")
SelfopSyncRun lockRun(long id);
@Select("SELECT * FROM ll_selfop_sync_task WHERE execution_purpose=#{purpose} AND status IN ('READY','RETRY_WAIT','RUNNING') AND (execution_purpose='BUSINESS' OR (status='READY' AND validation_root_task_id=id)) AND (next_retry_at_utc IS NULL OR next_retry_at_utc<=#{now}) ORDER BY CASE WHEN task_type IN ('RECHECK','REBUILD') THEN 1 WHEN task_role='WAREHOUSE_SYNC' THEN 0 WHEN task_type='BACKFILL' THEN 3 ELSE 2 END,id LIMIT #{limit}")
@Select("SELECT * FROM ll_selfop_sync_task WHERE execution_purpose=#{purpose} AND task_type<>'INVENTORY_FLOW_PROBE' AND status IN ('READY','RETRY_WAIT','RUNNING') AND (execution_purpose='BUSINESS' OR (status='READY' AND validation_root_task_id=id)) AND (next_retry_at_utc IS NULL OR next_retry_at_utc<=#{now}) ORDER BY CASE WHEN task_type IN ('RECHECK','REBUILD') THEN 1 WHEN task_role='WAREHOUSE_SYNC' THEN 0 WHEN task_type='BACKFILL' THEN 3 ELSE 2 END,id LIMIT #{limit}")
List<SelfopSyncTask> due(@Param("now") LocalDateTime now, @Param("limit") int limit, @Param("purpose") String purpose);
// Started inventory work may resume across midnight; unstarted historical slots remain excluded.
// Publication independently requires the planned day and observation day to match for daily samples.
@@ -7,7 +7,7 @@ public final class SelfopEgressDtos {
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,CUSTOMERS,PRODUCTS}
public enum Operation {WAREHOUSES,OUTBOUND_QUERY,OUTBOUND_LOCATE,OUTBOUND_DETAIL,PRODUCT_INVENTORY,CUSTOMERS,PRODUCTS,INVENTORY_FLOW}
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)
public record AuthorizationDenial(String errorCode,boolean sampleDone,Instant waitUntil){}
@@ -39,6 +39,11 @@ public class SelfopEgressService {
SelfopBusinessBudget business=hasBusinessSession(root)?business(root):null;
if(business!=null){verifyBusinessTask(task,root);String limit=business.limit(now);if(limit!=null){stopBusiness(root,business,limit,now);throw reject(limit);}}
verify(task,run,root,account,now);
boolean flowOperation=operation==Operation.INVENTORY_FLOW;
if(flowOperation!="INVENTORY_FLOW_PROBE".equals(task.getTaskType())
||flowOperation&&(!"test".equals(config.getEnvironment())||!"BUSINESS".equals(task.getExecutionPurpose())
||pageSize!=1000||!config.getWarehouses().containsKey(warehouseCode)
||business==null||business.http!=0||!Objects.equals(task.getRequestHash(),requestHash)))throw reject("EGRESS_FLOW_PROBE_INVALID");
boolean masterOperation=operation==Operation.CUSTOMERS||operation==Operation.PRODUCTS;
if(masterOperation!=("MASTER_DATA".equals(task.getTaskType()))
||masterOperation&&(!"BUSINESS".equals(task.getExecutionPurpose())||pageSize<1||pageSize>(operation==Operation.CUSTOMERS?1000:100)))throw reject("EGRESS_OPERATION_TASK_MISMATCH");
@@ -0,0 +1,103 @@
package com.baiyue.lastleg.selfoperated.analytics.sync;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.selfoperated.analytics.client.RestLingxingAnalyticsClient;
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDateTime;
import java.util.*;
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
/** TEST-only, one audited request. No schedule, retry, publication or historical inventory mutation. */
@Service
public class InventoryFlowProbeService {
private final SelfopAnalyticsProperties config;
private final SelfopSyncTaskMapper tasks;
private final SelfopSyncRunMapper runs;
private final SelfopOutboundDiscoveryScopeMapper accounts;
private final SelfopLeaseService leases;
private final RestLingxingAnalyticsClient client;
private final TransactionTemplate tx;
public InventoryFlowProbeService(SelfopAnalyticsProperties config,SelfopSyncTaskMapper tasks,SelfopSyncRunMapper runs,
SelfopOutboundDiscoveryScopeMapper accounts,SelfopLeaseService leases,RestLingxingAnalyticsClient client,TransactionTemplate tx) {
this.config=config;this.tasks=tasks;this.runs=runs;this.accounts=accounts;this.leases=leases;this.client=client;this.tx=tx;
}
public record Request(String requestKey,String warehouseCode,String customerCode,String sku,LocalDateTime startTime,LocalDateTime endTime) {
public Map<String,Object> providerRequest(){return Map.of("warehouseCode",warehouseCode,"customerCode",customerCode,"sku",sku,
"startTime",java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(startTime),
"endTime",java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(endTime));}
}
public record Result(String taskNo,boolean complete,JsonNode page) {}
static void validate(Request q,LocalDateTime now) {
require(q!=null,"SELFOP_FLOW_REQUEST_REQUIRED");identity(q.requestKey(),80);identity(q.warehouseCode(),64);
identity(q.customerCode(),64);identity(q.sku(),128);
require(q.startTime()!=null&&q.endTime()!=null&&q.startTime().isBefore(q.endTime())
&&!q.endTime().isAfter(q.startTime().plusDays(31))&&!q.endTime().isAfter(now),"SELFOP_FLOW_WINDOW_INVALID");
}
public Result probe(Request q,String actor) {
validate(q,utcNow().plusHours(8));
require("test".equals(config.getEnvironment())&&"BUSINESS".equals(config.getCollectionMode())
&&config.isExecutionEnabled()&&config.getWarehouses().containsKey(q.warehouseCode()),"SELFOP_FLOW_PROBE_DISABLED");
String key="flow-probe:"+q.requestKey(),digest=hash(q.providerRequest());
var account=accounts.selectOne(new QueryWrapper<SelfopOutboundDiscoveryScope>().eq("source_account",config.getSourceAccount()));
require(account!=null,"SELFOP_SCOPE_MISSING");
var lease=leases.acquireDiscovery(account.getId(),"flow-probe-"+UUID.randomUUID());
try {
var existing=tasks.selectOne(new QueryWrapper<SelfopSyncTask>().eq("source_account",config.getSourceAccount()).eq("request_key",key));
if(existing!=null) {
require(digest.equals(existing.getRequestHash()),"SELFOP_IDEMPOTENCY_CONFLICT");
require("SAMPLE_DONE".equals(existing.getStatus())&&existing.getRoutingManifestJson()!=null,"SELFOP_FLOW_PREVIOUS_ATTEMPT_REQUIRES_REVIEW");
return read(existing.getRoutingManifestJson(),Result.class);
}
var now=utcNow();var task=new SelfopSyncTask();var run=new SelfopSyncRun();
task.setId(IdWorker.getId());task.setTaskNo("LXFLOW-"+UUID.randomUUID().toString().replace("-","").toUpperCase(Locale.ROOT));
task.setSourceAccount(config.getSourceAccount());task.setExecutionPurpose("BUSINESS");task.setTaskRole("ACCOUNT_DISCOVERY");
task.setDiscoveryScopeId(account.getId());task.setTaskType("INVENTORY_FLOW_PROBE");task.setStatus("RUNNING");
task.setApplyState("NOT_APPLICABLE");task.setAttemptCount(1);task.setRequestKey(key);task.setRequestHash(digest);
task.setScopeHash(digest);task.setQueryScopeJson(json(q.providerRequest()));task.setBusinessRootTaskId(task.getId());
task.setBusinessBudgetJson(SelfopBusinessBudget.freeze(now));task.setRequestedBy(actor);task.setCreatedAt(now);task.setUpdatedAt(now);
run.setId(IdWorker.getId());run.setTaskId(task.getId());run.setSourceAccount(config.getSourceAccount());run.setExecutionPurpose("BUSINESS");
run.setRunRole("ACCOUNT_DISCOVERY");run.setAttemptNo(1);run.setScopeHash(digest);run.setScopeSnapshotJson(task.getQueryScopeJson());
run.setFenceToken(lease.fence());run.setStatus("FETCHING");run.setLastPhase("FETCHING");run.setFetchStartedAtUtc(now);
run.setCoverageStatus("UNKNOWN");run.setPageCount(0);run.setUniqueObjectCount(0L);run.setCreatedAt(now);run.setUpdatedAt(now);
tx.executeWithoutResult(s->{leases.verify(lease);tasks.insert(task);runs.insert(run);});
try {
JsonNode page=client.inventoryFlow(SelfopRequestContext.forTask(task,run,config),q.warehouseCode(),q.customerCode(),q.sku(),q.startTime(),q.endTime());
boolean complete=validatePage(page);
var result=new Result(task.getTaskNo(),complete,page);
tx.executeWithoutResult(s->{
leases.verify(lease);var done=utcNow();
task.setStatus("SAMPLE_DONE");task.setRoutingManifestJson(json(result));task.setUpdatedAt(done);
run.setStatus("SAMPLE_DONE");run.setLastPhase("INVENTORY_FLOW_PROBE");run.setPageCount(1);
run.setSourceTotalCount(page.path("total").asLong());run.setUniqueObjectCount((long)page.path("records").size());
run.setCoverageStatus(complete?"COMPLETE":"PARTIAL");run.setFetchFinishedAtUtc(done);run.setFinishedAtUtc(done);run.setUpdatedAt(done);
tasks.updateById(task);runs.updateById(run);
});
return result;
} catch(RuntimeException failure) {
String error=failure instanceof com.baiyue.lastleg.selfoperated.analytics.client.AnalyticsClientException c?c.code():
failure instanceof com.baiyue.lastleg.selfoperated.analytics.request.EgressRejectedException e?e.code():"FLOW_PROBE_FAILED";
tx.executeWithoutResult(s->{leases.verify(lease);task.setStatus("DEAD");task.setUpdatedAt(utcNow());
run.setStatus("FAILED");run.setErrorCode(error);run.setErrorMessage("库存流水诊断失败;不自动重试");run.setFinishedAtUtc(utcNow());run.setUpdatedAt(utcNow());
tasks.updateById(task);runs.updateById(run);});
throw failure;
}
} finally {leases.release(lease);}
}
static boolean validatePage(JsonNode page) {
require(page!=null&&page.path("records").isArray()&&page.path("current").asInt()==1&&page.path("size").asInt()==1000
&&page.path("total").asLong(-1)>=0,"SELFOP_FLOW_PAGE_INVALID");
long total=page.path("total").asLong();
require(page.path("pages").asLong(-1)==(total==0?0:(total-1)/1000+1)
&&page.path("records").size()==Math.min(1000,total),"SELFOP_FLOW_PAGE_INVALID");
var ids=new HashSet<String>();
for(var row:page.path("records"))require(row.path("id").isTextual()&&!row.path("id").asText().isBlank()
&&ids.add(row.path("id").asText()),"SELFOP_FLOW_DUPLICATE_ID");
return total<=1000;
}
}