cangku/backend/src/services/alibaba1688SkuDimensionAiService.js

475 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
const axios = require('axios');
const { getAlibaba1688Product } = require('./alibaba1688ProductService');
const {
getEncodingByProductId,
getClaimTtlSeconds,
} = require('./alibaba1688ProductEncodingRegistry');
const {
buildAlibaba1688SkuKey,
buildAlibaba1688SkuFingerprint,
} = require('./alibaba1688SkuIdentity');
const { getSelectionFissionAiConfig } = require('./selectionFissionAiSettings');
const SKU_DIMENSION_MODEL = 'qwen3.7-flash';
const MAX_SKUS_PER_REQUEST = 30;
const REQUEST_CONCURRENCY = 2;
const MAX_REQUEST_ATTEMPTS = 2;
const MAX_MODEL_REQUEST_TIMEOUT_MS = 60000;
const MAX_DIMENSION_CM = 100000;
const MAX_CODING_SKUS = 200;
const DIMENSION_PROMPT = [
'你是1688电商商品SKU尺寸解析专家。请分析商品资料以及每一条SKU名称和规格,为每条SKU给出商品本体的长、宽、高。',
'所有数值统一换算成厘米,不要使用包装尺寸、物流尺寸或重量。长表示主要水平长度,宽表示另一水平尺寸或进深,高表示垂直高度。',
'优先采用SKU名称和规格中明确出现的尺寸;其次结合同一商品其他SKU的尺寸规律、商品标题、类目和属性推断缺失维度。',
'SKU名称或规格中出现的cm、mm、米尺寸是硬约束:每个明确数值都必须出现在该SKU返回的长、宽、高之一。只有一个尺寸值时,请结合品类判断它属于长、宽还是高;不同明确尺寸的SKU不得返回完全相同的长宽高。',
'同一尺寸仅颜色不同的SKU必须返回完全相同的长宽高。即使信息不完整,也要给出最合理的正数估计,并在evidence中简短说明依据。',
'只输出JSON对象,格式为:{"items":[{"skuRef":"S1","lengthCm":120,"widthCm":60,"heightCm":75,"confidence":"high","evidence":"SKU明确标注120×60×75cm"}]}。',
'必须为输入中的每个skuRef返回且只返回一条结果,不得遗漏、重复、改写skuRef或返回null。',
].join('\n');
function dimensionError(message, statusCode = 502, code = 'ALIBABA_1688_SKU_DIMENSION_AI_FAILED') {
const error = new Error(message);
error.statusCode = statusCode;
error.status = statusCode;
error.code = code;
return error;
}
function normalizeText(value, maxLength = 1000) {
return String(value ?? '').trim().slice(0, maxLength);
}
function parseFirstJsonObject(text) {
const raw = String(text || '').trim();
if (!raw) return null;
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
for (const candidate of [fenced?.[1], raw].filter(Boolean)) {
try {
return JSON.parse(candidate);
} catch {
const start = candidate.indexOf('{');
const end = candidate.lastIndexOf('}');
if (start >= 0 && end > start) {
try {
return JSON.parse(candidate.slice(start, end + 1));
} catch {
// Try the next candidate.
}
}
}
}
return null;
}
function normalizeDimension(value, label) {
let number = Number(value);
if (!Number.isFinite(number) && typeof value === 'string') {
const match = value.trim().match(/(-?\d+(?:\.\d+)?)\s*(mm|毫米|cm|厘米|m|米)?/i);
if (match) {
number = Number(match[1]);
const unit = String(match[2] || 'cm').toLowerCase();
if (unit === 'mm' || unit === '毫米') number /= 10;
if (unit === 'm' || unit === '米') number *= 100;
}
}
if (!Number.isFinite(number) || number <= 0 || number > MAX_DIMENSION_CM) {
throw dimensionError(
`AI返回的${label}不是有效的厘米正数`,
502,
'ALIBABA_1688_SKU_DIMENSION_AI_INVALID_VALUE'
);
}
return Math.round(number * 100) / 100;
}
function convertExplicitDimensionToCm(value, unit) {
let number = Number(value);
if (!Number.isFinite(number) || number <= 0) return null;
const normalizedUnit = String(unit || 'cm').toLowerCase();
if (normalizedUnit === 'mm' || normalizedUnit === '毫米') number /= 10;
if (normalizedUnit === 'm' || normalizedUnit === '米') number *= 100;
return number <= MAX_DIMENSION_CM ? Math.round(number * 100) / 100 : null;
}
function extractExplicitDimensionValues(row = {}) {
const text = [
row.skuName,
...(Array.isArray(row.specs) ? row.specs.flatMap(spec => [spec?.name, spec?.value]) : []),
].map(value => normalizeText(value, 1200)).filter(Boolean).join(' ');
const values = new Set();
const add = (value, unit) => {
const centimeters = convertExplicitDimensionToCm(value, unit);
if (centimeters != null) values.add(centimeters);
};
const groupedPattern = /(\d+(?:\.\d+)?)\s*[xX×*]\s*(\d+(?:\.\d+)?)(?:\s*[xX×*]\s*(\d+(?:\.\d+)?))?\s*(mm|毫米|cm|厘米|公分|m|米)/gi;
for (const match of text.matchAll(groupedPattern)) {
add(match[1], match[4]);
add(match[2], match[4]);
if (match[3]) add(match[3], match[4]);
}
const singlePattern = /(\d+(?:\.\d+)?)\s*(mm|毫米|cm|厘米|公分|m|米)/gi;
for (const match of text.matchAll(singlePattern)) add(match[1], match[2]);
return [...values];
}
function assertExplicitDimensionsPreserved(rows = [], requestRows = []) {
for (let index = 0; index < requestRows.length; index += 1) {
const explicitValues = extractExplicitDimensionValues(requestRows[index]);
if (!explicitValues.length) continue;
const actualValues = [rows[index]?.lengthCm, rows[index]?.widthCm, rows[index]?.heightCm]
.map(Number)
.filter(Number.isFinite);
const ignoredValues = explicitValues.filter(value => !actualValues.some(actual => (
Math.abs(actual - value) <= Math.max(0.05, value * 0.005)
)));
if (ignoredValues.length) {
throw dimensionError(
`Qwen忽略了 ${requestRows[index].skuRef} 规格中的明确尺寸 ${ignoredValues.join('/')}cm`,
502,
'ALIBABA_1688_SKU_DIMENSION_AI_EXPLICIT_VALUE_IGNORED'
);
}
}
}
function normalizeDimensionPayload(payload, requestRows = []) {
const rawItems = Array.isArray(payload) ? payload : payload?.items;
if (!Array.isArray(rawItems)) {
throw dimensionError(
'Qwen未返回可解析的SKU尺寸列表',
502,
'ALIBABA_1688_SKU_DIMENSION_AI_JSON_INVALID'
);
}
const byRef = new Map();
for (const item of rawItems) {
const skuRef = normalizeText(item?.skuRef || item?.sku_ref, 40);
if (!skuRef || byRef.has(skuRef)) continue;
const dimensions = item?.dimensions && typeof item.dimensions === 'object'
? item.dimensions
: item;
byRef.set(skuRef, {
skuRef,
lengthCm: normalizeDimension(dimensions.lengthCm ?? dimensions.length_cm ?? dimensions.length, '长度'),
widthCm: normalizeDimension(dimensions.widthCm ?? dimensions.width_cm ?? dimensions.width, '宽度'),
heightCm: normalizeDimension(dimensions.heightCm ?? dimensions.height_cm ?? dimensions.height, '高度'),
confidence: ['high', 'medium', 'low'].includes(normalizeText(item?.confidence, 20).toLowerCase())
? normalizeText(item.confidence, 20).toLowerCase()
: 'medium',
evidence: normalizeText(item?.evidence || item?.reason, 300),
});
}
const missingRef = requestRows.find((row) => !byRef.has(row.skuRef))?.skuRef;
if (missingRef || byRef.size !== requestRows.length) {
throw dimensionError(
`Qwen返回的SKU尺寸不完整${missingRef ? `,缺少 ${missingRef}` : ''}`,
502,
'ALIBABA_1688_SKU_DIMENSION_AI_INCOMPLETE'
);
}
return requestRows.map((row) => ({ ...row, ...byRef.get(row.skuRef) }));
}
function buildAnalysisRows(productRecord) {
const sourceSkus = Array.isArray(productRecord?.product?.skus) ? productRecord.product.skus : [];
if (!sourceSkus.length) {
throw dimensionError('该1688商品没有可解析的SKU', 422, 'ALIBABA_1688_SKU_REQUIRED');
}
if (sourceSkus.length > MAX_CODING_SKUS) {
throw dimensionError(
`该商品共有 ${sourceSkus.length} 个SKU,超过编码上限 ${MAX_CODING_SKUS} 个`,
422,
'ALIBABA_1688_SKU_LIMIT_EXCEEDED'
);
}
return sourceSkus.map((sku, index) => ({
skuRef: `S${index + 1}`,
sourceSkuKey: buildAlibaba1688SkuKey(sku, index),
sourceIndex: Number(sku?.index) || index + 1,
skuName: normalizeText(sku?.name, 1200),
specs: (Array.isArray(sku?.specs) ? sku.specs : []).map((spec) => ({
name: normalizeText(spec?.name, 120),
value: normalizeText(spec?.value, 500),
})),
}));
}
function buildDimensionAnalysisAttributes(product = {}) {
const attributes = (Array.isArray(product.attributes) ? product.attributes : []).slice(0, 80).map((item) => ({
name: normalizeText(item?.name, 160),
value: normalizeText(item?.value, 500),
}));
const hasLogisticsTable = attributes.some((item) => {
const text = `${item.name} ${item.value}`;
return /体积/i.test(text) && /(重量|毛重|净重)/i.test(text);
});
return attributes.filter((item) => {
const text = `${item.name} ${item.value}`;
if (/(包装尺寸|物流尺寸|包装体积|体积.*(?:重量|毛重|净重))/i.test(text)) return false;
if (hasLogisticsTable && /^(?:\s*\d+(?:\.\d+)?){4,}\s*$/.test(item.value)) return false;
return item.name || item.value;
});
}
function buildProductContext(productRecord, rows) {
const product = productRecord?.product || {};
return {
productId: normalizeText(productRecord?.productId, 80),
title: normalizeText(productRecord?.title || product.title, 500),
category: normalizeText(productRecord?.category || product.category, 500),
attributes: buildDimensionAnalysisAttributes(product),
skus: rows.map((row) => ({
skuRef: row.skuRef,
skuName: row.skuName,
specs: row.specs,
explicitDimensionValuesCm: extractExplicitDimensionValues(row),
})),
};
}
function dimensionsFromSavedRequest(productRecord, encoding) {
const rows = buildAnalysisRows(productRecord);
const savedItems = Array.isArray(encoding?.request?.skuItems) ? encoding.request.skuItems : [];
if (savedItems.length !== rows.length) return [];
return rows.map((row, index) => ({
sourceSkuKey: row.sourceSkuKey,
sourceIndex: row.sourceIndex,
lengthCm: savedItems[index]?.lengthCm ?? null,
widthCm: savedItems[index]?.widthCm ?? null,
heightCm: savedItems[index]?.heightCm ?? null,
confidence: 'saved',
evidence: '首次正式编码请求已保存',
}));
}
function hasCompleteSavedDimensions(rows) {
return Array.isArray(rows) && rows.length > 0 && rows.every(row => (
Number.isFinite(Number(row.lengthCm)) && Number(row.lengthCm) > 0
&& Number.isFinite(Number(row.widthCm)) && Number(row.widthCm) > 0
&& Number.isFinite(Number(row.heightCm)) && Number(row.heightCm) > 0
));
}
function assertRetrySnapshotCurrent(productRecord, encoding) {
const rows = buildAnalysisRows(productRecord);
const savedItems = Array.isArray(encoding?.request?.skuItems) ? encoding.request.skuItems : [];
const sourceSkuFingerprint = buildAlibaba1688SkuFingerprint(productRecord.product?.skus);
const snapshotMatches = savedItems.length === rows.length
&& (!Number(encoding?.sourceSkuCount) || Number(encoding.sourceSkuCount) === rows.length)
&& (!encoding?.sourceSkuFingerprint
|| encoding.sourceSkuFingerprint === sourceSkuFingerprint);
if (!snapshotMatches) {
throw dimensionError(
'商品 SKU 已在首次编码请求后发生变化,不能复用历史请求或重新解析尺寸',
409,
'ALIBABA_1688_SKU_CHANGED_SINCE_ENCODING_REQUEST'
);
}
return { rows, sourceSkuFingerprint };
}
function isRetryableEncodingRecord(encoding, now = Date.now()) {
if (['failed', 'conflict'].includes(encoding?.status)) return true;
if (encoding?.status !== 'encoding') return false;
const startedAt = new Date(encoding.startedAt).getTime();
return Number.isFinite(startedAt)
&& startedAt + getClaimTtlSeconds() * 1000 <= Number(now);
}
function isRetryableModelError(error) {
const status = Number(error?.response?.status || error?.statusCode || error?.status || 0);
return !error?.response || [408, 425, 429].includes(status) || status >= 500;
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function requestDimensionChunk(config, context, requestRows, options = {}) {
const httpClient = options.httpClient || axios;
const wait = options.delay || delay;
let lastError;
for (let attempt = 1; attempt <= MAX_REQUEST_ATTEMPTS; attempt += 1) {
try {
const correction = attempt > 1 && lastError
? `\n\n上次结果未通过校验:${normalizeText(lastError.message, 500)}。请重新逐条检查SKU中的明确尺寸,确保这些数值实际出现在对应长、宽、高中。`
: '';
const response = await httpClient.post(`${String(config.baseUrl).replace(/\/+$/, '')}/chat/completions`, {
model: SKU_DIMENSION_MODEL,
messages: [{
role: 'user',
content: `${DIMENSION_PROMPT}${correction}\n\n商品及SKU资料:${JSON.stringify(context)}`,
}],
response_format: { type: 'json_object' },
max_completion_tokens: Math.min(6000, Math.max(1200, requestRows.length * 130)),
enable_thinking: false,
temperature: 0,
}, {
timeout: Math.min(
Math.max(Number(config.timeoutMs) || MAX_MODEL_REQUEST_TIMEOUT_MS, 1000),
MAX_MODEL_REQUEST_TIMEOUT_MS
),
headers: {
Authorization: `Bearer ${config.apiKey}`,
'Content-Type': 'application/json',
},
maxBodyLength: 10 * 1024 * 1024,
});
const parsed = parseFirstJsonObject(response?.data?.choices?.[0]?.message?.content);
const rows = normalizeDimensionPayload(parsed, requestRows);
assertExplicitDimensionsPreserved(rows, requestRows);
return {
rows,
usage: {
promptTokens: Number(response?.data?.usage?.prompt_tokens || 0),
completionTokens: Number(response?.data?.usage?.completion_tokens || 0),
totalTokens: Number(response?.data?.usage?.total_tokens || 0),
},
};
} catch (error) {
lastError = error;
if (attempt >= MAX_REQUEST_ATTEMPTS || !isRetryableModelError(error)) break;
await wait(500 * attempt);
}
}
if (lastError?.code?.startsWith('ALIBABA_1688_SKU_DIMENSION_')) throw lastError;
const upstreamMessage = normalizeText(
lastError?.response?.data?.error?.message || lastError?.response?.data?.message || lastError?.message,
500
);
throw dimensionError(
`Qwen智能解析SKU尺寸失败:${upstreamMessage || '模型服务暂不可用'}`,
Number(lastError?.response?.status) || 502,
'ALIBABA_1688_SKU_DIMENSION_AI_REQUEST_FAILED'
);
}
async function mapWithConcurrency(items, concurrency, mapper) {
const results = new Array(items.length);
let nextIndex = 0;
async function worker() {
while (nextIndex < items.length) {
const index = nextIndex;
nextIndex += 1;
results[index] = await mapper(items[index], index);
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
return results;
}
async function analyzeAlibaba1688SkuDimensions(productId, options = {}) {
const productRecord = await (options.getProduct || getAlibaba1688Product)(productId);
const encoding = await (options.getEncoding || getEncodingByProductId)(productId);
if (encoding?.status === 'encoded') {
return {
alreadyEncoded: true,
source: 'encoded-request',
model: null,
rows: dimensionsFromSavedRequest(productRecord, encoding),
};
}
if (encoding?.request) {
assertRetrySnapshotCurrent(productRecord, encoding);
const savedRows = dimensionsFromSavedRequest(productRecord, encoding);
if (hasCompleteSavedDimensions(savedRows)) {
return {
alreadyEncoded: false,
alreadyRequested: true,
source: 'saved-request',
model: null,
rows: savedRows,
};
}
const now = typeof options.now === 'function' ? options.now() : (options.now ?? Date.now());
if (!isRetryableEncodingRecord(encoding, now)) {
throw dimensionError(
'该商品的编码请求仍在处理中,暂不能重新解析 SKU 尺寸',
409,
'ALIBABA_1688_ENCODING_IN_PROGRESS'
);
}
}
const config = await (options.getConfig || getSelectionFissionAiConfig)();
if (!config.enabled) {
throw dimensionError('选品AI分析模型未启用', 409, 'SELECTION_AI_DISABLED');
}
if (!config.apiKey) {
throw dimensionError('选品AI分析模型尚未配置API Key', 409, 'SELECTION_AI_API_KEY_MISSING');
}
const rows = buildAnalysisRows(productRecord);
const sourceSkuFingerprint = buildAlibaba1688SkuFingerprint(productRecord.product?.skus);
const chunks = [];
for (let offset = 0; offset < rows.length; offset += MAX_SKUS_PER_REQUEST) {
chunks.push(rows.slice(offset, offset + MAX_SKUS_PER_REQUEST));
}
const requestChunk = options.requestChunk || requestDimensionChunk;
const chunkResults = await mapWithConcurrency(
chunks,
Number(options.concurrency) || REQUEST_CONCURRENCY,
(chunk) => requestChunk(config, buildProductContext(productRecord, chunk), chunk, options)
);
const usage = chunkResults.reduce((total, result) => ({
promptTokens: total.promptTokens + result.usage.promptTokens,
completionTokens: total.completionTokens + result.usage.completionTokens,
totalTokens: total.totalTokens + result.usage.totalTokens,
}), { promptTokens: 0, completionTokens: 0, totalTokens: 0 });
const latestProduct = await (
options.getLatestProduct || options.getProduct || getAlibaba1688Product
)(productId);
const latestFingerprint = buildAlibaba1688SkuFingerprint(latestProduct.product?.skus);
if (!sourceSkuFingerprint || latestFingerprint !== sourceSkuFingerprint) {
throw dimensionError(
'智能解析期间商品SKU发生变化,请关闭窗口后重新加载',
409,
'ALIBABA_1688_SKU_CHANGED_DURING_AI_ANALYSIS'
);
}
return {
alreadyEncoded: false,
alreadyRequested: Boolean(encoding?.request),
source: 'qwen',
model: SKU_DIMENSION_MODEL,
analyzedAt: new Date().toISOString(),
sourceSkuFingerprint,
skuCount: rows.length,
batchCount: chunks.length,
rows: chunkResults.flatMap((result) => result.rows).map((row) => ({
sourceSkuKey: row.sourceSkuKey,
sourceIndex: row.sourceIndex,
lengthCm: row.lengthCm,
widthCm: row.widthCm,
heightCm: row.heightCm,
confidence: row.confidence,
evidence: row.evidence,
})),
usage,
};
}
module.exports = {
SKU_DIMENSION_MODEL,
MAX_SKUS_PER_REQUEST,
MAX_MODEL_REQUEST_TIMEOUT_MS,
DIMENSION_PROMPT,
analyzeAlibaba1688SkuDimensions,
assertExplicitDimensionsPreserved,
buildAnalysisRows,
buildDimensionAnalysisAttributes,
dimensionsFromSavedRequest,
extractExplicitDimensionValues,
hasCompleteSavedDimensions,
assertRetrySnapshotCurrent,
isRetryableEncodingRecord,
normalizeDimensionPayload,
parseFirstJsonObject,
requestDimensionChunk,
};