feat: support batch competitor planning lookup
This commit is contained in:
parent
40ca6d5eb8
commit
987ab3f6a0
@ -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>
|
||||
•
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 : "京东商品详情接口调用失败");
|
||||
}
|
||||
}
|
||||
|
||||
88
app/api/product-planning/marketplace/items/route.ts
Normal file
88
app/api/product-planning/marketplace/items/route.ts
Normal 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);
|
||||
}
|
||||
@ -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 : "淘宝商品详情接口调用失败");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 : "月销量接口调用失败");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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={
|
||||
|
||||
@ -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 =
|
||||
"商品策划工具「拓展分析」功能临时维护中,请稍后再试。";
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
377
lib/product-planning/detail-plus.ts
Normal file
377
lib/product-planning/detail-plus.ts
Normal 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 };
|
||||
}
|
||||
@ -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 || "",
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
210
lib/product-planning/marketplace-items.ts
Normal file
210
lib/product-planning/marketplace-items.ts
Normal 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;
|
||||
}
|
||||
177
lib/product-planning/planning-db.ts
Normal file
177
lib/product-planning/planning-db.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
75
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user