cangku/backend/src/services/alibaba1688SimilarSizeService.js

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