284 lines
10 KiB
JavaScript
284 lines
10 KiB
JavaScript
'use strict';
|
|
|
|
const crypto = require('crypto');
|
|
const planningDatabase = require('./planningDatabase');
|
|
const { getSelectionFissionAiConfig } = require('./selectionFissionAiSettings');
|
|
const {
|
|
SKU_DIMENSION_MODEL,
|
|
requestDimensionChunk,
|
|
} = require('./alibaba1688SkuDimensionAiService');
|
|
|
|
const CACHE_TABLE = 'planning_selection_1688_size_cache';
|
|
const CACHE_TTL_HOURS = 24;
|
|
const MAX_PRODUCTS = 50;
|
|
const MAX_SKUS_PER_REQUEST = 30;
|
|
|
|
let schemaReady = false;
|
|
let schemaPromise = null;
|
|
|
|
function serviceError(message, statusCode = 400, code = 'INVALID_REQUEST') {
|
|
const error = new Error(message);
|
|
error.status = statusCode;
|
|
error.statusCode = statusCode;
|
|
error.code = code;
|
|
return error;
|
|
}
|
|
|
|
function normalizeText(value, maxLength = 1000) {
|
|
return String(value ?? '').trim().slice(0, maxLength);
|
|
}
|
|
|
|
function normalizeId(value, label) {
|
|
const text = normalizeText(value, 120);
|
|
if (!text || !/^[A-Za-z0-9_-]+$/.test(text)) {
|
|
throw serviceError(`${label}格式不正确`, 400, 'ALIBABA_1688_SIZE_INVALID_ID');
|
|
}
|
|
return text;
|
|
}
|
|
|
|
function formatDimension(value) {
|
|
const number = Number(value);
|
|
if (!Number.isFinite(number)) return '';
|
|
return Number.isInteger(number) ? String(number) : number.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
|
|
}
|
|
|
|
function buildSizeKey(lengthCm, widthCm, heightCm) {
|
|
return [lengthCm, widthCm, heightCm]
|
|
.map(Number)
|
|
.sort((left, right) => right - left)
|
|
.map((value) => Math.round(value * 10) / 10)
|
|
.join('x');
|
|
}
|
|
|
|
function buildSizeLabel(lengthCm, widthCm, heightCm) {
|
|
return `${formatDimension(lengthCm)} x ${formatDimension(widthCm)} x ${formatDimension(heightCm)} cm`;
|
|
}
|
|
|
|
function buildSourceHash(product) {
|
|
return crypto.createHash('sha256').update(JSON.stringify({
|
|
productId: product.productId,
|
|
skuId: product.skuId,
|
|
title: product.title,
|
|
skuTitle: product.skuTitle,
|
|
industryName: product.industryName,
|
|
})).digest('hex');
|
|
}
|
|
|
|
function normalizeProducts(products) {
|
|
if (!Array.isArray(products) || products.length === 0) {
|
|
throw serviceError('没有可识别的1688相似商品', 400, 'ALIBABA_1688_SIZE_PRODUCTS_REQUIRED');
|
|
}
|
|
if (products.length > MAX_PRODUCTS) {
|
|
throw serviceError(`单次最多识别 ${MAX_PRODUCTS} 个1688相似商品`, 413, 'ALIBABA_1688_SIZE_PRODUCT_LIMIT');
|
|
}
|
|
return products.map((product, index) => {
|
|
const productId = normalizeId(product?.itemId || product?.productId, '1688商品ID');
|
|
const skuId = normalizeText(product?.skuId, 120) || `default-${index + 1}`;
|
|
const row = {
|
|
productId,
|
|
skuId,
|
|
title: normalizeText(product?.title, 1000),
|
|
skuTitle: normalizeText(product?.skuTitle, 1000),
|
|
industryName: normalizeText(product?.industryName, 300),
|
|
};
|
|
return { ...row, sourceHash: buildSourceHash(row) };
|
|
});
|
|
}
|
|
|
|
async function ensureSchema() {
|
|
if (schemaReady) return;
|
|
if (schemaPromise) return schemaPromise;
|
|
schemaPromise = planningDatabase.execute(
|
|
`CREATE TABLE IF NOT EXISTS ${CACHE_TABLE} (
|
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
|
plan_id BIGINT NOT NULL,
|
|
plan_item_id BIGINT NOT NULL,
|
|
product_id VARCHAR(120) NOT NULL,
|
|
sku_id VARCHAR(120) NOT NULL,
|
|
source_hash CHAR(64) NOT NULL,
|
|
sku_title VARCHAR(1000) NULL,
|
|
length_cm DECIMAL(12,2) NOT NULL,
|
|
width_cm DECIMAL(12,2) NOT NULL,
|
|
height_cm DECIMAL(12,2) NOT NULL,
|
|
size_key VARCHAR(120) NOT NULL,
|
|
confidence VARCHAR(20) NOT NULL DEFAULT 'medium',
|
|
evidence VARCHAR(500) NULL,
|
|
ai_model VARCHAR(120) NULL,
|
|
expires_at DATETIME NOT NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
UNIQUE KEY uk_selection_1688_size_cache (plan_item_id, product_id, sku_id, source_hash),
|
|
KEY idx_selection_1688_size_expiry (expires_at),
|
|
KEY idx_selection_1688_size_filter (plan_item_id, size_key, expires_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='1688相似款SKU尺寸临时识别缓存'`
|
|
).then(() => {
|
|
schemaReady = true;
|
|
}).catch((error) => {
|
|
schemaPromise = null;
|
|
throw error;
|
|
});
|
|
return schemaPromise;
|
|
}
|
|
|
|
function mapCacheRow(row) {
|
|
return {
|
|
productId: String(row.product_id),
|
|
skuId: String(row.sku_id),
|
|
sourceHash: String(row.source_hash),
|
|
skuTitle: row.sku_title || '',
|
|
lengthCm: Number(row.length_cm),
|
|
widthCm: Number(row.width_cm),
|
|
heightCm: Number(row.height_cm),
|
|
sizeKey: row.size_key,
|
|
sizeLabel: buildSizeLabel(row.length_cm, row.width_cm, row.height_cm),
|
|
confidence: row.confidence || 'medium',
|
|
evidence: row.evidence || '',
|
|
model: row.ai_model || null,
|
|
expiresAt: row.expires_at,
|
|
};
|
|
}
|
|
|
|
async function readCachedRows(planItemId, products) {
|
|
const productIds = [...new Set(products.map((product) => product.productId))];
|
|
const placeholders = productIds.map(() => '?').join(', ');
|
|
const rows = await planningDatabase.query(
|
|
`SELECT * FROM ${CACHE_TABLE}
|
|
WHERE plan_item_id = ? AND product_id IN (${placeholders}) AND expires_at > NOW()`,
|
|
[planItemId, ...productIds]
|
|
);
|
|
const expected = new Set(products.map((product) => (
|
|
`${product.productId}:${product.skuId}:${product.sourceHash}`
|
|
)));
|
|
return rows.map(mapCacheRow).filter((row) => (
|
|
expected.has(`${row.productId}:${row.skuId}:${row.sourceHash}`)
|
|
));
|
|
}
|
|
|
|
async function saveRows(planId, planItemId, rows) {
|
|
for (const row of rows) {
|
|
await planningDatabase.execute(
|
|
`INSERT INTO ${CACHE_TABLE} (
|
|
plan_id, plan_item_id, product_id, sku_id, source_hash, sku_title,
|
|
length_cm, width_cm, height_cm, size_key, confidence, evidence, ai_model, expires_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL ${CACHE_TTL_HOURS} HOUR))
|
|
ON DUPLICATE KEY UPDATE
|
|
sku_title = VALUES(sku_title), length_cm = VALUES(length_cm), width_cm = VALUES(width_cm),
|
|
height_cm = VALUES(height_cm), size_key = VALUES(size_key), confidence = VALUES(confidence),
|
|
evidence = VALUES(evidence), ai_model = VALUES(ai_model), expires_at = VALUES(expires_at),
|
|
updated_at = NOW()`,
|
|
[
|
|
planId, planItemId, row.productId, row.skuId, row.sourceHash, row.skuTitle,
|
|
row.lengthCm, row.widthCm, row.heightCm, row.sizeKey, row.confidence,
|
|
row.evidence, row.model,
|
|
]
|
|
);
|
|
}
|
|
}
|
|
|
|
function buildAnalysisRow(product, index) {
|
|
return {
|
|
skuRef: `S${index + 1}`,
|
|
sourceSkuKey: `${product.productId}:${product.skuId}`,
|
|
sourceIndex: index + 1,
|
|
skuName: product.skuTitle || product.title,
|
|
specs: [
|
|
{ name: '商品标题', value: product.title },
|
|
{ name: 'SKU规格', value: product.skuTitle || '当前搜索结果默认SKU' },
|
|
{ name: '行业类目', value: product.industryName },
|
|
].filter((row) => row.value),
|
|
product,
|
|
};
|
|
}
|
|
|
|
async function analyzeMissingProducts(products, options = {}) {
|
|
const config = await (options.getConfig || getSelectionFissionAiConfig)();
|
|
if (!config.enabled) throw serviceError('选品AI分析模型未启用', 409, 'SELECTION_AI_DISABLED');
|
|
if (!config.apiKey) throw serviceError('选品AI分析模型尚未配置API Key', 409, 'SELECTION_AI_API_KEY_MISSING');
|
|
|
|
const analysisRows = products.map(buildAnalysisRow);
|
|
const chunks = [];
|
|
for (let offset = 0; offset < analysisRows.length; offset += MAX_SKUS_PER_REQUEST) {
|
|
chunks.push(analysisRows.slice(offset, offset + MAX_SKUS_PER_REQUEST));
|
|
}
|
|
const requestChunk = options.requestChunk || requestDimensionChunk;
|
|
const resolved = [];
|
|
for (const chunk of chunks) {
|
|
const result = await requestChunk(config, {
|
|
purpose: '1688相似款同尺寸筛选比价',
|
|
products: chunk.map((row) => ({
|
|
skuRef: row.skuRef,
|
|
productId: row.product.productId,
|
|
title: row.product.title,
|
|
skuTitle: row.product.skuTitle,
|
|
industryName: row.product.industryName,
|
|
})),
|
|
}, chunk, options);
|
|
for (const row of result.rows) {
|
|
const source = chunk.find((candidate) => candidate.skuRef === row.skuRef);
|
|
resolved.push({
|
|
...source.product,
|
|
lengthCm: row.lengthCm,
|
|
widthCm: row.widthCm,
|
|
heightCm: row.heightCm,
|
|
sizeKey: buildSizeKey(row.lengthCm, row.widthCm, row.heightCm),
|
|
sizeLabel: buildSizeLabel(row.lengthCm, row.widthCm, row.heightCm),
|
|
confidence: row.confidence,
|
|
evidence: row.evidence,
|
|
model: SKU_DIMENSION_MODEL,
|
|
});
|
|
}
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
async function analyzeAlibaba1688SimilarProductSizes(planId, planItemId, input = {}, options = {}) {
|
|
const normalizedPlanId = Number(planId);
|
|
const normalizedPlanItemId = Number(planItemId);
|
|
if (!Number.isInteger(normalizedPlanId) || normalizedPlanId <= 0
|
|
|| !Number.isInteger(normalizedPlanItemId) || normalizedPlanItemId <= 0) {
|
|
throw serviceError('选品计划或商品节点ID不正确', 400, 'ALIBABA_1688_SIZE_PLAN_REQUIRED');
|
|
}
|
|
const products = normalizeProducts(input.products);
|
|
const planItemExists = options.verifyPlanItem
|
|
? await options.verifyPlanItem(normalizedPlanId, normalizedPlanItemId)
|
|
: (await planningDatabase.query(
|
|
`SELECT id FROM planning_selection_fission_plan_items WHERE id = ? AND plan_id = ? LIMIT 1`,
|
|
[normalizedPlanItemId, normalizedPlanId]
|
|
)).length > 0;
|
|
if (!planItemExists) {
|
|
throw serviceError('选品计划中的商品节点不存在', 404, 'ALIBABA_1688_SIZE_PLAN_ITEM_NOT_FOUND');
|
|
}
|
|
await (options.ensureSchema || ensureSchema)();
|
|
await (options.cleanupExpired || (() => planningDatabase.execute(
|
|
`DELETE FROM ${CACHE_TABLE} WHERE expires_at <= NOW()`
|
|
)))();
|
|
const cachedRows = await (options.readCachedRows || readCachedRows)(normalizedPlanItemId, products);
|
|
const cachedKeys = new Set(cachedRows.map((row) => `${row.productId}:${row.skuId}:${row.sourceHash}`));
|
|
const missing = products.filter((row) => !cachedKeys.has(`${row.productId}:${row.skuId}:${row.sourceHash}`));
|
|
const analyzedRows = missing.length ? await analyzeMissingProducts(missing, options) : [];
|
|
if (analyzedRows.length) {
|
|
await (options.saveRows || saveRows)(normalizedPlanId, normalizedPlanItemId, analyzedRows);
|
|
}
|
|
const rows = [...cachedRows, ...analyzedRows];
|
|
return {
|
|
rows,
|
|
cachedCount: cachedRows.length,
|
|
analyzedCount: analyzedRows.length,
|
|
expiresInHours: CACHE_TTL_HOURS,
|
|
model: analyzedRows.length ? SKU_DIMENSION_MODEL : null,
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
CACHE_TTL_HOURS,
|
|
analyzeAlibaba1688SimilarProductSizes,
|
|
buildSizeKey,
|
|
buildSizeLabel,
|
|
normalizeProducts,
|
|
_test: {
|
|
analyzeMissingProducts,
|
|
buildSourceHash,
|
|
mapCacheRow,
|
|
},
|
|
};
|