cangku/backend/src/services/aiImageFeatureLibrary.js

432 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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