const crypto = require('crypto'); const planningDatabase = require('./planningDatabase'); const { ensurePlanningAiImageSchema } = require('./planningSupportSchema'); const SPACE_ENUM = ['客厅', '卧室', '餐厅', '书房', '办公室', '阳台', '户外', '其他']; const SCENE_ENUM = ['家用', '办公', '商用', '酒店民宿', '其他']; const STYLE_ENUM = ['现代简约', '中式', '日式', '北欧', '轻奢', '工业风', '复古', '其他']; const APPEARANCE_SIMILARITY_CODE_COLUMN = 'appearance_similarity_code'; function firstText(value) { if (value === null || value === undefined) { return ''; } return String(value) .trim() .replace(/[,,]/g, '、') .split(/[、/|;;]/)[0] .trim(); } function includesAny(text, keywords = []) { return keywords.some(keyword => text.includes(keyword)); } function normalizeCategoryTag(value, fallbackCategoryName = null) { const text = normalizeText(value || fallbackCategoryName, 100); if (!text) { return '其他'; } if (includesAny(text, ['边几', '角几', '边桌', '角桌'])) { return '边几'; } if (includesAny(text, ['茶几'])) { return '茶几'; } if (includesAny(text, ['床头柜'])) { return '床头柜'; } if (includesAny(text, ['书桌', '办公桌'])) { return '书桌'; } if (includesAny(text, ['餐桌'])) { return '餐桌'; } if (includesAny(text, ['椅', '凳'])) { return '椅凳'; } if (includesAny(text, ['柜', '收纳'])) { return '柜类'; } return firstText(text) || '其他'; } function normalizeSpaceTag(value) { const text = normalizeText(value, 100); if (!text) { return '其他'; } if (includesAny(text, ['客厅', '沙发旁', '会客'])) { return '客厅'; } if (includesAny(text, ['卧室', '床边', '床头'])) { return '卧室'; } if (includesAny(text, ['餐厅', '餐桌'])) { return '餐厅'; } if (includesAny(text, ['书房', '阅读'])) { return '书房'; } if (includesAny(text, ['办公室', '办公区', '工位', '会议室'])) { return '办公室'; } if (includesAny(text, ['阳台', '露台'])) { return '阳台'; } if (includesAny(text, ['户外', '庭院', '花园'])) { return '户外'; } return SPACE_ENUM.includes(text) ? text : '其他'; } function normalizeSceneTag(value) { const text = normalizeText(value, 100); if (!text) { return '其他'; } if (includesAny(text, ['家用', '居家', '日常', '家庭'])) { return '家用'; } if (includesAny(text, ['办公', '工位', '会议'])) { return '办公'; } if (includesAny(text, ['商用', '门店', '展厅', '店铺'])) { return '商用'; } if (includesAny(text, ['酒店', '民宿', '客房'])) { return '酒店民宿'; } return SCENE_ENUM.includes(text) ? text : '其他'; } function normalizeStyleTag(value) { const text = normalizeText(value, 100); if (!text) { return '其他'; } if (includesAny(text, ['现代简约', '简约现代', '现代'])) { return '现代简约'; } if (includesAny(text, ['中式', '新中式'])) { return '中式'; } if (includesAny(text, ['日式', '原木风', 'muji'])) { return '日式'; } if (includesAny(text, ['北欧'])) { return '北欧'; } if (includesAny(text, ['轻奢'])) { return '轻奢'; } if (includesAny(text, ['工业风', '工业'])) { return '工业风'; } if (includesAny(text, ['复古', '怀旧'])) { return '复古'; } return STYLE_ENUM.includes(text) ? text : '其他'; } function normalizeColorTag(value) { const text = normalizeText(value, 100); if (!text) { return '其他'; } if (includesAny(text, ['胡桃', '黑胡桃'])) { return '胡桃色'; } if (includesAny(text, ['原木', '浅木', '木纹', '橡木', '榉木'])) { return '原木色'; } if (includesAny(text, ['白', '米白', '奶白', '暖白', '象牙白'])) { return '白色'; } if (includesAny(text, ['黑', '雅黑'])) { return '黑色'; } if (includesAny(text, ['灰', '高级灰'])) { return '灰色'; } if (includesAny(text, ['棕', '咖', '栗'])) { return '棕色'; } if (includesAny(text, ['金'])) { return '金色'; } if (includesAny(text, ['银'])) { return '银色'; } return firstText(text) || '其他'; } function normalizeMaterialTag(value) { const text = normalizeText(value, 100); if (!text) { return '其他'; } if (includesAny(text, ['金属', '铁', '钢', '铝', '合金'])) { if (includesAny(text, ['木', '板', '密度板', '颗粒板'])) { return '金属+木质'; } return '金属'; } if (includesAny(text, ['实木', '木质', '木材', '板材', '密度板', '颗粒板', '木板'])) { return '木质'; } if (includesAny(text, ['玻璃'])) { return '玻璃'; } if (includesAny(text, ['岩板', '石', '大理石', '石材'])) { return '石材'; } if (includesAny(text, ['塑料', 'pp', 'abs', '树脂'])) { return '塑料'; } if (includesAny(text, ['布', '皮', '绒'])) { return '软包'; } return firstText(text) || '其他'; } function buildNormalizedTags(rawTags = {}, fallback = {}) { return { category: normalizeCategoryTag(rawTags.category || fallback.category, fallback.categoryName), space: normalizeSpaceTag(rawTags.space || fallback.space), scene: normalizeSceneTag(rawTags.scene || fallback.scene), style: normalizeStyleTag(rawTags.style || fallback.style), color: normalizeColorTag(rawTags.color || fallback.color), material: normalizeMaterialTag(rawTags.material || fallback.material), }; } function safeJsonStringify(value) { if (value === null || value === undefined) { return null; } return typeof value === 'string' ? value : JSON.stringify(value); } function safeJsonParse(value) { if (!value) { return {}; } if (typeof value === 'object') { return value; } try { return JSON.parse(value); } catch (error) { return {}; } } function normalizeText(value, maxLength = null) { if (value === null || value === undefined) { return null; } const text = String(value).trim(); if (!text) { return null; } return maxLength ? text.slice(0, maxLength) : text; } function normalizeInteger(value) { if (value === null || value === undefined || value === '') { return null; } const numeric = Number(value); return Number.isFinite(numeric) ? Math.round(numeric) : null; } function buildImageHash({ imageUrl, sourceSystem, sourceTable, sourceId, skuId, goodsId }) { const base = [ normalizeText(imageUrl) || '', normalizeText(sourceSystem) || '', normalizeText(sourceTable) || '', normalizeText(sourceId) || '', normalizeText(skuId) || '', normalizeText(goodsId) || '', ].join('|'); if (!base.replace(/\|/g, '')) { return null; } return crypto.createHash('sha256').update(base).digest('hex'); } function normalizeFeatureRecord(input = {}) { const rawTags = input.rawTags || input.rawTagsJson || {}; const normalizedTags = buildNormalizedTags( safeJsonParse(rawTags), { category: input.categoryTag, space: input.spaceTag, scene: input.sceneTag, style: input.styleTag, color: input.colorTag, material: input.materialTag, categoryName: input.categoryName, } ); const imageHash = normalizeText(input.imageHash, 64) || buildImageHash({ imageUrl: input.imageUrl, sourceSystem: input.sourceSystem, sourceTable: input.sourceTable, sourceId: input.sourceId, skuId: input.skuId, goodsId: input.goodsId, }); return { biz_type: normalizeText(input.bizType, 30) || 'internal', source_system: normalizeText(input.sourceSystem, 50), source_table: normalizeText(input.sourceTable, 100), source_id: normalizeText(input.sourceId, 100), sku_id: normalizeText(input.skuId, 100), goods_id: normalizeText(input.goodsId, 100), product_name: normalizeText(input.productName, 500), category_name: normalizeText(input.categoryName, 200), appearance_similarity_code: normalizeText(input.appearanceSimilarityCode || input.appearance_similarity_code, 160), image_url: normalizeText(input.imageUrl, 1000), image_hash: imageHash, image_status: normalizeText(input.imageStatus, 20) || 'success', raw_tags_json: safeJsonStringify(rawTags), normalized_tags_json: safeJsonStringify(normalizedTags), category_tag: normalizeText(normalizedTags.category, 100), space_tag: normalizeText(normalizedTags.space, 50), scene_tag: normalizeText(normalizedTags.scene, 50), style_tag: normalizeText(normalizedTags.style, 100), color_tag: normalizeText(normalizedTags.color, 100), material_tag: normalizeText(normalizedTags.material, 100), vision_provider: normalizeText(input.visionProvider, 50), vision_model: normalizeText(input.visionModel, 100), vision_prompt_version: normalizeText(input.visionPromptVersion, 50), vision_prompt_tokens: normalizeInteger(input.visionPromptTokens), vision_completion_tokens: normalizeInteger(input.visionCompletionTokens), vision_total_tokens: normalizeInteger(input.visionTotalTokens), embedding_provider: normalizeText(input.embeddingProvider, 50), embedding_model: normalizeText(input.embeddingModel, 100), embedding_vector_json: safeJsonStringify(input.embeddingVector || input.embeddingVectorJson), embedding_dimension: normalizeInteger(input.embeddingDimension), embedding_prompt_tokens: normalizeInteger(input.embeddingPromptTokens), embedding_total_tokens: normalizeInteger(input.embeddingTotalTokens), processed_at: input.processedAt ? new Date(input.processedAt) : new Date(), error_message: normalizeText(input.errorMessage, 2000), }; } async function upsertAiImageFeature(record) { await ensurePlanningAiImageSchema(); const payload = normalizeFeatureRecord(record); if (!payload.image_url) { throw new Error('imageUrl 不能为空'); } if (!payload.source_id && !payload.image_hash) { throw new Error('sourceId 和 imageHash 至少需要一个'); } const sql = ` INSERT INTO ai_image_feature_library ( biz_type, source_system, source_table, source_id, sku_id, goods_id, product_name, category_name, appearance_similarity_code, image_url, image_hash, image_status, raw_tags_json, normalized_tags_json, category_tag, space_tag, scene_tag, style_tag, color_tag, material_tag, vision_provider, vision_model, vision_prompt_version, vision_prompt_tokens, vision_completion_tokens, vision_total_tokens, embedding_provider, embedding_model, embedding_vector_json, embedding_dimension, embedding_prompt_tokens, embedding_total_tokens, processed_at, error_message ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) ON DUPLICATE KEY UPDATE sku_id = VALUES(sku_id), goods_id = VALUES(goods_id), product_name = VALUES(product_name), category_name = VALUES(category_name), appearance_similarity_code = COALESCE(VALUES(appearance_similarity_code), appearance_similarity_code), image_url = VALUES(image_url), image_status = VALUES(image_status), raw_tags_json = VALUES(raw_tags_json), normalized_tags_json = VALUES(normalized_tags_json), category_tag = VALUES(category_tag), space_tag = VALUES(space_tag), scene_tag = VALUES(scene_tag), style_tag = VALUES(style_tag), color_tag = VALUES(color_tag), material_tag = VALUES(material_tag), vision_provider = VALUES(vision_provider), vision_model = VALUES(vision_model), vision_prompt_version = VALUES(vision_prompt_version), vision_prompt_tokens = VALUES(vision_prompt_tokens), vision_completion_tokens = VALUES(vision_completion_tokens), vision_total_tokens = VALUES(vision_total_tokens), embedding_provider = VALUES(embedding_provider), embedding_model = VALUES(embedding_model), embedding_vector_json = VALUES(embedding_vector_json), embedding_dimension = VALUES(embedding_dimension), embedding_prompt_tokens = VALUES(embedding_prompt_tokens), embedding_total_tokens = VALUES(embedding_total_tokens), processed_at = VALUES(processed_at), error_message = VALUES(error_message), updated_at = CURRENT_TIMESTAMP `; const params = [ payload.biz_type, payload.source_system, payload.source_table, payload.source_id, payload.sku_id, payload.goods_id, payload.product_name, payload.category_name, payload.appearance_similarity_code, payload.image_url, payload.image_hash, payload.image_status, payload.raw_tags_json, payload.normalized_tags_json, payload.category_tag, payload.space_tag, payload.scene_tag, payload.style_tag, payload.color_tag, payload.material_tag, payload.vision_provider, payload.vision_model, payload.vision_prompt_version, payload.vision_prompt_tokens, payload.vision_completion_tokens, payload.vision_total_tokens, payload.embedding_provider, payload.embedding_model, payload.embedding_vector_json, payload.embedding_dimension, payload.embedding_prompt_tokens, payload.embedding_total_tokens, payload.processed_at, payload.error_message, ]; const result = await planningDatabase.execute(sql, params); return { affectedRows: result?.affectedRows || 0, imageHash: payload.image_hash, sourceId: payload.source_id, }; } async function batchUpsertAiImageFeatures(records = []) { const results = []; for (const record of records) { results.push(await upsertAiImageFeature(record)); } return results; } module.exports = { APPEARANCE_SIMILARITY_CODE_COLUMN, buildNormalizedTags, buildImageHash, normalizeFeatureRecord, upsertAiImageFeature, batchUpsertAiImageFeatures, };