322 lines
10 KiB
JavaScript
322 lines
10 KiB
JavaScript
'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,
|
||
};
|