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() {
+
@@ -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({
+ | 数据来源 |
平台 |
商品ID |
主图 |
标题 |
店铺 |
当前价格 |
+ 划线价格 |
总销量 |
近期销量 |
+ 类目ID |
材质 |
+ 标准品类 |
+ 市场分类 |
SKU概览 |
+ 图数 |
+ 参数预览 |
+ 操作 |
@@ -462,6 +441,11 @@ function CompetitorTable({
className="border-t align-top"
key={`${row.platform}-${row.itemId}`}
>
+
+
+ {formatDataSource(row.dataSource)}
+
+ |
{formatPlatform(row.platform, row.isTmall)}
|
@@ -474,37 +458,6 @@ function CompetitorTable({
className="h-16 w-16 rounded-md object-cover"
src={row.mainImage}
/>
-
-
) : (
"-"
@@ -527,11 +480,15 @@ function CompetitorTable({
{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)}
+ |
+
+
+
+
+
+
+
+ |
);
})}
@@ -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}
-
-
-
-
-
-

-
- ))}
-
- )}
-
-
-
- );
- })}
-
- );
-}
-
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);
});