'use strict'; const axios = require('axios'); const database = require('../config/database'); const CONFIG_KEY = 'selection_fission_ai_analysis_config'; const DEFAULT_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'; const DEFAULT_MODEL = 'qwen3.7-max-2026-06-08'; const DEFAULT_PDD_PROPERTY_MODEL = 'qwen3.8-max'; const DEFAULT_TIMEOUT_MS = 120000; const DEFAULT_IMAGE_GENERATION_TIMEOUT_MS = 15 * 60 * 1000; const DEFAULT_EXTERNAL_IMAGE_BASE_URL = 'https://ai.yydd.ai'; const DEFAULT_EXTERNAL_IMAGE_MODEL = 'provider-default'; const DEFAULT_QWEN_IMAGE_BASE_URL = 'https://dashscope.aliyuncs.com'; const DEFAULT_QWEN_IMAGE_MODEL = 'qwen-image-3.0-pro'; const MAX_SKUS_PER_REQUEST = 8; const REQUIRED_NAME_COUNT = 5; const MAX_SKU_NAME_ATTEMPTS = 3; const MIN_PRODUCT_TITLE_LENGTH = 28; const MAX_PRODUCT_TITLE_LENGTH = 30; const DEFAULT_SKU_NAME_PROMPT = [ '你是中国电商平台的SKU命名专家。请基于公司原始SKU名称、规格、颜色、SPU和市场商品信息,为每个SKU生成5个新的中文SKU名称。', '名称需要准确区分规格和颜色,保留影响用户选择的核心属性,表达自然简洁,不得虚构材质、功能、品牌、尺寸或赠品。', '尺寸和规格以公司原始SKU名称为最高优先级;接口中的独立size字段可能是物流包装尺寸,与原始名称冲突时必须忽略。', '每个名称不超过60个汉字,不要带序号,5个名称互不重复,也不得与原始SKU名称完全相同。', '只输出JSON对象,格式为:{"items":[{"skuCode":"SKU编码","names":["名称1","名称2","名称3","名称4","名称5"]}]}。', ].join('\n'); const DEFAULT_PRODUCT_TITLE_PROMPT = [ '你是中国电商平台的商品标题策略专家。请根据平台标题规则、结构化关键词、竞品标题和公司商品真实信息,先推荐分类关键词,再生成5个中文商品标题。', '关键词结构为:品牌(可选)、品类(必填)、材质(可选)、功能卖点(必填)、场景(必填)、热搜词(必填)、飙升词(必填)、修饰词(可选)。', '竞品关键词优先从选中的榜单Top20商品标题拆分;品类关键词从系统提供的10个品类词中选择。合并后按出现频次和搜索意图确定优先级。', '组题时删除与前置品牌、品类、材质、功能词语义重复的词;所有飙升词必须出现,品类词必须出现。', '参考链接标题只用于提取通用品类、材质、功能、场景和搜索词。必须识别其中的第三方品牌、系列名和品牌型号,写入forbiddenBrandTerms,并确保所有候选标题完全不包含这些词。', '每个标题必须为28至30个汉字,在不重复堆砌的前提下优先写满30字;不要空格、标点或序号。不得虚构或复制第三方品牌、材质、功能、尺寸、赠品或认证。', '只输出JSON对象,格式为:{"keywordStrategy":{"brand":"","category":"品类词","material":"材质词","functionalSellingPoints":["功能词"],"scenes":["场景词"],"hotKeywords":["热搜词"],"surgingKeywords":["飙升词"],"modifiers":["修饰词"],"forbiddenBrandTerms":["参考标题中的第三方品牌或型号"]},"titles":["标题1","标题2","标题3","标题4","标题5"]}。', ].join('\n'); const PREVIOUS_PRODUCT_TITLE_PROMPT = [ '你是中国电商平台的商品标题策略专家。请根据平台标题规则、结构化关键词、竞品标题和公司商品真实信息,生成5个中文商品标题。', '关键词结构为:品牌(可选)、品类(必填)、材质(可选)、功能卖点(必填)、场景(必填)、热搜词(必填)、飙升词(必填)、修饰词(可选)。', '竞品关键词优先从选中的榜单Top20商品标题拆分;品类关键词从系统提供的10个品类词中选择。合并后按出现频次和搜索意图确定优先级。', '组题时删除与前置品牌、品类、材质、功能词语义重复的词;所有飙升词必须出现,品类词必须出现。', '每个标题最多30个字符,不要空格、标点、序号或重复堆砌。不得虚构品牌、材质、功能、尺寸、赠品或认证。', '只输出JSON对象,格式为:{"titles":["标题1","标题2","标题3","标题4","标题5"]}。', ].join('\n'); const RECENT_PRODUCT_TITLE_PROMPT = [ '你是中国电商平台的商品标题策略专家。请根据平台标题规则、结构化关键词、竞品标题和公司商品真实信息,先推荐分类关键词,再生成5个中文商品标题。', '关键词结构为:品牌(可选)、品类(必填)、材质(可选)、功能卖点(必填)、场景(必填)、热搜词(必填)、飙升词(必填)、修饰词(可选)。', '竞品关键词优先从选中的榜单Top20商品标题拆分;品类关键词从系统提供的10个品类词中选择。合并后按出现频次和搜索意图确定优先级。', '组题时删除与前置品牌、品类、材质、功能词语义重复的词;所有飙升词必须出现,品类词必须出现。', '每个标题必须为26至30个汉字,在不重复堆砌的前提下尽量接近30字;不要空格、标点或序号。不得虚构品牌、材质、功能、尺寸、赠品或认证。', '只输出JSON对象,格式为:{"keywordStrategy":{"brand":"","category":"品类词","material":"材质词","functionalSellingPoints":["功能词"],"scenes":["场景词"],"hotKeywords":["热搜词"],"surgingKeywords":["飙升词"],"modifiers":["修饰词"]},"titles":["标题1","标题2","标题3","标题4","标题5"]}。', ].join('\n'); const LEGACY_PRODUCT_TITLE_PROMPT = [ '你是中国电商平台的商品标题优化专家。请根据市场商品标题、公司SPU、类目和商品图片生成5个新的中文商品标题。', '标题需要覆盖核心品类词、主要规格、颜色和真实卖点,表达自然并适合电商搜索,不得虚构品牌、材质、功能、尺寸、赠品或认证。', '每个标题不超过60个汉字,不要带序号,不要重复。', '只输出JSON对象,格式为:{"titles":["标题1","标题2","标题3","标题4","标题5"]}。', ].join('\n'); const LEGACY_MAIN_IMAGE_PROMPT = [ '第一张图是市场参考图,第二张图是公司商品白底图。', '彻底移除市场参考图中的原商品、文字、图标、标签、角标、价格、水印和品牌标识,只保留干净的场景、构图、视角、光线与真实阴影。', '将第二张图中的公司商品完整替换到市场参考图原商品位置,保持商品结构、颜色和比例准确,边缘自然,不新增任何文字或图标。', '参考市场图的陈设风格,在商品上或周围布置与品类和场景协调的书籍、摆件、收纳盒、绿植等生活化装饰品,画面饱满但不过度堆砌,不能输出光秃秃的裸产品。', '所有装饰品必须采用无文字、无Logo设计,书籍封面和书脊只保留纯色或自然纹理,不得出现任何字符、符号或伪文字。', '装饰品仅作为场景陈设,不得改变、遮挡或虚构商品的层板、柜门、五金、尺寸和其他主体结构,也不得表现为随商品赠送的配件。', '输出一张可直接用于电商主图的正方形高清图片。', ].join('\n'); const PREVIOUS_MAIN_IMAGE_PROMPT = [ '第一张图是公司商品白底图或干净商品图,第二张图是同品类市场参考图。第一张图决定商品身份,第二张图决定商业摄影构图与视觉风格。', '第一张图是唯一商品主体,禁止保留、复刻或混合市场商品主体。', '必须准确保留公司商品的外形、结构、颜色、材质细节、比例、接口、层数、配件和数量;如果公司图带原始场景,只清理背景,不得改变商品本体。', '强参考市场图的机位、视角、裁切、主体占比、空间布局、陈列方式、景深、灯光方向、色温、背景材质和接触阴影,把公司商品放入市场图原商品的位置。', '彻底移除市场图中的原商品、文字、图标、标签、角标、价格、水印和品牌标识,但保留其场景道具与氛围。最终效果是公司商品主体加市场图的构图风格,不要生成白底棚拍或泛化背景。', '参考市场图的陈设风格,在商品上或周围布置与品类和场景协调的书籍、摆件、收纳盒、绿植等生活化装饰品,画面饱满但不过度堆砌,不能输出光秃秃的裸产品。', '所有装饰品必须采用无文字、无Logo设计,书籍封面和书脊只保留纯色或自然纹理,不得出现任何字符、符号或伪文字。', '装饰品仅作为场景陈设,不得改变、遮挡或虚构商品的层板、柜门、五金、尺寸和其他主体结构,也不得表现为随商品赠送的配件。', '输出一张可直接用于电商主图的正方形高清图片。', ].join('\n'); const DEFAULT_MAIN_IMAGE_PROMPT = [ '第一张图是目标商品图,也是唯一商品身份来源;第二张图是同品类市场参考图,只提供商业摄影场景、机位、构图、裁切、灯光、阴影和道具。', '彻底移除第二张图中的市场商品及其品牌信息,把第一张图的完整目标商品作为一个刚性整体放入相同场景,禁止拆分重绘或复制市场商品结构。', '目标商品的轮廓、几何结构、长宽高比例、厚度、开口、边缘、连接关系、层数、部件位置与精确数量、颜色、材质、纹理、五金和配件必须与第一张图完全一致。', '若市场图的机位会迫使商品变形,优先保持目标商品外观结构不变,只采用最接近的有效机位。', '参考市场图的陈设风格,在商品上或周围布置与品类和场景协调的书籍、摆件、收纳盒、绿植等生活化装饰品,画面饱满但不过度堆砌,不能输出光秃秃的裸产品。', '所有装饰品必须采用无文字、无Logo设计,书籍封面和书脊只保留纯色或自然纹理,不得出现任何字符、符号或伪文字。', '装饰品仅作为场景陈设,不得改变、遮挡或虚构商品结构,不得表现为随商品赠送的配件。输出前逐项核对腿、抽屉、层板、柜门、面板、横梁、把手、孔位、轮子、接口和附件。', '输出一张可直接用于电商主图的正方形高清图片。', ].join('\n'); const LEGACY_SKU_IMAGE_PROMPT = [ '第一张图是已完成产品替换的市场主图场景,第二张图是公司SKU产品图。', '把第二张图的SKU商品自然放入第一张图相同的场景、构图、视角和光线中,准确保留SKU结构、颜色、尺寸比例与细节。', '沿用第一张图的装饰风格,在SKU商品上或周围合理布置书籍、摆件、收纳盒、绿植等生活化场景道具;如果参考图装饰不足,需要主动补充,不能输出光秃秃的裸产品。', '所有装饰品必须采用无文字、无Logo设计,书籍封面和书脊只保留纯色或自然纹理,不得出现任何字符、符号或伪文字。', '装饰品不能遮挡或改变SKU主体结构,不得虚构商品组件或暗示装饰品随商品赠送;不新增文字、图标、水印或品牌标识。', '输出一张正方形高清电商SKU图。', ].join('\n'); const PREVIOUS_SKU_IMAGE_PROMPT = [ '第一张图是当前SKU唯一商品身份图,必须准确保留该SKU的结构、规格、颜色、材质、尺寸比例、部件和配件数量。', '只有在参考市场图生成模式下才会提供第二张图;第二张只提供场景、构图、机位、裁切和光线参考,必须完全移除其原商品。', '禁止引用主图商品、其他SKU或参考图原商品,禁止修改当前SKU的规格、颜色、结构和配件。', '清除文字图标模式下只移除文字、品牌、店铺标识、价格、促销标签、角标、水印、二维码和图标覆盖物,不得重绘商品。', '输出一张且仅一张无文字、无Logo、无水印的高清电商SKU图。', ].join('\n'); const DEFAULT_SKU_IMAGE_PROMPT = [ '第一张图是当前SKU唯一商品身份图;必须把该SKU作为不可拆分、不可重新设计的刚性整体,禁止引用主图商品、同SPU其他SKU或市场参考图商品。', '只有在参考市场图生成模式下才会提供第二张图;第二张只提供场景、构图、机位、裁切、灯光、阴影和道具,必须彻底移除其原商品及品牌信息。', '当前SKU的轮廓、几何结构、规格、长宽高比例、厚度、开口、连接关系、部件位置与精确数量、颜色、材质、纹理、五金和配件必须与第一张图完全一致。', '若市场图机位会导致SKU变形或零部件变化,优先保持SKU外观结构不变,只采用最接近的有效机位;输出前逐项核对所有可见部件和附件。', '清除文字图标模式下只移除文字、品牌、店铺标识、价格、促销标签、角标、水印、二维码和图标覆盖物,不得重绘商品。', '输出一张且仅一张无文字、无Logo、无水印的高清电商SKU图。', ].join('\n'); function normalizeText(value, maxLength = 1000) { return String(value ?? '').trim().slice(0, maxLength); } function normalizeBoolean(value, fallback = false) { if (value === true || value === false) return value; const text = String(value ?? '').trim().toLowerCase(); if (['1', 'true', 'yes', 'on', 'enabled'].includes(text)) return true; if (['0', 'false', 'no', 'off', 'disabled'].includes(text)) return false; return fallback; } function normalizePositiveInteger(value, fallback, min = 1000, max = 10 * 60 * 1000) { const number = Number(value); if (!Number.isFinite(number)) return fallback; return Math.min(Math.max(Math.trunc(number), min), max); } function normalizeBaseUrl(value) { return normalizeText(value, 500).replace(/\/+$/, ''); } function getFirstEnvValue(names = [], fallback = '') { for (const name of names) { const value = normalizeText(process.env[name], 4000); if (value) return value; } return fallback; } function preserveSecret(input, previous, fallback = '') { return input === undefined || input === '' ? normalizeText(previous || fallback, 4000) : normalizeText(input, 4000); } function normalizeImageSize() { return '1K'; } function normalizeImageGenerationConfig(value, existing = {}) { const raw = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; const previous = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {}; const rawExternal = raw.external && typeof raw.external === 'object' ? raw.external : {}; const previousExternal = previous.external && typeof previous.external === 'object' ? previous.external : {}; const rawQwen = raw.qwen && typeof raw.qwen === 'object' ? raw.qwen : {}; const previousQwen = previous.qwen && typeof previous.qwen === 'object' ? previous.qwen : {}; const provider = normalizeText(raw.provider || previous.provider, 40) === 'qwen' ? 'qwen' : 'external'; return { provider, timeoutMs: normalizePositiveInteger( raw.timeoutMs || raw.timeout_ms || previous.timeoutMs || previous.timeout_ms, normalizePositiveInteger( process.env.SELECTION_IMAGE_GEN_TIMEOUT_MS, DEFAULT_IMAGE_GENERATION_TIMEOUT_MS, 10000, 30 * 60 * 1000 ), 10000, 30 * 60 * 1000 ), external: { baseUrl: normalizeBaseUrl( rawExternal.baseUrl || rawExternal.base_url || previousExternal.baseUrl || previousExternal.base_url || getFirstEnvValue(['SELECTION_IMAGE_GEN_BASE_URL', 'IMAGE_GEN_BASE_URL'], DEFAULT_EXTERNAL_IMAGE_BASE_URL) ) || DEFAULT_EXTERNAL_IMAGE_BASE_URL, apiKey: preserveSecret( rawExternal.apiKey ?? rawExternal.api_key, previousExternal.apiKey || previousExternal.api_key, getFirstEnvValue(['SELECTION_IMAGE_GEN_API_KEY', 'IMAGE_GEN_API_KEY', 'EXTERNAL_IMAGE_GEN_API_KEY']) ), model: normalizeText( rawExternal.model || previousExternal.model || process.env.SELECTION_IMAGE_GEN_MODEL || DEFAULT_EXTERNAL_IMAGE_MODEL, 160 ) || DEFAULT_EXTERNAL_IMAGE_MODEL, imageSize: normalizeImageSize( rawExternal.imageSize || rawExternal.image_size || previousExternal.imageSize || previousExternal.image_size || process.env.SELECTION_IMAGE_GEN_IMAGE_SIZE, '1K' ), }, qwen: { baseUrl: normalizeBaseUrl( rawQwen.baseUrl || rawQwen.base_url || previousQwen.baseUrl || previousQwen.base_url || process.env.QWEN_IMAGE_BASE_URL || DEFAULT_QWEN_IMAGE_BASE_URL ) || DEFAULT_QWEN_IMAGE_BASE_URL, apiKey: preserveSecret( rawQwen.apiKey ?? rawQwen.api_key, previousQwen.apiKey || previousQwen.api_key, process.env.QWEN_IMAGE_API_KEY ), model: normalizeText( rawQwen.model || previousQwen.model || process.env.QWEN_IMAGE_MODEL || DEFAULT_QWEN_IMAGE_MODEL, 160 ) || DEFAULT_QWEN_IMAGE_MODEL, imageSize: normalizeImageSize( rawQwen.imageSize || rawQwen.image_size || previousQwen.imageSize || previousQwen.image_size, '1K' ), promptExtend: normalizeBoolean( rawQwen.promptExtend ?? rawQwen.prompt_extend, normalizeBoolean(previousQwen.promptExtend ?? previousQwen.prompt_extend, true) ), watermark: normalizeBoolean( rawQwen.watermark, normalizeBoolean(previousQwen.watermark, false) ), }, }; } function maskSecret(value) { const text = String(value || ''); if (!text) return ''; if (text.length <= 8) return `${text.slice(0, 2)}******`; return `${text.slice(0, 4)}${'*'.repeat(Math.min(10, text.length - 8))}${text.slice(-4)}`; } function safeJsonParse(value, fallback = null) { if (!value) return fallback; if (typeof value === 'object') return value; try { return JSON.parse(value); } catch (_error) { return fallback; } } function normalizeMainImagePrompt(value) { const prompt = normalizeText(value, 8000); const isPreviousDefaultPrompt = prompt.startsWith('第一张图是公司商品白底图,第二张图是市场参考图。'); return !prompt || prompt === LEGACY_MAIN_IMAGE_PROMPT || prompt === PREVIOUS_MAIN_IMAGE_PROMPT || isPreviousDefaultPrompt ? DEFAULT_MAIN_IMAGE_PROMPT : prompt; } function normalizeSkuImagePrompt(value) { const prompt = normalizeText(value, 8000); const isPreviousDefaultPrompt = prompt.startsWith('参考已替换完成的市场主图场景'); return !prompt || prompt === LEGACY_SKU_IMAGE_PROMPT || prompt === PREVIOUS_SKU_IMAGE_PROMPT || isPreviousDefaultPrompt ? DEFAULT_SKU_IMAGE_PROMPT : prompt; } async function getSystemConfigValue() { const rows = await database.local.query( 'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1', [CONFIG_KEY] ); return rows[0]?.configValue ?? null; } async function upsertSystemConfig(value) { const configValue = JSON.stringify(value ?? null); const rows = await database.local.query( 'SELECT id FROM system_config WHERE config_key = ? LIMIT 1', [CONFIG_KEY] ); if (rows[0]) { await database.local.query( 'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?', [configValue, 'json', CONFIG_KEY] ); return; } await database.local.insert('system_config', { config_key: CONFIG_KEY, config_value: configValue, config_type: 'json', }); } function normalizeConfig(value, existing = {}) { const raw = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; const previous = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {}; const apiKeyInput = raw.apiKey ?? raw.api_key; const apiKey = apiKeyInput === undefined || apiKeyInput === '' ? normalizeText(previous.apiKey || previous.api_key || process.env.DASHSCOPE_API_KEY, 4000) : normalizeText(apiKeyInput, 4000); const requestedProductTitlePrompt = normalizeText( raw.productTitlePrompt || raw.product_title_prompt || previous.productTitlePrompt || previous.product_title_prompt, 8000 ); const requestedMainImagePrompt = normalizeText( raw.mainImagePrompt || raw.main_image_prompt || previous.mainImagePrompt || previous.main_image_prompt, 8000 ); const imageGenerationInput = raw.imageGeneration || raw.image_generation; const previousImageGeneration = previous.imageGeneration || previous.image_generation; return { enabled: normalizeBoolean(raw.enabled, normalizeBoolean(previous.enabled, true)), provider: 'aliyun-model-studio', baseUrl: normalizeBaseUrl( raw.baseUrl || raw.base_url || previous.baseUrl || previous.base_url || DEFAULT_BASE_URL ) || DEFAULT_BASE_URL, apiKey, model: normalizeText(raw.model || previous.model || DEFAULT_MODEL, 160) || DEFAULT_MODEL, skuNamePrompt: normalizeText( raw.skuNamePrompt || raw.sku_name_prompt || previous.skuNamePrompt || previous.sku_name_prompt || DEFAULT_SKU_NAME_PROMPT, 8000 ) || DEFAULT_SKU_NAME_PROMPT, productTitlePrompt: !requestedProductTitlePrompt || requestedProductTitlePrompt === LEGACY_PRODUCT_TITLE_PROMPT || requestedProductTitlePrompt === PREVIOUS_PRODUCT_TITLE_PROMPT || requestedProductTitlePrompt === RECENT_PRODUCT_TITLE_PROMPT ? DEFAULT_PRODUCT_TITLE_PROMPT : requestedProductTitlePrompt, mainImagePrompt: normalizeMainImagePrompt(requestedMainImagePrompt), skuImagePrompt: normalizeSkuImagePrompt( raw.skuImagePrompt || raw.sku_image_prompt || previous.skuImagePrompt || previous.sku_image_prompt || DEFAULT_SKU_IMAGE_PROMPT, ), timeoutMs: normalizePositiveInteger( raw.timeoutMs || raw.timeout_ms || previous.timeoutMs || previous.timeout_ms, DEFAULT_TIMEOUT_MS, 10000, 10 * 60 * 1000 ), imageGeneration: normalizeImageGenerationConfig( imageGenerationInput, previousImageGeneration ), }; } function toAdminConfig(config) { const imageGeneration = config.imageGeneration || normalizeImageGenerationConfig(); return { enabled: Boolean(config.enabled), provider: config.provider, baseUrl: config.baseUrl, model: config.model, skuNamePrompt: config.skuNamePrompt, productTitlePrompt: config.productTitlePrompt, mainImagePrompt: config.mainImagePrompt, skuImagePrompt: config.skuImagePrompt, timeoutMs: config.timeoutMs, apiKeyConfigured: Boolean(config.apiKey), apiKeyPreview: maskSecret(config.apiKey), imageGeneration: { provider: imageGeneration.provider, timeoutMs: imageGeneration.timeoutMs, external: { baseUrl: imageGeneration.external.baseUrl, model: imageGeneration.external.model, imageSize: imageGeneration.external.imageSize, apiKeyConfigured: Boolean(imageGeneration.external.apiKey), apiKeyPreview: maskSecret(imageGeneration.external.apiKey), }, qwen: { baseUrl: imageGeneration.qwen.baseUrl, model: imageGeneration.qwen.model, imageSize: imageGeneration.qwen.imageSize, promptExtend: imageGeneration.qwen.promptExtend, watermark: imageGeneration.qwen.watermark, apiKeyConfigured: Boolean(imageGeneration.qwen.apiKey), apiKeyPreview: maskSecret(imageGeneration.qwen.apiKey), }, }, }; } async function getSelectionFissionAiConfig() { const raw = await getSystemConfigValue().catch(() => null); return normalizeConfig(safeJsonParse(raw, {})); } async function getSelectionFissionAiAdminSettings() { return toAdminConfig(await getSelectionFissionAiConfig()); } async function getSelectionFissionImageGenerationConfig() { const config = await getSelectionFissionAiConfig(); return config.imageGeneration; } async function saveSelectionFissionAiConfig(input = {}) { const raw = await getSystemConfigValue().catch(() => null); const existing = safeJsonParse(raw, {}); const config = normalizeConfig(input, existing); await upsertSystemConfig(config); return toAdminConfig(config); } function parseFirstJsonObject(text) { const raw = String(text || '').trim(); if (!raw) return null; const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i); for (const candidate of [fenced?.[1], raw].filter(Boolean)) { try { return JSON.parse(candidate); } catch (_error) { const start = candidate.indexOf('{'); const end = candidate.lastIndexOf('}'); if (start >= 0 && end > start) { try { return JSON.parse(candidate.slice(start, end + 1)); } catch (_nestedError) { // Try the next candidate. } } } } return null; } function normalizeGeneratedName(value) { return normalizeText(value, 120) .replace(/^[\d一二三四五]+[.、::)\]]\s*/, '') .replace(/^["'“”]|["'“”]$/g, '') .trim(); } function normalizeSkuNameCandidatesPayload(payload, skuRows = []) { const rawItems = Array.isArray(payload) ? payload : Array.isArray(payload?.items) ? payload.items : []; const itemBySku = new Map(); for (const item of rawItems) { const skuCode = normalizeText(item?.skuCode || item?.sku_code || item?.sku, 160); const names = Array.isArray(item?.names) ? item.names : Array.isArray(item?.skuNames) ? item.skuNames : Array.isArray(item?.sku_names) ? item.sku_names : []; const uniqueNames = Array.from(new Set(names.map(normalizeGeneratedName).filter(Boolean))); if (skuCode) itemBySku.set(skuCode.toUpperCase(), uniqueNames); } return skuRows.map((row) => { const skuCode = normalizeText(row.skuCode || row.sku_code, 160); const originalSkuName = normalizeGeneratedName( row.originalSkuName || row.original_sku_name || row.skuName || row.sku_name ); const names = (itemBySku.get(skuCode.toUpperCase()) || []) .filter((name) => !originalSkuName || name !== originalSkuName); if (names.length !== REQUIRED_NAME_COUNT) { const error = new Error( `AI未能为SKU ${skuCode} 返回恰好${REQUIRED_NAME_COUNT}个与原名不同的有效名称` ); error.status = 502; error.code = 'AI_SKU_NAME_INVALID_RESPONSE'; throw error; } return { skuCode, originalSkuName, names }; }); } function buildSkuNameRequestContent(context, skuRows, prompt) { const rowsForPrompt = skuRows.map((row) => ({ skuCode: row.skuCode, originalSkuName: row.originalSkuName || row.skuName, size: row.size || '', color: row.colorName || '', })); const content = [{ type: 'text', text: [ prompt, '', `SPU编码:${normalizeText(context.spuCode, 160) || '未提供'}`, `SPU名称:${normalizeText(context.spuName, 255) || '未提供'}`, `市场商品标题:${normalizeText(context.marketTitle, 500) || '未提供'}`, `待处理SKU:${JSON.stringify(rowsForPrompt)}`, ].join('\n'), }]; for (const row of skuRows) { const imageUrl = normalizeText(row.imageUrl || row.generatedImageUrl || row.skuImageUrl, 2000); if (imageUrl) { content.push({ type: 'text', text: `SKU ${row.skuCode} 参考图片:` }); content.push({ type: 'image_url', image_url: { url: imageUrl } }); } } return content; } function buildSkuNameRepairInstruction(skuRows = [], attempt = 1) { return [ `上一次输出未通过校验,这是第${attempt + 1}次生成。`, `必须为下列每个SKU分别返回恰好${REQUIRED_NAME_COUNT}个名称,不能遗漏任何SKU:${skuRows.map((row) => row.skuCode).join('、')}。`, '同一SKU的名称必须互不重复,且不能与originalSkuName完全相同;只返回约定的JSON对象,不要解释。', ].join('\n'); } async function requestChatCompletion(config, messages, maxCompletionTokens = 1800) { if (!config.enabled) { const error = new Error('选品AI分析模型未启用'); error.status = 409; error.code = 'SELECTION_AI_DISABLED'; throw error; } if (!config.apiKey) { const error = new Error('选品AI分析模型尚未配置API Key'); error.status = 409; error.code = 'SELECTION_AI_API_KEY_MISSING'; throw error; } try { const response = await axios.post(`${config.baseUrl}/chat/completions`, { model: config.model, messages, response_format: { type: 'json_object' }, max_completion_tokens: maxCompletionTokens, enable_thinking: false, temperature: 0.7, }, { timeout: config.timeoutMs, headers: { Authorization: `Bearer ${config.apiKey}`, 'Content-Type': 'application/json', }, maxBodyLength: 20 * 1024 * 1024, }); return response.data; } catch (error) { const message = normalizeText( error.response?.data?.error?.message || error.response?.data?.message || error.message, 1000 ); const wrapped = new Error(`阿里云模型请求失败:${message || '未知错误'}`); wrapped.status = error.response?.status || 502; wrapped.code = error.response?.data?.error?.code || 'ALIYUN_MODEL_REQUEST_FAILED'; throw wrapped; } } function normalizePddPropertyRecommendations(payload, categoryRule = {}) { const inputRows = Array.isArray(payload?.properties) ? payload.properties : Array.isArray(payload?.items) ? payload.items : []; const ruleById = new Map((categoryRule.properties || []).map((row) => [Number(row.refPid), row])); const normalized = []; for (const row of inputRows) { const refPid = Number(row.refPid || row.ref_pid); const rule = ruleById.get(refPid); if (!rule) continue; const requestedVid = Number(row.vid || 0); const requestedValue = normalizeText(row.value, 500); const enumValue = requestedVid ? (rule.values || []).find((value) => Number(value.vid) === requestedVid) : (rule.values || []).find((value) => normalizeText(value.value, 500) === requestedValue); if ((rule.values || []).length > 0 && !enumValue) continue; const value = enumValue?.value || requestedValue; if (!value) continue; normalized.push({ refPid, vid: enumValue?.vid || 0, value, propertyName: rule.name, evidence: normalizeText(row.evidence || row.reason, 1000), confidence: Math.min(Math.max(Number(row.confidence) || 0, 0), 1), }); } return normalized; } async function generatePddPropertyRecommendations(context = {}) { const config = await getSelectionFissionAiConfig(); const model = normalizeText( process.env.PDD_PROPERTY_AI_MODEL || DEFAULT_PDD_PROPERTY_MODEL, 160 ) || DEFAULT_PDD_PROPERTY_MODEL; const categoryRule = context.categoryRule || { properties: [] }; const lockedRefPids = new Set((context.lockedRefPids || []).map(Number)); const propertyRules = (categoryRule.properties || []).map((property) => ({ refPid: property.refPid, name: property.name, required: Boolean(property.required), important: Boolean(property.important), chooseMaxNum: Number(property.chooseMaxNum || 1), valueType: property.valueType, valueUnit: property.valueUnit, values: (property.values || []).slice(0, 200), lockedByExcel: lockedRefPids.has(Number(property.refPid)), })); const prompt = [ '你是拼多多商品发布属性分析专家。根据1688结构化商品参数、图片OCR文字和拼多多实时类目规则,推荐商品属性。', '只能使用输入中真实存在的事实,不得猜测品牌、认证、环保等级、产地、材质或功能。证据不足的属性不要输出。', '有枚举值的属性只能从给定values中选择,并同时返回对应refPid、vid和value;没有枚举值时才可填写文本value。', 'lockedByExcel=true的属性已由人工Excel规则确定,禁止输出或覆盖。优先处理必填和重要属性。', '只输出JSON对象:{"properties":[{"refPid":123,"vid":456,"value":"枚举文本","confidence":0.95,"evidence":"来源证据"}]}。', `拼多多叶子类目:${normalizeText(context.categoryName, 255)}`, `拼多多类目路径:${JSON.stringify(context.categoryPath || [])}`, `1688商品标题:${normalizeText(context.productTitle, 500)}`, `1688类目:${normalizeText(context.sourceCategory, 500)}`, `1688商品参数文案:${normalizeText(context.sourceDescription, 8000)}`, `1688商品参数:${JSON.stringify(context.sourceAttributes || [])}`, `图片OCR结果:${JSON.stringify((context.ocrResults || []).map((row) => ({ kind: row.kind, index: row.index, text: normalizeText(row.fullText, 3000), })))}`, `拼多多实时属性规则:${JSON.stringify(propertyRules)}`, ].join('\n'); const response = await requestChatCompletion({ ...config, model }, [{ role: 'user', content: prompt, }], Math.max(2000, Math.min(8000, propertyRules.length * 180))); const parsed = parseFirstJsonObject(response?.choices?.[0]?.message?.content); if (!parsed) { const error = new Error('qwen3.8-max未返回可解析的拼多多属性JSON'); error.status = 502; error.code = 'PDD_PROPERTY_AI_JSON_MISSING'; throw error; } return { model, generatedAt: new Date().toISOString(), properties: normalizePddPropertyRecommendations(parsed, categoryRule), usage: { promptTokens: Number(response?.usage?.prompt_tokens || 0), completionTokens: Number(response?.usage?.completion_tokens || 0), totalTokens: Number(response?.usage?.total_tokens || 0), }, }; } async function generateSkuNameCandidates(context = {}, skuRows = []) { const normalizedRows = (Array.isArray(skuRows) ? skuRows : []) .filter((row) => normalizeText(row?.skuCode || row?.sku_code, 160)); if (normalizedRows.length === 0) { const error = new Error('没有可生成名称的SKU定价行'); error.status = 400; throw error; } const config = await getSelectionFissionAiConfig(); const results = []; let usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }; for (let offset = 0; offset < normalizedRows.length; offset += MAX_SKUS_PER_REQUEST) { const chunk = normalizedRows.slice(offset, offset + MAX_SKUS_PER_REQUEST); let chunkRows = null; let lastValidationError = null; for (let attempt = 0; attempt < MAX_SKU_NAME_ATTEMPTS; attempt += 1) { const requestContent = buildSkuNameRequestContent(context, chunk, config.skuNamePrompt); if (attempt > 0) { requestContent.push({ type: 'text', text: buildSkuNameRepairInstruction(chunk, attempt), }); } const response = await requestChatCompletion(config, [{ role: 'user', content: requestContent, }], Math.max(1200, chunk.length * 450)); usage = { promptTokens: usage.promptTokens + Number(response?.usage?.prompt_tokens || 0), completionTokens: usage.completionTokens + Number(response?.usage?.completion_tokens || 0), totalTokens: usage.totalTokens + Number(response?.usage?.total_tokens || 0), }; const content = response?.choices?.[0]?.message?.content; const parsed = parseFirstJsonObject(content); try { if (!parsed) { const error = new Error('阿里云模型未返回可解析的SKU名称JSON'); error.status = 502; error.code = 'AI_SKU_NAME_JSON_MISSING'; throw error; } chunkRows = normalizeSkuNameCandidatesPayload(parsed, chunk); break; } catch (error) { lastValidationError = error; if (attempt === MAX_SKU_NAME_ATTEMPTS - 1) throw error; } } if (!chunkRows) throw lastValidationError; results.push(...chunkRows); } return { model: config.model, generatedAt: new Date().toISOString(), rows: results, usage, }; } function normalizeKeywordList(value, limit = 20) { const rows = Array.isArray(value) ? value : String(value || '').split(/[,,、\s]+/); return Array.from(new Set(rows.map((item) => normalizeText(item, 40)).filter(Boolean))).slice(0, limit); } function normalizeProductTitleCategoryTerm(value) { const categoryPath = normalizeText(value, 255); if (!categoryPath) return ''; const segments = categoryPath .split(/\s*(?:>|>|》|\/|\\|→|-)\s*/) .map((item) => normalizeText(item, 40)) .filter(Boolean); const leaf = segments.at(-1) || categoryPath; const parentMatch = segments .slice(0, -1) .reverse() .find((item) => item.length >= 2 && leaf.endsWith(item)); if (parentMatch) return parentMatch; return leaf.replace(/^(?:单个|单件|单只|一体式)/, '') || leaf; } function normalizeProductTitleStrategy(value = {}, context = {}) { const raw = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; const category = normalizeProductTitleCategoryTerm(context.categoryName || raw.category); const fallbackKeyword = category || normalizeText(context.spuName, 40) || '商品'; return { brand: normalizeText(raw.brand, 40), category: fallbackKeyword, material: normalizeText(raw.material, 40), functionalSellingPoints: normalizeKeywordList( raw.functionalSellingPoints || raw.functional_selling_points || [fallbackKeyword], 5 ), scenes: normalizeKeywordList(raw.scenes || ['家用'], 3), hotKeywords: normalizeKeywordList(raw.hotKeywords || raw.hot_keywords || [fallbackKeyword], 5), surgingKeywords: normalizeKeywordList( raw.surgingKeywords || raw.surging_keywords || [fallbackKeyword], 2 ), modifiers: normalizeKeywordList(raw.modifiers, 4), categoryKeywords: normalizeKeywordList(raw.categoryKeywords || raw.category_keywords, 10), competitorTitles: normalizeKeywordList(raw.competitorTitles || raw.competitor_titles || context.competitorTitles, 20), forbiddenBrandTerms: normalizeKeywordList( raw.forbiddenBrandTerms || raw.forbidden_brand_terms || context.forbiddenBrandTerms, 20 ), }; } function normalizeProductTitleCandidatesPayload(payload, strategy = {}) { const titles = Array.isArray(payload) ? payload : Array.isArray(payload?.titles) ? payload.titles : Array.isArray(payload?.items) ? payload.items : []; const normalized = Array.from(new Set(titles .map((item) => normalizeGeneratedName( typeof item === 'string' ? item : item?.title || item?.name )) .filter(Boolean) .map((title) => title.replace(/[\s,,。;;、|/]+/g, '')) .filter((title) => title.length >= MIN_PRODUCT_TITLE_LENGTH && title.length <= MAX_PRODUCT_TITLE_LENGTH))); if (normalized.length !== REQUIRED_NAME_COUNT) { const error = new Error(`AI未能返回恰好${REQUIRED_NAME_COUNT}个有效商品标题(每个28至30字)`); error.status = 502; error.code = 'AI_PRODUCT_TITLE_INVALID_RESPONSE'; throw error; } const category = normalizeText(strategy.category, 40); const requiredWords = normalizeKeywordList(strategy.surgingKeywords); if (category && normalized.some((title) => !title.includes(category))) { const error = new Error(`AI生成的标题未全部包含品类词“${category}”`); error.status = 502; error.code = 'AI_PRODUCT_TITLE_CATEGORY_MISSING'; throw error; } const missingSurging = requiredWords.find((word) => normalized.some((title) => !title.includes(word))); if (missingSurging) { const error = new Error(`AI生成的标题未全部包含飙升词“${missingSurging}”`); error.status = 502; error.code = 'AI_PRODUCT_TITLE_SURGING_MISSING'; throw error; } const forbiddenBrandTerm = normalizeKeywordList(strategy.forbiddenBrandTerms, 20) .find((term) => normalized.some((title) => title.toLowerCase().includes(term.toLowerCase()))); if (forbiddenBrandTerm) { const error = new Error(`AI生成的标题包含参考商品品牌或型号“${forbiddenBrandTerm}”`); error.status = 502; error.code = 'AI_PRODUCT_TITLE_REFERENCE_BRAND_LEAK'; throw error; } return normalized; } async function generateProductTitleCandidates(context = {}) { const config = await getSelectionFissionAiConfig(); const strategy = normalizeProductTitleStrategy(context.keywordStrategy, context); const baseText = [ config.productTitlePrompt, '', `市场商品标题:${normalizeText(context.marketTitle, 500) || '未提供'}`, `公司SPU编码:${normalizeText(context.spuCode, 160) || '未提供'}`, `公司SPU名称:${normalizeText(context.spuName, 255) || '未提供'}`, `商品类目:${normalizeText(context.categoryName, 255) || '未提供'}`, `关键词策略:${JSON.stringify(strategy)}`, `榜单竞品标题(最多20条):${JSON.stringify(strategy.competitorTitles)}`, `品类候选词(最多10个):${JSON.stringify(strategy.categoryKeywords)}`, `禁用的参考品牌或型号:${JSON.stringify(strategy.forbiddenBrandTerms)}`, '请先基于商品事实、竞品标题和候选词优化 keywordStrategy 中各分类推荐词,再用推荐结果组题。不得虚构商品属性。', '参考标题里的第三方品牌、系列名和品牌型号只能写入forbiddenBrandTerms,严禁出现在任何候选标题中。', '硬性长度要求:每个标题必须为28至30个汉字,在语义自然且不重复堆砌的前提下优先写满30字。', ].join('\n'); const imageUrl = normalizeText(context.imageUrl, 2000); let lastValidationError = null; for (let attempt = 1; attempt <= 3; attempt += 1) { const content = [{ type: 'text', text: attempt === 1 ? baseText : `${baseText}\n\n上次输出未通过校验:${lastValidationError?.message || '格式不合规'}。请重新生成,严格保证5个标题各自为28至30字、优先写满30字,并包含品类词和全部飙升词。`, }]; if (imageUrl) { content.push({ type: 'text', text: '商品主图:' }); content.push({ type: 'image_url', image_url: { url: imageUrl } }); } const response = await requestChatCompletion(config, [{ role: 'user', content }], 1000); const parsed = parseFirstJsonObject(response?.choices?.[0]?.message?.content); try { if (!parsed) { const error = new Error('阿里云模型未返回可解析的商品标题JSON'); error.status = 502; error.code = 'AI_PRODUCT_TITLE_JSON_MISSING'; throw error; } const recommendedRaw = parsed.keywordStrategy || parsed.keyword_strategy || {}; const recommendedStrategy = normalizeProductTitleStrategy({ ...strategy, ...recommendedRaw, category: strategy.category, categoryKeywords: strategy.categoryKeywords, competitorTitles: strategy.competitorTitles, forbiddenBrandTerms: [ ...strategy.forbiddenBrandTerms, ...normalizeKeywordList( recommendedRaw.forbiddenBrandTerms || recommendedRaw.forbidden_brand_terms, 20 ), ], }, context); return { model: config.model, generatedAt: new Date().toISOString(), strategy: recommendedStrategy, titles: normalizeProductTitleCandidatesPayload(parsed, recommendedStrategy), usage: { promptTokens: Number(response?.usage?.prompt_tokens || 0), completionTokens: Number(response?.usage?.completion_tokens || 0), totalTokens: Number(response?.usage?.total_tokens || 0), }, }; } catch (error) { lastValidationError = error; if (attempt === 3) throw error; } } throw lastValidationError; } async function testSelectionFissionAiConfig() { const config = await getSelectionFissionAiConfig(); const response = await requestChatCompletion(config, [{ role: 'user', content: '只返回JSON:{"ok":true,"message":"连接成功"}', }], 100); const parsed = parseFirstJsonObject(response?.choices?.[0]?.message?.content); if (parsed?.ok !== true) { const error = new Error('模型已响应,但未返回预期的连接测试结果'); error.status = 502; throw error; } return { provider: config.provider, model: config.model, message: normalizeText(parsed.message, 100) || '连接成功', }; } module.exports = { CONFIG_KEY, DEFAULT_BASE_URL, DEFAULT_EXTERNAL_IMAGE_BASE_URL, DEFAULT_EXTERNAL_IMAGE_MODEL, DEFAULT_MAIN_IMAGE_PROMPT, DEFAULT_MODEL, DEFAULT_PDD_PROPERTY_MODEL, DEFAULT_PRODUCT_TITLE_PROMPT, DEFAULT_QWEN_IMAGE_BASE_URL, DEFAULT_QWEN_IMAGE_MODEL, DEFAULT_SKU_IMAGE_PROMPT, DEFAULT_SKU_NAME_PROMPT, buildSkuNameRepairInstruction, generateProductTitleCandidates, generatePddPropertyRecommendations, generateSkuNameCandidates, getSelectionFissionAiAdminSettings, getSelectionFissionAiConfig, getSelectionFissionImageGenerationConfig, normalizeImageGenerationConfig, normalizeProductTitleCandidatesPayload, normalizeProductTitleCategoryTerm, normalizePddPropertyRecommendations, normalizeMainImagePrompt, normalizeSkuImagePrompt, normalizeSkuNameCandidatesPayload, saveSelectionFissionAiConfig, testSelectionFissionAiConfig, };