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