820 lines
25 KiB
JavaScript
820 lines
25 KiB
JavaScript
'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,
|
||
};
|