diff --git a/src/api/scan.ts b/src/api/scan.ts index 28b05ee..902cb09 100644 --- a/src/api/scan.ts +++ b/src/api/scan.ts @@ -1,63 +1,15 @@ -import axios from "axios"; import request from "@/utils/reqeust"; -export type ExtensionPairResponse = { - token: string; - expiresAt: string; - userEmail: string | null; - apiBaseUrl: string; -}; - -export type IngestScanRequest = { - brandId: string; - storeId: string; - scannedAt: string; - payload: Record; - extractorStatus: "ok" | "partial" | "failed"; - extractorErrors: string[]; - trigger: "manual" | "scheduled"; - platformAccountId?: string | null; - fieldMeta: Record; -}; - -export async function pairExtensionTokenApi() { +/** + * 网页侧用 Web token 换取“扩展专用 token”。 + * 中文备注:接口来自 BACKEND_API_DOCUMENTATION.md:POST `/api/auth/extension-pair` + * + * 按你的要求:返回值用 any,不定义一堆类型。 + */ +export async function pairExtensionTokenApi(): Promise { return await request.post("/auth/extension-pair", { label: getExtensionPairLabel(), - }) as unknown as ExtensionPairResponse; -} - -export async function ingestScanWithExtensionTokenApi( - body: IngestScanRequest, - extensionToken: string, - apiBaseUrl?: string, - extensionVersion?: string, -) { - const url = `${normaliseApiBaseUrl(apiBaseUrl)}/ingest/scan`; - const response = await axios.post(url, body, { - headers: { - Authorization: `Bearer ${extensionToken}`, - "Content-Type": "application/json", - "X-Ext-Version": extensionVersion || "0.1.4", - }, - timeout: 90000, }); - - if (response.data?.code === 1 || response.data?.code === "200") { - return response.data.data; - } - - throw response.data; -} - -function normaliseApiBaseUrl(apiBaseUrl?: string) { - const fallback = process.env.NEXT_PUBLIC_API_URL || ""; - const raw = (apiBaseUrl || fallback).replace(/\/$/, ""); - - if (!raw) { - throw new Error("Missing API base URL."); - } - - return raw.endsWith("/api") ? raw : `${raw}/api`; } function getExtensionPairLabel() { diff --git a/src/app/dashboard/(home)/_components/start-scan.tsx b/src/app/dashboard/(home)/_components/start-scan.tsx index b8e0bb1..5e90669 100644 --- a/src/app/dashboard/(home)/_components/start-scan.tsx +++ b/src/app/dashboard/(home)/_components/start-scan.tsx @@ -1,13 +1,13 @@ import {ArrowRight, Loader2, MessagesSquare, Radar} from "lucide-react"; import Link from "next/link"; import {useRouter} from "next/navigation"; -import React, {ReactNode, useEffect, useMemo, useRef} from "react"; +import React, {ReactNode, useEffect, useMemo} from "react"; import {toast} from "sonner"; import {getSettingApi} from "@/api/set"; -import {ExtensionPairResponse, ingestScanWithExtensionTokenApi, pairExtensionTokenApi} from "@/api/scan"; import useExtensionStore from "@/store/extension"; -import {buildIngestScanRequest, getIngestContext} from "@/utils/extension/scan_payload"; +import useSubscribeStore from "@/store/subscribe"; +import {getIngestContext} from "@/utils/extension/scan_payload"; import { connectDianshanCrawl, ExtensionCrawlMessage, @@ -24,10 +24,8 @@ type ConfigFor = { export const StartScanningCard = () => { const extension = useExtensionStore(); + const {status, remainingSeconds, endTime, ready: subReady} = useSubscribeStore(); const router = useRouter(); - const pairRef = useRef(null); - const uploadLockRef = useRef(""); - const settingsRef = useRef(null); useEffect(() => { if (!extension.isInstalled) { @@ -44,6 +42,29 @@ export const StartScanningCard = () => { }, [extension.isInstalled]); const configFor = useMemo(() => { + // 中文备注:在“扩展检测/订阅状态”都没准备好之前,先给一个稳定的 Loading UI,避免卡片闪烁 + if (!extension.checked || !subReady) { + return { + title: "Preparing scan...", + body: "Checking extension and subscription status.", + action: ( + + ), + }; + } + + // 中文备注:订阅到期时,首页必须禁止扫描(trial 到期 / subscription 到期) + const scanLocked = (status === "trial" && remainingSeconds <= 0) + || (status === "canceled" && remainingSeconds <= 0) + // 中文备注:active + 已设置结束时间 + 倒计时归零,表示已到期但后端状态可能尚未刷新 + || (status === "active" && !!endTime && remainingSeconds <= 0); + if (!extension.isInstalled) { return { title: "Install the Chrome extension", @@ -58,6 +79,22 @@ export const StartScanningCard = () => { } } + if (scanLocked) { + return { + title: "Scanning locked", + body: "Your subscription has expired. Scans are blocked until you upgrade or renew your plan.", + className: "border-rose-200 bg-rose-50 text-rose-600", + action: ( + + Upgrade / Renew + + + ), + }; + } + if (extension.isFetching) { return { title: "Scanning your store...", @@ -86,21 +123,44 @@ export const StartScanningCard = () => { ), } - }, [extension.isFetching, extension.isInstalled, extension.lastScanError]) + }, [endTime, extension.checked, extension.isFetching, extension.isInstalled, extension.lastScanError, remainingSeconds, status, subReady]) async function handStart() { + // 中文备注:二次兜底,避免状态切换时按钮仍可点击 + if ((status === "trial" && remainingSeconds <= 0) + || (status === "canceled" && remainingSeconds <= 0) + || (status === "active" && !!endTime && remainingSeconds <= 0)) { + toast.error("Subscription expired. Scanning is locked."); + router.push("/dashboard/billing"); + return; + } + extension.setFetching(true); extension.setLastScanError(""); try { const settings = await getSettingApi(); - getIngestContext(settings as any); - settingsRef.current = settings; - const pair = await pairExtensionTokenApi(); - pairRef.current = pair; - window.sessionStorage.setItem("storeai-extension-pair", JSON.stringify(pair)); - await startExtensionCrawl("Shopee"); + // 中文备注:最终以接口返回的 BrandCore 为准做一次权限校验,避免本地订阅状态不同步导致漏放行 + const brand: any = (settings as any)?.brand ?? null; + if (brand && brand.is_comp !== true) { + const now = Date.now(); + const subStatus = String(brand.subscription_status || ""); + const trialEndsAtMs = brand.trial_ends_at ? Date.parse(String(brand.trial_ends_at)) : NaN; + const cancelAtMs = brand.subscription_cancel_at ? Date.parse(String(brand.subscription_cancel_at)) : NaN; + + const trialActive = subStatus === "trial" && (!brand.trial_ends_at || (!Number.isNaN(trialEndsAtMs) && trialEndsAtMs > now)); + const activeAllowed = subStatus === "active" && (!brand.subscription_cancel_at || (!Number.isNaN(cancelAtMs) && cancelAtMs > now)); + const pastDueAllowed = subStatus === "past_due"; + + if (!(trialActive || activeAllowed || pastDueAllowed)) { + throw new Error("Subscription expired. Scanning is locked."); + } + } + + const ingestContext = getIngestContext(settings as any); + + await startExtensionCrawl("Shopee", ingestContext); } catch (error) { const message = getErrorMessage(error, "Failed to start scan."); extension.setFetching(false); @@ -127,7 +187,11 @@ export const StartScanningCard = () => { } if (message.type === STORE_REPLY_EVENTS.CRAWL_DONE) { - await uploadFinishedScan(message); + // 中文备注:新逻辑由扩展侧直接把扫描结果提交到后端;网页侧只需要提示成功并刷新。 + extension.setFetching(false); + extension.setLastScanError(""); + toast.success("Scan finished."); + router.refresh(); return; } @@ -143,47 +207,6 @@ export const StartScanningCard = () => { } } - async function uploadFinishedScan(message: ExtensionCrawlMessage) { - const crawlId = message.data?.state?.id; - - if (!message.data || !crawlId || uploadLockRef.current === crawlId) { - return; - } - - uploadLockRef.current = crawlId; - - try { - const pair = pairRef.current ?? readSavedPair(); - - if (!pair?.token) { - throw new Error("Extension token is missing. Please run the scan again from this page."); - } - - const settings = settingsRef.current ?? await getSettingApi(); - const body = buildIngestScanRequest(message.data, settings as any); - await ingestScanWithExtensionTokenApi(body, pair.token, pair.apiBaseUrl); - - extension.setFetching(false); - extension.setLastScanError(""); - toast.success("Scan uploaded successfully."); - router.refresh(); - } catch (error) { - const messageText = getErrorMessage(error, "Scan finished, but upload failed."); - extension.setFetching(false); - extension.setLastScanError(messageText); - toast.error(messageText); - } - } - - function readSavedPair(): ExtensionPairResponse | null { - try { - const value = window.sessionStorage.getItem("storeai-extension-pair"); - return value ? JSON.parse(value) as ExtensionPairResponse : null; - } catch { - return null; - } - } - function getErrorMessage(error: unknown, fallback: string) { if (typeof error === "string" && error) { return error; diff --git a/src/app/dashboard/(home)/_components/tip.tsx b/src/app/dashboard/(home)/_components/tip.tsx index a51f0c2..6278ec0 100644 --- a/src/app/dashboard/(home)/_components/tip.tsx +++ b/src/app/dashboard/(home)/_components/tip.tsx @@ -11,7 +11,12 @@ import useSubscribeStore from "@/store/subscribe"; */ export const SubscriptionTip = () => { - const {status, remainingSeconds} = useSubscribeStore(); + const {status, remainingSeconds, ready} = useSubscribeStore(); + + // 中文备注:订阅信息还没初始化完成时,先不渲染(避免先渲染一套内容再抖动切换) + if (!ready) { + return null; + } // 格式化时间显示 const formatFullTime = (seconds: number) => { diff --git a/src/app/onboarding/extension/page.tsx b/src/app/onboarding/extension/page.tsx index ac5d3d6..442de04 100644 --- a/src/app/onboarding/extension/page.tsx +++ b/src/app/onboarding/extension/page.tsx @@ -9,6 +9,7 @@ import InstructionList from "./instruction-list"; import ChromiumCheck from "./chromium-check"; import InstallCard from "./install-card"; import LiveCard from "./live-card"; +import PairHandoff from "./pair-handoff"; /** onboarding 第三步:Chrome 扩展安装说明和检测面板。 */ @@ -23,6 +24,7 @@ export default function ExtensionStepPage() { subtitle="The extension scans your store dashboard inside your already-authenticated browser. Read-only - your store password never touches our servers." >
+ @@ -79,4 +81,3 @@ export default function ExtensionStepPage() { ); } - diff --git a/src/app/onboarding/extension/pair-handoff.tsx b/src/app/onboarding/extension/pair-handoff.tsx new file mode 100644 index 0000000..7577b5a --- /dev/null +++ b/src/app/onboarding/extension/pair-handoff.tsx @@ -0,0 +1,144 @@ +"use client"; + +import {useEffect, useState} from "react"; +import {useSearchParams} from "next/navigation"; + +import useUserStore from "@/store/user"; +import {pairExtensionTokenApi} from "@/api/scan"; +import {getSettingApi} from "@/api/set"; +import {EXTENSION_ID} from "@/utils/extension/detect_extension"; + +declare const chrome: any; + +/** + * 网页侧“配对回传”: + * - 由扩展 popup 的“登录”按钮打开本页(带 from=extension)。 + * - 若网页已登录:调用 `/api/auth/extension-pair` 拿到 extension token,然后回传给扩展保存。 + * 中文备注:这里不搞复杂类型,按需求全用 any。 + */ +export default function PairHandoff() { + const params = useSearchParams(); + const token = useUserStore((s) => s.token); + const [status, setStatus] = useState<"idle" | "pairing" | "ok" | "need_login" | "error">("idle"); + const [errorText, setErrorText] = useState(""); + + useEffect(() => { + const from = params.get("from"); + if (from !== "extension") return; + + // 中文备注:必须在浏览器环境 + 可调用 chrome.runtime 的情况下才能回传。 + if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) { + return; + } + + if (!token) { + setStatus("need_login"); + return; + } + + void (async () => { + setStatus("pairing"); + setErrorText(""); + + try { + const extId = params.get("extId") || EXTENSION_ID; + + // 1) 先问扩展是否已经配对(避免重复 pair) + const authCheck: any = await new Promise((resolve, reject) => { + chrome.runtime.sendMessage( + extId, + {type: "DIANSHAN_AUTH_CHECK"}, + (resp: any) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + return; + } + resolve(resp); + }, + ); + }); + + if (authCheck?.data?.authed) { + setStatus("ok"); + return; + } + + // 2) 换取扩展 token(Web token -> extension token) + const pair = await pairExtensionTokenApi(); + + // 3) 读取品牌/店铺/定时配置(给扩展做 ingest 与 scheduled scan 使用) + const settings: any = await getSettingApi(); + const brand = settings?.brand || null; + const storeId = brand?.stores?.[0]?.id || brand?.storeId || brand?.store_id || null; + + const authState: any = { + token: pair?.token, + apiBaseUrl: pair?.apiBaseUrl, + userEmail: pair?.userEmail ?? null, + brandId: brand?.id ?? null, + storeId, + timezone: brand?.timezone ?? null, + morningBriefHour: brand?.morning_brief_hour ?? null, + eveningRecapHour: brand?.evening_recap_hour ?? null, + }; + + // 4) 回传给扩展保存 + await new Promise((resolve, reject) => { + chrome.runtime.sendMessage( + extId, + {type: "DIANSHAN_SSO_HANDOFF", payload: {authState}}, + (resp: any) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + return; + } + if (!resp?.ok) { + reject(new Error(resp?.error || "handoff_failed")); + return; + } + resolve(); + }, + ); + }); + + setStatus("ok"); + } catch (e: any) { + setStatus("error"); + setErrorText(e?.message || "Pairing failed."); + } + })(); + }, [params, token]); + + if (status === "idle") return null; + + if (status === "need_login") { + return ( +
+ 请先在网页完成登录,然后保持本页面打开,系统会自动把登录态同步给扩展。 +
+ ); + } + + if (status === "pairing") { + return ( +
+ 正在与扩展配对中… +
+ ); + } + + if (status === "ok") { + return ( +
+ 扩展已配对成功,你可以回到扩展继续操作。 +
+ ); + } + + return ( +
+ 配对失败:{errorText || "unknown error"} +
+ ); +} + diff --git a/src/store/extension.ts b/src/store/extension.ts index 898f62f..5e2f2a4 100644 --- a/src/store/extension.ts +++ b/src/store/extension.ts @@ -2,11 +2,14 @@ import {create} from "zustand"; type ExtensionState = { isInstalled: boolean; + // 中文备注:是否已完成“检测扩展是否安装”的流程(避免页面刚刷新时先闪一下“安装扩展”卡片) + checked: boolean; isFirst: boolean; isFetching: boolean; lastScanError: string; extensionInfo: ExtensionInfo; setInstalled: (status: boolean) => void; + setChecked: (value: boolean) => void; setFetching: (status: boolean) => void; setLastScanError: (message: string) => void; } @@ -18,6 +21,7 @@ type ExtensionInfo = { const useExtensionStore = create((set) => ({ isInstalled: false, + checked: false, isFirst: true, isFetching: false, lastScanError: "", @@ -26,6 +30,7 @@ const useExtensionStore = create((set) => ({ chromeUrl:"chrome://extensions" }, setInstalled: (value) => set({isInstalled: value}), + setChecked: (value) => set({checked: value}), setFetching: (value) => set({isFetching: value}), setLastScanError: (value) => set({lastScanError: value}), })) diff --git a/src/store/subscribe.ts b/src/store/subscribe.ts index 6f19dde..f00d7a4 100644 --- a/src/store/subscribe.ts +++ b/src/store/subscribe.ts @@ -6,30 +6,35 @@ type SubscriptionStatus = "trial" | "active" | "past_due" | "canceled" | ""; type SubscribeState = { status: SubscriptionStatus; - endTime: string; // 目标结束时间字符串 - remainingSeconds: number; // 剩余秒数(手动计时用) + endTime: string; // 目标结束时间字符串(trial_ends_at 或 subscription_cancel_at) + remainingSeconds: number; // 倒计时秒数(用于前端显示) + // 中文备注:是否已从接口初始化完成(用于避免 dashboard 刷新时订阅提示/按钮状态“后出现”) + ready: boolean; init: () => Promise; - _startTimer: () => void; // 内部启动定时器方法 - _timer: any; // 内部定时器引用 + _startTimer: () => void; + _timer: any; } const useSubscribeStore = create((set, get) => ({ status: "", endTime: "", remainingSeconds: 0, + ready: false, _timer: null, /** - * 初始化:获取数据并启动计时 + * 初始化:拉取 dashboard shell(含 BrandCore.subscription_status 等字段) */ init: async () => { try { const res: any = await getBrandStatusApi(); - const brand = res.brand; + const brand = res?.brand; - if (!brand) return; + if (!brand) { + return; + } - // 逻辑优先级:试用结束时间 > 订阅取消时间 + // 逻辑优先级:trial_ends_at > subscription_cancel_at const targetTime = brand.trial_ends_at || brand.subscription_cancel_at || ""; set({ @@ -37,33 +42,31 @@ const useSubscribeStore = create((set, get) => ({ endTime: targetTime, }); - // 拆分逻辑:数据就绪后,启动内部计时器 get()._startTimer(); - } catch (error) { console.log("Failed to init subscribe store:", error); + } finally { + // 中文备注:无论成功失败,都标记为 ready,让 UI 有稳定的“已加载”状态 + set({ready: true}); } }, /** - * 内部方法:管理定时器生命周期 + * 内部方法:根据 endTime 计算倒计时 */ _startTimer: () => { const {_timer, endTime} = get(); - // 1. 清理旧的定时器,防止叠加 if (_timer) { clearInterval(_timer); set({_timer: null}); } - // 2. 如果没有结束时间,直接归零并返回 if (!endTime) { set({remainingSeconds: 0}); return; } - // 3. 定义每秒执行的计算逻辑 const tick = () => { const now = Math.floor(Date.now() / 1000); const end = Math.floor(new Date(endTime).getTime() / 1000); @@ -71,7 +74,6 @@ const useSubscribeStore = create((set, get) => ({ set({remainingSeconds: diff}); - // 如果倒计时结束,清理自己 if (diff <= 0) { const currentTimer = get()._timer; if (currentTimer) clearInterval(currentTimer); @@ -79,13 +81,12 @@ const useSubscribeStore = create((set, get) => ({ } }; - // 4. 立即执行一次,避免首秒空白 tick(); - // 5. 启动新定时器并记录引用 const newTimer = setInterval(tick, 1000); set({_timer: newTimer}); }, })); -export default useSubscribeStore; \ No newline at end of file +export default useSubscribeStore; + diff --git a/src/utils/extension/detect_extension.ts b/src/utils/extension/detect_extension.ts index aa4bc21..adf0322 100644 --- a/src/utils/extension/detect_extension.ts +++ b/src/utils/extension/detect_extension.ts @@ -1,40 +1,66 @@ -import {STORE_REPLY_EVENTS, STORE_SEND_EVENTS} from "./type"; +import {STORE_SEND_EVENTS} from "./type"; import useExtensionStore from "@/store/extension"; /** - * 检擦扩展是否安装 + * 检测扩展是否安装 + * + * 中文备注: + * - 原实现是 500ms 轮询一次,导致 dashboard 刷新后会先闪一下“安装扩展”卡片,然后又消失 + * - 这里新增 `checked` 标记:未检测完成前,UI 应显示“检测中”而不是“未安装” */ -// 你的固定扩展 ID export const EXTENSION_ID = process.env.NEXT_PUBLIC_EXTENSION_ID || "bhnpckgpcfnoiphhknaakhfieihpocan"; + declare const chrome: any; + export const detectExtension = () => { - // 如果已经安装了,就不跑了 - if (useExtensionStore.getState().isInstalled) return; + // 如果已经检测过且已安装,就不重复轮询 + if (useExtensionStore.getState().checked && useExtensionStore.getState().isInstalled) { + return; + } + + // 中文备注:开始检测(会进入轮询);这里把 checked 置回 false,让 UI 显示“检测中” + useExtensionStore.getState().setChecked(false); + + let triedAtLeastOnce = false; const timer = setInterval(() => { - // 1. 检查浏览器是否支持 chrome 插件环境 - if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) { + if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) { return; } - // 2. 尝试给特定 ID 的扩展发送 PING + triedAtLeastOnce = true; + chrome.runtime.sendMessage( EXTENSION_ID, {type: STORE_SEND_EVENTS.PING}, - (response: { ok?: boolean; success?: boolean; }) => { - // 检查是否有错误(比如扩展没装或 ID 不对) + (response: { ok?: boolean; success?: boolean }) => { + // 没装扩展 / ID 不对 等情况:lastError 会有值 if (chrome.runtime.lastError) { - // 这里静默失败,继续轮询 + if (triedAtLeastOnce && !useExtensionStore.getState().checked) { + useExtensionStore.getState().setChecked(true); + } return; } - // 3. 收到正确回复 if (response && (response.success || response.ok)) { clearInterval(timer); useExtensionStore.getState().setInstalled(true); - console.log("Extension detected via ID!"); + useExtensionStore.getState().setChecked(true); + return; } - } + + // 有返回但不是 ok:也视为检测结束(未安装) + if (triedAtLeastOnce && !useExtensionStore.getState().checked) { + useExtensionStore.getState().setChecked(true); + } + }, ); - }, 500); // 2秒轮询一次 + }, 500); + + // 中文备注:兜底,最多等待 1.2s 就认为检测结束(未安装),避免一直 loading + setTimeout(() => { + if (!useExtensionStore.getState().checked) { + useExtensionStore.getState().setChecked(true); + } + }, 1200); }; diff --git a/src/utils/extension/scan_bridge.ts b/src/utils/extension/scan_bridge.ts index 5e30137..cd7e352 100644 --- a/src/utils/extension/scan_bridge.ts +++ b/src/utils/extension/scan_bridge.ts @@ -15,6 +15,12 @@ export type ExtensionCrawlData = { result: Record | null; }; +export type ExtensionIngestContext = { + brandId: string; + storeId: string; + trigger?: "manual" | "scheduled"; +}; + export type ExtensionCrawlState = { id: string; platformId: string; @@ -62,10 +68,13 @@ export function connectDianshanCrawl(onMessage: (message: ExtensionCrawlMessage) }; } -export function startExtensionCrawl(platformId = "Shopee"): Promise { +export function startExtensionCrawl( + platformId = "Shopee", + ingestContext?: ExtensionIngestContext, +): Promise { return sendExtensionMessage({ type: STORE_SEND_EVENTS.START_CRAWL, - payload: {platformId}, + payload: {platformId, ingestContext}, }); } diff --git a/src/utils/extension/scan_payload.ts b/src/utils/extension/scan_payload.ts index 6f65f66..04810c4 100644 --- a/src/utils/extension/scan_payload.ts +++ b/src/utils/extension/scan_payload.ts @@ -1,67 +1,17 @@ -import type {ExtensionCrawlData, ExtensionCrawlState} from "./scan_bridge"; -import type {IngestScanRequest} from "@/api/scan"; +/** + * 网页侧只保留“提取 brandId/storeId”能力。 + * 中文备注:旧版本网页会把爬取结果组装成 ingest/scan 请求并代扩展上传; + * 现在改为“扩展侧直接上传”,网页侧不再需要 buildIngestScanRequest。 + */ -type DashboardSettings = { - brand?: { - id?: string; - stores?: Array<{ id?: string }>; - storeId?: string; - store_id?: string; - } | null; - store?: { id?: string } | null; - storeId?: string; - store_id?: string; -}; - -export function buildIngestScanRequest( - crawlData: ExtensionCrawlData, - settings: DashboardSettings, -): IngestScanRequest { - const {brandId, storeId} = getIngestContext(settings); - - const scannedAt = new Date().toISOString(); - const state = crawlData.state; - const rawResult = crawlData.result || {}; - const databoard = readStepResult(rawResult, "databoard"); - const adscenter = readStepResult(rawResult, "adscenter"); - const reviews = readStepResult(rawResult, "message"); - const accountHealth = readStepResult(rawResult, "accounthealth"); - const business = readObject(databoard["商业分析"]); - - return { - brandId, - storeId, - scannedAt, - extractorStatus: getExtractorStatus(state), - extractorErrors: getExtractorErrors(state), - trigger: "manual", - platformAccountId: null, - fieldMeta: {}, - payload: { - store_id: storeId, - scanned_at: scannedAt, - today: buildTodayPayload(business), - recent_3h: [], - skus: [], - ads: buildAdsPayload(adscenter), - reviews: buildReviewsPayload(reviews, scannedAt), - competitors: [], - review_summary: null, - product_aggregates: null, - traffic_sources: [], - shop_health: buildShopHealthPayload(accountHealth), - }, - }; -} - -export function getIngestContext(settings: DashboardSettings) { - const brandId = settings.brand?.id; - const storeId = settings.brand?.stores?.[0]?.id - || settings.brand?.storeId - || settings.brand?.store_id - || settings.store?.id - || settings.storeId - || settings.store_id; +export function getIngestContext(settings: any) { + const brandId = settings?.brand?.id; + const storeId = settings?.brand?.stores?.[0]?.id + || settings?.brand?.storeId + || settings?.brand?.store_id + || settings?.store?.id + || settings?.storeId + || settings?.store_id; if (!brandId) { throw new Error("Please finish brand setup before running a scan."); @@ -74,185 +24,3 @@ export function getIngestContext(settings: DashboardSettings) { return {brandId, storeId}; } -function buildTodayPayload(business: Record) { - const sales = readMetric(business["销售"]); - const visitors = readMetric(business["访客数"]); - const productClicks = readMetric(business["Product Clicks"]); - const orders = readMetric(business["订单"]); - const conversionRate = readMetric(business["Order Conversion Rate"]); - - return { - orders: parseInteger(orders.value), - gmv_cents: parseMoneyCents(sales.value), - cancel_rate: 0, - return_rate: 0, - gmv_delta_yesterday_pct: parsePercent(sales.change), - gmv_net_cents: null, - conversion_rate: parsePercent(conversionRate.value), - visitors: parseNullableInteger(visitors.value), - product_clicks: parseNullableInteger(productClicks.value), - aov_cents: null, - orders_delta_yesterday_pct: parsePercent(orders.change), - conversion_rate_delta_yesterday_pct: parsePercent(conversionRate.change), - visitors_delta_yesterday_pct: parsePercent(visitors.change), - product_clicks_delta_yesterday_pct: parsePercent(productClicks.change), - aov_delta_yesterday_pct: null, - }; -} - -function buildAdsPayload(adscenter: Record) { - const rows = readArray(adscenter["进行中广告列表"]); - - return rows.map((item, index) => { - const row = readObject(item); - const info = readObject(row["广告信息"]); - const name = stringify(info["广告名称"]) || `campaign-${index + 1}`; - - return { - campaign_id: name, - campaign_name: name, - type: stringify(info["广告类型"]) || "unknown", - state: "ongoing", - spend_cents: parseMoneyCents(row["花费"]), - clicks: 0, - orders: 0, - revenue_cents: parseMoneyCents(row["销售额"]), - impressions: 0, - roas: parseNumber(row["广告支出回报率"]) ?? 0, - target_roas: parseNumber(row["目标ROAS"]), - daily_budget_cents: parseMoneyCents(row["每日预算"]), - keywords: [], - }; - }); -} - -function buildReviewsPayload(reviews: Record, scannedAt: string) { - const rows = readArray(reviews["低星评论"]); - - return rows.map((item, index) => { - const row = readObject(item); - const orderId = stringify(row["订单编号"]); - - return { - review_id: orderId || `review-${index + 1}`, - sku_id: null, - rating: 1, - text: stringify(row["评价内容"]) || stringify(row["商品名称"]) || "", - replied: false, - created_at: scannedAt, - }; - }); -} - -function buildShopHealthPayload(accountHealth: Record) { - const healthRows = readArray(accountHealth["健康状态"]); - - if (healthRows.length === 0) { - return null; - } - - return { - rating_overall: null, - penalty_points: null, - metrics: {}, - }; -} - -function getExtractorStatus(state: ExtensionCrawlState | null): "ok" | "partial" | "failed" { - if (!state || state.status === "failed" || state.status === "canceled") { - return "failed"; - } - - const hasFailedStep = state.steps.some((step) => step.status === "failed"); - const hasSuccessStep = state.steps.some((step) => step.status === "success"); - - if (hasFailedStep && hasSuccessStep) { - return "partial"; - } - - if (hasFailedStep) { - return "failed"; - } - - return "ok"; -} - -function getExtractorErrors(state: ExtensionCrawlState | null) { - if (!state) { - return ["missing extension crawl state"]; - } - - return state.steps - .filter((step) => step.status === "failed" || step.message) - .map((step) => `${step.name}: ${step.message || step.status}`) - .slice(0, 50); -} - -function readStepResult(result: Record, key: string) { - return readObject(readObject(result[key])["result"]); -} - -function readMetric(value: unknown) { - const item = readObject(value); - return { - value: item.value, - change: item.change, - }; -} - -function readObject(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; -} - -function readArray(value: unknown): unknown[] { - return Array.isArray(value) ? value : []; -} - -function stringify(value: unknown) { - return typeof value === "string" ? value.trim() : value == null ? "" : String(value).trim(); -} - -function parseInteger(value: unknown) { - return Math.max(0, Math.round(parseNumber(value) ?? 0)); -} - -function parseNullableInteger(value: unknown) { - const parsed = parseNumber(value); - return parsed == null ? null : Math.max(0, Math.round(parsed)); -} - -function parseMoneyCents(value: unknown) { - const parsed = parseNumber(value); - return Math.max(0, Math.round((parsed ?? 0) * 100)); -} - -function parsePercent(value: unknown) { - const text = stringify(value); - - if (!text) { - return null; - } - - const parsed = parseNumber(text); - - if (parsed == null) { - return null; - } - - return text.includes("%") ? parsed / 100 : parsed; -} - -function parseNumber(value: unknown) { - const text = stringify(value) - .replace(/RM/gi, "") - .replace(/,/g, "") - .replace(/%/g, "") - .replace(/[^\d.-]/g, ""); - - if (!text || text === "-" || text === "." || text === "-.") { - return null; - } - - const parsed = Number(text); - return Number.isFinite(parsed) ? parsed : null; -}