diff --git a/adapters/binance/binance-copy-trading.adapter.yaml b/adapters/binance/binance-copy-trading.adapter.yaml new file mode 100644 index 00000000..9168e778 --- /dev/null +++ b/adapters/binance/binance-copy-trading.adapter.yaml @@ -0,0 +1,23 @@ +apiVersion: bpa.adapter/v1alpha1 +kind: Adapter +metadata: + id: binance-copy-trading + version: 1.0.0 + title: Binance 合约跟单只读采集 Adapter + description: 从用户已登录的 Binance 合约跟单管理页读取账户汇总、项目摘要和当前可见仓位;不读取认证存储,不执行交易或设置操作。 +platform: binance +origins: [https://www.binance.com] +extension: { minimumVersion: 0.6.2 } +capabilities: + - nodeId: binance.copy-trading.management.snapshot.read + nodeVersions: [1.0.0] + handlerId: binance.copy-trading.management.snapshot.read + handlerVersion: 1.0.0 + implementationDigest: sha256:9996a1ee4f02435581bff8d92fe9ba6418d1889f014e215689a14cac9040e083 + permissions: [browser.dom.read, browser.dom.write, browser.tabs.read] + - nodeId: binance.copy-trading.project.detail.collect + nodeVersions: [1.0.0] + handlerId: binance.copy-trading.project.detail.collect + handlerVersion: 1.0.0 + implementationDigest: sha256:9996a1ee4f02435581bff8d92fe9ba6418d1889f014e215689a14cac9040e083 + permissions: [browser.dom.read, browser.dom.write, browser.tabs.read] diff --git a/adapters/binance/package.json b/adapters/binance/package.json new file mode 100644 index 00000000..9627ba07 --- /dev/null +++ b/adapters/binance/package.json @@ -0,0 +1,17 @@ +{ + "name": "@bpa/adapter-binance", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "@bpa/compiler": "workspace:*", + "@bpa/schemas": "workspace:*" + }, + "devDependencies": { + "@types/jsdom": "^21.1.7", + "jsdom": "^27.0.1" + } +} diff --git a/adapters/binance/src/index.test.ts b/adapters/binance/src/index.test.ts new file mode 100644 index 00000000..23c8bf0e --- /dev/null +++ b/adapters/binance/src/index.test.ts @@ -0,0 +1,124 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { parseWorkflowYaml } from "@bpa/compiler"; +import { JSDOM } from "jsdom"; +import { describe, expect, it } from "vitest"; +import { + collectBinanceManagementSnapshot, + detectBinanceRiskSignals, + readBinanceManagementSnapshot +} from "./index.js"; + +function page(body: string, url = "https://www.binance.com/zh-CN/copy-trading/copy-management"): Document { + return new JSDOM(`
${body}
`, { url }).window.document; +} + +describe("Binance copy-trading adapter", () => { + it("pins the exact read-only browser implementation", () => { + const adapter = parseWorkflowYaml(readFileSync( + new URL("../binance-copy-trading.adapter.yaml", import.meta.url), + "utf8" + )) as { + metadata: { version: string }; + extension: { minimumVersion: string }; + capabilities: Array<{ implementationDigest: string; permissions: string[] }>; + }; + const implementationDigest = `sha256:${createHash("sha256") + .update([ + "apps/extension/src/entrypoints/content.ts", + "apps/extension/src/lib/capability-manifest.ts", + "apps/extension/src/lib/content-action-router.ts", + "apps/extension/src/lib/page-observer-registry.ts", + "apps/extension/src/lib/adapter-node-registry.ts", + "apps/extension/src/lib/binance-detail-background.ts", + "apps/extension/src/lib/binance-detail-content.ts", + "adapters/binance/src/index.ts", + "adapters/binance/src/project-detail.ts" + ].map((path) => readFileSync(new URL(`../../../${path}`, import.meta.url))).join("\n")) + .digest("hex")}`; + expect(adapter).toMatchObject({ + metadata: { version: "1.0.0" }, + extension: { minimumVersion: "0.6.2" }, + capabilities: [ + { + implementationDigest, + permissions: ["browser.dom.read", "browser.dom.write", "browser.tabs.read"] + }, + { + implementationDigest, + permissions: ["browser.dom.read", "browser.dom.write", "browser.tabs.read"] + } + ] + }); + }); + it("reads account summary and a project without collecting trader names", () => { + const document = page(` +
保证金余额1,000 USDT
+
+ 项目 ID:project_1001跟单时间2026-08-01 + 净利润25 USDT交易员显示名不得落库的姓名 + +
+ `); + const result = readBinanceManagementSnapshot(document, document.defaultView!.location.href, new Date("2026-08-12T04:00:00.000Z")); + expect(result).toMatchObject({ status: "complete", accountSummary: { 保证金余额: "1,000 USDT" }, projects: [{ projectId: "project_1001", status: "ongoing", summary: { 跟单时间: "2026-08-01", 净利润: "25 USDT" } }], formMutations: 0 }); + expect(JSON.stringify(result)).not.toContain("不得落库的姓名"); + }); + + it("accepts an explicit empty state", () => { + expect(readBinanceManagementSnapshot(page("
暂无进行中跟单项目
"))).toMatchObject({ status: "empty_confirmed", projects: [] }); + }); + + it("finds a project that was already expanded before collection", () => { + const document = page(` +
+ 项目 ID:project_1001净利润25 USDT +
+
+ `); + expect(readBinanceManagementSnapshot(document).projects).toMatchObject([ + { projectId: "project_1001", summary: { 净利润: "25 USDT" } } + ]); + }); + + it("collects ongoing and ended projects and restores the original tab", async () => { + const document = page(` + + +
+ 项目 ID:ongoing_1001净利润1 USDT +
+ `); + const [ongoing, ended] = Array.from( + document.querySelectorAll("[role='tab']") + ); + const projects = document.querySelector("#projects")!; + const activate = (button: HTMLButtonElement, id: string): void => { + ongoing!.setAttribute("aria-selected", String(button === ongoing)); + ended!.setAttribute("aria-selected", String(button === ended)); + projects.setAttribute("data-project-id", id); + projects.innerHTML = `项目 ID:${id}净利润1 USDT`; + }; + ongoing!.addEventListener("click", () => activate(ongoing!, "ongoing_1001")); + ended!.addEventListener("click", () => activate(ended!, "ended_1001")); + const result = await collectBinanceManagementSnapshot( + document, + document.defaultView!.location.href, + { deadline: new Date(Date.now() + 5_000).toISOString() } + ); + expect(result.projects.map((project) => project.projectId)).toEqual([ + "ongoing_1001", + "ended_1001" + ]); + expect(document.querySelector("[role='tab'][aria-selected='true']")?.textContent).toBe("进行中"); + }); + + it("fails closed on structure drift", () => { + expect(() => readBinanceManagementSnapshot(page("
页面已改版
"))).toThrow("BINANCE_STRUCTURE_UNCONFIRMED"); + }); + + it("blocks login, captcha and risk control", () => { + expect(detectBinanceRiskSignals(page("请输入验证码"))[0]).toMatchObject({ code: "CAPTCHA_REQUIRED", severity: "blocking" }); + expect(detectBinanceRiskSignals(page("", "https://www.binance.com/zh-CN/login"))[0]).toMatchObject({ code: "SESSION_EXPIRED" }); + }); +}); diff --git a/adapters/binance/src/index.ts b/adapters/binance/src/index.ts new file mode 100644 index 00000000..784110a8 --- /dev/null +++ b/adapters/binance/src/index.ts @@ -0,0 +1,336 @@ +import type { RiskSignal } from "@bpa/schemas"; + +export const BINANCE_ADAPTER_ID = "binance-copy-trading"; +export const BINANCE_ADAPTER_VERSION = "1.0.0"; +export const BINANCE_ORIGIN = "https://www.binance.com"; +export const BINANCE_MANAGEMENT_PATH = "/zh-CN/copy-trading/copy-management"; + +export * from "./project-detail.js"; + +const ACCOUNT_LABELS = [ + "保证金余额", + "钱包余额", + "已实现总盈亏", + "净利润" +] as const; + +const PROJECT_LABELS = [ + "跟单时间", + "净跟单金额", + "保证金余额", + "已实现盈亏", + "未实现盈亏", + "累计分润", + "净利润", + "分润比例", + "止损状态" +] as const; + +const POSITION_HEADERS = [ + "交易对", + "方向", + "杠杆", + "大小", + "保证金", + "收益率", + "开仓价", + "标记价", + "强平价" +] as const; + +function normalizeText(value: string | null | undefined): string { + return (value ?? "").normalize("NFKC").replace(/\s+/gu, " ").trim(); +} + +function pageText(document: Document): string { + return normalizeText(document.body?.innerText ?? document.body?.textContent) + .slice(0, 200_000); +} + +function blockingSignal( + code: RiskSignal["code"], + category: RiskSignal["category"], + detail: string, + detectedAt: Date +): RiskSignal { + return { + code, + category, + severity: "blocking", + source: "page", + detected_at: detectedAt.toISOString(), + detail + }; +} + +export function detectBinanceRiskSignals( + document: Document, + pageUrl = document.defaultView?.location.href ?? "", + detectedAt = new Date() +): RiskSignal[] { + let url: URL; + try { + url = new URL(pageUrl); + } catch { + return [blockingSignal("PAGE_CONTEXT_CHANGED", "page_context", "Binance 页面 URL 无法解析。", detectedAt)]; + } + if (url.origin !== BINANCE_ORIGIN) { + return [blockingSignal("PAGE_CONTEXT_CHANGED", "page_context", "当前页面不是 Binance 主站。", detectedAt)]; + } + if (/login|register|passport|signin|authorize/iu.test(url.pathname)) { + return [blockingSignal("SESSION_EXPIRED", "session", "Binance 会话需要人工重新登录。", detectedAt)]; + } + const text = pageText(document); + const definitions: Array<[RegExp, RiskSignal["code"], RiskSignal["category"], string]> = [ + [/(?:请完成|需要|进行)(?:安全)?验证|滑块验证|请输入验证码|captcha/iu, "CAPTCHA_REQUIRED", "challenge", "Binance 页面要求人工完成验证。"], + [/访问过于频繁|操作过于频繁|请求过于频繁|too many requests|try again later/iu, "RATE_LIMITED", "throttle", "Binance 页面提示访问频率过高。"], + [/当前访问存在风险|检测到异常操作|账号存在风险|risk control|suspicious activity/iu, "RISK_CONTROL", "challenge", "Binance 风控阻断了只读采集。"] + ]; + return definitions + .filter(([pattern]) => pattern.test(text)) + .map(([, code, category, detail]) => blockingSignal(code, category, detail, detectedAt)); +} + +function visible(element: Element): boolean { + let current: Element | null = element; + for (let depth = 0; current && depth < 20; depth += 1) { + if (current.hasAttribute("hidden") || current.getAttribute("aria-hidden") === "true") return false; + const style = current.getAttribute("style") ?? ""; + if (/display\s*:\s*none|visibility\s*:\s*hidden/iu.test(style)) return false; + const computed = current.ownerDocument.defaultView?.getComputedStyle(current); + if (computed?.display === "none" || computed?.visibility === "hidden") return false; + current = current.parentElement; + } + return true; +} + +function candidateElements(document: Document): Element[] { + return Array.from(document.querySelectorAll( + "main span,main div,main p,main dt,main dd,main td,main th,[role='main'] span,[role='main'] div" + )).filter(visible).slice(0, 20_000); +} + +function labeledFields(root: ParentNode, labels: readonly string[]): Record { + const elements = Array.from(root.querySelectorAll("span,div,p,dt,dd,td,th")) + .filter(visible) + .slice(0, 5_000); + const result: Record = {}; + for (const label of labels) { + const element = elements.find((candidate) => normalizeText(candidate.textContent) === label); + if (!element) continue; + const candidates = [ + element.nextElementSibling, + element.parentElement?.nextElementSibling, + element.parentElement + ]; + const value = candidates + .map((candidate) => normalizeText(candidate?.textContent)) + .find((candidate) => candidate.length > 0 && candidate !== label && candidate.length <= 500); + if (value) result[label] = value; + } + return result; +} + +function projectIdFromElement(element: Element): string | undefined { + const attributes = [ + element.getAttribute("data-project-id"), + element.getAttribute("data-portfolio-id"), + element.getAttribute("href") + ].filter((value): value is string => Boolean(value)); + for (const value of attributes) { + const match = value.match(/(?:project|portfolio|leadPortfolio)(?:Id|_id)?[=/:-]([A-Za-z0-9_-]{4,120})/iu); + if (match?.[1]) return match[1]; + } + const text = normalizeText(element.textContent); + return text.match(/^(?:项目\s*ID|Project\s*ID)\s*[::]?\s*([A-Za-z0-9_-]{4,120})$/iu)?.[1]; +} + +function nearestProjectRoot(element: Element): Element { + let current = element; + for (let depth = 0; current && depth < 10; depth += 1) { + const text = normalizeText(current.textContent); + const detailControls = Array.from(current.querySelectorAll( + "button,[role='button'],a,span,div,p" + )).filter((candidate) => + visible(candidate) && ["展开详情", "收起详情", "收起"].includes( + normalizeText(candidate.textContent) + ) + ); + if ( + detailControls.length === 1 && + PROJECT_LABELS.some((label) => text.includes(label)) + ) return current; + if (!current.parentElement) break; + current = current.parentElement; + } + throw new Error("BINANCE_PROJECT_CARD_MISSING"); +} + +export interface BinancePositionRow { + readonly values: Readonly>; +} + +export interface BinanceCopyProject { + readonly projectId: string; + readonly status: "ongoing" | "ended"; + readonly summary: Readonly>; + readonly currentPositions: readonly BinancePositionRow[]; +} + +export interface BinanceManagementSnapshot { + readonly schemaVersion: "binance-copy-trading/v0.1"; + readonly status: "complete" | "empty_confirmed"; + readonly observedAt: string; + readonly pageUrl: string; + readonly accountSummary: Readonly>; + readonly activeTab: "ongoing" | "ended"; + readonly projects: readonly BinanceCopyProject[]; + readonly warnings: readonly string[]; + readonly formMutations: 0; +} + +function managementTabControl( + document: Document, + label: "进行中" | "已结束" +): HTMLElement { + const matches = (value: string): boolean => { + const normalized = normalizeText(value); + if (normalized === label) return true; + const suffix = normalized.slice(label.length).replace(/\s+/gu, ""); + return normalized.startsWith(label) && /^(?:\(\d+\)|(\d+)|\d+)$/u.test(suffix); + }; + const controls = Array.from(document.querySelectorAll( + "[role='tab'],button,[role='button']" + )).filter( + (element) => visible(element) && matches(element.textContent ?? "") + ); + if (controls.length !== 1) throw new Error("BINANCE_MANAGEMENT_TAB_AMBIGUOUS"); + return controls[0]!; +} + +function managementSignature(snapshot: BinanceManagementSnapshot): string { + return `${snapshot.activeTab}:${snapshot.status}:${snapshot.projects + .map((project) => project.projectId) + .join("\u0000")}`; +} + +export async function collectBinanceManagementSnapshot( + document: Document, + pageUrl = document.defaultView?.location.href ?? "", + options: { + readonly deadline: string; + readonly wait?: (milliseconds: number) => Promise; + readonly observedAt?: Date; + } +): Promise { + const deadline = Date.parse(options.deadline); + if (!Number.isFinite(deadline) || Date.now() >= deadline) { + throw new Error("DEADLINE_EXCEEDED"); + } + const wait = options.wait ?? ((milliseconds: number) => + new Promise((resolve) => setTimeout(resolve, milliseconds))); + const initial = readBinanceManagementSnapshot(document, pageUrl, options.observedAt); + const collected: BinanceCopyProject[] = []; + const seen = new Set(); + const add = (snapshot: BinanceManagementSnapshot): void => { + for (const project of snapshot.projects) { + if (seen.has(project.projectId)) throw new Error("BINANCE_PROJECT_DUPLICATED_ACROSS_TABS"); + seen.add(project.projectId); + collected.push(project); + } + }; + const initialLabel = initial.activeTab === "ended" ? "已结束" : "进行中"; + try { + for (const target of ["ongoing", "ended"] as const) { + const label = target === "ended" ? "已结束" : "进行中"; + let snapshot = readBinanceManagementSnapshot(document, pageUrl, options.observedAt); + if (snapshot.activeTab !== target) { + const before = managementSignature(snapshot); + managementTabControl(document, label).click(); + while (Date.now() < deadline) { + snapshot = readBinanceManagementSnapshot(document, pageUrl, options.observedAt); + if (snapshot.activeTab === target && managementSignature(snapshot) !== before) break; + await wait(120); + } + if (snapshot.activeTab !== target) throw new Error("BINANCE_MANAGEMENT_TAB_TIMEOUT"); + } + add(snapshot); + } + } finally { + const current = readBinanceManagementSnapshot(document, pageUrl, options.observedAt); + if (current.activeTab !== initial.activeTab) { + managementTabControl(document, initialLabel).click(); + const cleanupDeadline = Math.max(deadline, Date.now() + 5_000); + while (Date.now() < cleanupDeadline) { + if (readBinanceManagementSnapshot(document, pageUrl, options.observedAt).activeTab === initial.activeTab) break; + await wait(120); + } + if (readBinanceManagementSnapshot(document, pageUrl, options.observedAt).activeTab !== initial.activeTab) { + throw new Error("BINANCE_MANAGEMENT_RESTORE_FAILED"); + } + } + } + return { + ...initial, + status: collected.length === 0 ? "empty_confirmed" : "complete", + projects: collected, + warnings: [], + formMutations: 0 + }; +} + +function positionRows(root: ParentNode): BinancePositionRow[] { + const rows = Array.from(root.querySelectorAll("tr,[role='row']")).filter(visible).slice(0, 500); + return rows.flatMap((row) => { + const cells = Array.from(row.querySelectorAll("th,td,[role='cell'],[role='gridcell']")) + .map((cell) => normalizeText(cell.textContent)); + if (cells.length < 2 || cells.some((value) => POSITION_HEADERS.includes(value as never))) return []; + const values = Object.fromEntries(POSITION_HEADERS.slice(0, cells.length).map((header, index) => [header, cells[index]!])) as Record; + return [{ values }]; + }); +} + +export function readBinanceManagementSnapshot( + document: Document, + pageUrl = document.defaultView?.location.href ?? "", + observedAt = new Date() +): BinanceManagementSnapshot { + const url = new URL(pageUrl); + if (url.origin !== BINANCE_ORIGIN || !url.pathname.startsWith(BINANCE_MANAGEMENT_PATH)) { + throw new Error("PAGE_MISMATCH"); + } + const risks = detectBinanceRiskSignals(document, pageUrl, observedAt); + if (risks.some((signal) => signal.severity === "blocking")) throw new Error(risks[0]!.code); + const text = pageText(document); + const activeTab = /已结束|Ended/iu.test( + normalizeText(document.querySelector("[role='tab'][aria-selected='true']")?.textContent) + ) ? "ended" : "ongoing"; + const projectElements = candidateElements(document).filter((element) => projectIdFromElement(element) !== undefined); + const seen = new Set(); + const projects = projectElements.flatMap((element) => { + const projectId = projectIdFromElement(element)!; + if (seen.has(projectId)) return []; + seen.add(projectId); + const root = nearestProjectRoot(element); + const project: BinanceCopyProject = { + projectId, + status: /已结束|Ended/iu.test(normalizeText(root.textContent)) ? "ended" : activeTab, + summary: labeledFields(root, PROJECT_LABELS), + currentPositions: positionRows(root) + }; + return [project]; + }); + const explicitEmpty = /暂无(?:进行中|已结束)?(?:跟单|项目|数据)|没有(?:进行中|已结束)?(?:跟单|项目)|No (?:copy|project|data)/iu.test(text); + if (projects.length === 0 && !explicitEmpty) throw new Error("BINANCE_STRUCTURE_UNCONFIRMED"); + return { + schemaVersion: "binance-copy-trading/v0.1", + status: projects.length === 0 ? "empty_confirmed" : "complete", + observedAt: observedAt.toISOString(), + pageUrl: url.href, + accountSummary: labeledFields(document, ACCOUNT_LABELS), + activeTab, + projects, + warnings: ["DETAIL_TABS_NOT_COLLECTED_IN_V0_1"], + formMutations: 0 + }; +} diff --git a/adapters/binance/src/project-detail.test.ts b/adapters/binance/src/project-detail.test.ts new file mode 100644 index 00000000..d97aa2e1 --- /dev/null +++ b/adapters/binance/src/project-detail.test.ts @@ -0,0 +1,166 @@ +import { JSDOM } from "jsdom"; +import { describe, expect, it } from "vitest"; +import { + collectBinanceProjectDetail, + readBinanceDetailPage, + validateBinanceProjectTarget +} from "./project-detail.js"; + +const managementUrl = + "https://www.binance.com/zh-CN/copy-trading/copy-management"; + +function page(body: string): Document { + return new JSDOM(`
项目 ID:project_1001${body}
`, { + url: managementUrl + }).window.document; +} + +describe("Binance project detail collector", () => { + it("accepts only a status-bound Binance management target", () => { + expect(validateBinanceProjectTarget({ + projectId: "project_1001", + projectStatus: "ongoing", + managementUrl + })).toEqual({ projectId: "project_1001", projectStatus: "ongoing", managementUrl }); + expect(() => validateBinanceProjectTarget({ + projectId: "project_1001", + projectStatus: "ongoing", + managementUrl: "https://evil.example/copy-management" + })).toThrow("BINANCE_PROJECT_TARGET_INVALID"); + }); + + it("keeps legitimate duplicate trades using page and row ordinal", () => { + const document = page(` + + + + + +
时间合约价格数量交易员显示名
2026-08-12 12:00:00BTCUSDT1200000.01敏感姓名
2026-08-12 12:00:00BTCUSDT1200000.01敏感姓名
+ `); + const result = readBinanceDetailPage(document, { + projectId: "project_1001", + sourceTab: "交易历史" + }); + expect(result.records).toHaveLength(2); + expect(result.records[0]!.recordKey).not.toBe(result.records[1]!.recordKey); + expect(JSON.stringify(result)).not.toContain("敏感姓名"); + }); + + it("walks all eight tabs and restores the initially active tab", async () => { + const labels = ["仓位", "仓位历史记录", "历史委托", "交易历史", "分润记录", "转账记录", "资金费用", "跟单失败订单"]; + const document = page(` + + +
项目 ID:project_1001 + +
+ `); + const toggle = document.querySelector("#toggle")!; + const details = document.querySelector("#details")!; + toggle.addEventListener("click", () => { + const opening = toggle.textContent === "展开详情"; + toggle.textContent = opening ? "收起详情" : "展开详情"; + details.hidden = !opening; + details.innerHTML = opening ? ` +
${labels.map((label, index) => + `` + ).join("")}
+
总交易手续费-1.2 USDT
+ +
时间合约
2026-08-12BTCUSDT
` : ""; + for (const button of details.querySelectorAll("[role='tab']")) { + button.addEventListener("click", () => { + for (const other of details.querySelectorAll("[role='tab']")) { + other.setAttribute("aria-selected", String(other === button)); + } + }); + } + }); + for (const button of document.querySelectorAll("main > [role='tab']")) { + button.addEventListener("click", () => { + for (const other of document.querySelectorAll("main > [role='tab']")) { + other.setAttribute("aria-selected", String(other === button)); + } + }); + } + const result = await collectBinanceProjectDetail(document, { + projectId: "project_1001", + projectStatus: "ongoing", + managementUrl + }, { deadline: new Date(Date.now() + 5_000).toISOString() }); + expect(result.tabs).toHaveLength(8); + expect(result.tabs.every((tab) => tab.pageCount === 1)).toBe(true); + expect(result.tabs[0]!.summary).toEqual({ 总交易手续费: "-1.2 USDT" }); + expect(toggle.textContent).toBe("展开详情"); + expect(document.querySelector("main > [role='tab'][aria-selected='true']")?.textContent).toBe("进行中 (3)"); + }); + + it("preserves a project that was already expanded by the user", async () => { + const labels = ["仓位", "仓位历史记录", "历史委托", "交易历史", "分润记录", "转账记录", "资金费用", "跟单失败订单"]; + const document = page(` + + +
项目 ID:project_1001 + +
+ ${labels.map((label, index) => ``).join("")} + +
时间合约
2026-08-12BTCUSDT
+
+
+ `); + const details = document.querySelector("#details")!; + for (const button of details.querySelectorAll("[role='tab']")) { + button.addEventListener("click", () => { + for (const other of details.querySelectorAll("[role='tab']")) { + other.setAttribute("aria-selected", String(other === button)); + } + }); + } + await collectBinanceProjectDetail(document, { + projectId: "project_1001", + projectStatus: "ongoing", + managementUrl + }, { deadline: new Date(Date.now() + 5_000).toISOString() }); + expect(document.querySelector("#toggle")?.textContent).toBe("收起详情"); + }); + + it("advances pagination until the last page", async () => { + const document = page(` + + +
时间合约
2026-08-11BTCUSDT
+ 1 + + `); + const current = document.querySelector("[aria-current='page']")!; + const time = document.querySelector("#time")!; + const next = document.querySelector("[aria-label='下一页']")!; + next.addEventListener("click", () => { + current.textContent = "2"; + time.textContent = "2026-08-12"; + next.disabled = true; + }); + const first = readBinanceDetailPage(document, { + projectId: "project_1001", + sourceTab: "交易历史" + }); + expect(first).toMatchObject({ page: 1, hasNextPage: true }); + next.click(); + const second = readBinanceDetailPage(document, { + projectId: "project_1001", + sourceTab: "交易历史" + }); + expect(second).toMatchObject({ page: 2, hasNextPage: false }); + expect(second.signature).not.toBe(first.signature); + }); + + it("fails closed when a selected tab has no table or explicit empty state", () => { + const document = page('页面改版'); + expect(() => readBinanceDetailPage(document, { + projectId: "project_1001", + sourceTab: "资金费用" + })).toThrow("BINANCE_DETAIL_STRUCTURE_UNCONFIRMED"); + }); +}); diff --git a/adapters/binance/src/project-detail.ts b/adapters/binance/src/project-detail.ts new file mode 100644 index 00000000..3aab34b5 --- /dev/null +++ b/adapters/binance/src/project-detail.ts @@ -0,0 +1,568 @@ +import { detectBinanceRiskSignals } from "./index.js"; + +export const BINANCE_DETAIL_TAB_LABELS = [ + "仓位", + "仓位历史记录", + "历史委托", + "交易历史", + "分润记录", + "转账记录", + "资金费用", + "跟单失败订单" +] as const; + +export type BinanceDetailTab = (typeof BINANCE_DETAIL_TAB_LABELS)[number]; + +const BINANCE_ORIGIN = "https://www.binance.com"; +const BINANCE_MANAGEMENT_PATH = "/zh-CN/copy-trading/copy-management"; +const MAX_PAGES_PER_TAB = 100; +const MAX_ROWS_PER_TAB = 10_000; +const SENSITIVE_HEADER = /交易员|带单员|trader\s*(?:display\s*)?name|display\s*name/iu; +const TAB_SUMMARY_LABELS = [ + "总交易手续费", + "总资金费用", + "分润前总盈亏", + "分润金额" +] as const; + +function normalize(value: string | null | undefined): string { + return (value ?? "").normalize("NFKC").replace(/\s+/gu, " ").trim(); +} + +function visible(element: Element): boolean { + let current: Element | null = element; + for (let depth = 0; current && depth < 20; depth += 1) { + if (current.hasAttribute("hidden") || current.getAttribute("aria-hidden") === "true") return false; + const style = current.getAttribute("style") ?? ""; + if (/display\s*:\s*none|visibility\s*:\s*hidden/iu.test(style)) return false; + const computed = current.ownerDocument.defaultView?.getComputedStyle(current); + if (computed?.display === "none" || computed?.visibility === "hidden") return false; + current = current.parentElement; + } + return true; +} + +function exactVisibleElements(root: ParentNode, label: string): HTMLElement[] { + return Array.from(root.querySelectorAll( + "[role='tab'],button,[role='button']" + )).filter((element) => visible(element) && normalize(element.textContent) === label); +} + +function uniqueTabControl(root: ParentNode, label: BinanceDetailTab): HTMLElement { + const candidates = exactVisibleElements(root, label); + if (candidates.length !== 1) throw new Error("BINANCE_DETAIL_TAB_AMBIGUOUS"); + return candidates[0]!; +} + +function selectedTab(root: ParentNode): BinanceDetailTab | undefined { + const selected = Array.from(root.querySelectorAll( + "[role='tab'][aria-selected='true'],[role='tab'][data-state='active'],[role='tab'][class*='active']" + )).filter(visible); + const labels = selected + .map((element) => normalize(element.textContent)) + .filter((label): label is BinanceDetailTab => + BINANCE_DETAIL_TAB_LABELS.includes(label as BinanceDetailTab) + ); + return labels.length === 1 ? labels[0] : undefined; +} + +function pageNumber(root: ParentNode): number { + const candidates = Array.from(root.querySelectorAll( + "[aria-current='page'],[class*='pagination'] [class*='active'],[class*='pagination-item-active']" + )).filter(visible); + const numbers = candidates + .map((element) => Number(normalize(element.textContent))) + .filter((value) => Number.isSafeInteger(value) && value >= 1); + const unique = [...new Set(numbers)]; + if (unique.length > 1) throw new Error("BINANCE_PAGINATION_AMBIGUOUS"); + return unique[0] ?? 1; +} + +function nextPageControl(root: ParentNode): HTMLElement | undefined { + const candidates = Array.from(root.querySelectorAll( + "button[aria-label='下一页'],button[title='下一页'],button[aria-label='Next page'],button[title='Next page'],li[class*='pagination-next']" + )).filter(visible); + if (candidates.length > 1) throw new Error("BINANCE_PAGINATION_AMBIGUOUS"); + return candidates[0]; +} + +function controlDisabled(control: HTMLElement): boolean { + const button = control.matches("button") + ? control + : control.querySelector("button") ?? control; + return ( + control.hasAttribute("disabled") || + button.hasAttribute("disabled") || + control.getAttribute("aria-disabled") === "true" || + button.getAttribute("aria-disabled") === "true" || + `${control.className} ${button.className}`.toLowerCase().includes("disabled") + ); +} + +function activeTable(root: ParentNode): HTMLTableElement | undefined { + const tables = Array.from(root.querySelectorAll("table")) + .filter((table) => visible(table) && table.querySelectorAll("tr").length > 0); + if (tables.length > 1) throw new Error("BINANCE_DETAIL_TABLE_AMBIGUOUS"); + return tables[0]; +} + +function explicitEmpty(root: ParentNode): boolean { + const text = normalize(root.textContent); + return /暂无(?:仓位|记录|数据|订单)|没有(?:仓位|记录|数据|订单)|No (?:position|record|data|order)/iu.test(text); +} + +export interface BinanceDetailRecord { + readonly recordKey: string; + readonly projectId: string; + readonly sourceTab: BinanceDetailTab; + readonly page: number; + readonly rowOrdinal: number; + readonly fields: Readonly>; +} + +export interface BinanceDetailPage { + readonly projectId: string; + readonly sourceTab: BinanceDetailTab; + readonly page: number; + readonly records: readonly BinanceDetailRecord[]; + readonly hasNextPage: boolean; + readonly signature: string; +} + +export interface BinanceProjectDetailSnapshot { + readonly schemaVersion: "binance-copy-trading/v0.1"; + readonly status: "complete"; + readonly projectId: string; + readonly observedAt: string; + readonly pageUrl: string; + readonly tabs: readonly { + readonly sourceTab: BinanceDetailTab; + readonly pageCount: number; + readonly summary: Readonly>; + readonly records: readonly BinanceDetailRecord[]; + }[]; + readonly formMutations: 0; +} + +function tabSummary(root: ParentNode): Readonly> { + const elements = Array.from(root.querySelectorAll( + "span,div,p,dt,dd" + )).filter(visible).slice(0, 5_000); + const result: Record = {}; + for (const label of TAB_SUMMARY_LABELS) { + const element = elements.find( + (candidate) => normalize(candidate.textContent) === label + ); + if (!element) continue; + const value = [ + element.nextElementSibling, + element.parentElement?.nextElementSibling, + element.parentElement + ].map((candidate) => normalize(candidate?.textContent)).find( + (candidate) => candidate.length > 0 && candidate !== label && candidate.length <= 200 + ); + if (value) result[label] = value; + } + return result; +} + +export function validateBinanceProjectTarget( + input: Readonly> +): { projectId: string; projectStatus: "ongoing" | "ended"; managementUrl: string } { + if ( + Object.keys(input).some((key) => !["projectId", "projectStatus", "managementUrl"].includes(key)) || + typeof input.projectId !== "string" || + !/^[A-Za-z0-9_-]{4,120}$/u.test(input.projectId) || + (input.projectStatus !== "ongoing" && input.projectStatus !== "ended") || + typeof input.managementUrl !== "string" + ) { + throw new Error("BINANCE_PROJECT_TARGET_INVALID"); + } + let management: URL; + try { + management = new URL(input.managementUrl); + } catch { + throw new Error("BINANCE_PROJECT_TARGET_INVALID"); + } + const safe = (url: URL): boolean => + url.origin === BINANCE_ORIGIN && + url.pathname.startsWith(BINANCE_MANAGEMENT_PATH) && + !url.username && + !url.password && + !url.hash && + !/login|register|passport|signin|authorize/iu.test(url.pathname); + if ( + !safe(management) || + management.pathname !== BINANCE_MANAGEMENT_PATH || + management.search !== "" + ) { + throw new Error("BINANCE_PROJECT_TARGET_INVALID"); + } + return { + projectId: input.projectId, + projectStatus: input.projectStatus, + managementUrl: management.href + }; +} + +function managementTab(root: ParentNode): "ongoing" | "ended" | undefined { + const selected = Array.from(root.querySelectorAll( + "[role='tab'][aria-selected='true'],[role='tab'][data-state='active'],[role='tab'][class*='active']" + )).filter(visible).map((element) => normalize(element.textContent)); + const statusForLabel = (label: string): "ongoing" | "ended" | undefined => { + for (const [prefix, status] of [["进行中", "ongoing"], ["已结束", "ended"]] as const) { + if (label === prefix) return status; + const suffix = label.slice(prefix.length).replace(/\s+/gu, ""); + if (label.startsWith(prefix) && /^(?:\(\d+\)|(\d+)|\d+)$/u.test(suffix)) return status; + } + return undefined; + }; + const matches = selected.flatMap((label) => { + const status = statusForLabel(label); + return status ? [status] : []; + }); + return matches.length === 1 ? matches[0] : undefined; +} + +function managementTabControl(document: Document, status: "ongoing" | "ended"): HTMLElement { + const label = status === "ongoing" ? "进行中" : "已结束"; + const controls = Array.from(document.querySelectorAll( + "[role='tab'],button,[role='button']" + )).filter((element) => { + if (!visible(element)) return false; + const text = normalize(element.textContent); + if (text === label) return true; + const suffix = text.slice(label.length).replace(/\s+/gu, ""); + return text.startsWith(label) && /^(?:\(\d+\)|(\d+)|\d+)$/u.test(suffix); + }); + if (controls.length !== 1) throw new Error("BINANCE_MANAGEMENT_TAB_AMBIGUOUS"); + return controls[0]!; +} + +function detailToggleControls(root: ParentNode, label: "展开详情" | "收起详情" | "收起"): HTMLElement[] { + const preferred = Array.from(root.querySelectorAll( + "button,[role='button'],a" + )).filter((element) => visible(element) && normalize(element.textContent) === label); + if (preferred.length > 0) return preferred; + return Array.from(root.querySelectorAll("span,div,p")) + .filter((element) => + visible(element) && + normalize(element.textContent) === label && + !Array.from(element.children).some((child) => normalize(child.textContent) === label) + ); +} + +function projectIdentityElements(document: Document, projectId: string): HTMLElement[] { + const candidates = Array.from(document.querySelectorAll( + "[data-project-id],[data-portfolio-id],main span,main div,main p,[role='main'] span,[role='main'] div" + )).filter(visible); + return candidates.filter((element) => { + const attribute = element.getAttribute("data-project-id") ?? element.getAttribute("data-portfolio-id"); + if (attribute === projectId) return true; + const match = normalize(element.textContent).match( + /^(?:项目\s*ID|Project\s*ID)\s*[::]?\s*([A-Za-z0-9_-]{4,120})$/iu + ); + return match?.[1] === projectId; + }); +} + +function projectRoot(document: Document, projectId: string): HTMLElement { + const roots = new Set(); + for (const identity of projectIdentityElements(document, projectId)) { + let current: HTMLElement | null = identity; + for (let depth = 0; current && depth < 10; depth += 1) { + const toggleCount = detailToggleControls(current, "展开详情").length + + detailToggleControls(current, "收起详情").length + + detailToggleControls(current, "收起").length; + if (toggleCount === 1) { + roots.add(current); + break; + } + current = current.parentElement; + } + } + if (roots.size === 0) throw new Error("BINANCE_PROJECT_CARD_MISSING"); + if (roots.size !== 1) throw new Error("BINANCE_PROJECT_CARD_AMBIGUOUS"); + return [...roots][0]!; +} + +async function waitUntil( + predicate: () => boolean, + deadline: number, + wait: (milliseconds: number) => Promise, + timeoutCode: string, + isCancelled: () => boolean +): Promise { + while (Date.now() < deadline) { + if (isCancelled()) throw new Error("COMMAND_CANCELLED"); + if (predicate()) return; + await wait(120); + } + throw new Error(timeoutCode); +} + +function collapseControls(root: ParentNode): HTMLElement[] { + return [ + ...detailToggleControls(root, "收起详情"), + ...detailToggleControls(root, "收起") + ]; +} + +function recordKey( + projectId: string, + sourceTab: BinanceDetailTab, + page: number, + rowOrdinal: number, + fields: Readonly> +): string { + const values = Object.entries(fields).flatMap(([key, value]) => [key, value]); + return [projectId, sourceTab, String(page), String(rowOrdinal), ...values] + .map((value) => encodeURIComponent(value)) + .join("|"); +} + +export function readBinanceDetailPage( + root: ParentNode, + input: { readonly projectId: string; readonly sourceTab: BinanceDetailTab } +): BinanceDetailPage { + const active = selectedTab(root); + if (active !== input.sourceTab) throw new Error("BINANCE_DETAIL_TAB_NOT_ACTIVE"); + const table = activeTable(root); + if (!table) { + if (!explicitEmpty(root)) throw new Error("BINANCE_DETAIL_STRUCTURE_UNCONFIRMED"); + const page = pageNumber(root); + return { + projectId: input.projectId, + sourceTab: input.sourceTab, + page, + records: [], + hasNextPage: false, + signature: `${input.sourceTab}:${page}:empty` + }; + } + const headers = Array.from(table.querySelectorAll("thead th,[role='columnheader']")) + .map((element) => normalize(element.textContent)); + if (headers.length < 1 || headers.some((header) => header.length < 1)) { + throw new Error("BINANCE_DETAIL_HEADERS_MISSING"); + } + const page = pageNumber(root); + const rows = Array.from(table.querySelectorAll("tbody tr,[role='row']")) + .filter((row) => row.querySelectorAll("td,[role='cell'],[role='gridcell']").length > 0) + .slice(0, MAX_ROWS_PER_TAB + 1); + if (rows.length > MAX_ROWS_PER_TAB) throw new Error("BINANCE_DETAIL_ROW_LIMIT_EXCEEDED"); + const records = rows.map((row, index) => { + const cells = Array.from(row.querySelectorAll("td,[role='cell'],[role='gridcell']")) + .map((cell) => normalize(cell.textContent)); + if (cells.length !== headers.length) throw new Error("BINANCE_DETAIL_ROW_CHANGED"); + const fields = Object.fromEntries( + headers.flatMap((header, cellIndex) => + SENSITIVE_HEADER.test(header) ? [] : [[header, cells[cellIndex]!]] + ) + ); + const rowOrdinal = index + 1; + return { + recordKey: recordKey(input.projectId, input.sourceTab, page, rowOrdinal, fields), + projectId: input.projectId, + sourceTab: input.sourceTab, + page, + rowOrdinal, + fields + }; + }); + const next = nextPageControl(root); + return { + projectId: input.projectId, + sourceTab: input.sourceTab, + page, + records, + hasNextPage: Boolean(next && !controlDisabled(next)), + signature: `${input.sourceTab}:${page}:${records.map((record) => record.recordKey).join("\u0000")}` + }; +} + +function waitForChange( + read: () => BinanceDetailPage, + previousSignature: string, + deadline: number, + wait: (milliseconds: number) => Promise, + isCancelled: () => boolean +): Promise { + return (async () => { + while (Date.now() < deadline) { + if (isCancelled()) throw new Error("COMMAND_CANCELLED"); + const observed = read(); + if (observed.signature !== previousSignature) return observed; + await wait(150); + } + throw new Error("BINANCE_PAGINATION_TIMEOUT"); + })(); +} + +export async function collectBinanceProjectDetail( + document: Document, + input: Readonly>, + options: { + readonly deadline: string; + readonly wait?: (milliseconds: number) => Promise; + readonly observedAt?: Date; + readonly isCancelled?: () => boolean; + } +): Promise { + const target = validateBinanceProjectTarget(input); + const deadline = Date.parse(options.deadline); + if (!Number.isFinite(deadline) || Date.now() >= deadline) throw new Error("DEADLINE_EXCEEDED"); + const wait = options.wait ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))); + const isCancelled = options.isCancelled ?? (() => false); + if (isCancelled()) throw new Error("COMMAND_CANCELLED"); + const risks = detectBinanceRiskSignals(document, document.defaultView?.location.href ?? ""); + if (risks.some((risk) => risk.severity === "blocking")) throw new Error(risks[0]!.code); + if (document.defaultView?.location.href !== target.managementUrl) throw new Error("PAGE_CONTEXT_CHANGED"); + const initialManagementTab = managementTab(document); + if (!initialManagementTab) throw new Error("BINANCE_MANAGEMENT_TAB_AMBIGUOUS"); + let root: HTMLElement | undefined; + let initialTab: BinanceDetailTab | undefined; + let expanded = false; + let openedByCollector = false; + const tabs: BinanceProjectDetailSnapshot["tabs"][number][] = []; + try { + if (initialManagementTab !== target.projectStatus) { + managementTabControl(document, target.projectStatus).click(); + await waitUntil( + () => managementTab(document) === target.projectStatus, + deadline, + wait, + "BINANCE_MANAGEMENT_TAB_TIMEOUT", + isCancelled + ); + } + root = projectRoot(document, target.projectId); + const expand = detailToggleControls(root, "展开详情"); + const collapse = collapseControls(root); + if (expand.length === 1 && collapse.length === 0) { + expand[0]!.click(); + expanded = true; + openedByCollector = true; + await waitUntil( + () => { + try { + root = projectRoot(document, target.projectId); + return collapseControls(root).length === 1 && + BINANCE_DETAIL_TAB_LABELS.every((label) => exactVisibleElements(root!, label).length === 1); + } catch { + return false; + } + }, + deadline, + wait, + "BINANCE_PROJECT_EXPAND_TIMEOUT", + isCancelled + ); + } else if (expand.length === 0 && collapse.length === 1) { + expanded = true; + if (!BINANCE_DETAIL_TAB_LABELS.every((label) => exactVisibleElements(root!, label).length === 1)) { + throw new Error("BINANCE_PROJECT_EXPAND_TIMEOUT"); + } + } else { + throw new Error("BINANCE_PROJECT_EXPAND_AMBIGUOUS"); + } + initialTab = selectedTab(root); + for (const sourceTab of BINANCE_DETAIL_TAB_LABELS) { + if (isCancelled()) throw new Error("COMMAND_CANCELLED"); + if (Date.now() >= deadline) throw new Error("DEADLINE_EXCEEDED"); + const control = uniqueTabControl(root, sourceTab); + if (selectedTab(root) !== sourceTab) { + control.click(); + while (selectedTab(root) !== sourceTab) { + if (isCancelled()) throw new Error("COMMAND_CANCELLED"); + if (Date.now() >= deadline) throw new Error("BINANCE_DETAIL_TAB_TIMEOUT"); + await wait(100); + } + } + let page = readBinanceDetailPage(root, { projectId: target.projectId, sourceTab }); + const summary = tabSummary(root); + const records: BinanceDetailRecord[] = [...page.records]; + const seenPages = new Set([page.page]); + let pageCount = 1; + while (page.hasNextPage) { + if (isCancelled()) throw new Error("COMMAND_CANCELLED"); + if (pageCount >= MAX_PAGES_PER_TAB) throw new Error("BINANCE_PAGE_LIMIT_EXCEEDED"); + const next = nextPageControl(root); + if (!next || controlDisabled(next)) throw new Error("BINANCE_PAGINATION_CHANGED"); + const clickable = next.matches("button") ? next : next.querySelector("button") ?? next; + clickable.click(); + const nextPage = await waitForChange( + () => readBinanceDetailPage(root!, { projectId: target.projectId, sourceTab }), + page.signature, + deadline, + wait, + isCancelled + ); + if (nextPage.page <= page.page || seenPages.has(nextPage.page)) { + throw new Error("BINANCE_PAGINATION_REPEATED"); + } + seenPages.add(nextPage.page); + records.push(...nextPage.records); + if (records.length > MAX_ROWS_PER_TAB) throw new Error("BINANCE_DETAIL_ROW_LIMIT_EXCEEDED"); + page = nextPage; + pageCount += 1; + } + tabs.push({ sourceTab, pageCount, summary, records }); + } + } finally { + let cleanupError: Error | undefined; + const cleanupDeadline = Math.max(deadline, Date.now() + 5_000); + const ignoreCancellation = (): boolean => false; + if (root && expanded) { + try { + if (initialTab && selectedTab(root) !== initialTab) { + uniqueTabControl(root, initialTab).click(); + } + if (openedByCollector) { + const collapse = collapseControls(root); + if (collapse.length !== 1) throw new Error("BINANCE_PROJECT_COLLAPSE_FAILED"); + collapse[0]!.click(); + await waitUntil( + () => { + try { + root = projectRoot(document, target.projectId); + return detailToggleControls(root, "展开详情").length === 1; + } catch { + return false; + } + }, + cleanupDeadline, + wait, + "BINANCE_PROJECT_COLLAPSE_FAILED", + ignoreCancellation + ); + } + } catch { + cleanupError = new Error("BINANCE_PROJECT_COLLAPSE_FAILED"); + } + } + try { + if (managementTab(document) !== initialManagementTab) { + managementTabControl(document, initialManagementTab).click(); + await waitUntil( + () => managementTab(document) === initialManagementTab, + cleanupDeadline, + wait, + "BINANCE_MANAGEMENT_RESTORE_FAILED", + ignoreCancellation + ); + } + } catch { + cleanupError = new Error("BINANCE_MANAGEMENT_RESTORE_FAILED"); + } + if (cleanupError) throw cleanupError; + } + return { + schemaVersion: "binance-copy-trading/v0.1", + status: "complete", + projectId: target.projectId, + observedAt: (options.observedAt ?? new Date()).toISOString(), + pageUrl: target.managementUrl, + tabs, + formMutations: 0 + }; +} diff --git a/adapters/doudian/doudian-alliance.adapter.yaml b/adapters/doudian/doudian-alliance.adapter.yaml index 90996fbb..43d84875 100644 --- a/adapters/doudian/doudian-alliance.adapter.yaml +++ b/adapters/doudian/doudian-alliance.adapter.yaml @@ -17,7 +17,7 @@ capabilities: - 2.0.0 handlerId: doudian.alliance.shops.discover handlerVersion: 2.0.0 - implementationDigest: sha256:7229ac766621e0747f309720a00d00d93919a2db571d33f7fc49661412891746 + implementationDigest: sha256:934366e1b0d67eca794040e8939a6496ecc638a2e0ff2da677a9503930124761 permissions: - browser.dom.read - browser.dom.write @@ -28,7 +28,7 @@ capabilities: - 2.0.0 handlerId: doudian.alliance.shop.retired-products.scan handlerVersion: 2.0.0 - implementationDigest: sha256:7229ac766621e0747f309720a00d00d93919a2db571d33f7fc49661412891746 + implementationDigest: sha256:934366e1b0d67eca794040e8939a6496ecc638a2e0ff2da677a9503930124761 permissions: - browser.dom.read - browser.dom.write diff --git a/adapters/doudian/doudian-experience.adapter.yaml b/adapters/doudian/doudian-experience.adapter.yaml index 2133f363..a65c218c 100644 --- a/adapters/doudian/doudian-experience.adapter.yaml +++ b/adapters/doudian/doudian-experience.adapter.yaml @@ -15,11 +15,11 @@ capabilities: nodeVersions: [2.0.0] handlerId: doudian.experience.shops.discover handlerVersion: 2.0.0 - implementationDigest: sha256:3eb970f4d2192dcb09ccb178011767387c6965f0f34447bd976b8fb7b255f11d + implementationDigest: sha256:286676f5346e82202b99a94c82f556eaccc232cc611861c0fa3bcc874a6ac776 permissions: [browser.dom.read, browser.dom.write, browser.tabs.read, browser.tabs.navigate] - nodeId: doudian.experience.shop.snapshot.read nodeVersions: [2.0.0] handlerId: doudian.experience.shop.snapshot.read handlerVersion: 2.0.0 - implementationDigest: sha256:3eb970f4d2192dcb09ccb178011767387c6965f0f34447bd976b8fb7b255f11d + implementationDigest: sha256:286676f5346e82202b99a94c82f556eaccc232cc611861c0fa3bcc874a6ac776 permissions: [browser.dom.read, browser.dom.write, browser.tabs.read, browser.tabs.navigate] diff --git a/adapters/doudian/doudian-inventory.adapter.yaml b/adapters/doudian/doudian-inventory.adapter.yaml index d0851c69..e1f32e83 100644 --- a/adapters/doudian/doudian-inventory.adapter.yaml +++ b/adapters/doudian/doudian-inventory.adapter.yaml @@ -15,7 +15,7 @@ capabilities: nodeVersions: [1.0.0] handlerId: doudian.inventory.shop.activate handlerVersion: 1.0.0 - implementationDigest: sha256:313427d43ed3fa0d11a2a7a4719dd17be218da1b50645817b2e72fe7fc81259c + implementationDigest: sha256:7c23db6a00797d79b6e7132f8273f8da321491ee29c570e5c89337eb9a4b2b81 permissions: - browser.dom.read - browser.dom.write @@ -26,7 +26,7 @@ capabilities: - 2.0.0 handlerId: doudian.inventory.product.snapshot.read handlerVersion: 2.0.0 - implementationDigest: sha256:313427d43ed3fa0d11a2a7a4719dd17be218da1b50645817b2e72fe7fc81259c + implementationDigest: sha256:7c23db6a00797d79b6e7132f8273f8da321491ee29c570e5c89337eb9a4b2b81 permissions: - browser.dom.read - browser.dom.write diff --git a/adapters/doudian/src/alliance-retired.test.ts b/adapters/doudian/src/alliance-retired.test.ts index 4e1921cd..8b058e4d 100644 --- a/adapters/doudian/src/alliance-retired.test.ts +++ b/adapters/doudian/src/alliance-retired.test.ts @@ -6,7 +6,9 @@ import { dismissBuyinPromotionDialogs, openBuyinRetiredProducts, openDoudianAlliancePromotion, + openDoudianShopSwitcher, readBuyinRetiredProducts, + readDoudianHeaderShopIdentity, readDoudianHeaderShopName, selectDoudianAllianceShop } from "./alliance-retired.js"; @@ -46,7 +48,8 @@ describe("Doudian alliance retired-products runtime", () => { "apps/extension/src/lib/extension-runtime-resources.ts", "apps/extension/src/lib/managed-tab-lifecycle.ts", "apps/extension/src/lib/native-connection-supervisor.ts", - "adapters/doudian/src/alliance-retired.ts" + "adapters/doudian/src/alliance-retired.ts", + "adapters/doudian/src/shop-context.ts" ] .map((path) => readFileSync(new URL(`../../../${path}`, import.meta.url)) @@ -103,6 +106,68 @@ describe("Doudian alliance retired-products runtime", () => { ).toThrow("SHOP_NOT_ACTIVE"); }); + it("discovers and selects shops from the current Auxo drawer", () => { + const doc = documentOf(` +
+
+
切换组织/店铺
+
+ 甲食品旗舰店 + 店铺ID 10001 正常营业 +
+
+ 乙食品专营店 + 店铺ID 10002 正常营业 +
+
+
+ `); + expect(discoverDoudianAllianceShops(doc)).toEqual([ + { + id: "10001", + name: "甲食品旗舰店", + status: "active", + statusText: "正常营业" + }, + { + id: "10002", + name: "乙食品专营店", + status: "active", + statusText: "正常营业" + } + ]); + const target = doc.querySelector( + ".index_shopOption__two" + )!; + const click = vi.fn(); + target.addEventListener("click", click); + selectDoudianAllianceShop(doc, { + id: "10002", + name: "乙食品专营店", + status: "active", + statusText: "正常营业" + }); + expect(click).toHaveBeenCalledOnce(); + }); + + it("discovers shops from the current Auxo modal switcher", () => { + const doc = documentOf(` +
+
+
甲食品旗舰店店铺ID 10001 正常营业
+
+
+ `); + expect(discoverDoudianAllianceShops(doc)).toEqual([ + { + id: "10001", + name: "甲食品旗舰店", + status: "active", + statusText: "正常营业" + } + ]); + }); + it("fails closed when a shop list mixes valid and malformed cards", () => { const doc = documentOf(`
切换组织/店铺 @@ -125,7 +190,10 @@ describe("Doudian alliance retired-products runtime", () => { `); expect(discoverDoudianAllianceShops(doc)).toHaveLength(2); const cards = Array.from(doc.querySelectorAll(".roleItem")); - const clicks = cards.map((card) => vi.spyOn(card, "click")); + const clicks = cards.map(() => vi.fn()); + cards.forEach((card, index) => + card.addEventListener("click", clicks[index]!) + ); selectDoudianAllianceShop(doc, { id: "10002", name: "同名食品店", @@ -136,6 +204,32 @@ describe("Doudian alliance retired-products runtime", () => { expect(clicks[1]).toHaveBeenCalledOnce(); }); + it("assigns stable switcher ordinals to same-name cards without visible IDs", () => { + const doc = documentOf(` +
切换组织/店铺 +
同名食品店正常营业
+
同名食品店正常营业
+
+ `); + expect(discoverDoudianAllianceShops(doc)).toEqual([ + expect.objectContaining({ name: "同名食品店", switcherOrdinal: 0 }), + expect.objectContaining({ name: "同名食品店", switcherOrdinal: 1 }) + ]); + const cards = Array.from(doc.querySelectorAll(".roleItem")); + const clicks = cards.map(() => vi.fn()); + cards.forEach((card, index) => + card.addEventListener("click", clicks[index]!) + ); + selectDoudianAllianceShop(doc, { + name: "同名食品店", + switcherOrdinal: 1, + status: "active", + statusText: "正常营业" + }); + expect(clicks[0]).not.toHaveBeenCalled(); + expect(clicks[1]).toHaveBeenCalledOnce(); + }); + it("uses exact semantic entries for the Doudian-to-Buyin path", () => { const doc = documentOf(`
@@ -162,6 +256,77 @@ describe("Doudian alliance retired-products runtime", () => { expect(readDoudianHeaderShopName(doc)).toBe("榆园儿食品专营店"); }); + it("binds a header shop name to the unique numeric ID in its account popover", () => { + const doc = documentOf(` +
+
甲食品旗舰店
+
+
+
甲食品旗舰店
+
店铺ID 10001
+
切换组织/店铺
+
+ `); + expect(readDoudianHeaderShopIdentity(doc)).toEqual({ + id: "10001", + name: "甲食品旗舰店" + }); + }); + + it("dispatches the pointer and mouse sequence on semantic account and switch-row containers", () => { + const doc = documentOf(` +
+
甲食品旗舰店
+
+ `); + const account = doc.querySelector(".headerShopName")!; + const accountEvents: string[] = []; + for (const type of ["mouseover", "mousedown", "mouseup", "click"]) { + account.addEventListener(type, () => accountEvents.push(type)); + } + openDoudianShopSwitcher(doc); + expect(accountEvents).toEqual([ + "mouseover", + "mousedown", + "mouseup", + "click" + ]); + + const popover = documentOf(` +
+
切换组织/店铺
+
+ `); + const switchRow = popover.querySelector(".descriptions")!; + const switchEvents: string[] = []; + for (const type of ["mouseover", "mousedown", "mouseup", "click"]) { + switchRow.addEventListener(type, () => switchEvents.push(type)); + } + openDoudianShopSwitcher(popover); + expect(switchEvents).toEqual([ + "mouseover", + "mousedown", + "mouseup", + "click" + ]); + }); + + it("rejects a numeric ID from an account popover for another shop", () => { + const doc = documentOf(` +
+
甲食品旗舰店
+
+
+
乙食品专营店
+
店铺ID 10002
+
切换组织/店铺
+
+ `); + expect(() => readDoudianHeaderShopIdentity(doc)).toThrow( + "SHOP_IDENTITY_UNCERTAIN" + ); + }); + it("closes stacked promotion dialogs from the top and opens clear-out", () => { const doc = documentOf(`
如何迁移旧版数据?
diff --git a/adapters/doudian/src/alliance-retired.ts b/adapters/doudian/src/alliance-retired.ts index deeab2cb..dbd0d81b 100644 --- a/adapters/doudian/src/alliance-retired.ts +++ b/adapters/doudian/src/alliance-retired.ts @@ -22,6 +22,7 @@ export type DoudianAllianceNodeErrorCode = | "CAPTCHA_REQUIRED" | "COMMAND_RESULT_TOO_LARGE" | "COMMAND_CANCELLED" + | "CURRENT_SHOP_NOT_IN_LIST" | "DEADLINE_EXCEEDED" | "DOUDIAN_ALLIANCE_DISCOVERY_FAILED" | "DOUDIAN_ALLIANCE_MAX_SHOPS_INVALID" @@ -41,6 +42,7 @@ export type DoudianAllianceNodeErrorCode = | "RISK_CONTROL" | "SESSION_EXPIRED" | "SHOP_CONTEXT_RESTORE_FAILED" + | "SHOP_IDENTITY_DRIFT" | "SHOP_IDENTITY_AMBIGUOUS" | "SHOP_IDENTITY_MISMATCH" | "SHOP_IDENTITY_UNCERTAIN" @@ -48,8 +50,17 @@ export type DoudianAllianceNodeErrorCode = | "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_TARGET_INVALID"; + | "SHOP_SWITCH_SEARCH_AMBIGUOUS" + | "SHOP_SWITCH_TRIGGER_AMBIGUOUS" + | "SHOP_TARGET_AMBIGUOUS" + | "SHOP_TARGET_INVALID" + | "SHOP_TARGET_TIMEOUT"; export const DOUDIAN_ALLIANCE_NODE_ERROR_CODES = new Set([ "ALLIANCE_CONTENT_RESPONSE_TIMEOUT", @@ -62,6 +73,7 @@ export const DOUDIAN_ALLIANCE_NODE_ERROR_CODES = new Set("body *") + ).filter( + (element) => + normalizeText(element.textContent) === "切换组织/店铺" && + visibleElement(element) && + !element.matches(".auxo-popover") && + element !== doc.body + ); + if (switchEntries.length > 0) { + const actionContainers = [ + ...new Set( + switchEntries.map((element) => { + let action = element; + while ( + action.parentElement && + !action.parentElement.matches(".auxo-popover") && + normalizeText(action.parentElement.textContent) === + "切换组织/店铺" && + visibleElement(action.parentElement) + ) { + action = action.parentElement; + } + return action; + }) + ) + ]; + activateElement( + requireUnique( + actionContainers, + "SHOP_SWITCH_TRIGGER_AMBIGUOUS" + ) + ); + return; + } const candidates = Array.from( doc.querySelectorAll( - "#fxg-pc-header [class*='headerShopName']" + "#fxg-pc-header [class*='userName']," + + "#fxg-pc-header [class*='headerShopName']" ) - ).filter((element) => normalizeText(element.textContent)); - const target = requireUnique(candidates, "SHOP_SWITCH_TRIGGER_AMBIGUOUS"); - target.click(); + ).filter( + (element) => normalizeText(element.textContent) && visibleElement(element) + ); + const actionCandidates = [ + ...new Set( + candidates.map( + (element) => + element.closest("[class*='headerShopName']") ?? + element + ) + ) + ]; + const target = requireUnique( + actionCandidates, + "SHOP_SWITCH_TRIGGER_AMBIGUOUS" + ); + activateElement(target); } -function visibleShopDialog(doc: Document): HTMLElement { - const dialogs = Array.from( - doc.querySelectorAll("[role='dialog']") - ).filter((dialog) => { - const text = normalizeText(dialog.textContent); - return ( +function activateElement(element: HTMLElement): void { + const view = element.ownerDocument.defaultView; + if (!view) { + throw new DoudianAllianceError("SHOP_SWITCH_TRIGGER_AMBIGUOUS"); + } + const rect = element.getBoundingClientRect(); + const clientX = Number.isFinite(rect.left + rect.width / 2) + ? rect.left + rect.width / 2 + : 0; + const clientY = Number.isFinite(rect.top + rect.height / 2) + ? rect.top + rect.height / 2 + : 0; + const eventInit: MouseEventInit = { + bubbles: true, + cancelable: true, + composed: true, + clientX, + clientY, + button: 0, + buttons: 1 + }; + const PointerEventConstructor = view.PointerEvent; + if (PointerEventConstructor) { + element.dispatchEvent( + new PointerEventConstructor("pointerover", { + ...eventInit, + pointerId: 1, + pointerType: "mouse", + isPrimary: true + }) + ); + } + element.dispatchEvent(new view.MouseEvent("mouseover", eventInit)); + if (PointerEventConstructor) { + element.dispatchEvent( + new PointerEventConstructor("pointerdown", { + ...eventInit, + pointerId: 1, + pointerType: "mouse", + isPrimary: true + }) + ); + } + element.dispatchEvent(new view.MouseEvent("mousedown", eventInit)); + if (PointerEventConstructor) { + element.dispatchEvent( + new PointerEventConstructor("pointerup", { + ...eventInit, + buttons: 0, + pointerId: 1, + pointerType: "mouse", + isPrimary: true + }) + ); + } + element.dispatchEvent( + new view.MouseEvent("mouseup", { ...eventInit, buttons: 0 }) + ); + element.dispatchEvent( + new view.MouseEvent("click", { ...eventInit, buttons: 0 }) + ); +} + +function visibleElement(element: HTMLElement): boolean { + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; +} + +function visibleShopSwitcher( + doc: Document, + required = true +): HTMLElement | undefined { + const roots = new Set(); + for (const element of Array.from( + doc.querySelectorAll( + "[role='dialog'],.auxo-modal-wrap,.auxo-drawer-open," + + ".auxo-drawer-content-wrapper" + ) + )) { + const root = + element.closest( + ".auxo-modal-wrap,.auxo-drawer-open,[role='dialog']" + ) ?? + element; + if (!visibleElement(root) || roots.has(root)) continue; + const text = normalizeText(root.textContent); + if ( text.includes("切换组织/店铺") || text.includes("切换店铺") || - dialog.querySelector("[class*='roleItem']") !== null - ); - }); - return requireUnique(dialogs, "SHOP_SWITCH_DIALOG_AMBIGUOUS"); + root.querySelector("[class*='roleItem'],[class*='introName']") !== null + ) { + roots.add(root); + } + } + if (!required && roots.size === 0) return undefined; + return requireUnique( + [...roots], + "SHOP_SWITCH_DIALOG_AMBIGUOUS" + ); +} + +function visibleShopDialog(doc: Document): HTMLElement { + return visibleShopSwitcher(doc)!; } function shopIdFromText(value: string): string | undefined { @@ -244,6 +453,39 @@ function shopIdFromText(value: string): string | undefined { ); } +function readCurrentAccountPopoverShopId( + doc: Document, + currentShopName: string +): string | undefined { + const accountPopovers = Array.from( + doc.querySelectorAll(".auxo-popover") + ).filter((popover) => { + if (!visibleElement(popover)) return false; + const text = normalizeText(popover.textContent); + return ( + text.includes("切换组织/店铺") && + text.includes(currentShopName) + ); + }); + if (accountPopovers.length === 0) return undefined; + if (accountPopovers.length !== 1) { + throw new DoudianAllianceError("SHOP_IDENTITY_AMBIGUOUS"); + } + const ids = [ + ...new Set( + Array.from( + normalizeText(accountPopovers[0]!.textContent).matchAll( + /店铺\s*ID[::\s]*(\d{5,30})/giu + ) + ).map((match) => match[1]!) + ) + ]; + if (ids.length !== 1) { + throw new DoudianAllianceError("SHOP_IDENTITY_UNCERTAIN"); + } + return ids[0]; +} + function blockedShopStatus(value: string): string | undefined { const compact = compactText(value); if (compact.includes("正常营业")) return undefined; @@ -263,18 +505,43 @@ function blockedShopStatus(value: string): string | undefined { ].find((status) => compact.includes(status)); } +function shopSwitcherCards(dialog: HTMLElement): HTMLElement[] { + const legacyCards = Array.from( + dialog.querySelectorAll("[class*='roleItem']") + ); + if (legacyCards.length > 0) return legacyCards; + const cards: HTMLElement[] = []; + for (const nameElement of Array.from( + dialog.querySelectorAll("[class*='introName']") + )) { + let candidate: HTMLElement = nameElement; + while (candidate.parentElement && candidate.parentElement !== dialog) { + const parent = candidate.parentElement; + if ( + parent.querySelectorAll("[class*='introName']").length === 1 && + (shopIdFromText(normalizeText(parent.textContent)) !== undefined || + blockedShopStatus(normalizeText(parent.textContent)) !== undefined) + ) { + candidate = parent; + break; + } + candidate = parent; + } + if (!cards.includes(candidate)) cards.push(candidate); + } + return cards; +} + export function discoverDoudianAllianceShops( doc: Document ): readonly AllianceShop[] { const dialog = visibleShopDialog(doc); - const cards = Array.from( - dialog.querySelectorAll("[class*='roleItem']") - ); + const cards = shopSwitcherCards(dialog); if (cards.length === 0) throw new DoudianAllianceError("SHOP_LIST_EMPTY"); const shops = cards.flatMap((card): AllianceShop[] => { const nameElement = card.querySelector( "[class*='introName']" - ); + ) ?? (card.matches("[class*='introName']") ? card : null); const name = normalizeText(nameElement?.textContent); if (!name || name.length > 80) { throw new DoudianAllianceError("SHOP_LIST_INCOMPLETE"); @@ -291,31 +558,33 @@ export function discoverDoudianAllianceShops( } ]; }); - const identities = new Set(); + const nameCounts = new Map(); for (const shop of shops) { - const identity = shop.id ? `id:${shop.id}` : `name:${shop.name}`; + nameCounts.set(shop.name, (nameCounts.get(shop.name) ?? 0) + 1); + } + const nameOrdinals = new Map(); + const distinguishable = shops.map((shop) => { + if ((nameCounts.get(shop.name) ?? 0) < 2 || shop.id) return shop; + const switcherOrdinal = nameOrdinals.get(shop.name) ?? 0; + nameOrdinals.set(shop.name, switcherOrdinal + 1); + return { ...shop, switcherOrdinal }; + }); + const identities = new Set(); + for (const shop of distinguishable) { + const identity = shop.id + ? `id:${shop.id}` + : `name:${shop.name}:${shop.switcherOrdinal ?? 0}`; if (identities.has(identity)) { throw new DoudianAllianceError("SHOP_LIST_DUPLICATED"); } identities.add(identity); } - for (const shop of shops) { - if ( - shops.some( - (candidate) => - candidate !== shop && - candidate.name === shop.name && - (!candidate.id || !shop.id) - ) - ) { - throw new DoudianAllianceError("SHOP_IDENTITY_AMBIGUOUS"); - } - } - return shops; + return distinguishable; } export function closeDoudianShopSwitcher(doc: Document): void { - const dialog = visibleShopDialog(doc); + const dialog = visibleShopSwitcher(doc, false); + if (!dialog) return; const closeButtons = Array.from( dialog.querySelectorAll( "button[aria-label='Close'],button[aria-label='close']," + @@ -323,10 +592,10 @@ export function closeDoudianShopSwitcher(doc: Document): void { ) ); if (closeButtons.length === 0) return; - requireUnique( + activateElement(requireUnique( closeButtons, "SHOP_SWITCH_DIALOG_CLOSE_AMBIGUOUS" - ).click(); + )); } function shopSwitcherScrollTarget(doc: Document): HTMLElement | undefined { @@ -407,21 +676,22 @@ export function selectDoudianAllianceShop( throw new DoudianAllianceError("SHOP_TARGET_INVALID"); } const dialog = visibleShopDialog(doc); - const matches = Array.from( - dialog.querySelectorAll("[class*='roleItem']") - ).filter((card) => { + const matches = shopSwitcherCards(dialog).filter((card) => { const name = normalizeText( card.querySelector("[class*='introName']")?.textContent ); if (name !== expected) return false; - return shop.id - ? shopIdFromText(normalizeText(card.textContent)) === shop.id - : true; + const cardId = shopIdFromText(normalizeText(card.textContent)); + return shop.id && cardId ? cardId === shop.id : true; }); - const card = requireUnique(matches, "SHOP_TARGET_AMBIGUOUS"); + const card = + shop.switcherOrdinal === undefined + ? requireUnique(matches, "SHOP_TARGET_AMBIGUOUS") + : matches[shop.switcherOrdinal]; + if (!card) throw new DoudianAllianceError("SHOP_TARGET_AMBIGUOUS"); const blocked = blockedShopStatus(normalizeText(card.textContent)); if (blocked) throw new DoudianAllianceError("SHOP_NOT_ACTIVE"); - card.click(); + activateElement(card); } export function openDoudianAllianceMenu(doc: Document): void { diff --git a/adapters/doudian/src/experience-score.test.ts b/adapters/doudian/src/experience-score.test.ts index 5fd82894..c8f07b19 100644 --- a/adapters/doudian/src/experience-score.test.ts +++ b/adapters/doudian/src/experience-score.test.ts @@ -69,7 +69,8 @@ describe("Doudian experience-score adapter", () => { "apps/extension/src/lib/adapter-node-registry.ts", "apps/extension/src/lib/experience-score-background.ts", "apps/extension/src/lib/experience-score-content.ts", - "adapters/doudian/src/experience-score.ts" + "adapters/doudian/src/experience-score.ts", + "adapters/doudian/src/shop-context.ts" ].map((path) => readFileSync(new URL(`../../../${path}`,import.meta.url))) .join("\n")) .digest("hex")}`; diff --git a/adapters/doudian/src/index.test.ts b/adapters/doudian/src/index.test.ts index 4e3fc567..3f883973 100644 --- a/adapters/doudian/src/index.test.ts +++ b/adapters/doudian/src/index.test.ts @@ -77,6 +77,25 @@ describe("doudian adapter", () => { }); }); + it("keeps the observed header identity stable while the account popover is open", () => { + const doc = new JSDOM(` + +
+
测试旗舰店 +
店铺ID 123456789 切换组织/店铺
+
+
+ + `, { url: "https://fxg.jinritemai.com/ffa/g/list" }).window.document; + doc.querySelector(".userName")!.getBoundingClientRect = () => + ({ top: 72, bottom: 96, width: 150, height: 24 }) as DOMRect; + expect(readDoudianShopContext(doc).shop).toEqual({ + id: "name:4cf24bd7", + name: "测试旗舰店", + identity_confirmed: true + }); + }); + it("falls back to the first complete shop-name line in transformed layouts", () => { const doc = { defaultView: { diff --git a/adapters/doudian/src/inventory-snapshot.test.ts b/adapters/doudian/src/inventory-snapshot.test.ts index 0c0500d4..d7616d1c 100644 --- a/adapters/doudian/src/inventory-snapshot.test.ts +++ b/adapters/doudian/src/inventory-snapshot.test.ts @@ -71,7 +71,8 @@ describe("doudian inventory snapshot", () => { "apps/extension/src/lib/native-connection-supervisor.ts", "adapters/doudian/src/alliance-retired.ts", "adapters/doudian/src/inventory-snapshot.ts", - "adapters/doudian/src/product-list-guard.ts" + "adapters/doudian/src/product-list-guard.ts", + "adapters/doudian/src/shop-context.ts" ].map((path) => readFileSync(new URL(`../../../${path}`, import.meta.url))) .join("\n")) .digest("hex")}`; diff --git a/adapters/doudian/src/shop-context.ts b/adapters/doudian/src/shop-context.ts index 4e774ddc..7b09f3c0 100644 --- a/adapters/doudian/src/shop-context.ts +++ b/adapters/doudian/src/shop-context.ts @@ -119,10 +119,12 @@ function extractStableShopId(element: Element | undefined): string | undefined { // Ignore malformed attributes from untrusted page content. } } - const textId = normalizeText(current.textContent).match( - /店铺\s*ID[::\s]*(\d{5,30})/iu - )?.[1]; - if (textId) return textId; + if (current === element) { + const textId = normalizeText(current.textContent).match( + /店铺\s*ID[::\s]*(\d{5,30})/iu + )?.[1]; + if (textId) return textId; + } current = current.parentElement ?? undefined; } return undefined; diff --git a/apps/extension/package.json b/apps/extension/package.json index 7f9f868e..76b15891 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -10,6 +10,7 @@ "typecheck": "wxt prepare && tsc --noEmit" }, "dependencies": { + "@bpa/adapter-binance": "workspace:*", "@bpa/adapter-doudian": "workspace:*", "@bpa/adapter-marketplace": "workspace:*", "@bpa/browser-bridge": "workspace:*", diff --git a/apps/extension/src/entrypoints/content.ts b/apps/extension/src/entrypoints/content.ts index cc8eee3a..7551bb82 100644 --- a/apps/extension/src/entrypoints/content.ts +++ b/apps/extension/src/entrypoints/content.ts @@ -1,3 +1,8 @@ +import { + collectBinanceManagementSnapshot, + detectBinanceRiskSignals, + readBinanceManagementSnapshot +} from "@bpa/adapter-binance"; import { collectDoudianProductScope, collectDoudianProductInventorySnapshot, @@ -37,12 +42,22 @@ import { type ExperienceScoreStageRequest } from "../lib/experience-score-content"; import { probeObservedPage } from "../lib/page-observer-registry"; +import { + binanceDetailErrorPayload, + executeBinanceDetailStage, + type BinanceDetailStageRequest +} from "../lib/binance-detail-content"; const runningAllianceStages = new Map< string, { readonly controller: AbortController; readonly completion: Promise } >(); +const runningBinanceStages = new Map< + string, + { readonly controller: AbortController; readonly completion: Promise } +>(); + function waitForPageChange(maxWaitMs: number): Promise { return new Promise((resolve) => { let settled = false; @@ -144,6 +159,27 @@ async function readShopContextWhenReady( } const handlers: ContentActionHandlers = { + async "binance.copy-trading.management.snapshot.read"(_input, request) { + const startedAt = Date.now(); + const riskSignals = detectBinanceRiskSignals(document, location.href); + if (firstBlockingRiskSignal(riskSignals)) { + throw new ContentActionRiskError(riskSignals); + } + const output = await collectBinanceManagementSnapshot(document, location.href, { + deadline: request.deadline! + }); + return { + output: { ...output }, + riskSignals, + timingObservation: { + readiness_wait_ms: Date.now() - startedAt, + stable_for_ms: 0 + } + }; + }, + async "binance.copy-trading.project.detail.collect"() { + throw new Error("BACKGROUND_ORCHESTRATION_REQUIRED"); + }, async "ecommerce.marketplace.search-results.read"(input) { const startedAt = Date.now(); const riskSignals = detectMarketplaceRiskSignals(document, location.href); @@ -359,6 +395,7 @@ const handlers: ContentActionHandlers = { export default defineContentScript({ matches: [ + "https://www.binance.com/zh-CN/copy-trading/copy-management*", "https://fxg.jinritemai.com/ffa/g/list*", "https://fxg.jinritemai.com/ffa/g/create*", "https://fxg.jinritemai.com/ffa/morder/order/*", @@ -449,10 +486,56 @@ export default defineContentScript({ "https://search.jd.com" ].includes(location.origin) ? detectMarketplaceRiskSignals(document, location.href) + : location.origin === "https://www.binance.com" + ? detectBinanceRiskSignals(document, location.href) : detectDoudianRiskSignals(document, location.href) }); return true; } + if (request.type === "bpa.binance.detail.cancel-stage") { + const requestId = ( + request as ContentActionRequest & { requestId?: unknown } + ).requestId; + if (typeof requestId !== "string" || requestId.length < 1) { + sendResponse({ ok: false, stopped: false }); + return false; + } + const running = runningBinanceStages.get(requestId); + running?.controller.abort(); + void (running?.completion ?? Promise.resolve()).finally(() => + sendResponse({ ok: true, requestId, stopped: true }) + ); + return true; + } + if (request.type === "bpa.binance.detail.stage") { + const stageRequest = request as ContentActionRequest & { + requestId?: unknown; + request: BinanceDetailStageRequest; + }; + if ( + typeof stageRequest.requestId !== "string" || + stageRequest.requestId.length < 1 || + runningBinanceStages.has(stageRequest.requestId) + ) { + sendResponse({ ok: false, error: binanceDetailErrorPayload(undefined) }); + return false; + } + const requestId = stageRequest.requestId; + const controller = new AbortController(); + const completion = executeBinanceDetailStage( + stageRequest.request, + document, + location.href, + () => controller.signal.aborted + ) + .then((result) => sendResponse({ ok: true, requestId, result })) + .catch((error) => + sendResponse({ ok: false, requestId, error: binanceDetailErrorPayload(error) }) + ) + .finally(() => runningBinanceStages.delete(requestId)); + runningBinanceStages.set(requestId, { controller, completion }); + return true; + } if (request.type === "bpa.doudian.alliance.cancel-stage") { const requestId = ( request as ContentActionRequest & { requestId?: unknown } diff --git a/apps/extension/src/lib/adapter-node-registry.test.ts b/apps/extension/src/lib/adapter-node-registry.test.ts index 30ea306c..15dfe9ed 100644 --- a/apps/extension/src/lib/adapter-node-registry.test.ts +++ b/apps/extension/src/lib/adapter-node-registry.test.ts @@ -309,7 +309,7 @@ describe("Adapter Node registry", () => { it("fails alliance discovery when an active shop lacks a stable numeric id", async () => { driver.discoverShopContext.mockResolvedValue({ - currentShopName: "无ID店铺", + currentShop: { id: "10001", name: "无ID店铺" }, shops: [ { name: "无ID店铺", status: "active", statusText: "正常营业" } ] @@ -329,7 +329,7 @@ describe("Adapter Node registry", () => { it("keeps an id-less blocked alliance shop and skips its scan", async () => { driver.discoverShopContext.mockResolvedValue({ - currentShopName: sourceShop.name, + currentShop: { id: sourceShop.id!, name: sourceShop.name }, shops: [ sourceShop, { name: "已停业店铺", status: "blocked", statusText: "已停业" } diff --git a/apps/extension/src/lib/adapter-node-registry.ts b/apps/extension/src/lib/adapter-node-registry.ts index ac8bb1e5..d0ff86f8 100644 --- a/apps/extension/src/lib/adapter-node-registry.ts +++ b/apps/extension/src/lib/adapter-node-registry.ts @@ -12,6 +12,10 @@ import { createExperienceScoreBrowserDriver, ExperienceScoreDriverError } from "./experience-score-background"; +import { + BinanceDetailDriverError, + createBinanceDetailBrowserDriver +} from "./binance-detail-background"; export interface AdapterNodeResponse { readonly ok: boolean; @@ -203,6 +207,7 @@ const ALLIANCE_DISCOVERY_ERRORS = new Set([ "CAPTCHA_REQUIRED", "COMMAND_RESULT_TOO_LARGE", "COMMAND_CANCELLED", + "CURRENT_SHOP_NOT_IN_LIST", "DEADLINE_EXCEEDED", "DOUDIAN_ALLIANCE_DISCOVERY_FAILED", "DOUDIAN_ALLIANCE_MAX_SHOPS_INVALID", @@ -211,12 +216,25 @@ const ALLIANCE_DISCOVERY_ERRORS = new Set([ "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_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" ]); const ALLIANCE_SCAN_ERRORS = new Set([ @@ -335,6 +353,64 @@ function experienceErrorResponse( }; } +const BINANCE_RETRYABLE_ERRORS = new Set([ + "BINANCE_CONTENT_RESPONSE_TIMEOUT", + "BINANCE_DETAIL_TAB_TIMEOUT", + "BINANCE_PAGINATION_TIMEOUT", + "BROWSER_DISCONNECTED", + "PAGE_LOADING" +]); + +function binanceErrorResponse(error: unknown): AdapterNodeResponse { + const safe = + error instanceof BinanceDetailDriverError + ? error + : new BinanceDetailDriverError("BINANCE_DETAIL_STAGE_FAILED"); + const blocking = [ + "BINANCE_MANAGEMENT_RESTORE_FAILED", + "CAPTCHA_REQUIRED", + "PAGE_CONTEXT_CHANGED", + "RATE_LIMITED", + "RISK_CONTROL", + "SESSION_EXPIRED" + ].includes(safe.code); + const riskSignals = safe.riskSignals.length > 0 + ? [...safe.riskSignals] + : blocking + ? [{ + code: safe.code === "SESSION_EXPIRED" + ? "SESSION_EXPIRED" as const + : safe.code === "CAPTCHA_REQUIRED" + ? "CAPTCHA_REQUIRED" as const + : safe.code === "RATE_LIMITED" + ? "RATE_LIMITED" as const + : safe.code === "PAGE_CONTEXT_CHANGED" || safe.code === "BINANCE_MANAGEMENT_RESTORE_FAILED" + ? "PAGE_CONTEXT_CHANGED" as const + : "RISK_CONTROL" as const, + category: safe.code === "SESSION_EXPIRED" + ? "session" as const + : safe.code === "PAGE_CONTEXT_CHANGED" || safe.code === "BINANCE_MANAGEMENT_RESTORE_FAILED" + ? "page_context" as const + : safe.code === "RATE_LIMITED" + ? "throttle" as const + : "challenge" as const, + severity: "blocking" as const, + source: "adapter" as const, + detected_at: new Date().toISOString(), + detail: `Binance 详情采集已停止:${safe.code}` + }] + : []; + return { + ok: false, + error: { + code: safe.code, + message: safe.message, + retryable: BINANCE_RETRYABLE_ERRORS.has(safe.code) + }, + ...(riskSignals.length > 0 ? { riskSignals } : {}) + }; +} + const discoverAllianceShops: AdapterNodeHandler = async (input, context) => { const startedAt = Date.now(); const maxShops = Number(input.maxShops ?? 100); @@ -379,7 +455,9 @@ const discoverAllianceShops: AdapterNodeHandler = async (input, context) => { throw new AllianceRetiredDriverError("SHOP_IDENTITY_UNCERTAIN"); } const sourceMatches = active.filter( - (shop) => normalize(shop.name) === normalize(discovery.currentShopName) + (shop) => + shop.id === discovery.currentShop.id && + normalize(shop.name) === normalize(discovery.currentShop.name) ); if (sourceMatches.length !== 1) { throw new AllianceRetiredDriverError( @@ -733,7 +811,30 @@ const readExperienceShop: AdapterNodeHandler = async (input, context) => { } }; +const collectBinanceProject: AdapterNodeHandler = async (input, context) => { + const startedAt = Date.now(); + const driver = createBinanceDetailBrowserDriver({ + sourceTabId: context.sourceTabId, + deadline: context.deadline, + ...(context.isCancelled ? { isCancelled: context.isCancelled } : {}) + }); + try { + const snapshot = await driver.collectProject(input); + return { + ok: true, + output: { ...snapshot }, + timingObservation: { + readiness_wait_ms: Date.now() - startedAt, + stable_for_ms: 300 + } + }; + } catch (error) { + return binanceErrorResponse(error); + } +}; + const handlers = new Map([ + ["binance.copy-trading.project.detail.collect", collectBinanceProject], ["doudian.inventory.shop.activate", activateInventoryShop], ["doudian.alliance.shops.discover", discoverAllianceShops], ["doudian.alliance.shop.retired-products.scan", scanAllianceShop], diff --git a/apps/extension/src/lib/alliance-retired-background.test.ts b/apps/extension/src/lib/alliance-retired-background.test.ts index e908a7e5..484a5aa7 100644 --- a/apps/extension/src/lib/alliance-retired-background.test.ts +++ b/apps/extension/src/lib/alliance-retired-background.test.ts @@ -68,7 +68,7 @@ function installBrowser( result: { stage, shops: [shop], - currentShopName: shop.name + currentShop: { id: shop.id!, name: shop.name } } }; } @@ -86,6 +86,27 @@ function installBrowser( } }; } + if (stage === "read-shop-context") { + return { + ok: true, + requestId: message.requestId, + result: { + stage, + currentShop: { id: shop.id!, name: shop.name } + } + }; + } + if (stage === "switch-shop") { + return { + ok: true, + requestId: message.requestId, + result: { + stage, + shopName: shop.name, + currentShop: { id: shop.id!, name: shop.name } + } + }; + } return { ok: true, requestId: message.requestId, @@ -228,6 +249,96 @@ describe("alliance retired-products browser navigation", () => { expect(state.removed).toEqual([]); }); + it("resumes id-less discovery after a shop switch reloads the source tab", async () => { + const sourceUrl = "https://fxg.jinritemai.com/ffa/g/list"; + const state = installBrowser( + [{ + id: 1, + windowId: 10, + active: true, + status: "complete", + url: sourceUrl + }], + () => undefined + ); + let currentShop = { id: "10001", name: "甲食品旗舰店" }; + const originalSendMessage = browser.tabs.sendMessage; + browser.tabs.sendMessage = (async ( + tabId: number, + message: { + type: string; + requestId?: string; + request?: { stage?: string; shop?: typeof shop }; + } + ) => { + if (message.type === "bpa.risk.preflight") { + return { riskSignals: [] }; + } + if (message.type !== "bpa.doudian.alliance.stage") { + return originalSendMessage(tabId, message); + } + const stage = message.request?.stage; + if (stage === "discover-shops") { + return { + ok: true, + requestId: message.requestId, + result: { + stage, + currentShop, + shops: [ + { + name: "甲食品旗舰店", + status: "active", + statusText: "正常营业", + switcherOrdinal: 0 + }, + { + name: "乙食品专营店", + status: "active", + statusText: "正常营业", + switcherOrdinal: 0 + } + ] + } + }; + } + if (stage === "switch-shop") { + const requested = message.request?.shop; + currentShop = requested?.name === "乙食品专营店" + ? { id: "10002", name: requested.name } + : { id: "10001", name: "甲食品旗舰店" }; + state.tabs.set(tabId, { + ...state.tabs.get(tabId)!, + url: "https://fxg.jinritemai.com/ffa/mshop/homepage/index", + status: "complete" + }); + throw new Error("The message port closed during navigation"); + } + if (stage === "read-shop-context") { + return { + ok: true, + requestId: message.requestId, + result: { stage, currentShop } + }; + } + return originalSendMessage(tabId, message); + }) as typeof browser.tabs.sendMessage; + const driver = createAllianceRetiredBrowserDriver({ + sourceTabId: 1, + deadline: new Date(Date.now() + 10_000).toISOString() + }); + + await expect(driver.discoverShopContext()).resolves.toMatchObject({ + currentShop: { id: "10001", name: "甲食品旗舰店" }, + shops: [ + { id: "10001", name: "甲食品旗舰店" }, + { id: "10002", name: "乙食品专营店" } + ] + }); + expect(state.tabs.get(1)?.url).toBe(sourceUrl); + expect(currentShop).toEqual({ id: "10001", name: "甲食品旗舰店" }); + }); + it("rejects before a tab-opening stage when no managed slot is available", async () => { const state = installBrowser( [ diff --git a/apps/extension/src/lib/alliance-retired-background.ts b/apps/extension/src/lib/alliance-retired-background.ts index f85b58d9..32a69700 100644 --- a/apps/extension/src/lib/alliance-retired-background.ts +++ b/apps/extension/src/lib/alliance-retired-background.ts @@ -72,13 +72,28 @@ export class AllianceRetiredDriverError extends Error { const DISCOVERY_ERROR_CODES = new Set([ "ALLIANCE_CONTENT_RESPONSE_TIMEOUT", + "CURRENT_SHOP_NOT_IN_LIST", "PAGE_LOADING", "PAGE_MISMATCH", "PAGE_URL_INVALID", + "SHOP_CONTEXT_RESTORE_FAILED", + "SHOP_IDENTITY_DRIFT", "SHOP_IDENTITY_AMBIGUOUS", "SHOP_IDENTITY_UNCONFIRMED", + "SHOP_IDENTITY_UNCERTAIN", "SHOP_LIST_EMPTY", - "SHOP_LIST_INCOMPLETE" + "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" ]); const SCAN_ERROR_CODES = new Set([ @@ -101,8 +116,11 @@ function safeContentCode( value: unknown, expectedStage: AllianceRetiredStageResult["stage"] ): DoudianAllianceNodeErrorCode { + const discoveryStage = + expectedStage === "discover-shops" || + expectedStage === "read-shop-context"; const fallback = - expectedStage === "discover-shops" + discoveryStage ? "DOUDIAN_ALLIANCE_DISCOVERY_FAILED" : "ALLIANCE_STAGE_FAILED"; if (typeof value !== "string" || !DOUDIAN_ALLIANCE_NODE_ERROR_CODES.has( @@ -111,7 +129,7 @@ function safeContentCode( return fallback; } const allowed = - expectedStage === "discover-shops" + discoveryStage ? DISCOVERY_ERROR_CODES : SCAN_ERROR_CODES; return allowed.has(value as DoudianAllianceNodeErrorCode) @@ -129,7 +147,10 @@ export interface AllianceRetiredBrowserDriver cleanupShopTabs(): Promise; discoverShopContext(): Promise<{ readonly shops: readonly AllianceShop[]; - readonly currentShopName: string; + readonly currentShop: { + readonly id: string; + readonly name: string; + }; }>; } @@ -147,6 +168,10 @@ function tabMatches( } } +function normalizeShopName(value: string): string { + return value.normalize("NFKC").replace(/\s+/gu, ""); +} + export function createAllianceRetiredBrowserDriver(input: { readonly sourceTabId: number; readonly deadline: string; @@ -373,6 +398,163 @@ export function createAllianceRetiredBrowserDriver(input: { throw new AllianceRetiredDriverError("ALLIANCE_TAB_TIMEOUT"); }; + const readShopContextAfterNavigation = async () => { + if (!sourceUrl) { + throw new AllianceRetiredDriverError("ALLIANCE_SOURCE_TAB_MISSING"); + } + const source = new URL(sourceUrl); + const current = await browser.tabs + .get(input.sourceTabId) + .catch(() => undefined); + if (!current) { + throw new AllianceRetiredDriverError("BROWSER_DISCONNECTED"); + } + if (!tabMatches(current, source.origin, source.pathname)) { + await browser.tabs + .update(input.sourceTabId, { url: sourceUrl }) + .catch(() => { + throw new AllianceRetiredDriverError("BROWSER_DISCONNECTED"); + }); + } + await waitForComplete(input.sourceTabId); + const retryUntil = Math.min( + Date.parse(input.deadline), + Date.now() + 20_000 + ); + let lastError: unknown; + while (Date.now() < retryUntil) { + assertNotCancelled(); + try { + return await stage>( + input.sourceTabId, + { stage: "read-shop-context" }, + "read-shop-context" + ); + } catch (error) { + lastError = error; + if ( + !(error instanceof AllianceRetiredDriverError) || + !["BROWSER_DISCONNECTED", "PAGE_LOADING"].includes(error.code) + ) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } + throw lastError instanceof AllianceRetiredDriverError + ? lastError + : new AllianceRetiredDriverError("BROWSER_DISCONNECTED"); + }; + + const switchAndConfirmShop = async (shop: AllianceShop) => { + let switchResult: + | Extract + | undefined; + try { + switchResult = await stage>( + input.sourceTabId, + { stage: "switch-shop", shop }, + "switch-shop" + ); + } catch (error) { + if ( + !(error instanceof AllianceRetiredDriverError) || + error.code !== "BROWSER_DISCONNECTED" + ) { + throw error; + } + } + const observed = switchResult?.currentShop ?? + (await readShopContextAfterNavigation()).currentShop; + if ( + normalizeShopName(observed.name) !== normalizeShopName(shop.name) || + (shop.id !== undefined && observed.id !== shop.id) + ) { + throw new AllianceRetiredDriverError("SHOP_IDENTITY_MISMATCH"); + } + return observed; + }; + + const resolveDiscoveredShopIds = async ( + shops: readonly AllianceShop[], + sourceShop: { readonly id: string; readonly name: string } + ): Promise => { + const resolved: AllianceShop[] = []; + let sourceSwitcherOrdinal: number | undefined; + let mayNeedRestore = false; + let primaryError: unknown; + try { + for (const shop of shops) { + assertNotCancelled(); + if (shop.status === "blocked") { + resolved.push(shop); + continue; + } + if ( + normalizeShopName(shop.name) === normalizeShopName(sourceShop.name) && + (shop.id === sourceShop.id || + (shop.id === undefined && + shops.filter( + (candidate) => + candidate.status === "active" && + normalizeShopName(candidate.name) === + normalizeShopName(sourceShop.name) + ).length === 1)) + ) { + resolved.push({ ...shop, id: sourceShop.id }); + if (shop.id === undefined) { + sourceSwitcherOrdinal = shop.switcherOrdinal; + } + continue; + } + if (shop.id !== undefined) { + resolved.push(shop); + continue; + } + mayNeedRestore = true; + const identity = await switchAndConfirmShop(shop); + if ( + identity.id === sourceShop.id && + normalizeShopName(identity.name) === normalizeShopName(sourceShop.name) + ) { + sourceSwitcherOrdinal = shop.switcherOrdinal; + } + resolved.push({ ...shop, id: identity.id }); + } + } catch (error) { + primaryError = error; + } + if (mayNeedRestore) { + try { + await switchAndConfirmShop({ + id: sourceShop.id, + ...(sourceSwitcherOrdinal === undefined + ? {} + : { switcherOrdinal: sourceSwitcherOrdinal }), + name: sourceShop.name, + status: "active", + statusText: "正常营业" + }); + } catch { + throw new AllianceRetiredDriverError("SHOP_CONTEXT_RESTORE_FAILED"); + } + } + if (primaryError) throw primaryError; + const ids = resolved + .filter((shop) => shop.status === "active") + .map((shop) => shop.id); + if (ids.some((id) => id === undefined) || new Set(ids).size !== ids.length) { + throw new AllianceRetiredDriverError("SHOP_IDENTITY_AMBIGUOUS"); + } + return resolved; + }; + const discoverShopContext = async () => { sourceUrl ??= ( await browser.tabs.get(input.sourceTabId).catch(() => { @@ -388,8 +570,8 @@ export function createAllianceRetiredBrowserDriver(input: { "discover-shops" ); return { - shops: result.shops, - currentShopName: result.currentShopName + shops: await resolveDiscoveredShopIds(result.shops, result.currentShop), + currentShop: result.currentShop }; }; @@ -399,11 +581,7 @@ export function createAllianceRetiredBrowserDriver(input: { return (await discoverShopContext()).shops; }, async switchShop(shop) { - await stage( - input.sourceTabId, - { stage: "switch-shop", shop }, - "switch-shop" - ); + await switchAndConfirmShop(shop); }, async openPromotion(_shop) { const landingTabId = await withManagedTabReservation(async () => { diff --git a/apps/extension/src/lib/alliance-retired-content.test.ts b/apps/extension/src/lib/alliance-retired-content.test.ts index 46864a3e..19bda688 100644 --- a/apps/extension/src/lib/alliance-retired-content.test.ts +++ b/apps/extension/src/lib/alliance-retired-content.test.ts @@ -67,10 +67,10 @@ describe("alliance retired-products content stages", () => {
甲食品旗舰店店铺ID 10001 正常营业
`); - const close = vi.spyOn( - document.querySelector("button")!, - "click" - ); + const close = vi.fn(); + document + .querySelector("button")! + .addEventListener("click", close); await expect( executeAllianceRetiredStage( { stage: "discover-shops" }, @@ -79,17 +79,150 @@ describe("alliance retired-products content stages", () => { ) ).resolves.toMatchObject({ stage: "discover-shops", - currentShopName: "甲食品旗舰店", + currentShop: { id: "10001", name: "甲食品旗舰店" }, shops: [{ id: "10001", name: "甲食品旗舰店" }] }); expect(close).toHaveBeenCalledOnce(); }); + it("opens and discovers the current drawer switcher after the header menu", async () => { + const document = doc(` +
+
甲食品旗舰店
+
+
+ `); + const trigger = document.querySelector(".headerShopName")!; + trigger.addEventListener("click", () => { + document.querySelector("#drawer-host")!.innerHTML = ` +
+
+ +
切换组织/店铺
+
+ 甲食品旗舰店 + 店铺ID 10001 正常营业 +
+
+
`; + }); + await expect( + executeAllianceRetiredStage( + { stage: "discover-shops" }, + document, + "https://fxg.jinritemai.com/ffa/g/list" + ) + ).resolves.toMatchObject({ + stage: "discover-shops", + currentShop: { id: "10001", name: "甲食品旗舰店" }, + shops: [{ id: "10001", name: "甲食品旗舰店" }] + }); + }); + + it("discovers shops when the current numeric ID is exposed only by the account popover", async () => { + const document = doc(` +
+
甲食品旗舰店
+
+
+
+ `); + document.querySelector(".headerShopName")!.addEventListener( + "click", + () => { + document.querySelector("#popover-host")!.innerHTML = ` +
+
甲食品旗舰店
+
店铺ID 10001
+
切换组织/店铺
+
`; + document.querySelector(".switch-entry")!.addEventListener( + "click", + () => { + document.querySelector("#drawer-host")!.innerHTML = ` +
+
+ +
切换组织/店铺
+
+ 甲食品旗舰店 + 店铺ID 10001 正常营业 +
+
+
`; + } + ); + } + ); + await expect( + executeAllianceRetiredStage( + { stage: "discover-shops" }, + document, + "https://fxg.jinritemai.com/ffa/g/list" + ) + ).resolves.toMatchObject({ + currentShop: { id: "10001", name: "甲食品旗舰店" }, + shops: [{ id: "10001", name: "甲食品旗舰店" }] + }); + }); + + it("reopens the account popover when a stale id-less switcher is already visible", async () => { + const document = doc(` +
+
甲食品旗舰店
+
+
+
+
+ +
甲食品旗舰店正常营业
+
+
+ `); + document.querySelector("button")!.addEventListener( + "click", + () => { + document.querySelector("#drawer-host")!.innerHTML = ""; + } + ); + document.querySelector(".headerShopName")!.addEventListener( + "click", + () => { + document.querySelector("#popover-host")!.innerHTML = ` +
+
甲食品旗舰店
+
店铺ID 10001
+
切换组织/店铺
+
`; + document.querySelector(".switch-entry")!.addEventListener( + "click", + () => { + document.querySelector("#drawer-host")!.innerHTML = ` +
+ +
甲食品旗舰店正常营业
+
`; + } + ); + } + ); + + await expect( + executeAllianceRetiredStage( + { stage: "discover-shops" }, + document, + "https://fxg.jinritemai.com/ffa/g/list" + ) + ).resolves.toMatchObject({ + currentShop: { id: "10001", name: "甲食品旗舰店" } + }); + }); + it("continues discovery when the authenticated header classes change", async () => { const document = doc(`
精选联盟 - +
账号管理
切换组织/店铺 @@ -105,7 +238,7 @@ describe("alliance retired-products content stages", () => { ) ).resolves.toMatchObject({ stage: "discover-shops", - currentShopName: "榆园儿食品专营店", + currentShop: { id: "10001", name: "榆园儿食品专营店" }, shops: [{ id: "10001", name: "榆园儿食品专营店" }] }); }); @@ -113,7 +246,7 @@ describe("alliance retired-products content stages", () => { it("discovers shops across a virtualized switcher", async () => { const document = doc(`
-
甲食品旗舰店
+
甲食品旗舰店
切换组织/店铺
甲食品旗舰店店铺ID 10001 正常营业
@@ -144,10 +277,92 @@ describe("alliance retired-products content stages", () => { }); }); + it("returns id-less active cards for navigation-safe background resolution", async () => { + const document = doc(` +
+
甲食品旗舰店
+
+
+
甲食品旗舰店正常营业
+
乙食品专营店正常营业
+
+ `); + const header = document.querySelector(".headerShopName")!; + const name = document.querySelector(".userName")!; + document.querySelector(".source")!.addEventListener( + "click", + () => { + header.dataset.shopId = "10001"; + name.textContent = "甲食品旗舰店"; + } + ); + document.querySelector(".target")!.addEventListener( + "click", + () => { + header.dataset.shopId = "10002"; + name.textContent = "乙食品专营店"; + } + ); + await expect( + executeAllianceRetiredStage( + { stage: "discover-shops" }, + document, + "https://fxg.jinritemai.com/ffa/g/list" + ) + ).resolves.toMatchObject({ + currentShop: { id: "10001", name: "甲食品旗舰店" }, + shops: [ + { name: "甲食品旗舰店" }, + { name: "乙食品专营店" } + ] + }); + expect(header.dataset.shopId).toBe("10001"); + expect(name.textContent).toBe("甲食品旗舰店"); + }); + + it("preserves ordinal identity for same-name id-less cards", async () => { + const document = doc(` +
+
同名食品店
+
+
+
同名食品店正常营业
+
同名食品店正常营业
+
+ `); + const header = document.querySelector(".headerShopName")!; + const name = document.querySelector(".userName")!; + document.querySelector(".first")!.addEventListener( + "click", + () => { + header.dataset.shopId = "10001"; + name.textContent = "同名食品店"; + } + ); + document.querySelector(".second")!.addEventListener( + "click", + () => { + header.dataset.shopId = "10002"; + name.textContent = "同名食品店"; + } + ); + await expect( + executeAllianceRetiredStage( + { stage: "discover-shops" }, + document, + "https://fxg.jinritemai.com/ffa/g/list" + ) + ).resolves.toMatchObject({ + currentShop: { id: "10002", name: "同名食品店" }, + shops: [{ switcherOrdinal: 0 }, { switcherOrdinal: 1 }] + }); + expect(header.dataset.shopId).toBe("10002"); + }); + it("does not silently stop after eight virtualized shop pages", async () => { const document = doc(`
-
店铺0食品店
+
店铺0食品店
切换组织/店铺
店铺0食品店店铺ID 10000 正常营业
@@ -178,10 +393,10 @@ describe("alliance retired-products content stages", () => { }); }); - it("rejects two distinct shop IDs with the same visible name", async () => { + it("uses the numeric header identity when two shops share a name", async () => { const document = doc(`
-
同名食品店
+
同名食品店
切换组织/店铺
同名食品店店铺ID 10001 正常营业
@@ -194,7 +409,10 @@ describe("alliance retired-products content stages", () => { document, "https://fxg.jinritemai.com/ffa/g/list" ) - ).rejects.toThrow("SHOP_IDENTITY_AMBIGUOUS"); + ).resolves.toMatchObject({ + currentShop: { id: "10001", name: "同名食品店" }, + shops: [{ id: "10001" }, { id: "10002" }] + }); }); it("filters the switcher and confirms the selected shop", async () => { @@ -235,7 +453,8 @@ describe("alliance retired-products content stages", () => { ) ).resolves.toEqual({ stage: "switch-shop", - shopName: "乙食品专营店" + shopName: "乙食品专营店", + currentShop: { id: "10002", name: "乙食品专营店" } }); }); diff --git a/apps/extension/src/lib/alliance-retired-content.ts b/apps/extension/src/lib/alliance-retired-content.ts index c35cac5c..8b775bd1 100644 --- a/apps/extension/src/lib/alliance-retired-content.ts +++ b/apps/extension/src/lib/alliance-retired-content.ts @@ -28,6 +28,7 @@ import { export type AllianceRetiredStageRequest = | { readonly stage: "discover-shops" } + | { readonly stage: "read-shop-context" } | { readonly stage: "switch-shop"; readonly shop: AllianceShop; @@ -44,11 +45,25 @@ export type AllianceRetiredStageResult = | { readonly stage: "discover-shops"; readonly shops: readonly AllianceShop[]; - readonly currentShopName: string; + readonly currentShop: { + readonly id: string; + readonly name: string; + }; + } + | { + readonly stage: "read-shop-context"; + readonly currentShop: { + readonly id: string; + readonly name: string; + }; } | { readonly stage: "switch-shop"; readonly shopName: string; + readonly currentShop: { + readonly id: string; + readonly name: string; + }; } | { readonly stage: "open-promotion" } | { readonly stage: "open-product-promotion" } @@ -157,7 +172,15 @@ async function ensureShopDialog( openDoudianShopSwitcher(doc); } await waitUntil( - () => discoverDoudianAllianceShops(doc), + () => { + try { + return discoverDoudianAllianceShops(doc); + } catch (error) { + assertNotCancelled(isCancelled); + openDoudianShopSwitcher(doc); + throw error; + } + }, 8_000, "SHOP_SWITCH_DIALOG_TIMEOUT", doc, @@ -165,9 +188,40 @@ async function ensureShopDialog( ); } +async function readCurrentShopIdentity( + doc: Document, + isCancelled: () => boolean +): Promise<{ readonly id: string; readonly name: string }> { + assertNotCancelled(isCancelled); + try { + return readDoudianHeaderShopIdentity(doc); + } catch (error) { + if ( + !(error instanceof DoudianAllianceError) || + error.code !== "SHOP_IDENTITY_UNCERTAIN" + ) { + throw error; + } + try { + closeDoudianShopSwitcher(doc); + await waitForChange(250, doc); + } catch { + // No switcher was open; continue through the authenticated header. + } + openDoudianShopSwitcher(doc); + } + return waitUntil( + () => readDoudianHeaderShopIdentity(doc), + 15_000, + "SHOP_IDENTITY_UNCERTAIN", + doc, + isCancelled + ); +} + async function discoverAllShops( doc: Document, - currentShopName: string, + currentShop: { readonly id: string; readonly name: string }, isCancelled: () => boolean ): Promise { assertNotCancelled(isCancelled); @@ -180,7 +234,7 @@ async function discoverAllShops( for (const shop of discoverDoudianAllianceShops(doc)) { const key = shop.id ? `id:${shop.id}` - : `name:${normalize(shop.name)}`; + : `name:${normalize(shop.name)}:${shop.switcherOrdinal ?? 0}`; const existing = shops.get(key); if ( existing && @@ -197,15 +251,14 @@ async function discoverAllShops( await waitForChange(450, doc); assertNotCancelled(isCancelled); } - const sameName = [...shops.values()].filter( - (shop) => normalize(shop.name) === normalize(currentShopName) + const sourceMatches = [...shops.values()].filter( + (shop) => + normalize(shop.name) === normalize(currentShop.name) && + (shop.id === undefined || shop.id === currentShop.id) ); - if (sameName.length === 0) { + if (sourceMatches.length === 0) { throw new DoudianAllianceError("CURRENT_SHOP_NOT_IN_LIST"); } - if (sameName.length > 1) { - throw new DoudianAllianceError("SHOP_IDENTITY_AMBIGUOUS"); - } return [...shops.values()]; } @@ -256,12 +309,17 @@ export async function executeAllianceRetiredStage( assertNotCancelled(isCancelled); if (request.stage === "discover-shops") { assertDoudianProductListPage(pageUrl); - const currentShopName = readDoudianHeaderShopName(doc); + const currentShop = await readCurrentShopIdentity(doc, isCancelled); await ensureShopDialog(doc, isCancelled); - const shops = await discoverAllShops(doc, currentShopName, isCancelled); + const discovered = await discoverAllShops(doc, currentShop, isCancelled); assertNotCancelled(isCancelled); closeDoudianShopSwitcher(doc); - return { stage: request.stage, shops, currentShopName }; + return { stage: request.stage, shops: discovered, currentShop }; + } + if (request.stage === "read-shop-context") { + assertDoudianProductListPage(pageUrl); + const currentShop = await readCurrentShopIdentity(doc, isCancelled); + return { stage: request.stage, currentShop }; } if (request.stage === "switch-shop") { assertDoudianProductListPage(pageUrl); @@ -275,7 +333,8 @@ export async function executeAllianceRetiredStage( } catch { // The switcher is already closed. } - return { stage: request.stage, shopName: current }; + const currentShop = await readCurrentShopIdentity(doc, isCancelled); + return { stage: request.stage, shopName: current, currentShop }; } await ensureShopDialog(doc, isCancelled); await selectShopAcrossVirtualList(doc, request.shop, isCancelled); @@ -293,11 +352,11 @@ export async function executeAllianceRetiredStage( isCancelled ); assertNotCancelled(isCancelled); - const identity = readDoudianHeaderShopIdentity(doc); + const identity = await readCurrentShopIdentity(doc, isCancelled); if (!request.shop.id || identity.id !== request.shop.id) { throw new DoudianAllianceError("SHOP_IDENTITY_MISMATCH"); } - return { stage: request.stage, shopName }; + return { stage: request.stage, shopName, currentShop: identity }; } if (request.stage === "open-promotion") { assertDoudianProductListPage(pageUrl); diff --git a/apps/extension/src/lib/binance-detail-background.test.ts b/apps/extension/src/lib/binance-detail-background.test.ts new file mode 100644 index 00000000..03be7b51 --- /dev/null +++ b/apps/extension/src/lib/binance-detail-background.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + BinanceDetailDriverError, + createBinanceDetailBrowserDriver +} from "./binance-detail-background.js"; + +const managementUrl = "https://www.binance.com/zh-CN/copy-trading/copy-management"; +const target = { + projectId: "project_1001", + projectStatus: "ongoing" as const, + managementUrl +}; +const snapshot = { + schemaVersion: "binance-copy-trading/v0.1" as const, + status: "complete" as const, + projectId: target.projectId, + observedAt: "2026-08-12T10:00:00.000Z", + pageUrl: managementUrl, + tabs: [], + formMutations: 0 as const +}; + +describe("Binance same-page detail browser driver", () => { + const get = vi.fn(); + const update = vi.fn(); + const sendMessage = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + get.mockResolvedValue({ id: 42, status: "complete", url: managementUrl }); + sendMessage.mockResolvedValue({ riskSignals: [] }); + vi.stubGlobal("browser", { tabs: { get, update, sendMessage } }); + }); + + it("collects in the bound management tab without navigation", async () => { + sendMessage.mockImplementationOnce(async () => ({ riskSignals: [] })); + sendMessage.mockImplementationOnce(async (_tabId, message) => ({ + ok: true, + requestId: message.requestId, + result: { stage: "collect-project", snapshot } + })); + const driver = createBinanceDetailBrowserDriver({ + sourceTabId: 42, + deadline: new Date(Date.now() + 60_000).toISOString() + }); + await expect(driver.collectProject(target)).resolves.toEqual(snapshot); + expect(update).not.toHaveBeenCalled(); + expect(get).toHaveBeenCalledTimes(2); + expect(sendMessage).toHaveBeenCalledTimes(2); + }); + + it("fails closed before content actions when the bound page changed", async () => { + get.mockResolvedValue({ id: 42, status: "complete", url: "https://www.binance.com/zh-CN/login" }); + const driver = createBinanceDetailBrowserDriver({ + sourceTabId: 42, + deadline: new Date(Date.now() + 60_000).toISOString() + }); + await expect(driver.collectProject(target)).rejects.toEqual( + expect.objectContaining>({ code: "PAGE_CONTEXT_CHANGED" }) + ); + expect(sendMessage).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }); + + it("stops on blocking preflight without starting project collection", async () => { + sendMessage.mockReset().mockResolvedValueOnce({ + riskSignals: [{ + code: "CAPTCHA_REQUIRED", + category: "challenge", + severity: "blocking", + source: "page", + detected_at: "2026-08-12T10:00:00.000Z", + detail: "manual verification required" + }] + }); + const driver = createBinanceDetailBrowserDriver({ + sourceTabId: 42, + deadline: new Date(Date.now() + 60_000).toISOString() + }); + await expect(driver.collectProject(target)).rejects.toEqual( + expect.objectContaining>({ code: "CAPTCHA_REQUIRED" }) + ); + expect(sendMessage).toHaveBeenCalledOnce(); + expect(update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/extension/src/lib/binance-detail-background.ts b/apps/extension/src/lib/binance-detail-background.ts new file mode 100644 index 00000000..95851a5b --- /dev/null +++ b/apps/extension/src/lib/binance-detail-background.ts @@ -0,0 +1,196 @@ +import { + validateBinanceProjectTarget, + type BinanceProjectDetailSnapshot +} from "@bpa/adapter-binance"; +import type { RiskSignal } from "@bpa/schemas"; +import type { + BinanceDetailStageRequest, + BinanceDetailStageResult +} from "./binance-detail-content"; + +export type BinanceDetailDriverErrorCode = + | "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_IDENTITY_MISMATCH" + | "BINANCE_PROJECT_CARD_AMBIGUOUS" + | "BINANCE_PROJECT_CARD_MISSING" + | "BINANCE_PROJECT_COLLAPSE_FAILED" + | "BINANCE_PROJECT_EXPAND_AMBIGUOUS" + | "BINANCE_PROJECT_EXPAND_TIMEOUT" + | "BINANCE_PROJECT_TARGET_INVALID" + | "BROWSER_DISCONNECTED" + | "CAPTCHA_REQUIRED" + | "COMMAND_CANCELLED" + | "DEADLINE_EXCEEDED" + | "PAGE_CONTEXT_CHANGED" + | "PAGE_LOADING" + | "RATE_LIMITED" + | "RISK_CONTROL" + | "SESSION_EXPIRED"; + +const CONTENT_CODES = new Set([ + "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_PAGE_LIMIT_EXCEEDED", + "BINANCE_PAGINATION_AMBIGUOUS", + "BINANCE_PAGINATION_CHANGED", + "BINANCE_PAGINATION_REPEATED", + "BINANCE_PAGINATION_TIMEOUT", + "BINANCE_PROJECT_IDENTITY_MISMATCH", + "BINANCE_PROJECT_CARD_AMBIGUOUS", + "BINANCE_PROJECT_CARD_MISSING", + "BINANCE_PROJECT_COLLAPSE_FAILED", + "BINANCE_PROJECT_EXPAND_AMBIGUOUS", + "BINANCE_PROJECT_EXPAND_TIMEOUT", + "BINANCE_PROJECT_TARGET_INVALID", + "CAPTCHA_REQUIRED", + "COMMAND_CANCELLED", + "DEADLINE_EXCEEDED", + "PAGE_CONTEXT_CHANGED", + "RATE_LIMITED", + "RISK_CONTROL", + "SESSION_EXPIRED" +]); + +export class BinanceDetailDriverError extends Error { + constructor( + readonly code: BinanceDetailDriverErrorCode, + readonly riskSignals: readonly RiskSignal[] = [] + ) { + super(`Binance 详情浏览器采集已停止:${code}`); + this.name = "BinanceDetailDriverError"; + } +} + +interface StageResponse { + readonly ok?: boolean; + readonly requestId?: string; + readonly result?: BinanceDetailStageResult; + readonly error?: { readonly code?: string }; +} + +interface PreflightResponse { + readonly riskSignals?: readonly RiskSignal[]; +} + +export function createBinanceDetailBrowserDriver(input: { + readonly sourceTabId: number; + readonly deadline: string; + readonly isCancelled?: () => boolean; + readonly stageResponseTimeoutMs?: number; +}): { collectProject(target: Readonly>): Promise } { + const assertActive = (): void => { + if (input.isCancelled?.()) throw new BinanceDetailDriverError("COMMAND_CANCELLED"); + if (!Number.isFinite(Date.parse(input.deadline)) || Date.now() >= Date.parse(input.deadline)) { + throw new BinanceDetailDriverError("DEADLINE_EXCEEDED"); + } + }; + const browserFailure = (): BinanceDetailDriverError => + new BinanceDetailDriverError("BROWSER_DISCONNECTED"); + const assertPage = async (expectedUrl: string): Promise => { + assertActive(); + const tab = await browser.tabs.get(input.sourceTabId).catch(() => undefined); + if (!tab) throw browserFailure(); + if (tab.status !== "complete") throw new BinanceDetailDriverError("PAGE_LOADING"); + if (tab.url !== expectedUrl) throw new BinanceDetailDriverError("PAGE_CONTEXT_CHANGED"); + }; + const preflight = async (): Promise => { + const response = (await browser.tabs.sendMessage(input.sourceTabId, { + type: "bpa.risk.preflight" + }).catch(() => { + throw browserFailure(); + })) as PreflightResponse; + const blocking = response.riskSignals?.find((signal) => signal.severity === "blocking"); + if (blocking) { + const code = ["CAPTCHA_REQUIRED", "RATE_LIMITED", "RISK_CONTROL", "SESSION_EXPIRED"].includes(blocking.code) + ? (blocking.code as BinanceDetailDriverErrorCode) + : "RISK_CONTROL"; + throw new BinanceDetailDriverError(code, response.riskSignals ?? []); + } + }; + return { + async collectProject(rawTarget) { + let target: ReturnType; + try { + target = validateBinanceProjectTarget(rawTarget); + } catch { + throw new BinanceDetailDriverError("BINANCE_PROJECT_TARGET_INVALID"); + } + let snapshot: BinanceProjectDetailSnapshot | undefined; + let primaryError: unknown; + try { + await assertPage(target.managementUrl); + await preflight(); + const requestId = `${input.sourceTabId}:${Date.now()}:${target.projectId}`; + const request: BinanceDetailStageRequest = { + stage: "collect-project", + ...target, + deadline: input.deadline + }; + const remaining = Date.parse(input.deadline) - Date.now(); + const timeoutMs = Math.max(1, Math.min(input.stageResponseTimeoutMs ?? 5 * 60_000, remaining)); + let timer: ReturnType | undefined; + let response: StageResponse; + try { + response = (await Promise.race([ + browser.tabs.sendMessage(input.sourceTabId, { + type: "bpa.binance.detail.stage", + requestId, + request + }), + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new BinanceDetailDriverError("BINANCE_CONTENT_RESPONSE_TIMEOUT")), timeoutMs); + }) + ])) as StageResponse; + } catch (error) { + if (error instanceof BinanceDetailDriverError && error.code === "BINANCE_CONTENT_RESPONSE_TIMEOUT") { + const stopped = (await browser.tabs.sendMessage(input.sourceTabId, { + type: "bpa.binance.detail.cancel-stage", + requestId + }).catch(() => undefined)) as { stopped?: boolean } | undefined; + if (stopped?.stopped !== true) throw browserFailure(); + } + throw error instanceof BinanceDetailDriverError ? error : browserFailure(); + } finally { + if (timer) clearTimeout(timer); + } + if (!response?.ok || response.requestId !== requestId || response.result?.stage !== "collect-project") { + const contentCode = response?.error?.code; + throw new BinanceDetailDriverError( + typeof contentCode === "string" && CONTENT_CODES.has(contentCode as BinanceDetailDriverErrorCode) + ? (contentCode as BinanceDetailDriverErrorCode) + : "BINANCE_DETAIL_STAGE_FAILED" + ); + } + snapshot = response.result.snapshot; + await assertPage(target.managementUrl); + } catch (error) { + primaryError = error; + } + if (primaryError) throw primaryError; + if (!snapshot) throw new BinanceDetailDriverError("BINANCE_DETAIL_STAGE_FAILED"); + return snapshot; + } + }; +} diff --git a/apps/extension/src/lib/binance-detail-content.ts b/apps/extension/src/lib/binance-detail-content.ts new file mode 100644 index 00000000..57280179 --- /dev/null +++ b/apps/extension/src/lib/binance-detail-content.ts @@ -0,0 +1,87 @@ +import { + collectBinanceProjectDetail, + validateBinanceProjectTarget, + type BinanceProjectDetailSnapshot +} from "@bpa/adapter-binance"; + +export interface BinanceDetailStageRequest { + readonly stage: "collect-project"; + readonly projectId: string; + readonly projectStatus: "ongoing" | "ended"; + readonly managementUrl: string; + readonly deadline: string; +} + +export interface BinanceDetailStageResult { + readonly stage: "collect-project"; + readonly snapshot: BinanceProjectDetailSnapshot; +} + +const SAFE_ERROR_CODES = new Set([ + "BINANCE_DETAIL_HEADERS_MISSING", + "BINANCE_DETAIL_ROW_CHANGED", + "BINANCE_DETAIL_ROW_LIMIT_EXCEEDED", + "BINANCE_DETAIL_STRUCTURE_UNCONFIRMED", + "BINANCE_DETAIL_TAB_AMBIGUOUS", + "BINANCE_DETAIL_TAB_NOT_ACTIVE", + "BINANCE_DETAIL_TAB_TIMEOUT", + "BINANCE_PAGE_LIMIT_EXCEEDED", + "BINANCE_MANAGEMENT_RESTORE_FAILED", + "BINANCE_MANAGEMENT_TAB_AMBIGUOUS", + "BINANCE_MANAGEMENT_TAB_TIMEOUT", + "BINANCE_PAGINATION_AMBIGUOUS", + "BINANCE_PAGINATION_CHANGED", + "BINANCE_PAGINATION_REPEATED", + "BINANCE_PAGINATION_TIMEOUT", + "BINANCE_PROJECT_IDENTITY_MISMATCH", + "BINANCE_PROJECT_CARD_AMBIGUOUS", + "BINANCE_PROJECT_CARD_MISSING", + "BINANCE_PROJECT_COLLAPSE_FAILED", + "BINANCE_PROJECT_EXPAND_AMBIGUOUS", + "BINANCE_PROJECT_EXPAND_TIMEOUT", + "BINANCE_PROJECT_TARGET_INVALID", + "CAPTCHA_REQUIRED", + "COMMAND_CANCELLED", + "DEADLINE_EXCEEDED", + "PAGE_CONTEXT_CHANGED", + "RATE_LIMITED", + "RISK_CONTROL", + "SESSION_EXPIRED" +]); + +export function binanceDetailErrorPayload(error: unknown): { + readonly code: string; + readonly message: string; +} { + const code = + error instanceof Error && SAFE_ERROR_CODES.has(error.message) + ? error.message + : "BINANCE_DETAIL_STAGE_FAILED"; + return { code, message: `Binance 详情只读采集已停止:${code}` }; +} + +export async function executeBinanceDetailStage( + request: BinanceDetailStageRequest, + document: Document, + pageUrl: string, + isCancelled: () => boolean +): Promise { + if (request?.stage !== "collect-project") { + throw new Error("BINANCE_PROJECT_TARGET_INVALID"); + } + const targetInput: Readonly> = { + projectId: request.projectId, + projectStatus: request.projectStatus, + managementUrl: request.managementUrl + }; + const target = validateBinanceProjectTarget(targetInput); + if (pageUrl !== target.managementUrl) throw new Error("PAGE_CONTEXT_CHANGED"); + const snapshot = await collectBinanceProjectDetail(document, targetInput, { + deadline: request.deadline, + isCancelled + }); + if (document.defaultView?.location.href !== target.managementUrl) { + throw new Error("PAGE_CONTEXT_CHANGED"); + } + return { stage: "collect-project", snapshot }; +} diff --git a/apps/extension/src/lib/capability-manifest.test.ts b/apps/extension/src/lib/capability-manifest.test.ts index a84ac233..7c9c82a6 100644 --- a/apps/extension/src/lib/capability-manifest.test.ts +++ b/apps/extension/src/lib/capability-manifest.test.ts @@ -71,9 +71,21 @@ describe("extension capability manifest", () => { "exact_tab_binding_v2", "active_page_probe_v1" ]); - expect(report.capabilities).toHaveLength(13); + expect(report.capabilities).toHaveLength(15); expect(report.capabilities).toEqual( expect.arrayContaining([ + expect.objectContaining({ + node_id: "binance.copy-trading.management.snapshot.read", + risk_level: "R1", + adapter_id: "binance-copy-trading", + versions: ["1.0.0"] + }), + expect.objectContaining({ + node_id: "binance.copy-trading.project.detail.collect", + risk_level: "R1", + adapter_id: "binance-copy-trading", + versions: ["1.0.0"] + }), expect.objectContaining({ node_id: "ecommerce.marketplace.search-results.read", risk_level: "R1", @@ -159,6 +171,18 @@ describe("extension capability manifest", () => { }); it.each([ + { + nodeId: "binance.copy-trading.management.snapshot.read", + nodeVersion: "1.0.0", + currentUrl: "https://www.binance.com/zh-CN/copy-trading/copy-management", + grantedPermissions: ["browser.dom.read", "browser.dom.write", "browser.tabs.read"] + }, + { + nodeId: "binance.copy-trading.project.detail.collect", + nodeVersion: "1.0.0", + currentUrl: "https://www.binance.com/zh-CN/copy-trading/copy-management", + grantedPermissions: ["browser.dom.read", "browser.dom.write", "browser.tabs.read"] + }, { nodeId: "browser.design.snapshot.capture", nodeVersion: "1.0.0", diff --git a/apps/extension/src/lib/capability-manifest.ts b/apps/extension/src/lib/capability-manifest.ts index fbc948ec..8a4ab6e9 100644 --- a/apps/extension/src/lib/capability-manifest.ts +++ b/apps/extension/src/lib/capability-manifest.ts @@ -1,6 +1,8 @@ import type { BridgeCapability } from "@bpa/browser-bridge"; export const BROWSER_PROTOCOL = "bpa.browser/2"; +export const BINANCE_ADAPTER_VERSION = "1.0.0"; +export const BINANCE_ORIGIN = "https://www.binance.com"; export const DOUDIAN_ADAPTER_VERSION = "1.2.0"; export const DOUDIAN_INVENTORY_ADAPTER_VERSION = "2.0.0"; export const DOUDIAN_ALLIANCE_ADAPTER_VERSION = "2.0.0"; @@ -19,6 +21,8 @@ export const BROWSER_FEATURES = [ export type ExtensionNodeId = | "browser.design.snapshot.capture" + | "binance.copy-trading.management.snapshot.read" + | "binance.copy-trading.project.detail.collect" | "doudian.shop.context.read" | "doudian.product.scope.collect" | "doudian.product.scope.restore" @@ -43,7 +47,7 @@ export interface ExtensionCapability { readonly observerCapabilityId: string; }[]; readonly adapter?: { - readonly id: "doudian" | "doudian-inventory" | "doudian-alliance" | "doudian-experience" | "marketplace-search"; + readonly id: "binance-copy-trading" | "doudian" | "doudian-inventory" | "doudian-alliance" | "doudian-experience" | "marketplace-search"; readonly version: string; }; readonly executionTarget?: "background"; @@ -56,6 +60,43 @@ const READ_ONLY_PERMISSIONS = [ ] as const; export const EXTENSION_CAPABILITIES: readonly ExtensionCapability[] = [ + { + nodeId: "binance.copy-trading.management.snapshot.read", + versions: ["1.0.0"], + riskLevel: "R1", + permissions: [ + "browser.dom.read", + "browser.dom.write", + "browser.tabs.read" + ], + routes: [ + { + origin: BINANCE_ORIGIN, + pathnamePrefixes: ["/zh-CN/copy-trading/copy-management"], + observerCapabilityId: "binance.copy-trading.page" + } + ], + adapter: { id: "binance-copy-trading", version: BINANCE_ADAPTER_VERSION } + }, + { + nodeId: "binance.copy-trading.project.detail.collect", + versions: ["1.0.0"], + riskLevel: "R1", + permissions: [ + "browser.dom.read", + "browser.dom.write", + "browser.tabs.read" + ], + routes: [ + { + origin: BINANCE_ORIGIN, + pathnamePrefixes: ["/zh-CN/copy-trading/copy-management"], + observerCapabilityId: "binance.copy-trading.page" + } + ], + adapter: { id: "binance-copy-trading", version: BINANCE_ADAPTER_VERSION }, + executionTarget: "background" + }, { nodeId: "ecommerce.marketplace.search-results.read", versions: ["1.0.0"], @@ -331,7 +372,7 @@ export interface ExtensionCapabilityReport { pathname_prefixes: string[]; observer_capability_id: string; }>; - adapter_id?: "doudian" | "doudian-inventory" | "doudian-alliance" | "doudian-experience" | "marketplace-search"; + adapter_id?: "binance-copy-trading" | "doudian" | "doudian-inventory" | "doudian-alliance" | "doudian-experience" | "marketplace-search"; adapter_version?: string; }>; manifest_digest: `sha256:${string}`; diff --git a/apps/extension/src/lib/content-action-router.test.ts b/apps/extension/src/lib/content-action-router.test.ts index 5b4e50ae..2e910c77 100644 --- a/apps/extension/src/lib/content-action-router.test.ts +++ b/apps/extension/src/lib/content-action-router.test.ts @@ -26,6 +26,12 @@ function request( function handlers(): ContentActionHandlers { return { + "binance.copy-trading.management.snapshot.read": vi.fn(async () => ({ + output: { schemaVersion: "binance-copy-trading/v0.1", projects: [] } + })), + "binance.copy-trading.project.detail.collect": vi.fn(async () => ({ + output: { schemaVersion: "binance-copy-trading/v0.1", tabs: [] } + })), "ecommerce.marketplace.search-results.read": vi.fn(async () => ({ output: { schemaVersion: "marketplace-probe/v0.1", items: [] } })), diff --git a/apps/extension/src/lib/content-action-router.ts b/apps/extension/src/lib/content-action-router.ts index b71b4e6c..329c9b97 100644 --- a/apps/extension/src/lib/content-action-router.ts +++ b/apps/extension/src/lib/content-action-router.ts @@ -33,6 +33,14 @@ export interface ContentActionResult { } export interface ContentActionHandlers { + readonly "binance.copy-trading.management.snapshot.read": ( + input: Readonly>, + request: ContentActionRequest + ) => Promise; + readonly "binance.copy-trading.project.detail.collect": ( + input: Readonly>, + request: ContentActionRequest + ) => Promise; readonly "ecommerce.marketplace.search-results.read": ( input: Readonly>, request: ContentActionRequest diff --git a/apps/extension/src/lib/experience-score-background.ts b/apps/extension/src/lib/experience-score-background.ts index bf837e1c..bd612a95 100644 --- a/apps/extension/src/lib/experience-score-background.ts +++ b/apps/extension/src/lib/experience-score-background.ts @@ -304,7 +304,11 @@ export function createExperienceScoreBrowserDriver(input: { return { async discoverShopContext() { try { - return await shopDriver.discoverShopContext(); + const discovery = await shopDriver.discoverShopContext(); + return { + shops: discovery.shops, + currentShopName: discovery.currentShop.name + }; } catch (error) { throw mapShopDriverError( error, diff --git a/apps/extension/src/lib/page-observer-registry.ts b/apps/extension/src/lib/page-observer-registry.ts index 031d7197..264d0884 100644 --- a/apps/extension/src/lib/page-observer-registry.ts +++ b/apps/extension/src/lib/page-observer-registry.ts @@ -1,3 +1,9 @@ +import { + BINANCE_MANAGEMENT_PATH, + BINANCE_ORIGIN, + detectBinanceRiskSignals, + readBinanceManagementSnapshot +} from "@bpa/adapter-binance"; import { detectDoudianRiskSignals, readDoudianShopContext, @@ -117,6 +123,44 @@ function hasInteractivePageShell(document: Document): boolean { } const observers: readonly PageObserver[] = [ + { + capabilityId: "binance.copy-trading.page", + supports: (url) => + url.origin === BINANCE_ORIGIN && + url.pathname.startsWith(BINANCE_MANAGEMENT_PATH), + async probe(document, url) { + const blocking = firstBlockingRiskSignal( + detectBinanceRiskSignals(document, url.href) + ); + if (blocking) { + return { + observerCapabilityId: this.capabilityId, + authentication: { + state: blocking.code === "SESSION_EXPIRED" ? "anonymous" : "unknown" + }, + observationState: + blocking.code === "SESSION_EXPIRED" ? "auth_required" : "challenge", + reasonCode: blocking.code + }; + } + try { + readBinanceManagementSnapshot(document, url.href); + return { + observerCapabilityId: this.capabilityId, + authentication: { state: "authenticated" }, + observationState: "ready" + }; + } catch (error) { + const code = error instanceof Error ? error.message : String(error); + return { + observerCapabilityId: this.capabilityId, + authentication: { state: "unknown" }, + observationState: code === "BINANCE_STRUCTURE_UNCONFIRMED" ? "probing" : "stale", + reasonCode: code + }; + } + } + }, marketplaceObserver("douyin.marketplace-search.page", "https://www.douyin.com"), marketplaceObserver("taobao.marketplace-search.page", "https://s.taobao.com"), marketplaceObserver("jd.marketplace-search.page", "https://search.jd.com"), diff --git a/apps/extension/wxt.config.ts b/apps/extension/wxt.config.ts index 320bad4f..fe1572b6 100644 --- a/apps/extension/wxt.config.ts +++ b/apps/extension/wxt.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ "webNavigation" ], host_permissions: [ + "https://www.binance.com/*", "https://fxg.jinritemai.com/*", "https://buyin.jinritemai.com/*", "https://www.chanmama.com/*", diff --git a/apps/inventory-monitor/deploy/README.md b/apps/inventory-monitor/deploy/README.md index 486f9242..2e286606 100644 --- a/apps/inventory-monitor/deploy/README.md +++ b/apps/inventory-monitor/deploy/README.md @@ -49,12 +49,12 @@ The serialized recovery path deliberately degrades when either the frozen produc The inventory report is a separate one-shot launchd job. It does not import the experience-score project, open a browser, share a process, or write to inventory facts. It only reads the BPA application database and posts one interactive card through its own `0600` environment file. - Label: `com.bpa.inventory-feishu-report` -- Schedule: daily at 09:30 Asia/Shanghai +- Schedule: daily at 08:30 Asia/Shanghai - Environment: `~/Library/Application Support/BPA/inventory-feishu-report.env` - Logs: `inventory-feishu-report.out.log` and `inventory-feishu-report.err.log` - Idempotency: `audit.change_event` target `inventory-daily::` -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 天时先发现问题,运营确认后再升级;不写入正式预测事件。
'+signals.slice(0,30).map(item=>'').join('')+'
等级店铺商品渠道品 ID渠道库存SKU 日 P90 合计
冷启动关注'+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))+'
';}; -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 '';}).join('')+'
等级商品影响范围判断依据更新时间
'+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))+'
';} -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 '
'+ticks+''+labels+'
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 ''+(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 '';}).join('')+'
商家编码 / SKU ID当前占用未占用渠道品 ID / 库存日 P50 / P902h / 6h / 24h P90模型
'+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)+'':'—')+'
';} -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 '
'+products.map(product=>{const risk=riskByProduct.get(String(product.product_id))||'normal',title=productTitle(product.product_title,product.product_id);const search=[title,product.product_id,...(product.skus||[]).flatMap(sku=>[sku.platform_sku_id,sku.merchant_code,...(sku.channels||[]).map(channel=>channel.channelGoodsId)])].join(' ').toLowerCase();return '';}).join('')+'
商品风险SKU 数总库存映射质量操作
'+esc(title)+'
'+esc(product.product_id)+''+copyButton(product.product_id)+'
'+esc(severityName(risk))+''+esc((product.skus||[]).length)+''+esc(product.total_stock)+''+esc(product.mapping_confidence)+'
';} -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 '';}).join('')+'
等级店铺商品判断依据更新时间
'+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))+'
'+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 '
'+products.map(product=>{const key=String(product.shop_id)+':'+String(product.product_id),observedAt=Date.parse(product.observed_at||''),fresh=Number.isFinite(observedAt)&&Date.now()-observedAt<=validityMs,risk=fresh?(riskByProduct.get(key)||'normal'):'unknown',riskLabel=fresh?severityName(risk):'待刷新',legacyLow=productHasLegacyLowChannel(product),title=productTitle(product.product_title,product.product_id),search=[product.shop_name,title,product.product_id,...(product.skus||[]).flatMap(sku=>[sku.platform_sku_id,sku.merchant_code,...(sku.channels||[]).map(channel=>channel.channelGoodsId)])].join(' ').toLowerCase(),detailId=product.shop_id+':'+product.product_id;return '';}).join('')+'
店铺商品风险SKU 数总库存映射质量操作
'+esc(product.shop_name||product.shop_id)+'
'+esc(title)+'
'+esc(product.product_id)+''+copyButton(product.product_id)+'
'+esc(riskLabel)+''+(legacyLow?'渠道 <200 对照':'')+''+esc((product.skus||[]).length)+''+esc(product.total_stock)+''+esc(product.mapping_confidence)+'
';} -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||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?'
'+page.map(product=>{const severity=productSeverity(product,riskMap);return '';}).join('')+'
店铺商品风险状态SKU 数总库存快照时间数据状态
'+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)?'数据待刷新':'数据有效')+'
':'
没有匹配的商品,请调整搜索或筛选条件。
';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