'use strict'; const axios = require('axios'); const database = require('../config/database'); const { getResilientSecret, saveResilientSecret, } = require('./resilientSecretStore'); const CONFIG_KEY = 'selection_similar_products_config'; const SECRET_KEY = 'selection_similar_products_api_key'; const DEFAULT_ENDPOINT_URL = 'https://qh.yydd.ai/open-api/image-search/similar-products'; const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; let cachedConfig = null; let cachedAt = 0; // 最近一次解析成功(token 非空)的配置。读取链任何一环瞬时故障导致 // token 解析为空时回退该配置,避免业务误判"API 未配置"而切换慢通道。 let lastKnownGoodConfig = null; const CACHE_TTL_MS = 30000; const PROBE_MAX_ATTEMPTS = 3; const PROBE_RETRY_STATUSES = new Set([404, 429, 500, 502, 503, 504]); function normalizeText(value, maxLength = 1000) { return String(value ?? '').trim().slice(0, maxLength); } 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 stripTrailingSlash(value) { return normalizeText(value, 1000).replace(/\/+$/, ''); } function shouldRetrySimilarProductsProbe(status) { return PROBE_RETRY_STATUSES.has(Number(status || 0)); } function normalizeEndpointUrl(value) { const configuredUrl = stripTrailingSlash(value); if (!configuredUrl) return ''; return /\/open-api\/(?:image-search\/similar-products|similar-products\/query)$/i.test(configuredUrl) ? configuredUrl : `${configuredUrl}/open-api/image-search/similar-products`; } 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 stripSecretFields(value) { const raw = value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : {}; delete raw.apiKey; delete raw.api_key; delete raw.token; return raw; } function getFirstEnvValue(keys, fallback = '') { for (const key of keys) { const value = normalizeText(process.env[key], 4000); if (value) return value; } return fallback; } function getEnvironmentConfig() { const endpointUrl = normalizeEndpointUrl(getFirstEnvValue([ 'SELECTION_SIMILAR_PRODUCTS_ENDPOINT_URL', 'SELECTION_SIMILAR_PRODUCTS_BASE_URL', 'OPEN_API_SIMILAR_PRODUCTS_BASE_URL', 'IMAGE_SEARCH_SIMILAR_PRODUCTS_BASE_URL', ], DEFAULT_ENDPOINT_URL)); return { endpointUrl, baseUrl: endpointUrl, token: getFirstEnvValue([ 'SELECTION_SIMILAR_PRODUCTS_BEARER_TOKEN', 'SELECTION_SIMILAR_PRODUCTS_TOKEN', 'OPEN_API_SIMILAR_PRODUCTS_TOKEN', 'IMAGE_SEARCH_SIMILAR_PRODUCTS_TOKEN', ]), timeoutMs: normalizePositiveInteger( process.env.SELECTION_SIMILAR_PRODUCTS_TIMEOUT_MS, DEFAULT_TIMEOUT_MS ), source: 'environment', }; } function normalizeStoredConfig(value, fallback = getEnvironmentConfig()) { const raw = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; const endpointUrl = normalizeEndpointUrl( raw.endpointUrl || raw.endpoint_url || raw.baseUrl || raw.base_url || fallback.endpointUrl ); const token = normalizeText(raw.apiKey || raw.api_key || raw.token || fallback.token, 4000); return { endpointUrl, baseUrl: endpointUrl, token, timeoutMs: normalizePositiveInteger( raw.timeoutMs || raw.timeout_ms, fallback.timeoutMs || DEFAULT_TIMEOUT_MS ), source: raw.endpointUrl || raw.endpoint_url || raw.baseUrl || raw.base_url || raw.apiKey || raw.api_key || raw.token ? 'database' : fallback.source, }; } 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', }); } async function getSimilarProductsRuntimeConfig({ forceRefresh = false } = {}) { if (!forceRefresh && cachedConfig && Date.now() - cachedAt < CACHE_TTL_MS) { return { ...cachedConfig }; } const environmentConfig = getEnvironmentConfig(); let stored = {}; let storedReadable = false; try { stored = safeJsonParse(await getSystemConfigValue(), {}); storedReadable = true; } catch (error) { console.warn('读取以图搜图数据库配置失败,已回退环境变量:', error.message); } const legacyToken = normalizeText(stored.apiKey || stored.api_key || stored.token, 4000); const secret = await getResilientSecret(SECRET_KEY, { legacyValues: [legacyToken], fallbackValues: [environmentConfig.token], }); const sanitizedStored = stripSecretFields(stored); const config = normalizeStoredConfig({ ...sanitizedStored, apiKey: secret.value, }, { ...environmentConfig, token: secret.value || environmentConfig.token, }); config.secretSource = secret.source; if (config.token) { lastKnownGoodConfig = config; } else if (lastKnownGoodConfig?.token) { console.warn('以图搜图配置瞬时读取异常,回退最近一次可用配置'); cachedConfig = lastKnownGoodConfig; cachedAt = Date.now(); return { ...lastKnownGoodConfig }; } if (storedReadable && legacyToken && secret.value) { try { await upsertSystemConfig({ endpointUrl: config.endpointUrl, timeoutMs: config.timeoutMs, }); } catch (error) { console.warn('清理以图搜图旧密钥字段失败:', error.message); } } cachedConfig = config; cachedAt = Date.now(); return { ...config }; } function toAdminConfig(config) { return { endpointUrl: config.endpointUrl, timeoutMs: config.timeoutMs, apiKeyConfigured: Boolean(config.token), apiKeyPreview: maskSecret(config.token), source: config.source, }; } async function getSimilarProductsAdminSettings() { return toAdminConfig(await getSimilarProductsRuntimeConfig({ forceRefresh: true })); } async function saveSimilarProductsConfig(input = {}) { const current = await getSimilarProductsRuntimeConfig({ forceRefresh: true }); const apiKeyInput = input.apiKey ?? input.api_key; const nextToken = normalizeText(apiKeyInput, 4000); const token = nextToken || current.token; const config = normalizeStoredConfig({ endpointUrl: input.endpointUrl || input.endpoint_url || input.baseUrl || input.base_url, apiKey: token, timeoutMs: input.timeoutMs || input.timeout_ms, }, current); if (!config.endpointUrl) { const error = new Error('请输入以图搜图接口地址'); error.status = 400; throw error; } if (!config.token) { const error = new Error('请输入以图搜图 API Key'); error.status = 400; throw error; } if (nextToken) { await saveResilientSecret(SECRET_KEY, nextToken); } await upsertSystemConfig({ endpointUrl: config.endpointUrl, timeoutMs: config.timeoutMs, }); cachedConfig = { ...config, source: 'database', secretSource: 'database' }; cachedAt = Date.now(); return toAdminConfig(cachedConfig); } async function testSimilarProductsConfig() { const config = await getSimilarProductsRuntimeConfig({ forceRefresh: true }); if (!config.endpointUrl || !config.token) { const error = new Error('以图搜图接口地址或 API Key 未配置'); error.status = 400; throw error; } // 探活只验证连接与鉴权,不发送任何图片:上游网关先鉴权后校验参数, // 带 key 请求缺图参数返回 400 IMAGE_REQUIRED 即代表连接可达且 key 有效; // 401/403 才是鉴权失败。此前发送 1x1 测试图会让上游特征提取报错并污染其日志。 let response = null; let requestError = null; for (let attempt = 1; attempt <= PROBE_MAX_ATTEMPTS; attempt += 1) { try { response = await axios.post(config.endpointUrl, { top_k: 1 }, { timeout: Math.min(config.timeoutMs, 30000), headers: { Authorization: `Bearer ${config.token}`, 'X-API-Key': config.token, 'Content-Type': 'application/json', }, validateStatus: () => true, }); requestError = null; } catch (error) { requestError = error; response = null; } const status = Number(response?.status || 0); const shouldRetry = requestError || shouldRetrySimilarProductsProbe(status); if (!shouldRetry || attempt === PROBE_MAX_ATTEMPTS) break; await new Promise((resolve) => setTimeout(resolve, 500 * attempt)); } if (requestError) { const wrapped = new Error(requestError?.message || '连接测试失败'); wrapped.status = 502; throw wrapped; } const status = Number(response.status || 0); if (status === 401 || status === 403) { const error = new Error(response.data?.message || 'API Key 无效或已失效'); error.status = 502; throw error; } if (status === 400 || status === 422) { return { endpointUrl: config.endpointUrl, source: config.source, message: '连接与鉴权正常' }; } const error = new Error( (status >= 200 && status < 300 && '上游对无图探活未返回参数校验错误,接口行为异常') || response.data?.message || `图搜上游返回 HTTP ${status}` ); error.status = status || 502; throw error; } module.exports = { CONFIG_KEY, DEFAULT_ENDPOINT_URL, DEFAULT_TIMEOUT_MS, SECRET_KEY, getEnvironmentConfig, getSimilarProductsAdminSettings, getSimilarProductsRuntimeConfig, normalizeEndpointUrl, normalizeStoredConfig, saveSimilarProductsConfig, shouldRetrySimilarProductsProbe, stripSecretFields, testSimilarProductsConfig, };