'use strict'; const axios = require('axios'); const crypto = require('crypto'); const database = require('../config/database'); const planningDatabase = require('./planningDatabase'); const { buildImageHash, buildNormalizedTags } = require('./aiImageFeatureLibrary'); const { getDefaultS3Config, uploadBase64ImageToDefaultS3 } = require('./objectStorage'); const { ensurePlanningAiImageSchema } = require('./planningSupportSchema'); const ARK_BASE_URL = process.env.ARK_BASE_URL || 'https://ark.cn-beijing.volces.com/api/v3'; const VISION_MODEL = process.env.VISION_MODEL || 'doubao-1-5-vision-pro-32k-250115'; const EMBEDDING_MODEL = process.env.EMBEDDING_MODEL || 'doubao-embedding-vision-250615'; const DEFAULT_THRESHOLD = 0.5; const DEFAULT_LIMIT = 20; const VISION_PROMPT = [ '请识别这张商品图,并严格返回 JSON 对象。', '强约束:', '1. 只返回以下 6 个字段:category, space, scene, style, color, material', '2. 不要返回任何其他字段', '3. 所有字段值必须使用中文', '4. 不要返回英文', '5. 不要解释,不要补充说明,不要输出 markdown', '6. 如无法判断,返回“其他”', '字段要求:', '- category:商品类目,使用中文短语', '- space:只能从以下值中选择一个:客厅、卧室、餐厅、书房、办公室、阳台、户外、其他', '- scene:只能从以下值中选择一个:家用、办公、商用、酒店民宿、其他', '- style:款式风格,使用中文短语', '- color:主要颜色,使用中文短语', '- material:主要材质,使用中文短语', ].join('\n'); function getArkApiKey() { const apiKey = (process.env.ARK_API_KEY || '').trim(); if (!apiKey) { const error = new Error('未配置 ARK_API_KEY,无法执行 AI 图片检索'); error.statusCode = 500; throw error; } return apiKey; } function safeJsonParse(value, fallback = null) { if (value === null || value === undefined || value === '') { return fallback; } if (typeof value === 'object') { return value; } try { return JSON.parse(value); } catch (error) { return fallback; } } function toNullableText(value, maxLength = 1000) { if (value === null || value === undefined) { return null; } const text = String(value).trim(); if (!text) { return null; } return text.slice(0, maxLength); } function normalizeSearchResultItem(item = {}) { const similarity = Number(item.similarity); const finalScore = Number(item.finalScore); const similarityPercent = Number.isFinite(Number(item.similarityPercent)) ? Number(item.similarityPercent) : (Number.isFinite(similarity) ? Number((similarity * 100).toFixed(2)) : 0); const finalScorePercent = Number.isFinite(Number(item.finalScorePercent)) ? Number(item.finalScorePercent) : (Number.isFinite(finalScore) ? Number((finalScore * 100).toFixed(2)) : similarityPercent); const originalSimilarityPercent = Number.isFinite(Number(item.originalSimilarityPercent)) ? Number(item.originalSimilarityPercent) : similarityPercent; const tagBonusPercent = Number.isFinite(Number(item.tagBonusPercent)) ? Number(item.tagBonusPercent) : Number((finalScorePercent - originalSimilarityPercent).toFixed(2)); return { ...item, similarity: Number.isFinite(similarity) ? similarity : originalSimilarityPercent / 100, finalScore: Number.isFinite(finalScore) ? finalScore : finalScorePercent / 100, similarityPercent, originalSimilarityPercent, tagBonusPercent, finalScorePercent, }; } function cosineSimilarity(a, b) { if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length || !a.length) { return -1; } let dot = 0; let normA = 0; let normB = 0; for (let i = 0; i < a.length; i += 1) { const x = Number(a[i]) || 0; const y = Number(b[i]) || 0; dot += x * y; normA += x * x; normB += y * y; } if (!normA || !normB) { return -1; } return dot / (Math.sqrt(normA) * Math.sqrt(normB)); } function calcTagBonus(queryTags, candidate) { let bonus = 0; if (candidate.category_tag && candidate.category_tag === queryTags.category) { bonus += 0.03; } if (candidate.color_tag && candidate.color_tag === queryTags.color) { bonus += 0.02; } if (candidate.material_tag && candidate.material_tag === queryTags.material) { bonus += 0.015; } if (candidate.style_tag && candidate.style_tag === queryTags.style) { bonus += 0.01; } if (candidate.space_tag && candidate.space_tag === queryTags.space) { bonus += 0.005; } return bonus; } async function callVision(imageUrl) { const response = await axios.post( `${ARK_BASE_URL}/chat/completions`, { model: VISION_MODEL, messages: [ { role: 'user', content: [ { type: 'text', text: VISION_PROMPT }, { type: 'image_url', image_url: { url: imageUrl } }, ], }, ], max_tokens: 300, temperature: 0.1, response_format: { type: 'json_object' }, }, { headers: { Authorization: `Bearer ${getArkApiKey()}`, 'Content-Type': 'application/json', }, timeout: 120000, } ); return { rawTags: safeJsonParse(response.data?.choices?.[0]?.message?.content, {}) || {}, usage: response.data?.usage || {}, model: response.data?.model || VISION_MODEL, }; } async function callEmbedding(imageUrl) { const response = await axios.post( `${ARK_BASE_URL}/embeddings/multimodal`, { model: EMBEDDING_MODEL, input: [ { type: 'text', text: '只关注商品主体外观进行向量化,尽量忽略背景、模特、文字、水印、边框和详情页装饰。相同商品在不同背景下应尽量接近。' }, { type: 'image_url', image_url: { url: imageUrl } }, ], }, { headers: { Authorization: `Bearer ${getArkApiKey()}`, 'Content-Type': 'application/json', }, timeout: 120000, } ); const embeddingRow = Array.isArray(response.data?.data) ? response.data.data[0] : response.data?.data; return { vector: Array.isArray(embeddingRow?.embedding) ? embeddingRow.embedding : [], usage: response.data?.usage || {}, model: response.data?.model || EMBEDDING_MODEL, }; } async function resolveSearchImageSource({ imageUrl, imageBase64 }) { const normalizedUrl = toNullableText(imageUrl, 1000); if (normalizedUrl) { return { sourceType: 'url', imageForModel: normalizedUrl, persistedImageUrl: normalizedUrl, imageHash: buildImageHash({ imageUrl: normalizedUrl, sourceSystem: 'pdd', sourceTable: 'new_product_selection', sourceId: normalizedUrl }), }; } const normalizedBase64 = toNullableText(imageBase64, 10 * 1024 * 1024); if (!normalizedBase64 || !normalizedBase64.startsWith('data:image/')) { const error = new Error('请上传图片,或提供可访问的图片链接'); error.statusCode = 400; throw error; } let persistedImageUrl = null; try { const s3Config = await getDefaultS3Config(true); if (s3Config?.bucket && s3Config?.accessKeyId && s3Config?.secretAccessKey) { const uploadResult = await uploadBase64ImageToDefaultS3(normalizedBase64, 'pdd-new-product-selection'); persistedImageUrl = uploadResult.url; } } catch (error) { persistedImageUrl = null; } return { sourceType: 'upload', // 豆包对部分外链图片会返回 UnsupportedImageFormat,本地上传场景统一直接使用原始 data URL 给模型。 imageForModel: normalizedBase64, persistedImageUrl, imageHash: crypto.createHash('sha256').update(normalizedBase64).digest('hex'), }; } async function getPddNewProductSelectionCategories() { await ensurePlanningAiImageSchema(); const rows = await planningDatabase.query( ` SELECT category_name AS categoryName, category_tag AS categoryTag, COUNT(1) AS itemCount FROM ai_image_feature_library WHERE biz_type = 'internal' AND image_status = 'success' AND category_name IS NOT NULL AND category_name <> '' AND category_tag IS NOT NULL AND category_tag <> '' GROUP BY category_name, category_tag ORDER BY COUNT(1) DESC, category_name ASC, category_tag ASC ` ); const categoryMap = new Map(); for (const row of rows) { const categoryName = row.categoryName; const categoryTag = row.categoryTag; const itemCount = Number(row.itemCount || 0); if (!categoryMap.has(categoryName)) { categoryMap.set(categoryName, { categoryName, categoryTag, itemCount, totalCount: itemCount, }); continue; } const current = categoryMap.get(categoryName); current.totalCount += itemCount; if (itemCount > current.itemCount) { current.categoryTag = categoryTag; current.itemCount = itemCount; } } return Array.from(categoryMap.values()) .sort((a, b) => b.totalCount - a.totalCount || a.categoryName.localeCompare(b.categoryName, 'zh-CN')) .map((item) => ({ categoryName: item.categoryName, categoryTag: item.categoryTag, itemCount: item.totalCount, })); } async function getPddNewProductSelectionLibraryFilterOptions() { await ensurePlanningAiImageSchema(); const [ categoryRows, styleRows, colorRows, materialRows ] = await Promise.all([ planningDatabase.query( ` SELECT category_name AS categoryName, COUNT(1) AS itemCount FROM ai_image_feature_library WHERE biz_type = 'internal' AND image_status = 'success' AND category_name IS NOT NULL AND category_name <> '' GROUP BY category_name ORDER BY COUNT(1) DESC, category_name ASC ` ), planningDatabase.query( ` SELECT style_tag AS styleTag, COUNT(1) AS itemCount FROM ai_image_feature_library WHERE biz_type = 'internal' AND image_status = 'success' AND style_tag IS NOT NULL AND style_tag <> '' GROUP BY style_tag ORDER BY COUNT(1) DESC, style_tag ASC ` ), planningDatabase.query( ` SELECT color_tag AS colorTag, COUNT(1) AS itemCount FROM ai_image_feature_library WHERE biz_type = 'internal' AND image_status = 'success' AND color_tag IS NOT NULL AND color_tag <> '' GROUP BY color_tag ORDER BY COUNT(1) DESC, color_tag ASC ` ), planningDatabase.query( ` SELECT material_tag AS materialTag, COUNT(1) AS itemCount FROM ai_image_feature_library WHERE biz_type = 'internal' AND image_status = 'success' AND material_tag IS NOT NULL AND material_tag <> '' GROUP BY material_tag ORDER BY COUNT(1) DESC, material_tag ASC ` ), ]); return { categoryNames: categoryRows.map((row) => ({ value: row.categoryName, label: row.categoryName, itemCount: Number(row.itemCount || 0), })), styleTags: styleRows.map((row) => ({ value: row.styleTag, label: row.styleTag, itemCount: Number(row.itemCount || 0), })), colorTags: colorRows.map((row) => ({ value: row.colorTag, label: row.colorTag, itemCount: Number(row.itemCount || 0), })), materialTags: materialRows.map((row) => ({ value: row.materialTag, label: row.materialTag, itemCount: Number(row.itemCount || 0), })), }; } async function getPddNewProductSelectionLibraryList(params = {}) { await ensurePlanningAiImageSchema(); const page = Math.max(1, Number(params.page) || 1); const pageSize = Math.max(1, Math.min(Number(params.pageSize) || 20, 100)); const categoryName = toNullableText(params.categoryName, 200); const keyword = toNullableText(params.keyword, 200); const styleTag = toNullableText(params.styleTag, 100); const colorTag = toNullableText(params.colorTag, 100); const materialTag = toNullableText(params.materialTag, 100); const whereParts = [ "biz_type = 'internal'", "image_status = 'success'", ]; const bindParams = []; if (categoryName) { whereParts.push('category_name = ?'); bindParams.push(categoryName); } if (styleTag) { whereParts.push('style_tag = ?'); bindParams.push(styleTag); } if (colorTag) { whereParts.push('color_tag = ?'); bindParams.push(colorTag); } if (materialTag) { whereParts.push('material_tag = ?'); bindParams.push(materialTag); } if (keyword) { whereParts.push('(sku_id LIKE ? OR goods_id LIKE ? OR product_name LIKE ? OR category_tag LIKE ?)'); bindParams.push(`%${keyword}%`, `%${keyword}%`, `%${keyword}%`, `%${keyword}%`); } const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''; const totalRows = await planningDatabase.query( ` SELECT COUNT(1) AS total FROM ai_image_feature_library ${whereSql} `, bindParams ); const total = Number(totalRows[0]?.total || 0); const rows = total > 0 ? await planningDatabase.query( ` SELECT id, sku_id AS skuId, goods_id AS goodsId, product_name AS productName, category_name AS categoryName, category_tag AS categoryTag, space_tag AS spaceTag, scene_tag AS sceneTag, style_tag AS styleTag, color_tag AS colorTag, material_tag AS materialTag, image_url AS imageUrl, query_status FROM ( SELECT id, sku_id, goods_id, product_name, category_name, category_tag, space_tag, scene_tag, style_tag, color_tag, material_tag, image_url, image_status AS query_status FROM ai_image_feature_library ${whereSql} ORDER BY updated_at DESC, id DESC LIMIT ? OFFSET ? ) t `, [ ...bindParams, pageSize, (page - 1) * pageSize ] ) : []; return { page, pageSize, total, rows: rows.map((row) => ({ ...row, status: row.query_status, })), }; } async function getPddNewProductSelectionLogs(params = {}) { await ensurePlanningAiImageSchema(); const page = Math.max(1, Number(params.page) || 1); const pageSize = Math.max(1, Math.min(100, Number(params.pageSize) || Number(params.limit) || 10)); const offset = (page - 1) * pageSize; const operatorKeyword = toNullableText(params.operatorKeyword, 100); const sourceType = toNullableText(params.sourceType, 20); const selectedCategoryTag = toNullableText(params.selectedCategoryTag, 100); const status = toNullableText(params.status, 20); const keyword = toNullableText(params.keyword, 200); const whereParts = [ '1 = 1' ]; const queryParams = []; if (operatorKeyword) { whereParts.push('(created_by_name LIKE ? OR created_by_username LIKE ?)'); queryParams.push(`%${operatorKeyword}%`, `%${operatorKeyword}%`); } if (sourceType) { whereParts.push('source_type = ?'); queryParams.push(sourceType); } if (selectedCategoryTag) { whereParts.push('selected_category_tag = ?'); queryParams.push(selectedCategoryTag); } if (status) { whereParts.push('status = ?'); queryParams.push(status); } if (keyword) { whereParts.push('(biz_no LIKE ? OR source_input LIKE ?)'); queryParams.push(`%${keyword}%`, `%${keyword}%`); } const whereSql = `WHERE ${whereParts.join(' AND ')}`; const totalRows = await planningDatabase.query( ` SELECT COUNT(*) AS total FROM ai_image_search_logs ${whereSql} `, queryParams ); const rows = await planningDatabase.query( ` SELECT id, biz_no AS bizNo, source_type AS sourceType, source_input AS sourceInput, query_image_url AS queryImageUrl, created_by_name AS createdByName, created_by_username AS createdByUsername, selected_category_tag AS selectedCategoryTag, result_count AS resultCount, candidate_count AS candidateCount, similarity_threshold AS similarityThreshold, status, error_message AS errorMessage, created_at AS createdAt FROM ai_image_search_logs ${whereSql} ORDER BY id DESC LIMIT ? OFFSET ? `, [ ...queryParams, pageSize, offset ] ); return { page, pageSize, total: Number(totalRows?.[0]?.total || 0), rows: rows.map((row) => ({ id: row.id, bizNo: row.bizNo, sourceType: row.sourceType, sourceInput: row.sourceInput, queryImageUrl: row.queryImageUrl, createdByName: row.createdByName, createdByUsername: row.createdByUsername, selectedCategoryTag: row.selectedCategoryTag, resultCount: Number(row.resultCount || 0), candidateCount: Number(row.candidateCount || 0), similarityThreshold: Number(row.similarityThreshold || DEFAULT_THRESHOLD), status: row.status, errorMessage: row.errorMessage, createdAt: row.createdAt, })), }; } async function getPddNewProductSelectionLogDetail(logId) { await ensurePlanningAiImageSchema(); const rows = await planningDatabase.query( ` SELECT id, biz_no AS bizNo, source_type AS sourceType, source_input AS sourceInput, query_image_url AS queryImageUrl, created_by_name AS createdByName, selected_category_tag AS selectedCategoryTag, raw_tags_json AS rawTagsJson, normalized_tags_json AS normalizedTagsJson, result_count AS resultCount, candidate_count AS candidateCount, similarity_threshold AS similarityThreshold, result_snapshot_json AS resultSnapshotJson, embedding_dimension AS embeddingDimension, status, error_message AS errorMessage, response_ms AS responseMs, created_at AS createdAt FROM ai_image_search_logs WHERE id = ? LIMIT 1 `, [ logId ] ); const row = rows[0]; if (!row) { const error = new Error('未找到该条操作记录'); error.statusCode = 404; throw error; } return { id: row.id, bizNo: row.bizNo, sourceType: row.sourceType, sourceInput: row.sourceInput, queryImageUrl: row.queryImageUrl, createdByName: row.createdByName, selectedCategoryTag: row.selectedCategoryTag, rawTags: safeJsonParse(row.rawTagsJson, {}), normalizedTags: safeJsonParse(row.normalizedTagsJson, {}), resultCount: Number(row.resultCount || 0), candidateCount: Number(row.candidateCount || 0), similarityThreshold: Number(row.similarityThreshold || DEFAULT_THRESHOLD), results: (safeJsonParse(row.resultSnapshotJson, []) || []).map((item) => normalizeSearchResultItem(item)), embeddingDimension: Number(row.embeddingDimension || 0), status: row.status, errorMessage: row.errorMessage, responseMs: Number(row.responseMs || 0), createdAt: row.createdAt, }; } async function resolveOperator(userId) { const rows = await database.local.query( ` SELECT u.id, u.username, u.name, u.department_id AS departmentId, d.name AS departmentName FROM users u LEFT JOIN departments d ON u.department_id = d.id WHERE u.id = ? LIMIT 1 `, [ userId ] ); return rows[0] || null; } async function insertSearchLog(payload) { await ensurePlanningAiImageSchema(); const result = await planningDatabase.insert('ai_image_search_logs', payload); return result.insertId; } async function updateSearchLog(id, payload) { await ensurePlanningAiImageSchema(); await planningDatabase.update('ai_image_search_logs', id, payload); } async function searchPddNewProductSelection({ userId, imageUrl, imageBase64, selectedCategoryTag, similarityThreshold = DEFAULT_THRESHOLD, limit = DEFAULT_LIMIT, sourceInput }) { const operator = await resolveOperator(userId); if (!operator) { const error = new Error('当前用户不存在'); error.statusCode = 401; throw error; } const categoryTag = toNullableText(selectedCategoryTag, 100); if (!categoryTag) { const error = new Error('请选择类目'); error.statusCode = 400; throw error; } const bizNo = crypto.randomUUID(); const startedAt = Date.now(); const resolvedSource = await resolveSearchImageSource({ imageUrl, imageBase64 }); const logId = await insertSearchLog({ biz_no: bizNo, source_type: resolvedSource.sourceType, source_input: toNullableText(sourceInput || imageUrl || (resolvedSource.sourceType === 'upload' ? '本地上传图片' : ''), 1000), query_image_url: toNullableText(resolvedSource.persistedImageUrl || imageUrl, 1000), query_image_hash: resolvedSource.imageHash, selected_category_tag: categoryTag, similarity_threshold: Number(similarityThreshold || DEFAULT_THRESHOLD), status: 'processing', created_by_user_id: operator.id, created_by_username: operator.username, created_by_name: operator.name, created_department_id: operator.departmentId || null, created_department_name: operator.departmentName || null, }); try { const [ vision, embedding ] = await Promise.all([ callVision(resolvedSource.imageForModel), callEmbedding(resolvedSource.imageForModel), ]); const normalizedTags = buildNormalizedTags(vision.rawTags, { category: categoryTag, categoryName: categoryTag, }); normalizedTags.category = categoryTag; const candidates = await planningDatabase.query( ` SELECT id, sku_id, goods_id, product_name, image_url, category_name, category_tag, space_tag, scene_tag, style_tag, color_tag, material_tag, raw_tags_json, normalized_tags_json, embedding_vector_json FROM ai_image_feature_library WHERE biz_type = 'internal' AND image_status = 'success' AND category_tag = ? AND embedding_vector_json IS NOT NULL AND embedding_vector_json <> '' `, [ categoryTag ] ); const threshold = Number(similarityThreshold || DEFAULT_THRESHOLD); const limitedCount = Math.max(1, Math.min(Number(limit || DEFAULT_LIMIT), 50)); const ranked = candidates .map((item) => { const vector = safeJsonParse(item.embedding_vector_json, []); const similarity = cosineSimilarity(embedding.vector, vector); if (similarity < 0) { return null; } const tagBonus = calcTagBonus(normalizedTags, item); const finalScore = similarity + tagBonus; return { skuId: item.sku_id, goodsId: item.goods_id, productName: item.product_name, imageUrl: item.image_url, categoryName: item.category_name, categoryTag: item.category_tag, spaceTag: item.space_tag, sceneTag: item.scene_tag, styleTag: item.style_tag, colorTag: item.color_tag, materialTag: item.material_tag, rawTags: safeJsonParse(item.raw_tags_json, {}), normalizedTags: safeJsonParse(item.normalized_tags_json, {}), similarity, similarityPercent: Number((similarity * 100).toFixed(2)), originalSimilarityPercent: Number((similarity * 100).toFixed(2)), tagBonus, tagBonusPercent: Number((tagBonus * 100).toFixed(2)), finalScore, finalScorePercent: Number((finalScore * 100).toFixed(2)), }; }) .filter(Boolean) .filter((item) => item.finalScore >= threshold) .sort((a, b) => b.finalScore - a.finalScore) .slice(0, limitedCount) .map((item, index) => normalizeSearchResultItem({ rank: index + 1, ...item, })); const elapsedMs = Date.now() - startedAt; await updateSearchLog(logId, { query_image_url: toNullableText(resolvedSource.persistedImageUrl || imageUrl, 1000), raw_tags_json: JSON.stringify(vision.rawTags), normalized_tags_json: JSON.stringify(normalizedTags), embedding_dimension: Array.isArray(embedding.vector) ? embedding.vector.length : 0, candidate_count: candidates.length, result_count: ranked.length, result_snapshot_json: JSON.stringify(ranked), status: 'success', response_ms: elapsedMs, error_message: null, }); return { bizNo, logId, selectedCategoryTag: categoryTag, similarityThreshold: threshold, candidateCount: candidates.length, resultCount: ranked.length, elapsedMs, queryImageUrl: resolvedSource.persistedImageUrl || imageUrl || null, rawTags: vision.rawTags, normalizedTags, embeddingDimension: Array.isArray(embedding.vector) ? embedding.vector.length : 0, results: ranked, }; } catch (error) { await updateSearchLog(logId, { status: 'failed', response_ms: Date.now() - startedAt, error_message: toNullableText(error.response?.data ? JSON.stringify(error.response.data) : error.message, 2000), }); throw error; } } module.exports = { getPddNewProductSelectionCategories, getPddNewProductSelectionLibraryFilterOptions, getPddNewProductSelectionLibraryList, getPddNewProductSelectionLogDetail, getPddNewProductSelectionLogs, searchPddNewProductSelection, };