cangku/backend/src/services/alibaba1688ImageSearchSettings.js

359 lines
14 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 crypto = require('crypto');
const database = require('../config/database');
const {
getResilientSecret,
saveResilientSecret,
} = require('./resilientSecretStore');
const {
getYydNotificationCenterAdminConfig,
saveYydNotificationCenterConfig,
} = require('./yydNotificationCenterSettings');
const CONFIG_KEY = 'alibaba_1688_image_search_config';
const SECRET_KEY = 'alibaba_1688_image_search_ak';
const DEFAULT_ENDPOINT_URL = 'https://skills-gateway.1688.com/api/find_product/1.0.0';
const DEFAULT_TIMEOUT_MS = 45000;
const CREDENTIAL_VALIDITY_DAYS = 7;
const CREDENTIAL_VALIDITY_MS = CREDENTIAL_VALIDITY_DAYS * 24 * 60 * 60 * 1000;
const SKILL_VERSION = '1.7.0';
let cachedConfig = null;
let cachedAt = 0;
// 最近一次解析成功(AK 非空)的配置。读取链瞬时故障导致 AK 解析为空时
// 回退该配置,避免业务误判"尚未配置"而拒绝 1688 寻款。
let lastKnownGoodConfig = null;
function normalizeText(value, maxLength = 4000) {
return String(value ?? '').trim().slice(0, maxLength);
}
function safeJsonParse(value, fallback = {}) {
if (value && 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.ak;
delete raw.apiKey;
delete raw.api_key;
return raw;
}
function normalizeTimeout(value) {
const number = Number(value);
return Number.isFinite(number) ? Math.min(Math.max(Math.trunc(number), 3000), 120000) : DEFAULT_TIMEOUT_MS;
}
function normalizeIsoTimestamp(value) {
const timestamp = Date.parse(String(value || ''));
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : '';
}
function getCredentialValidity(config = {}, now = Date.now()) {
const configured = Boolean(config.ak || config.akConfigured || config.configured);
const updatedAt = normalizeIsoTimestamp(config.credentialUpdatedAt || config.credential_updated_at);
const expiresAt = normalizeIsoTimestamp(config.credentialExpiresAt || config.credential_expires_at);
if (!configured) {
return {
status: 'unconfigured',
tracked: false,
validityDays: CREDENTIAL_VALIDITY_DAYS,
updatedAt: null,
expiresAt: null,
remainingMs: null,
remainingDays: null,
percentRemaining: 0,
};
}
if (!updatedAt || !expiresAt) {
return {
status: 'untracked',
tracked: false,
validityDays: CREDENTIAL_VALIDITY_DAYS,
updatedAt: updatedAt || null,
expiresAt: expiresAt || null,
remainingMs: null,
remainingDays: null,
percentRemaining: null,
};
}
const currentTime = Number.isFinite(Number(now)) ? Number(now) : Date.now();
const remainingMs = Date.parse(expiresAt) - currentTime;
const remainingDays = Math.max(remainingMs / (24 * 60 * 60 * 1000), 0);
const percentRemaining = Math.max(0, Math.min(100, Math.round((remainingMs / CREDENTIAL_VALIDITY_MS) * 100)));
return {
status: remainingMs <= 0 ? 'expired' : remainingMs <= 2 * 24 * 60 * 60 * 1000 ? 'expiring' : 'active',
tracked: true,
validityDays: CREDENTIAL_VALIDITY_DAYS,
updatedAt,
expiresAt,
remainingMs,
remainingDays,
percentRemaining,
};
}
async function getExpiryNotificationAdminStatus() {
return getYydNotificationCenterAdminConfig();
}
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 getAlibaba1688GatewayError(payload) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return '';
const message = normalizeText(
payload.msgInfo || payload.message || payload.error || payload.errorMessage,
1000
);
const code = normalizeText(payload.code || payload.msgCode || payload.errorCode, 120).toLowerCase();
const httpCode = Number(payload.httpCode || payload.http_code || 0);
const credentialFailure = /(?:access\s*key|accesskey|\bak\b).*(?:disabled|forbidden|invalid|expired|禁用|无效|过期|停用)|(?:disabled|forbidden|invalid|expired|禁用|无效|过期|停用).*(?:access\s*key|accesskey|\bak\b)/i;
if (credentialFailure.test(`${code} ${message}`)) {
return message || '1688 AccessKey 已失效';
}
if (payload.success === false || httpCode >= 400) {
return message || `1688 网关返回业务错误${code ? `(${code})` : ''}`;
}
return '';
}
function getEnvironmentConfig() {
return {
endpointUrl: normalizeText(process.env.ALIBABA_1688_IMAGE_SEARCH_ENDPOINT_URL || DEFAULT_ENDPOINT_URL, 1000),
ak: normalizeText(process.env.ALIBABA_1688_IMAGE_SEARCH_AK || process.env.ALI_1688_AK),
timeoutMs: normalizeTimeout(process.env.ALIBABA_1688_IMAGE_SEARCH_TIMEOUT_MS),
source: 'environment',
};
}
async function getAlibaba1688ImageSearchRuntimeConfig({ forceRefresh = false } = {}) {
if (!forceRefresh && cachedConfig && Date.now() - cachedAt < 30000) return { ...cachedConfig };
const fallback = getEnvironmentConfig();
let stored = {};
let storedReadable = false;
try {
const rows = await database.local.query(
'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1',
[CONFIG_KEY]
);
stored = safeJsonParse(rows[0]?.configValue, {});
storedReadable = true;
} catch (error) {
console.warn('读取1688专用以图搜图配置失败,已回退环境变量:', error.message);
}
const legacyAk = normalizeText(stored.ak || stored.apiKey || stored.api_key);
const secret = await getResilientSecret(SECRET_KEY, {
legacyValues: [legacyAk],
fallbackValues: [fallback.ak],
});
const sanitizedStored = stripSecretFields(stored);
cachedConfig = {
endpointUrl: normalizeText(sanitizedStored.endpointUrl || sanitizedStored.endpoint_url || fallback.endpointUrl, 1000),
ak: secret.value,
timeoutMs: normalizeTimeout(sanitizedStored.timeoutMs || sanitizedStored.timeout_ms || fallback.timeoutMs),
credentialUpdatedAt: normalizeIsoTimestamp(
sanitizedStored.credentialUpdatedAt || sanitizedStored.credential_updated_at
),
credentialExpiresAt: normalizeIsoTimestamp(
sanitizedStored.credentialExpiresAt || sanitizedStored.credential_expires_at
),
source: Object.keys(sanitizedStored).length > 0 ? 'database' : fallback.source,
secretSource: secret.source,
};
if (cachedConfig.ak) {
lastKnownGoodConfig = cachedConfig;
} else if (lastKnownGoodConfig?.ak) {
console.warn('1688图搜配置瞬时读取异常,回退最近一次可用配置');
cachedConfig = lastKnownGoodConfig;
}
if (storedReadable && legacyAk && secret.value) {
try {
await upsertAlibaba1688Config({
endpointUrl: cachedConfig.endpointUrl,
timeoutMs: cachedConfig.timeoutMs,
credentialUpdatedAt: cachedConfig.credentialUpdatedAt || undefined,
credentialExpiresAt: cachedConfig.credentialExpiresAt || undefined,
});
} catch (error) {
console.warn('清理1688旧密钥字段失败:', error.message);
}
}
cachedAt = Date.now();
return { ...cachedConfig };
}
async function upsertAlibaba1688Config(config) {
const rows = await database.local.query('SELECT id FROM system_config WHERE config_key = ? LIMIT 1', [CONFIG_KEY]);
const value = JSON.stringify(config);
if (rows[0]) {
await database.local.query('UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?', [value, 'json', CONFIG_KEY]);
} else {
await database.local.insert('system_config', { config_key: CONFIG_KEY, config_value: value, config_type: 'json' });
}
}
async function saveAlibaba1688ImageSearchConfig(input = {}) {
const current = await getAlibaba1688ImageSearchRuntimeConfig({ forceRefresh: true });
const nextAk = normalizeText(input.ak || input.apiKey || input.api_key);
const now = new Date();
const config = {
endpointUrl: normalizeText(input.endpointUrl || input.endpoint_url || current.endpointUrl, 1000),
ak: nextAk || current.ak,
timeoutMs: normalizeTimeout(input.timeoutMs || input.timeout_ms || current.timeoutMs),
credentialUpdatedAt: nextAk ? now.toISOString() : current.credentialUpdatedAt,
credentialExpiresAt: nextAk
? new Date(now.getTime() + CREDENTIAL_VALIDITY_MS).toISOString()
: current.credentialExpiresAt,
};
if (!config.endpointUrl || !config.ak) throw Object.assign(new Error('1688专用以图搜图接口或 AK 未配置'), { status: 400 });
if (nextAk) {
await saveResilientSecret(SECRET_KEY, nextAk);
}
await upsertAlibaba1688Config({
endpointUrl: config.endpointUrl,
timeoutMs: config.timeoutMs,
credentialUpdatedAt: config.credentialUpdatedAt,
credentialExpiresAt: config.credentialExpiresAt,
});
cachedConfig = { ...config, source: 'database', secretSource: 'database' };
cachedAt = Date.now();
if (input.expiryNotification && typeof input.expiryNotification === 'object') {
await saveYydNotificationCenterConfig(input.expiryNotification);
}
return toAdminConfig(cachedConfig);
}
async function toAdminConfig(config = {}) {
return {
endpointUrl: config.endpointUrl,
timeoutMs: config.timeoutMs,
akConfigured: Boolean(config.ak),
akPreview: maskSecret(config.ak),
source: config.source,
credentialValidity: getCredentialValidity(config),
expiryNotification: await getExpiryNotificationAdminStatus(),
};
}
async function getAlibaba1688ImageSearchAdminSettings() {
return toAdminConfig(await getAlibaba1688ImageSearchRuntimeConfig({ forceRefresh: true }));
}
function extractAkKeys(rawAk) {
const raw = normalizeText(rawAk);
if (!raw) return {};
let decoded = '';
try {
decoded = Buffer.from(raw.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8');
} catch (_error) { decoded = raw; }
if (decoded.length <= 32) decoded = raw;
return { secret: decoded.slice(0, 32), akId: decoded.slice(32) };
}
function canonicalResource(uri) {
const parsed = new URL(uri, 'https://skills-gateway.1688.com');
const entries = [...parsed.searchParams.entries()].sort(([a, av], [b, bv]) => a.localeCompare(b) || av.localeCompare(bv));
const query = entries.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join('&');
return `${parsed.pathname || '/'}${query ? `?${query}` : ''}`;
}
function buildAlibaba1688SignedHeaders({ method = 'POST', uri, body = '', ak }) {
const { akId, secret } = extractAkKeys(ak);
if (!akId || !secret) throw Object.assign(new Error('1688专用以图搜图 AK 格式无效'), { status: 500 });
const contentType = 'application/json';
const timestamp = String(Math.trunc(Date.now() / 1000));
const contentMd5 = body ? crypto.createHash('md5').update(body, 'utf8').digest('base64') : '';
const cskHeaders = {
'x-csk-ak': akId,
'x-csk-time': timestamp,
'x-csk-nonce': crypto.randomBytes(4).toString('hex'),
'x-csk-content-md5': contentMd5,
'x-csk-version': SKILL_VERSION,
};
const canonicalHeaders = Object.keys(cskHeaders).sort().map((key) => `${key}:${cskHeaders[key].trim()}\n`).join('');
const stringToSign = `${method.toUpperCase()}\n${contentMd5}\n${contentType}\n${timestamp}\n${canonicalHeaders}${canonicalResource(uri)}`;
return {
'Content-Type': contentType,
'x-csk-sign': crypto.createHmac('sha256', secret).update(stringToSign, 'utf8').digest('base64'),
...cskHeaders,
};
}
async function testAlibaba1688ImageSearchConfig() {
const axios = require('axios');
const config = await getAlibaba1688ImageSearchRuntimeConfig({ forceRefresh: true });
if (!config.endpointUrl || !config.ak) {
throw Object.assign(new Error('1688专用以图搜图接口或 AK 未配置'), { status: 400 });
}
// 测试请求与业务请求同形态:传真实 1688 商品图 URL,而不是内嵌测试图,
// 避免上游收到非商品图(1x1/纹理图)的异常请求。该图为 1688 CDN 常驻
// 商品图,与业务 imageUrl 模式一致,实测可正常返回结果。
const payload = {
imageUrl: 'https://cbu01.alicdn.com/img/ibank/O1CN0136ckuj1S7eSfZm24K_!!2220313602200-0-cib.jpg',
pageSize: 1,
purchaseAmount: 1,
scoreLevel: 'high',
tags: '4306497',
icTags: '',
};
const body = JSON.stringify(payload);
const endpoint = new URL(config.endpointUrl);
try {
const response = await axios.post(config.endpointUrl, body, {
timeout: Math.min(config.timeoutMs, 30000),
headers: buildAlibaba1688SignedHeaders({
method: 'POST',
uri: `${endpoint.pathname}${endpoint.search}`,
body,
ak: config.ak,
}),
transformRequest: [(data) => data],
});
const businessError = getAlibaba1688GatewayError(response.data);
if (businessError) {
throw Object.assign(new Error(businessError), { status: 502 });
}
return { endpointUrl: config.endpointUrl, source: config.source, message: '1688专用接口连接与鉴权正常' };
} catch (error) {
const status = Number(error?.response?.status || 0);
const detail = error?.response?.data;
const wrapped = new Error(
getAlibaba1688GatewayError(detail)
|| detail?.msgInfo
|| detail?.message
|| detail?.error
|| error.message
|| '1688专用接口连接测试失败'
);
wrapped.status = status === 401 || status === 403 ? 502 : status || 502;
wrapped.upstreamStatus = status || undefined;
throw wrapped;
}
}
module.exports = {
CREDENTIAL_VALIDITY_DAYS,
CREDENTIAL_VALIDITY_MS,
CONFIG_KEY,
DEFAULT_ENDPOINT_URL,
SECRET_KEY,
buildAlibaba1688SignedHeaders,
extractAkKeys,
getCredentialValidity,
getExpiryNotificationAdminStatus,
getAlibaba1688GatewayError,
getAlibaba1688ImageSearchAdminSettings,
getAlibaba1688ImageSearchRuntimeConfig,
saveAlibaba1688ImageSearchConfig,
stripSecretFields,
testAlibaba1688ImageSearchConfig,
};