cangku/backend/src/services/selectionFissionAiSettings.js

956 lines
45 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.

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