forked from lunanting/cangku
432 lines
14 KiB
JavaScript
432 lines
14 KiB
JavaScript
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,
|
||
};
|