cangku/backend/src/services/similarProductsSettings.js

322 lines
10 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 {
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,
};