import { fetch as undiciFetch } from 'undici'; import { decryptSecret, encryptSecret, maskApiKey } from './llm-providers.mjs'; const TABLE = 'h5_user_wechat_mp_configs'; const DEFAULT_TOKEN_URL = 'https://api.weixin.qq.com/cgi-bin/stable_token'; const ACCOUNT_TYPES = new Set(['service', 'subscription']); function normalizeAccountType(value, fallback = 'service') { const normalized = String(value ?? '').trim().toLowerCase(); return ACCOUNT_TYPES.has(normalized) ? normalized : fallback; } function resolveEncryptionKey(env = process.env) { return ( env.H5_SETTINGS_ENCRYPTION_KEY ?? env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret' ); } async function readJsonResponse(response) { const text = await response.text().catch(() => ''); if (!response.ok) { throw new Error(text || `upstream ${response.status}`); } return text ? JSON.parse(text) : null; } async function ensureTable(pool) { await pool.query(` CREATE TABLE IF NOT EXISTS ${TABLE} ( user_id CHAR(36) PRIMARY KEY, app_id VARCHAR(64) NOT NULL, account_type ENUM('service', 'subscription') NOT NULL DEFAULT 'service', label VARCHAR(64) NULL, author VARCHAR(16) NULL, secret_ciphertext TEXT NOT NULL, secret_iv VARCHAR(32) NOT NULL, secret_tag VARCHAR(32) NOT NULL, verified_at BIGINT NULL, created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL, KEY idx_wechat_mp_config_app (app_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); } function mapRow(row) { if (!row) return null; return { userId: row.user_id, appId: row.app_id, accountType: row.account_type, label: row.label ?? '', author: row.author ?? '', secretCiphertext: row.secret_ciphertext, secretIv: row.secret_iv, secretTag: row.secret_tag, verifiedAt: row.verified_at ? Number(row.verified_at) : null, createdAt: Number(row.created_at ?? 0), updatedAt: Number(row.updated_at ?? 0), }; } function toPublicConfig(row, env = process.env) { if (!row) { return { configured: false, appId: '', accountType: 'service', label: '', author: '', appSecretMasked: '', verifiedAt: null, updatedAt: null, }; } let appSecretMasked = ''; try { const secret = decryptSecret( { ciphertext: row.secretCiphertext, iv: row.secretIv, tag: row.secretTag, }, resolveEncryptionKey(env), ); appSecretMasked = maskApiKey(secret); } catch { appSecretMasked = '********'; } return { configured: true, appId: row.appId, accountType: row.accountType, label: row.label, author: row.author, appSecretMasked, verifiedAt: row.verifiedAt, updatedAt: row.updatedAt, }; } export async function fetchWechatMpAccessToken( { appId, appSecret }, { tokenUrl = DEFAULT_TOKEN_URL, wechatFetch = undiciFetch } = {}, ) { const payload = await readJsonResponse( await wechatFetch(tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grant_type: 'client_credential', appid: appId, secret: appSecret, force_refresh: false, }), }), ); if (!payload?.access_token) { throw Object.assign(new Error(payload?.errmsg || '获取微信 access_token 失败'), { code: 'invalid_wechat_mp_config', }); } return { accessToken: String(payload.access_token), expiresIn: Number(payload.expires_in ?? 7200), }; } export function createMindSpaceWechatMpConfigService( pool, { env = process.env, tokenUrl = env.H5_WECHAT_MP_TOKEN_URL?.trim() || DEFAULT_TOKEN_URL, wechatFetch = undiciFetch, } = {}, ) { let ensurePromise = null; async function ensureReady() { if (!ensurePromise) { ensurePromise = ensureTable(pool); } await ensurePromise; } async function readRow(userId) { await ensureReady(); const [rows] = await pool.query( `SELECT user_id, app_id, account_type, label, author, secret_ciphertext, secret_iv, secret_tag, verified_at, created_at, updated_at FROM ${TABLE} WHERE user_id = ? LIMIT 1`, [userId], ); return mapRow(rows[0] ?? null); } function readCredentials(row) { if (!row) return null; const appSecret = decryptSecret( { ciphertext: row.secretCiphertext, iv: row.secretIv, tag: row.secretTag, }, resolveEncryptionKey(env), ); if (!appSecret) { throw new Error('公众号密钥解密失败,请重新保存配置'); } return { appId: row.appId, appSecret, accountType: row.accountType, author: row.author, label: row.label, }; } return { async getConfig(userId) { return toPublicConfig(await readRow(userId), env); }, async getCredentials(userId) { return readCredentials(await readRow(userId)); }, async upsertConfig( userId, { appId, appSecret, accountType, label, author, verify = true, } = {}, ) { await ensureReady(); const normalizedAppId = String(appId ?? '').trim(); if (!normalizedAppId) { throw Object.assign(new Error('请填写 AppID'), { code: 'invalid_wechat_mp_config' }); } const existing = await readRow(userId); const nextSecret = String(appSecret ?? '').trim(); let encrypted = null; if (nextSecret) { encrypted = encryptSecret(nextSecret, resolveEncryptionKey(env)); } else if (existing) { encrypted = { ciphertext: existing.secretCiphertext, iv: existing.secretIv, tag: existing.secretTag, }; } else { throw Object.assign(new Error('请填写 AppSecret'), { code: 'invalid_wechat_mp_config' }); } const credentials = { appId: normalizedAppId, appSecret: nextSecret || decryptSecret( { ciphertext: encrypted.ciphertext, iv: encrypted.iv, tag: encrypted.tag, }, resolveEncryptionKey(env), ), }; let verifiedAt = existing?.verifiedAt ?? null; if (verify) { await fetchWechatMpAccessToken(credentials, { tokenUrl, wechatFetch }); verifiedAt = Date.now(); } const now = Date.now(); await pool.query( `INSERT INTO ${TABLE} (user_id, app_id, account_type, label, author, secret_ciphertext, secret_iv, secret_tag, verified_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE app_id = VALUES(app_id), account_type = VALUES(account_type), label = VALUES(label), author = VALUES(author), secret_ciphertext = VALUES(secret_ciphertext), secret_iv = VALUES(secret_iv), secret_tag = VALUES(secret_tag), verified_at = VALUES(verified_at), updated_at = VALUES(updated_at)`, [ userId, normalizedAppId, normalizeAccountType(accountType, existing?.accountType ?? 'service'), String(label ?? existing?.label ?? '').trim().slice(0, 64) || null, String(author ?? existing?.author ?? '').trim().slice(0, 8) || null, encrypted.ciphertext, encrypted.iv, encrypted.tag, verifiedAt, existing?.createdAt ?? now, now, ], ); return this.getConfig(userId); }, async verifyConfig(userId) { const row = await readRow(userId); if (!row) { throw Object.assign(new Error('尚未配置公众号凭证'), { code: 'wechat_mp_not_configured' }); } const credentials = readCredentials(row); await fetchWechatMpAccessToken(credentials, { tokenUrl, wechatFetch }); const now = Date.now(); await pool.query( `UPDATE ${TABLE} SET verified_at = ?, updated_at = ? WHERE user_id = ?`, [now, now, userId], ); return this.getConfig(userId); }, async deleteConfig(userId) { await ensureReady(); await pool.query(`DELETE FROM ${TABLE} WHERE user_id = ?`, [userId]); return { ok: true }; }, }; } export const mindspaceWechatMpConfigInternals = { normalizeAccountType, toPublicConfig, };