From 073185f57c5c4b0efc2c4aadb5d667394e0e56f4 Mon Sep 17 00:00:00 2001 From: john Date: Sat, 29 Aug 2026 14:23:37 +0800 Subject: [PATCH] chore(ops): add script to sync local deepseek-cp config to production Automates copying the local deepseek-cp provider key, rebinding Goose executors, and syncing all goosed targets on 103. Co-authored-by: Cursor --- scripts/sync-deepseek-cp-prod.mjs | 259 ++++++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 scripts/sync-deepseek-cp-prod.mjs diff --git a/scripts/sync-deepseek-cp-prod.mjs b/scripts/sync-deepseek-cp-prod.mjs new file mode 100644 index 0000000..f9f6125 --- /dev/null +++ b/scripts/sync-deepseek-cp-prod.mjs @@ -0,0 +1,259 @@ +#!/usr/bin/env node +/** + * Copy local deepseek-cp provider config to production and rebind Goose executors. + * + * Usage (from dev machine): + * eval "$(ssh john@58.38.22.103 'grep -E "^(DATABASE_URL|TKMIND_API_TARGET|TKMIND_API_TARGETS|TKMIND_SERVER__SECRET_KEY)=" /Users/john/Project/Memind/.env | sed "s/^/TARGET_/"')" + * node scripts/sync-deepseek-cp-prod.mjs + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import mysql from 'mysql2/promise'; +import { Agent, fetch } from 'undici'; +import { createDbPool } from '../db.mjs'; +import { + createLlmProviderService, + decryptSecret, + CUSTOM_PROVIDER_ID, +} from '../llm-providers.mjs'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); +const CONFIG_NAME = 'deepseek-cp'; +const OLD_CONFIG_NAME = 'DeepSeek 直连'; +const MODEL = 'deepseek-v4-pro'; +const MODELS = ['deepseek-v4-pro', 'deepseek-v4-flash']; +const API_URL = 'https://card.nassaapi.xyz/v1'; +const EXECUTORS = ['goose', 'aider', 'openhands']; + +function loadEnvFile(filePath) { + if (!fs.existsSync(filePath)) return; + for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq < 0) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1).trim(); + if (!process.env[key]) process.env[key] = value; + } +} + +function parseApiTargets(raw, fallback) { + const values = String(raw ?? '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + return [...new Set([...(values.length ? values : []), fallback].filter(Boolean))]; +} + +async function readApiKeyFromLocal(pool) { + const [rows] = await pool.query( + 'SELECT * FROM h5_llm_provider_keys WHERE name = ? LIMIT 1', + [CONFIG_NAME], + ); + const row = rows[0]; + if (!row) throw new Error(`本地未找到 ${CONFIG_NAME} 配置`); + const apiKey = decryptSecret( + { + ciphertext: row.api_key_ciphertext, + iv: row.api_key_iv, + tag: row.api_key_tag, + }, + process.env.TKMIND_SERVER__SECRET_KEY, + ); + if (!apiKey) throw new Error(`无法解密本地 ${CONFIG_NAME} API Key`); + return apiKey; +} + +async function upsertDeepseekCp(svc, apiKey) { + const keys = await svc.listKeys(); + let row = keys.find((item) => item.name === CONFIG_NAME); + if (row) { + const updated = await svc.updateKey(row.id, { + name: CONFIG_NAME, + apiKey, + apiUrl: API_URL, + defaultModel: MODEL, + models: MODELS, + status: 'active', + }); + if (!updated.ok) throw new Error(`更新 ${CONFIG_NAME} 失败: ${updated.message}`); + row = updated.key; + console.log(`已更新生产 ${CONFIG_NAME}: ${row.id}`); + } else { + const created = await svc.createKey({ + providerId: CUSTOM_PROVIDER_ID, + providerKind: 'custom', + name: CONFIG_NAME, + apiKey, + apiUrl: API_URL, + defaultModel: MODEL, + models: MODELS, + }); + if (!created.ok) throw new Error(`创建 ${CONFIG_NAME} 失败: ${created.message}`); + row = created.key; + console.log(`已创建生产 ${CONFIG_NAME}: ${row.id}`); + } + return row.id; +} + +async function replaceProviderKeyReferences(pool, oldKeyId, newKeyId) { + if (!oldKeyId || oldKeyId === newKeyId) return; + const [pluginRows] = await pool.query( + 'SELECT plugin_id, llm_provider_key_id FROM h5_asset_plugin_configs WHERE llm_provider_key_id = ?', + [oldKeyId], + ); + if (pluginRows.length) { + await pool.query( + 'UPDATE h5_asset_plugin_configs SET llm_provider_key_id = ?, updated_at = ? WHERE llm_provider_key_id = ?', + [newKeyId, Date.now(), oldKeyId], + ); + console.log(`已更新 asset plugin 引用: ${pluginRows.length} 条`); + } + + const [memRows] = await pool.query( + 'SELECT config_scope, config_json FROM h5_memory_v2_admin_config WHERE config_json LIKE ?', + [`%${oldKeyId}%`], + ); + for (const row of memRows) { + const config = typeof row.config_json === 'string' + ? JSON.parse(row.config_json) + : row.config_json; + let changed = false; + for (const section of Object.values(config ?? {})) { + if (section && typeof section === 'object' && section.modelProviderKeyId === oldKeyId) { + section.modelProviderKeyId = newKeyId; + if (section.model === 'deepseek-chat') section.model = 'deepseek-v4-flash'; + changed = true; + } + } + if (changed) { + await pool.query( + 'UPDATE h5_memory_v2_admin_config SET config_json = ?, updated_at = ? WHERE config_scope = ?', + [JSON.stringify(config), Date.now(), row.config_scope], + ); + console.log(`已更新 memory-v2 配置: ${row.config_scope}`); + } + } + + const [wechatRows] = await pool.query( + 'SELECT config_key, config_json FROM h5_wechat_admin_config WHERE config_json LIKE ?', + [`%${oldKeyId}%`], + ); + for (const row of wechatRows) { + const config = typeof row.config_json === 'string' + ? JSON.parse(row.config_json) + : row.config_json; + if (config?.modelProviderKeyId === oldKeyId) { + config.modelProviderKeyId = newKeyId; + await pool.query( + 'UPDATE h5_wechat_admin_config SET config_json = ?, updated_at = ? WHERE config_key = ?', + [JSON.stringify(config), Date.now(), row.config_key], + ); + console.log(`已更新 wechat admin 配置: ${row.config_key}`); + } + } +} + +async function testChatCompletion(svc, keyId) { + const result = await svc.createChatCompletion({ + providerKeyId: keyId, + model: MODEL, + temperature: 0, + messages: [{ role: 'user', content: 'Reply with OK only.' }], + }); + return result; +} + +loadEnvFile(path.join(root, '.env')); +loadEnvFile(path.join(root, '.env.local')); + +const targetDatabaseUrl = String( + process.env.TARGET_DATABASE_URL ?? process.env.PROD_DATABASE_URL ?? '', +).trim(); +const targetApiTarget = String( + process.env.TARGET_TKMIND_API_TARGET ?? process.env.TKMIND_API_TARGET ?? '', +).trim(); +const targetApiTargets = parseApiTargets( + process.env.TARGET_TKMIND_API_TARGETS ?? process.env.TKMIND_API_TARGETS, + targetApiTarget, +); +const targetApiSecret = String( + process.env.TARGET_TKMIND_SERVER__SECRET_KEY + ?? process.env.TKMIND_SERVER__SECRET_KEY + ?? '', +).trim(); + +if (!targetDatabaseUrl) { + console.error('缺少 TARGET_DATABASE_URL(或 PROD_DATABASE_URL)'); + process.exit(1); +} +if (!targetApiTarget || !targetApiSecret) { + console.error('缺少 TARGET_TKMIND_API_TARGET / TARGET_TKMIND_SERVER__SECRET_KEY'); + process.exit(1); +} + +const localPool = createDbPool(); +const prodPool = mysql.createPool({ uri: targetDatabaseUrl, connectionLimit: 4 }); + +try { + const apiKey = await readApiKeyFromLocal(localPool); + console.log(`已读取本地 ${CONFIG_NAME} API Key`); + + const prodSvc = createLlmProviderService(prodPool, { + apiTarget: targetApiTarget, + apiTargets: targetApiTargets, + apiSecret: targetApiSecret, + }); + + const prodKeysBefore = await prodSvc.listKeys(); + const oldKey = prodKeysBefore.find((item) => item.name === OLD_CONFIG_NAME); + const oldKeyId = oldKey?.id ?? null; + + const newKeyId = await upsertDeepseekCp(prodSvc, apiKey); + + const selected = await prodSvc.selectKey(newKeyId); + if (!selected.ok) throw new Error(`启用 ${CONFIG_NAME} 失败: ${selected.message}`); + console.log(`已将 ${CONFIG_NAME} 设为全局默认`); + + for (const executor of EXECUTORS) { + const bound = await prodSvc.setExecutorBinding(executor, { + providerKeyId: newKeyId, + model: MODEL, + enabled: true, + }); + if (!bound.ok) throw new Error(`绑定 ${executor} 失败: ${bound.message}`); + console.log(`已绑定 ${executor} -> ${CONFIG_NAME} / ${MODEL}`); + } + + await replaceProviderKeyReferences(prodPool, oldKeyId, newKeyId); + + const synced = await prodSvc.syncSelectedToGoosed(); + if (!synced.ok) throw new Error(`同步 goosed 失败: ${synced.message}`); + console.log( + 'goosed 已同步:', + synced.providerId, + synced.model, + `targets=${(synced.targets ?? []).length}`, + ); + + const test = await testChatCompletion(prodSvc, newKeyId); + console.log( + `${CONFIG_NAME} 联通测试:`, + test.ok ? `OK model=${test.model}` : `失败 ${test.status ?? ''} ${test.message ?? ''}`, + ); + + const gooseRuntime = await prodSvc.getExecutorRuntimeConfig('goose', { includeSecret: false }); + console.log('Goose runtime:', JSON.stringify({ + providerId: gooseRuntime.providerId, + providerName: gooseRuntime.providerName, + model: gooseRuntime.model, + apiUrl: gooseRuntime.apiUrl, + })); + + if (!test.ok) process.exit(1); +} finally { + await localPool.end().catch(() => {}); + await prodPool.end().catch(() => {}); +}