`
-Set `BPA_FEISHU_INVENTORY_MODE=preview` to render the complete card to stdout without a network request. Production uses `send`. An accepted provider response is recorded only after the webhook returns success; uncertain external writes are not automatically retried.
+Set `BPA_FEISHU_INVENTORY_MODE=preview` to render the operator-only card to stdout without a network request. Production uses `send`. The daily card lists only deterministic critical or warning risks, includes the exact SKU ID, and links to the inventory dashboard through `BPA_FEISHU_INVENTORY_DASHBOARD_URL`. An accepted provider response is recorded only after the webhook returns success; uncertain external writes are not automatically retried.
## Multi-shop configuration
diff --git a/apps/inventory-monitor/deploy/com.bpa.inventory-feishu-report.plist b/apps/inventory-monitor/deploy/com.bpa.inventory-feishu-report.plist
index 84da3dd3..78083991 100644
--- a/apps/inventory-monitor/deploy/com.bpa.inventory-feishu-report.plist
+++ b/apps/inventory-monitor/deploy/com.bpa.inventory-feishu-report.plist
@@ -16,7 +16,7 @@
StartCalendarInterval
Hour
- 9
+ 8
Minute
30
diff --git a/apps/inventory-monitor/src/dashboard-client.ts b/apps/inventory-monitor/src/dashboard-client.ts
index ea91c2dc..25921111 100644
--- a/apps/inventory-monitor/src/dashboard-client.ts
+++ b/apps/inventory-monitor/src/dashboard-client.ts
@@ -1,127 +1,162 @@
-export const DASHBOARD_CLIENT_CSS = `
-.shop-picker{display:flex!important;align-items:center!important;gap:6px!important;color:var(--muted)!important;font-size:10px!important}.shop-picker select{height:31px!important;min-width:170px!important;border:1px solid #d0d5dd!important;border-radius:4px!important;background:#fff!important;color:#344054!important;padding:0 28px 0 8px!important;font:600 10px inherit!important}.metric:before{display:none!important}
-.hero{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:24px;align-items:end;padding:16px 0}.hero h1{margin:0;color:#171717;font-size:30px;letter-spacing:-.035em}.hero p{margin:7px 0 0;color:#737373;font-size:12px}.hero-alert{display:flex;align-items:center;gap:10px;min-width:420px;padding:12px 15px;border:1px solid #d9e7df;border-radius:6px;background:#f7fcf9;color:var(--green);font-size:11px}.hero-alert i{width:8px;height:8px;border-radius:50%;background:currentColor}.hero-alert strong{font-size:13px}.hero-alert span{color:#777}.hero-alert.critical{border-color:#ecc9c9;background:#fff8f8;color:var(--red)}.hero-alert.warning{border-color:#ead7b9;background:#fffaf2;color:var(--amber)}.hero-alert.unknown{border-color:#dedede;background:#fafafa;color:#777}.brand-mark{display:grid!important;width:34px!important;height:34px!important;margin-right:12px!important;border-radius:9px!important;background:#171717!important;color:#fff!important;place-items:center!important;font-size:17px!important;font-weight:750!important}.brand p{display:block!important;margin:0 0 2px!important;color:#8a8a8a!important;font-size:10px!important;letter-spacing:0!important}.brand h1{font-size:15px!important}.severity-pill.critical{background:#fff0ef!important;color:#c93636!important}.severity-pill.warning{background:#fff7e9!important;color:#986013!important}.severity-pill.unknown{background:#f1f1f1!important;color:#666!important}
-.shop-label{display:inline-flex;align-items:center;padding:3px 6px;border-radius:3px;background:#f2f4f7;color:#475467;font-size:9px;font-weight:600}.shop-status-list{display:grid;gap:0}.shop-status{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px;padding:9px 0;border-bottom:1px solid #eaecf0}.shop-status:last-child{border-bottom:0}.shop-status strong{display:block;font-size:10px}.shop-status p{margin:3px 0 0;color:var(--muted);font-size:9px}.shop-status .shop-numbers{display:flex;gap:4px;align-items:center}.queue-note{padding:12px;border:1px solid #e1e4e8;border-radius:4px;background:#fafafa;color:#667085;font-size:10px;margin-bottom:10px}.inventory-table .shop-column{width:150px}
-:root{--navy:#172033;--blue:#245b91;--green:#147a55;--red:#b42318;--amber:#a15c07;--line:#dfe3e8;--muted:#667085;font-family:Inter,"PingFang SC","Microsoft YaHei",sans-serif;color:var(--navy);background:#f5f6f7;font-synthesis:none}*{box-sizing:border-box}body{margin:0;background:#f5f6f7;min-width:320px}header{height:64px;padding:0 24px;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:20}.brand{display:flex;align-items:center}.brand-mark,.brand p,.eyebrow{display:none}.brand h1{margin:0;font-size:18px;font-weight:650;letter-spacing:0;color:#101828}.header-meta{display:flex;gap:10px;align-items:center;margin-top:4px;color:var(--muted);font-size:11px}.header-meta code{font-family:"SFMono-Regular",Consolas,monospace;color:#344054}.header-actions{display:flex;align-items:center;gap:8px}.status-chip{display:inline-flex;align-items:center;gap:6px;padding:5px 8px;border:1px solid #b7dfcc;border-radius:4px;background:#f6fef9;color:var(--green);font-size:11px}.status-chip i{width:6px;height:6px;border-radius:50%;background:currentColor}.status-chip.warning{border-color:#f0d7ad;background:#fffbeb;color:var(--amber)}.status-chip.critical{border-color:#f2c7c4;background:#fff7f6;color:var(--red)}button{border:1px solid #344054;border-radius:4px;background:#344054;color:#fff;padding:7px 11px;font:600 11px inherit;cursor:pointer}button:hover{background:#1d2939}button.secondary{border-color:#d0d5dd;background:#fff;color:#344054}button.secondary:hover{background:#f9fafb}button:disabled{opacity:.55;cursor:not-allowed}main{max-width:1600px;margin:0 auto;padding:18px 22px 50px}.metrics{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));background:#fff;border:1px solid var(--line);border-radius:5px}.metric{padding:14px 16px;border-right:1px solid var(--line);min-height:82px}.metric:last-child{border-right:0}.metric small{display:block;color:var(--muted);font-size:11px}.metric strong{display:block;margin-top:8px;font-size:22px;font-weight:650;color:#101828}.metric span{display:block;margin-top:4px;color:#7b8494;font-size:10px}.metric.critical strong{color:var(--red)}.metric.warning strong{color:var(--amber)}.metric.good strong{color:var(--green)}.priority-grid{display:grid;grid-template-columns:minmax(0,1.55fr) minmax(320px,.65fr);gap:12px;margin-top:12px}.panel,.product{background:#fff;border:1px solid var(--line);border-radius:5px;box-shadow:none}.panel{padding:16px}.section-title{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}.section-title h2,.panel>h2{margin:0;font-size:14px;font-weight:650;color:#101828}.count-badge{min-width:24px;height:24px;display:grid;place-items:center;border:1px solid var(--line);border-radius:4px;background:#f9fafb;font-size:11px;font-weight:650}.incidents-panel{min-height:260px}.quality-summary{display:grid;grid-template-columns:8px minmax(0,1fr) auto;gap:10px;align-items:start;padding:12px;border:1px solid #f0d7ad;border-radius:4px;background:#fffbeb;margin-bottom:10px}.quality-summary i{width:8px;height:8px;border-radius:50%;background:var(--amber);margin-top:4px}.quality-summary strong{display:block;font-size:11px;color:#7a2e0e}.quality-summary p{margin:4px 0 0;color:#854d0e;font-size:10px;line-height:1.5}.quality-summary span{font-size:10px;font-weight:650;color:var(--amber);white-space:nowrap}.risk-table{width:100%;border-collapse:collapse}.risk-table th,.risk-table td{text-align:left;padding:9px 8px;border-bottom:1px solid #eaecf0;font-size:11px;vertical-align:top}.risk-table th{background:#f9fafb;color:var(--muted);font-weight:600}.risk-table tr:last-child td{border-bottom:0}.severity-pill{display:inline-flex;padding:3px 6px;border-radius:3px;background:#f2f4f7;font-size:10px;font-weight:650}.severity-pill.critical{background:#fef3f2;color:var(--red)}.severity-pill.warning{background:#fffaeb;color:var(--amber)}.severity-pill.unknown{color:#475467}.risk-title{font-weight:600;color:#101828}.risk-reason{max-width:360px;color:#475467;line-height:1.45}.id-list{display:grid;gap:3px}.id-list code,.id-line code{font-family:"SFMono-Regular",Consolas,monospace;font-size:10px;color:#344054}.inline-copy{border:0;background:transparent;color:var(--blue);padding:0 0 0 5px;font-size:9px}.inline-copy:hover{background:transparent;text-decoration:underline}.reminder{display:grid;grid-template-columns:8px 1fr;gap:9px;padding:10px 0;border-bottom:1px solid #eaecf0}.reminder:last-child{border-bottom:0}.reminder-indicator{width:7px;height:7px;border-radius:50%;margin-top:4px;background:#98a2b3}.reminder.critical .reminder-indicator{background:var(--red)}.reminder.warning .reminder-indicator{background:var(--amber)}.reminder h3{margin:0 0 3px;font-size:11px;font-weight:650}.reminder p{margin:0;color:var(--muted);font-size:10px;line-height:1.5}.reminder-action{display:block;margin-top:3px;color:var(--blue)}.empty{padding:28px 12px;text-align:center;color:var(--muted);font-size:11px}.empty strong{display:block;margin-bottom:4px;color:#344054;font-size:12px}.readiness-panel,.backtest-panel,.inventory-panel,.details-grid{margin-top:12px}.readiness-panel #readiness{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));border-top:1px solid #eaecf0;border-left:1px solid #eaecf0}.readiness-item{padding:10px 12px;border-right:1px solid #eaecf0;border-bottom:1px solid #eaecf0}.readiness-item span{display:block;color:var(--muted);font-size:10px}.readiness-item strong{display:block;margin-top:5px;font-size:11px;font-weight:600}.readiness-item.wide{grid-column:span 2}.readiness-item .bar{height:5px;margin-top:7px;background:#eaecf0;display:flex}.readiness-item .bar i:nth-child(1){background:var(--green)}.readiness-item .bar i:nth-child(2){background:#d99a31}.readiness-item .bar i:nth-child(3){background:#98a2b3}.bar-legend{display:flex;gap:8px;flex-wrap:wrap;margin-top:5px;color:var(--muted);font-size:9px}.live-dot{display:none}.inventory-panel{padding:0}.inventory-panel .section-title{padding:14px 16px;margin:0;border-bottom:1px solid var(--line)}.inventory-heading-meta{margin-top:4px;color:var(--muted);font-size:10px}.table-tools{display:flex;align-items:center;gap:7px}.table-tools input,.table-tools select{height:31px;border:1px solid #d0d5dd;border-radius:4px;background:#fff;color:#344054;padding:0 9px;font:11px inherit}.table-tools input{width:260px}.table-tools input:focus,.table-tools select:focus{outline:2px solid #cfe0f2;outline-offset:0;border-color:#7fa6cc}.inventory-scroll{overflow:auto;max-height:720px}.inventory-table{width:100%;border-collapse:separate;border-spacing:0}.inventory-table>thead{position:sticky;top:0;z-index:3}.inventory-table th,.inventory-table td{text-align:left;padding:9px 12px;border-bottom:1px solid #eaecf0;font-size:10px;vertical-align:middle}.inventory-table th{background:#f9fafb;color:#667085;font-weight:600;white-space:nowrap}.inventory-table .product-summary:hover{background:#fafbfc}.product-name{font-size:11px;font-weight:600;color:#101828}.product-sub{display:flex;gap:8px;margin-top:3px;color:var(--muted);font-size:9px}.product-sub code{font-family:"SFMono-Regular",Consolas,monospace}.mapping{font-weight:600}.mapping.high{color:var(--green)}.mapping.medium{color:var(--amber)}.product-detail td{padding:0 20px 14px;background:#fafbfc}.sku-table{width:100%;border-collapse:collapse;border:1px solid #eaecf0;background:#fff}.sku-table th,.sku-table td{padding:7px 9px;border-bottom:1px solid #eaecf0;font-size:9px}.sku-table th{position:static;background:#f9fafb}.sku-table tr:last-child td{border-bottom:0}.id-line{display:flex;align-items:center;gap:4px;margin:2px 0}.channels{line-height:1.5}.data-unknown{color:#7b8494}.product-toggle{padding:4px 7px;background:#fff;color:#344054;border-color:#d0d5dd;font-size:9px}.details-grid{display:grid;grid-template-columns:minmax(280px,.65fr) minmax(0,1.35fr);gap:12px}.rule{display:grid;grid-template-columns:120px 1fr;gap:10px;padding:8px 0;border-bottom:1px solid #eaecf0;font-size:10px}.rule:last-child{border-bottom:0}.rule small{color:var(--muted)}.rule strong{font-weight:550}.schedule-table{width:100%;border-collapse:collapse}.schedule-table th,.schedule-table td{padding:8px;border-bottom:1px solid #eaecf0;text-align:left;font-size:10px;vertical-align:top}.schedule-table th{background:#f9fafb;color:var(--muted);font-weight:600}.chart-layout{display:grid;grid-template-columns:minmax(0,1fr) 170px;gap:16px;align-items:center}.chart-wrap{height:220px}.chart-wrap svg{width:100%;height:100%;display:block}.chart-metrics{border-left:1px solid #eaecf0}.chart-metric{display:flex;align-items:center;justify-content:space-between;padding:8px 0 8px 14px;border-bottom:1px solid #eaecf0}.chart-metric:last-child{border-bottom:0}.chart-metric small{color:var(--muted);font-size:9px}.chart-metric strong{font-size:11px}.sonner-region{position:fixed;right:18px;bottom:18px;z-index:1000;width:min(360px,calc(100vw - 24px));display:flex;flex-direction:column-reverse;gap:7px;pointer-events:none}.sonner-toast{pointer-events:auto;display:grid;grid-template-columns:22px minmax(0,1fr) auto;gap:9px;align-items:start;padding:10px;border:1px solid #d0d5dd;border-radius:5px;background:#fff;box-shadow:0 8px 24px #10182818;animation:sonner-in .16s ease-out}.sonner-toast.success{border-left:3px solid var(--green)}.sonner-toast.error{border-left:3px solid var(--red)}.sonner-toast.warning{border-left:3px solid var(--amber)}.sonner-icon{width:20px;height:20px;display:grid;place-items:center;color:#475467;font-size:11px;font-weight:700}.sonner-copy strong{display:block;margin:0 0 2px;font-size:11px}.sonner-copy p{margin:0;color:var(--muted);font-size:9px;line-height:1.4;word-break:break-word}.sonner-close{padding:0;border:0;background:transparent;color:#98a2b3;font-size:14px}.sonner-close:hover{background:transparent}.sonner-toast.leaving{animation:sonner-out .14s ease-in forwards}@keyframes sonner-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}@keyframes sonner-out{to{opacity:0;transform:translateY(6px)}}
-@media(max-width:1100px){.metrics{grid-template-columns:repeat(3,1fr)}.metric:nth-child(3){border-right:0}.metric:nth-child(n+4){border-top:1px solid var(--line)}.priority-grid,.details-grid{grid-template-columns:1fr}.readiness-panel #readiness{grid-template-columns:repeat(3,1fr)}}
-@media(max-width:760px){header{height:auto;padding:12px;align-items:flex-start;gap:10px}.header-actions{flex-wrap:wrap;justify-content:flex-end}main{padding:12px}.hero{grid-template-columns:1fr}.hero-alert{min-width:0}.metrics{grid-template-columns:1fr 1fr}.metric{border-top:1px solid var(--line)}.priority-grid{grid-template-columns:1fr}.readiness-panel #readiness{grid-template-columns:1fr 1fr}.table-tools{width:100%;flex-wrap:wrap}.table-tools input{width:100%}.inventory-panel .section-title{align-items:flex-start;flex-direction:column}.chart-layout{grid-template-columns:1fr}.chart-metrics{border-left:0}.header-meta{flex-wrap:wrap}}
-.metrics{grid-template-columns:repeat(6,minmax(0,1fr))}.channel-low{padding:1px 4px;border-radius:3px;background:#fff7e9;color:var(--amber)}.legacy-low-badge{display:inline-flex;margin-top:4px;padding:2px 5px;border-radius:3px;background:#fff7e9;color:var(--amber);font-size:9px;font-weight:650}
-`;
+export const DASHBOARD_CLIENT_HTML = String.raw`
+
+
+
+
+
+
+ BPA 库存运营面板
+
+
+
+ 跳到主要内容
+
+ 库存运营面板正在连接
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
-const DASHBOARD_CLIENT_JS_BASE = `
-let csrf='';
-let connectionFailed=false;
-let reconnectTimer;
-let currentOverview=null;
-let selectedShopId=localStorage.getItem('bpa-selected-shop-id')||'';
-const q=selector=>document.querySelector(selector);
-const qa=selector=>Array.from(document.querySelectorAll(selector));
-const esc=value=>String(value??'').replace(/[&<>"']/g,char=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char]));
-const attr=value=>esc(value).replace(/\\n/g,' ');
-const dt=value=>value?new Date(value).toLocaleString('zh-CN',{hour12:false}):'无数据';
-const pct=value=>value==null?'—':(Number(value)*100).toFixed(1)+'%';
-const horizon=(forecast,hours)=>((forecast?.horizons||[]).find(item=>item.hours===hours)||{});
-const legacyLowChannels=products=>(products||[]).flatMap(product=>(product.skus||[]).flatMap(sku=>(sku.channels||[]).filter(channel=>Number(channel.stock)<200)));
-const productHasLegacyLowChannel=product=>legacyLowChannels([product]).length>0;
-const recoveryStateName=value=>({running:'巡检运行中',succeeded:'巡检正常',degraded:'巡检部分失败',auth_required:'抖店登录失效',interrupted:'巡检已中断'}[value]||'等待首轮巡检');
-const recoveryReadinessHtml=recovery=>'自动巡检'+esc(recoveryStateName(recovery?.state))+''+(recovery?.shopName?''+esc(recovery.shopName)+'':'')+'
';
-const recoveryReminderHtml=recovery=>recovery?.state==='auth_required'?'抖店登录会话已失效
自动巡检已安全停止,未继续切店或写入数据。
重新登录专用 BPA 浏览器后恢复 ':'';
-const channelAttentionSignals=products=>{const grouped=new Map();for(const product of products||[]){for(const sku of product.skus||[]){const dailyP90=Number(sku.forecast?.daily_p90||0);for(const channel of sku.channels||[]){const key=[product.shop_id,product.product_id,channel.channelGoodsId].join(':');const current=grouped.get(key)||{shopId:product.shop_id,shopName:product.shop_name,productId:product.product_id,productTitle:product.product_title,channelGoodsId:channel.channelGoodsId,stock:Number(channel.stock),dailyP90:0};current.stock=Math.min(current.stock,Number(channel.stock));current.dailyP90+=dailyP90;grouped.set(key,current);}}}return [...grouped.values()].filter(item=>item.stock<50&&item.dailyP90>=50).sort((left,right)=>left.stock-right.stock||right.dailyP90-left.dailyP90);};
-const channelAttentionHtml=products=>{const signals=channelAttentionSignals(products);if(!signals.length)return '';return '渠道冷启动关注:库存低于 50 且关联 SKU 日 P90 合计不低于 50。该层用于历史不足 3 天时先发现问题,运营确认后再升级;不写入正式预测事件。
| 等级 | 店铺 | 商品 | 渠道品 ID | 渠道库存 | SKU 日 P90 合计 |
'+signals.slice(0,30).map(item=>'| 冷启动关注 | '+esc(item.shopName||item.shopId)+' | '+esc(productTitle(item.productTitle,item.productId))+' '+esc(item.productId)+''+copyButton(item.productId)+'
| '+esc(item.channelGoodsId)+''+copyButton(item.channelGoodsId)+' | '+esc(item.stock)+' | '+esc(item.dailyP90.toFixed(1))+' |
').join('')+'
';};
-const severityName=value=>({critical:'严重',warning:'预警',unknown:'待确认',normal:'正常'}[value]||value);
-const kindName=value=>({sku:'SKU',channel:'渠道',reserve:'未占用库存',data_quality:'数据质量'}[value]||value||'风险');
-const RISK_REASON_NAMES={
- 'Inventory data is stale or incomplete; deterministic risk was suppressed.':'库存快照已过期或数据不完整,系统已停止确定性风险判断。',
- 'Recent orders exceed 120 minutes or the latest complete historical order day exceeds 36 hours; deterministic risk was suppressed.':'订单数据源已过期:近期订单超过 2 小时,或历史完整日超过 36 小时。确定性库存风险判断已暂停。',
- 'Channel history has not reached the cold-start coverage gate.':'渠道库存历史尚未达到冷启动门槛(至少 3 天且有效快照覆盖率不低于 80%)。',
- 'Channel consumption estimate is unavailable.':'渠道消耗估算暂不可用,当前仅展示 SKU 级判断。',
- 'Channel mapping exists but no reliable consumption share is available.':'渠道映射已识别,但暂时无法得到可靠的渠道消耗份额。',
- 'SKU forecast is missing.':'该 SKU 暂无可用销量预测。',
- 'SKU stock covers the six-hour P90 demand.':'SKU 库存可覆盖未来 6 小时 P90 需求。',
- 'Channel stock covers the six-hour allocated P90 demand.':'渠道库存可覆盖未来 6 小时分配后的 P90 需求。',
- 'Unoccupied reserve can cover all channel top-up deficits for 24 hours.':'未占用库存可覆盖全部渠道未来 24 小时补足缺口。'
-};
-const riskReason=value=>{
- const text=String(value||'');
- if(RISK_REASON_NAMES[text])return RISK_REASON_NAMES[text];
- let match=text.match(/^SKU stock does not cover the (2|6)-hour P90 demand\.$/);
- if(match)return 'SKU 库存无法覆盖未来 '+match[1]+' 小时 P90 需求。';
- match=text.match(/^Channel stock does not cover the (2|6)-hour allocated P90 demand\.$/);
- if(match)return '渠道库存无法覆盖未来 '+match[1]+' 小时分配后的 P90 需求。';
- match=text.match(/^Unoccupied reserve cannot cover all channel top-up deficits for (6|24) hours\.$/);
- if(match)return '未占用库存无法覆盖全部渠道未来 '+match[1]+' 小时补足缺口。';
- if(/[A-Za-z]{4}/.test(text))return '风险证据待确认(原始诊断已隐藏)。';
- return text||'等待风险证据';
-};
-const productTitle=(value,productId)=>{const cleaned=String(value||'')
- .replace(/现货模式预览复制链接$/,'')
- .replace(/^0暂无评价设置优惠提升购买转化设置优惠券提升销量/,'')
- .trim();return cleaned&&!(cleaned.includes('售卖中')&&cleaned.length<=24)
- ?cleaned
- :'商品 '+String(productId||'');};
-const GOODS_URL='https://fxg.jinritemai.com/ffa/g/list';
-
-class ApiError extends Error{constructor(message,status,code){super(message);this.name='ApiError';this.status=status;this.code=code;}}
-const sonner=(()=>{const entries=new Map();let region;const ensure=()=>{if(region)return region;region=document.createElement('div');region.className='sonner-region';region.setAttribute('role','region');region.setAttribute('aria-live','polite');document.body.appendChild(region);return region;};const dismiss=id=>{const entry=entries.get(id);if(!entry)return;clearTimeout(entry.timer);entry.element.classList.add('leaving');entries.delete(id);setTimeout(()=>entry.element.remove(),160);};const show=options=>{const id=options.id||('notice-'+Date.now()+'-'+Math.random().toString(16).slice(2));dismiss(id);const element=document.createElement('div');element.className='sonner-toast '+(options.type||'info');const icon=document.createElement('span');icon.className='sonner-icon';icon.textContent=({success:'✓',error:'!',warning:'△',loading:'…'}[options.type]||'i');const copy=document.createElement('div');copy.className='sonner-copy';const title=document.createElement('strong');title.textContent=options.title;copy.appendChild(title);if(options.description){const description=document.createElement('p');description.textContent=options.description;copy.appendChild(description);}const close=document.createElement('button');close.className='sonner-close';close.type='button';close.textContent='×';close.addEventListener('click',()=>dismiss(id));element.append(icon,copy,close);ensure().appendChild(element);const duration=options.duration===0?0:(options.duration||4000);const timer=duration?setTimeout(()=>dismiss(id),duration):undefined;entries.set(id,{element,timer});return id;};return{show,dismiss};})();
-window.sonner=sonner;
+export const DASHBOARD_TECHNICAL_HTML = String.raw`
+BPA 库存技术监控
+跳到主要内容`;
-async function api(path,options){let response;try{response=await fetch(path,options);}catch{throw new ApiError('连接暂时中断',0,'NETWORK_ERROR');}const contentType=response.headers.get('content-type')||'';let payload;try{payload=contentType.includes('application/json')?await response.json():await response.text();}catch{payload=undefined;}if(!response.ok){const code=payload&&typeof payload==='object'?payload.error:undefined;if(response.status===401)throw new ApiError('访问会话已过期',401,code||'SESSION_REQUIRED');if(response.status===403)throw new ApiError(code==='CSRF_INVALID'?'页面安全令牌已失效':'启动链接无效或已经使用',403,code||'FORBIDDEN');throw new ApiError('服务暂时无法完成请求',response.status,code||('HTTP_'+response.status));}return payload;}
-function scheduleReconnect(){if(reconnectTimer)return;reconnectTimer=setTimeout(()=>{reconnectTimer=undefined;load().catch(error=>reportError(error,'自动重连'));},5000);}
-function reportError(error,context){const message=error instanceof Error?error.message:String(error);const code=error&&typeof error==='object'&&'code' in error?error.code:'UNEXPECTED_ERROR';const network=code==='NETWORK_ERROR';const recovery=code==='SESSION_REQUIRED'?'请重新打开服务生成的一次性访问地址。':network?'系统将在 5 秒后自动重试,SSH 隧道由后台守护。':'';connectionFailed=true;q('#status').className='status-chip '+(network?'warning':'critical');q('#status').textContent=network?'连接中断 · 自动重试中':message;sonner.dismiss('manual-refresh');sonner.show({id:'global-error',type:network?'warning':'error',title:network?'库存服务连接中断':message,description:[context,code,recovery].filter(Boolean).join(' · '),duration:0});if(network)scheduleReconnect();}
+export const DASHBOARD_CLIENT_CSS = String.raw`:root{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text","PingFang SC","Microsoft YaHei",sans-serif;color:#1d2925;background:#f4f8f6;font-synthesis:none;--surface:#fff;--surface-soft:#f7faf8;--ink:#1d2925;--muted:#687670;--line:#dce5e1;--green:#1b6f58;--green-soft:#dcefe8;--red:#aa312d;--red-soft:#f9dedc;--amber:#876400;--amber-soft:#fff0c2;--grey:#e9efec;--shadow:0 10px 28px rgb(21 42 35 / 7%)}*{box-sizing:border-box}body{margin:0;min-width:320px;background:#f4f8f6;color:var(--ink);font-variant-numeric:tabular-nums}button,input,select,textarea{font:inherit}button,a,select,input,textarea{outline-offset:3px;touch-action:manipulation;-webkit-tap-highlight-color:rgb(27 111 88 / 16%)}.skip-link{position:fixed;top:8px;left:8px;z-index:100;transform:translateY(-160%);padding:9px 12px;border-radius:8px;background:var(--green);color:white}.skip-link:focus{transform:translateY(0)}button:focus-visible,a:focus-visible,select:focus-visible,input:focus-visible,textarea:focus-visible{outline:3px solid #6ea995}.appbar{height:64px;position:sticky;top:0;z-index:20;display:flex;align-items:center;justify-content:space-between;padding:0 20px;background:rgb(255 255 255 / 94%);border-bottom:1px solid var(--line);backdrop-filter:blur(12px)}.brand,.actions{display:flex;align-items:center;gap:12px}.brand strong{font-size:20px}.pill,.badge,.count{display:inline-flex;align-items:center;border-radius:999px;padding:4px 9px;font-size:12px;font-weight:650}.pill.neutral,.badge.neutral{background:var(--grey);color:#4f5f59}.pill.normal,.badge.normal{background:var(--green-soft);color:var(--green)}.pill.warning,.badge.warning{background:var(--amber-soft);color:var(--amber)}.pill.critical,.badge.critical{background:var(--red-soft);color:var(--red)}.pill.unknown,.badge.unknown{background:var(--grey);color:#52605b}.actions button,.text-link{min-height:40px}.text-button,.icon-button,.primary-button,.text-link,.copy-button,.page-button{border:0;border-radius:999px;padding:9px 14px;cursor:pointer;text-decoration:none}.text-button,.icon-button,.text-link,.copy-button,.page-button{background:transparent;color:var(--green)}.text-button:hover,.icon-button:hover,.text-link:hover,.copy-button:hover,.page-button:hover{background:var(--green-soft)}.icon-button{font-size:20px;min-width:40px}.primary-button{background:var(--green);color:white}.primary-button:disabled{opacity:.5;cursor:wait}.shell{display:grid;grid-template-columns:280px minmax(0,1fr);gap:16px;max-width:1600px;margin:0 auto;padding:16px}.sidebar{position:sticky;top:80px;align-self:start;display:flex;flex-direction:column;gap:10px;max-height:calc(100vh - 96px);overflow:auto}.search-label,.section-label{font-size:13px;color:var(--muted);font-weight:650;margin:0}.sidebar input,.product-head select,.mobile-shopbar select,.review-dialog textarea{width:100%;border:1px solid #b8c6c0;border-radius:10px;background:white;color:var(--ink);padding:11px 12px}.shop-list{display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--line);border-radius:14px;overflow:hidden}.shop-button{border:0;border-bottom:1px solid var(--line);background:white;text-align:left;padding:12px;cursor:pointer;color:var(--ink)}.shop-button:last-child{border-bottom:0}.shop-button:hover,.shop-button.active{background:var(--green-soft)}.shop-button strong,.shop-button span{display:block}.shop-button span{margin-top:4px;color:var(--muted);font-size:12px}.main{min-width:0;display:flex;flex-direction:column;gap:18px}.mobile-shopbar{display:none}.hero,.section{background:var(--surface);border:1px solid var(--line);border-radius:16px;box-shadow:var(--shadow)}.hero{padding:18px 20px}.hero.critical{background:var(--red-soft);border-color:#efb9b5}.hero.warning{background:var(--amber-soft);border-color:#e9d38f}.hero.normal{background:var(--green-soft);border-color:#b8ded0}.hero h1{font-size:20px;margin:0 0 4px}.hero p{margin:0;color:inherit;opacity:.8}.stats{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:10px}.stat{background:var(--surface);border:1px solid var(--line);border-radius:14px;padding:14px;min-width:0}.stat span,.stat small{display:block;color:var(--muted);font-size:12px}.stat strong{display:block;margin:8px 0 5px;font-size:25px;line-height:1.05}.section{padding:18px}.section-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}.section h1,.section h2{margin:0;font-size:17px;text-wrap:balance}.count{background:var(--grey);color:var(--muted)}.muted{color:var(--muted);font-size:13px;margin:4px 0 0}.incident-list,.reminder-list{display:flex;flex-direction:column;gap:10px}.incident-card,.reminder-card{border:1px solid var(--line);border-left-width:4px;border-radius:12px;padding:13px;background:var(--surface-soft)}.incident-card.critical,.reminder-card.critical{border-left-color:var(--red)}.incident-card.warning,.reminder-card.warning{border-left-color:#c89b17}.incident-title{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.incident-title h3,.reminder-card h3{font-size:15px;margin:0}.incident-card p,.reminder-card p{margin:7px 0;color:#43514c;font-size:13px;line-height:1.55}.identifier-list{display:flex;flex-wrap:wrap;gap:7px;margin-top:9px}.copy-button{background:var(--grey);color:#3e4c47;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;padding:6px 9px}.incident-actions{display:flex;justify-content:flex-end}.empty{padding:24px;text-align:center;color:var(--muted);background:var(--surface-soft);border-radius:12px}.product-head{align-items:flex-start}.product-head select{width:auto;min-width:150px}.table-wrap{overflow:auto;border:1px solid var(--line);border-radius:12px}.inventory-table{border-collapse:collapse;width:100%;min-width:900px}.inventory-table th,.inventory-table td{padding:11px 12px;text-align:left;border-bottom:1px solid var(--line);vertical-align:middle}.inventory-table th{position:sticky;top:0;background:#eef4f1;color:#52615b;font-size:12px}.inventory-table td{font-size:13px}.inventory-table tr:last-child td{border-bottom:0}.inventory-table tbody tr:hover{background:#f5faf7}.product-title{font-weight:650}.pager{display:flex;justify-content:center;align-items:center;gap:8px;margin-top:12px;color:var(--muted);font-size:13px}.page-button{border:1px solid var(--line);background:white}.page-button:disabled{opacity:.4;cursor:not-allowed}.cycle-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.cycle-cell{padding:12px;background:var(--surface-soft);border-radius:10px}.cycle-cell span{display:block;color:var(--muted);font-size:12px}.cycle-cell strong{display:block;margin-top:5px}.drawer{overscroll-behavior:contain;position:fixed;inset:0 0 0 auto;width:min(520px,100%);max-height:none;height:100%;margin:0;border:0;border-left:1px solid var(--line);padding:0;background:var(--surface);color:var(--ink);overflow:auto}.drawer::backdrop,.review-dialog::backdrop{background:rgb(0 0 0 / 38%)}.drawer-body{padding:20px}.dialog-head{position:sticky;top:0;z-index:2;display:flex;justify-content:space-between;align-items:flex-start;gap:12px;background:var(--surface);padding-bottom:12px;border-bottom:1px solid var(--line)}.dialog-head h2{margin:0;font-size:19px}.detail-summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:14px 0}.detail-summary div,.sku-card{background:var(--surface-soft);border:1px solid var(--line);border-radius:12px;padding:12px}.detail-summary span{display:block;color:var(--muted);font-size:12px}.detail-summary strong{display:block;margin-top:5px}.sku-list{display:flex;flex-direction:column;gap:10px}.sku-card h3{margin:0 0 7px;font-size:14px}.sku-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px}.sku-grid div{font-size:13px}.sku-grid span{display:block;color:var(--muted);font-size:11px}.forecast-box{margin-top:10px;padding-top:10px;border-top:1px solid var(--line)}.forecast-box p{margin:5px 0;font-size:13px}.review-dialog{overscroll-behavior:contain;width:min(520px,calc(100% - 24px));border:0;border-radius:16px;padding:20px;color:var(--ink)}.review-dialog form{display:flex;flex-direction:column;gap:14px}.review-dialog fieldset{display:flex;flex-direction:column;gap:10px;border:1px solid var(--line);border-radius:12px;padding:12px}.review-dialog fieldset label{display:flex;gap:8px}.dialog-actions{display:flex;justify-content:flex-end;gap:8px}.toast{position:fixed;left:50%;bottom:22px;z-index:80;transform:translateX(-50%);max-width:min(520px,calc(100% - 24px));padding:10px 16px;border-radius:999px;background:#24342e;color:white;box-shadow:var(--shadow)}.technical-main{max-width:1200px;margin:0 auto;padding:20px;display:flex;flex-direction:column;gap:18px}.technical-json{overflow:auto;background:#1d2925;color:#dff2ea;padding:14px;border-radius:12px;white-space:pre-wrap}.chart{width:100%;height:auto;overflow:visible}.chart line{stroke:#dce5e1}.chart .actual{stroke:#23342e}.chart .p50{stroke:#3c7aa3}.chart .p90{stroke:#b37b14}.chart text{fill:#687670;font-size:11px}@media(max-width:1100px){.stats{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(max-width:839px){.appbar{padding:0 12px}.brand strong{font-size:17px}.actions .text-button{display:none}.shell{display:block;padding:10px}.sidebar{position:static;max-height:none;overflow:visible}.sidebar .search-label,.sidebar #search,.sidebar .section-label,.sidebar .shop-list{display:none}.mobile-shopbar{display:flex;align-items:center;gap:10px;margin-bottom:10px}.mobile-shopbar label{white-space:nowrap;color:var(--muted);font-size:13px}.main{gap:12px}.stats{grid-template-columns:repeat(2,minmax(0,1fr))}.section,.hero{border-radius:12px;padding:14px}.cycle-grid{grid-template-columns:1fr}.drawer{width:100%}}@media(max-width:520px){.appbar{height:auto;min-height:58px;align-items:flex-start;padding-block:10px}.brand{align-items:flex-start;flex-direction:column;gap:5px}.actions{gap:2px}.actions .text-link{font-size:12px;padding:8px}.stats{grid-template-columns:1fr 1fr}.stat strong{font-size:21px}.section-head.product-head{align-items:stretch;flex-direction:column}.product-head select{width:100%}.detail-summary,.sku-grid{grid-template-columns:1fr 1fr}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{scroll-behavior:auto!important;animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}}`;
-function copyButton(value,label='复制'){return '';}
-function dataQualityFinding(incident){const findings=Array.isArray(incident.findings)?incident.findings:[];return findings.find(item=>item.kind==='data_quality');}
-function dataQualityGroups(items){const groups=new Map();items.forEach(incident=>{const finding=dataQualityFinding(incident);if(!finding)return;const reason=riskReason(finding.reason),current=groups.get(reason)||{reason,count:0,lastSeen:null};current.count+=1;if(!current.lastSeen||String(incident.last_seen_at)>String(current.lastSeen))current.lastSeen=incident.last_seen_at;groups.set(reason,current);});return [...groups.values()];}
-function incidentTable(items,products,shopId){if(!items.length)return '当前无开放风险风险事件将在此处按严重程度排序。
';const groups=dataQualityGroups(items),riskItems=items.filter(incident=>!dataQualityFinding(incident)),summary=groups.map(group=>'订单数据质量阻断'+esc(group.reason)+' 最近检查 '+esc(dt(group.lastSeen))+'
影响 '+esc(group.count)+' 个商品 ').join('');if(!riskItems.length)return summary;return summary+'| 等级 | 商品 | 影响范围 | 判断依据 | 更新时间 | |
'+riskItems.map(incident=>{const findings=Array.isArray(incident.findings)?incident.findings:[];const finding=findings.find(item=>item.severity===incident.severity&&item.kind!=='data_quality')||findings.find(item=>item.severity===incident.severity)||findings[0]||{};const scope=finding.scope||{};const productId=scope.productId||incident.product_id;const product=(products||[]).find(item=>String(item.product_id)===String(productId));const title=productTitle(incident.product_title||product?.product_title,productId);const ids=[['商品',productId],['SKU',scope.platformSkuId],['编码',scope.merchantCode],['渠道',scope.channelGoodsId]].filter(item=>item[1]!=null&&String(item[1]).trim());return '| '+esc(severityName(incident.severity))+' | '+esc(title)+' '+esc(productId)+''+copyButton(productId)+'
| '+ids.map(item=>' '+esc(item[0])+' '+esc(item[1])+''+copyButton(item[1])+' ').join('')+' | '+esc(kindName(finding.kind))+' · '+esc(riskReason(finding.reason))+' | '+esc(dt(incident.last_seen_at))+' | |
';}).join('')+'
';}
-function remindersHtml(items){q('#reminderCount').textContent=String(items.length);return items.length?items.map(item=>''+esc(item.title)+'
'+esc(item.detail)+'
'+esc(item.action)+' ').join(''):'无待处理事项数据质量与运行状态均未触发提醒。
';}
-function readinessHtml(data){const freshness=data.freshness||{},cold=data.coldStart||{},feishu=data.notifications?.feishu||{},all=data.selectedShop?.id==='all',total=Number(cold.totalOrderSkus||0),width=value=>total?Math.max(0,Number(value||0)/total*100):0;return ''+(all?'全店最旧库存快照':'库存快照')+''+dt(freshness.latestInventoryAt)+'
'+(all?'全店最旧近期订单':'近期订单')+''+dt(freshness.latestOrderAt)+'
历史完整日'+dt(freshness.historicalCompleteThrough)+'
新鲜商品 / 全量'+esc(data.counts.freshProducts||0)+' / '+esc(data.counts.products)+'
数据商品 / SKU'+esc(data.counts.products)+' / '+esc(data.counts.skus)+'
飞书日报'+(feishu.lastSentAt?dt(feishu.lastSentAt):'待首次回报')+'
预测冷启动分层
直接建模 '+esc(cold.directModel||0)+'分层回退 '+esc(cold.hierarchicalFallback||0)+'店铺基线 '+esc(cold.storeBaseline||0)+'已映射 '+esc(cold.inventoryMappedSkus||0)+'
';}
-function chart(backtest){if(!backtest||backtest.status!=='ready'||!backtest.points?.length)return '回测数据不足积累至少 35 个完整自然日后自动生成。
';const points=backtest.points,w=920,h=210,pad={l:42,r:12,t:12,b:26},max=Math.max(...points.flatMap(point=>[point.actual,point.p90]),1)*1.08,xy=(index,value)=>[pad.l+index*(w-pad.l-pad.r)/Math.max(1,points.length-1),pad.t+(max-value)*(h-pad.t-pad.b)/max],path=key=>points.map((point,index)=>{const position=xy(index,Number(point[key]));return(index?'L':'M')+position[0].toFixed(1)+','+position[1].toFixed(1);}).join(' '),area=points.map((point,index)=>xy(index,Number(point.p90)).join(',')).join(' ')+' '+[...points].reverse().map((point,index)=>xy(points.length-1-index,Number(point.p50)).join(',')).join(' '),ticks=[0,.5,1].map(value=>{const y=pad.t+(1-value)*(h-pad.t-pad.b);return ''+Math.round(max*value)+'';}).join(''),labels=[0,Math.floor((points.length-1)/2),points.length-1].map(index=>{const position=xy(index,0);return ''+esc(points[index].date.slice(5))+'';}).join('');return 'P90 覆盖率'+pct(backtest.metrics.p90Coverage)+'
P50 Pinball'+esc(backtest.metrics.p50PinballLoss)+'
P90 Pinball'+esc(backtest.metrics.p90PinballLoss)+'
WAPE'+pct(backtest.metrics.wape)+'
';}
-function skuTable(product){return '| 商家编码 / SKU ID | 当前 | 占用 | 未占用 | 渠道品 ID / 库存 | 日 P50 / P90 | 2h / 6h / 24h P90 | 模型 |
'+(product.skus||[]).map(sku=>{const forecast=sku.forecast,h2=horizon(forecast,2),h6=horizon(forecast,6),h24=horizon(forecast,24),channels=(sku.channels||[]).map(channel=>{const legacyLow=Number(channel.stock)<200;return ''+esc(channel.channelGoodsId)+''+copyButton(channel.channelGoodsId)+''+esc(channel.stock)+''+(legacyLow?'旧 <200 对照':'')+'
';}).join('')||'无';return '| '+esc(sku.merchant_code)+' '+esc(sku.platform_sku_id)+''+copyButton(sku.platform_sku_id)+'
| '+esc(sku.current_stock)+' | '+esc(sku.occupied_stock)+' | '+esc(sku.unoccupied_stock)+' | '+channels+' | '+(forecast?esc(forecast.daily_p50)+' / '+esc(forecast.daily_p90):'待建模')+' | '+(forecast?esc(h2.p90)+' / '+esc(h6.p90)+' / '+esc(h24.p90):'—')+' | '+(forecast?esc(forecast.selected_model)+' '+esc(forecast.confidence)+'':'—')+' |
';}).join('')+'
';}
-function inventoryHtml(products,incidents){if(!products.length)return '暂无库存快照完成采集后将在此处显示。
';const riskByProduct=new Map();(incidents||[]).filter(item=>item.state==='open').forEach(item=>riskByProduct.set(String(item.product_id),item.severity));return '';}
-function incidentTableAllStores(items,products){const actionable=items.filter(item=>item.severity==='critical'||item.severity==='warning'),attention=channelAttentionSignals(products);q('#incidentCount').textContent=String(actionable.length+attention.length);const unknown=items.filter(item=>item.severity==='unknown').length,legacyLow=legacyLowChannels(products).length,note='正式事件 '+esc(actionable.length)+' 个;渠道冷启动关注 '+esc(attention.length)+' 个;另有 '+esc(unknown)+' 个数据待确认项,以及 '+esc(legacyLow)+' 个渠道品命中旧 <200 对照。
',attentionHtml=channelAttentionHtml(products);if(!actionable.length)return note+attentionHtml+(attention.length?'':'当前无正式严重、预警或冷启动关注商品系统仍按 30 分钟周期持续评估。
');return note+'| 等级 | 店铺 | 商品 | 判断依据 | 更新时间 | |
'+actionable.map(incident=>{const findings=Array.isArray(incident.findings)?incident.findings:[],finding=findings.find(item=>item.severity===incident.severity&&item.kind!=='data_quality')||findings[0]||{},scope=finding.scope||{},productId=scope.productId||incident.product_id,product=(products||[]).find(item=>String(item.shop_id)===String(incident.shop_id)&&String(item.product_id)===String(productId)),title=productTitle(incident.product_title||product?.product_title,productId);return '| '+esc(severityName(incident.severity))+' | '+esc(incident.shop_name||incident.shop_id)+' | '+esc(title)+' '+esc(productId)+''+copyButton(productId)+'
| '+esc(kindName(finding.kind))+' · '+esc(riskReason(finding.reason))+' | '+esc(dt(incident.last_seen_at))+' | |
';}).join('')+'
'+attentionHtml;}
-function remindersAllStores(items){const statuses=Array.isArray(currentOverview?.shopStatuses)?[...currentOverview.shopStatuses]:[];statuses.sort((left,right)=>(right.operationalCritical+right.critical*2+right.operationalWarning+right.warning)-(left.operationalCritical+left.critical*2+left.operationalWarning+left.warning));const actionable=items.filter(item=>item.severity==='critical'||item.severity==='warning');q('#reminderCount').textContent=String(actionable.length);const statusHtml=''+statuses.map(shop=>{const level=shop.critical||shop.operationalCritical?'critical':shop.warning||shop.operationalWarning?'warning':shop.unknown?'unknown':'normal';return '
'+esc(shop.name)+'库存 '+dt(shop.latestInventoryAt)+' · 订单 '+dt(shop.latestOrderAt)+'
'+esc(severityName(level))+''+esc(shop.critical)+' / '+esc(shop.warning)+'
';}).join('')+'
';const reminderHtml=actionable.slice(0,8).map(item=>''+esc(item.title)+' · '+esc(item.shop_name||'全店')+'
'+esc(item.detail)+'
'+esc(item.action)+' ').join('');return statusHtml+(reminderHtml?'运行提醒
'+reminderHtml:'');}
-function remindersAllStoresV2(items){const now=Date.now(),validityMs=2*60*60*1000,statuses=Array.isArray(currentOverview?.shopStatuses)?[...currentOverview.shopStatuses]:[],dataState=shop=>{const at=Date.parse(shop.latestInventoryAt||'');if(!Number.isFinite(at)||Number(shop.products||0)===0)return 'missing';return now-at<=validityMs?'valid':'stale';},statusById=new Map(statuses.map(shop=>[String(shop.id),shop]));const actionable=items.filter(item=>(item.severity==='critical'||item.severity==='warning')&&(!item.shop_id||dataState(statusById.get(String(item.shop_id))||{})==='valid')&&!String(item.id||'').includes('backtest-p90-coverage'));q('#reminderCount').textContent=String(actionable.length);statuses.sort((left,right)=>({valid:0,stale:1,missing:2})[dataState(left)]-({valid:0,stale:1,missing:2})[dataState(right)]||(right.critical*2+right.warning)-(left.critical*2+left.warning)||String(left.name).localeCompare(String(right.name),'zh-CN'));const statusHtml=''+statuses.map(shop=>{const state=dataState(shop),valid=state==='valid',missing=state==='missing',level=missing||state==='stale'?'unknown':shop.critical?'critical':shop.warning?'warning':shop.unknown?'unknown':'normal',label=missing?'无数据':state==='stale'?'已入库待刷新':shop.critical?'严重':shop.warning?'预警':shop.unknown?'数据不足':'数据有效',inventory=shop.latestInventoryAt?dt(shop.latestInventoryAt):'无数据',orders=shop.latestOrderAt?dt(shop.latestOrderAt):'无数据';return '
'+esc(shop.name)+'库存 '+esc(inventory)+' · 订单 '+esc(orders)+'
'+esc(label)+''+(valid?''+esc(shop.critical)+' / '+esc(shop.warning)+'':'')+'
';}).join('')+'
';const reminderHtml=actionable.slice(0,8).map(item=>''+esc(item.title)+' · '+esc(item.shop_name||'全店')+'
'+esc(item.detail)+'
'+esc(item.action)+' ').join('');return statusHtml+(reminderHtml?'运行与控制提醒
'+reminderHtml:'');}
-function inventoryAllStores(products,incidents){if(!products.length)return '暂无库存快照完成采集后将在此处显示。
';const riskByProduct=new Map(),validityMs=2*60*60*1000;(incidents||[]).filter(item=>item.state==='open').forEach(item=>riskByProduct.set(String(item.shop_id)+':'+String(item.product_id),item.severity));return '';}
-function rulesHtml(rules){const labels={skuChannelCritical:'SKU / 渠道严重',skuChannelWarning:'SKU / 渠道预警',reserveCritical:'未占用库存严重',reserveWarning:'未占用库存预警',legacyComparison:'旧规则对照'};return '策略版本'+esc(rules.policyVersion||'—')+'
'+Object.keys(labels).map(key=>''+esc(labels[key])+''+esc(rules[key]||'—')+'
').join('');}
-function productionCycleHtml(cycle){if(!cycle||cycle.state==='unavailable')return 'BPA Core 暂不可读库存事实仍可查看,但不据此判断正式周期正常。
待恢复 ';if(cycle.state==='not-run')return '尚无正式库存周期当首个 BPA Trigger 周期开始后,这里显示全局 13 店结果。
';if(cycle.state==='in-progress')return '正式库存周期执行中计划时间 '+esc(dt(cycle.scheduledAt))+';未终态前不回显上一轮健康结论。
执行中 ';const labels={complete:'完整',degraded:'降级完成',partial:'部分完成',failed:'失败',rejected:'安全拒绝',cancelled:'已取消','not-produced':'未形成汇总'},label=labels[cycle.state]||'待确认',coverage=cycle.coverage,inventory=cycle.inventory,risk=cycle.risk;if(!coverage||!inventory||!risk)return ''+esc(label)+'计划时间 '+esc(dt(cycle.scheduledAt))+';本轮没有形成可信的 13 店聚合,不按 0/13 展示。
需处理 ';const level=cycle.state==='complete'?'normal':cycle.state==='degraded'?'warning':'critical';return ''+esc(label)+' · '+esc(coverage.succeededShops)+' / '+esc(coverage.expectedShops)+' 店计划 '+esc(dt(cycle.scheduledAt))+' · 观测 '+esc(dt(cycle.observedAt))+'
'+esc(label)+'库存写入 '+esc(inventory.persistedProducts)+' / '+esc(inventory.attemptedProducts)+'失败 '+esc(inventory.failedProducts)+' · 未解析店铺 '+esc(coverage.unresolvedShops)+'
权威回执风险覆盖 '+esc(risk.succeededProducts)+' / '+esc(risk.attemptedProducts)+'降级 '+esc(risk.degradedProducts)+' · 严重 '+esc(risk.criticalProducts)+' · 待确认 '+esc(risk.unknownProducts)+'
'+(cycle.attentionRequired?'需关注':'无阻断')+' ';}
-function applyInventoryFilter(){const term=(q('#inventorySearch')?.value||'').trim().toLowerCase(),filter=q('#inventoryFilter')?.value||'all';qa('[data-inventory-row]').forEach(row=>{const matchesTerm=!term||(row.dataset.search||'').includes(term),matchesFilter=filter==='all'||(filter==='risk'?row.dataset.risk!=='normal':row.dataset.risk===filter),visible=matchesTerm&&matchesFilter;row.hidden=!visible;const detail=q('[data-detail-product="'+CSS.escape(row.dataset.productId||'')+'"]');if(!visible&&detail)detail.hidden=true;});const visible=qa('[data-inventory-row]').filter(row=>!row.hidden).length;q('#inventoryResultCount').textContent='显示 '+visible+' / '+qa('[data-inventory-row]').length+' 个商品';}
-function prepareLayout(){const brand=q('.brand>div');if(brand&&!q('#shopMeta'))brand.insertAdjacentHTML('beforeend','');const inventoryHeader=q('.inventory-panel .section-title');if(inventoryHeader)inventoryHeader.innerHTML='';}
-async function load(options={}){q('#status').className='status-chip';q('#status').innerHTML='读取中';const query=selectedShopId?'?shopId='+encodeURIComponent(selectedShopId):'';const data=await api('/api/overview'+query);if(reconnectTimer){clearTimeout(reconnectTimer);reconnectTimer=undefined;}currentOverview=data;selectedShopId=data.selectedShop?.id||data.shopId||selectedShopId;if(selectedShopId)localStorage.setItem('bpa-selected-shop-id',selectedShopId);const shopSelect=q('#shopSelect');if(shopSelect){const shops=Array.isArray(data.shops)?data.shops:[];shopSelect.innerHTML=shops.map(shop=>'').join('');}const freshness=data.freshness||{},reminders=data.reminders||[],incidents=data.incidents||[],open=incidents.filter(item=>item.state==='open'),critical=open.filter(item=>item.severity==='critical').length,warning=open.filter(item=>item.severity==='warning').length,unknown=open.filter(item=>item.severity==='unknown').length,operationalCritical=reminders.filter(item=>item.severity==='critical').length,operationalWarning=reminders.filter(item=>item.severity==='warning').length;q('#status').className='status-chip '+(critical||operationalCritical?'critical':warning||operationalWarning?'warning':'');q('#status').innerHTML=''+(critical||operationalCritical?'存在严重事项':warning||operationalWarning?'存在预警':'运行正常');q('#shopName').textContent=data.selectedShop?.name||'店铺';q('#shopId').textContent=selectedShopId||'—';q('#generatedAt').textContent='更新 '+dt(data.generatedAt);q('#metrics').innerHTML=[['严重风险',critical,'开放事件',critical?'critical':'good'],['预警风险',warning,'开放事件',warning?'warning':'good'],['待确认',unknown,'数据质量事件',unknown?'warning':'good'],['商品 / SKU',data.counts.products+' / '+data.counts.skus,'已入库范围',''],['P90 覆盖率',pct(data.backtest?.metrics?.p90Coverage),'目标 85%–95%',data.backtest?.status==='ready'?'good':'warning']].map(item=>''+esc(item[0])+''+esc(item[1])+''+esc(item[2])+'').join('');q('#incidentCount').textContent=String(open.length);q('#incidents').innerHTML=incidentTable(open,data.products||[],selectedShopId);q('#reminders').innerHTML=remindersHtml(reminders);q('#readiness').innerHTML=readinessHtml(data);q('#backtest').innerHTML=chart(data.backtest);q('#inventoryUpdated').textContent='快照 '+dt(freshness.latestInventoryAt);q('#products').innerHTML=inventoryHtml(data.products||[],open);q('#rules').innerHTML=rulesHtml(data.rules||{});q('#schedules').innerHTML=productionCycleHtml(data.productionCycle);applyInventoryFilter();notifyReminders(reminders);sonner.dismiss('global-error');if(connectionFailed){sonner.show({id:'connection-restored',type:'success',title:'连接已恢复',description:'看板数据已重新同步。'});connectionFailed=false;}if(options.manual)sonner.show({id:'manual-refresh',type:'success',title:'刷新完成',description:'已载入 '+data.counts.products+' 个商品、'+data.counts.skus+' 个 SKU。'});}
-function setNotifyButton(){const button=q('#enableNotify');if(!('Notification' in window)){button.textContent='桌面提醒不可用';button.disabled=true;return;}if(Notification.permission==='granted')button.textContent='桌面提醒已开启';else if(Notification.permission==='denied')button.textContent='桌面提醒已关闭';}
-async function enableNotifications(){if(!('Notification' in window))return;const permission=await Notification.requestPermission();setNotifyButton();sonner.show({type:permission==='granted'?'success':'warning',title:permission==='granted'?'桌面提醒已开启':'桌面提醒未开启'});}
-function notifyReminders(items){if(!('Notification' in window)||Notification.permission!=='granted')return;(items||[]).filter(item=>item.notificationEligible!==false&&(item.severity==='critical'||item.severity==='warning')).forEach(item=>{const key='bpa-reminder:'+item.id;if(localStorage.getItem(key))return;new Notification(item.title,{body:item.detail,tag:item.id});localStorage.setItem(key,new Date().toISOString());});}
-async function copyId(button){const value=button.dataset.copyId||'';try{await navigator.clipboard.writeText(value);}catch{const input=document.createElement('textarea');input.value=value;document.body.appendChild(input);input.select();document.execCommand('copy');input.remove();}sonner.show({type:'success',title:'已复制',description:value,duration:1600});}
-async function heartbeat(){try{const session=await bootstrapSession();csrf=session.csrf;}catch(error){reportError(error,'会话心跳');}}
-async function bootstrapSession(){const token=new URLSearchParams(location.hash.slice(1)).get('token');if(token){sessionStorage.setItem('bpa-inventory-access-token',token);history.replaceState(null,'',location.pathname);}try{return await api('/api/session');}catch(error){if(error?.code!=='SESSION_REQUIRED')throw error;const saved=sessionStorage.getItem('bpa-inventory-access-token');if(!saved)throw new Error('请使用局域网安全入口重新进入');return api('/api/session',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({token:saved})});}}
-async function init(){prepareLayout();const session=await bootstrapSession();csrf=session.csrf;setNotifyButton();await load();setInterval(()=>{if(document.visibilityState==='visible')load().catch(error=>reportError(error,'自动刷新'));},60000);setInterval(()=>void heartbeat(),300000);}
-document.addEventListener('click',event=>{const target=event.target instanceof Element?event.target:null,copy=target?.closest('[data-copy-id]'),review=target?.closest('[data-review]'),toggle=target?.closest('[data-toggle-product]');if(copy){void copyId(copy);return;}if(review){q('#incidentId').value=review.dataset.review;q('#review').showModal();return;}if(toggle){const detail=q('[data-detail-product="'+CSS.escape(toggle.dataset.toggleProduct||'')+'"]');if(detail){detail.hidden=!detail.hidden;toggle.textContent=detail.hidden?'展开 SKU':'收起 SKU';}}});
-document.addEventListener('input',event=>{if(event.target?.id==='inventorySearch')applyInventoryFilter();});
-document.addEventListener('change',event=>{if(event.target?.id==='inventoryFilter')applyInventoryFilter();if(event.target?.id==='shopSelect'){selectedShopId=event.target.value;sonner.show({id:'shop-switch',type:'loading',title:'正在切换店铺',duration:0});load().then(()=>sonner.show({id:'shop-switch',type:'success',title:'店铺数据已更新'})).catch(error=>reportError(error,'切换店铺'));}});
-q('#reload').addEventListener('click',()=>{sonner.show({id:'manual-refresh',type:'loading',title:'正在刷新数据',duration:0});load({manual:true}).catch(error=>reportError(error,'手动刷新'));});
-q('#enableNotify').addEventListener('click',()=>enableNotifications().catch(error=>reportError(error,'桌面提醒')));
-q('#submit').addEventListener('click',async event=>{event.preventDefault();try{await api('/api/reviews',{method:'POST',headers:{'content-type':'application/json','x-csrf-token':csrf},body:JSON.stringify({incidentId:q('#incidentId').value,decision:q('#decision').value,note:q('#note').value})});q('#review').close();sonner.show({type:'success',title:'处理结果已保存'});await load();}catch(error){reportError(error,'保存处理结果');}});
-window.addEventListener('unhandledrejection',event=>reportError(event.reason,'未处理异常'));
-window.addEventListener('error',event=>reportError(event.error||event.message,'页面运行异常'));
-function shopCoverage(){const statuses=Array.isArray(currentOverview?.shopStatuses)?currentOverview.shopStatuses:[],now=Date.now(),available=statuses.filter(shop=>Number(shop.products||0)>0&&shop.latestInventoryAt).length,valid=statuses.filter(shop=>{const at=Date.parse(shop.latestInventoryAt||'');return Number(shop.products||0)>0&&Number.isFinite(at)&&now-at<=2*60*60*1000;}).length;return {available,valid,total:statuses.length,incomplete:statuses.length>0&&valid'+(critical?critical+' 个严重风险需要立即处理':warning?warning+' 个预警风险需要关注':unknown?unknown+' 个数据项待确认':coverage.incomplete?coverage.valid+' / '+coverage.total+' 家店铺数据在 2 小时有效期内':'当前没有开放的确定性库存风险')+''+(critical||warning?'按预计耗尽时间优先处置':unknown?'待确认不等同于缺货风险':coverage.incomplete?'已入库 '+coverage.available+' 家;过期数据保留展示并等待刷新':'库存、订单与模型链路持续运行')+'';}
-function syncCommandCenterStatus(){const metrics=qa('#metrics .metric strong'),critical=Number(metrics[0]?.textContent||0),warning=Number(metrics[1]?.textContent||0),unknown=Number(metrics[2]?.textContent||0),coverage=shopCoverage(),status=q('#status');if(!status)return;status.className='status-chip '+(critical?'critical':warning||unknown||coverage.incomplete?'warning':'');status.innerHTML=''+(critical?'存在严重风险':warning?'存在预警风险':unknown?'数据待完善':coverage.incomplete?coverage.valid+'/'+coverage.total+' 数据有效':'数据正常');}
-const updateHeroFromData=updateHero;
-updateHero=()=>{const recovery=currentOverview?.recovery,control=currentOverview?.controlHealth;if(recovery?.state==='auth_required'){const hero=q('#heroAlert'),meta=q('#heroMeta'),shops=Array.isArray(currentOverview?.shops)?currentOverview.shops.length:1;if(meta)meta.textContent=shops+' 家店铺 · '+Number(currentOverview?.counts?.products||0)+' 个商品 · '+Number(currentOverview?.counts?.skus||0)+' 个 SKU';if(hero){hero.className='hero-alert critical';hero.innerHTML='抖店登录失效,自动巡检已安全停止当前快照继续展示;重新登录后自动恢复采集';}return;}if(Number(control?.staleCollectionCount||0)>0){const hero=q('#heroAlert');if(hero){hero.className='hero-alert critical';hero.innerHTML='采集控制记录未收口不代表任务仍在执行;确认前不要补触发';}return;}updateHeroFromData();};
-const syncCommandCenterStatusFromData=syncCommandCenterStatus;
-syncCommandCenterStatus=()=>{const status=q('#status');if(currentOverview?.recovery?.state==='auth_required'){if(status){status.className='status-chip critical';status.innerHTML='巡检等待登录';}return;}if(Number(currentOverview?.controlHealth?.staleCollectionCount||0)>0){if(status){status.className='status-chip critical';status.innerHTML='控制记录待核对';}return;}syncCommandCenterStatusFromData();};
-const statusGuard=new MutationObserver(()=>{updateHero();syncCommandCenterStatus();});
-statusGuard.observe(q('#metrics'),{childList:true,subtree:true});
-init().catch(error=>reportError(error,'会话初始化'));
-`;
+export const DASHBOARD_CLIENT_JS = String.raw`'use strict';
+const q=(selector,root=document)=>root.querySelector(selector);
+const qa=(selector,root=document)=>Array.from(root.querySelectorAll(selector));
+const esc=value=>String(value??'').replace(/[&<>"']/g,char=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char]));
+const attr=esc;
+const PRODUCT_PAGE_SIZE=50;
+const INCIDENT_PAGE_SIZE=20;
+let csrf='';
+let overview=null;
+let selectedShopId=new URL(location.href).searchParams.get('shopId')||'all';
+let productPage=1;
+let incidentPage=1;
+let toastTimer;
+let seenNotifications=null;
+const productByKey=new Map();
+function finite(value){const number=Number(value);return Number.isFinite(number)?number:0;}
+function dt(value){if(!value||!Number.isFinite(Date.parse(value)))return '尚无数据';return new Date(value).toLocaleString('zh-CN',{hour12:false});}
+function relative(value){if(!value||!Number.isFinite(Date.parse(value)))return '尚无数据';const minutes=Math.max(0,Math.floor((Date.now()-Date.parse(value))/60000));if(minutes<1)return '刚刚';if(minutes<60)return minutes+' 分钟前';const hours=Math.floor(minutes/60);if(hours<24)return hours+' 小时前';return Math.floor(hours/24)+' 天前';}
+function stale(value){return !value||!Number.isFinite(Date.parse(value))||Date.now()-Date.parse(value)>120*60*1000;}
+function showToast(message,tone='normal'){const element=q('#toast');element.textContent=message;element.hidden=false;element.dataset.tone=tone;clearTimeout(toastTimer);toastTimer=setTimeout(()=>{element.hidden=true;},2600);}
+async function api(path,options={}){let response;try{response=await fetch(path,{credentials:'same-origin',...options});}catch{const error=new Error('网络连接中断');error.code='NETWORK_ERROR';throw error;}let payload={};try{payload=await response.json();}catch{}if(!response.ok){const error=new Error(payload.message||payload.error||'数据读取失败');error.code=payload.error||'HTTP_'+response.status;throw error;}return payload;}
+async function establishSession(){const launchToken=new URLSearchParams(location.hash.slice(1)).get('token');if(launchToken){const result=await api('/api/session',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({token:launchToken})});history.replaceState(null,'',location.pathname+location.search);csrf=result.csrf;return;}const result=await api('/api/session');csrf=result.csrf;}
+function severityLabel(value){return ({critical:'严重风险',warning:'预警风险',unknown:'数据待确认',normal:'正常'})[value]||'数据待确认';}
+function incidentState(value){return ({pending:'待复核',open:'处理中',resolved:'已结束'})[value]||'待确认';}
+function recoveryHeadline(data){const recovery=data.recovery||{};if(recovery.state==='auth_required')return {tone:'critical',title:'抖店登录已失效,自动巡检已安全停止',detail:'当前已入库数据继续展示;重新登录后恢复采集。'};if(recovery.state==='interrupted')return {tone:'critical',title:'自动巡检已中断',detail:'库存事实仍可查看,需要技术人员检查采集状态。'};if(recovery.state==='running')return {tone:'warning',title:'自动巡检正在更新',detail:'部分商品可能已先完成更新,请以各商品快照时间为准。'};if(recovery.state==='degraded')return {tone:'warning',title:'最近一轮只完成了部分数据',detail:'已持久化的数据继续展示,未完成部分等待下一轮恢复。'};const critical=(data.incidents||[]).filter(item=>item.state==='open'&&item.severity==='critical').length;const warning=(data.incidents||[]).filter(item=>item.state==='open'&&item.severity==='warning').length;if(critical)return {tone:'critical',title:critical+' 个严重风险需要立即处理',detail:'按预计售罄时间和可调拨库存缺口优先处置。'};if(warning)return {tone:'warning',title:warning+' 个预警风险需要尽快处理',detail:'当前尚未达到严重程度,但需要运营跟进。'};return {tone:'normal',title:'当前没有开放的确定性库存风险',detail:'数据待确认项目不等同于缺货风险。'};}
+function updateUrl(){const url=new URL(location.href);if(selectedShopId==='all')url.searchParams.delete('shopId');else url.searchParams.set('shopId',selectedShopId);const search=q('#search').value.trim();if(search)url.searchParams.set('q',search);else url.searchParams.delete('q');history.replaceState(null,'',url);}
+function renderShops(){const shops=Array.isArray(overview.shops)?overview.shops:[];const statuses=Array.isArray(overview.shopStatuses)?overview.shopStatuses:[];const byId=new Map(statuses.map(item=>[String(item.id),item]));q('#shopCount').textContent='店铺(共 '+shops.length+' 家)';const aggregate=statuses.reduce((result,item)=>({critical:result.critical+finite(item.critical),warning:result.warning+finite(item.warning)}),{critical:0,warning:0});const all=[{id:'all',name:'全部店铺',critical:aggregate.critical,warning:aggregate.warning},...shops.map(shop=>({id:String(shop.id),name:String(shop.name),...(byId.get(String(shop.id))||{})}))];q('#shops').innerHTML=all.map(shop=>'').join('');q('#mobileShop').innerHTML=all.map(shop=>'').join('');}
+function renderHero(){const state=recoveryHeadline(overview);q('#hero').className='hero '+state.tone;q('#hero').innerHTML=''+esc(state.title)+'
'+esc(state.detail)+' · 数据汇总于 '+relative(overview.generatedAt)+'
';q('#connection').className='pill '+(state.tone==='critical'?'critical':state.tone==='warning'?'warning':'normal');q('#connection').textContent='连接正常';}
+function renderStats(){const open=(overview.incidents||[]).filter(item=>item.state==='open');const critical=open.filter(item=>item.severity==='critical').length;const warning=open.filter(item=>item.severity==='warning').length;const unknown=(overview.shopStatuses||[]).reduce((sum,item)=>sum+finite(item.unknown),0);const counts=overview.counts||{};const stats=[['严重风险',critical,'需要立即处理'],['预警风险',warning,'需要尽快处理'],['数据待确认',unknown,'不作为确定性风险'],['商品 / SKU',finite(counts.products)+' / '+finite(counts.skus),'已有库存数据'],['两小时内有效商品',finite(counts.freshProducts)+' / '+finite(counts.products),'超过两小时视为待刷新'],['自动巡检',({running:'正在运行',succeeded:'最近完成',degraded:'部分完成',auth_required:'等待登录',interrupted:'已中断'})[overview.recovery?.state]||'尚无状态','更新于 '+relative(overview.recovery?.updatedAt)]];q('#stats').innerHTML=stats.map(item=>''+esc(item[0])+''+esc(item[1])+''+esc(item[2])+'').join('');}
+function reasonText(finding,severity){const source=String(finding?.reason||'').toLowerCase();if(source.includes('2 小时')||source.includes('2小时')||source.includes('2h'))return '预计 2 小时内可能售罄,需要立即处理';if(source.includes('6 小时')||source.includes('6小时')||source.includes('6h'))return '预计 6 小时内可能售罄,需要尽快处理';if(source.includes('24 小时')||source.includes('24小时')||source.includes('24h'))return '可调拨库存不足以支撑未来 24 小时';if(source.includes('未占用')||source.includes('reserve'))return '可调拨库存不足,可能无法覆盖渠道需求';if(/过期|不完整|映射|identity|stale|confidence/.test(source))return '当前数据不足,系统暂停确定性风险判断';return severity==='critical'?'库存与高需求参考存在明显缺口,需要立即核查':'库存与高需求参考接近预警线,需要尽快核查';}
+function idsForIncident(incident){const values=[['商品ID',incident.product_id]];for(const finding of incident.findings||[]){const scope=finding.scope||{};values.push(['SKU ID',scope.platformSkuId||scope.platform_sku_id],['商家编码',scope.merchantCode||scope.merchant_code],['渠道品ID',scope.channelGoodsId||scope.channel_goods_id]);}const seen=new Set();return values.filter(([,value])=>{if(!value)return false;const key=String(value);if(seen.has(key))return false;seen.add(key);return true;});}
+function copyButton(label,value){return '';}
+function filteredIncidents(){return (overview.incidents||[]).filter(item=>item.state!=='resolved'&&(item.severity==='critical'||item.severity==='warning')).sort((a,b)=>(a.severity==='critical'?0:1)-(b.severity==='critical'?0:1)||String(b.last_seen_at).localeCompare(String(a.last_seen_at)));}
+function renderIncidents(){const all=filteredIncidents();const pages=Math.max(1,Math.ceil(all.length/INCIDENT_PAGE_SIZE));incidentPage=Math.min(incidentPage,pages);const page=all.slice((incidentPage-1)*INCIDENT_PAGE_SIZE,incidentPage*INCIDENT_PAGE_SIZE);q('#incidentCount').textContent=String(all.length);q('#incidents').innerHTML=page.length?''+page.map(item=>'
'+severityLabel(item.severity)+'
'+esc(item.product_title||item.product_id)+'
'+incidentState(item.state)+''+esc(item.shop_name||overview.selectedShop?.name||item.shop_id||overview.shopId)+' · 最近更新 '+relative(item.last_seen_at)+'
'+esc(reasonText((item.findings||[])[0],item.severity))+'
'+idsForIncident(item).map(pair=>copyButton(pair[0],pair[1])).join('')+'
').join('')+'
':'当前范围内没有正式严重风险或预警。
';renderPager('#incidentPager',incidentPage,pages,'incident');}
+function renderReminders(){const reminders=Array.isArray(overview.reminders)?overview.reminders:[];q('#reminderCount').textContent=String(reminders.length);q('#reminders').innerHTML=reminders.length?''+reminders.map(item=>'
'+esc(item.title||'运营提醒')+'
'+esc(item.detail||'当前状态需要关注。')+'
建议:'+esc(item.action||'请核对相关数据后处理。')+'
').join('')+'
':'当前没有待处理的运营提醒。
';}
+function productKey(product){return String(product.shop_id||overview.selectedShop?.id||overview.shopId)+':'+String(product.product_id);}
+function severityByProduct(){const map=new Map();for(const item of overview.incidents||[]){if(item.state!=='open')continue;const key=String(item.shop_id||overview.selectedShop?.id||overview.shopId)+':'+String(item.product_id);const current=map.get(key);if(!current||item.severity==='critical')map.set(key,item.severity);}return map;}
+function productSeverity(product,riskMap){if(stale(product.observed_at)||product.mapping_confidence==='unknown'||finite(product.completeness)<.8)return 'unknown';return riskMap.get(productKey(product))||'normal';}
+function searchText(product){return [product.shop_name,product.shop_id,product.product_title,product.product_id,...(product.skus||[]).flatMap(sku=>[sku.platform_sku_id,sku.merchant_code,...(sku.channels||[]).map(channel=>channel.channelGoodsId)])].filter(Boolean).join(' ').toLowerCase();}
+function filteredProducts(){const term=q('#search').value.trim().toLowerCase();const filter=q('#riskFilter').value;const riskMap=severityByProduct();return (overview.products||[]).filter(product=>(!term||searchText(product).includes(term))&&(filter==='all'||productSeverity(product,riskMap)===filter));}
+function renderProducts(){const all=filteredProducts();const pages=Math.max(1,Math.ceil(all.length/PRODUCT_PAGE_SIZE));productPage=Math.min(productPage,pages);const page=all.slice((productPage-1)*PRODUCT_PAGE_SIZE,productPage*PRODUCT_PAGE_SIZE);const riskMap=severityByProduct();productByKey.clear();for(const product of overview.products||[])productByKey.set(productKey(product),product);q('#productMeta').textContent='显示 '+page.length+' / '+all.length+' 个匹配商品 · 库存最后更新 '+dt(overview.freshness?.latestInventoryAt);q('#products').innerHTML=page.length?'| 店铺 | 商品 | 风险状态 | SKU 数 | 总库存 | 快照时间 | 数据状态 | |
'+page.map(product=>{const severity=productSeverity(product,riskMap);return '| '+esc(product.shop_name||overview.selectedShop?.name||product.shop_id||overview.shopId)+' | '+esc(product.product_title||product.product_id)+' '+copyButton('商品ID',product.product_id)+' | '+severityLabel(severity)+' | '+finite(product.sku_count||(product.skus||[]).length)+' | '+finite(product.total_stock)+' | '+relative(product.observed_at)+' | '+(stale(product.observed_at)?'数据待刷新':'数据有效')+' | |
';}).join('')+'
':'没有匹配的商品,请调整搜索或筛选条件。
';renderPager('#productPager',productPage,pages,'product');}
+function renderPager(selector,page,pages,kind){q(selector).innerHTML=pages<=1?'':'第 '+page+' / '+pages+' 页';}
+function horizon(forecast,hours){return (forecast?.horizons||[]).find(item=>Number(item.hours)===hours);}
+function demandLine(label,value){return ''+esc(label)+' '+(value==null?'尚未形成':esc(value))+'
';}
+function showProduct(key){const product=productByKey.get(key);if(!product)return;const detail=q('#productDetail');detail.innerHTML=''+esc(product.product_title||product.product_id)+'
'+esc(product.shop_name||overview.selectedShop?.name||product.shop_id||overview.shopId)+'
'+copyButton('商品ID',product.product_id)+'
总库存'+finite(product.total_stock)+'
SKU 数'+finite(product.sku_count||(product.skus||[]).length)+'
数据状态'+(stale(product.observed_at)?'待刷新':'有效')+'
SKU 与渠道库存
'+(product.skus||[]).map(sku=>{const forecast=sku.forecast;return '
'+esc(sku.merchant_code||sku.platform_sku_id)+'
'+copyButton('SKU ID',sku.platform_sku_id)+copyButton('商家编码',sku.merchant_code)+'
当前库存'+finite(sku.current_stock)+'
已占用'+finite(sku.occupied_stock)+'
未占用'+finite(sku.unoccupied_stock)+'
'+(sku.channels||[]).map(channel=>copyButton('渠道品ID',channel.channelGoodsId)+'库存 '+finite(channel.stock)+'').join('')+'
'+(forecast?demandLine('常态日需求参考',forecast.daily_p50)+demandLine('高需求日需求参考',forecast.daily_p90)+demandLine('未来 2 小时高需求参考',horizon(forecast,2)?.p90)+demandLine('未来 6 小时高需求参考',horizon(forecast,6)?.p90)+demandLine('未来 24 小时高需求参考',horizon(forecast,24)?.p90):'
预测数据尚未形成,不能据此判断库存安全。
')+'
';}).join('')+'
';q('#productDialog').showModal();}
+function renderProductionCycle(){const cycle=overview.productionCycle;if(!cycle||cycle.state==='unavailable'){q('#productionCycle').innerHTML='BPA Core 暂不可读。库存事实仍可查看,但不能据此判断正式周期正常。
';return;}if(cycle.state==='not-run'){q('#productionCycle').innerHTML='尚无正式库存周期。
';return;}if(cycle.state==='in-progress'){q('#productionCycle').innerHTML='当前状态执行中
计划时间'+dt(cycle.scheduledAt)+'
说明本轮未终态,不回显上一轮健康结论
';return;}const coverage=cycle.coverage,inventory=cycle.inventory,risk=cycle.risk;if(!coverage||!inventory||!risk){q('#productionCycle').innerHTML='本轮没有形成可信的全店聚合,不能按 0 家店展示。
';return;}q('#productionCycle').innerHTML='店铺覆盖'+finite(coverage.succeededShops)+' / '+finite(coverage.expectedShops)+'
库存写入'+finite(inventory.persistedProducts)+' / '+finite(inventory.attemptedProducts)+'
风险覆盖'+finite(risk.succeededProducts)+' / '+finite(risk.attemptedProducts)+'
';}
+function render(){renderShops();renderHero();renderStats();renderIncidents();renderReminders();renderProducts();renderProductionCycle();notifyNewItems();}
+function notifyNewItems(){const items=[...(overview.incidents||[]).filter(item=>item.state==='open'&&(item.severity==='critical'||item.severity==='warning')).map(item=>({id:'incident:'+item.incident_id,severity:item.severity,title:severityLabel(item.severity)+':'+(item.product_title||item.product_id),body:reasonText((item.findings||[])[0],item.severity)})),...(overview.reminders||[]).filter(item=>item.notificationEligible!==false&&(item.severity==='critical'||item.severity==='warning')).map(item=>({id:'reminder:'+item.id,severity:item.severity,title:item.title,body:item.detail}))];const next=new Set(items.map(item=>item.id));if('Notification' in window&&seenNotifications&&Notification.permission==='granted'){for(const item of items)if(!seenNotifications.has(item.id))new Notification(item.title,{body:item.body,tag:item.id});}seenNotifications=next;}
+async function load(options={}){q('#connection').className='pill neutral';q('#connection').textContent='正在同步';try{if(!csrf)await establishSession();overview=await api('/api/overview?shopId='+encodeURIComponent(selectedShopId));render();q('#connection').className='pill normal';q('#connection').textContent='连接正常';if(options.manual)showToast('已刷新最新数据');}catch(error){q('#connection').className='pill critical';q('#connection').textContent=error.code==='NETWORK_ERROR'?'网络中断':'数据读取失败';showToast(error.message,'critical');throw error;}}
+function openReview(incidentId){const incident=(overview.incidents||[]).find(item=>item.incident_id===incidentId);if(!incident)return;q('#incidentId').value=incidentId;q('#reviewProduct').textContent=(incident.shop_name||overview.selectedShop?.name||incident.shop_id||overview.shopId)+' · '+(incident.product_title||incident.product_id);q('#note').value='';q('#reviewDialog').showModal();}
+async function submitReview(event){event.preventDefault();const button=q('#submitReview');button.disabled=true;try{const decision=q('input[name="decision"]:checked').value;await api('/api/reviews',{method:'POST',headers:{'content-type':'application/json','x-csrf-token':csrf},body:JSON.stringify({incidentId:q('#incidentId').value,decision,note:q('#note').value})});q('#reviewDialog').close();showToast('运营判断已保存');await load();}catch(error){showToast(error.message,'critical');}finally{button.disabled=false;}}
+async function copyValue(value){try{await navigator.clipboard.writeText(value);}catch{const area=document.createElement('textarea');area.value=value;document.body.append(area);area.select();document.execCommand('copy');area.remove();}showToast('已复制 '+value);}
+document.addEventListener('click',event=>{const target=event.target instanceof Element?event.target:null;const shop=target?.closest('[data-shop]');const copy=target?.closest('[data-copy]');const product=target?.closest('[data-product]');const review=target?.closest('[data-review]');const page=target?.closest('[data-page-kind]');const closeReview=target?.closest('[data-close-review]');if(closeReview){q('#reviewDialog').close();}else if(shop){selectedShopId=shop.dataset.shop;productPage=1;incidentPage=1;updateUrl();load().catch(()=>{});}else if(copy){copyValue(copy.dataset.copy).catch(()=>showToast('复制失败','critical'));}else if(product){showProduct(product.dataset.product);}else if(review){openReview(review.dataset.review);}else if(page&&!page.disabled){if(page.dataset.pageKind==='product')productPage=Number(page.dataset.page);else incidentPage=Number(page.dataset.page);page.dataset.pageKind==='product'?renderProducts():renderIncidents();}else if(target?.closest('[data-close-product]'))q('#productDialog').close();});
+q('#search').value=new URL(location.href).searchParams.get('q')||'';
+q('#search').addEventListener('input',()=>{productPage=1;updateUrl();renderProducts();});
+q('#riskFilter').addEventListener('change',()=>{productPage=1;renderProducts();});
+q('#mobileShop').addEventListener('change',event=>{selectedShopId=event.target.value;productPage=1;incidentPage=1;updateUrl();load().catch(()=>{});});
+q('#reload').addEventListener('click',()=>load({manual:true}).catch(()=>{}));
+q('#notify').addEventListener('click',async()=>{if(!('Notification' in window)){showToast('当前浏览器不支持桌面提醒');return;}const permission=await Notification.requestPermission();showToast(permission==='granted'?'桌面提醒已开启':'桌面提醒未开启');});
+q('#reviewForm').addEventListener('submit',event=>submitReview(event));
+establishSession().then(()=>load()).catch(error=>{q('#connection').className='pill critical';q('#connection').textContent='连接失败';showToast(error.message,'critical');});
+setInterval(()=>{if(document.visibilityState==='visible')load().catch(()=>{});},60000);
+setInterval(()=>{if(document.visibilityState==='visible')establishSession().catch(()=>{});},300000);`;
-export const DASHBOARD_CLIENT_JS = DASHBOARD_CLIENT_JS_BASE
- .replace("订单数据质量阻断","数据待确认")
- .replace("let selectedShopId=localStorage.getItem('bpa-selected-shop-id')||'';","let selectedShopId='all';")
- .replace("q('#incidents').innerHTML=incidentTable(open,data.products||[],selectedShopId);","q('#incidents').innerHTML=incidentTableAllStores(open,data.products||[]);")
- .replace("q('#reminders').innerHTML=remindersHtml(reminders);","q('#reminders').innerHTML=recoveryReminderHtml(data.recovery)+remindersAllStoresV2(reminders);")
- .replace("q('#readiness').innerHTML=readinessHtml(data);","q('#readiness').innerHTML=recoveryReadinessHtml(data.recovery)+readinessHtml(data);")
- .replace("q('#products').innerHTML=inventoryHtml(data.products||[],open);","q('#products').innerHTML=inventoryAllStores(data.products||[],open);")
- .replace("data.selectedShop?.name||'店铺'","'全店监测'")
- .replace("['P90 覆盖率',pct(data.backtest?.metrics?.p90Coverage),'目标 85%–95%',data.backtest?.status==='ready'?'good':'warning']","['低库存对照',legacyLowChannels(data.products||[]).length,'渠道库存 < 200(非正式风险)',legacyLowChannels(data.products||[]).length?'warning':'good'],['P90 覆盖率',pct(data.backtest?.metrics?.p90Coverage),'目标 85%–95%',data.backtest?.status==='ready'?'good':'warning']")
- .replace(" 家店铺 · 当前店 "," 家店铺 · ")
- .replace("请重新打开服务生成的一次性访问地址。","请从局域网安全入口重新进入;后续会话将在当前标签页自动续签。");
+export const DASHBOARD_TECHNICAL_JS = String.raw`'use strict';
+const q=selector=>document.querySelector(selector);
+const esc=value=>String(value??'').replace(/[&<>"']/g,char=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char]));
+const finite=value=>Number.isFinite(Number(value))?Number(value):0;
+const metric=value=>value==null?'—':Number(value).toFixed(3);
+async function api(path){const response=await fetch(path,{credentials:'same-origin'});const body=await response.json();if(!response.ok)throw new Error(body.error||'数据读取失败');return body;}
+function chart(backtest){if(!backtest||backtest.status!=='ready'||!Array.isArray(backtest.points)||!backtest.points.length)return '回测数据不足。
';const points=backtest.points.slice(-35),width=1000,height=300,pad=36,max=Math.max(1,...points.flatMap(point=>[finite(point.actual),finite(point.p90)]));const xy=(index,value)=>[pad+index*(width-pad*2)/Math.max(1,points.length-1),height-pad-(finite(value)/max)*(height-pad*2)];const path=key=>points.map((point,index)=>{const position=xy(index,point[key]);return (index?'L':'M')+position[0].toFixed(1)+' '+position[1].toFixed(1);}).join(' ');return '';}
+async function load(){q('#connection').className='pill neutral';q('#connection').textContent='正在同步';await api('/api/session');const data=await api('/api/overview?shopId=all');const backtest=data.backtest||{},metrics=backtest.metrics||{};q('#technicalMetrics').innerHTML=[['P90 覆盖率',metric(metrics.p90Coverage)],['P50 Pinball Loss',metric(metrics.p50PinballLoss)],['P90 Pinball Loss',metric(metrics.p90PinballLoss)],['WAPE',metric(metrics.wape)],['回测窗口',finite(backtest.windowDays)+' 天'],['模型',backtest.model||'—']].map(item=>''+esc(item[0])+''+esc(item[1])+'').join('');q('#backtest').innerHTML=chart(backtest);const cold=data.coldStart||{};q('#coldStart').innerHTML='直接模型'+finite(cold.directModel)+'
分层回退'+finite(cold.hierarchicalFallback)+'
店铺基线'+finite(cold.storeBaseline)+'
';q('#cycleTechnical').textContent=JSON.stringify(data.productionCycle||{state:'unavailable'},null,2);q('#connection').className='pill normal';q('#connection').textContent='连接正常';}
+q('#reload').addEventListener('click',()=>load().catch(error=>{q('#connection').className='pill critical';q('#connection').textContent=error.message;}));load().catch(error=>{q('#connection').className='pill critical';q('#connection').textContent=error.message;});setInterval(()=>{if(document.visibilityState==='visible')load().catch(()=>{});},60000);`;
diff --git a/apps/inventory-monitor/src/feishu-report-main.ts b/apps/inventory-monitor/src/feishu-report-main.ts
index 3899a6a0..10fd484f 100644
--- a/apps/inventory-monitor/src/feishu-report-main.ts
+++ b/apps/inventory-monitor/src/feishu-report-main.ts
@@ -18,6 +18,9 @@ if (!new Set(["preview","send"]).has(mode)) throw new Error("BPA_FEISHU_INVENTOR
const reportKind = process.env.BPA_FEISHU_REPORT_KIND?.trim() || "daily";
if (!new Set(["daily","alert"]).has(reportKind)) throw new Error("BPA_FEISHU_REPORT_KIND must be daily or alert");
const webhookUrl = mode === "send" ? required("BPA_FEISHU_INVENTORY_WEBHOOK_URL") : "";
+const inventoryDashboardUrl = reportKind === "daily"
+ ? required("BPA_FEISHU_INVENTORY_DASHBOARD_URL")
+ : "";
const pool = createAppPostgresPool({
connectionString:required("BPA_APP_DATABASE_URL"),
applicationName:`bpa-inventory-feishu-${reportKind}`,
@@ -32,7 +35,9 @@ try {
reportShops.push({ shop,overview:await repository.overview(shop.id) as unknown as InventoryReportOverview });
}
const report = reportKind === "daily"
- ? buildConsolidatedInventoryFeishuReport({ shops:reportShops })
+ ? buildConsolidatedInventoryFeishuReport({
+ shops:reportShops,dashboardUrl:inventoryDashboardUrl
+ })
: buildInventoryFeishuAlert({ shops:reportShops });
if (!report) {
process.stdout.write(`${JSON.stringify({ status:"skipped",kind:reportKind,reason:"NO_ACTIONABLE_ANOMALY" })}\n`);
diff --git a/apps/inventory-monitor/src/feishu-report.test.ts b/apps/inventory-monitor/src/feishu-report.test.ts
index d16ce232..50e45e7d 100644
--- a/apps/inventory-monitor/src/feishu-report.test.ts
+++ b/apps/inventory-monitor/src/feishu-report.test.ts
@@ -5,6 +5,7 @@ describe("inventory Feishu report",() => {
it("builds one restrained operational card with traceable ids",() => {
const report = buildConsolidatedInventoryFeishuReport({
now:new Date("2026-08-03T01:30:00.000Z"),
+ dashboardUrl:"http://192.168.3.135:17650/",
shops:[{ shop:{ id:"10461048",name:"榆园儿食品专营店" },overview:{
generatedAt:"2026-08-03T01:29:00.000Z",shopId:"10461048",
counts:{ products:77,skus:168 },
@@ -18,14 +19,51 @@ describe("inventory Feishu report",() => {
expect(report.reportKey).toBe("inventory-daily:all:2026-08-03");
expect(report.counts).toEqual({ critical:1,warning:0,unknown:0,products:77,skus:168 });
const body = JSON.stringify(report.payload);
- expect(body).toContain("库存风险报告|全店日报 · 1 家店铺");
- expect(body).toContain("3720154950123258166");
- expect(body).toContain("channel-1");
+ expect(body).toContain("🟢 库存风险报告|1 家店铺");
+ expect(body).toContain("📅 2026-08-03 | 数据:2026-08-03 09:20 更新");
+ expect(body).toContain("# 确定性风险");
+ expect(body).toContain("## 榆园儿食品专营店");
+ expect(body).toContain("SKU_ID:sku-1");
expect(body).toContain("P90 需求预计将耗尽当前库存");
+ expect(body).toContain("请前往库存看板查看并处理");
+ expect(body).toContain("打开库存看板");
+ expect(body).toContain("http://192.168.3.135:17650/");
expect(body).not.toContain("P90 demand exhausts stock");
+ expect(body).not.toContain("监测范围");
+ expect(body).not.toContain("店铺概览");
+ expect(body).not.toContain("优先处置");
expect(body).not.toContain("webhook");
});
+ it("renders only a concise no-risk conclusion when no risk is actionable",() => {
+ const report = buildConsolidatedInventoryFeishuReport({
+ now:new Date("2026-08-03T00:30:00.000Z"),
+ dashboardUrl:"http://192.168.3.135:17650/",
+ shops:[{ shop:{ id:"shop-1",name:"测试店铺" },overview:{
+ generatedAt:"2026-08-03T00:29:00.000Z",shopId:"shop-1",
+ counts:{ products:12,skus:24 },
+ freshness:{ latestInventoryAt:"2026-08-03T00:20:00.000Z" },
+ incidents:[]
+ } }]
+ });
+ const body = JSON.stringify(report.payload);
+ expect(body).toContain("✅ 暂无风险");
+ expect(body).not.toContain("打开库存看板");
+ expect(body).not.toContain("12 个商品");
+ expect(body).not.toContain("24 个 SKU");
+ });
+
+ it("rejects dashboard links that contain a login fragment",() => {
+ expect(() => buildConsolidatedInventoryFeishuReport({
+ dashboardUrl:"http://192.168.3.135:17650/#token=secret",
+ shops:[{ shop:{ id:"shop-1",name:"测试店铺" },overview:{
+ generatedAt:"2026-08-03T00:29:00.000Z",shopId:"shop-1",
+ counts:{ products:1,skus:1 },
+ incidents:[{ state:"open",severity:"critical" }]
+ } }]
+ })).toThrow("INVENTORY_DASHBOARD_URL_INVALID");
+ });
+
it("builds an idempotent daytime alert only for actionable anomalies",() => {
const input = {
now:new Date("2026-08-03T04:00:00.000Z"),
diff --git a/apps/inventory-monitor/src/feishu-report.ts b/apps/inventory-monitor/src/feishu-report.ts
index 5123057f..0c1b445b 100644
--- a/apps/inventory-monitor/src/feishu-report.ts
+++ b/apps/inventory-monitor/src/feishu-report.ts
@@ -70,6 +70,35 @@ function localTime(value: unknown): string {
}).format(at);
}
+function localDateTime(value: unknown): string {
+ if (typeof value !== "string" || !value) return "无数据";
+ const at = new Date(value);
+ if (!Number.isFinite(at.getTime())) return "无数据";
+ return new Intl.DateTimeFormat("sv-SE",{
+ timeZone:"Asia/Shanghai",year:"numeric",month:"2-digit",day:"2-digit",
+ hour:"2-digit",minute:"2-digit",hour12:false
+ }).format(at);
+}
+
+function oldestInventoryUpdate(items: readonly ShopInventoryReport[]): string {
+ const timestamps = items
+ .map((item) => Date.parse(item.overview.freshness?.latestInventoryAt ?? ""))
+ .filter((value) => Number.isFinite(value));
+ if (!timestamps.length) return "无数据";
+ return localDateTime(new Date(Math.min(...timestamps)).toISOString());
+}
+
+function dashboardUrl(value: string): string {
+ const parsed = new URL(value);
+ if (
+ !new Set(["http:","https:"]).has(parsed.protocol) ||
+ parsed.username || parsed.password || parsed.hash || parsed.search
+ ) {
+ throw new Error("INVENTORY_DASHBOARD_URL_INVALID");
+ }
+ return parsed.toString();
+}
+
function severityName(value: unknown): string {
return ({ critical:"严重",warning:"预警",unknown:"待确认",normal:"正常" } as Record)[String(value)] ?? "待确认";
}
@@ -127,41 +156,70 @@ function digestPayload(payload: Record): string {
export function buildConsolidatedInventoryFeishuReport(input: {
readonly shops: readonly ShopInventoryReport[];
+ readonly dashboardUrl: string;
readonly now?: Date;
}): InventoryFeishuReport {
const now = input.now ?? new Date();
const counts = reportCounts(input.shops);
const template = counts.critical > 0 ? "red" : counts.warning > 0 ? "orange" : "green";
- const shopLines = input.shops.map((item) => {
- const incidents = openIncidents(item);
- const critical = incidents.filter((candidate) => candidate.severity === "critical").length;
- const warning = incidents.filter((candidate) => candidate.severity === "warning").length;
- const unknown = incidents.filter((candidate) => candidate.severity === "unknown").length;
- const marker = critical ? "🔴" : warning ? "🟠" : unknown ? "⚪" : "🟢";
- return `${marker} **${text(item.shop.name,80)}**|严重 ${critical} · 预警 ${warning} · 待确认 ${unknown}|库存 ${localTime(item.overview.freshness?.latestInventoryAt)}`;
+ const actionable = input.shops.flatMap((item) => {
+ const lines = openIncidents(item)
+ .filter((incident) => incident.severity === "critical" || incident.severity === "warning")
+ .flatMap((incident) => {
+ const findings = Array.isArray(incident.findings) && incident.findings.length
+ ? incident.findings
+ : [{}];
+ return findings.map((candidate) => {
+ const finding = record(candidate);
+ const scope = record(finding.scope);
+ const marker = incident.severity === "critical" ? "🔴" : "🟠";
+ const productId = text(scope.productId ?? incident.product_id,80);
+ const skuId = text(scope.platformSkuId ?? incident.platform_sku_id,80) || "待确认";
+ const title = productTitle(incident.product_title,`商品 ${productId || "待确认"}`);
+ return `${marker} ${title}|SKU_ID:${skuId}|${riskReason(finding.reason)} 请前往库存看板查看并处理`;
+ });
+ });
+ return lines.length ? [{ shopName:text(item.shop.name,80),lines }] : [];
});
- const riskLines = input.shops.flatMap((item) => openIncidents(item)
- .filter((incident) => incident.severity === "critical" || incident.severity === "warning")
- .map((incident) => riskLine(item,incident))).slice(0,12);
+ const visibleLineLimit = 20;
+ let visibleLines = 0;
+ const riskSections: string[] = [];
+ for (const item of actionable) {
+ if (visibleLines >= visibleLineLimit) break;
+ const lines = item.lines.slice(0,visibleLineLimit - visibleLines);
+ visibleLines += lines.length;
+ riskSections.push(`## ${item.shopName}\n${lines.join("\n")}`);
+ }
+ const totalRiskLines = actionable.reduce((sum,item) => sum + item.lines.length,0);
+ if (totalRiskLines > visibleLines) {
+ riskSections.push(`另有 ${totalRiskLines - visibleLines} 条风险,请前往库存看板查看并处理`);
+ }
+ const riskContent = riskSections.length ? riskSections.join("\n\n") : "✅ 暂无风险";
+ const cardElements: Record[] = [
+ {
+ tag:"div",
+ text:{
+ tag:"lark_md",
+ content:`📅 ${dateInShanghai(now)} | 数据:${oldestInventoryUpdate(input.shops)} 更新\n\n# 确定性风险\n\n${riskContent}`
+ }
+ }
+ ];
+ if (riskSections.length) {
+ cardElements.push({
+ tag:"action",
+ actions:[{
+ tag:"button",type:"primary",
+ text:{ tag:"plain_text",content:"打开库存看板" },
+ url:dashboardUrl(input.dashboardUrl)
+ }]
+ });
+ }
const payload = {
msg_type:"interactive",
card:{
config:{ wide_screen_mode:true },
- header:{ template,title:{ tag:"plain_text",content:`库存风险报告|全店日报 · ${input.shops.length} 家店铺` } },
- elements:[
- { tag:"div",fields:[
- { is_short:true,text:{ tag:"lark_md",content:`**确定性风险**\n🔴 严重 ${counts.critical}|🟠 预警 ${counts.warning}` } },
- { is_short:true,text:{ tag:"lark_md",content:`**监测范围**\n${counts.products} 个商品|${counts.skus} 个 SKU` } },
- { is_short:true,text:{ tag:"lark_md",content:`**数据待确认**\n${counts.unknown} 个事件` } },
- { is_short:true,text:{ tag:"lark_md",content:`**生成时间**\n${localTime(now.toISOString())}` } }
- ] },
- { tag:"hr" },
- { tag:"div",text:{ tag:"lark_md",content:`**店铺概览**\n${shopLines.join("\n") || "暂无店铺数据"}` } },
- { tag:"hr" },
- { tag:"div",text:{ tag:"lark_md",content:`**优先处置**\n${riskLines.join("\n\n") || "🟢 当前没有开放的严重或预警风险。"}` } },
- { tag:"hr" },
- { tag:"note",elements:[{ tag:"plain_text",content:"库存均衡策略 v1.0|待确认事件仅用于数据补全,不等同于缺货风险" }] }
- ]
+ header:{ template,title:{ tag:"plain_text",content:`🟢 库存风险报告|${input.shops.length} 家店铺` } },
+ elements:cardElements
}
};
const digest = digestPayload(payload);
@@ -211,10 +269,11 @@ export function buildInventoryFeishuAlert(input: {
export function buildInventoryFeishuReport(input: {
readonly shop: { readonly id: string; readonly name: string };
readonly overview: InventoryReportOverview;
+ readonly dashboardUrl: string;
readonly now?: Date;
}): InventoryFeishuReport {
return buildConsolidatedInventoryFeishuReport({
- shops:[{ shop:input.shop,overview:input.overview }],
+ shops:[{ shop:input.shop,overview:input.overview }],dashboardUrl:input.dashboardUrl,
...(input.now ? { now:input.now } : {})
});
}
diff --git a/apps/inventory-monitor/src/main.ts b/apps/inventory-monitor/src/main.ts
index d24ac15e..4e86dced 100644
--- a/apps/inventory-monitor/src/main.ts
+++ b/apps/inventory-monitor/src/main.ts
@@ -64,22 +64,6 @@ try {
}
if (sessionSecret.length < 32) throw new Error("WEB_SESSION_SECRET_TOO_SHORT");
await chmod(sessionSecretFile,0o600);
-const accessTokenFile = process.env.BPA_INVENTORY_WEB_ACCESS_TOKEN_FILE?.trim() || (
- isWindowsNamedPipe(socketPath)
- ? join(resolveDefaultBpaHome(), "run", "inventory-web-access.key")
- : `${socketPath}.web-access.key`
-);
-await mkdir(dirname(accessTokenFile),{ recursive:true,mode:0o700 });
-let accessToken: string;
-try {
- accessToken = (await readFile(accessTokenFile,"utf8")).trim();
-} catch (error) {
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
- accessToken = randomBytes(32).toString("base64url");
- await writeFile(accessTokenFile,`${accessToken}\n`,{ encoding:"utf8",mode:0o600,flag:"wx" });
-}
-if (accessToken.length < 32) throw new Error("WEB_ACCESS_TOKEN_TOO_SHORT");
-await chmod(accessTokenFile,0o600);
const recoveryStatusPath = process.env.BPA_INVENTORY_RECOVERY_STATUS_FILE?.trim() ||
join(resolveDefaultBpaHome(),"run","inventory-multishop-recovery.status.json");
const attentionControl = new ControlClient(
@@ -90,7 +74,7 @@ const attentionControl = new ControlClient(
{ timeoutMs:2_000 }
);
const web = await startInventoryWebServer({
- repository,shops,port,sessionSecret,listenHost,publicHost,accessToken,recoveryStatusPath,
+ repository,shops,port,sessionSecret,listenHost,publicHost,recoveryStatusPath,
runtimeAttentionReminders:createRuntimeAttentionReminderProvider(attentionControl),
runtimeProductionCycleSummary:createRuntimeProductionCycleSummaryProvider(
attentionControl
@@ -102,7 +86,7 @@ const launchUrlFile = process.env.BPA_INVENTORY_LAUNCH_URL_FILE?.trim() || (
: `${socketPath}.review-url`
);
await mkdir(dirname(launchUrlFile),{ recursive:true,mode:0o700 });
-await writeFile(launchUrlFile,`${web.accessUrl ?? web.launchUrl}\n`,{ encoding:"utf8",mode:0o600 });
+await writeFile(launchUrlFile,`${web.launchUrl}\n`,{ encoding:"utf8",mode:0o600 });
await chmod(launchUrlFile,0o600);
process.stdout.write(`${JSON.stringify({
diff --git a/apps/inventory-monitor/src/web-server.test.ts b/apps/inventory-monitor/src/web-server.test.ts
index b3a20f49..c778ffc4 100644
--- a/apps/inventory-monitor/src/web-server.test.ts
+++ b/apps/inventory-monitor/src/web-server.test.ts
@@ -79,7 +79,7 @@ describe("inventory review server", () => {
}
});
- it("uses a one-time launch token, idle cookie and CSRF boundary", async () => {
+ it("serves the local operations app with a launch token, idle session and CSRF write boundary", async () => {
const repository = {
collectionControlHealth:vi.fn(async () => healthyControl()),
overview: vi.fn(async () => ({ counts: { products: 0,skus: 0,incidents: 0 } })),
@@ -89,53 +89,45 @@ describe("inventory review server", () => {
try {
const page = await fetch(`http://127.0.0.1:${server.port}/`);
const pageBody = await page.text();
- expect(pageBody).toContain("库存风险指挥台");
- expect(pageBody).toContain("风险处置队列");
- expect(pageBody.indexOf("风险处置队列")).toBeLessThan(pageBody.indexOf("P90 预测回测"));
- expect(pageBody).toContain("P90 预测回测");
- expect(pageBody).toContain("正式库存周期");
- expect(pageBody).not.toContain("影子");
+ expect(pageBody).toContain("库存运营面板");
+ expect(pageBody).toContain("风险处理队列");
+ expect(pageBody).toContain("商品库存");
+ expect(pageBody).toContain("最近一次正式库存周期");
+ expect(pageBody).toContain('data-close-review');
+ expect(pageBody).toContain('class="skip-link"');
+ expect(pageBody).not.toContain("P90");
+ expect(pageBody).not.toContain("Pinball");
+ const technicalPage = await fetch(`http://127.0.0.1:${server.port}/technical`).then((response) => response.text());
+ expect(technicalPage).toContain("库存技术监控");
+ expect(technicalPage).toContain("预测回测");
+ expect(technicalPage).toContain("冷启动与映射覆盖");
+ const technicalScript = await fetch(`http://127.0.0.1:${server.port}/technical.js`).then((response) => response.text());
+ expect(() => new Function(technicalScript)).not.toThrow();
const clientScript = await fetch(`http://127.0.0.1:${server.port}/app.js`).then((response) => response.text());
- expect(clientScript).toContain("data-copy-id");
- expect(pageBody).not.toContain('id="shopSelect"');
- expect(clientScript).toContain("selectedShopId='all'");
- expect(clientScript).toContain("incidentTableAllStores");
+ expect(clientScript).toContain("data-copy");
+ expect(clientScript).toContain("PRODUCT_PAGE_SIZE=50");
+ expect(clientScript).toContain("预计 2 小时内可能售罄");
+ expect(clientScript).toContain("常态日需求参考");
+ expect(clientScript).not.toContain("selected_model");
+ expect(clientScript).not.toContain("dataset_id");
expect(() => new Function(clientScript)).not.toThrow();
- const clientStyles = await fetch(`http://127.0.0.1:${server.port}/app-v2.css`).then((response) => response.text());
- expect(clientStyles).toContain(".priority-grid");
- expect(clientStyles).toContain(".sonner-region");
- expect(clientScript).toContain("window.sonner=sonner");
- expect(clientScript).toContain("SESSION_REQUIRED");
- expect(clientScript).toContain("系统将在 5 秒后自动重试");
- expect(clientScript).toContain("scheduleReconnect");
- expect(clientScript).toContain("部分完成");
- expect(clientScript).toContain("未终态前不回显上一轮健康结论");
- expect(clientScript).not.toContain("schedulesHtml");
- expect(clientScript).toContain("dataQualityGroups");
- expect(clientScript).toContain("运行与控制提醒");
- expect(clientScript).toContain("控制记录待核对");
- expect(clientScript).not.toContain("订单数据质量阻断");
+ const clientStyles = await fetch(`http://127.0.0.1:${server.port}/app.css`).then((response) => response.text());
+ expect(clientStyles).toContain(".mobile-shopbar");
+ expect(clientStyles).toContain("prefers-reduced-motion");
+ expect(clientScript).toContain("本轮未终态,不回显上一轮健康结论");
expect(clientScript).toContain("数据待确认");
- expect(clientScript).toContain("影响 '+esc(group.count)+' 个商品");
expect(clientScript).toContain("item.notificationEligible!==false");
+ expect(clientScript).toContain("closeReview");
const launch = new URL(server.launchUrl);
- const launchToken = new URLSearchParams(launch.hash.slice(1)).get("token");
expect(launch.hostname).toBe("127.0.0.1");
- expect(launchToken).toBeTruthy();
- const session = await fetch(`http://127.0.0.1:${server.port}/api/session`,{
- method:"POST",headers:{ "content-type":"application/json" },
- body:JSON.stringify({ token:launchToken })
- });
+ expect(new URLSearchParams(launch.hash.slice(1)).get("token")).toBeTruthy();
+ const session = await fetch(`http://127.0.0.1:${server.port}/api/session`);
expect(session.status).toBe(200);
const cookie = session.headers.get("set-cookie")?.split(";",1)[0];
expect(session.headers.get("set-cookie")).toContain("HttpOnly");
expect(session.headers.get("set-cookie")).toContain("SameSite=Strict");
+ expect(session.headers.get("set-cookie")).toContain("Max-Age=1800");
const { csrf } = await session.json() as { csrf: string };
- const reused = await fetch(`http://127.0.0.1:${server.port}/api/session`,{
- method:"POST",headers:{ "content-type":"application/json" },
- body:JSON.stringify({ token:launchToken })
- });
- expect(reused.status).toBe(403);
const overview = await fetch(`http://127.0.0.1:${server.port}/api/overview`,{
headers:{ cookie:cookie! }
});
@@ -157,7 +149,7 @@ describe("inventory review server", () => {
}
});
- it("keeps a signed rolling session across a service restart and enforces idle expiry", async () => {
+ it("keeps a rolling signed session across restarts and enforces idle expiry", async () => {
const repository = {
collectionControlHealth:vi.fn(async () => healthyControl()),
overview: vi.fn(async () => ({ counts: { products: 77,skus: 168,incidents: 0 } })),
@@ -168,11 +160,7 @@ describe("inventory review server", () => {
const first = await startInventoryWebServer({
repository,shopId:"10461048",port:0,sessionSecret,now:() => currentTime
});
- const launchToken = new URLSearchParams(new URL(first.launchUrl).hash.slice(1)).get("token");
- const login = await fetch(`http://127.0.0.1:${first.port}/api/session`,{
- method:"POST",headers:{ "content-type":"application/json" },
- body:JSON.stringify({ token:launchToken })
- });
+ const login = await fetch(`http://127.0.0.1:${first.port}/api/session`);
const cookie = login.headers.get("set-cookie")?.split(";",1)[0];
await first.close();
@@ -193,7 +181,6 @@ describe("inventory review server", () => {
headers:{ cookie:renewedCookie! }
});
expect(expired.status).toBe(401);
- await expect(expired.json()).resolves.toEqual({ error:"SESSION_REQUIRED" });
} finally {
await restarted.close();
}
@@ -229,11 +216,7 @@ describe("inventory review server", () => {
port:0
});
try {
- const launchToken = new URLSearchParams(new URL(server.launchUrl).hash.slice(1)).get("token");
- const login = await fetch(`http://127.0.0.1:${server.port}/api/session`,{
- method:"POST",headers:{ "content-type":"application/json" },
- body:JSON.stringify({ token:launchToken })
- });
+ const login = await fetch(`http://127.0.0.1:${server.port}/api/session`);
const cookie = login.headers.get("set-cookie")?.split(";",1)[0];
const selected = await fetch(`http://127.0.0.1:${server.port}/api/overview?shopId=shop-2`,{
headers:{ cookie:cookie! }
@@ -257,20 +240,21 @@ describe("inventory review server", () => {
}
});
- it("supports a reusable LAN bootstrap token while retaining isolated sessions", async () => {
+ it("keeps a tokenized launch URL while trusted loopback establishes isolated sessions", async () => {
const repository = {
collectionControlHealth:vi.fn(async () => healthyControl()),
overview: vi.fn(async () => ({ counts:{ products:0,skus:0,incidents:0 } })),
reviewIncident: vi.fn(async () => undefined)
};
- const accessToken = "shared-lan-access-token-that-is-long-enough-1234";
const server = await startInventoryWebServer({
- repository,shopId:"10461048",port:0,accessToken,publicHost:"192.168.3.135"
+ repository,shopId:"10461048",port:0,publicHost:"192.168.3.135"
});
try {
- expect(server.accessUrl).toBe(`http://192.168.3.135:${server.port}/#token=${accessToken}`);
+ const launch = new URL(server.launchUrl);
+ expect(launch.origin).toBe(`http://192.168.3.135:${server.port}`);
+ expect(new URLSearchParams(launch.hash.slice(1)).get("token")).toBeTruthy();
const login = async (): Promise => fetch(`http://127.0.0.1:${server.port}/api/session`,{
- method:"POST",headers:{ "content-type":"application/json" },body:JSON.stringify({ token:accessToken })
+ headers:{ "x-forwarded-for":"198.51.100.8" }
});
const first = await login();
const second = await login();
@@ -282,7 +266,7 @@ describe("inventory review server", () => {
}
});
- it("automatically restores a loopback review session without exposing the shared token",async () => {
+ it("automatically restores a review session for a trusted loopback entry",async () => {
const repository = {
collectionControlHealth:vi.fn(async () => healthyControl()),
overview:vi.fn(async () => ({ counts:{ products:0,skus:0,incidents:0 } })),
@@ -438,4 +422,26 @@ describe("inventory review server", () => {
await server.close();
}
});
+
+ it("does not expose repository diagnostics through the employee API",async () => {
+ const repository = {
+ collectionControlHealth:vi.fn(async () => healthyControl()),
+ overview:vi.fn(async () => {
+ throw new Error("postgresql://operator:secret@private-host/inventory");
+ }),
+ reviewIncident:vi.fn(async () => undefined)
+ };
+ const server = await startInventoryWebServer({ repository,shopId:"shop-1",port:0 });
+ try {
+ const session = await fetch(`http://127.0.0.1:${server.port}/api/session`);
+ const cookie = session.headers.get("set-cookie")?.split(";",1)[0];
+ const response = await fetch(`http://127.0.0.1:${server.port}/api/overview`,{
+ headers:{ cookie:cookie! }
+ });
+ expect(response.status).toBe(500);
+ expect(await response.json()).toEqual({ error:"INTERNAL_ERROR" });
+ } finally {
+ await server.close();
+ }
+ });
});
diff --git a/apps/inventory-monitor/src/web-server.ts b/apps/inventory-monitor/src/web-server.ts
index 10f469cb..4005b489 100644
--- a/apps/inventory-monitor/src/web-server.ts
+++ b/apps/inventory-monitor/src/web-server.ts
@@ -3,7 +3,13 @@ import { readFile } from "node:fs/promises";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { InventoryRepository } from "./repository.js";
import type { InventoryShopConfig } from "./shop-config.js";
-import { DASHBOARD_CLIENT_CSS, DASHBOARD_CLIENT_JS } from "./dashboard-client.js";
+import {
+ DASHBOARD_CLIENT_CSS,
+ DASHBOARD_CLIENT_HTML,
+ DASHBOARD_CLIENT_JS,
+ DASHBOARD_TECHNICAL_HTML,
+ DASHBOARD_TECHNICAL_JS
+} from "./dashboard-client.js";
import {
buildSystemOperationalReminders,
type CollectionControlHealth
@@ -18,6 +24,7 @@ import type {
} from "./runtime-production-cycle-summary.js";
const SESSION_COOKIE = "bpa_inventory_session";
+const SESSION_COOKIE_MAX_AGE_SECONDS = 30 * 60;
const SESSION_IDLE_MS = 30 * 60 * 1000;
const BODY_LIMIT = 64 * 1024;
@@ -73,7 +80,7 @@ function decodeSession(value: string, secret: string): Session | undefined {
}
function sessionCookie(value: string): string {
- return `${SESSION_COOKIE}=${value}; HttpOnly; SameSite=Strict; Path=/; Max-Age=1800`;
+ return `${SESSION_COOKIE}=${value}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${SESSION_COOKIE_MAX_AGE_SECONDS}`;
}
function headers(response: ServerResponse, contentType: string): void {
@@ -403,20 +410,21 @@ export async function startInventoryWebServer(input: {
const server: Server = createServer((request, response) => {
void (async () => {
const url = new URL(request.url ?? "/", "http://inventory.local");
- if (request.method === "GET" && url.pathname === "/") return send(response,200,MULTI_SHOP_HTML,"text/html; charset=utf-8");
- if (request.method === "GET" && url.pathname === "/app.css") return send(response,200,MULTI_SHOP_CSS,"text/css; charset=utf-8");
- if (request.method === "GET" && url.pathname === "/app-v2.css") return send(response,200,DASHBOARD_CLIENT_CSS,"text/css; charset=utf-8");
+ if (request.method === "GET" && url.pathname === "/") return send(response,200,DASHBOARD_CLIENT_HTML,"text/html; charset=utf-8");
+ if (request.method === "GET" && url.pathname === "/technical") return send(response,200,DASHBOARD_TECHNICAL_HTML,"text/html; charset=utf-8");
+ if (request.method === "GET" && url.pathname === "/app.css") return send(response,200,DASHBOARD_CLIENT_CSS,"text/css; charset=utf-8");
if (request.method === "GET" && url.pathname === "/app.js") return send(response,200,DASHBOARD_CLIENT_JS,"text/javascript; charset=utf-8");
+ if (request.method === "GET" && url.pathname === "/technical.js") return send(response,200,DASHBOARD_TECHNICAL_JS,"text/javascript; charset=utf-8");
if (url.pathname === "/api/session" && request.method === "POST") {
const payload = await body(request);
const supplied = typeof payload.token === "string" ? payload.token : "";
const oneTimeMatch = Boolean(launchToken && supplied && safeEqual(supplied,launchToken));
const sharedMatch = Boolean(input.accessToken && supplied && safeEqual(supplied,input.accessToken));
- if (!oneTimeMatch && !sharedMatch) return json(response,403,{ error: "SESSION_TOKEN_INVALID" });
+ if (!oneTimeMatch && !sharedMatch) return json(response,403,{ error:"SESSION_TOKEN_INVALID" });
if (oneTimeMatch) launchToken = "";
- const session: Session = { id: token(), csrf: token(), lastSeenAt: now() };
+ const session: Session = { id:token(),csrf:token(),lastSeenAt:now() };
response.setHeader("Set-Cookie",sessionCookie(encodeSession(session,sessionSecret)));
- return json(response,200,{ csrf: session.csrf });
+ return json(response,200,{ csrf:session.csrf });
}
let auth = authenticate(request);
if (
@@ -484,7 +492,7 @@ export async function startInventoryWebServer(input: {
return json(response,200,{ saved: true });
}
return json(response,404,{ error: "NOT_FOUND" });
- })().catch((error) => json(response,500,{ error: error instanceof Error ? error.message.slice(0,500) : "INTERNAL_ERROR" }));
+ })().catch(() => json(response,500,{ error:"INTERNAL_ERROR" }));
});
await new Promise((resolve,reject) => {
server.once("error",reject);
diff --git a/apps/local-core/src/binance-data-runtime-provider.test.ts b/apps/local-core/src/binance-data-runtime-provider.test.ts
new file mode 100644
index 00000000..1ce4974a
--- /dev/null
+++ b/apps/local-core/src/binance-data-runtime-provider.test.ts
@@ -0,0 +1,201 @@
+import type {
+ BinanceCollectionRunRecord,
+ BinanceCopyTradingStore,
+ PersistBinanceCopyTradingCaptureInput
+} from "@bpa/persistence";
+import type { RuntimeInvocation } from "@bpa/node-runtime";
+import { describe, expect, it } from "vitest";
+import { BinanceDataRuntimeProvider } from "./binance-data-runtime-provider.js";
+
+const capturedAt = "2026-08-12T04:30:00.000Z";
+
+class MemoryBinanceStore implements BinanceCopyTradingStore {
+ calls: PersistBinanceCopyTradingCaptureInput[] = [];
+
+ persistBinanceCopyTradingCapture(input: PersistBinanceCopyTradingCaptureInput) {
+ this.calls.push(structuredClone(input));
+ const run: BinanceCollectionRunRecord = {
+ collectionRunId: input.collectionRunId,
+ workflowRunId: input.workflowRunId,
+ sourceUrl: input.sourceUrl,
+ attemptAt: input.attemptAt,
+ captureAt: input.captureAt,
+ status: input.status,
+ contentDigest: input.contentDigest,
+ projectCount: input.projectCount,
+ pageCount: input.pageCount,
+ recordCount: input.recordCount,
+ ...(input.oldestEventTimeUtc === undefined
+ ? {}
+ : { oldestEventTimeUtc: input.oldestEventTimeUtc }),
+ ...(input.newestEventTimeUtc === undefined
+ ? {}
+ : { newestEventTimeUtc: input.newestEventTimeUtc }),
+ lastSuccessAt: input.captureAt,
+ createdAt: input.attemptAt
+ };
+ return {
+ status: "accepted" as const,
+ run,
+ newCurrentRecordCount: input.rawRecords.length
+ };
+ }
+
+ getBinanceCollectionRun() { return undefined; }
+ getLatestSuccessfulBinanceCollectionRun() { return undefined; }
+ listBinanceRawRecords() { return []; }
+ listBinanceCurrentRecords() { return []; }
+}
+
+function invocation(projects: unknown): RuntimeInvocation {
+ return {
+ invocationId: "invocation:binance:persist",
+ identity: {
+ runId: "run:binance",
+ scopePath: [],
+ iterationKey: "root",
+ stepKey: "persist_capture",
+ attempt: 1
+ },
+ node: {
+ kind: "node",
+ id: "binance.copy-trading.capture.persist",
+ version: "1.0.0",
+ digest: `sha256:${"a".repeat(64)}`
+ },
+ providerId: "binance-data",
+ input: {
+ pageTimeZone: "Asia/Shanghai",
+ management: {
+ schemaVersion: "binance-copy-trading/v0.1",
+ status: "complete",
+ observedAt: capturedAt,
+ pageUrl: "https://www.binance.com/zh-CN/copy-trading/copy-management",
+ accountSummary: { 净利润: "1.00 USDT" },
+ activeTab: "ongoing",
+ projects: [
+ {
+ projectId: "project_1001",
+ status: "ongoing",
+ summary: { 净利润: "1.00 USDT" },
+ currentPositions: []
+ }
+ ],
+ warnings: [],
+ formMutations: 0
+ },
+ projects
+ } as RuntimeInvocation["input"],
+ permissionSnapshot: {
+ riskLevel: "R1",
+ permissions: ["binance.copy-trading.capture.write"],
+ domains: []
+ },
+ deadlineAt: Date.parse("2026-08-12T05:00:00.000Z"),
+ idempotencyKey: "run:binance:root:persist_capture:1",
+ fencingToken: 7,
+ traceId: "trace:binance"
+ };
+}
+
+function completeProjects() {
+ const fields = {
+ 时间: "2026-08-12 12:00:00",
+ 合约: "BTCUSDT",
+ 方向: "买入",
+ 价格: "120000",
+ 数量: "0.01",
+ 手续费: "-0.48 USDT"
+ };
+ return {
+ total: 1,
+ succeeded: {
+ count: 1,
+ items: [
+ {
+ itemKey: "project_1001",
+ output: {
+ schemaVersion: "binance-copy-trading/v0.1",
+ status: "complete",
+ projectId: "project_1001",
+ observedAt: capturedAt,
+ pageUrl: "https://www.binance.com/zh-CN/copy-trading/copy-management",
+ tabs: [
+ {
+ sourceTab: "交易历史",
+ pageCount: 1,
+ records: [
+ {
+ recordKey: "page-row-1",
+ projectId: "project_1001",
+ sourceTab: "交易历史",
+ page: 1,
+ rowOrdinal: 1,
+ fields
+ },
+ {
+ recordKey: "page-row-2",
+ projectId: "project_1001",
+ sourceTab: "交易历史",
+ page: 1,
+ rowOrdinal: 2,
+ fields
+ }
+ ]
+ }
+ ],
+ formMutations: 0
+ }
+ }
+ ]
+ },
+ failed: { count: 0, items: [] },
+ unresolved: { count: 0, items: [] }
+ };
+}
+
+describe("BinanceDataRuntimeProvider", () => {
+ it("preserves identical legitimate trades and normalizes page time to UTC", async () => {
+ const store = new MemoryBinanceStore();
+ const result = await new BinanceDataRuntimeProvider(
+ store,
+ () => new Date("2026-08-12T04:31:00.000Z")
+ ).invoke(invocation(completeProjects()), new AbortController().signal);
+
+ expect(result.status).toBe("succeeded");
+ expect(store.calls).toHaveLength(1);
+ expect(store.calls[0]!.rawRecords).toHaveLength(2);
+ expect(store.calls[0]!.rawRecords[0]!.currentRecordKey).not.toBe(
+ store.calls[0]!.rawRecords[1]!.currentRecordKey
+ );
+ expect(store.calls[0]!.rawRecords.map((record) => record.eventTimeUtc)).toEqual([
+ "2026-08-12T04:00:00Z",
+ "2026-08-12T04:00:00Z"
+ ]);
+ expect(store.calls[0]!.pageCount).toBe(2);
+ });
+
+ it("fails closed without a store call when foreach coverage is incomplete", async () => {
+ const store = new MemoryBinanceStore();
+ const incomplete = completeProjects() as unknown as {
+ total: number;
+ succeeded: { count: number; items: unknown[] };
+ failed: { count: number; items: unknown[] };
+ unresolved: { count: number; items: unknown[] };
+ };
+ incomplete.succeeded = { count: 0, items: [] };
+ incomplete.failed = {
+ count: 1,
+ items: [{ itemKey: "project_1001", error: { code: "PAGINATION_FAILED" } }]
+ };
+ const result = await new BinanceDataRuntimeProvider(store).invoke(
+ invocation(incomplete),
+ new AbortController().signal
+ );
+ expect(result).toMatchObject({
+ status: "failed",
+ error: { code: "BINANCE_CAPTURE_PERSIST_FAILED" }
+ });
+ expect(store.calls).toHaveLength(0);
+ });
+});
diff --git a/apps/local-core/src/binance-data-runtime-provider.ts b/apps/local-core/src/binance-data-runtime-provider.ts
new file mode 100644
index 00000000..576d60bf
--- /dev/null
+++ b/apps/local-core/src/binance-data-runtime-provider.ts
@@ -0,0 +1,463 @@
+import { createHash } from "node:crypto";
+import { Temporal } from "@js-temporal/polyfill";
+import type {
+ RuntimeInvocation,
+ RuntimeOutcome,
+ RuntimeProvider
+} from "@bpa/node-runtime";
+import type {
+ BinanceCopyTradingStore,
+ OperationalExecutionContext
+} from "@bpa/persistence";
+import type { ArtifactRef, JsonValue } from "@bpa/workflow-ir";
+
+const NODE_ID = "binance.copy-trading.capture.persist";
+const NODE_VERSION = "1.0.0";
+const PERMISSION = "binance.copy-trading.capture.write";
+const TIME_FIELDS = ["时间", "成交时间", "资金费时间", "Time"] as const;
+const SYMBOL_FIELDS = ["合约", "交易对", "Symbol"] as const;
+const SIDE_FIELDS = ["方向", "买卖/多空方向", "买卖", "Side"] as const;
+
+type JsonObject = Record;
+
+function object(value: JsonValue, label: string): JsonObject {
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
+ throw new Error(`${label} must be an object`);
+ }
+ return value as JsonObject;
+}
+
+function array(value: JsonValue | undefined, label: string): JsonValue[] {
+ if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
+ return value;
+}
+
+function text(value: JsonValue | undefined, label: string): string {
+ if (typeof value !== "string" || !value.trim()) {
+ throw new Error(`${label} must be a non-empty string`);
+ }
+ return value;
+}
+
+function integer(value: JsonValue | undefined, label: string): number {
+ if (!Number.isSafeInteger(value) || Number(value) < 0) {
+ throw new Error(`${label} must be a non-negative integer`);
+ }
+ return Number(value);
+}
+
+function canonicalJson(value: JsonValue): string {
+ if (value === null || typeof value !== "object") {
+ return JSON.stringify(value);
+ }
+ if (Array.isArray(value)) {
+ return `[${value.map(canonicalJson).join(",")}]`;
+ }
+ const record = value as JsonObject;
+ return `{${Object.keys(record)
+ .sort()
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key]!)}`)
+ .join(",")}}`;
+}
+
+function digest(value: JsonValue): string {
+ return `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`;
+}
+
+function stableId(prefix: string, value: JsonValue): string {
+ return `${prefix}:${digest(value).slice("sha256:".length)}`;
+}
+
+function exactPermission(invocation: RuntimeInvocation): boolean {
+ return (
+ invocation.permissionSnapshot.riskLevel === "R1" &&
+ invocation.permissionSnapshot.domains.length === 0 &&
+ invocation.permissionSnapshot.permissions.length === 1 &&
+ invocation.permissionSnapshot.permissions[0] === PERMISSION
+ );
+}
+
+function executionContext(
+ invocation: RuntimeInvocation
+): OperationalExecutionContext {
+ return {
+ invocationId: invocation.invocationId,
+ identity: invocation.identity,
+ node: invocation.node,
+ idempotencyKey: invocation.idempotencyKey,
+ fencingToken: invocation.fencingToken
+ };
+}
+
+function firstString(
+ fields: JsonObject,
+ candidates: readonly string[]
+): string | undefined {
+ for (const candidate of candidates) {
+ const value = fields[candidate];
+ if (typeof value === "string" && value.trim()) return value.trim();
+ }
+ return undefined;
+}
+
+function eventTimeUtc(
+ original: string | undefined,
+ pageTimeZone: string
+): string | undefined {
+ if (!original) return undefined;
+ try {
+ return Temporal.Instant.from(original).toString();
+ } catch {
+ // Binance zh-CN renders local wall-clock time without an offset.
+ }
+ const match = original.match(
+ /^(\d{4})[-\/]([01]\d)[-\/]([0-3]\d)[ T]([0-2]\d):([0-5]\d):([0-5]\d)$/u
+ );
+ if (!match) return undefined;
+ try {
+ return Temporal.PlainDateTime.from({
+ year: Number(match[1]),
+ month: Number(match[2]),
+ day: Number(match[3]),
+ hour: Number(match[4]),
+ minute: Number(match[5]),
+ second: Number(match[6])
+ }).toZonedDateTime(pageTimeZone).toInstant().toString();
+ } catch {
+ return undefined;
+ }
+}
+
+interface SucceededItem {
+ itemKey: string;
+ output: JsonObject;
+}
+
+function successfulProjectOutputs(value: JsonValue): SucceededItem[] {
+ const outcome = object(value, "projects");
+ const total = integer(outcome.total, "projects.total");
+ const succeeded = object(outcome.succeeded ?? null, "projects.succeeded");
+ const failed = object(outcome.failed ?? null, "projects.failed");
+ const unresolved = object(outcome.unresolved ?? null, "projects.unresolved");
+ const succeededItems = array(succeeded.items, "projects.succeeded.items");
+ const failedItems = array(failed.items, "projects.failed.items");
+ const unresolvedItems = array(unresolved.items, "projects.unresolved.items");
+ if (
+ integer(succeeded.count, "projects.succeeded.count") !== succeededItems.length ||
+ integer(failed.count, "projects.failed.count") !== failedItems.length ||
+ integer(unresolved.count, "projects.unresolved.count") !== unresolvedItems.length ||
+ succeededItems.length + failedItems.length + unresolvedItems.length !== total ||
+ failedItems.length > 0 ||
+ unresolvedItems.length > 0
+ ) {
+ throw new Error("Project collection is not complete");
+ }
+ const result = succeededItems.map((item, index) => {
+ const envelope = object(item, `projects.succeeded.items[${index}]`);
+ return {
+ itemKey: text(envelope.itemKey, "project itemKey"),
+ output: object(envelope.output ?? null, "project output")
+ };
+ });
+ if (new Set(result.map((item) => item.itemKey)).size !== result.length) {
+ throw new Error("Project collection contains duplicate item keys");
+ }
+ return result;
+}
+
+function succeeded(output: JsonValue): RuntimeOutcome {
+ return { status: "succeeded", output, evidence: [], riskSignals: [] };
+}
+
+function rejected(code: string, message: string): RuntimeOutcome {
+ return {
+ status: "rejected",
+ error: { code, message, retryable: false },
+ evidence: [],
+ riskSignals: []
+ };
+}
+
+function failed(code: string, message: string): RuntimeOutcome {
+ return {
+ status: "failed",
+ error: { code, message, retryable: false },
+ evidence: [],
+ riskSignals: []
+ };
+}
+
+export function isBinanceDataNode(id: string, version: string): boolean {
+ return id === NODE_ID && version === NODE_VERSION;
+}
+
+export class BinanceDataRuntimeProvider implements RuntimeProvider {
+ readonly id = "binance-data";
+
+ constructor(
+ readonly store: BinanceCopyTradingStore,
+ readonly now: () => Date = () => new Date()
+ ) {}
+
+ supports(node: ArtifactRef & { readonly kind: "node" }): boolean {
+ return isBinanceDataNode(node.id, node.version);
+ }
+
+ async invoke(
+ invocation: RuntimeInvocation,
+ signal: AbortSignal
+ ): Promise {
+ if (signal.aborted) {
+ return rejected("CANCELLED", "Binance persistence was cancelled before commit.");
+ }
+ if (!this.supports(invocation.node)) {
+ return rejected(
+ "BINANCE_DATA_NODE_UNSUPPORTED",
+ "Binance data Node id and version are not exact."
+ );
+ }
+ if (!exactPermission(invocation)) {
+ return rejected(
+ "BINANCE_DATA_PERMISSION_MISMATCH",
+ "Binance data permission snapshot is not exact."
+ );
+ }
+ try {
+ const input = object(invocation.input, "Binance persist input");
+ const management = object(input.management ?? null, "management");
+ const managementProjects = array(management.projects, "management.projects");
+ const projectOutputs = successfulProjectOutputs(input.projects ?? null);
+ const pageTimeZone = text(input.pageTimeZone, "pageTimeZone");
+ Temporal.Now.zonedDateTimeISO(pageTimeZone);
+ const captureAt = text(management.observedAt, "management.observedAt");
+ Temporal.Instant.from(captureAt);
+ const sourceUrl = text(management.pageUrl, "management.pageUrl");
+ const status = text(management.status, "management.status");
+ if (!new Set(["complete", "empty_confirmed"]).has(status)) {
+ throw new Error("Management status is invalid");
+ }
+ const projectsById = new Map();
+ for (const projectValue of managementProjects) {
+ const project = object(projectValue, "management project");
+ const projectId = text(project.projectId, "management projectId");
+ if (projectsById.has(projectId)) {
+ throw new Error("Management project ids are not unique");
+ }
+ projectsById.set(projectId, project);
+ }
+ if (
+ projectOutputs.length !== projectsById.size ||
+ projectOutputs.some((item) => !projectsById.has(item.itemKey))
+ ) {
+ throw new Error("Project detail coverage does not match management");
+ }
+ const collectionRunId = stableId("binance-collection", {
+ workflowRunId: invocation.identity.runId,
+ idempotencyKey: invocation.idempotencyKey
+ });
+ const sourceCaptures: Parameters<
+ BinanceCopyTradingStore["persistBinanceCopyTradingCapture"]
+ >[0]["sourceCaptures"][number][] = [];
+ sourceCaptures.push({
+ captureId: stableId("binance-capture", {
+ collectionRunId,
+ sourceKind: "management"
+ }),
+ sourceKind: "management",
+ sourceUrl,
+ captureAt,
+ recordCount: managementProjects.length,
+ payloadDigest: digest(management),
+ payload: management
+ });
+ const projectSnapshots: Parameters<
+ BinanceCopyTradingStore["persistBinanceCopyTradingCapture"]
+ >[0]["projects"][number][] = [];
+ const positions: Parameters<
+ BinanceCopyTradingStore["persistBinanceCopyTradingCapture"]
+ >[0]["positions"][number][] = [];
+ for (const [projectId, project] of projectsById) {
+ const projectStatus = text(project.status, "project.status");
+ if (projectStatus !== "ongoing" && projectStatus !== "ended") {
+ throw new Error("Project status is invalid");
+ }
+ projectSnapshots.push({
+ projectId,
+ projectStatus,
+ sourceUrl,
+ capturedAt: captureAt,
+ summary: object(project.summary ?? null, "project.summary")
+ });
+ const currentPositions = array(
+ project.currentPositions,
+ "project.currentPositions"
+ );
+ currentPositions.forEach((positionValue, index) => {
+ const position = object(positionValue, "project position");
+ const fields = object(position.values ?? null, "project position values");
+ positions.push({
+ snapshotId: stableId("binance-position", {
+ collectionRunId,
+ projectId,
+ ordinal: index + 1
+ }),
+ projectId,
+ symbol: firstString(fields, SYMBOL_FIELDS) ?? "unknown",
+ positionSide: firstString(fields, SIDE_FIELDS) ?? "unknown",
+ ordinal: index + 1,
+ capturedAt: captureAt,
+ fields
+ });
+ });
+ }
+ const rawRecords: Parameters<
+ BinanceCopyTradingStore["persistBinanceCopyTradingCapture"]
+ >[0]["rawRecords"][number][] = [];
+ const eventTimes: string[] = [];
+ const duplicateOrdinals = new Map();
+ let projectPageCount = 0;
+ for (const item of projectOutputs) {
+ const output = item.output;
+ if (text(output.projectId, "detail projectId") !== item.itemKey) {
+ throw new Error("Detail project id does not match foreach item key");
+ }
+ const detailCaptureAt = text(output.observedAt, "detail observedAt");
+ Temporal.Instant.from(detailCaptureAt);
+ const detailUrl = text(output.pageUrl, "detail pageUrl");
+ for (const tabValue of array(output.tabs, "detail tabs")) {
+ const tab = object(tabValue, "detail tab");
+ const sourceTab = text(tab.sourceTab, "detail sourceTab");
+ const pageCount = integer(tab.pageCount, "detail pageCount");
+ if (pageCount < 1) throw new Error("Detail pageCount must be positive");
+ const records = array(tab.records, "detail records");
+ const byPage = new Map();
+ for (const recordValue of records) {
+ const record = object(recordValue, "detail record");
+ const page = integer(record.page, "detail record page");
+ const rowOrdinal = integer(
+ record.rowOrdinal,
+ "detail record rowOrdinal"
+ );
+ if (page < 1 || page > pageCount || rowOrdinal < 1) {
+ throw new Error("Detail record pagination identity is invalid");
+ }
+ const fields = object(record.fields ?? null, "detail record fields");
+ const fieldsDigest = digest(fields);
+ const duplicateBase = `${item.itemKey}\u0000${sourceTab}\u0000${fieldsDigest}`;
+ const duplicateOrdinal = (duplicateOrdinals.get(duplicateBase) ?? 0) + 1;
+ duplicateOrdinals.set(duplicateBase, duplicateOrdinal);
+ const currentRecordKey = stableId("binance-current", {
+ projectId: item.itemKey,
+ sourceTab,
+ fieldsDigest,
+ duplicateOrdinal
+ });
+ const originalEventTime = firstString(fields, TIME_FIELDS);
+ const normalizedEventTime = eventTimeUtc(originalEventTime, pageTimeZone);
+ if (normalizedEventTime) eventTimes.push(normalizedEventTime);
+ rawRecords.push({
+ rawRecordId: stableId("binance-raw", {
+ collectionRunId,
+ projectId: item.itemKey,
+ sourceTab,
+ page,
+ rowOrdinal
+ }),
+ currentRecordKey,
+ projectId: item.itemKey,
+ sourceTab,
+ page,
+ rowOrdinal,
+ captureAt: detailCaptureAt,
+ ...(originalEventTime ? { originalEventTime } : {}),
+ ...(normalizedEventTime ? { eventTimeUtc: normalizedEventTime } : {}),
+ pageTimeZoneAssumption: pageTimeZone,
+ fields,
+ fieldsDigest
+ });
+ const pageRecords = byPage.get(page) ?? [];
+ pageRecords.push(record);
+ byPage.set(page, pageRecords);
+ }
+ for (let page = 1; page <= pageCount; page += 1) {
+ const payload: JsonValue = {
+ projectId: item.itemKey,
+ sourceTab,
+ page,
+ records: byPage.get(page) ?? []
+ };
+ sourceCaptures.push({
+ captureId: stableId("binance-capture", {
+ collectionRunId,
+ projectId: item.itemKey,
+ sourceTab,
+ page
+ }),
+ sourceKind: "project_tab",
+ projectId: item.itemKey,
+ sourceTab,
+ page,
+ sourceUrl: detailUrl,
+ captureAt: detailCaptureAt,
+ recordCount: byPage.get(page)?.length ?? 0,
+ payloadDigest: digest(payload),
+ payload
+ });
+ projectPageCount += 1;
+ }
+ }
+ }
+ eventTimes.sort();
+ const contentDigest = digest({
+ management,
+ projects: projectOutputs.map((item) => item.output)
+ });
+ const persisted = this.store.persistBinanceCopyTradingCapture({
+ collectionRunId,
+ workflowRunId: invocation.identity.runId,
+ sourceUrl,
+ attemptAt: this.now().toISOString(),
+ captureAt,
+ status:
+ status === "empty_confirmed"
+ ? "authenticated_but_no_data"
+ : "success",
+ contentDigest,
+ projectCount: projectsById.size,
+ pageCount: 1 + projectPageCount,
+ recordCount: rawRecords.length,
+ ...(eventTimes[0] === undefined
+ ? {}
+ : { oldestEventTimeUtc: eventTimes[0] }),
+ ...(eventTimes.at(-1) === undefined
+ ? {}
+ : { newestEventTimeUtc: eventTimes.at(-1)! }),
+ executionContext: executionContext(invocation),
+ sourceCaptures,
+ projects: projectSnapshots,
+ positions,
+ rawRecords
+ });
+ return succeeded({
+ status: persisted.run.status,
+ collectionRunId: persisted.run.collectionRunId,
+ captureAt: persisted.run.captureAt,
+ lastSuccessAt: persisted.run.lastSuccessAt ?? null,
+ collectedProjectCount: persisted.run.projectCount,
+ pageCount: persisted.run.pageCount,
+ recordCount: persisted.run.recordCount,
+ newRecordCount: persisted.newCurrentRecordCount,
+ oldestEventTimeUtc: persisted.run.oldestEventTimeUtc ?? null,
+ newestEventTimeUtc: persisted.run.newestEventTimeUtc ?? null,
+ duplicate: persisted.status === "duplicate"
+ });
+ } catch (error) {
+ return failed(
+ "BINANCE_CAPTURE_PERSIST_FAILED",
+ error instanceof Error
+ ? `Binance capture was not committed: ${error.message}`
+ : "Binance capture was not committed."
+ );
+ }
+ }
+}
diff --git a/apps/local-core/src/binance-market-runtime-provider.test.ts b/apps/local-core/src/binance-market-runtime-provider.test.ts
new file mode 100644
index 00000000..c879b18d
--- /dev/null
+++ b/apps/local-core/src/binance-market-runtime-provider.test.ts
@@ -0,0 +1,187 @@
+import type {
+ BinanceMarketCaptureRecord,
+ BinanceMarketStore,
+ PersistBinanceMarketCaptureInput
+} from "@bpa/persistence";
+import type { RuntimeInvocation } from "@bpa/node-runtime";
+import { describe, expect, it } from "vitest";
+import { BinanceMarketRuntimeProvider } from "./binance-market-runtime-provider.js";
+
+class MemoryMarketStore implements BinanceMarketStore {
+ calls: PersistBinanceMarketCaptureInput[] = [];
+
+ persistBinanceMarketCapture(input: PersistBinanceMarketCaptureInput) {
+ this.calls.push(structuredClone(input));
+ const capture: BinanceMarketCaptureRecord = {
+ marketCaptureId: input.marketCaptureId,
+ workflowRunId: input.workflowRunId,
+ captureAt: input.captureAt,
+ sourceUrl: input.sourceUrl,
+ symbolCount: input.symbols.length,
+ candleCount: input.candles.length,
+ fundingCount: input.funding.length,
+ referenceCount: input.references.length,
+ createdAt: input.captureAt
+ };
+ return {
+ status: "accepted" as const,
+ capture,
+ insertedCandleCount: input.candles.length,
+ insertedFundingCount: input.funding.length
+ };
+ }
+
+ getBinanceMarketCapture() { return undefined; }
+}
+
+function projects() {
+ return {
+ total: 1,
+ succeeded: {
+ count: 1,
+ items: [
+ {
+ itemKey: "project_1001",
+ output: {
+ projectId: "project_1001",
+ tabs: [
+ {
+ sourceTab: "交易历史",
+ pageCount: 1,
+ records: [
+ {
+ page: 1,
+ rowOrdinal: 1,
+ fields: {
+ 时间: "2026-08-12 12:00:00",
+ 合约: "BTCUSDT 永续"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ]
+ },
+ failed: { count: 0, items: [] },
+ unresolved: { count: 0, items: [] }
+ };
+}
+
+function invocation(): RuntimeInvocation {
+ return {
+ invocationId: "invocation:market",
+ identity: {
+ runId: "run:market",
+ scopePath: [],
+ iterationKey: "root",
+ stepKey: "market",
+ attempt: 1
+ },
+ node: {
+ kind: "node",
+ id: "binance.futures.market-reference.collect",
+ version: "1.0.0",
+ digest: `sha256:${"a".repeat(64)}`
+ },
+ providerId: "binance-market",
+ input: { projects: projects(), pageTimeZone: "Asia/Shanghai" },
+ permissionSnapshot: {
+ riskLevel: "R1",
+ permissions: [
+ "binance.futures.market.read",
+ "binance.futures.market.write"
+ ],
+ domains: ["https://fapi.binance.com"]
+ },
+ deadlineAt: Date.parse("2026-08-12T06:00:00.000Z"),
+ idempotencyKey: "run:market:root:market:1",
+ fencingToken: 1,
+ traceId: "trace:market"
+ };
+}
+
+function response(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "content-type": "application/json" }
+ });
+}
+
+describe("BinanceMarketRuntimeProvider", () => {
+ it("uses only fixed public GET endpoints and persists normalized UTC data", async () => {
+ const store = new MemoryMarketStore();
+ const requests: URL[] = [];
+ const fetcher = async (input: string | URL, init?: RequestInit) => {
+ const url = new URL(String(input));
+ requests.push(url);
+ expect(init?.method).toBe("GET");
+ expect(init?.credentials).toBe("omit");
+ expect(url.origin).toBe("https://fapi.binance.com");
+ if (url.pathname.endsWith("/exchangeInfo")) {
+ return response({
+ symbols: [
+ {
+ symbol: "BTCUSDT",
+ pair: "BTCUSDT",
+ contractType: "PERPETUAL",
+ status: "TRADING",
+ onboardDate: 1569398400000,
+ deliveryDate: 4133404800000,
+ baseAsset: "BTC",
+ quoteAsset: "USDT",
+ marginAsset: "USDT"
+ }
+ ]
+ });
+ }
+ if (url.pathname.endsWith("/klines")) {
+ return response([[1786503600000, "1", "2", "0.5", "1.5", "10", 1786503659999, "15", 7, "4", "6", "0"]]);
+ }
+ if (url.pathname.endsWith("/fundingRate")) {
+ return response([{ symbol: "BTCUSDT", fundingTime: 1786503600000, fundingRate: "0.0001", markPrice: "60000" }]);
+ }
+ if (url.pathname.endsWith("/premiumIndex")) {
+ return response({ symbol: "BTCUSDT", markPrice: "60001", indexPrice: "60000", lastFundingRate: "0.0001", nextFundingTime: 1786532400000 });
+ }
+ if (url.pathname.endsWith("/openInterest")) {
+ return response({ symbol: "BTCUSDT", openInterest: "12345" });
+ }
+ throw new Error(`Unexpected URL ${url.href}`);
+ };
+ const result = await new BinanceMarketRuntimeProvider(
+ store,
+ fetcher,
+ () => new Date("2026-08-12T05:00:00.000Z")
+ ).invoke(invocation(), new AbortController().signal);
+
+ expect(result, JSON.stringify(result)).toMatchObject({ status: "succeeded" });
+ expect(requests.map((url) => url.pathname)).toEqual([
+ "/fapi/v1/exchangeInfo",
+ "/fapi/v1/klines",
+ "/fapi/v1/fundingRate",
+ "/fapi/v1/premiumIndex",
+ "/fapi/v1/openInterest"
+ ]);
+ expect(store.calls[0]).toMatchObject({
+ sourceUrl: "https://fapi.binance.com",
+ candles: [{ symbol: "BTCUSDT", tradeCount: 7 }],
+ funding: [{ symbol: "BTCUSDT", fundingRate: "0.0001" }],
+ references: [{ symbol: "BTCUSDT", openInterest: "12345" }]
+ });
+ });
+
+ it.each([418, 429])("stops and requests backoff on HTTP %s", async (status) => {
+ const store = new MemoryMarketStore();
+ const result = await new BinanceMarketRuntimeProvider(
+ store,
+ async () => response({ code: -1003 }, status)
+ ).invoke(invocation(), new AbortController().signal);
+ expect(result).toMatchObject({
+ status: "failed",
+ error: { code: "BINANCE_MARKET_RATE_LIMITED", retryable: true }
+ });
+ expect(store.calls).toHaveLength(0);
+ });
+});
diff --git a/apps/local-core/src/binance-market-runtime-provider.ts b/apps/local-core/src/binance-market-runtime-provider.ts
new file mode 100644
index 00000000..0b5d6acc
--- /dev/null
+++ b/apps/local-core/src/binance-market-runtime-provider.ts
@@ -0,0 +1,468 @@
+import { createHash } from "node:crypto";
+import { Temporal } from "@js-temporal/polyfill";
+import type {
+ RuntimeInvocation,
+ RuntimeOutcome,
+ RuntimeProvider
+} from "@bpa/node-runtime";
+import type {
+ BinanceMarketStore,
+ OperationalExecutionContext,
+ PersistBinanceMarketCaptureInput
+} from "@bpa/persistence";
+import type { ArtifactRef, JsonValue } from "@bpa/workflow-ir";
+
+const NODE_ID = "binance.futures.market-reference.collect";
+const NODE_VERSION = "1.0.0";
+const PERMISSIONS = [
+ "binance.futures.market.read",
+ "binance.futures.market.write"
+] as const;
+const BASE_URL = "https://fapi.binance.com";
+const MAX_REQUESTS = 20_000;
+const TIME_FIELDS = ["时间", "成交时间", "资金费时间", "Time"] as const;
+const SYMBOL_FIELDS = ["合约", "交易对", "Symbol"] as const;
+
+type JsonObject = Record;
+type Fetcher = (input: string | URL, init?: RequestInit) => Promise;
+
+class BinanceMarketError extends Error {
+ constructor(
+ readonly code: string,
+ message: string,
+ readonly retryable: boolean
+ ) {
+ super(message);
+ }
+}
+
+function object(value: JsonValue, label: string): JsonObject {
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
+ throw new Error(`${label} must be an object`);
+ }
+ return value as JsonObject;
+}
+
+function array(value: JsonValue | undefined, label: string): JsonValue[] {
+ if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
+ return value;
+}
+
+function text(value: JsonValue | undefined, label: string): string {
+ if (typeof value !== "string" || !value.trim()) {
+ throw new Error(`${label} must be a non-empty string`);
+ }
+ return value.trim();
+}
+
+function integer(value: JsonValue | undefined, label: string): number {
+ if (!Number.isSafeInteger(value) || Number(value) < 0) {
+ throw new Error(`${label} must be a non-negative integer`);
+ }
+ return Number(value);
+}
+
+function canonicalJson(value: JsonValue): string {
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
+ const record = value as JsonObject;
+ return `{${Object.keys(record).sort().map((key) =>
+ `${JSON.stringify(key)}:${canonicalJson(record[key]!)}`
+ ).join(",")}}`;
+}
+
+function digest(value: JsonValue): string {
+ return `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`;
+}
+
+function stableId(prefix: string, value: JsonValue): string {
+ return `${prefix}:${digest(value).slice("sha256:".length)}`;
+}
+
+function instant(milliseconds: number): string {
+ if (!Number.isSafeInteger(milliseconds) || milliseconds < 0) {
+ throw new Error("Binance timestamp is invalid");
+ }
+ return new Date(milliseconds).toISOString();
+}
+
+function wallClockInstant(value: string, timeZone: string): string | undefined {
+ try {
+ return Temporal.Instant.from(value).toString();
+ } catch {
+ // The authenticated zh-CN page uses local wall-clock strings.
+ }
+ const match = value.match(
+ /^(\d{4})[-\/]([01]\d)[-\/]([0-3]\d)[ T]([0-2]\d):([0-5]\d):([0-5]\d)$/u
+ );
+ if (!match) return undefined;
+ try {
+ return Temporal.PlainDateTime.from({
+ year: Number(match[1]),
+ month: Number(match[2]),
+ day: Number(match[3]),
+ hour: Number(match[4]),
+ minute: Number(match[5]),
+ second: Number(match[6])
+ }).toZonedDateTime(timeZone).toInstant().toString();
+ } catch {
+ return undefined;
+ }
+}
+
+function firstString(fields: JsonObject, names: readonly string[]): string | undefined {
+ for (const name of names) {
+ const value = fields[name];
+ if (typeof value === "string" && value.trim()) return value.trim();
+ }
+ return undefined;
+}
+
+function normalizeSymbol(value: string): string | undefined {
+ const compact = value.toUpperCase().replace(/[^A-Z0-9]/gu, " ");
+ const candidates = compact.split(/\s+/u).filter(Boolean);
+ return candidates.find((candidate) =>
+ /^[A-Z0-9]{5,30}$/u.test(candidate) &&
+ /(USDT|USDC|BUSD)$/u.test(candidate)
+ );
+}
+
+function successfulOutputs(value: JsonValue): JsonObject[] {
+ const outcome = object(value, "projects");
+ const total = integer(outcome.total, "projects.total");
+ const succeeded = object(outcome.succeeded ?? null, "projects.succeeded");
+ const failed = object(outcome.failed ?? null, "projects.failed");
+ const unresolved = object(outcome.unresolved ?? null, "projects.unresolved");
+ const outputs = array(succeeded.items, "projects.succeeded.items");
+ if (
+ integer(succeeded.count, "projects.succeeded.count") !== outputs.length ||
+ integer(failed.count, "projects.failed.count") !== 0 ||
+ integer(unresolved.count, "projects.unresolved.count") !== 0 ||
+ outputs.length !== total
+ ) {
+ throw new Error("Market collection requires complete project coverage");
+ }
+ return outputs.map((item) =>
+ object(object(item, "project item").output ?? null, "project output")
+ );
+}
+
+function projectReferences(
+ projects: JsonValue,
+ pageTimeZone: string,
+ fallbackEnd: number
+): { symbols: string[]; startTime: number; endTime: number } {
+ const symbols = new Set();
+ const eventTimes: number[] = [];
+ for (const output of successfulOutputs(projects)) {
+ for (const tabValue of array(output.tabs, "detail tabs")) {
+ const tab = object(tabValue, "detail tab");
+ for (const recordValue of array(tab.records, "detail records")) {
+ const fields = object(object(recordValue, "detail record").fields ?? null, "fields");
+ const rawSymbol = firstString(fields, SYMBOL_FIELDS);
+ const symbol = rawSymbol ? normalizeSymbol(rawSymbol) : undefined;
+ if (symbol) symbols.add(symbol);
+ const rawTime = firstString(fields, TIME_FIELDS);
+ const normalized = rawTime ? wallClockInstant(rawTime, pageTimeZone) : undefined;
+ if (normalized) eventTimes.push(Date.parse(normalized));
+ }
+ }
+ }
+ const buffer = 60 * 60 * 1000;
+ return {
+ symbols: [...symbols].sort(),
+ startTime: Math.max(0, (eventTimes.length ? Math.min(...eventTimes) : fallbackEnd - 2 * buffer) - buffer),
+ endTime: (eventTimes.length ? Math.max(...eventTimes) : fallbackEnd) + buffer
+ };
+}
+
+function executionContext(invocation: RuntimeInvocation): OperationalExecutionContext {
+ return {
+ invocationId: invocation.invocationId,
+ identity: invocation.identity,
+ node: invocation.node,
+ idempotencyKey: invocation.idempotencyKey,
+ fencingToken: invocation.fencingToken
+ };
+}
+
+function succeeded(output: JsonValue): RuntimeOutcome {
+ return { status: "succeeded", output, evidence: [], riskSignals: [] };
+}
+
+function failure(error: unknown): RuntimeOutcome {
+ const known = error instanceof BinanceMarketError ? error : undefined;
+ return {
+ status: "failed",
+ error: {
+ code: known?.code ?? "BINANCE_MARKET_COLLECTION_FAILED",
+ message: known?.message ??
+ (error instanceof Error
+ ? `Binance market collection stopped: ${error.message}`
+ : "Binance market collection stopped."),
+ retryable: known?.retryable ?? false
+ },
+ evidence: [],
+ riskSignals: []
+ };
+}
+
+export function isBinanceMarketNode(id: string, version: string): boolean {
+ return id === NODE_ID && version === NODE_VERSION;
+}
+
+export class BinanceMarketRuntimeProvider implements RuntimeProvider {
+ readonly id = "binance-market";
+
+ constructor(
+ readonly store: BinanceMarketStore,
+ readonly fetcher: Fetcher = fetch,
+ readonly now: () => Date = () => new Date()
+ ) {}
+
+ supports(node: ArtifactRef & { readonly kind: "node" }): boolean {
+ return isBinanceMarketNode(node.id, node.version);
+ }
+
+ async #json(
+ path: string,
+ params: Record,
+ signal: AbortSignal,
+ requestBudget: { count: number }
+ ): Promise {
+ if (requestBudget.count >= MAX_REQUESTS) {
+ throw new BinanceMarketError(
+ "BINANCE_MARKET_REQUEST_LIMIT_EXCEEDED",
+ "Binance market request safety limit was reached.",
+ false
+ );
+ }
+ const url = new URL(path, BASE_URL);
+ if (url.origin !== BASE_URL || !url.pathname.startsWith("/fapi/v1/")) {
+ throw new Error("Binance market URL is outside the allowlist");
+ }
+ for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
+ requestBudget.count += 1;
+ let response: Response;
+ try {
+ response = await this.fetcher(url, {
+ method: "GET",
+ credentials: "omit",
+ redirect: "error",
+ headers: { accept: "application/json" },
+ signal: AbortSignal.any([signal, AbortSignal.timeout(15_000)])
+ });
+ } catch (error) {
+ if (signal.aborted) throw new BinanceMarketError("CANCELLED", "Binance market collection was cancelled.", false);
+ throw new BinanceMarketError(
+ "BINANCE_MARKET_NETWORK_FAILURE",
+ `Binance public market request failed: ${error instanceof Error ? error.name : "network error"}`,
+ true
+ );
+ }
+ if (response.status === 418 || response.status === 429) {
+ throw new BinanceMarketError(
+ "BINANCE_MARKET_RATE_LIMITED",
+ `Binance public market API returned HTTP ${response.status}; backoff is required.`,
+ true
+ );
+ }
+ if (!response.ok) {
+ throw new BinanceMarketError(
+ "BINANCE_MARKET_HTTP_FAILURE",
+ `Binance public market API returned HTTP ${response.status}.`,
+ response.status >= 500
+ );
+ }
+ try {
+ return await response.json() as JsonValue;
+ } catch {
+ throw new BinanceMarketError(
+ "BINANCE_MARKET_STRUCTURE_CHANGED",
+ "Binance public market API did not return valid JSON.",
+ false
+ );
+ }
+ }
+
+ async invoke(invocation: RuntimeInvocation, signal: AbortSignal): Promise {
+ if (!this.supports(invocation.node)) {
+ return failure(new BinanceMarketError("BINANCE_MARKET_NODE_UNSUPPORTED", "Binance market Node id and version are not exact.", false));
+ }
+ if (
+ invocation.permissionSnapshot.riskLevel !== "R1" ||
+ invocation.permissionSnapshot.domains.length !== 1 ||
+ invocation.permissionSnapshot.domains[0] !== BASE_URL ||
+ invocation.permissionSnapshot.permissions.length !== PERMISSIONS.length ||
+ PERMISSIONS.some((permission, index) =>
+ invocation.permissionSnapshot.permissions[index] !== permission
+ )
+ ) {
+ return failure(new BinanceMarketError("BINANCE_MARKET_PERMISSION_MISMATCH", "Binance market permission snapshot is not exact.", false));
+ }
+ const requestBudget = { count: 0 };
+ try {
+ const input = object(invocation.input, "market input");
+ const pageTimeZone = text(input.pageTimeZone, "pageTimeZone");
+ Temporal.Now.zonedDateTimeISO(pageTimeZone);
+ const captureAt = this.now().toISOString();
+ const range = projectReferences(input.projects ?? null, pageTimeZone, Date.parse(captureAt));
+ const exchangeInfo = object(await this.#json("/fapi/v1/exchangeInfo", {}, signal, requestBudget), "exchangeInfo");
+ const exchangeSymbols = array(exchangeInfo.symbols, "exchangeInfo.symbols");
+ const knownSymbols = new Map();
+ for (const symbolValue of exchangeSymbols) {
+ const symbol = object(symbolValue, "exchange symbol");
+ knownSymbols.set(text(symbol.symbol, "exchange symbol.symbol"), symbol);
+ }
+ const missing = range.symbols.filter((symbol) => !knownSymbols.has(symbol));
+ if (missing.length > 0) {
+ throw new BinanceMarketError(
+ "BINANCE_MARKET_SYMBOL_MISSING",
+ `Binance exchangeInfo does not contain referenced symbols: ${missing.join(",")}`,
+ false
+ );
+ }
+ const rawCandles: JsonValue[] = [];
+ const rawFunding: JsonValue[] = [];
+ const rawReferences: JsonValue[] = [];
+ const candles: PersistBinanceMarketCaptureInput["candles"][number][] = [];
+ const funding: PersistBinanceMarketCaptureInput["funding"][number][] = [];
+ const references: PersistBinanceMarketCaptureInput["references"][number][] = [];
+ for (const symbol of range.symbols) {
+ let cursor = range.startTime;
+ while (cursor <= range.endTime) {
+ const payload = array(await this.#json("/fapi/v1/klines", {
+ symbol,
+ interval: "1m",
+ startTime: String(cursor),
+ endTime: String(range.endTime),
+ limit: "1500"
+ }, signal, requestBudget), "klines");
+ rawCandles.push({ symbol, rows: payload });
+ if (payload.length === 0) break;
+ for (const rowValue of payload) {
+ const row = array(rowValue, "kline row");
+ if (row.length < 11) throw new Error("Kline row structure changed");
+ candles.push({
+ symbol,
+ openTimeUtc: instant(integer(row[0], "kline open time")),
+ closeTimeUtc: instant(integer(row[6], "kline close time")),
+ open: text(row[1], "kline open"),
+ high: text(row[2], "kline high"),
+ low: text(row[3], "kline low"),
+ close: text(row[4], "kline close"),
+ volume: text(row[5], "kline volume"),
+ quoteVolume: text(row[7], "kline quote volume"),
+ tradeCount: integer(row[8], "kline trade count")
+ });
+ }
+ const last = array(payload.at(-1), "last kline");
+ const next = integer(last[0], "last kline open time") + 60_000;
+ if (next <= cursor) throw new Error("Kline pagination did not advance");
+ cursor = next;
+ if (payload.length < 1500) break;
+ }
+ cursor = range.startTime;
+ while (cursor <= range.endTime) {
+ const payload = array(await this.#json("/fapi/v1/fundingRate", {
+ symbol,
+ startTime: String(cursor),
+ endTime: String(range.endTime),
+ limit: "1000"
+ }, signal, requestBudget), "funding rates");
+ rawFunding.push({ symbol, rows: payload });
+ if (payload.length === 0) break;
+ for (const itemValue of payload) {
+ const item = object(itemValue, "funding rate");
+ funding.push({
+ symbol: text(item.symbol, "funding symbol"),
+ fundingTimeUtc: instant(integer(item.fundingTime, "funding time")),
+ fundingRate: text(item.fundingRate, "funding rate"),
+ ...(typeof item.markPrice === "string" ? { markPrice: item.markPrice } : {})
+ });
+ }
+ const last = object(payload.at(-1)!, "last funding rate");
+ const next = integer(last.fundingTime, "last funding time") + 1;
+ if (next <= cursor) throw new Error("Funding pagination did not advance");
+ cursor = next;
+ if (payload.length < 1000) break;
+ }
+ const premium = object(await this.#json("/fapi/v1/premiumIndex", { symbol }, signal, requestBudget), "premium index");
+ const openInterest = object(await this.#json("/fapi/v1/openInterest", { symbol }, signal, requestBudget), "open interest");
+ rawReferences.push({ symbol, premium, openInterest });
+ references.push({
+ symbol,
+ markPrice: text(premium.markPrice, "markPrice"),
+ indexPrice: text(premium.indexPrice, "indexPrice"),
+ lastFundingRate: text(premium.lastFundingRate, "lastFundingRate"),
+ ...(typeof premium.nextFundingTime === "number" && premium.nextFundingTime > 0
+ ? { nextFundingTimeUtc: instant(premium.nextFundingTime) }
+ : {}),
+ ...(typeof openInterest.openInterest === "string"
+ ? { openInterest: openInterest.openInterest }
+ : {}),
+ observedAt: captureAt
+ });
+ }
+ const symbolSnapshots = exchangeSymbols.map((symbolValue) => {
+ const symbol = object(symbolValue, "exchange symbol");
+ return {
+ symbol: text(symbol.symbol, "symbol"),
+ pair: text(symbol.pair, "pair"),
+ contractType: text(symbol.contractType, "contractType"),
+ status: text(symbol.status, "status"),
+ ...(typeof symbol.onboardDate === "number"
+ ? { onboardDateUtc: instant(symbol.onboardDate) }
+ : {}),
+ ...(typeof symbol.deliveryDate === "number"
+ ? { deliveryDateUtc: instant(symbol.deliveryDate) }
+ : {}),
+ baseAsset: text(symbol.baseAsset, "baseAsset"),
+ quoteAsset: text(symbol.quoteAsset, "quoteAsset"),
+ marginAsset: text(symbol.marginAsset, "marginAsset")
+ };
+ });
+ const candlesPayload: JsonValue = { klines: rawCandles, funding: rawFunding };
+ const referencesPayload: JsonValue = rawReferences;
+ const marketCaptureId = stableId("binance-market", {
+ workflowRunId: invocation.identity.runId,
+ idempotencyKey: invocation.idempotencyKey
+ });
+ const persisted = this.store.persistBinanceMarketCapture({
+ marketCaptureId,
+ workflowRunId: invocation.identity.runId,
+ captureAt,
+ sourceUrl: BASE_URL,
+ symbolsPayload: exchangeInfo,
+ symbolsDigest: digest(exchangeInfo),
+ candlesPayload,
+ candlesDigest: digest(candlesPayload),
+ referencesPayload,
+ referencesDigest: digest(referencesPayload),
+ symbols: symbolSnapshots,
+ candles,
+ funding,
+ references,
+ executionContext: executionContext(invocation)
+ });
+ return succeeded({
+ status: "success",
+ marketCaptureId,
+ captureAt,
+ referencedSymbolCount: range.symbols.length,
+ symbolMetadataCount: persisted.capture.symbolCount,
+ candleCount: persisted.capture.candleCount,
+ insertedCandleCount: persisted.insertedCandleCount,
+ fundingCount: persisted.capture.fundingCount,
+ insertedFundingCount: persisted.insertedFundingCount,
+ referenceCount: persisted.capture.referenceCount,
+ requestCount: requestBudget.count,
+ windowStartUtc: instant(range.startTime),
+ windowEndUtc: instant(range.endTime),
+ duplicate: persisted.status === "duplicate"
+ });
+ } catch (error) {
+ return failure(error);
+ }
+ }
+}
diff --git a/apps/local-core/src/browser-gateway.test.ts b/apps/local-core/src/browser-gateway.test.ts
index 075113bd..571c743f 100644
--- a/apps/local-core/src/browser-gateway.test.ts
+++ b/apps/local-core/src/browser-gateway.test.ts
@@ -787,6 +787,48 @@ describe("local browser gateway", () => {
fencing_token: 1
}
});
+ const deliveredDispatchCount = outgoing.filter(
+ (message) => message.type === "command.dispatch"
+ ).length;
+ persistence.upsertBrowserPageObservation({
+ sessionId,
+ browserInstanceId: "browser-test",
+ tabId: 42,
+ windowId: 7,
+ origin: "https://fxg.jinritemai.com",
+ pathname: "/ffa/g/list",
+ contentScriptReady: true,
+ authentication: "authenticated",
+ authenticationContextRef: "auth-context-changed-during-command",
+ observationState: "ready",
+ pageEpoch: "tab-42:2:during-command",
+ observerCapabilityId: "doudian.page",
+ revision: 2,
+ observedAt: new Date().toISOString()
+ });
+ expect(gateway.dispatchPending()).toBe(0);
+ expect(
+ persistence.getGatewayCommand(String(command.payload.command_id))?.state
+ ).toBe("accepted");
+ expect(
+ outgoing.filter((message) => message.type === "command.dispatch")
+ ).toHaveLength(deliveredDispatchCount);
+ persistence.upsertBrowserPageObservation({
+ sessionId,
+ browserInstanceId: "browser-test",
+ tabId: 42,
+ windowId: 7,
+ origin: "https://fxg.jinritemai.com",
+ pathname: "/ffa/g/list",
+ contentScriptReady: true,
+ authentication: "authenticated",
+ authenticationContextRef: "auth-context-gateway-test",
+ observationState: "ready",
+ pageEpoch: "tab-42:1:gateway-test",
+ observerCapabilityId: "doudian.page",
+ revision: 3,
+ observedAt: new Date().toISOString()
+ });
const evidenceBody = Buffer.from(
JSON.stringify({
schema: "bpa.browser-evidence/1",
diff --git a/apps/local-core/src/browser-gateway.ts b/apps/local-core/src/browser-gateway.ts
index 6c7c935f..0dedcb91 100644
--- a/apps/local-core/src/browser-gateway.ts
+++ b/apps/local-core/src/browser-gateway.ts
@@ -818,6 +818,7 @@ export class LocalBrowserGateway implements RuntimeProvider {
}
}
for (const command of pending) {
+ if (command.state !== "queued") continue;
if (this.#runIsCancelled(command)) continue;
const tabKey = this.#commandTabKey(command);
const occupyingCommand = tabKey
diff --git a/apps/local-core/src/control.test.ts b/apps/local-core/src/control.test.ts
index 14c87e0b..95c838c9 100644
--- a/apps/local-core/src/control.test.ts
+++ b/apps/local-core/src/control.test.ts
@@ -682,7 +682,7 @@ describe("local control socket", () => {
sendControlRequest(socketPath, "doctor")
).resolves.toMatchObject({
status: "ok",
- persistence: { adapter: "sqlite", schemaVersion: 25 }
+ persistence: { adapter: "sqlite", schemaVersion: 26 }
});
});
diff --git a/apps/local-core/src/control.ts b/apps/local-core/src/control.ts
index 86113960..c15dcfd5 100644
--- a/apps/local-core/src/control.ts
+++ b/apps/local-core/src/control.ts
@@ -98,6 +98,14 @@ import {
isInventoryDataNode
} from "./inventory-data-runtime-provider.js";
import { AllianceRetiredDataRuntimeProvider } from "./alliance-retired-data-runtime-provider.js";
+import {
+ BinanceDataRuntimeProvider,
+ isBinanceDataNode
+} from "./binance-data-runtime-provider.js";
+import {
+ BinanceMarketRuntimeProvider,
+ isBinanceMarketNode
+} from "./binance-market-runtime-provider.js";
import { PACKAGING_DATASET_PROFILE } from "@bpa/packaging-dataset";
import {
TEAM_WORKER_CODE_DIGEST,
@@ -355,6 +363,12 @@ export class LocalCoreService {
if (!providers.list().includes("alliance-retired-data")) {
providers.register(new AllianceRetiredDataRuntimeProvider(persistence));
}
+ if (!providers.list().includes("binance-data")) {
+ providers.register(new BinanceDataRuntimeProvider(persistence));
+ }
+ if (!providers.list().includes("binance-market")) {
+ providers.register(new BinanceMarketRuntimeProvider(persistence));
+ }
if (
inventoryServiceClient &&
!providers.list().includes("inventory-data")
@@ -2309,7 +2323,11 @@ export class LocalCoreService {
? "experience-data"
: isAllianceRetiredDataNode(id, version)
? "alliance-retired-data"
- : isEcommerceEvidenceNode(id, version)
+ : isBinanceDataNode(id, version)
+ ? "binance-data"
+ : isBinanceMarketNode(id, version)
+ ? "binance-market"
+ : isEcommerceEvidenceNode(id, version)
? "ecommerce-evidence"
: isInventoryDataNode(id, version)
? "inventory-data"
diff --git a/apps/local-core/src/doudian-inventory-trigger-spec.test.ts b/apps/local-core/src/doudian-inventory-trigger-spec.test.ts
new file mode 100644
index 00000000..d1d78376
--- /dev/null
+++ b/apps/local-core/src/doudian-inventory-trigger-spec.test.ts
@@ -0,0 +1,63 @@
+import { readFileSync } from "node:fs";
+import { parse } from "yaml";
+import { describe, expect, it } from "vitest";
+import { validateTriggerSpec } from "@bpa/schemas";
+
+const inventoryTemplatePath = new URL(
+ "../../../config/triggers/doudian-inventory-production-cycle-interval.trigger.yaml",
+ import.meta.url
+);
+const retiredTemplatePath = new URL(
+ "../../../config/triggers/doudian-alliance-retired-products-daily.trigger.yaml",
+ import.meta.url
+);
+
+describe("Doudian inventory and retired-products background scheduling", () => {
+ it("keeps both templates disabled and serialized by one browser and account lease", () => {
+ const inventory = parse(
+ readFileSync(inventoryTemplatePath, "utf8")
+ ) as Record;
+ const retired = parse(
+ readFileSync(retiredTemplatePath, "utf8")
+ ) as Record;
+ expect(
+ validateTriggerSpec(inventory),
+ JSON.stringify(validateTriggerSpec.errors)
+ ).toBe(true);
+ expect(
+ validateTriggerSpec(retired),
+ JSON.stringify(validateTriggerSpec.errors)
+ ).toBe(true);
+ expect(inventory).toMatchObject({
+ enabled: false,
+ concurrencyKey: "doudian-account:company-main",
+ retryPolicy: "none",
+ missedRunPolicy: "skip",
+ externalDomainLease: {
+ providerId: "inventory-postgres",
+ resourceId: "inventory-production-cycle",
+ ttlSeconds: 300
+ },
+ schedule: {
+ type: "interval",
+ intervalSeconds: 1800,
+ onTimeWindowSeconds: 300
+ }
+ });
+ expect(retired).toMatchObject({
+ enabled: false,
+ concurrencyKey: "doudian-account:company-main",
+ retryPolicy: "none",
+ missedRunPolicy: "run_once",
+ schedule: { type: "daily", localTime: "15:00" }
+ });
+ expect(String(inventory.browserInstanceId)).toMatch(
+ /^deployment-placeholder:/u
+ );
+ expect(String(retired.browserInstanceId)).toMatch(
+ /^deployment-placeholder:/u
+ );
+ expect(inventory.concurrencyKey).toBe(retired.concurrencyKey);
+ expect(inventory.browserInstanceId).toBe(retired.browserInstanceId);
+ });
+});
diff --git a/config/triggers/doudian-alliance-retired-products-daily.trigger.yaml b/config/triggers/doudian-alliance-retired-products-daily.trigger.yaml
index 07ff1bee..fae3f6c6 100644
--- a/config/triggers/doudian-alliance-retired-products-daily.trigger.yaml
+++ b/config/triggers/doudian-alliance-retired-products-daily.trigger.yaml
@@ -16,7 +16,10 @@ idempotencyPolicy: occurrence
retryPolicy: none
missedRunPolicy: run_once
# Deployment gate: keep disabled until real Mac browserInstanceId replacement
-# and measured experience-score plus retired-products p95 prove 15:00 is safe.
+# and measured inventory-cycle plus retired-products p95 prove 15:00 is safe.
+# Both workflows must bind the same browserInstanceId and concurrencyKey. The
+# Trigger Runtime then defers the later occurrence instead of allowing two
+# controllers to operate the same authenticated Chrome profile.
schedule:
type: daily
timezone: Asia/Shanghai
diff --git a/config/triggers/doudian-inventory-production-cycle-interval.trigger.yaml b/config/triggers/doudian-inventory-production-cycle-interval.trigger.yaml
new file mode 100644
index 00000000..90ab0343
--- /dev/null
+++ b/config/triggers/doudian-inventory-production-cycle-interval.trigger.yaml
@@ -0,0 +1,43 @@
+apiVersion: bpa.trigger/v1alpha2
+id: doudian-inventory-production-cycle-interval
+version: 1.0.0
+appId: inventory-monitor
+kind: schedule
+workflow:
+ id: doudian.inventory.production-cycle
+ version: 1.0.0
+enabled: false
+inputSchemaVersion: doudian-inventory-production-cycle-interval/1
+input:
+ expectedShopCount: 13
+ shops:
+ - { id: "10001", name: deployment-placeholder-shop-01 }
+ - { id: "10002", name: deployment-placeholder-shop-02 }
+ - { id: "10003", name: deployment-placeholder-shop-03 }
+ - { id: "10004", name: deployment-placeholder-shop-04 }
+ - { id: "10005", name: deployment-placeholder-shop-05 }
+ - { id: "10006", name: deployment-placeholder-shop-06 }
+ - { id: "10007", name: deployment-placeholder-shop-07 }
+ - { id: "10008", name: deployment-placeholder-shop-08 }
+ - { id: "10009", name: deployment-placeholder-shop-09 }
+ - { id: "10010", name: deployment-placeholder-shop-10 }
+ - { id: "10011", name: deployment-placeholder-shop-11 }
+ - { id: "10012", name: deployment-placeholder-shop-12 }
+ - { id: "10013", name: deployment-placeholder-shop-13 }
+concurrencyKey: doudian-account:company-main
+browserInstanceId: deployment-placeholder:replace-with-company-mac-browser-instance
+externalDomainLease:
+ providerId: inventory-postgres
+ resourceId: inventory-production-cycle
+ ttlSeconds: 300
+idempotencyPolicy: occurrence
+retryPolicy: none
+missedRunPolicy: skip
+# Phase-one background cadence. Keep disabled until the placeholders are
+# replaced from the private 13-shop runtime configuration and the legacy
+# production-cycle launchd entry is stopped in the same maintenance window.
+schedule:
+ type: interval
+ anchorAt: "2026-01-01T00:00:00Z"
+ intervalSeconds: 1800
+ onTimeWindowSeconds: 300
diff --git a/docs/catalog.json b/docs/catalog.json
index c1f2a0d3..b7621582 100644
--- a/docs/catalog.json
+++ b/docs/catalog.json
@@ -291,6 +291,17 @@
"since": "0.7.0",
"public": false
},
+ {
+ "id": "repo.plan.binance-copy-trading-data-collection-requirements-0.1",
+ "source": "docs/plans/binance-copy-trading-data-collection-requirements-v0.1.md",
+ "title": "Binance 数据采集工作流需求基线 v0.1",
+ "summary": "登记攀升项目的 Binance 合约跟单只读浏览器采集范围、数据合同、安全停止线和待确认项。",
+ "authority": "plan",
+ "implementation": "planned",
+ "audience": ["operator", "developer", "integrator", "ai"],
+ "since": "0.7.0",
+ "public": false
+ },
{
"id": "repo.research.priority-items",
"source": "docs/research/重点项检查插件抽象复盘-v0.1.md",
diff --git a/docs/plans/binance-copy-trading-data-collection-requirements-v0.1.md b/docs/plans/binance-copy-trading-data-collection-requirements-v0.1.md
new file mode 100644
index 00000000..29234532
--- /dev/null
+++ b/docs/plans/binance-copy-trading-data-collection-requirements-v0.1.md
@@ -0,0 +1,287 @@
+# Binance 数据采集工作流需求基线 v0.1
+
+> 需求编号:`REQ-WF-2026-08-10-001`。
+> 来源项目:攀升。
+> 优先级:高。
+> 基线更新时间:2026-08-12。
+> 当前状态:开发中;采集与持久化代码已通过本地门禁,尚未部署或完成真实页面全分页 E2E。
+> 运行边界:用户本机、已登录 Binance 浏览器会话、正式只读 API、只读采集。
+> 验收边界:本地代码验证、真实浏览器 E2E、调度验收和长期稳定性证据分别报告。
+
+## 1. 总目标
+
+为攀升项目建立本机自动、只读、可增量的 Binance 数据采集链路。采集结果用于后续成本
+核算、跟单评价、仓位及交易行为研究、E01、E03 等研究模块。BPA 只负责采集、规范化、
+状态和数据交付,不在工作流中生成交易建议、经济结论或自动下单。
+
+用户不接受长期手工导出 CSV。已有 CSV 只用于一次性验证口径,不建立 CSV 兼容层、迁移
+层或正式运行回退路径。
+
+## 2. 数据源优先级
+
+1. Binance 正式公开 API 或账户只读 API 能覆盖的数据使用 API。
+2. Copy Trading follower 数据没有正式可用 API,使用本机已登录浏览器的结构化页面。
+3. 普通 Futures API 的空结果不得解释为无跟单数据。
+4. 若以后确认存在稳定、只读且可合法复用的内部接口,可以保持同一数据合同替换 DOM
+ 读取实现;当前不依赖未经验证的私有接口。
+5. 禁止截图 OCR 作为正式数据源,禁止要求用户手工导出 CSV。
+
+API 凭证环境名为 `BINANCE_API_KEY`、`BINANCE_SECRET_KEY`。不得输出、记录、复制或写入
+采集结果;不修改 API 权限或 IP 白名单。
+
+## 3. 已实测页面能力
+
+页面:
+
+需求方已在真实登录态下验证结构化 DOM 或可访问性树可读取以下内容,不需要 OCR:
+
+2026-08-12 再次只读核验确认:登录页可见真实合约跟单,进行中 3 个、已结束 4 个、模拟
+跟单 7 个;项目详情没有独立链接,而是每张项目卡片内的“展开详情”控制。实现已据此改为
+按 `project_id` 精确定位卡片、只读展开、采集、收起并恢复原列表页签,不再申请标签页导航权限。
+
+- 账户及项目列表:合约跟单类型,进行中、已结束、模拟跟单,项目数量,全部保证金余额、
+ 钱包余额、已实现总盈亏、净利润,以及项目显示名、项目 ID、跟单时间、状态、模式和
+ 是否私人。
+- 项目汇总:净跟单金额、保证金余额、已实现盈亏、未实现盈亏、累计总分润、净利润、
+ 分润比例、仓位止损及暂停或结束状态。
+- 当前仓位:合约、方向、永续类型、杠杆、未实现盈亏、收益率、仓位大小、保证金、
+ 保证金模式和比率、开仓价、标记价、强平价。
+- 详情页签:仓位、仓位历史记录、历史委托、交易历史、分润记录、转账记录、资金费用、
+ 跟单失败订单。
+- 交易历史:总交易手续费、时间、合约、买卖或多空方向、价格、数量、手续费、吃单或
+ 挂单角色、已实现盈亏和全部分页。实测一个进行中项目有 24 页。
+- 资金费用:总资金费用、时间、类型、数量、资产种类、合约和全部分页。实测一个项目
+ 有 2 页。
+- 分润记录:时间、分润前总盈亏、分润金额和全部分页。
+
+页面提示交易历史可能延迟几分钟。采集状态必须区分页面尚未更新与真正空数据。
+
+## 4. 正式采集范围
+
+### 4.1 Copy Trading follower 页面
+
+- 默认采集真实合约跟单的进行中和已结束项目。
+- 模拟跟单默认排除;以后若启用必须标记 `simulated`,不得与真实记录混合。
+- 每轮先采账户汇总和完整项目列表,再按 `project_id` 展开项目。
+- 采集项目汇总、当前仓位和八个详情页签的全部分页,不能只采当前可见前 10 条。
+- 页面收起或展开状态不得导致漏采。
+- 进行中项目增量采集;项目转为已结束时执行一次全量回扫。
+- 已结束项目完整归档,并低频复核延迟到账的资金费、分润或历史记录。
+
+### 4.2 Binance 正式 API 行情与参考数据
+
+- 合约交易对元数据及上下线状态。
+- 1 分钟 K 线,至少包含 OHLCV、开收盘时间、成交额和成交量。
+- 标记价格、指数价格、资金费率及资金费时间。
+- 正式 API 可得时采集未平仓量及相关合约市场字段。
+- 市场数据覆盖跟单成交前后窗口,用于下游研究价格路径、滑点、领先滞后和持仓结果。
+- API 数据与网页 follower 数据分来源保存,不假定普通 Futures 账户记录等于跟单记录。
+
+## 5. 最小数据模型
+
+### 5.1 运行与来源
+
+`binance_collection_run`
+
+- `run_id`、`attempt_at`、`capture_at`、`last_success_at`,均为 UTC。
+- `status`、`failed_stage`、非敏感 `error_code` 和 `error_summary`。
+- `collected_project_count`、`page_count`、`record_count`。
+- `oldest_event_at`、`newest_event_at`、`source_delay_seconds`。
+
+`binance_source_capture`
+
+- `capture_id`、`run_id`、`source_kind`,取 `copy_trading_page` 或 `binance_api`。
+- `source_url` 或公开 API 路由标识、`capture_at`、页面或 API 原始时间。
+- `project_id`、`source_tab`、`page_number` 或游标、结构版本、内容摘要。
+
+### 5.2 项目、仓位和明细
+
+`binance_copy_project_snapshot`
+
+- `project_id`、稳定伪名、真实或模拟标记、状态、模式、私人标记、页面原始字段。
+- 保证金、钱包、已实现及未实现盈亏、分润、净利润和止损或暂停状态分字段保存。
+
+`binance_position_snapshot`
+
+- 快照键为 `project_id + symbol + position_side + capture_at`。
+- 当前仓位只新增快照,不覆盖历史快照。
+
+`binance_copy_raw_record`
+
+- append-only 原始记录,保留 `project_id`、来源页签、页码、当页序号、首次发现批次、
+ 原始时间、UTC 时间、页面时区假设和原页面字段。
+- 页面无官方 ID 时,候选记录键至少覆盖项目、页签、时间、合约、方向、价格、数量、
+ 手续费、已实现盈亏、角色、页码或稳定序号和首次发现批次。
+- 同秒、同价、同量、同手续费的合法多次撮合必须保留,不按整行去重。
+- 后续发现官方 trade 或 order identifier 时新增保存并优先用于规范化合并,不删除原始记录。
+
+`binance_copy_record_current`
+
+- 从 append-only 原始层生成的规范化当前视图,按稳定记录键幂等合并。
+- 原始层与规范化层分离,重跑不得制造冲突,也不得覆盖合法重复记录。
+
+`binance_market_candle_1m`、`binance_market_reference`
+
+- 按 `symbol + event/open time + source_kind` 保存 API 市场数据。
+- 统一 UTC,同时保留 Binance 原始时间戳。
+
+### 5.3 隐私
+
+- 下游默认只见稳定伪名,例如 `leader-01`,不直接使用交易员显示名。
+- 原始显示名默认不落库;若以后确需保存,只进入独立受限映射表。
+- 不保存 Cookie、密码、验证码、API Secret、完整请求头、`localStorage` 或
+ `sessionStorage`。
+- 真实金融数据不进入 Git,与代码仓库物理分离。
+
+## 6. 一次性 CSV 对账口径
+
+已有 4 个已结束项目、255 条成交的官方 CSV 只用于口径验证,源文件不进入正式工作流:
+
+- CSV Fee 为负数 USDT,Realized Profit 不含 Fee。
+- 页面已实现盈亏已经综合交易盈亏、手续费及资金费等调整。
+- 页面净利润等于页面已实现盈亏减累计分润。
+- 不得再次从页面净利润扣手续费或资金费。
+- 毛交易盈亏、交易手续费、资金费、分润、页面已实现盈亏、最终净利润独立保存。
+- 既有合计值和约 0.486 USDT 未解释差额只作为人工核验事实,不写成程序常量。
+- 因存在未解释差额,分润、转账、资金费用和失败订单必须完整采集。
+
+## 7. 浏览器只读安全边界
+
+只允许导航、展开、切换只读页签、翻页和读取。禁止点击或调用全部平仓、关闭仓位、
+止盈止损、调整余额、暂停或恢复、设置、开始或停止跟单、转账、充值提现,以及任何修改
+账户、交易、仓位或权限的操作。
+
+登录失效、验证码、风险控制、KYC、重新认证或改变状态的确认框出现时,本轮立即停止并
+进入人工处理,不绕过、不确认、不自动恢复认证。
+
+## 8. 失败与数据质量状态
+
+业务采集状态至少区分:
+
+- `success`
+- `authenticated_but_no_data`
+- `page_not_updated_yet`
+- `login_required`
+- `captcha_or_risk_control`
+- `structure_changed`
+- `required_field_missing`
+- `pagination_failed`
+- `partial_collection`
+- `network_failure`
+
+BPA 引擎终态仍使用 `succeeded`、`rejected`、`failed`、`uncertain` 等既有状态;上述业务
+状态作为结构化运行结果保存。空页面不能自动解释为账户无数据。任一关键项目或分页失败,
+不得用本轮空数据覆盖上次成功状态。中断后允许按检查点续跑,但不得写出冲突记录。
+
+每轮保存失败阶段、受影响 `project_id` 和页签;页面总额与明细汇总做对账,差额单列,
+不强行配平。
+
+## 9. BPA 现有能力复用结论
+
+可以直接复用:
+
+1. Browser Protocol v2 的精确标签绑定、页面观察、认证上下文、能力和权限白名单。
+2. Workflow v1alpha3 的 `foreach`、截止时间、有限重试、取消、检查点和恢复。
+3. TriggerSpec v1alpha2 的 schedule、`browserInstanceId`、`concurrencyKey`、错过运行策略和
+ 禁止隐式重试。
+4. SQLite 中的 Run、Node execution、event、checkpoint、Attention、append-only operational
+ facts、Dataset staging 和发布血缘。
+5. 现有抖店逐对象持久化事实、完整或部分 Dataset 发布意图和通知人工接管模式。
+
+不能直接复用或尚未完成:
+
+1. 已实现 Binance 官方公开市场数据 Runtime Provider,但尚未在正式调度中形成持续运行证据。
+2. 已实现 Binance append-only 原始明细和规范化当前视图;增量当前记录数已具备,页面总额对账表仍待实现。
+3. 已新增 Binance 专属 SQLite 表,没有套用不适合多成交记录的 operational fact 唯一键。
+4. 当前 Binance Workflow 已原子持久化跟单 Capture 和市场参考 Capture;攀升只读 Dataset/查询合同仍待实现。
+5. 当前实现已用真实页面确认列表和项目卡片结构;展开、八页签全分页和恢复行为尚未由新扩展完成真实 E2E。
+6. 最早和最新事件时间已实现;`page_not_updated_yet` 只保留状态合同,关键字段完整性和页面延迟证明仍待真实 DOM 验收。
+
+## 10. 最小端到端实现顺序
+
+### 阶段 A:一个真实项目闭环
+
+1. 部署新构建的 BPA 扩展到专用 Binance 浏览器实例。
+2. 只读绑定一个已登录管理页,真实验证项目 ID、项目汇总和当前仓位字段。
+3. 采集一个真实项目的交易历史全分页、资金费用全分页和分润记录全分页。
+4. 写入本机 SQLite 的原始 append-only 层、规范化视图和运行状态。
+5. 立即增量复跑一次,验证无冲突、合法重复记录保留、分页和管理页恢复稳定。
+6. 输出该项目的记录数、页数、时间范围、延迟和页面总额对账差额。
+
+### 阶段 B:扩大 follower 覆盖
+
+1. 扩展进行中和已结束全部项目,项目转结束时全量回扫。
+2. 纳入其余详情页签,补齐字段完整性和真实空数据判断。
+3. 增加部分成功、检查点恢复、last success 和人工接管。
+
+### 阶段 C:正式 API 行情
+
+1. 单独实现 Binance 正式 API Adapter,不与 follower DOM 采集器耦合。
+2. 先采交易对元数据、1 分钟 K 线、标记价、指数价和资金费率,再补正式可得的 OI。
+3. 用成交时间窗口驱动行情补齐,API 与页面来源明确分列。
+
+### 阶段 D:调度与下游
+
+1. 四项运行配置确认后创建默认禁用的 TriggerSpec。
+2. 使用独立 `concurrencyKey` 和专用 `browserInstanceId`,避免与其他 RPA 争抢登录标签。
+3. 完成攀升只读查询接口或 Dataset 读取合同,再启用调度和通知。
+
+## 11. 最小建议存储位置
+
+建议使用 BPA Local Core 已有 SQLite 实例,但新增 `binance_*` 专属表,不把真实金融数据
+保存到仓库目录,也不直接套用“一项目一业务日一条”的 operational fact 表。
+
+理由:
+
+- 本机运行、事务、检查点、Run 血缘、Attention 和 Dataset 能力已经存在。
+- append-only 成交和高频 1 分钟行情需要专用复合键及索引。
+- 攀升可通过 Local Core 的只读查询或受控 Dataset 读取,不直接控制浏览器和账户。
+
+现有 Local Core SQLite 文件路径属于“数据保存位置”配置;未确认前不创建正式数据落点,
+也不预先拆分第二个数据库文件。
+
+## 12. 待用户确认的四项配置
+
+| 配置 | 最小建议 | 主要影响 |
+| --- | --- | --- |
+| 采集频率 | 进行中项目每 15 分钟;已结束每日复核一次,连续 7 天无变化后改为每周 | 页面延迟容忍、浏览器负载、行情补齐窗口和 Trigger |
+| 数据保存位置 | 复用 BPA Local Core 现有本机 SQLite 实例,新增 `binance_*` 专属表;攀升只读访问 | 表结构、权限、备份、路径和查询接口 |
+| 保留期限 | 原始 follower 记录长期保留;Run 证据 180 天;1 分钟行情先保留 24 个月 | 容量、清理任务、研究窗口和备份成本 |
+| 失败通知渠道 | 先用 BPA Operator Attention;外部渠道待指定 | 登录、验证码、结构漂移和分页失败的人工接管时效 |
+
+这些建议值不进入正式 Trigger、清理任务或通知副作用,直到用户确认。
+
+## 13. 当前实质阻断
+
+1. **页面控制阻断**:已在可控 Chrome 实例中找到用户已登录的 Binance 管理页并完成
+ 只读 DOM 核验;随后浏览器控制通道在读取项目祖先结构时超时重置。按金融采集失败封闭
+ 边界,本轮停止继续点击。新 BPA 扩展尚未在该登录会话形成全分页 E2E 证据。
+2. **部署协调阻断**:重新加载 BPA 扩展会短暂重启扩展,必须先确认没有其他正在运行的
+ BPA 浏览器任务,再进入维护窗口。
+3. **存储配置待确认**:SQLite v26 表结构已实现;正式路径、保留期和下游访问方式未确认,
+ 当前没有写入真实金融数据。
+4. **调度阻断**:频率、专用 `browserInstanceId`、并发键和通知渠道未确认;当前不得启用
+ Schedule Trigger。
+5. **API 行情验收缺口**:已实现无需凭证的 Binance 官方公开 USDⓈ-M 市场数据节点;尚未
+ 在真实成交窗口执行并形成正式数据证据。2026-08-12 的无凭证只读探测在本机访问
+ `fapi.binance.com` 时 15 秒网络超时,未把网络失败解释为 API 空数据,也未写入数据库。
+
+当前没有授权,也没有实现任何交易、账户设置或 API 权限修改。
+
+## 14. 当前实现状态(2026-08-12)
+
+已实现并通过本地行为测试:
+
+- Workflow v3:管理页真实项目发现、逐项目八页签全分页、完整覆盖后原子持久化、公开市场参考数据补齐。
+- SQLite v26 专用表:采集 Run、原始 Source Capture、项目/仓位快照、原始详情行、规范化当前视图、交易对元数据、1 分钟 K 线、资金费率、标记价、指数价和未平仓量。
+- 原始详情行 append-only;同值多次撮合按本轮稳定出现次序保留,不按整行去重。
+- 同一运行写入可幂等重放;不完整 foreach、分页失败或结构校验失败不会提交 Copy Trading Capture。
+- 页面时间按明确的 `Asia/Shanghai` 假设规范为 UTC,同时保存原始时间和时区假设。
+- 市场数据只调用 `https://fapi.binance.com/fapi/v1/*` 固定公开 GET 端点,`credentials: omit`,不读取 API Key;支持 K 线和资金费分页,HTTP 418/429 立即停止并请求退避。
+
+仍未形成真实平台证据:
+
+- 已在可控 Chrome 实例中找到已登录 Binance 标签页,真实确认项目数量、三个进行中项目 ID、列表字段和卡片内“展开详情”控制;未读取或导出任何会话存储。
+- 浏览器控制通道随后超时重置,因而本轮没有继续点击项目详情或分页,也没有产生真实金融数据。
+- 详情采集已改为同一管理页内按项目状态切换列表、按 `project_id` 精确找卡、展开、八页签全分页、收起并恢复原列表页签;任何歧义、验证码、登录页、结构漂移或恢复失败都停止本轮。
+- 共享扩展变更导致的抖店 Adapter 与 Skill installer 摘要已经机械同步;仓库总校验已恢复通过,没有修改抖店业务逻辑。
diff --git a/nodes/core/binance.copy-trading.capture.persist.node.yaml b/nodes/core/binance.copy-trading.capture.persist.node.yaml
new file mode 100644
index 00000000..28df3691
--- /dev/null
+++ b/nodes/core/binance.copy-trading.capture.persist.node.yaml
@@ -0,0 +1,40 @@
+apiVersion: bpa/v1alpha1
+kind: Node
+metadata:
+ id: binance.copy-trading.capture.persist
+ version: 1.0.0
+ title: 持久化 Binance 合约跟单完整采集
+ description: 在全部项目详情完整返回后,以单事务追加保存原始采集、项目和仓位快照,并幂等更新规范化当前视图;任何校验失败均不提交。
+runtime: engine_builtin
+inputSchema:
+ type: object
+ additionalProperties: false
+ required: [management, projects, pageTimeZone]
+ properties:
+ management: { type: object }
+ projects: { type: object }
+ pageTimeZone: { const: Asia/Shanghai }
+outputSchema:
+ type: object
+ additionalProperties: false
+ required: [status, collectionRunId, captureAt, lastSuccessAt, collectedProjectCount, pageCount, recordCount, newRecordCount, oldestEventTimeUtc, newestEventTimeUtc, duplicate]
+ properties:
+ status: { enum: [success, authenticated_but_no_data, page_not_updated_yet] }
+ collectionRunId: { type: string, minLength: 1, maxLength: 200 }
+ captureAt: { type: string, format: date-time }
+ lastSuccessAt: { type: [string, "null"], format: date-time }
+ collectedProjectCount: { type: integer, minimum: 0, maximum: 500 }
+ pageCount: { type: integer, minimum: 1, maximum: 100000 }
+ recordCount: { type: integer, minimum: 0, maximum: 5000000 }
+ newRecordCount: { type: integer, minimum: 0, maximum: 5000000 }
+ oldestEventTimeUtc: { type: [string, "null"], format: date-time }
+ newestEventTimeUtc: { type: [string, "null"], format: date-time }
+ duplicate: { type: boolean }
+risk:
+ level: R1
+ permissions: [binance.copy-trading.capture.write]
+execution:
+ timeoutDefault: 2m
+ idempotency: verified_write
+ cancellable: true
+errors: [BINANCE_DATA_NODE_UNSUPPORTED, BINANCE_DATA_PERMISSION_MISMATCH, BINANCE_CAPTURE_PERSIST_FAILED, CANCELLED]
diff --git a/nodes/core/binance.copy-trading.management.snapshot.read.node.yaml b/nodes/core/binance.copy-trading.management.snapshot.read.node.yaml
new file mode 100644
index 00000000..57eb7776
--- /dev/null
+++ b/nodes/core/binance.copy-trading.management.snapshot.read.node.yaml
@@ -0,0 +1,42 @@
+apiVersion: bpa/v1alpha2
+kind: Node
+metadata:
+ id: binance.copy-trading.management.snapshot.read
+ version: 1.0.0
+ title: 读取 Binance 合约跟单管理快照
+ description: 只读提取当前管理页的账户汇总、真实跟单项目摘要和当前可见仓位;不点击详情、交易或设置控件。
+runtime: browser
+inputSchema: { type: object, additionalProperties: false }
+outputSchema:
+ type: object
+ additionalProperties: false
+ required: [schemaVersion, status, observedAt, pageUrl, accountSummary, activeTab, projects, warnings, formMutations]
+ properties:
+ schemaVersion: { const: binance-copy-trading/v0.1 }
+ status: { enum: [complete, empty_confirmed] }
+ observedAt: { type: string, format: date-time }
+ pageUrl: { type: string, format: uri }
+ accountSummary: { type: object, additionalProperties: { type: string } }
+ activeTab: { enum: [ongoing, ended] }
+ projects: { type: array, maxItems: 500, items: { type: object } }
+ warnings: { type: array, maxItems: 50, items: { type: string } }
+ formMutations: { const: 0 }
+risk:
+ level: R1
+ permissions: [browser.dom.read, browser.dom.write, browser.tabs.read]
+ domains: [https://www.binance.com]
+resources:
+ browser:
+ kind: browser
+ capabilities: [browser.dom.read, browser.dom.write, browser.tabs.read]
+ allowedOrigins: [https://www.binance.com]
+ authentication: authenticated
+ purpose: 绑定用户已登录的 Binance 合约跟单管理页并执行只读快照
+execution:
+ timeoutDefault: 30s
+ idempotency: repeatable_read
+ retryableErrors: [PAGE_LOADING, BROWSER_DISCONNECTED, CONTENT_SCRIPT_UNAVAILABLE]
+ cancellable: true
+errors: [PAGE_MISMATCH, PAGE_CONTEXT_CHANGED, SESSION_EXPIRED, CAPTCHA_REQUIRED, RATE_LIMITED, RISK_CONTROL, BINANCE_STRUCTURE_UNCONFIRMED, BINANCE_MANAGEMENT_TAB_AMBIGUOUS, BINANCE_MANAGEMENT_TAB_TIMEOUT, BINANCE_PROJECT_DUPLICATED_ACROSS_TABS, BROWSER_DISCONNECTED, CONTENT_SCRIPT_UNAVAILABLE, DEADLINE_EXCEEDED]
+evidence: { required: [result, error] }
+adapter: { id: binance-copy-trading, versions: [1.0.0] }
diff --git a/nodes/core/binance.copy-trading.project.detail.collect.node.yaml b/nodes/core/binance.copy-trading.project.detail.collect.node.yaml
new file mode 100644
index 00000000..fd498a9e
--- /dev/null
+++ b/nodes/core/binance.copy-trading.project.detail.collect.node.yaml
@@ -0,0 +1,60 @@
+apiVersion: bpa/v1alpha2
+kind: Node
+metadata:
+ id: binance.copy-trading.project.detail.collect
+ version: 1.0.0
+ title: 采集单个 Binance 合约跟单项目全部详情
+ description: 在管理页按 project_id 精确展开一个项目,遍历八个只读详情页签及全部分页,完成后收起并恢复原列表页签。
+runtime: browser
+inputSchema:
+ type: object
+ additionalProperties: false
+ required: [projectId, projectStatus, managementUrl]
+ properties:
+ projectId: { type: string, pattern: "^[A-Za-z0-9_-]{4,120}$" }
+ projectStatus: { enum: [ongoing, ended] }
+ managementUrl: { const: "https://www.binance.com/zh-CN/copy-trading/copy-management" }
+outputSchema:
+ type: object
+ additionalProperties: false
+ required: [schemaVersion, status, projectId, observedAt, pageUrl, tabs, formMutations]
+ properties:
+ schemaVersion: { const: binance-copy-trading/v0.1 }
+ status: { const: complete }
+ projectId: { type: string }
+ observedAt: { type: string, format: date-time }
+ pageUrl: { type: string, format: uri }
+ tabs:
+ type: array
+ minItems: 8
+ maxItems: 8
+ items:
+ type: object
+ required: [sourceTab, pageCount, summary, records]
+ properties:
+ sourceTab: { enum: [仓位, 仓位历史记录, 历史委托, 交易历史, 分润记录, 转账记录, 资金费用, 跟单失败订单] }
+ pageCount: { type: integer, minimum: 1, maximum: 100 }
+ summary: { type: object, additionalProperties: { type: string } }
+ records: { type: array, maxItems: 10000, items: { type: object } }
+ formMutations: { const: 0 }
+risk:
+ level: R1
+ permissions: [browser.dom.read, browser.dom.write, browser.tabs.read]
+ domains: [https://www.binance.com]
+resources:
+ browser:
+ kind: browser
+ capabilities: [browser.dom.read, browser.dom.write, browser.tabs.read]
+ allowedOrigins: [https://www.binance.com]
+ authentication: authenticated
+ purpose: 仅切换列表页签、展开或收起项目、切换详情页签和分页;禁止账户状态变更操作
+execution:
+ timeoutDefault: 10m
+ idempotency: repeatable_read
+ retryableErrors: [PAGE_LOADING, BROWSER_DISCONNECTED, BINANCE_CONTENT_RESPONSE_TIMEOUT, BINANCE_DETAIL_TAB_TIMEOUT, BINANCE_PAGINATION_TIMEOUT]
+ cancellable: true
+ timingPolicy:
+ rateLimit: { scope: authentication_context, minIntervalMs: 1000, maxQueueMs: 10000 }
+errors: [BINANCE_CONTENT_RESPONSE_TIMEOUT, BINANCE_DETAIL_HEADERS_MISSING, BINANCE_DETAIL_ROW_CHANGED, BINANCE_DETAIL_ROW_LIMIT_EXCEEDED, BINANCE_DETAIL_STAGE_FAILED, BINANCE_DETAIL_STRUCTURE_UNCONFIRMED, BINANCE_DETAIL_TAB_AMBIGUOUS, BINANCE_DETAIL_TAB_NOT_ACTIVE, BINANCE_DETAIL_TAB_TIMEOUT, BINANCE_MANAGEMENT_RESTORE_FAILED, BINANCE_MANAGEMENT_TAB_AMBIGUOUS, BINANCE_MANAGEMENT_TAB_TIMEOUT, BINANCE_PAGE_LIMIT_EXCEEDED, BINANCE_PAGINATION_AMBIGUOUS, BINANCE_PAGINATION_CHANGED, BINANCE_PAGINATION_REPEATED, BINANCE_PAGINATION_TIMEOUT, BINANCE_PROJECT_CARD_AMBIGUOUS, BINANCE_PROJECT_CARD_MISSING, BINANCE_PROJECT_COLLAPSE_FAILED, BINANCE_PROJECT_EXPAND_AMBIGUOUS, BINANCE_PROJECT_EXPAND_TIMEOUT, BINANCE_PROJECT_IDENTITY_MISMATCH, BINANCE_PROJECT_TARGET_INVALID, BROWSER_DISCONNECTED, CAPTCHA_REQUIRED, COMMAND_CANCELLED, DEADLINE_EXCEEDED, PAGE_CONTEXT_CHANGED, PAGE_LOADING, RATE_LIMITED, RISK_CONTROL, SESSION_EXPIRED]
+evidence: { required: [result, error] }
+adapter: { id: binance-copy-trading, versions: [1.0.0] }
diff --git a/nodes/core/binance.futures.market-reference.collect.node.yaml b/nodes/core/binance.futures.market-reference.collect.node.yaml
new file mode 100644
index 00000000..122754c0
--- /dev/null
+++ b/nodes/core/binance.futures.market-reference.collect.node.yaml
@@ -0,0 +1,44 @@
+apiVersion: bpa/v1alpha1
+kind: Node
+metadata:
+ id: binance.futures.market-reference.collect
+ version: 1.0.0
+ title: 采集 Binance USDⓈ-M 合约市场参考数据
+ description: 从官方公开只读 REST API 采集交易对元数据、成交前后窗口 1 分钟 K 线、资金费率、标记价、指数价和未平仓量,并原子落库;不使用账户凭证。
+runtime: engine_builtin
+inputSchema:
+ type: object
+ additionalProperties: false
+ required: [projects, pageTimeZone]
+ properties:
+ projects: { type: object }
+ pageTimeZone: { const: Asia/Shanghai }
+outputSchema:
+ type: object
+ additionalProperties: false
+ required: [status, marketCaptureId, captureAt, referencedSymbolCount, symbolMetadataCount, candleCount, insertedCandleCount, fundingCount, insertedFundingCount, referenceCount, requestCount, windowStartUtc, windowEndUtc, duplicate]
+ properties:
+ status: { const: success }
+ marketCaptureId: { type: string, minLength: 1, maxLength: 200 }
+ captureAt: { type: string, format: date-time }
+ referencedSymbolCount: { type: integer, minimum: 0, maximum: 1000 }
+ symbolMetadataCount: { type: integer, minimum: 0, maximum: 10000 }
+ candleCount: { type: integer, minimum: 0, maximum: 5000000 }
+ insertedCandleCount: { type: integer, minimum: 0, maximum: 5000000 }
+ fundingCount: { type: integer, minimum: 0, maximum: 1000000 }
+ insertedFundingCount: { type: integer, minimum: 0, maximum: 1000000 }
+ referenceCount: { type: integer, minimum: 0, maximum: 1000 }
+ requestCount: { type: integer, minimum: 1, maximum: 20000 }
+ windowStartUtc: { type: string, format: date-time }
+ windowEndUtc: { type: string, format: date-time }
+ duplicate: { type: boolean }
+risk:
+ level: R1
+ permissions: [binance.futures.market.read, binance.futures.market.write]
+ domains: [https://fapi.binance.com]
+execution:
+ timeoutDefault: 30m
+ idempotency: verified_write
+ cancellable: true
+ retryableErrors: [BINANCE_MARKET_NETWORK_FAILURE, BINANCE_MARKET_HTTP_FAILURE, BINANCE_MARKET_RATE_LIMITED]
+errors: [BINANCE_MARKET_NODE_UNSUPPORTED, BINANCE_MARKET_PERMISSION_MISMATCH, BINANCE_MARKET_NETWORK_FAILURE, BINANCE_MARKET_HTTP_FAILURE, BINANCE_MARKET_RATE_LIMITED, BINANCE_MARKET_STRUCTURE_CHANGED, BINANCE_MARKET_SYMBOL_MISSING, BINANCE_MARKET_REQUEST_LIMIT_EXCEEDED, BINANCE_MARKET_COLLECTION_FAILED, CANCELLED]
diff --git a/nodes/core/doudian.alliance.shops.discover.node.yaml b/nodes/core/doudian.alliance.shops.discover.node.yaml
index ec38db68..c55b55ee 100644
--- a/nodes/core/doudian.alliance.shops.discover.node.yaml
+++ b/nodes/core/doudian.alliance.shops.discover.node.yaml
@@ -62,7 +62,7 @@ resources:
authentication: authenticated
purpose: 从已认证抖店页面完整枚举当前账号可切换店铺
execution:
- timeoutDefault: 2m
+ timeoutDefault: 5m
idempotency: repeatable_read
retryableErrors: [PAGE_LOADING, BROWSER_DISCONNECTED]
cancellable: true
@@ -73,6 +73,7 @@ errors:
- CAPTCHA_REQUIRED
- COMMAND_RESULT_TOO_LARGE
- COMMAND_CANCELLED
+ - CURRENT_SHOP_NOT_IN_LIST
- DEADLINE_EXCEEDED
- DOUDIAN_ALLIANCE_DISCOVERY_FAILED
- DOUDIAN_ALLIANCE_MAX_SHOPS_INVALID
@@ -81,11 +82,24 @@ errors:
- PAGE_URL_INVALID
- RISK_CONTROL
- SESSION_EXPIRED
+ - SHOP_CONTEXT_RESTORE_FAILED
+ - SHOP_IDENTITY_DRIFT
- SHOP_IDENTITY_AMBIGUOUS
- SHOP_IDENTITY_UNCERTAIN
- SHOP_IDENTITY_UNCONFIRMED
- SHOP_LIMIT_EXCEEDED
- SHOP_LIST_EMPTY
- SHOP_LIST_INCOMPLETE
+ - SHOP_LIST_DUPLICATED
+ - SHOP_NOT_ACTIVE
+ - SHOP_SWITCH_DIALOG_AMBIGUOUS
+ - SHOP_SWITCH_DIALOG_CLOSE_AMBIGUOUS
+ - SHOP_SWITCH_DIALOG_TIMEOUT
+ - SHOP_SWITCH_NOT_CONFIRMED
+ - SHOP_SWITCH_SEARCH_AMBIGUOUS
+ - SHOP_SWITCH_TRIGGER_AMBIGUOUS
+ - SHOP_TARGET_AMBIGUOUS
+ - SHOP_TARGET_INVALID
+ - SHOP_TARGET_TIMEOUT
evidence: { required: [result, error] }
adapter: { id: doudian-alliance, versions: [2.0.0] }
diff --git a/packages/persistence-sqlite/src/authoring-v9.test.ts b/packages/persistence-sqlite/src/authoring-v9.test.ts
index cfb56521..f5944565 100644
--- a/packages/persistence-sqlite/src/authoring-v9.test.ts
+++ b/packages/persistence-sqlite/src/authoring-v9.test.ts
@@ -978,7 +978,7 @@ describe("migration v9", () => {
})
).toThrow("crash");
const recovered = new SqlitePersistence({ path });
- expect(recovered.health().schemaVersion).toBe(25);
+ expect(recovered.health().schemaVersion).toBe(26);
expect(recovered.getAuthoringSession("missing")).toBeUndefined();
recovered.close();
} finally {
diff --git a/packages/persistence-sqlite/src/binance-copy-trading-v26.test.ts b/packages/persistence-sqlite/src/binance-copy-trading-v26.test.ts
new file mode 100644
index 00000000..0011d03c
--- /dev/null
+++ b/packages/persistence-sqlite/src/binance-copy-trading-v26.test.ts
@@ -0,0 +1,267 @@
+import type {
+ EngineCheckpointRecord,
+ ExecutionEventRecord,
+ OperationalExecutionContext,
+ RunPlanSnapshotRecord,
+ RunRecord
+} from "@bpa/persistence";
+import { describe, expect, it } from "vitest";
+import { SqlitePersistence } from "./index.js";
+
+const timestamp = "2026-08-12T04:30:00.000Z";
+
+function context(runId: string): OperationalExecutionContext {
+ return {
+ invocationId: `invocation:${runId}:persist`,
+ identity: {
+ runId,
+ scopePath: [],
+ iterationKey: "root",
+ stepKey: "persist_capture",
+ attempt: 1
+ },
+ node: {
+ kind: "node",
+ id: "binance.copy-trading.capture.persist",
+ version: "1.0.0",
+ digest: `sha256:${"b".repeat(64)}`
+ },
+ idempotencyKey: `${runId}:root:persist_capture:1`,
+ fencingToken: 1
+ };
+}
+
+function createRun(
+ store: SqlitePersistence,
+ runId: string,
+ execution: OperationalExecutionContext
+): void {
+ const run: RunRecord = {
+ id: runId,
+ workflowId: "binance.copy-trading.management.snapshot",
+ workflowVersion: "3.0.0",
+ workflowDigest: "sha256:workflow",
+ status: "waiting_browser",
+ revision: 0,
+ input: {},
+ createdAt: timestamp,
+ updatedAt: timestamp
+ };
+ const plan: RunPlanSnapshotRecord = {
+ runId,
+ irVersion: "bpa.workflow-ir/2",
+ planDigest: "sha256:plan",
+ workflowSourceDigest: "sha256:workflow",
+ artifactClosureDigest: "sha256:closure",
+ planJson: {
+ irVersion: "bpa.workflow-ir/2",
+ workflow: {
+ id: run.workflowId,
+ version: run.workflowVersion,
+ digest: run.workflowDigest
+ },
+ artifactClosure: { entries: [] },
+ riskSnapshot: [],
+ limits: { maxDepth: 1, maxStepExecutions: 10 },
+ entry: "done",
+ steps: { done: { key: "done", kind: "terminal", status: "succeeded" } }
+ },
+ riskSnapshot: [],
+ createdAt: timestamp
+ };
+ const checkpoint: EngineCheckpointRecord = {
+ runId,
+ stateVersion: "bpa.engine-state/2",
+ stateRevision: 1,
+ state: {
+ stateVersion: "bpa.engine-state/2",
+ runId,
+ status: "waiting_runtime",
+ revision: 1,
+ active: { kind: "call", invocation: execution }
+ } as unknown as EngineCheckpointRecord["state"],
+ updatedAt: timestamp
+ };
+ const event: ExecutionEventRecord = {
+ id: `event:${runId}:1`,
+ runId,
+ sequence: 1,
+ type: "RUN_CREATED",
+ payload: {},
+ occurredAt: timestamp
+ };
+ store.createRecoverableRun({ run, planSnapshot: plan, checkpoint, event });
+}
+
+function capture(runId: string, execution: OperationalExecutionContext) {
+ const contentDigest = `sha256:${"c".repeat(64)}`;
+ return {
+ collectionRunId: `binance-collection:${runId}`,
+ workflowRunId: runId,
+ sourceUrl: "https://www.binance.com/zh-CN/copy-trading/copy-management",
+ attemptAt: timestamp,
+ captureAt: timestamp,
+ status: "success" as const,
+ contentDigest,
+ projectCount: 1,
+ pageCount: 2,
+ recordCount: 2,
+ oldestEventTimeUtc: "2026-08-12T04:00:00.000Z",
+ newestEventTimeUtc: "2026-08-12T04:00:00.000Z",
+ executionContext: execution,
+ sourceCaptures: [
+ {
+ captureId: `capture:${runId}:management`,
+ sourceKind: "management" as const,
+ sourceUrl: "https://www.binance.com/zh-CN/copy-trading/copy-management",
+ captureAt: timestamp,
+ recordCount: 1,
+ payloadDigest: contentDigest,
+ payload: { projects: 1 }
+ },
+ {
+ captureId: `capture:${runId}:trade:1`,
+ sourceKind: "project_tab" as const,
+ projectId: "project_1001",
+ sourceTab: "交易历史",
+ page: 1,
+ sourceUrl:
+ "https://www.binance.com/zh-CN/copy-trading/copy-management/project_1001",
+ captureAt: timestamp,
+ recordCount: 2,
+ payloadDigest: contentDigest,
+ payload: { page: 1 }
+ }
+ ],
+ projects: [
+ {
+ projectId: "project_1001",
+ projectStatus: "ongoing" as const,
+ sourceUrl:
+ "https://www.binance.com/zh-CN/copy-trading/copy-management/project_1001",
+ capturedAt: timestamp,
+ summary: { 净利润: "1.00 USDT" }
+ }
+ ],
+ positions: [],
+ rawRecords: [1, 2].map((ordinal) => ({
+ rawRecordId: `raw:${runId}:${ordinal}`,
+ currentRecordKey: `current:duplicate:${ordinal}`,
+ projectId: "project_1001",
+ sourceTab: "交易历史",
+ page: 1,
+ rowOrdinal: ordinal,
+ captureAt: timestamp,
+ originalEventTime: "2026-08-12 12:00:00",
+ eventTimeUtc: "2026-08-12T04:00:00.000Z",
+ pageTimeZoneAssumption: "Asia/Shanghai",
+ fields: { 时间: "2026-08-12 12:00:00", 合约: "BTCUSDT" },
+ fieldsDigest: contentDigest
+ }))
+ };
+}
+
+describe("Binance copy-trading SQLite v26", () => {
+ it("atomically keeps duplicate rows and idempotently replays one capture", () => {
+ const store = new SqlitePersistence({ path: ":memory:" });
+ const runId = "run:binance:1";
+ const execution = context(runId);
+ createRun(store, runId, execution);
+ const input = capture(runId, execution);
+
+ const first = store.persistBinanceCopyTradingCapture(input);
+ const second = store.persistBinanceCopyTradingCapture(input);
+
+ expect(first).toMatchObject({ status: "accepted", newCurrentRecordCount: 2 });
+ expect(second).toMatchObject({ status: "duplicate", newCurrentRecordCount: 0 });
+ expect(store.listBinanceRawRecords(input.collectionRunId)).toHaveLength(2);
+ expect(store.listBinanceCurrentRecords("project_1001")).toHaveLength(2);
+ expect(store.health().schemaVersion).toBe(26);
+ });
+
+ it("rolls back the whole capture when any raw identity conflicts", () => {
+ const store = new SqlitePersistence({ path: ":memory:" });
+ const runId = "run:binance:rollback";
+ const execution = context(runId);
+ createRun(store, runId, execution);
+ const input = capture(runId, execution);
+ input.rawRecords[1]!.rawRecordId = input.rawRecords[0]!.rawRecordId;
+
+ expect(() => store.persistBinanceCopyTradingCapture(input)).toThrow();
+ expect(store.getBinanceCollectionRun(input.collectionRunId)).toBeUndefined();
+ expect(store.listBinanceRawRecords(input.collectionRunId)).toEqual([]);
+ expect(store.listBinanceCurrentRecords()).toEqual([]);
+ });
+
+ it("idempotently persists public market candles and funding", () => {
+ const store = new SqlitePersistence({ path: ":memory:" });
+ const runId = "run:binance:market";
+ const execution = context(runId);
+ createRun(store, runId, execution);
+ const input = {
+ marketCaptureId: "market-capture:1",
+ workflowRunId: runId,
+ captureAt: timestamp,
+ sourceUrl: "https://fapi.binance.com",
+ symbolsPayload: { symbols: ["BTCUSDT"] },
+ symbolsDigest: `sha256:${"d".repeat(64)}`,
+ candlesPayload: { rows: 1 },
+ candlesDigest: `sha256:${"e".repeat(64)}`,
+ referencesPayload: { rows: 1 },
+ referencesDigest: `sha256:${"f".repeat(64)}`,
+ symbols: [{
+ symbol: "BTCUSDT",
+ pair: "BTCUSDT",
+ contractType: "PERPETUAL",
+ status: "TRADING",
+ baseAsset: "BTC",
+ quoteAsset: "USDT",
+ marginAsset: "USDT"
+ }],
+ candles: [{
+ symbol: "BTCUSDT",
+ openTimeUtc: "2026-08-12T04:00:00.000Z",
+ closeTimeUtc: "2026-08-12T04:00:59.999Z",
+ open: "60000",
+ high: "60100",
+ low: "59900",
+ close: "60050",
+ volume: "10",
+ quoteVolume: "600500",
+ tradeCount: 20
+ }],
+ funding: [{
+ symbol: "BTCUSDT",
+ fundingTimeUtc: "2026-08-12T00:00:00.000Z",
+ fundingRate: "0.0001",
+ markPrice: "60000"
+ }],
+ references: [{
+ symbol: "BTCUSDT",
+ markPrice: "60001",
+ indexPrice: "60000",
+ lastFundingRate: "0.0001",
+ openInterest: "12345",
+ observedAt: timestamp
+ }],
+ executionContext: execution
+ };
+ const first = store.persistBinanceMarketCapture(input);
+ const second = store.persistBinanceMarketCapture(input);
+ expect(first).toMatchObject({
+ status: "accepted",
+ insertedCandleCount: 1,
+ insertedFundingCount: 1
+ });
+ expect(second).toMatchObject({
+ status: "duplicate",
+ insertedCandleCount: 0,
+ insertedFundingCount: 0
+ });
+ expect(store.getBinanceMarketCapture(input.marketCaptureId)).toMatchObject({
+ candleCount: 1,
+ fundingCount: 1,
+ referenceCount: 1
+ });
+ });
+});
diff --git a/packages/persistence-sqlite/src/evidence-persistence.test.ts b/packages/persistence-sqlite/src/evidence-persistence.test.ts
index 8ec1cc35..ed578e98 100644
--- a/packages/persistence-sqlite/src/evidence-persistence.test.ts
+++ b/packages/persistence-sqlite/src/evidence-persistence.test.ts
@@ -726,7 +726,7 @@ describe("migration v7", () => {
})
).toThrow("crash");
const recovered = new SqlitePersistence({ path });
- expect(recovered.health().schemaVersion).toBe(25);
+ expect(recovered.health().schemaVersion).toBe(26);
recovered.close();
});
});
diff --git a/packages/persistence-sqlite/src/external-domain-lease-v24.test.ts b/packages/persistence-sqlite/src/external-domain-lease-v24.test.ts
index 4a8aff5b..9bf27dc0 100644
--- a/packages/persistence-sqlite/src/external-domain-lease-v24.test.ts
+++ b/packages/persistence-sqlite/src/external-domain-lease-v24.test.ts
@@ -27,6 +27,24 @@ const t3 = "2026-08-09T00:03:00.000Z";
const t4 = "2026-08-09T00:04:00.000Z";
const expiry = "2026-08-09T00:10:00.000Z";
+function dropMigration26(database: Database.Database): void {
+ const tables = [
+ "binance_market_reference_snapshots",
+ "binance_market_funding_rates",
+ "binance_market_candles_1m",
+ "binance_market_symbol_snapshots",
+ "binance_market_captures",
+ "binance_copy_record_current",
+ "binance_copy_raw_records",
+ "binance_position_snapshots",
+ "binance_copy_project_snapshots",
+ "binance_source_captures",
+ "binance_collection_runs"
+ ];
+ database.exec(tables.map((table) => `DROP TABLE ${table};`).join("\n"));
+ database.prepare("DELETE FROM schema_migrations WHERE version=26").run();
+}
+
function event(runId: string, sequence = 1): ExecutionEventRecord {
return {
id: `event:${runId}:${sequence}`,
@@ -511,6 +529,7 @@ describe("migration v24", () => {
const seeded = new SqlitePersistence({ path: databasePath });
seeded.close();
const v23 = new Database(databasePath);
+ dropMigration26(v23);
v23.exec(`
DROP TABLE external_domain_lease_reconciliations;
DROP TABLE external_domain_leases;
@@ -519,7 +538,7 @@ describe("migration v24", () => {
v23.close();
const upgraded = new SqlitePersistence({ path: databasePath });
- expect(upgraded.health().schemaVersion).toBe(25);
+ expect(upgraded.health().schemaVersion).toBe(26);
upgraded.close();
const inspected = new Database(databasePath, { readonly: true });
expect(
@@ -543,6 +562,7 @@ describe("migration v24", () => {
const seeded = new SqlitePersistence({ path: databasePath });
seeded.close();
const v23 = new Database(databasePath);
+ dropMigration26(v23);
v23.exec(`
DROP TABLE external_domain_lease_reconciliations;
DROP TABLE external_domain_leases;
@@ -576,7 +596,7 @@ describe("migration v24", () => {
inspected.close();
const recovered = new SqlitePersistence({ path: databasePath });
- expect(recovered.health().schemaVersion).toBe(25);
+ expect(recovered.health().schemaVersion).toBe(26);
recovered.close();
} finally {
rmSync(directory, { recursive: true, force: true });
@@ -790,6 +810,7 @@ describe("inventory effect reconciliation persistence v25", () => {
const seeded = new SqlitePersistence({ path:databasePath });
seeded.close();
const v24 = new Database(databasePath);
+ dropMigration26(v24);
v24.exec(`
DROP TABLE external_domain_lease_reconciliations;
DELETE FROM schema_migrations WHERE version=25;
@@ -811,7 +832,7 @@ describe("inventory effect reconciliation persistence v25", () => {
).get()).toBeUndefined();
inspected.close();
const recovered = new SqlitePersistence({ path:databasePath });
- expect(recovered.health().schemaVersion).toBe(25);
+ expect(recovered.health().schemaVersion).toBe(26);
recovered.close();
} finally {
rmSync(directory,{ recursive:true,force:true });
diff --git a/packages/persistence-sqlite/src/index.ts b/packages/persistence-sqlite/src/index.ts
index d6b0d142..d3687be8 100644
--- a/packages/persistence-sqlite/src/index.ts
+++ b/packages/persistence-sqlite/src/index.ts
@@ -40,6 +40,10 @@ import {
type AttentionDeliveryState,
type AttentionRecord,
type AuditRecord,
+ type BinanceCollectionRunRecord,
+ type BinanceCurrentRecord,
+ type BinanceMarketCaptureRecord,
+ type BinanceRawRecord,
type BrowserCapabilityRecord,
type BrowserControlLeaseRecord,
type BrowserPageObservationRecord,
@@ -88,6 +92,8 @@ import {
type Persistence,
type PublishArtifactInput,
type PreparedOperationalDatasetPublication,
+ type PersistBinanceCopyTradingCaptureInput,
+ type PersistBinanceMarketCaptureInput,
type RetentionJobRecord,
type RecoverySessionRecord,
type RecoverySessionState,
@@ -3115,6 +3121,532 @@ export class SqlitePersistence implements Persistence {
).map((row) => this.#readOperationalFact(row));
}
+ persistBinanceCopyTradingCapture(
+ input: PersistBinanceCopyTradingCaptureInput
+ ): {
+ status: "accepted" | "duplicate";
+ run: BinanceCollectionRunRecord;
+ newCurrentRecordCount: number;
+ } {
+ assertAuthoringId(input.collectionRunId, "Binance collectionRunId");
+ if (input.workflowRunId !== input.executionContext.identity.runId) {
+ throw new Error("Binance workflowRunId must match execution context");
+ }
+ for (const [label, value] of [
+ ["attemptAt", input.attemptAt],
+ ["captureAt", input.captureAt],
+ ["oldestEventTimeUtc", input.oldestEventTimeUtc],
+ ["newestEventTimeUtc", input.newestEventTimeUtc]
+ ] as const) {
+ if (value !== undefined) assertTimestamp(value, `Binance ${label}`);
+ }
+ if (!/^sha256:[a-f0-9]{64}$/u.test(input.contentDigest)) {
+ throw new Error("Binance contentDigest is invalid");
+ }
+ for (const [label, value] of [
+ ["projectCount", input.projectCount],
+ ["pageCount", input.pageCount],
+ ["recordCount", input.recordCount]
+ ] as const) {
+ if (!Number.isSafeInteger(value) || value < 0) {
+ throw new Error(`Binance ${label} is invalid`);
+ }
+ }
+ if (
+ input.projects.length !== input.projectCount ||
+ input.rawRecords.length !== input.recordCount
+ ) {
+ throw new Error("Binance capture counts do not conserve");
+ }
+ if (
+ input.sourceCaptures.filter((capture) => capture.sourceKind === "management")
+ .length !== 1 ||
+ input.sourceCaptures.length !== input.pageCount
+ ) {
+ throw new Error("Binance source capture coverage is invalid");
+ }
+ return this.#db.transaction(() => {
+ this.#assertActiveOperationalExecutionContext(
+ input.workflowRunId,
+ input.executionContext
+ );
+ this.#assertActiveTriggerOwnership(input.workflowRunId);
+ const existing = this.getBinanceCollectionRun(input.collectionRunId);
+ if (existing) {
+ if (
+ existing.workflowRunId !== input.workflowRunId ||
+ existing.contentDigest !== input.contentDigest ||
+ existing.status !== input.status
+ ) {
+ throw new OperationalFactConflictError(
+ `Binance collection ${input.collectionRunId} already has different content`
+ );
+ }
+ return {
+ status: "duplicate" as const,
+ run: existing,
+ newCurrentRecordCount: 0
+ };
+ }
+ const previous = this.getLatestSuccessfulBinanceCollectionRun();
+ const status = input.status;
+ const lastSuccessAt =
+ status === "success" || status === "authenticated_but_no_data"
+ ? input.captureAt
+ : previous?.lastSuccessAt;
+ this.#db.prepare(
+ `INSERT INTO binance_collection_runs(
+ collection_run_id,workflow_run_id,source_url,attempt_at,capture_at,
+ status,content_digest,project_count,page_count,record_count,
+ oldest_event_time_utc,newest_event_time_utc,last_success_at,created_at
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
+ ).run(
+ input.collectionRunId,
+ input.workflowRunId,
+ input.sourceUrl,
+ input.attemptAt,
+ input.captureAt,
+ status,
+ input.contentDigest,
+ input.projectCount,
+ input.pageCount,
+ input.recordCount,
+ input.oldestEventTimeUtc ?? null,
+ input.newestEventTimeUtc ?? null,
+ lastSuccessAt ?? null,
+ this.#clock().toISOString()
+ );
+ const captureStatement = this.#db.prepare(
+ `INSERT INTO binance_source_captures(
+ capture_id,collection_run_id,source_kind,project_id,source_tab,page,
+ source_url,capture_at,record_count,payload_digest,payload_json
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?)`
+ );
+ for (const capture of input.sourceCaptures) {
+ assertJsonCompatible(capture.payload, "Binance source capture payload");
+ captureStatement.run(
+ capture.captureId,
+ input.collectionRunId,
+ capture.sourceKind,
+ capture.projectId ?? null,
+ capture.sourceTab ?? null,
+ capture.page ?? null,
+ capture.sourceUrl,
+ capture.captureAt,
+ capture.recordCount,
+ capture.payloadDigest,
+ json(capture.payload)
+ );
+ }
+ const projectStatement = this.#db.prepare(
+ `INSERT INTO binance_copy_project_snapshots(
+ collection_run_id,project_id,project_status,source_url,captured_at,
+ summary_json
+ ) VALUES (?,?,?,?,?,?)`
+ );
+ for (const project of input.projects) {
+ assertJsonCompatible(project.summary, "Binance project summary");
+ projectStatement.run(
+ input.collectionRunId,
+ project.projectId,
+ project.projectStatus,
+ project.sourceUrl,
+ project.capturedAt,
+ json(project.summary)
+ );
+ }
+ const positionStatement = this.#db.prepare(
+ `INSERT INTO binance_position_snapshots(
+ snapshot_id,collection_run_id,project_id,symbol,position_side,
+ ordinal,captured_at,fields_json
+ ) VALUES (?,?,?,?,?,?,?,?)`
+ );
+ for (const position of input.positions) {
+ assertJsonCompatible(position.fields, "Binance position fields");
+ positionStatement.run(
+ position.snapshotId,
+ input.collectionRunId,
+ position.projectId,
+ position.symbol,
+ position.positionSide,
+ position.ordinal,
+ position.capturedAt,
+ json(position.fields)
+ );
+ }
+ const rawStatement = this.#db.prepare(
+ `INSERT INTO binance_copy_raw_records(
+ raw_record_id,collection_run_id,current_record_key,project_id,
+ source_tab,page,row_ordinal,capture_at,original_event_time,
+ event_time_utc,page_time_zone_assumption,fields_json,fields_digest
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`
+ );
+ const currentStatement = this.#db.prepare(
+ `INSERT INTO binance_copy_record_current(
+ current_record_key,project_id,source_tab,original_event_time,
+ event_time_utc,page_time_zone_assumption,fields_json,fields_digest,
+ first_collection_run_id,last_collection_run_id,first_seen_at,last_seen_at
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
+ ON CONFLICT(current_record_key) DO UPDATE SET
+ original_event_time=excluded.original_event_time,
+ event_time_utc=excluded.event_time_utc,
+ page_time_zone_assumption=excluded.page_time_zone_assumption,
+ fields_json=excluded.fields_json,
+ fields_digest=excluded.fields_digest,
+ last_collection_run_id=excluded.last_collection_run_id,
+ last_seen_at=excluded.last_seen_at`
+ );
+ const currentExistsStatement = this.#db.prepare(
+ `SELECT 1 FROM binance_copy_record_current
+ WHERE current_record_key=?`
+ );
+ let newCurrentRecordCount = 0;
+ for (const record of input.rawRecords) {
+ assertJsonCompatible(record.fields, "Binance raw record fields");
+ rawStatement.run(
+ record.rawRecordId,
+ input.collectionRunId,
+ record.currentRecordKey,
+ record.projectId,
+ record.sourceTab,
+ record.page,
+ record.rowOrdinal,
+ record.captureAt,
+ record.originalEventTime ?? null,
+ record.eventTimeUtc ?? null,
+ record.pageTimeZoneAssumption ?? null,
+ json(record.fields),
+ record.fieldsDigest
+ );
+ if (!currentExistsStatement.get(record.currentRecordKey)) {
+ newCurrentRecordCount += 1;
+ }
+ currentStatement.run(
+ record.currentRecordKey,
+ record.projectId,
+ record.sourceTab,
+ record.originalEventTime ?? null,
+ record.eventTimeUtc ?? null,
+ record.pageTimeZoneAssumption ?? null,
+ json(record.fields),
+ record.fieldsDigest,
+ input.collectionRunId,
+ input.collectionRunId,
+ input.captureAt,
+ input.captureAt
+ );
+ }
+ return {
+ status: "accepted" as const,
+ run: this.getBinanceCollectionRun(input.collectionRunId)!,
+ newCurrentRecordCount
+ };
+ })();
+ }
+
+ getBinanceCollectionRun(
+ collectionRunId: string
+ ): BinanceCollectionRunRecord | undefined {
+ const row = this.#db.prepare(
+ "SELECT * FROM binance_collection_runs WHERE collection_run_id=?"
+ ).get(collectionRunId) as SqlRow | undefined;
+ return row ? this.#readBinanceCollectionRun(row) : undefined;
+ }
+
+ getLatestSuccessfulBinanceCollectionRun():
+ | BinanceCollectionRunRecord
+ | undefined {
+ const row = this.#db.prepare(
+ `SELECT * FROM binance_collection_runs
+ WHERE status IN ('success','authenticated_but_no_data')
+ ORDER BY capture_at DESC,collection_run_id DESC LIMIT 1`
+ ).get() as SqlRow | undefined;
+ return row ? this.#readBinanceCollectionRun(row) : undefined;
+ }
+
+ listBinanceRawRecords(collectionRunId: string): BinanceRawRecord[] {
+ return (this.#db.prepare(
+ `SELECT * FROM binance_copy_raw_records WHERE collection_run_id=?
+ ORDER BY project_id,source_tab,page,row_ordinal`
+ ).all(collectionRunId) as SqlRow[]).map((row) => ({
+ rawRecordId: String(row.raw_record_id),
+ collectionRunId: String(row.collection_run_id),
+ currentRecordKey: String(row.current_record_key),
+ projectId: String(row.project_id),
+ sourceTab: String(row.source_tab),
+ page: Number(row.page),
+ rowOrdinal: Number(row.row_ordinal),
+ captureAt: String(row.capture_at),
+ ...(row.original_event_time == null ? {} : {
+ originalEventTime: String(row.original_event_time)
+ }),
+ ...(row.event_time_utc == null ? {} : {
+ eventTimeUtc: String(row.event_time_utc)
+ }),
+ ...(row.page_time_zone_assumption == null ? {} : {
+ pageTimeZoneAssumption: String(row.page_time_zone_assumption)
+ }),
+ fields: parseJson(row.fields_json) as JsonValue,
+ fieldsDigest: String(row.fields_digest)
+ }));
+ }
+
+ listBinanceCurrentRecords(projectId?: string): BinanceCurrentRecord[] {
+ const rows = (projectId
+ ? this.#db.prepare(
+ `SELECT * FROM binance_copy_record_current WHERE project_id=?
+ ORDER BY source_tab,event_time_utc,current_record_key`
+ ).all(projectId)
+ : this.#db.prepare(
+ `SELECT * FROM binance_copy_record_current
+ ORDER BY project_id,source_tab,event_time_utc,current_record_key`
+ ).all()) as SqlRow[];
+ return rows.map((row) => ({
+ currentRecordKey: String(row.current_record_key),
+ projectId: String(row.project_id),
+ sourceTab: String(row.source_tab),
+ ...(row.original_event_time == null ? {} : {
+ originalEventTime: String(row.original_event_time)
+ }),
+ ...(row.event_time_utc == null ? {} : {
+ eventTimeUtc: String(row.event_time_utc)
+ }),
+ ...(row.page_time_zone_assumption == null ? {} : {
+ pageTimeZoneAssumption: String(row.page_time_zone_assumption)
+ }),
+ fields: parseJson(row.fields_json) as JsonValue,
+ fieldsDigest: String(row.fields_digest),
+ firstCollectionRunId: String(row.first_collection_run_id),
+ lastCollectionRunId: String(row.last_collection_run_id),
+ firstSeenAt: String(row.first_seen_at),
+ lastSeenAt: String(row.last_seen_at)
+ }));
+ }
+
+ #readBinanceCollectionRun(row: SqlRow): BinanceCollectionRunRecord {
+ return {
+ collectionRunId: String(row.collection_run_id),
+ workflowRunId: String(row.workflow_run_id),
+ sourceUrl: String(row.source_url),
+ attemptAt: String(row.attempt_at),
+ captureAt: String(row.capture_at),
+ status: String(row.status) as BinanceCollectionRunRecord["status"],
+ contentDigest: String(row.content_digest),
+ projectCount: Number(row.project_count),
+ pageCount: Number(row.page_count),
+ recordCount: Number(row.record_count),
+ ...(row.oldest_event_time_utc == null ? {} : {
+ oldestEventTimeUtc: String(row.oldest_event_time_utc)
+ }),
+ ...(row.newest_event_time_utc == null ? {} : {
+ newestEventTimeUtc: String(row.newest_event_time_utc)
+ }),
+ ...(row.last_success_at == null ? {} : {
+ lastSuccessAt: String(row.last_success_at)
+ }),
+ createdAt: String(row.created_at)
+ };
+ }
+
+ persistBinanceMarketCapture(input: PersistBinanceMarketCaptureInput): {
+ status: "accepted" | "duplicate";
+ capture: BinanceMarketCaptureRecord;
+ insertedCandleCount: number;
+ insertedFundingCount: number;
+ } {
+ assertAuthoringId(input.marketCaptureId, "Binance marketCaptureId");
+ if (input.workflowRunId !== input.executionContext.identity.runId) {
+ throw new Error("Binance market workflowRunId must match execution context");
+ }
+ assertTimestamp(input.captureAt, "Binance market captureAt");
+ for (const [label, value] of [
+ ["symbolsDigest", input.symbolsDigest],
+ ["candlesDigest", input.candlesDigest],
+ ["referencesDigest", input.referencesDigest]
+ ] as const) {
+ if (!/^sha256:[a-f0-9]{64}$/u.test(value)) {
+ throw new Error(`Binance market ${label} is invalid`);
+ }
+ }
+ assertJsonCompatible(input.symbolsPayload, "Binance symbols payload");
+ assertJsonCompatible(input.candlesPayload, "Binance candles payload");
+ assertJsonCompatible(input.referencesPayload, "Binance references payload");
+ return this.#db.transaction(() => {
+ this.#assertActiveOperationalExecutionContext(
+ input.workflowRunId,
+ input.executionContext
+ );
+ this.#assertActiveTriggerOwnership(input.workflowRunId);
+ const existing = this.getBinanceMarketCapture(input.marketCaptureId);
+ if (existing) {
+ return {
+ status: "duplicate" as const,
+ capture: existing,
+ insertedCandleCount: 0,
+ insertedFundingCount: 0
+ };
+ }
+ this.#db.prepare(
+ `INSERT INTO binance_market_captures(
+ market_capture_id,workflow_run_id,capture_at,source_url,
+ symbols_payload_json,symbols_digest,candles_payload_json,
+ candles_digest,references_payload_json,references_digest,
+ symbol_count,candle_count,funding_count,reference_count,created_at
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
+ ).run(
+ input.marketCaptureId,
+ input.workflowRunId,
+ input.captureAt,
+ input.sourceUrl,
+ json(input.symbolsPayload),
+ input.symbolsDigest,
+ json(input.candlesPayload),
+ input.candlesDigest,
+ json(input.referencesPayload),
+ input.referencesDigest,
+ input.symbols.length,
+ input.candles.length,
+ input.funding.length,
+ input.references.length,
+ this.#clock().toISOString()
+ );
+ const symbolStatement = this.#db.prepare(
+ `INSERT INTO binance_market_symbol_snapshots(
+ market_capture_id,symbol,pair,contract_type,status,onboard_date_utc,
+ delivery_date_utc,base_asset,quote_asset,margin_asset
+ ) VALUES (?,?,?,?,?,?,?,?,?,?)`
+ );
+ for (const symbol of input.symbols) {
+ symbolStatement.run(
+ input.marketCaptureId,
+ symbol.symbol,
+ symbol.pair,
+ symbol.contractType,
+ symbol.status,
+ symbol.onboardDateUtc ?? null,
+ symbol.deliveryDateUtc ?? null,
+ symbol.baseAsset,
+ symbol.quoteAsset,
+ symbol.marginAsset
+ );
+ }
+ const candleExists = this.#db.prepare(
+ `SELECT 1 FROM binance_market_candles_1m
+ WHERE symbol=? AND open_time_utc=?`
+ );
+ const candleStatement = this.#db.prepare(
+ `INSERT INTO binance_market_candles_1m(
+ symbol,open_time_utc,close_time_utc,open,high,low,close,volume,
+ quote_volume,trade_count,first_market_capture_id,
+ last_market_capture_id,first_seen_at,last_seen_at
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ ON CONFLICT(symbol,open_time_utc) DO UPDATE SET
+ close_time_utc=excluded.close_time_utc,open=excluded.open,
+ high=excluded.high,low=excluded.low,close=excluded.close,
+ volume=excluded.volume,quote_volume=excluded.quote_volume,
+ trade_count=excluded.trade_count,
+ last_market_capture_id=excluded.last_market_capture_id,
+ last_seen_at=excluded.last_seen_at`
+ );
+ let insertedCandleCount = 0;
+ for (const candle of input.candles) {
+ if (!candleExists.get(candle.symbol, candle.openTimeUtc)) {
+ insertedCandleCount += 1;
+ }
+ candleStatement.run(
+ candle.symbol,
+ candle.openTimeUtc,
+ candle.closeTimeUtc,
+ candle.open,
+ candle.high,
+ candle.low,
+ candle.close,
+ candle.volume,
+ candle.quoteVolume,
+ candle.tradeCount,
+ input.marketCaptureId,
+ input.marketCaptureId,
+ input.captureAt,
+ input.captureAt
+ );
+ }
+ const fundingExists = this.#db.prepare(
+ `SELECT 1 FROM binance_market_funding_rates
+ WHERE symbol=? AND funding_time_utc=?`
+ );
+ const fundingStatement = this.#db.prepare(
+ `INSERT INTO binance_market_funding_rates(
+ symbol,funding_time_utc,funding_rate,mark_price,
+ first_market_capture_id,last_market_capture_id,first_seen_at,last_seen_at
+ ) VALUES (?,?,?,?,?,?,?,?)
+ ON CONFLICT(symbol,funding_time_utc) DO UPDATE SET
+ funding_rate=excluded.funding_rate,mark_price=excluded.mark_price,
+ last_market_capture_id=excluded.last_market_capture_id,
+ last_seen_at=excluded.last_seen_at`
+ );
+ let insertedFundingCount = 0;
+ for (const funding of input.funding) {
+ if (!fundingExists.get(funding.symbol, funding.fundingTimeUtc)) {
+ insertedFundingCount += 1;
+ }
+ fundingStatement.run(
+ funding.symbol,
+ funding.fundingTimeUtc,
+ funding.fundingRate,
+ funding.markPrice ?? null,
+ input.marketCaptureId,
+ input.marketCaptureId,
+ input.captureAt,
+ input.captureAt
+ );
+ }
+ const referenceStatement = this.#db.prepare(
+ `INSERT INTO binance_market_reference_snapshots(
+ market_capture_id,symbol,mark_price,index_price,last_funding_rate,
+ next_funding_time_utc,open_interest,observed_at
+ ) VALUES (?,?,?,?,?,?,?,?)`
+ );
+ for (const reference of input.references) {
+ referenceStatement.run(
+ input.marketCaptureId,
+ reference.symbol,
+ reference.markPrice,
+ reference.indexPrice,
+ reference.lastFundingRate,
+ reference.nextFundingTimeUtc ?? null,
+ reference.openInterest ?? null,
+ reference.observedAt
+ );
+ }
+ return {
+ status: "accepted" as const,
+ capture: this.getBinanceMarketCapture(input.marketCaptureId)!,
+ insertedCandleCount,
+ insertedFundingCount
+ };
+ })();
+ }
+
+ getBinanceMarketCapture(
+ marketCaptureId: string
+ ): BinanceMarketCaptureRecord | undefined {
+ const row = this.#db.prepare(
+ "SELECT * FROM binance_market_captures WHERE market_capture_id=?"
+ ).get(marketCaptureId) as SqlRow | undefined;
+ if (!row) return undefined;
+ return {
+ marketCaptureId: String(row.market_capture_id),
+ workflowRunId: String(row.workflow_run_id),
+ captureAt: String(row.capture_at),
+ sourceUrl: String(row.source_url),
+ symbolCount: Number(row.symbol_count),
+ candleCount: Number(row.candle_count),
+ fundingCount: Number(row.funding_count),
+ referenceCount: Number(row.reference_count),
+ createdAt: String(row.created_at)
+ };
+ }
+
getOperationalBusinessContext(
runId: string,
businessTimeZone: string
diff --git a/packages/persistence-sqlite/src/migrations.ts b/packages/persistence-sqlite/src/migrations.ts
index 35a34992..9dec2ed4 100644
--- a/packages/persistence-sqlite/src/migrations.ts
+++ b/packages/persistence-sqlite/src/migrations.ts
@@ -1966,5 +1966,227 @@ export const migrations: Migration[] = [
CREATE INDEX external_domain_lease_reconciliations_state
ON external_domain_lease_reconciliations(resolved_at, request_id);
`
+ },
+ {
+ version: 26,
+ sql: `
+ CREATE TABLE binance_collection_runs (
+ collection_run_id TEXT PRIMARY KEY,
+ workflow_run_id TEXT NOT NULL
+ REFERENCES workflow_runs(id) ON DELETE RESTRICT,
+ source_url TEXT NOT NULL,
+ attempt_at TEXT NOT NULL,
+ capture_at TEXT NOT NULL,
+ status TEXT NOT NULL CHECK (status IN (
+ 'success', 'authenticated_but_no_data', 'page_not_updated_yet',
+ 'login_required', 'captcha_or_risk_control', 'structure_changed',
+ 'required_field_missing', 'pagination_failed',
+ 'partial_collection', 'network_failure'
+ )),
+ content_digest TEXT NOT NULL,
+ project_count INTEGER NOT NULL CHECK (project_count >= 0),
+ page_count INTEGER NOT NULL CHECK (page_count >= 0),
+ record_count INTEGER NOT NULL CHECK (record_count >= 0),
+ oldest_event_time_utc TEXT,
+ newest_event_time_utc TEXT,
+ last_success_at TEXT,
+ created_at TEXT NOT NULL
+ ) STRICT;
+
+ CREATE INDEX binance_collection_runs_status_capture
+ ON binance_collection_runs(status, capture_at DESC);
+
+ CREATE TABLE binance_source_captures (
+ capture_id TEXT PRIMARY KEY,
+ collection_run_id TEXT NOT NULL
+ REFERENCES binance_collection_runs(collection_run_id)
+ ON DELETE RESTRICT,
+ source_kind TEXT NOT NULL CHECK (
+ source_kind IN ('management', 'project_tab')
+ ),
+ project_id TEXT,
+ source_tab TEXT,
+ page INTEGER CHECK (page IS NULL OR page >= 1),
+ source_url TEXT NOT NULL,
+ capture_at TEXT NOT NULL,
+ record_count INTEGER NOT NULL CHECK (record_count >= 0),
+ payload_digest TEXT NOT NULL,
+ payload_json TEXT NOT NULL,
+ CHECK (
+ (source_kind = 'management'
+ AND project_id IS NULL AND source_tab IS NULL AND page IS NULL)
+ OR
+ (source_kind = 'project_tab'
+ AND project_id IS NOT NULL AND source_tab IS NOT NULL
+ AND page IS NOT NULL)
+ )
+ ) STRICT;
+
+ CREATE INDEX binance_source_captures_run
+ ON binance_source_captures(collection_run_id, source_kind, project_id,
+ source_tab, page);
+
+ CREATE TABLE binance_copy_project_snapshots (
+ collection_run_id TEXT NOT NULL
+ REFERENCES binance_collection_runs(collection_run_id)
+ ON DELETE RESTRICT,
+ project_id TEXT NOT NULL,
+ project_status TEXT NOT NULL CHECK (
+ project_status IN ('ongoing', 'ended')
+ ),
+ source_url TEXT NOT NULL,
+ captured_at TEXT NOT NULL,
+ summary_json TEXT NOT NULL,
+ PRIMARY KEY(collection_run_id, project_id)
+ ) STRICT;
+
+ CREATE TABLE binance_position_snapshots (
+ snapshot_id TEXT PRIMARY KEY,
+ collection_run_id TEXT NOT NULL
+ REFERENCES binance_collection_runs(collection_run_id)
+ ON DELETE RESTRICT,
+ project_id TEXT NOT NULL,
+ symbol TEXT NOT NULL,
+ position_side TEXT NOT NULL,
+ ordinal INTEGER NOT NULL CHECK (ordinal >= 1),
+ captured_at TEXT NOT NULL,
+ fields_json TEXT NOT NULL,
+ UNIQUE(collection_run_id, project_id, symbol, position_side, ordinal)
+ ) STRICT;
+
+ CREATE TABLE binance_copy_raw_records (
+ raw_record_id TEXT PRIMARY KEY,
+ collection_run_id TEXT NOT NULL
+ REFERENCES binance_collection_runs(collection_run_id)
+ ON DELETE RESTRICT,
+ current_record_key TEXT NOT NULL,
+ project_id TEXT NOT NULL,
+ source_tab TEXT NOT NULL,
+ page INTEGER NOT NULL CHECK (page >= 1),
+ row_ordinal INTEGER NOT NULL CHECK (row_ordinal >= 1),
+ capture_at TEXT NOT NULL,
+ original_event_time TEXT,
+ event_time_utc TEXT,
+ page_time_zone_assumption TEXT,
+ fields_json TEXT NOT NULL,
+ fields_digest TEXT NOT NULL,
+ UNIQUE(collection_run_id, project_id, source_tab, page, row_ordinal)
+ ) STRICT;
+
+ CREATE INDEX binance_copy_raw_records_project_event
+ ON binance_copy_raw_records(project_id, source_tab, event_time_utc);
+
+ CREATE TABLE binance_copy_record_current (
+ current_record_key TEXT PRIMARY KEY,
+ project_id TEXT NOT NULL,
+ source_tab TEXT NOT NULL,
+ original_event_time TEXT,
+ event_time_utc TEXT,
+ page_time_zone_assumption TEXT,
+ fields_json TEXT NOT NULL,
+ fields_digest TEXT NOT NULL,
+ first_collection_run_id TEXT NOT NULL
+ REFERENCES binance_collection_runs(collection_run_id)
+ ON DELETE RESTRICT,
+ last_collection_run_id TEXT NOT NULL
+ REFERENCES binance_collection_runs(collection_run_id)
+ ON DELETE RESTRICT,
+ first_seen_at TEXT NOT NULL,
+ last_seen_at TEXT NOT NULL
+ ) STRICT;
+
+ CREATE INDEX binance_copy_record_current_project_event
+ ON binance_copy_record_current(project_id, source_tab, event_time_utc);
+
+ CREATE TABLE binance_market_captures (
+ market_capture_id TEXT PRIMARY KEY,
+ workflow_run_id TEXT NOT NULL
+ REFERENCES workflow_runs(id) ON DELETE RESTRICT,
+ capture_at TEXT NOT NULL,
+ source_url TEXT NOT NULL,
+ symbols_payload_json TEXT NOT NULL,
+ symbols_digest TEXT NOT NULL,
+ candles_payload_json TEXT NOT NULL,
+ candles_digest TEXT NOT NULL,
+ references_payload_json TEXT NOT NULL,
+ references_digest TEXT NOT NULL,
+ symbol_count INTEGER NOT NULL CHECK (symbol_count >= 0),
+ candle_count INTEGER NOT NULL CHECK (candle_count >= 0),
+ funding_count INTEGER NOT NULL CHECK (funding_count >= 0),
+ reference_count INTEGER NOT NULL CHECK (reference_count >= 0),
+ created_at TEXT NOT NULL
+ ) STRICT;
+
+ CREATE TABLE binance_market_symbol_snapshots (
+ market_capture_id TEXT NOT NULL
+ REFERENCES binance_market_captures(market_capture_id)
+ ON DELETE RESTRICT,
+ symbol TEXT NOT NULL,
+ pair TEXT NOT NULL,
+ contract_type TEXT NOT NULL,
+ status TEXT NOT NULL,
+ onboard_date_utc TEXT,
+ delivery_date_utc TEXT,
+ base_asset TEXT NOT NULL,
+ quote_asset TEXT NOT NULL,
+ margin_asset TEXT NOT NULL,
+ PRIMARY KEY(market_capture_id, symbol)
+ ) STRICT;
+
+ CREATE TABLE binance_market_candles_1m (
+ symbol TEXT NOT NULL,
+ open_time_utc TEXT NOT NULL,
+ close_time_utc TEXT NOT NULL,
+ open TEXT NOT NULL,
+ high TEXT NOT NULL,
+ low TEXT NOT NULL,
+ close TEXT NOT NULL,
+ volume TEXT NOT NULL,
+ quote_volume TEXT NOT NULL,
+ trade_count INTEGER NOT NULL CHECK (trade_count >= 0),
+ first_market_capture_id TEXT NOT NULL
+ REFERENCES binance_market_captures(market_capture_id)
+ ON DELETE RESTRICT,
+ last_market_capture_id TEXT NOT NULL
+ REFERENCES binance_market_captures(market_capture_id)
+ ON DELETE RESTRICT,
+ first_seen_at TEXT NOT NULL,
+ last_seen_at TEXT NOT NULL,
+ PRIMARY KEY(symbol, open_time_utc)
+ ) STRICT;
+
+ CREATE TABLE binance_market_funding_rates (
+ symbol TEXT NOT NULL,
+ funding_time_utc TEXT NOT NULL,
+ funding_rate TEXT NOT NULL,
+ mark_price TEXT,
+ first_market_capture_id TEXT NOT NULL
+ REFERENCES binance_market_captures(market_capture_id)
+ ON DELETE RESTRICT,
+ last_market_capture_id TEXT NOT NULL
+ REFERENCES binance_market_captures(market_capture_id)
+ ON DELETE RESTRICT,
+ first_seen_at TEXT NOT NULL,
+ last_seen_at TEXT NOT NULL,
+ PRIMARY KEY(symbol, funding_time_utc)
+ ) STRICT;
+
+ CREATE TABLE binance_market_reference_snapshots (
+ market_capture_id TEXT NOT NULL
+ REFERENCES binance_market_captures(market_capture_id)
+ ON DELETE RESTRICT,
+ symbol TEXT NOT NULL,
+ mark_price TEXT NOT NULL,
+ index_price TEXT NOT NULL,
+ last_funding_rate TEXT NOT NULL,
+ next_funding_time_utc TEXT,
+ open_interest TEXT,
+ observed_at TEXT NOT NULL,
+ PRIMARY KEY(market_capture_id, symbol)
+ ) STRICT;
+
+ CREATE INDEX binance_market_reference_symbol_observed
+ ON binance_market_reference_snapshots(symbol, observed_at);
+ `
}
];
diff --git a/packages/persistence-sqlite/src/persistence-v8.test.ts b/packages/persistence-sqlite/src/persistence-v8.test.ts
index f92caf8e..85f7b1af 100644
--- a/packages/persistence-sqlite/src/persistence-v8.test.ts
+++ b/packages/persistence-sqlite/src/persistence-v8.test.ts
@@ -685,7 +685,7 @@ describe("migration v8", () => {
})
).toThrow("crash");
const recovered = new SqlitePersistence({ path });
- expect(recovered.health().schemaVersion).toBe(25);
+ expect(recovered.health().schemaVersion).toBe(26);
recovered.close();
});
});
diff --git a/packages/persistence-sqlite/src/persistence.test.ts b/packages/persistence-sqlite/src/persistence.test.ts
index f4ee5fd5..d161cbe7 100644
--- a/packages/persistence-sqlite/src/persistence.test.ts
+++ b/packages/persistence-sqlite/src/persistence.test.ts
@@ -27,6 +27,24 @@ import { migrations } from "./migrations.js";
const timestamp = "2026-07-27T00:00:00.000Z";
+function dropMigration26(database: Database.Database): void {
+ const tables = [
+ "binance_market_reference_snapshots",
+ "binance_market_funding_rates",
+ "binance_market_candles_1m",
+ "binance_market_symbol_snapshots",
+ "binance_market_captures",
+ "binance_copy_record_current",
+ "binance_copy_raw_records",
+ "binance_position_snapshots",
+ "binance_copy_project_snapshots",
+ "binance_source_captures",
+ "binance_collection_runs"
+ ];
+ database.exec(tables.map((table) => `DROP TABLE ${table};`).join("\n"));
+ database.prepare("DELETE FROM schema_migrations WHERE version=26").run();
+}
+
function event(
runId: string,
sequence: number,
@@ -1812,6 +1830,7 @@ describe("append-only migrations", () => {
const seeded = new SqlitePersistence({ path:databasePath });
seeded.close();
const v22 = new Database(databasePath);
+ dropMigration26(v22);
v22.exec(`
DROP TABLE external_domain_lease_reconciliations;
DROP TABLE external_domain_leases;
@@ -1821,7 +1840,7 @@ describe("append-only migrations", () => {
v22.close();
const upgraded = new SqlitePersistence({ path:databasePath });
- expect(upgraded.health().schemaVersion).toBe(25);
+ expect(upgraded.health().schemaVersion).toBe(26);
upgraded.close();
const inspected = new Database(databasePath,{ readonly:true });
expect(inspected.prepare(
@@ -1881,6 +1900,7 @@ describe("append-only migrations", () => {
store.close();
const v22 = new Database(databasePath);
+ dropMigration26(v22);
v22.exec(`
DROP TABLE external_domain_lease_reconciliations;
DROP TABLE external_domain_leases;
@@ -1988,7 +2008,7 @@ describe("append-only migrations", () => {
})
).toThrow("crash");
const store = new SqlitePersistence({ path: databasePath });
- expect(store.health().schemaVersion).toBe(25);
+ expect(store.health().schemaVersion).toBe(26);
store.close();
} finally {
rmSync(directory, { recursive: true, force: true });
@@ -2018,6 +2038,7 @@ describe("append-only migrations", () => {
seeded.close();
const legacy = new Database(databasePath);
+ dropMigration26(legacy);
legacy.exec(`
DROP TABLE external_domain_lease_reconciliations;
DROP TABLE external_domain_leases;
@@ -2115,7 +2136,7 @@ describe("append-only migrations", () => {
legacy.close();
const upgraded = new SqlitePersistence({ path: databasePath });
- expect(upgraded.health().schemaVersion).toBe(25);
+ expect(upgraded.health().schemaVersion).toBe(26);
expect(upgraded.getAssistanceTask(task.task.taskId)).toEqual(task);
expect(
upgraded.getAssistanceRequestResult("not-recorded")
@@ -2140,7 +2161,7 @@ describe("append-only migrations", () => {
})
).toThrow("crash");
const store = new SqlitePersistence({ path: databasePath });
- expect(store.health().schemaVersion).toBe(25);
+ expect(store.health().schemaVersion).toBe(26);
expect(store.getAssistanceRequestResult("not-recorded")).toBeUndefined();
store.close();
} finally {
@@ -2162,7 +2183,7 @@ describe("append-only migrations", () => {
})
).toThrow("crash");
const store = new SqlitePersistence({ path: databasePath });
- expect(store.health().schemaVersion).toBe(25);
+ expect(store.health().schemaVersion).toBe(26);
store.close();
} finally {
rmSync(directory, { recursive: true, force: true });
@@ -2176,6 +2197,7 @@ describe("append-only migrations", () => {
const seeded = new SqlitePersistence({ path: databasePath });
seeded.close();
const legacy = new Database(databasePath);
+ dropMigration26(legacy);
legacy.exec(`
DROP TABLE external_domain_lease_reconciliations;
DROP TABLE external_domain_leases;
@@ -2268,7 +2290,7 @@ describe("append-only migrations", () => {
legacy.close();
const upgraded = new SqlitePersistence({ path: databasePath });
- expect(upgraded.health().schemaVersion).toBe(25);
+ expect(upgraded.health().schemaVersion).toBe(26);
expect(
upgraded.createWorkflowDraft({
draftId: "v5-upgraded-draft",
@@ -2309,7 +2331,7 @@ describe("append-only migrations", () => {
})
).toThrow("crash");
const store = new SqlitePersistence({ path: databasePath });
- expect(store.health().schemaVersion).toBe(25);
+ expect(store.health().schemaVersion).toBe(26);
expect(store.getWorkflowDraft("not-created")).toBeUndefined();
store.close();
} finally {
@@ -2322,7 +2344,7 @@ describe("append-only migrations", () => {
const databasePath = join(directory, "bpa.sqlite3");
try {
const store = new SqlitePersistence({ path: databasePath });
- expect(store.health().schemaVersion).toBe(25);
+ expect(store.health().schemaVersion).toBe(26);
store.close();
const raw = new Database(databasePath);
raw
diff --git a/packages/persistence/src/index.ts b/packages/persistence/src/index.ts
index d540ddab..6aea9556 100644
--- a/packages/persistence/src/index.ts
+++ b/packages/persistence/src/index.ts
@@ -700,6 +700,223 @@ export interface OperationalFactStore {
): OperationalDatasetPublicationLineage | undefined;
}
+export type BinanceCollectionStatus =
+ | "success"
+ | "authenticated_but_no_data"
+ | "page_not_updated_yet"
+ | "login_required"
+ | "captcha_or_risk_control"
+ | "structure_changed"
+ | "required_field_missing"
+ | "pagination_failed"
+ | "partial_collection"
+ | "network_failure";
+
+export interface BinanceSourceCaptureInput {
+ captureId: string;
+ sourceKind: "management" | "project_tab";
+ projectId?: string;
+ sourceTab?: string;
+ page?: number;
+ sourceUrl: string;
+ captureAt: string;
+ recordCount: number;
+ payloadDigest: string;
+ payload: JsonValue;
+}
+
+export interface BinanceProjectSnapshotInput {
+ projectId: string;
+ projectStatus: "ongoing" | "ended";
+ sourceUrl: string;
+ capturedAt: string;
+ summary: JsonValue;
+}
+
+export interface BinancePositionSnapshotInput {
+ snapshotId: string;
+ projectId: string;
+ symbol: string;
+ positionSide: string;
+ ordinal: number;
+ capturedAt: string;
+ fields: JsonValue;
+}
+
+export interface BinanceRawRecordInput {
+ rawRecordId: string;
+ currentRecordKey: string;
+ projectId: string;
+ sourceTab: string;
+ page: number;
+ rowOrdinal: number;
+ captureAt: string;
+ originalEventTime?: string;
+ eventTimeUtc?: string;
+ pageTimeZoneAssumption?: string;
+ fields: JsonValue;
+ fieldsDigest: string;
+}
+
+export interface PersistBinanceCopyTradingCaptureInput {
+ collectionRunId: string;
+ workflowRunId: string;
+ sourceUrl: string;
+ attemptAt: string;
+ captureAt: string;
+ status: Extract<
+ BinanceCollectionStatus,
+ "success" | "authenticated_but_no_data" | "page_not_updated_yet"
+ >;
+ contentDigest: string;
+ projectCount: number;
+ pageCount: number;
+ recordCount: number;
+ oldestEventTimeUtc?: string;
+ newestEventTimeUtc?: string;
+ executionContext: OperationalExecutionContext;
+ sourceCaptures: readonly BinanceSourceCaptureInput[];
+ projects: readonly BinanceProjectSnapshotInput[];
+ positions: readonly BinancePositionSnapshotInput[];
+ rawRecords: readonly BinanceRawRecordInput[];
+}
+
+export interface BinanceCollectionRunRecord {
+ collectionRunId: string;
+ workflowRunId: string;
+ sourceUrl: string;
+ attemptAt: string;
+ captureAt: string;
+ status: BinanceCollectionStatus;
+ contentDigest: string;
+ projectCount: number;
+ pageCount: number;
+ recordCount: number;
+ oldestEventTimeUtc?: string;
+ newestEventTimeUtc?: string;
+ lastSuccessAt?: string;
+ createdAt: string;
+}
+
+export interface BinanceRawRecord extends BinanceRawRecordInput {
+ collectionRunId: string;
+}
+
+export interface BinanceCurrentRecord {
+ currentRecordKey: string;
+ projectId: string;
+ sourceTab: string;
+ originalEventTime?: string;
+ eventTimeUtc?: string;
+ pageTimeZoneAssumption?: string;
+ fields: JsonValue;
+ fieldsDigest: string;
+ firstCollectionRunId: string;
+ lastCollectionRunId: string;
+ firstSeenAt: string;
+ lastSeenAt: string;
+}
+
+export interface BinanceCopyTradingStore {
+ persistBinanceCopyTradingCapture(
+ input: PersistBinanceCopyTradingCaptureInput
+ ): {
+ status: "accepted" | "duplicate";
+ run: BinanceCollectionRunRecord;
+ newCurrentRecordCount: number;
+ };
+ getBinanceCollectionRun(
+ collectionRunId: string
+ ): BinanceCollectionRunRecord | undefined;
+ getLatestSuccessfulBinanceCollectionRun():
+ | BinanceCollectionRunRecord
+ | undefined;
+ listBinanceRawRecords(collectionRunId: string): BinanceRawRecord[];
+ listBinanceCurrentRecords(projectId?: string): BinanceCurrentRecord[];
+}
+
+export interface BinanceMarketCandleInput {
+ symbol: string;
+ openTimeUtc: string;
+ closeTimeUtc: string;
+ open: string;
+ high: string;
+ low: string;
+ close: string;
+ volume: string;
+ quoteVolume: string;
+ tradeCount: number;
+}
+
+export interface BinanceMarketFundingInput {
+ symbol: string;
+ fundingTimeUtc: string;
+ fundingRate: string;
+ markPrice?: string;
+}
+
+export interface BinanceMarketReferenceInput {
+ symbol: string;
+ markPrice: string;
+ indexPrice: string;
+ lastFundingRate: string;
+ nextFundingTimeUtc?: string;
+ openInterest?: string;
+ observedAt: string;
+}
+
+export interface PersistBinanceMarketCaptureInput {
+ marketCaptureId: string;
+ workflowRunId: string;
+ captureAt: string;
+ sourceUrl: string;
+ symbolsPayload: JsonValue;
+ symbolsDigest: string;
+ candlesPayload: JsonValue;
+ candlesDigest: string;
+ referencesPayload: JsonValue;
+ referencesDigest: string;
+ symbols: readonly {
+ symbol: string;
+ pair: string;
+ contractType: string;
+ status: string;
+ onboardDateUtc?: string;
+ deliveryDateUtc?: string;
+ baseAsset: string;
+ quoteAsset: string;
+ marginAsset: string;
+ }[];
+ candles: readonly BinanceMarketCandleInput[];
+ funding: readonly BinanceMarketFundingInput[];
+ references: readonly BinanceMarketReferenceInput[];
+ executionContext: OperationalExecutionContext;
+}
+
+export interface BinanceMarketCaptureRecord {
+ marketCaptureId: string;
+ workflowRunId: string;
+ captureAt: string;
+ sourceUrl: string;
+ symbolCount: number;
+ candleCount: number;
+ fundingCount: number;
+ referenceCount: number;
+ createdAt: string;
+}
+
+export interface BinanceMarketStore {
+ persistBinanceMarketCapture(input: PersistBinanceMarketCaptureInput): {
+ status: "accepted" | "duplicate";
+ capture: BinanceMarketCaptureRecord;
+ insertedCandleCount: number;
+ insertedFundingCount: number;
+ };
+ getBinanceMarketCapture(
+ marketCaptureId: string
+ ): BinanceMarketCaptureRecord | undefined;
+}
+
export interface DecisionRecordStore {
putDecision(record: DecisionRecordDefinition): DecisionRecordDefinition;
getActiveDecision(
@@ -1834,6 +2051,8 @@ export interface Persistence
AssistanceUnitOfWork,
DatasetPublicationUnitOfWork,
OperationalFactStore,
+ BinanceCopyTradingStore,
+ BinanceMarketStore,
DecisionRecordStore,
GatewayDeliveryUnitOfWork,
ExecutionStore,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 073b0499..b68d06a6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -91,6 +91,22 @@ importers:
specifier: ^4.0.18
version: 4.1.10(@types/node@24.13.3)(jsdom@27.4.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))
+ adapters/binance:
+ dependencies:
+ '@bpa/compiler':
+ specifier: workspace:*
+ version: link:../../packages/compiler
+ '@bpa/schemas':
+ specifier: workspace:*
+ version: link:../../packages/schemas
+ devDependencies:
+ '@types/jsdom':
+ specifier: ^21.1.7
+ version: 21.1.7
+ jsdom:
+ specifier: ^27.0.1
+ version: 27.4.0
+
adapters/doudian:
dependencies:
'@bpa/compiler':
@@ -204,6 +220,9 @@ importers:
apps/extension:
dependencies:
+ '@bpa/adapter-binance':
+ specifier: workspace:*
+ version: link:../../adapters/binance
'@bpa/adapter-doudian':
specifier: workspace:*
version: link:../../adapters/doudian
diff --git a/skills/doudian-alliance-retired-monitor/scripts/Install-DoudianAllianceMonitor.ps1 b/skills/doudian-alliance-retired-monitor/scripts/Install-DoudianAllianceMonitor.ps1
index 022645ca..d8f631b2 100644
--- a/skills/doudian-alliance-retired-monitor/scripts/Install-DoudianAllianceMonitor.ps1
+++ b/skills/doudian-alliance-retired-monitor/scripts/Install-DoudianAllianceMonitor.ps1
@@ -207,7 +207,7 @@ $RequiredAssets = @(
@{
type = "node"
file = "doudian.alliance.shops.discover.node.yaml"
- sha256 = "32c528191ff91a4c7710d5d5e21353f757f9ffd5a20d829dfd07a6b68cb695d7"
+ sha256 = "de3e160ada864906f371c4ffc7c2bc660797f22a1e4e3857cd58edf4405c8977"
},
@{
type = "node"
@@ -232,12 +232,12 @@ $RequiredAssets = @(
@{
type = "adapter"
file = "doudian-alliance.adapter.yaml"
- sha256 = "f1076b4ef181efc6b2c21d0100f47480275155176d9785dc8f2fbd7d68562f45"
+ sha256 = "61ae11189199e0ebefaa6b2d27977610a1ec8fd7a9f949ca1070a4598e74938b"
},
@{
type = "workflow"
file = "doudian.alliance-retired-products-monitor.workflow.yaml"
- sha256 = "f785df76afaca27e9b5acc28c8914f63623f628690ed73c7b6ae3e5033dc2c8e"
+ sha256 = "d430368219a90e086a3b8af4c6bf3ded3342b5ffe85a104b6db8e67d0300ec47"
}
)
foreach ($Asset in $RequiredAssets) {
diff --git a/workflows/examples/binance.copy-trading.management.snapshot.workflow.yaml b/workflows/examples/binance.copy-trading.management.snapshot.workflow.yaml
new file mode 100644
index 00000000..a0a18b3d
--- /dev/null
+++ b/workflows/examples/binance.copy-trading.management.snapshot.workflow.yaml
@@ -0,0 +1,174 @@
+apiVersion: bpa/v1alpha3
+kind: Workflow
+metadata:
+ id: binance.copy-trading.management.snapshot
+ version: 3.0.0
+ title: Binance 合约跟单全量只读采集
+ description: 绑定已登录管理页,采集进行中和已结束项目,逐项目遍历八个详情页签及全部分页,并在完整覆盖后单事务追加落库。
+spec:
+ riskLevel: R1
+ inputSchema: { type: object, additionalProperties: false }
+ outputSchema:
+ type: object
+ additionalProperties: false
+ required: [status, collection, market]
+ properties:
+ status: { enum: [success, authenticated_but_no_data, page_not_updated_yet] }
+ collection: { type: object }
+ market: { type: object }
+ limits: { maxDepth: 3, maxStepExecutions: 1100 }
+ resourceSlots:
+ binance_page:
+ kind: browser
+ capabilities: [browser.dom.read, browser.dom.write, browser.tabs.read]
+ allowedOrigins: [https://www.binance.com]
+ authentication: authenticated
+ purpose: 绑定已登录管理页,仅切换项目、详情页签和分页并恢复管理页
+ root:
+ kind: sequence
+ steps:
+ - key: read_management
+ kind: call
+ use: binance.copy-trading.management.snapshot.read@1.0.0
+ with: {}
+ timeout: 2m
+ resourceMappings: { browser: binance_page }
+ handlers:
+ failure:
+ kind: sequence
+ steps:
+ - key: collection_failed
+ kind: terminal
+ status: failed
+ error: { code: BINANCE_MANAGEMENT_COLLECTION_FAILED, message: Binance 管理页没有形成可信快照;上一次成功结果不得被空数据覆盖。 }
+ rejected:
+ kind: sequence
+ steps:
+ - key: collection_blocked
+ kind: terminal
+ status: rejected
+ error: { code: BINANCE_MANAGEMENT_COLLECTION_BLOCKED, message: 登录、验证码、风控或页面上下文变化阻断了只读采集。 }
+ timeout:
+ kind: sequence
+ steps:
+ - key: collection_timeout
+ kind: terminal
+ status: failed
+ error: { code: BINANCE_MANAGEMENT_COLLECTION_TIMEOUT, message: Binance 页面读取超过硬 Deadline,禁止将超时解释为真实空数据。 }
+ - key: collect_projects
+ kind: foreach
+ items: ${steps.read_management.output.projects}
+ itemName: project
+ indexName: project_index
+ itemKey: ${item.projectId}
+ maxItems: 500
+ maxDuration: 8h
+ onItemError: stop
+ body:
+ kind: sequence
+ steps:
+ - key: collect_project
+ kind: call
+ use: binance.copy-trading.project.detail.collect@1.0.0
+ with:
+ projectId: ${item.projectId}
+ projectStatus: ${item.status}
+ managementUrl: https://www.binance.com/zh-CN/copy-trading/copy-management
+ timeout: 10m
+ resourceMappings: { browser: binance_page }
+ retry:
+ maxAttempts: 2
+ backoff: 2s
+ retryableErrors: [PAGE_LOADING, BROWSER_DISCONNECTED, BINANCE_CONTENT_RESPONSE_TIMEOUT, BINANCE_DETAIL_TAB_TIMEOUT, BINANCE_PAGINATION_TIMEOUT]
+ handlers:
+ failure:
+ kind: sequence
+ steps:
+ - key: project_failed
+ kind: terminal
+ status: failed
+ error: { code: BINANCE_PROJECT_COLLECTION_FAILED, message: 当前项目详情未形成完整快照,禁止把部分分页解释为全量数据。 }
+ rejected:
+ kind: sequence
+ steps:
+ - key: project_blocked
+ kind: terminal
+ status: rejected
+ error: { code: BINANCE_PROJECT_COLLECTION_BLOCKED, message: 登录、风控、页面上下文或管理页恢复失败阻断了剩余项目。 }
+ timeout:
+ kind: sequence
+ steps:
+ - key: project_timeout
+ kind: terminal
+ status: uncertain
+ error: { code: BINANCE_PROJECT_COLLECTION_TIMEOUT, message: 项目详情采集超时且完整性未知。 }
+ - key: persist_capture
+ kind: call
+ use: binance.copy-trading.capture.persist@1.0.0
+ with:
+ management: ${steps.read_management.output}
+ projects: ${steps.collect_projects.output}
+ pageTimeZone: Asia/Shanghai
+ timeout: 2m
+ handlers:
+ failure:
+ kind: sequence
+ steps:
+ - key: persistence_failed
+ kind: terminal
+ status: failed
+ error: { code: BINANCE_CAPTURE_PERSIST_FAILED, message: 完整采集未能原子落库;上一次成功状态和数据保持不变。 }
+ rejected:
+ kind: sequence
+ steps:
+ - key: persistence_rejected
+ kind: terminal
+ status: rejected
+ error: { code: BINANCE_CAPTURE_PERSIST_REJECTED, message: 持久化权限或运行身份不匹配,本轮没有提交金融数据。 }
+ timeout:
+ kind: sequence
+ steps:
+ - key: persistence_timeout
+ kind: terminal
+ status: uncertain
+ error: { code: BINANCE_CAPTURE_PERSIST_TIMEOUT, message: 持久化超时且提交状态不确定,禁止自动重试写入。 }
+ - key: collect_market_reference
+ kind: call
+ use: binance.futures.market-reference.collect@1.0.0
+ with:
+ projects: ${steps.collect_projects.output}
+ pageTimeZone: Asia/Shanghai
+ timeout: 30m
+ retry:
+ maxAttempts: 2
+ backoff: 30s
+ retryableErrors: [BINANCE_MARKET_NETWORK_FAILURE, BINANCE_MARKET_HTTP_FAILURE, BINANCE_MARKET_RATE_LIMITED]
+ handlers:
+ failure:
+ kind: sequence
+ steps:
+ - key: market_failed
+ kind: terminal
+ status: failed
+ error: { code: BINANCE_MARKET_COLLECTION_FAILED, message: 官方市场参考数据未形成完整窗口;跟单原始数据已保存,但本轮整体不得宣称完整。 }
+ rejected:
+ kind: sequence
+ steps:
+ - key: market_rejected
+ kind: terminal
+ status: rejected
+ error: { code: BINANCE_MARKET_COLLECTION_REJECTED, message: 市场数据只读权限或固定 Origin 不匹配。 }
+ timeout:
+ kind: sequence
+ steps:
+ - key: market_timeout
+ kind: terminal
+ status: failed
+ error: { code: BINANCE_MARKET_COLLECTION_TIMEOUT, message: 官方市场数据窗口采集超时,禁止发布不完整参考数据。 }
+ - key: complete
+ kind: terminal
+ status: succeeded
+ output:
+ status: ${steps.persist_capture.output.status}
+ collection: ${steps.persist_capture.output}
+ market: ${steps.collect_market_reference.output}
diff --git a/workflows/examples/doudian.alliance-retired-products-monitor.workflow.yaml b/workflows/examples/doudian.alliance-retired-products-monitor.workflow.yaml
index a6986893..24eda083 100644
--- a/workflows/examples/doudian.alliance-retired-products-monitor.workflow.yaml
+++ b/workflows/examples/doudian.alliance-retired-products-monitor.workflow.yaml
@@ -50,7 +50,7 @@ spec:
use: doudian.alliance.shops.discover@2.0.0
with:
maxShops: ${input.maxShops}
- timeout: 2m
+ timeout: 5m
resourceMappings: { browser: alliance_browser }
retry:
maxAttempts: 2