diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index fb40e28f1..4ec56c61a 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -4592,10 +4592,21 @@ export function AdminSettingsClient() { oneboundBaseUrl: productPlanningApiConfig.oneboundBaseUrl.trim(), taobaoApiKey: productPlanningApiConfig.taobaoApiKey.trim(), taobaoApiSecret: productPlanningApiConfig.taobaoApiSecret.trim(), + detailPlusBaseUrl: productPlanningApiConfig.detailPlusBaseUrl.trim(), + detailPlusInternalBaseUrl: + productPlanningApiConfig.detailPlusInternalBaseUrl.trim(), + detailPlusApiKey: productPlanningApiConfig.detailPlusApiKey.trim(), qaApiUrl: productPlanningApiConfig.qaApiUrl.trim(), qaToken: productPlanningApiConfig.qaToken.trim(), saleApiUrl: productPlanningApiConfig.saleApiUrl.trim(), saleToken: productPlanningApiConfig.saleToken.trim(), + planningDbHost: productPlanningApiConfig.planningDbHost.trim(), + planningDbPort: productPlanningApiConfig.planningDbPort.trim(), + planningDbDatabase: productPlanningApiConfig.planningDbDatabase.trim(), + planningDbUser: productPlanningApiConfig.planningDbUser.trim(), + planningDbPassword: productPlanningApiConfig.planningDbPassword.trim(), + planningDbCharset: productPlanningApiConfig.planningDbCharset.trim(), + planningDbSsl: productPlanningApiConfig.planningDbSsl, }, }), }), @@ -15793,6 +15804,64 @@ export function AdminSettingsClient() { +
+ + + setProductPlanningApiConfig((prev) => ({ + ...prev, + detailPlusBaseUrl: e.target.value, + })) + } + placeholder="http://ltai5tdb2ogyj59fquyedh.yydd.ai:18082/api/public/taobao-detail-plus" + /> +
+ 竞品分析会优先用这个接口查询商品详情,查不到再走 Onebound 旧接口。 +
+
+ +
+ + + setProductPlanningApiConfig((prev) => ({ + ...prev, + detailPlusInternalBaseUrl: e.target.value, + })) + } + placeholder="http://127.0.0.1:4000/api/public/taobao-detail-plus" + /> +
+ 服务器同机部署时可填内网地址,系统会先尝试内网再尝试公网。 +
+
+ +
+ + + setProductPlanningApiConfig((prev) => ({ + ...prev, + detailPlusApiKey: e.target.value, + })) + } + placeholder="留空保存时会保留原 API Key" + /> +
+
-
+
+ +
+
+
+
企划数据库回写配置
+
+ 旧接口兜底查到的淘宝详情会同步写入新的企划详情表,供后续新接口命中。 +
+
+
+ SSL + + setProductPlanningApiConfig((prev) => ({ + ...prev, + planningDbSsl: checked, + })) + } + /> +
+
+ +
+
+ + + setProductPlanningApiConfig((prev) => ({ + ...prev, + planningDbHost: e.target.value, + })) + } + placeholder="156.225.27.52" + /> +
+ +
+ + + setProductPlanningApiConfig((prev) => ({ + ...prev, + planningDbPort: e.target.value, + })) + } + placeholder="60023" + /> +
+ +
+ + + setProductPlanningApiConfig((prev) => ({ + ...prev, + planningDbDatabase: e.target.value, + })) + } + placeholder="planning_database" + /> +
+ +
+ + + setProductPlanningApiConfig((prev) => ({ + ...prev, + planningDbUser: e.target.value, + })) + } + placeholder="planning_user" + /> +
+ +
+ + + setProductPlanningApiConfig((prev) => ({ + ...prev, + planningDbPassword: e.target.value, + })) + } + placeholder="留空保存时会保留原密码" + /> +
+ +
+ + + setProductPlanningApiConfig((prev) => ({ + ...prev, + planningDbCharset: e.target.value, + })) + } + placeholder="utf8mb4" + /> +
+
+
@@ -15911,7 +16100,7 @@ export function AdminSettingsClient() {

• - 商品策划工具的评价、关键词洞察、淘宝/京东详情、问大家和销量接口都会优先使用这里的配置。 + 竞品分析会优先读取详情增强接口;未命中时自动走旧接口,并将旧结果回写到企划详情表。

• diff --git a/app/api/admin/settings/route.ts b/app/api/admin/settings/route.ts index 013854b19..4ed475670 100644 --- a/app/api/admin/settings/route.ts +++ b/app/api/admin/settings/route.ts @@ -264,7 +264,22 @@ export async function POST(request: Request) { } if (key === PRODUCT_PLANNING_API_CONFIG_KEY) { - nextValue = normalizeProductPlanningApiConfig(value); + const existingSetting = await getSystemSetting({ + key: PRODUCT_PLANNING_API_CONFIG_KEY, + }).catch(() => null); + const existingValue = + existingSetting?.value && typeof existingSetting.value === "object" + ? (existingSetting.value as Record) + : {}; + + nextValue = normalizeProductPlanningApiConfig(value, { + taobaoApiKey: String(existingValue.taobaoApiKey || "").trim(), + taobaoApiSecret: String(existingValue.taobaoApiSecret || "").trim(), + detailPlusApiKey: String(existingValue.detailPlusApiKey || "").trim(), + qaToken: String(existingValue.qaToken || "").trim(), + saleToken: String(existingValue.saleToken || "").trim(), + planningDbPassword: String(existingValue.planningDbPassword || "").trim(), + }); } if (key === PRODUCT_PLANNING_AI_MODEL_CONFIG_KEY) { diff --git a/app/api/product-planning/jd/item/route.ts b/app/api/product-planning/jd/item/route.ts index b6e80be48..46c8ab83b 100644 --- a/app/api/product-planning/jd/item/route.ts +++ b/app/api/product-planning/jd/item/route.ts @@ -9,7 +9,7 @@ import { PRODUCT_PLANNING_DISABLED_MESSAGE, } from "@/lib/product-planning/server-feature"; import { getProductPlanningToken } from "@/lib/product-planning/server-auth"; -import { normalizeJdItem } from "@/lib/product-planning/taobao"; +import { fetchProductPlanningJdItem } from "@/lib/product-planning/marketplace-items"; import type { ProductPlanningTaobaoItemResponse } from "@/lib/product-planning/types"; export const runtime = "nodejs"; @@ -41,68 +41,14 @@ export async function GET(request: NextRequest) { } const config = await getResolvedProductPlanningApiConfig(); - const apiKey = config.taobaoApiKey; - const apiSecret = config.taobaoApiSecret; - if (!apiKey || !apiSecret) { - return jsonError(500, "缺少商品策划工具淘宝 API Key / Secret,请前往系统设置配置"); - } - - const apiUrl = - `${config.oneboundBaseUrl}/jd/item_get_app/?key=${encodeURIComponent(apiKey)}` + - `&secret=${encodeURIComponent(apiSecret)}&num_iid=${encodeURIComponent(itemId)}&domain_type=jd`; - const descApiUrl = - `${config.oneboundBaseUrl}/jd/item_get_desc/?key=${encodeURIComponent(apiKey)}` + - `&secret=${encodeURIComponent(apiSecret)}&num_iid=${encodeURIComponent(itemId)}&domain_type=jd`; - try { - const [response, descResponse] = await Promise.all([ - fetch(apiUrl, { - signal: AbortSignal.timeout(15000), - headers: { Accept: "application/json" }, - cache: "no-store", - }), - fetch(descApiUrl, { - signal: AbortSignal.timeout(15000), - headers: { Accept: "application/json" }, - cache: "no-store", - }).catch(() => null), - ]); - - if (!response.ok) { - return jsonError(502, `京东商品详情接口请求失败:${response.status}`); - } - - const data = await response.json(); - if (data?.error_code && data.error_code !== "0000") { - return jsonError(502, data.error || data.reason || "京东商品详情接口返回错误"); - } - - if (!data?.item) { - return jsonError(404, "未找到商品信息"); - } - - let descHtml = ""; - if (descResponse?.ok) { - try { - const descData = await descResponse.json(); - descHtml = String(descData?.item?.desc || "").trim(); - } catch (error) { - console.warn("[ProductPlanning][JdItem] parse desc failed:", error); - } - } - + const item = await fetchProductPlanningJdItem(itemId, config); return NextResponse.json({ success: true, - item: normalizeJdItem( - { - ...data.item, - desc: descHtml || data.item.desc, - }, - itemId - ), + item, } satisfies ProductPlanningTaobaoItemResponse); } catch (error) { console.error("[ProductPlanning][JdItem] request failed:", error); - return jsonError(502, "京东商品详情接口调用失败"); + return jsonError(502, error instanceof Error ? error.message : "京东商品详情接口调用失败"); } } diff --git a/app/api/product-planning/marketplace/items/route.ts b/app/api/product-planning/marketplace/items/route.ts new file mode 100644 index 000000000..d93ace9d0 --- /dev/null +++ b/app/api/product-planning/marketplace/items/route.ts @@ -0,0 +1,88 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + PRODUCT_PLANNING_ADVANCED_DISABLED, + PRODUCT_PLANNING_ADVANCED_DISABLED_MESSAGE, +} from "@/lib/product-planning/advanced-disabled"; +import { + fetchProductPlanningMarketplaceItemsBatch, +} from "@/lib/product-planning/marketplace-items"; +import { getProductPlanningToken } from "@/lib/product-planning/server-auth"; +import { getResolvedProductPlanningApiConfig } from "@/lib/product-planning/server-config"; +import { + isProductPlanningEnabled, + PRODUCT_PLANNING_DISABLED_MESSAGE, +} from "@/lib/product-planning/server-feature"; +import type { + ProductPlanningMarketplaceItemRef, + ProductPlanningMarketplaceItemsRequest, + ProductPlanningMarketplaceItemsResponse, +} from "@/lib/product-planning/types"; + +export const runtime = "nodejs"; + +const MAX_BATCH_SIZE = 100; + +function jsonError(status: number, error: string) { + return NextResponse.json( + { + success: false, + items: [], + error, + } satisfies ProductPlanningMarketplaceItemsResponse, + { status } + ); +} + +function normalizeItems(value: unknown) { + if (!Array.isArray(value)) return [] as ProductPlanningMarketplaceItemRef[]; + + const seen = new Set(); + const result: ProductPlanningMarketplaceItemRef[] = []; + for (const entry of value) { + if (!entry || typeof entry !== "object") continue; + const record = entry as Record; + const platform = record.platform === "jd" ? "jd" : "taobao"; + const itemId = String(record.itemId || "").trim(); + if (!itemId || !/^[\w-]{1,64}$/.test(itemId)) continue; + const key = `${platform}:${itemId}`; + if (seen.has(key)) continue; + seen.add(key); + result.push({ platform, itemId }); + if (result.length >= MAX_BATCH_SIZE) break; + } + + return result; +} + +export async function POST(request: NextRequest) { + if (PRODUCT_PLANNING_ADVANCED_DISABLED) { + return jsonError(503, PRODUCT_PLANNING_ADVANCED_DISABLED_MESSAGE); + } + + const token = await getProductPlanningToken(request); + if (!token?.sub) { + return jsonError(401, "未登录"); + } + if (!(await isProductPlanningEnabled())) { + return jsonError(403, PRODUCT_PLANNING_DISABLED_MESSAGE); + } + + const body = (await request.json().catch(() => null)) as + | ProductPlanningMarketplaceItemsRequest + | null; + const items = normalizeItems(body?.items); + if (items.length === 0) { + return jsonError(400, "请提供至少一个有效商品 ID"); + } + + const config = await getResolvedProductPlanningApiConfig(); + const results = await fetchProductPlanningMarketplaceItemsBatch(items, config, { + includeMonthSale: body?.includeMonthSale !== false, + }); + + return NextResponse.json({ + success: results.some((result) => result.success), + items: results, + error: results.some((result) => result.success) ? undefined : "批量查询未获取到可用商品", + } satisfies ProductPlanningMarketplaceItemsResponse); +} diff --git a/app/api/product-planning/taobao/item/route.ts b/app/api/product-planning/taobao/item/route.ts index e17faa2d8..9d5a066f3 100644 --- a/app/api/product-planning/taobao/item/route.ts +++ b/app/api/product-planning/taobao/item/route.ts @@ -9,7 +9,7 @@ import { PRODUCT_PLANNING_DISABLED_MESSAGE, } from "@/lib/product-planning/server-feature"; import { getProductPlanningToken } from "@/lib/product-planning/server-auth"; -import { normalizeTaobaoItem } from "@/lib/product-planning/taobao"; +import { fetchProductPlanningTaobaoItem } from "@/lib/product-planning/marketplace-items"; import type { ProductPlanningTaobaoItemResponse } from "@/lib/product-planning/types"; export const runtime = "nodejs"; @@ -41,70 +41,14 @@ export async function GET(request: NextRequest) { } const config = await getResolvedProductPlanningApiConfig(); - const apiKey = config.taobaoApiKey; - const apiSecret = config.taobaoApiSecret; - if (!apiKey || !apiSecret) { - return jsonError(500, "缺少商品策划工具淘宝 API Key / Secret,请前往系统设置配置"); - } - - const apiUrl = - `${config.oneboundBaseUrl}/taobao/item_get_pro/?key=${encodeURIComponent(apiKey)}` + - `&secret=${encodeURIComponent(apiSecret)}&num_iid=${encodeURIComponent(itemId)}`; - try { - const response = await fetch(apiUrl, { - signal: AbortSignal.timeout(15000), - headers: { Accept: "application/json" }, - cache: "no-store", - }); - - if (!response.ok) { - return jsonError(502, `淘宝商品详情接口请求失败:${response.status}`); - } - - const data = await response.json(); - if (data?.error_code && data.error_code !== "0000") { - return jsonError(502, data.error || data.reason || "淘宝商品详情接口返回错误"); - } - - if (!data?.item) { - const fallbackUrl = - `${config.oneboundBaseUrl}/taobao/item_get/?key=${encodeURIComponent(apiKey)}` + - `&secret=${encodeURIComponent(apiSecret)}&num_iid=${encodeURIComponent(itemId)}`; - const fallbackResponse = await fetch(fallbackUrl, { - signal: AbortSignal.timeout(15000), - headers: { Accept: "application/json" }, - cache: "no-store", - }); - - if (!fallbackResponse.ok) { - return jsonError(502, `淘宝商品详情接口请求失败:${fallbackResponse.status}`); - } - - const fallbackData = await fallbackResponse.json(); - if (fallbackData?.error_code && fallbackData.error_code !== "0000") { - return jsonError( - 502, - fallbackData.error || fallbackData.reason || "淘宝商品详情接口返回错误" - ); - } - - if (!fallbackData?.item) { - return jsonError(404, "未找到商品信息"); - } - - return NextResponse.json({ - success: true, - item: normalizeTaobaoItem(fallbackData.item, itemId), - } satisfies ProductPlanningTaobaoItemResponse); - } - + const item = await fetchProductPlanningTaobaoItem(itemId, config); return NextResponse.json({ success: true, - item: normalizeTaobaoItem(data.item, itemId), + item, } satisfies ProductPlanningTaobaoItemResponse); } catch (error) { console.error("[ProductPlanning][TaobaoItem] request failed:", error); - return jsonError(502, "淘宝商品详情接口调用失败"); + return jsonError(502, error instanceof Error ? error.message : "淘宝商品详情接口调用失败"); } } diff --git a/app/api/product-planning/taobao/month-sale/route.ts b/app/api/product-planning/taobao/month-sale/route.ts index c16648d9e..63cc5aa58 100644 --- a/app/api/product-planning/taobao/month-sale/route.ts +++ b/app/api/product-planning/taobao/month-sale/route.ts @@ -9,7 +9,7 @@ import { PRODUCT_PLANNING_DISABLED_MESSAGE, } from "@/lib/product-planning/server-feature"; import { getProductPlanningToken } from "@/lib/product-planning/server-auth"; -import { extractMonthSale } from "@/lib/product-planning/taobao"; +import { fetchProductPlanningMonthSale } from "@/lib/product-planning/marketplace-items"; import type { ProductPlanningMonthSaleResponse } from "@/lib/product-planning/types"; export const runtime = "nodejs"; @@ -43,37 +43,21 @@ export async function GET(request: NextRequest) { } const config = await getResolvedProductPlanningApiConfig(); - const saleToken = config.saleToken; - if (!saleToken) { + if (!config.saleToken) { return jsonError(500, itemId, "缺少商品策划工具销量 Token,请前往系统设置配置"); } - const apiUrl = - `${config.saleApiUrl}?itemId=${encodeURIComponent(itemId)}` + - `&token=${encodeURIComponent(saleToken)}&v=3.0`; - try { - const response = await fetch(apiUrl, { - signal: AbortSignal.timeout(15000), - headers: { Accept: "application/json" }, - cache: "no-store", - }); - - if (!response.ok) { - return jsonError(502, itemId, `月销量接口请求失败:${response.status}`); - } - - const data = await response.json(); - const recentSales = extractMonthSale(data); + const recentSales = await fetchProductPlanningMonthSale(itemId, config); return NextResponse.json({ success: true, itemId, recentSales, - raw: data && typeof data === "object" ? data : null, + raw: null, } satisfies ProductPlanningMonthSaleResponse); } catch (error) { console.error("[ProductPlanning][MonthSale] request failed:", error); - return jsonError(502, itemId, "月销量接口调用失败"); + return jsonError(502, itemId, error instanceof Error ? error.message : "月销量接口调用失败"); } } diff --git a/components/product-planning/product-planning-page.tsx b/components/product-planning/product-planning-page.tsx index 80bf0a920..e121fc291 100644 --- a/components/product-planning/product-planning-page.tsx +++ b/components/product-planning/product-planning-page.tsx @@ -1,6 +1,5 @@ "use client"; -import { ChevronDown } from "lucide-react"; import { startTransition, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { Streamdown } from "streamdown"; @@ -13,11 +12,6 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; import { Input } from "@/components/ui/input"; import { Progress } from "@/components/ui/progress"; import { Switch } from "@/components/ui/switch"; @@ -50,12 +44,12 @@ import type { ProductPlanningKeywordInsightGroup, ProductPlanningKeywordInsightResponse, ProductPlanningKeywordInsightSuggestion, - ProductPlanningMonthSaleResponse, + ProductPlanningMarketplaceItemRef, + ProductPlanningMarketplaceItemsResponse, ProductPlanningPlatform, ProductPlanningPropertyRow, ProductPlanningRowsResponse, ProductPlanningTaobaoItem, - ProductPlanningTaobaoItemResponse, ProductPlanningTemplate, ProductPlanningTemplateListResponse, ProductPlanningTemplateMutationResponse, @@ -76,10 +70,7 @@ type FetchProgressState = { currentLabel: string; }; -type MarketplaceItemRef = { - platform: ProductPlanningPlatform; - itemId: string; -}; +type MarketplaceItemRef = ProductPlanningMarketplaceItemRef; type CompetitorImageAnalyzeParams = { item: ProductPlanningTaobaoItem; @@ -329,6 +320,13 @@ function formatPlatform(platform: ProductPlanningPlatform, isTmall = false) { return isTmall ? "天猫" : "淘宝"; } +function formatDataSource(value: ProductPlanningTaobaoItem["dataSource"]) { + if (value === "detail-plus") return "详情增强"; + if (value === "legacy") return "旧接口兜底"; + if (value === "jd") return "京东接口"; + return "未标记"; +} + function formatPropertyPreview( properties: ProductPlanningPropertyRow[], limit = 8 @@ -351,33 +349,6 @@ function createSnapshotId() { return `snapshot-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } -function getCompetitorImageEntries(item: ProductPlanningTaobaoItem) { - const entries = [ - item.mainImage - ? { - key: `${item.platform}:${item.itemId}:main`, - label: "主图", - imageUrl: item.mainImage, - } - : null, - ...item.detailImages.map((imageUrl, index) => ({ - key: `${item.platform}:${item.itemId}:detail:${index}`, - label: `详情图 ${index + 1}`, - imageUrl, - })), - ].filter( - ( - entry - ): entry is { - key: string; - label: string; - imageUrl: string; - } => Boolean(entry?.imageUrl) - ); - - return uniqueBy(entries, (entry) => entry.imageUrl); -} - function formatDateTime(value: string) { const date = new Date(value); if (Number.isNaN(date.getTime())) return "-"; @@ -440,16 +411,24 @@ function CompetitorTable({ + + + + + + + + @@ -462,6 +441,11 @@ function CompetitorTable({ className="border-t align-top" key={`${row.platform}-${row.itemId}`} > + @@ -474,37 +458,6 @@ function CompetitorTable({ className="h-16 w-16 rounded-md object-cover" src={row.mainImage} /> - - ) : ( "-" @@ -527,11 +480,15 @@ function CompetitorTable({ + + + + + + + ); })} @@ -642,172 +630,6 @@ function SalesTable({ rows }: { rows: ProductPlanningTaobaoItem[] }) { ); } -function CompetitorGallery({ - items, - analyzingImageKey, - onAnalyzeImage, - onViewAnalysis, - analysisRecords, - activeAnalysisKey, -}: { - items: ProductPlanningTaobaoItem[]; - analyzingImageKey: string | null; - onAnalyzeImage: (params: CompetitorImageAnalyzeParams) => void; - onViewAnalysis: (key: string) => void; - analysisRecords: Record; - activeAnalysisKey: string | null; -}) { - if (items.length === 0) { - return ( -
- 还没有可展示的竞品图集。 -
- ); - } - - return ( -
- {items.map((item) => { - const imageEntries = getCompetitorImageEntries(item); - const detailCount = Math.max( - imageEntries.length - (item.mainImage ? 1 : 0), - 0 - ); - - return ( - - -
-
- - {formatPlatform(item.platform, item.isTmall)} - - {item.title} -
-

- 商品ID:{item.itemId},图集 {imageEntries.length} 张 - {detailCount > 0 ? `,其中详情图 ${detailCount} 张` : ""} -

-
- -
- -
-
-
-

品牌 / 店铺

-

- {item.brand || "未识别品牌"} - {item.shopName ? ` / ${item.shopName}` : ""} -

-
-
-

- 价格 / 发货地 -

-

- {formatPrice(item.price)} - {item.location ? ` / ${item.location}` : ""} -

-
-
-

图集统计

-

- 主图 {item.itemImages.length} 张 / 详情图{" "} - {item.detailImages.length} 张 -

-
-
-

媒体信息

-

- {item.videoUrl ? "含主图视频" : "无主图视频"} -

-
-
- -
-
-

商品参数预览

-

- {formatPropertyPreview(item.properties)} -

- {item.detailedMaterial ? ( -

- 详细材质:{item.detailedMaterial} -

- ) : null} -
-
- - {imageEntries.length === 0 ? ( -
- 当前商品没有可展示的图片。 -
- ) : ( -
- {imageEntries.map((entry) => ( -
-
-

{entry.label}

-
- - -
-
- {`${item.title}-${entry.label}`} -
- ))} -
- )} -
-
-
- ); - })} -
- ); -} - function KeywordInsightSuggestionTable({ rows, }: { @@ -1619,31 +1441,38 @@ export function ProductPlanningPage({ } } - async function fetchMarketplaceItem(reference: MarketplaceItemRef) { - const endpoint = - reference.platform === "jd" - ? "/api/product-planning/jd/item" - : "/api/product-planning/taobao/item"; - const response = await fetch( - `${endpoint}?id=${encodeURIComponent(reference.itemId)}` - ); + async function fetchMarketplaceItemsBatch( + references: MarketplaceItemRef[], + options: { includeMonthSale?: boolean } = {} + ) { + const response = await fetch("/api/product-planning/marketplace/items", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + items: references, + includeMonthSale: options.includeMonthSale !== false, + }), + }); const payload = - (await response.json()) as ProductPlanningTaobaoItemResponse; - if (!response.ok || !payload.success || !payload.item) { - throw new Error(payload.error || "获取商品详情失败"); + (await response.json()) as ProductPlanningMarketplaceItemsResponse; + if (!response.ok || !payload.items) { + throw new Error(payload.error || "批量获取商品详情失败"); } - return payload.item; - } - async function fetchMonthSale(itemId: string) { - const response = await fetch( - `/api/product-planning/taobao/month-sale?id=${encodeURIComponent(itemId)}` - ); - const payload = (await response.json()) as ProductPlanningMonthSaleResponse; - if (!response.ok || !payload.success) { - throw new Error(payload.error || "获取近期销量失败"); + const rows = payload.items + .filter((result) => result.success && result.item) + .map((result) => result.item as ProductPlanningTaobaoItem); + const errors = payload.items + .filter((result) => !result.success) + .map((result) => `${result.platform}:${result.itemId} ${result.error || "查询失败"}`); + + if (rows.length === 0) { + throw new Error(errors[0] || payload.error || "批量查询未获取到可用商品"); } - return payload.recentSales; + + return { rows, errors }; } function parseMarketplaceItemLines(value: string) { @@ -1671,26 +1500,21 @@ export function ProductPlanningPage({ setActiveCompetitorAnalysisKey(null); try { - const rows: ProductPlanningTaobaoItem[] = []; - for (const reference of uniqueBy( + const references = uniqueBy( itemRefs, (item) => `${item.platform}:${item.itemId}` - )) { - const item = await fetchMarketplaceItem(reference); - if (reference.platform === "taobao") { - try { - item.recentSales = await fetchMonthSale(reference.itemId); - } catch (error) { - console.warn( - "[ProductPlanning] competitor month sale failed:", - error - ); - } - } - rows.push(item); - } + ); + const { rows, errors } = await fetchMarketplaceItemsBatch(references, { + includeMonthSale: true, + }); startTransition(() => setCompetitorItems(rows)); - toast.success(`已获取 ${rows.length} 个竞品商品数据`); + if (errors.length > 0) { + const message = `成功 ${rows.length} 个,失败 ${errors.length} 个:${errors.slice(0, 3).join(";")}`; + setCompetitorError(message); + toast.warning(message); + } else { + toast.success(`已获取 ${rows.length} 个竞品商品数据`); + } } catch (error) { const message = error instanceof Error ? error.message : "获取竞品数据失败"; @@ -1900,21 +1724,21 @@ export function ProductPlanningPage({ setSalesError(null); try { - const rows: ProductPlanningTaobaoItem[] = []; - for (const reference of uniqueBy( + const references = uniqueBy( itemRefs, (item) => `${item.platform}:${item.itemId}` - )) { - const item = await fetchMarketplaceItem(reference); - if (reference.platform === "taobao") { - item.recentSales = await fetchMonthSale(reference.itemId); - } else { - item.recentSales = item.recentSales ?? item.totalSales; - } - rows.push(item); - } + ); + const { rows, errors } = await fetchMarketplaceItemsBatch(references, { + includeMonthSale: true, + }); startTransition(() => setSalesItems(rows)); - toast.success(`已获取 ${rows.length} 个商品的销量数据`); + if (errors.length > 0) { + const message = `成功 ${rows.length} 个,失败 ${errors.length} 个:${errors.slice(0, 3).join(";")}`; + setSalesError(message); + toast.warning(message); + } else { + toast.success(`已获取 ${rows.length} 个商品的销量数据`); + } } catch (error) { const message = error instanceof Error ? error.message : "获取销量数据失败"; @@ -3060,43 +2884,17 @@ export function ProductPlanningPage({ - - -
-
- 竞品图集 - - 展开后可查看主图与详情图,并逐张触发 AI 分析。 - -
- -
-
- - - void handleCompetitorImageAnalyze(params) - } - onViewAnalysis={handleViewCompetitorAnalysis} - /> - -
+
+ +
; + } + | { + item: null; + raw?: Record; + error?: string; + }; + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function parseJsonMaybe(value: unknown): unknown { + if (typeof value !== "string") return value; + const text = value.trim(); + if (!text) return null; + try { + return JSON.parse(text); + } catch { + return value; + } +} + +function pickText(...values: unknown[]) { + for (const value of values) { + const text = normalizeText(value); + if (text) return text; + } + return ""; +} + +function pickNumber(...values: unknown[]) { + for (const value of values) { + const parsed = parseRangeValue(value); + if (typeof parsed === "number") return parsed; + } + return null; +} + +function normalizeImageList(...values: unknown[]) { + const urls: string[] = []; + + for (const value of values) { + const parsed = parseJsonMaybe(value); + if (typeof parsed === "string") { + const url = ensureHttpsUrl(parsed); + if (url) urls.push(url); + continue; + } + + if (Array.isArray(parsed)) { + for (const entry of parsed) { + if (typeof entry === "string") { + const url = ensureHttpsUrl(entry); + if (url) urls.push(url); + continue; + } + const record = asRecord(entry); + const url = ensureHttpsUrl( + record.url || + record.img || + record.image || + record.pic_url || + record.picUrl || + record.main_image_url || + record.src + ); + if (url) urls.push(url); + } + continue; + } + + const record = asRecord(parsed); + const direct = ensureHttpsUrl( + record.url || + record.img || + record.image || + record.pic_url || + record.picUrl || + record.main_image_url || + record.mainImage + ); + if (direct) urls.push(direct); + if (Array.isArray(record.item_imgs)) urls.push(...normalizeImageList(record.item_imgs)); + if (Array.isArray(record.images)) urls.push(...normalizeImageList(record.images)); + } + + return uniqueBy(urls, (url) => url); +} + +function extractDetailImagesFromHtml(value: unknown) { + const html = String(value || ""); + if (!html) return [] as string[]; + + const urls: string[] = []; + const pattern = /(?:data-lazyload|src)=["']([^"']+\.(?:jpg|jpeg|png|gif|webp)(?:[^"']*)?)["']/gi; + let match = pattern.exec(html); + while (match) { + const url = ensureHttpsUrl(match[1]); + if (url) urls.push(url); + match = pattern.exec(html); + } + + return uniqueBy(urls, (url) => url); +} + +function normalizeSkuList(...values: unknown[]) { + const result: ProductPlanningSkuRow[] = []; + + for (const value of values) { + const parsed = parseJsonMaybe(value); + const parsedRecord = asRecord(parsed); + const rows: unknown[] = Array.isArray(parsed) + ? parsed + : Array.isArray(parsedRecord.sku) + ? parsedRecord.sku + : Array.isArray(parsedRecord.skus) + ? parsedRecord.skus + : []; + + for (const row of rows) { + const record = asRecord(row); + const name = + pickText( + record.properties_name, + record.propertiesName, + record.name, + record.title, + record.sku_name, + record.skuName, + record.sku_id, + record.skuId + ) || "默认规格"; + result.push({ + name, + price: pickNumber(record.price, record.promotion_price, record.sale_price), + }); + } + } + + return uniqueBy(result, (row) => `${row.name}:${row.price ?? ""}`); +} + +function normalizePropertyRows(...values: unknown[]) { + const result: ProductPlanningPropertyRow[] = []; + + const push = (name: unknown, value: unknown) => { + const normalizedName = normalizeText(name); + const normalizedValue = normalizeText(value); + if (!normalizedName || !normalizedValue) return; + result.push({ name: normalizedName, value: normalizedValue }); + }; + + for (const value of values) { + const parsed = parseJsonMaybe(value); + + if (Array.isArray(parsed)) { + for (const entry of parsed) { + if (typeof entry === "string") { + const [name, ...rest] = entry.split(/[::]/); + push(name, rest.join(":")); + continue; + } + const record = asRecord(entry); + push( + record.name || record.attr_name || record.key || record.title || record.property_name, + record.value || record.attr_value || record.val || record.text || record.property_value + ); + } + continue; + } + + const record = asRecord(parsed); + for (const [name, value] of Object.entries(record)) { + if (Array.isArray(value) || (value && typeof value === "object")) { + const nestedRows = normalizePropertyRows(value); + if (nestedRows.length > 0) { + result.push(...nestedRows); + continue; + } + } + push(name, value); + } + } + + return uniqueBy(result, (row) => `${row.name}:${row.value}`); +} + +function firstRecord(value: unknown) { + const parsed = parseJsonMaybe(value); + if (Array.isArray(parsed)) return asRecord(parsed[0]); + return asRecord(parsed); +} + +function buildDetailPlusUrls(config: ProductPlanningApiConfig, itemId: string) { + const bases = [config.detailPlusInternalBaseUrl, config.detailPlusBaseUrl] + .map((value) => String(value || "").trim().replace(/\/+$/, "")) + .filter(Boolean); + + return uniqueBy(bases, (base) => base).map((base) => `${base}/${encodeURIComponent(itemId)}`); +} + +export function normalizeDetailPlusItem( + detail: Record, + requestedItemId: string +): ProductPlanningTaobaoItem | null { + const productParameters = asRecord(detail.productParameters); + const productRecord = asRecord( + detail.taobaoProductRecord || firstRecord(detail.taobaoProductRecords) + ); + const normalizedRecord = asRecord(parseJsonMaybe(productRecord.normalized_item_json)); + const rawData = asRecord(parseJsonMaybe(detail.raw_data_json || productRecord.raw_data_json)); + const rawItem = asRecord(rawData.item || rawData.data || rawData); + + const itemId = pickText( + detail.item_id, + productRecord.item_id, + productRecord.source_item_id, + normalizedRecord.itemId, + requestedItemId + ); + if (!itemId) return null; + + const itemImages = normalizeImageList( + detail.item_imgs_json, + rawItem.item_imgs, + rawItem.images, + normalizedRecord.itemImages, + normalizedRecord.images, + productRecord.main_image_url + ); + const descriptionHtml = pickText(detail.desc_html, rawItem.desc, normalizedRecord.descriptionHtml); + const detailImages = uniqueBy( + [ + ...normalizeImageList(rawItem.desc_img, normalizedRecord.detailImages, normalizedRecord.detail_images), + ...extractDetailImagesFromHtml(descriptionHtml), + ], + (url) => url + ); + const skus = normalizeSkuList( + productParameters.skus, + detail.skus_json, + rawItem.skus, + normalizedRecord.skus + ); + const properties = normalizePropertyRows( + productParameters.props, + productParameters.attributes, + productParameters.specs, + detail.props_json, + detail.attribute_json, + detail.specs_json, + normalizedRecord.properties, + rawItem.props, + rawItem.props_list + ); + const material = + pickText(productRecord.product_material, productRecord.core_material_name) || + extractMaterialSummary(properties); + const detailedMaterial = + pickText(productRecord.product_material, productRecord.core_material_name) || + extractDetailedMaterialSummary(properties); + const mainImage = + ensureHttpsUrl(productRecord.main_image_url || rawItem.pic_url || normalizedRecord.mainImage || itemImages[0]) || + undefined; + + return { + platform: "taobao", + dataSource: "detail-plus", + itemId, + requestedItemId, + title: pickText(detail.title, productRecord.title, rawItem.title, normalizedRecord.title) || "无标题", + detailUrl: ensureHttpsUrl( + detail.detail_url || productRecord.detail_url || productRecord.detail_link || rawItem.detail_url || "" + ), + sourceUrl: ensureHttpsUrl(detail.source_url || productRecord.source_url || ""), + productRecordId: Number(productRecord.id) || undefined, + detailStorageRecordId: + Number(detail.id || productRecord.detail_storage_record_id) || undefined, + shopName: pickText(detail.shop_name, productRecord.shop_name, rawItem.shop_name, rawItem.nick) || undefined, + shopId: pickText(detail.shop_id, productRecord.shop_id, productRecord.supplier_shop_id) || undefined, + location: pickText(detail.location, productRecord.location, rawItem.location) || undefined, + categoryId: pickText(detail.category_id, productRecord.category_id) || undefined, + price: pickNumber(detail.price, productRecord.price, rawItem.price), + originalPrice: pickNumber(productRecord.original_price, rawItem.orginal_price, rawItem.original_price, detail.price), + salesText: pickText(productRecord.total_sold, rawItem.sales, rawItem.volume) || undefined, + totalSales: parseInteger(productRecord.total_sold || rawItem.sales || rawItem.volume, 0), + recentSales: parseRangeValue(productRecord.analysis_sales) ?? null, + brand: pickText(rawItem.brand, normalizedRecord.brand) || undefined, + isTmall: + pickText(detail.seller_type, rawItem.seller_type).toLowerCase().includes("tmall") || + String(detail.detail_url || productRecord.detail_url || "").includes("tmall.com"), + mainImage, + itemImages, + detailImages, + descriptionHtml: descriptionHtml || undefined, + videoUrl: ensureHttpsUrl(detail.video_url || rawItem.video_url || asRecord(rawItem.video).url || ""), + videoThumb: ensureHttpsUrl(asRecord(rawItem.video).thumb || ""), + skus, + properties, + material, + detailedMaterial, + standardProductType: + pickText(productRecord.standard_product_type, productRecord.core_product_type_name) || undefined, + marketCategoryName: + pickText(productRecord.market_category_name, productRecord.market_common_category_name) || undefined, + priceBand: pickText(productRecord.price_band, productRecord.analysis_price_range) || undefined, + skuCount: Number(productRecord.sku_count || skus.length) || skus.length, + }; +} + +export async function fetchTaobaoDetailPlusItem( + itemId: string, + config: ProductPlanningApiConfig +): Promise { + const apiKey = String(config.detailPlusApiKey || "").trim(); + if (!apiKey) { + return { item: null, error: "详情增强接口 API Key 未配置" }; + } + + const urls = buildDetailPlusUrls(config, itemId); + if (urls.length === 0) { + return { item: null, error: "详情增强接口地址未配置" }; + } + + let lastError = ""; + for (const url of urls) { + try { + const response = await fetch(url, { + method: "GET", + headers: { + Accept: "application/json", + "x-api-key": apiKey, + }, + signal: AbortSignal.timeout(15_000), + cache: "no-store", + }); + + const raw = (await response.json().catch(() => null)) as Record | null; + if (!response.ok) { + lastError = `详情增强接口请求失败:${response.status}`; + if (response.status === 404) return { item: null, raw: raw || undefined, error: lastError }; + continue; + } + + if (!raw?.success || !raw.data) { + lastError = normalizeText(raw?.error) || "详情增强接口未返回可用数据"; + continue; + } + + const item = normalizeDetailPlusItem(asRecord(raw.data), itemId); + if (item) return { item, raw }; + lastError = "详情增强接口数据结构无法识别"; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + console.warn("[ProductPlanning][DetailPlus] request failed:", error); + } + } + + return { item: null, error: lastError }; +} diff --git a/lib/product-planning/export.ts b/lib/product-planning/export.ts index 4dcd83933..e8be07eea 100644 --- a/lib/product-planning/export.ts +++ b/lib/product-planning/export.ts @@ -11,6 +11,13 @@ function formatPlatform(value: ProductPlanningTaobaoItem["platform"], isTmall: b return isTmall ? "天猫" : "淘宝"; } +function formatDataSource(value: ProductPlanningTaobaoItem["dataSource"]) { + if (value === "detail-plus") return "详情增强"; + if (value === "legacy") return "旧接口兜底"; + if (value === "jd") return "京东接口"; + return "未标记"; +} + function formatPropertySummary(row: ProductPlanningTaobaoItem, limit = 10) { return row.properties .slice(0, limit) @@ -105,42 +112,64 @@ export async function downloadCompetitorWorkbook( worksheetName: "竞品分析", fileName: baseFileName, columns: [ + { header: "数据来源", key: "dataSource", width: 14 }, { header: "平台", key: "platform", width: 12 }, { header: "商品ID", key: "itemId", width: 18 }, + { header: "请求ID", key: "requestedItemId", width: 18 }, { header: "商品标题", key: "title", width: 40 }, { header: "品牌", key: "brand", width: 18 }, { header: "店铺", key: "shopName", width: 26 }, + { header: "店铺ID", key: "shopId", width: 18 }, { header: "发货地", key: "location", width: 18 }, + { header: "类目ID", key: "categoryId", width: 18 }, { header: "当前价格", key: "price", width: 14 }, { header: "划线价格", key: "originalPrice", width: 14 }, { header: "总销量", key: "totalSales", width: 14 }, { header: "近期销量", key: "recentSales", width: 14 }, { header: "材质", key: "material", width: 24 }, { header: "详细材质", key: "detailedMaterial", width: 36 }, + { header: "标准品类", key: "standardProductType", width: 22 }, + { header: "市场分类", key: "marketCategoryName", width: 22 }, + { header: "价格带", key: "priceBand", width: 16 }, + { header: "SKU数量", key: "skuCount", width: 12 }, { header: "主图数", key: "itemImageCount", width: 12 }, { header: "详情图数", key: "detailImageCount", width: 12 }, { header: "主图视频", key: "videoUrl", width: 36 }, + { header: "企划详情ID", key: "detailStorageRecordId", width: 14 }, + { header: "产品记录ID", key: "productRecordId", width: 14 }, { header: "参数预览", key: "propertySummary", width: 80 }, { header: "商品链接", key: "detailUrl", width: 48 }, + { header: "来源链接", key: "sourceUrl", width: 48 }, ], rows: rows.map((row) => ({ + dataSource: formatDataSource(row.dataSource), platform: formatPlatform(row.platform, row.isTmall), itemId: row.itemId, + requestedItemId: row.requestedItemId || row.itemId, title: row.title, brand: row.brand || "", shopName: row.shopName || "", + shopId: row.shopId || "", location: row.location || "", + categoryId: row.categoryId || "", price: row.price ?? "", originalPrice: row.originalPrice ?? "", totalSales: row.totalSales ?? "", recentSales: row.recentSales ?? "", material: row.material || "", detailedMaterial: row.detailedMaterial || "", + standardProductType: row.standardProductType || "", + marketCategoryName: row.marketCategoryName || "", + priceBand: row.priceBand || "", + skuCount: row.skuCount ?? row.skus.length, itemImageCount: row.itemImages.length, detailImageCount: row.detailImages.length, videoUrl: row.videoUrl || "", + detailStorageRecordId: row.detailStorageRecordId || "", + productRecordId: row.productRecordId || "", propertySummary: formatPropertySummary(row), detailUrl: row.detailUrl || "", + sourceUrl: row.sourceUrl || "", })), }); } diff --git a/lib/product-planning/marketplace-items.ts b/lib/product-planning/marketplace-items.ts new file mode 100644 index 000000000..b8c5b8072 --- /dev/null +++ b/lib/product-planning/marketplace-items.ts @@ -0,0 +1,210 @@ +import "server-only"; + +import type { ProductPlanningApiConfig } from "./config"; +import { fetchTaobaoDetailPlusItem } from "./detail-plus"; +import { syncLegacyTaobaoDetailToPlanningDb } from "./planning-db"; +import { + extractMonthSale, + normalizeJdItem, + normalizeTaobaoItem, +} from "./taobao"; +import type { + ProductPlanningMarketplaceItemRef, + ProductPlanningMarketplaceItemsResult, + ProductPlanningTaobaoItem, +} from "./types"; + +const BATCH_CONCURRENCY = 4; + +async function fetchJson(url: string, label: string) { + const response = await fetch(url, { + signal: AbortSignal.timeout(15_000), + headers: { Accept: "application/json" }, + cache: "no-store", + }); + + if (!response.ok) { + throw new Error(`${label}请求失败:${response.status}`); + } + + return response.json(); +} + +async function fetchLegacyTaobaoItem(itemId: string, config: ProductPlanningApiConfig) { + const apiKey = config.taobaoApiKey; + const apiSecret = config.taobaoApiSecret; + if (!apiKey || !apiSecret) { + throw new Error("详情增强接口未命中,且缺少旧版淘宝 API Key / Secret,请前往系统设置配置"); + } + + const primaryUrl = + `${config.oneboundBaseUrl}/taobao/item_get_pro/?key=${encodeURIComponent(apiKey)}` + + `&secret=${encodeURIComponent(apiSecret)}&num_iid=${encodeURIComponent(itemId)}`; + const primaryData = await fetchJson(primaryUrl, "淘宝商品详情接口"); + if (primaryData?.error_code && primaryData.error_code !== "0000") { + throw new Error(primaryData.error || primaryData.reason || "淘宝商品详情接口返回错误"); + } + if (primaryData?.item) { + return { + rawItem: primaryData.item as Record, + rawResponse: primaryData as Record, + }; + } + + const fallbackUrl = + `${config.oneboundBaseUrl}/taobao/item_get/?key=${encodeURIComponent(apiKey)}` + + `&secret=${encodeURIComponent(apiSecret)}&num_iid=${encodeURIComponent(itemId)}`; + const fallbackData = await fetchJson(fallbackUrl, "淘宝商品详情接口"); + if (fallbackData?.error_code && fallbackData.error_code !== "0000") { + throw new Error(fallbackData.error || fallbackData.reason || "淘宝商品详情接口返回错误"); + } + if (!fallbackData?.item) { + throw new Error("未找到商品信息"); + } + + return { + rawItem: fallbackData.item as Record, + rawResponse: fallbackData as Record, + }; +} + +export async function fetchProductPlanningTaobaoItem( + itemId: string, + config: ProductPlanningApiConfig +) { + const detailPlusResult = await fetchTaobaoDetailPlusItem(itemId, config); + if (detailPlusResult.item) { + return detailPlusResult.item; + } + + const legacy = await fetchLegacyTaobaoItem(itemId, config); + const normalizedItem = normalizeTaobaoItem(legacy.rawItem, itemId); + await syncLegacyTaobaoDetailToPlanningDb({ + config, + requestedItemId: itemId, + rawItem: legacy.rawItem, + rawResponse: legacy.rawResponse, + normalizedItem, + }).catch((error) => { + console.warn("[ProductPlanning][MarketplaceItems] sync fallback detail failed:", error); + }); + + return normalizedItem; +} + +export async function fetchProductPlanningJdItem( + itemId: string, + config: ProductPlanningApiConfig +) { + const apiKey = config.taobaoApiKey; + const apiSecret = config.taobaoApiSecret; + if (!apiKey || !apiSecret) { + throw new Error("缺少商品策划工具淘宝 API Key / Secret,请前往系统设置配置"); + } + + const apiUrl = + `${config.oneboundBaseUrl}/jd/item_get_app/?key=${encodeURIComponent(apiKey)}` + + `&secret=${encodeURIComponent(apiSecret)}&num_iid=${encodeURIComponent(itemId)}&domain_type=jd`; + const descApiUrl = + `${config.oneboundBaseUrl}/jd/item_get_desc/?key=${encodeURIComponent(apiKey)}` + + `&secret=${encodeURIComponent(apiSecret)}&num_iid=${encodeURIComponent(itemId)}&domain_type=jd`; + + const [data, descData] = await Promise.all([ + fetchJson(apiUrl, "京东商品详情接口"), + fetchJson(descApiUrl, "京东商品详情描述接口").catch(() => null), + ]); + + if (data?.error_code && data.error_code !== "0000") { + throw new Error(data.error || data.reason || "京东商品详情接口返回错误"); + } + if (!data?.item) { + throw new Error("未找到商品信息"); + } + + const descHtml = String(descData?.item?.desc || "").trim(); + return normalizeJdItem( + { + ...data.item, + desc: descHtml || data.item.desc, + }, + itemId + ); +} + +export async function fetchProductPlanningMonthSale( + itemId: string, + config: ProductPlanningApiConfig +) { + const saleToken = config.saleToken; + if (!saleToken) return null; + + const apiUrl = + `${config.saleApiUrl}?itemId=${encodeURIComponent(itemId)}` + + `&token=${encodeURIComponent(saleToken)}&v=3.0`; + const data = await fetchJson(apiUrl, "月销量接口"); + return extractMonthSale(data); +} + +export async function fetchProductPlanningMarketplaceItem( + reference: ProductPlanningMarketplaceItemRef, + config: ProductPlanningApiConfig, + options: { includeMonthSale?: boolean } = {} +): Promise { + const item = + reference.platform === "jd" + ? await fetchProductPlanningJdItem(reference.itemId, config) + : await fetchProductPlanningTaobaoItem(reference.itemId, config); + + if (reference.platform === "taobao" && options.includeMonthSale) { + const recentSales = await fetchProductPlanningMonthSale(reference.itemId, config).catch((error) => { + console.warn("[ProductPlanning][MarketplaceItems] month sale failed:", error); + return null; + }); + if (typeof recentSales === "number") { + item.recentSales = recentSales; + } + } + + if (reference.platform === "jd") { + item.recentSales = item.recentSales ?? item.totalSales; + } + + return item; +} + +export async function fetchProductPlanningMarketplaceItemsBatch( + references: ProductPlanningMarketplaceItemRef[], + config: ProductPlanningApiConfig, + options: { includeMonthSale?: boolean } = {} +) { + const results: ProductPlanningMarketplaceItemsResult[] = new Array(references.length); + let cursor = 0; + + async function worker() { + while (cursor < references.length) { + const index = cursor; + cursor += 1; + const reference = references[index]; + try { + const item = await fetchProductPlanningMarketplaceItem(reference, config, options); + results[index] = { + ...reference, + success: true, + item, + }; + } catch (error) { + results[index] = { + ...reference, + success: false, + error: error instanceof Error ? error.message : "获取商品详情失败", + }; + } + } + } + + await Promise.all( + Array.from({ length: Math.min(BATCH_CONCURRENCY, references.length) }, () => worker()) + ); + + return results; +} diff --git a/lib/product-planning/planning-db.ts b/lib/product-planning/planning-db.ts new file mode 100644 index 000000000..24112bd03 --- /dev/null +++ b/lib/product-planning/planning-db.ts @@ -0,0 +1,177 @@ +import "server-only"; + +import mysql from "mysql2/promise"; + +import type { ProductPlanningApiConfig } from "./config"; +import type { ProductPlanningTaobaoItem } from "./types"; + +type SyncLegacyDetailParams = { + config: ProductPlanningApiConfig; + requestedItemId: string; + rawItem: Record; + rawResponse: Record; + normalizedItem: ProductPlanningTaobaoItem; +}; + +function hasPlanningDbConfig(config: ProductPlanningApiConfig) { + return Boolean( + config.planningDbHost && + config.planningDbPort && + config.planningDbDatabase && + config.planningDbUser && + config.planningDbPassword + ); +} + +function text(value: unknown, maxLength?: number) { + const normalized = String(value || "").trim(); + if (!maxLength || normalized.length <= maxLength) return normalized || null; + return normalized.slice(0, maxLength); +} + +function numberOrNull(value: unknown) { + const normalized = Number(value); + return Number.isFinite(normalized) ? normalized : null; +} + +function json(value: unknown) { + try { + return JSON.stringify(value ?? {}); + } catch { + return "{}"; + } +} + +function getSellerInfo(rawItem: Record) { + const sellerInfo = rawItem.seller_info; + return sellerInfo && typeof sellerInfo === "object" && !Array.isArray(sellerInfo) + ? (sellerInfo as Record) + : {}; +} + +export async function syncLegacyTaobaoDetailToPlanningDb({ + config, + requestedItemId, + rawItem, + rawResponse, + normalizedItem, +}: SyncLegacyDetailParams) { + if (!hasPlanningDbConfig(config)) { + return { ok: false, skipped: true, reason: "planning db config missing" }; + } + + const sellerInfo = getSellerInfo(rawItem); + const itemImagesJson = normalizedItem.itemImages.map((url) => ({ url })); + const propsJson = normalizedItem.properties.map((entry) => ({ + name: entry.name, + value: entry.value, + })); + const mainItemInfo = { + itemId: normalizedItem.itemId, + title: normalizedItem.title, + price: normalizedItem.price, + originalPrice: normalizedItem.originalPrice, + mainImage: normalizedItem.mainImage, + detailUrl: normalizedItem.detailUrl, + shopName: normalizedItem.shopName, + }; + + const connection = await mysql.createConnection({ + host: config.planningDbHost, + port: Number(config.planningDbPort) || 3306, + user: config.planningDbUser, + password: config.planningDbPassword, + database: config.planningDbDatabase, + charset: config.planningDbCharset || "utf8mb4", + connectTimeout: 3000, + ssl: config.planningDbSsl ? { rejectUnauthorized: false } : undefined, + }); + + try { + await connection.execute( + `INSERT INTO taobao_detail_plus_data ( + requested_item_id, + item_id, + api_name, + api_code, + api_msg, + api_version, + source_url, + title, + nick, + num, + price, + category_id, + location, + shop_id, + shop_name, + new_item_count, + all_item_count, + total_count, + user_id, + seller_type, + video_url, + detail_url, + desc_html, + left_nums, + item_imgs_count, + skus_count, + attributes_count, + props_count, + item_imgs_json, + skus_json, + attribute_json, + specs_json, + props_json, + main_item_info_json, + raw_data_json, + raw_response_json, + fetched_at + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(3) + )`, + [ + text(requestedItemId, 64), + text(normalizedItem.itemId, 64), + text(rawResponse.api_name || rawResponse.name, 128), + text(rawResponse.error_code || rawResponse.code || "0000", 32), + text(rawResponse.reason || rawResponse.error || rawResponse.msg || "legacy fallback synced", 255), + text(rawResponse.api_version || rawResponse.version, 32), + text(normalizedItem.sourceUrl || normalizedItem.detailUrl), + text(normalizedItem.title, 1024), + text(rawItem.nick || sellerInfo.shop_name || normalizedItem.shopName, 255), + text(rawItem.num, 64), + text(normalizedItem.price ?? rawItem.price, 128), + text(normalizedItem.categoryId || rawItem.cid || rawItem.category_id, 64), + text(normalizedItem.location || rawItem.location, 255), + text(normalizedItem.shopId || sellerInfo.shop_id || rawItem.shop_id, 64), + text(normalizedItem.shopName || sellerInfo.shop_name || rawItem.shop_name, 255), + text(rawItem.new_item_count, 64), + text(rawItem.all_item_count, 64), + text(rawItem.total_count, 64), + text(rawItem.user_id || sellerInfo.user_id, 64), + text(rawItem.seller_type || (normalizedItem.isTmall ? "tmall" : "taobao"), 32), + text(normalizedItem.videoUrl), + text(normalizedItem.detailUrl), + text(normalizedItem.descriptionHtml), + numberOrNull(rawItem.left_nums), + normalizedItem.itemImages.length, + normalizedItem.skus.length, + normalizedItem.properties.length, + normalizedItem.properties.length, + json(itemImagesJson), + json(normalizedItem.skus), + json(propsJson), + json({}), + json(propsJson), + json(mainItemInfo), + json(rawItem), + json(rawResponse), + ] + ); + + return { ok: true, skipped: false }; + } finally { + await connection.end().catch(() => undefined); + } +} diff --git a/lib/product-planning/server-config.ts b/lib/product-planning/server-config.ts index 687e7ef78..5ec19ed01 100644 --- a/lib/product-planning/server-config.ts +++ b/lib/product-planning/server-config.ts @@ -12,7 +12,17 @@ export async function getResolvedProductPlanningApiConfig() { return normalizeProductPlanningApiConfig(setting?.value, { taobaoApiKey: process.env.TAOBAO_API_KEY, taobaoApiSecret: process.env.TAOBAO_API_SECRET, + detailPlusBaseUrl: process.env.PRODUCT_PLANNING_DETAIL_PLUS_BASE_URL, + detailPlusInternalBaseUrl: process.env.PRODUCT_PLANNING_DETAIL_PLUS_INTERNAL_BASE_URL, + detailPlusApiKey: process.env.PRODUCT_PLANNING_DETAIL_PLUS_API_KEY, qaToken: process.env.PRODUCT_PLANNING_QA_TOKEN, saleToken: process.env.TAOBAO_SALE_API_TOKEN, + planningDbHost: process.env.PRODUCT_PLANNING_DB_HOST, + planningDbPort: process.env.PRODUCT_PLANNING_DB_PORT, + planningDbDatabase: process.env.PRODUCT_PLANNING_DB_DATABASE || process.env.PRODUCT_PLANNING_DB_NAME, + planningDbUser: process.env.PRODUCT_PLANNING_DB_USER, + planningDbPassword: process.env.PRODUCT_PLANNING_DB_PASSWORD, + planningDbCharset: process.env.PRODUCT_PLANNING_DB_CHARSET, + planningDbSsl: process.env.PRODUCT_PLANNING_DB_SSL === "true", }); } diff --git a/lib/product-planning/taobao.ts b/lib/product-planning/taobao.ts index a9d0420c6..91880e644 100644 --- a/lib/product-planning/taobao.ts +++ b/lib/product-planning/taobao.ts @@ -212,15 +212,23 @@ export function normalizeTaobaoItem(item: any, itemId: string): ProductPlanningT return { platform: "taobao", + dataSource: "legacy", itemId, + requestedItemId: itemId, title: normalizeText(item?.title) || "无标题", + sourceUrl: ensureHttpsUrl(item?.source_url || ""), detailUrl: ensureHttpsUrl(item?.detail_url || ""), + shopId: + normalizeText(item?.seller_info?.shop_id) || + normalizeText(item?.shop_id) || + undefined, shopName: normalizeText(item?.seller_info?.shop_name) || normalizeText(item?.shop_name) || normalizeText(item?.nick) || undefined, location: normalizeText(item?.location) || undefined, + categoryId: normalizeText(item?.cid || item?.category_id) || undefined, price: normalizePrice(item?.price), originalPrice: normalizePrice(item?.orginal_price || item?.original_price || item?.price), salesText: normalizeText(item?.sales) || undefined, @@ -238,6 +246,7 @@ export function normalizeTaobaoItem(item: any, itemId: string): ProductPlanningT properties, material, detailedMaterial, + skuCount: skus.length, }; } @@ -252,7 +261,9 @@ export function normalizeJdItem(item: any, itemId: string): ProductPlanningTaoba return { platform: "jd", + dataSource: "jd", itemId, + requestedItemId: itemId, title: normalizeText(item?.title) || "无标题", detailUrl: ensureHttpsUrl(item?.detail_url || `https://item.jd.com/${itemId}.html`), shopName: @@ -260,7 +271,12 @@ export function normalizeJdItem(item: any, itemId: string): ProductPlanningTaoba normalizeText(item?.seller_info?.shop_name) || normalizeText(item?.nick) || undefined, + shopId: + normalizeText(item?.shop_id) || + normalizeText(item?.seller_info?.shop_id) || + undefined, location: normalizeText(item?.location) || undefined, + categoryId: normalizeText(item?.cid || item?.category_id) || undefined, price: normalizePrice(item?.price), originalPrice: normalizePrice(item?.orginal_price || item?.original_price || item?.price), salesText: normalizeText(item?.sales || item?.volume) || undefined, @@ -278,6 +294,7 @@ export function normalizeJdItem(item: any, itemId: string): ProductPlanningTaoba properties, material, detailedMaterial, + skuCount: skus.length, }; } diff --git a/lib/product-planning/types.ts b/lib/product-planning/types.ts index 2cb6bacce..26a7e3e17 100644 --- a/lib/product-planning/types.ts +++ b/lib/product-planning/types.ts @@ -87,11 +87,18 @@ export type ProductPlanningPropertyRow = { export type ProductPlanningTaobaoItem = { platform: ProductPlanningPlatform; + dataSource?: "detail-plus" | "legacy" | "jd"; itemId: string; + requestedItemId?: string; title: string; detailUrl?: string; + sourceUrl?: string; + productRecordId?: number; + detailStorageRecordId?: number; shopName?: string; + shopId?: string; location?: string; + categoryId?: string; price: number | null; originalPrice: number | null; salesText?: string; @@ -109,6 +116,10 @@ export type ProductPlanningTaobaoItem = { properties: ProductPlanningPropertyRow[]; material?: string; detailedMaterial?: string; + standardProductType?: string; + marketCategoryName?: string; + priceBand?: string; + skuCount?: number; }; export type ProductPlanningTaobaoItemResponse = { @@ -117,6 +128,28 @@ export type ProductPlanningTaobaoItemResponse = { error?: string; }; +export type ProductPlanningMarketplaceItemRef = { + platform: ProductPlanningPlatform; + itemId: string; +}; + +export type ProductPlanningMarketplaceItemsRequest = { + items: ProductPlanningMarketplaceItemRef[]; + includeMonthSale?: boolean; +}; + +export type ProductPlanningMarketplaceItemsResult = ProductPlanningMarketplaceItemRef & { + success: boolean; + item?: ProductPlanningTaobaoItem; + error?: string; +}; + +export type ProductPlanningMarketplaceItemsResponse = { + success: boolean; + items: ProductPlanningMarketplaceItemsResult[]; + error?: string; +}; + export type ProductPlanningMonthSaleResponse = { success: boolean; itemId: string; diff --git a/package.json b/package.json index 4f21a868a..395d04358 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "lucide-react": "^0.446.0", "matter-js": "^0.20.0", "mssql": "^12.2.1", + "mysql2": "^3.22.3", "nanoid": "^5.0.8", "next": "^16.0.9", "next-auth": "^5.0.0-beta.30", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f6e5fc8a..ae35ac2c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -187,7 +187,7 @@ importers: version: 16.4.7 drizzle-orm: specifier: ^0.34.0 - version: 0.34.1(@neondatabase/serverless@0.9.5)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.18)(@vercel/postgres@0.10.0)(postgres@3.4.5)(react@19.0.0-rc-45804af1-20241021) + version: 0.34.1(@neondatabase/serverless@0.9.5)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.18)(@vercel/postgres@0.10.0)(mysql2@3.22.3(@types/node@22.13.10))(postgres@3.4.5)(react@19.0.0-rc-45804af1-20241021) embla-carousel-react: specifier: ^8.6.0 version: 8.6.0(react@19.0.0-rc-45804af1-20241021) @@ -227,6 +227,9 @@ importers: mssql: specifier: ^12.2.1 version: 12.2.1(@azure/core-client@1.10.1) + mysql2: + specifier: ^3.22.3 + version: 3.22.3(@types/node@22.13.10) nanoid: specifier: ^5.0.8 version: 5.1.3 @@ -3546,6 +3549,10 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -4047,6 +4054,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -4398,6 +4409,9 @@ packages: peerDependencies: next: '>=13.2.0' + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -4625,6 +4639,9 @@ packages: is-promise@2.2.2: resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + is-wsl@3.1.1: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} @@ -4890,6 +4907,9 @@ packages: long@4.0.0: resolution: {integrity: sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -4903,6 +4923,10 @@ packages: lowlight@1.20.0: resolution: {integrity: sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==} + lru.min@1.1.4: + resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + lucide-react@0.446.0: resolution: {integrity: sha512-BU7gy8MfBMqvEdDPH79VhOXSEgyG8TSPOKWaExWGCQVqnGH7wGgDngPbofu+KdtVjPQBWbEmnfMTq90CTiiDRg==} peerDependencies: @@ -5215,6 +5239,16 @@ packages: resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} hasBin: true + mysql2@3.22.3: + resolution: {integrity: sha512-uWWxvZSRvRhtBdh2CdcuK83YcOfPdmEeEYB069bAmPnV93QApDGVPuvCQOLjlh7tYHEWdgQPrn6kosDxHBVLkA==} + engines: {node: '>= 8.0'} + peerDependencies: + '@types/node': '>= 8' + + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -5935,6 +5969,10 @@ packages: sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + sql-escaper@1.3.3: + resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==} + engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} + ssf@0.11.2: resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} engines: {node: '>=0.8'} @@ -9991,6 +10029,8 @@ snapshots: asynckit@0.4.0: {} + aws-ssl-profiles@1.1.2: {} + bail@2.0.2: {} balanced-match@1.0.2: {} @@ -10474,6 +10514,8 @@ snapshots: delayed-stream@1.0.0: {} + denque@2.1.0: {} + dequal@2.0.3: {} detect-gpu@5.0.70: @@ -10515,13 +10557,14 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.34.1(@neondatabase/serverless@0.9.5)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.18)(@vercel/postgres@0.10.0)(postgres@3.4.5)(react@19.0.0-rc-45804af1-20241021): + drizzle-orm@0.34.1(@neondatabase/serverless@0.9.5)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.18)(@vercel/postgres@0.10.0)(mysql2@3.22.3(@types/node@22.13.10))(postgres@3.4.5)(react@19.0.0-rc-45804af1-20241021): optionalDependencies: '@neondatabase/serverless': 0.9.5 '@opentelemetry/api': 1.9.0 '@types/pg': 8.11.6 '@types/react': 18.3.18 '@vercel/postgres': 0.10.0 + mysql2: 3.22.3(@types/node@22.13.10) postgres: 3.4.5 react: 19.0.0-rc-45804af1-20241021 @@ -10776,6 +10819,10 @@ snapshots: dependencies: next: 16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021) + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -11069,6 +11116,8 @@ snapshots: is-promise@2.2.2: {} + is-property@1.0.2: {} + is-wsl@3.1.1: dependencies: is-inside-container: 1.0.0 @@ -11288,6 +11337,8 @@ snapshots: long@4.0.0: {} + long@5.3.2: {} + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -11301,6 +11352,8 @@ snapshots: fault: 1.0.4 highlight.js: 10.7.3 + lru.min@1.1.4: {} + lucide-react@0.446.0(react@19.0.0-rc-45804af1-20241021): dependencies: react: 19.0.0-rc-45804af1-20241021 @@ -11967,6 +12020,22 @@ snapshots: mustache@4.2.0: {} + mysql2@3.22.3(@types/node@22.13.10): + dependencies: + '@types/node': 22.13.10 + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.2 + long: 5.3.2 + lru.min: 1.1.4 + named-placeholders: 1.1.6 + sql-escaper: 1.3.3 + + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.4 + nanoid@3.3.11: {} nanoid@3.3.9: {} @@ -12906,6 +12975,8 @@ snapshots: sprintf-js@1.1.3: {} + sql-escaper@1.3.3: {} + ssf@0.11.2: dependencies: frac: 1.1.2 diff --git a/tests/routes/admin-settings.test.ts b/tests/routes/admin-settings.test.ts index a99579585..698664399 100644 --- a/tests/routes/admin-settings.test.ts +++ b/tests/routes/admin-settings.test.ts @@ -28,6 +28,7 @@ test.describe.serial("/api/admin/settings", () => { test("product planning config normalization trims values and falls back to defaults", () => { const defaults = defaultProductPlanningApiConfig(); const expectedValue: ProductPlanningApiConfig = { + ...defaults, oneboundBaseUrl: "https://api.example.com/onebound", taobaoApiKey: "demo-key", taobaoApiSecret: "demo-secret", @@ -51,35 +52,66 @@ test.describe.serial("/api/admin/settings", () => { }); test("product planning config normalization can reuse fallback secrets and urls", () => { + const defaults = defaultProductPlanningApiConfig(); const normalized = normalizeProductPlanningApiConfig( { oneboundBaseUrl: " ", taobaoApiKey: "", taobaoApiSecret: " custom-secret ", + detailPlusBaseUrl: "", + detailPlusInternalBaseUrl: " ", + detailPlusApiKey: "", qaApiUrl: "", qaToken: " ", saleApiUrl: " ", saleToken: "", + planningDbHost: " ", + planningDbPort: "", + planningDbDatabase: " ", + planningDbUser: "", + planningDbPassword: "", + planningDbCharset: " ", }, { oneboundBaseUrl: "https://fallback.example.com/root/", taobaoApiKey: "fallback-key", taobaoApiSecret: "fallback-secret", + detailPlusBaseUrl: "https://fallback.example.com/detail-plus/", + detailPlusInternalBaseUrl: "http://127.0.0.1:4000/detail-plus/", + detailPlusApiKey: "fallback-detail-plus-key", qaApiUrl: "https://fallback.example.com/qa/", qaToken: "fallback-qa-token", saleApiUrl: "https://fallback.example.com/sale/", saleToken: "fallback-sale-token", + planningDbHost: "db.example.com", + planningDbPort: "60023", + planningDbDatabase: "planning_database", + planningDbUser: "planning_user", + planningDbPassword: "planning-password", + planningDbCharset: "utf8mb4", + planningDbSsl: true, } ); expect(normalized).toEqual({ + ...defaults, oneboundBaseUrl: "https://fallback.example.com/root", taobaoApiKey: "fallback-key", taobaoApiSecret: "custom-secret", + detailPlusBaseUrl: "https://fallback.example.com/detail-plus", + detailPlusInternalBaseUrl: "http://127.0.0.1:4000/detail-plus", + detailPlusApiKey: "fallback-detail-plus-key", qaApiUrl: "https://fallback.example.com/qa", qaToken: "fallback-qa-token", saleApiUrl: "https://fallback.example.com/sale", saleToken: "fallback-sale-token", + planningDbHost: "db.example.com", + planningDbPort: "60023", + planningDbDatabase: "planning_database", + planningDbUser: "planning_user", + planningDbPassword: "planning-password", + planningDbCharset: "utf8mb4", + planningDbSsl: true, } satisfies ProductPlanningApiConfig); });
数据来源 平台 商品ID 主图 标题 店铺 当前价格划线价格 总销量 近期销量类目ID 材质标准品类市场分类 SKU概览图数参数预览操作
+ + {formatDataSource(row.dataSource)} + + {formatPlatform(row.platform, row.isTmall)} {row.shopName || "-"} {formatPrice(row.price)}{formatPrice(row.originalPrice)} {formatInteger(row.totalSales)} {formatInteger(row.recentSales)}{row.categoryId || "-"} {row.material || "-"} {row.standardProductType || "-"}{row.marketCategoryName || "-"} {row.skus.length > 0 ? (
@@ -542,33 +499,64 @@ function CompetitorTable({
))} - - ) : ( "-" )}
+ 主图 {row.itemImages.length} / 详情 {row.detailImages.length} + + {formatPropertyPreview(row.properties, 10)} + +
+ + + + +
+