feat: support batch competitor planning lookup
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-30 17:35:25 +08:00
parent 40ca6d5eb8
commit 987ab3f6a0
19 changed files with 1459 additions and 490 deletions

View File

@ -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() {
</div>
</div>
<div className="space-y-2">
<Label htmlFor="productPlanningDetailPlusBaseUrl">
详情增强接口地址(公网)
</Label>
<Input
id="productPlanningDetailPlusBaseUrl"
value={productPlanningApiConfig.detailPlusBaseUrl}
onChange={(e) =>
setProductPlanningApiConfig((prev) => ({
...prev,
detailPlusBaseUrl: e.target.value,
}))
}
placeholder="http://ltai5tdb2ogyj59fquyedh.yydd.ai:18082/api/public/taobao-detail-plus"
/>
<div className="text-xs text-muted-foreground">
竞品分析会优先用这个接口查询商品详情,查不到再走 Onebound 旧接口。
</div>
</div>
<div className="space-y-2">
<Label htmlFor="productPlanningDetailPlusInternalBaseUrl">
详情增强接口地址(内网,可选)
</Label>
<Input
id="productPlanningDetailPlusInternalBaseUrl"
value={productPlanningApiConfig.detailPlusInternalBaseUrl}
onChange={(e) =>
setProductPlanningApiConfig((prev) => ({
...prev,
detailPlusInternalBaseUrl: e.target.value,
}))
}
placeholder="http://127.0.0.1:4000/api/public/taobao-detail-plus"
/>
<div className="text-xs text-muted-foreground">
服务器同机部署时可填内网地址,系统会先尝试内网再尝试公网。
</div>
</div>
<div className="space-y-2 lg:col-span-2">
<Label htmlFor="productPlanningDetailPlusApiKey">
详情增强接口 API Key
</Label>
<Input
id="productPlanningDetailPlusApiKey"
type="password"
value={productPlanningApiConfig.detailPlusApiKey}
onChange={(e) =>
setProductPlanningApiConfig((prev) => ({
...prev,
detailPlusApiKey: e.target.value,
}))
}
placeholder="留空保存时会保留原 API Key"
/>
</div>
<div className="space-y-2">
<Label htmlFor="productPlanningQaApiUrl">
问大家接口地址
@ -15885,7 +15954,7 @@ export function AdminSettingsClient() {
</div>
</div>
<div className="space-y-2 lg:col-span-2">
<div className="space-y-2">
<Label htmlFor="productPlanningSaleToken">销量 Token</Label>
<Input
id="productPlanningSaleToken"
@ -15900,6 +15969,126 @@ export function AdminSettingsClient() {
placeholder="请输入销量查询 Token"
/>
</div>
<div className="rounded-lg border bg-muted/30 p-4 lg:col-span-2">
<div className="mb-4 flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<div className="font-medium">企划数据库回写配置</div>
<div className="text-muted-foreground text-xs">
旧接口兜底查到的淘宝详情会同步写入新的企划详情表,供后续新接口命中。
</div>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">SSL</span>
<Switch
checked={productPlanningApiConfig.planningDbSsl}
onCheckedChange={(checked) =>
setProductPlanningApiConfig((prev) => ({
...prev,
planningDbSsl: checked,
}))
}
/>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="productPlanningDbHost">数据库 Host</Label>
<Input
id="productPlanningDbHost"
value={productPlanningApiConfig.planningDbHost}
onChange={(e) =>
setProductPlanningApiConfig((prev) => ({
...prev,
planningDbHost: e.target.value,
}))
}
placeholder="156.225.27.52"
/>
</div>
<div className="space-y-2">
<Label htmlFor="productPlanningDbPort">数据库端口</Label>
<Input
id="productPlanningDbPort"
value={productPlanningApiConfig.planningDbPort}
onChange={(e) =>
setProductPlanningApiConfig((prev) => ({
...prev,
planningDbPort: e.target.value,
}))
}
placeholder="60023"
/>
</div>
<div className="space-y-2">
<Label htmlFor="productPlanningDbDatabase">
数据库名
</Label>
<Input
id="productPlanningDbDatabase"
value={productPlanningApiConfig.planningDbDatabase}
onChange={(e) =>
setProductPlanningApiConfig((prev) => ({
...prev,
planningDbDatabase: e.target.value,
}))
}
placeholder="planning_database"
/>
</div>
<div className="space-y-2">
<Label htmlFor="productPlanningDbUser">数据库账号</Label>
<Input
id="productPlanningDbUser"
value={productPlanningApiConfig.planningDbUser}
onChange={(e) =>
setProductPlanningApiConfig((prev) => ({
...prev,
planningDbUser: e.target.value,
}))
}
placeholder="planning_user"
/>
</div>
<div className="space-y-2">
<Label htmlFor="productPlanningDbPassword">
数据库密码
</Label>
<Input
id="productPlanningDbPassword"
type="password"
value={productPlanningApiConfig.planningDbPassword}
onChange={(e) =>
setProductPlanningApiConfig((prev) => ({
...prev,
planningDbPassword: e.target.value,
}))
}
placeholder="留空保存时会保留原密码"
/>
</div>
<div className="space-y-2">
<Label htmlFor="productPlanningDbCharset">字符集</Label>
<Input
id="productPlanningDbCharset"
value={productPlanningApiConfig.planningDbCharset}
onChange={(e) =>
setProductPlanningApiConfig((prev) => ({
...prev,
planningDbCharset: e.target.value,
}))
}
placeholder="utf8mb4"
/>
</div>
</div>
</div>
</div>
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 text-xs text-blue-800 space-y-1">
@ -15911,7 +16100,7 @@ export function AdminSettingsClient() {
</p>
<p>
•
商品策划工具的评价、关键词洞察、淘宝/京东详情、问大家和销量接口都会优先使用这里的配置。
竞品分析会优先读取详情增强接口;未命中时自动走旧接口,并将旧结果回写到企划详情表。
</p>
<p>
•

View File

@ -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<string, unknown>)
: {};
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) {

View File

@ -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 : "京东商品详情接口调用失败");
}
}

View File

@ -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<string>();
const result: ProductPlanningMarketplaceItemRef[] = [];
for (const entry of value) {
if (!entry || typeof entry !== "object") continue;
const record = entry as Record<string, unknown>;
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);
}

View File

@ -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 : "淘宝商品详情接口调用失败");
}
}

View File

@ -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 : "月销量接口调用失败");
}
}

View File

@ -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({
<table className="min-w-full text-sm">
<thead className="bg-muted/60">
<tr>
<th className="px-3 py-2 text-left font-medium">数据来源</th>
<th className="px-3 py-2 text-left font-medium">平台</th>
<th className="px-3 py-2 text-left font-medium">商品ID</th>
<th className="px-3 py-2 text-left font-medium">主图</th>
<th className="px-3 py-2 text-left font-medium">标题</th>
<th className="px-3 py-2 text-left font-medium">店铺</th>
<th className="px-3 py-2 text-left font-medium">当前价格</th>
<th className="px-3 py-2 text-left font-medium">划线价格</th>
<th className="px-3 py-2 text-left font-medium">总销量</th>
<th className="px-3 py-2 text-left font-medium">近期销量</th>
<th className="px-3 py-2 text-left font-medium">类目ID</th>
<th className="px-3 py-2 text-left font-medium">材质</th>
<th className="px-3 py-2 text-left font-medium">标准品类</th>
<th className="px-3 py-2 text-left font-medium">市场分类</th>
<th className="px-3 py-2 text-left font-medium">SKU概览</th>
<th className="px-3 py-2 text-left font-medium">图数</th>
<th className="px-3 py-2 text-left font-medium">参数预览</th>
<th className="px-3 py-2 text-left font-medium">操作</th>
</tr>
</thead>
<tbody>
@ -462,6 +441,11 @@ function CompetitorTable({
className="border-t align-top"
key={`${row.platform}-${row.itemId}`}
>
<td className="px-3 py-2">
<Badge variant={row.dataSource === "detail-plus" ? "default" : "outline"}>
{formatDataSource(row.dataSource)}
</Badge>
</td>
<td className="px-3 py-2">
{formatPlatform(row.platform, row.isTmall)}
</td>
@ -474,37 +458,6 @@ function CompetitorTable({
className="h-16 w-16 rounded-md object-cover"
src={row.mainImage}
/>
<Button
disabled={analyzingItemKey === mainImageKey}
onClick={() =>
onAnalyzeImage({
item: row,
imageUrl: row.mainImage || "",
imageLabel: "主图",
imageKey: mainImageKey,
})
}
size="sm"
type="button"
variant="outline"
>
{analyzingItemKey === mainImageKey
? "分析中..."
: "AI 分析主图"}
</Button>
<Button
disabled={!analysisRecords[mainImageKey]}
onClick={() => onViewAnalysis(mainImageKey)}
size="sm"
type="button"
variant={
activeAnalysisKey === mainImageKey
? "default"
: "ghost"
}
>
查看结果
</Button>
</div>
) : (
"-"
@ -527,11 +480,15 @@ function CompetitorTable({
</td>
<td className="px-3 py-2">{row.shopName || "-"}</td>
<td className="px-3 py-2">{formatPrice(row.price)}</td>
<td className="px-3 py-2">{formatPrice(row.originalPrice)}</td>
<td className="px-3 py-2">{formatInteger(row.totalSales)}</td>
<td className="px-3 py-2">{formatInteger(row.recentSales)}</td>
<td className="px-3 py-2">{row.categoryId || "-"}</td>
<td className="whitespace-pre-wrap px-3 py-2">
{row.material || "-"}
</td>
<td className="px-3 py-2">{row.standardProductType || "-"}</td>
<td className="px-3 py-2">{row.marketCategoryName || "-"}</td>
<td className="px-3 py-2">
{row.skus.length > 0 ? (
<div className="min-w-[220px] space-y-2 text-muted-foreground text-xs">
@ -542,33 +499,64 @@ function CompetitorTable({
</div>
))}
</div>
<Button
disabled={analyzingSkuKey === skuKey}
onClick={() => onAnalyzeSku(row)}
size="sm"
type="button"
variant="outline"
>
{analyzingSkuKey === skuKey
? "分析中..."
: "AI 分析 SKU"}
</Button>
<Button
disabled={!analysisRecords[skuKey]}
onClick={() => onViewAnalysis(skuKey)}
size="sm"
type="button"
variant={
activeAnalysisKey === skuKey ? "default" : "ghost"
}
>
查看结果
</Button>
</div>
) : (
"-"
)}
</td>
<td className="px-3 py-2">
主图 {row.itemImages.length} / 详情 {row.detailImages.length}
</td>
<td className="min-w-[320px] whitespace-pre-wrap px-3 py-2 text-muted-foreground text-xs">
{formatPropertyPreview(row.properties, 10)}
</td>
<td className="px-3 py-2">
<div className="flex min-w-[150px] flex-col gap-2">
<Button
disabled={!row.mainImage || analyzingItemKey === mainImageKey}
onClick={() =>
onAnalyzeImage({
item: row,
imageUrl: row.mainImage || "",
imageLabel: "主图",
imageKey: mainImageKey,
})
}
size="sm"
type="button"
variant="outline"
>
{analyzingItemKey === mainImageKey ? "主图分析中" : "AI 分析主图"}
</Button>
<Button
disabled={!analysisRecords[mainImageKey]}
onClick={() => onViewAnalysis(mainImageKey)}
size="sm"
type="button"
variant={activeAnalysisKey === mainImageKey ? "default" : "ghost"}
>
查看主图结果
</Button>
<Button
disabled={row.skus.length === 0 || analyzingSkuKey === skuKey}
onClick={() => onAnalyzeSku(row)}
size="sm"
type="button"
variant="outline"
>
{analyzingSkuKey === skuKey ? "SKU 分析中" : "AI 分析 SKU"}
</Button>
<Button
disabled={!analysisRecords[skuKey]}
onClick={() => onViewAnalysis(skuKey)}
size="sm"
type="button"
variant={activeAnalysisKey === skuKey ? "default" : "ghost"}
>
查看 SKU 结果
</Button>
</div>
</td>
</tr>
);
})}
@ -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<string, ProductPlanningCompetitorAnalysisRecord>;
activeAnalysisKey: string | null;
}) {
if (items.length === 0) {
return (
<div className="rounded-lg border border-dashed p-6 text-muted-foreground text-sm">
还没有可展示的竞品图集。
</div>
);
}
return (
<div className="space-y-4">
{items.map((item) => {
const imageEntries = getCompetitorImageEntries(item);
const detailCount = Math.max(
imageEntries.length - (item.mainImage ? 1 : 0),
0
);
return (
<Collapsible
className="rounded-xl border bg-card"
defaultOpen={false}
key={`${item.platform}:${item.itemId}`}
>
<CollapsibleTrigger className="group flex w-full items-center justify-between gap-4 px-4 py-3 text-left">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">
{formatPlatform(item.platform, item.isTmall)}
</Badge>
<span className="font-medium">{item.title}</span>
</div>
<p className="text-muted-foreground text-xs">
商品ID:{item.itemId},图集 {imageEntries.length} 张
{detailCount > 0 ? `,其中详情图 ${detailCount} 张` : ""}
</p>
</div>
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="border-t px-4 py-4">
<div className="space-y-4">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-lg border bg-muted/30 p-3">
<p className="text-muted-foreground text-xs">品牌 / 店铺</p>
<p className="mt-1 font-medium text-sm">
{item.brand || "未识别品牌"}
{item.shopName ? ` / ${item.shopName}` : ""}
</p>
</div>
<div className="rounded-lg border bg-muted/30 p-3">
<p className="text-muted-foreground text-xs">
价格 / 发货地
</p>
<p className="mt-1 font-medium text-sm">
{formatPrice(item.price)}
{item.location ? ` / ${item.location}` : ""}
</p>
</div>
<div className="rounded-lg border bg-muted/30 p-3">
<p className="text-muted-foreground text-xs">图集统计</p>
<p className="mt-1 font-medium text-sm">
主图 {item.itemImages.length} 张 / 详情图{" "}
{item.detailImages.length} 张
</p>
</div>
<div className="rounded-lg border bg-muted/30 p-3">
<p className="text-muted-foreground text-xs">媒体信息</p>
<p className="mt-1 font-medium text-sm">
{item.videoUrl ? "含主图视频" : "无主图视频"}
</p>
</div>
</div>
<div className="rounded-lg border p-4">
<div className="space-y-2">
<p className="font-medium text-sm">商品参数预览</p>
<p className="text-muted-foreground text-sm">
{formatPropertyPreview(item.properties)}
</p>
{item.detailedMaterial ? (
<p className="text-muted-foreground text-xs">
详细材质:{item.detailedMaterial}
</p>
) : null}
</div>
</div>
{imageEntries.length === 0 ? (
<div className="rounded-lg border border-dashed p-4 text-muted-foreground text-sm">
当前商品没有可展示的图片。
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{imageEntries.map((entry) => (
<div
className="space-y-3 rounded-xl border p-3"
key={entry.key}
>
<div className="flex items-center justify-between gap-2">
<p className="font-medium text-sm">{entry.label}</p>
<div className="flex items-center gap-2">
<Button
disabled={analyzingImageKey === entry.key}
onClick={() =>
onAnalyzeImage({
item,
imageUrl: entry.imageUrl,
imageLabel: entry.label,
imageKey: entry.key,
})
}
size="sm"
type="button"
variant="outline"
>
{analyzingImageKey === entry.key
? "分析中..."
: "AI 分析"}
</Button>
<Button
disabled={!analysisRecords[entry.key]}
onClick={() => onViewAnalysis(entry.key)}
size="sm"
type="button"
variant={
activeAnalysisKey === entry.key
? "default"
: "ghost"
}
>
查看结果
</Button>
</div>
</div>
<img
alt={`${item.title}-${entry.label}`}
className="aspect-square w-full rounded-lg object-cover"
src={entry.imageUrl}
/>
</div>
))}
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
);
})}
</div>
);
}
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({
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="space-y-1">
<CardTitle>竞品图集</CardTitle>
<CardDescription>
展开后可查看主图与详情图,并逐张触发 AI 分析。
</CardDescription>
</div>
<Button
disabled={
Object.keys(competitorAnalysisRecords).length === 0
}
onClick={() =>
void handleCompetitorAnalysisWorkbookExport()
}
size="sm"
type="button"
variant="outline"
>
导出分析汇总
</Button>
</div>
</CardHeader>
<CardContent>
<CompetitorGallery
activeAnalysisKey={activeCompetitorAnalysisKey}
analysisRecords={competitorAnalysisRecords}
analyzingImageKey={competitorImageLoadingId}
items={competitorItems}
onAnalyzeImage={(params) =>
void handleCompetitorImageAnalyze(params)
}
onViewAnalysis={handleViewCompetitorAnalysis}
/>
</CardContent>
</Card>
<div className="flex justify-end">
<Button
disabled={Object.keys(competitorAnalysisRecords).length === 0}
onClick={() => void handleCompetitorAnalysisWorkbookExport()}
size="sm"
type="button"
variant="outline"
>
导出分析汇总
</Button>
</div>
<MarkdownPanel
actions={

View File

@ -1,4 +1,4 @@
export const PRODUCT_PLANNING_ADVANCED_DISABLED = true;
export const PRODUCT_PLANNING_ADVANCED_DISABLED = false;
export const PRODUCT_PLANNING_ADVANCED_DISABLED_MESSAGE =
"商品策划工具「拓展分析」功能临时维护中,请稍后再试。";

View File

@ -4,13 +4,25 @@ export type ProductPlanningApiConfig = {
oneboundBaseUrl: string;
taobaoApiKey: string;
taobaoApiSecret: string;
detailPlusBaseUrl: string;
detailPlusInternalBaseUrl: string;
detailPlusApiKey: string;
qaApiUrl: string;
qaToken: string;
saleApiUrl: string;
saleToken: string;
planningDbHost: string;
planningDbPort: string;
planningDbDatabase: string;
planningDbUser: string;
planningDbPassword: string;
planningDbCharset: string;
planningDbSsl: boolean;
};
const DEFAULT_ONEBOUND_BASE_URL = "https://api-gw.onebound.cn";
const DEFAULT_DETAIL_PLUS_BASE_URL =
"http://ltai5tdb2ogyj59fquyedh.yydd.ai:18082/api/public/taobao-detail-plus";
const DEFAULT_QA_API_URL = "http://119.91.237.2:5432/taobao_social_feed_aggregate";
const DEFAULT_SALE_API_URL = "http://119.91.237.2:5432/taobao_item_month_sale";
@ -26,15 +38,35 @@ function normalizeSecret(value: unknown, fallback = "") {
return normalized || normalizedFallback;
}
function normalizeBoolean(value: unknown, fallback = false) {
if (typeof value === "boolean") return value;
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (["true", "1", "yes", "y", "on"].includes(normalized)) return true;
if (["false", "0", "no", "n", "off"].includes(normalized)) return false;
}
return fallback;
}
export function defaultProductPlanningApiConfig(): ProductPlanningApiConfig {
return {
oneboundBaseUrl: DEFAULT_ONEBOUND_BASE_URL,
taobaoApiKey: "",
taobaoApiSecret: "",
detailPlusBaseUrl: DEFAULT_DETAIL_PLUS_BASE_URL,
detailPlusInternalBaseUrl: "",
detailPlusApiKey: "",
qaApiUrl: DEFAULT_QA_API_URL,
qaToken: "",
saleApiUrl: DEFAULT_SALE_API_URL,
saleToken: "",
planningDbHost: "",
planningDbPort: "3306",
planningDbDatabase: "",
planningDbUser: "",
planningDbPassword: "",
planningDbCharset: "utf8mb4",
planningDbSsl: false,
};
}
@ -51,9 +83,25 @@ export function normalizeProductPlanningApiConfig(
oneboundBaseUrl: normalizeUrl(raw.oneboundBaseUrl, fallback?.oneboundBaseUrl || DEFAULT_ONEBOUND_BASE_URL),
taobaoApiKey: normalizeSecret(raw.taobaoApiKey, fallback?.taobaoApiKey || ""),
taobaoApiSecret: normalizeSecret(raw.taobaoApiSecret, fallback?.taobaoApiSecret || ""),
detailPlusBaseUrl: normalizeUrl(
raw.detailPlusBaseUrl,
fallback?.detailPlusBaseUrl || DEFAULT_DETAIL_PLUS_BASE_URL
),
detailPlusInternalBaseUrl: normalizeUrl(
raw.detailPlusInternalBaseUrl,
fallback?.detailPlusInternalBaseUrl || ""
),
detailPlusApiKey: normalizeSecret(raw.detailPlusApiKey, fallback?.detailPlusApiKey || ""),
qaApiUrl: normalizeUrl(raw.qaApiUrl, fallback?.qaApiUrl || DEFAULT_QA_API_URL),
qaToken: normalizeSecret(raw.qaToken, fallback?.qaToken || ""),
saleApiUrl: normalizeUrl(raw.saleApiUrl, fallback?.saleApiUrl || DEFAULT_SALE_API_URL),
saleToken: normalizeSecret(raw.saleToken, fallback?.saleToken || ""),
planningDbHost: normalizeSecret(raw.planningDbHost, fallback?.planningDbHost || ""),
planningDbPort: normalizeSecret(raw.planningDbPort, fallback?.planningDbPort || "3306"),
planningDbDatabase: normalizeSecret(raw.planningDbDatabase, fallback?.planningDbDatabase || ""),
planningDbUser: normalizeSecret(raw.planningDbUser, fallback?.planningDbUser || ""),
planningDbPassword: normalizeSecret(raw.planningDbPassword, fallback?.planningDbPassword || ""),
planningDbCharset: normalizeSecret(raw.planningDbCharset, fallback?.planningDbCharset || "utf8mb4"),
planningDbSsl: normalizeBoolean(raw.planningDbSsl, fallback?.planningDbSsl || false),
};
}

View File

@ -0,0 +1,377 @@
import "server-only";
import type {
ProductPlanningPropertyRow,
ProductPlanningSkuRow,
ProductPlanningTaobaoItem,
} from "./types";
import { extractDetailedMaterialSummary, extractMaterialSummary } from "./taobao";
import { ensureHttpsUrl, normalizeText, parseInteger, parseRangeValue, uniqueBy } from "./utils";
import type { ProductPlanningApiConfig } from "./config";
type DetailPlusFetchResult =
| {
item: ProductPlanningTaobaoItem;
raw: Record<string, unknown>;
}
| {
item: null;
raw?: Record<string, unknown>;
error?: string;
};
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
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<string, unknown>,
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<DetailPlusFetchResult> {
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<string, unknown> | 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 };
}

View File

@ -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 || "",
})),
});
}

View File

@ -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<string, unknown>,
rawResponse: primaryData as Record<string, unknown>,
};
}
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<string, unknown>,
rawResponse: fallbackData as Record<string, unknown>,
};
}
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<ProductPlanningTaobaoItem> {
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;
}

View File

@ -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<string, unknown>;
rawResponse: Record<string, unknown>;
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<string, unknown>) {
const sellerInfo = rawItem.seller_info;
return sellerInfo && typeof sellerInfo === "object" && !Array.isArray(sellerInfo)
? (sellerInfo as Record<string, unknown>)
: {};
}
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);
}
}

View File

@ -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",
});
}

View File

@ -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,
};
}

View File

@ -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;

View File

@ -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",

75
pnpm-lock.yaml generated
View File

@ -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

View File

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