359 lines
14 KiB
JavaScript
359 lines
14 KiB
JavaScript
'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,
|
||
};
|