diff --git a/deploy/_diag_wx_key.mjs b/deploy/_diag_wx_key.mjs new file mode 100644 index 0000000..9bccaa3 --- /dev/null +++ b/deploy/_diag_wx_key.mjs @@ -0,0 +1,77 @@ +#!/usr/bin/env node +const fs = require('fs'); +const crypto = require('crypto'); +const path = process.argv[2] || '/opt/dukang/server/dukang-api/.env.production'; + +function stripQuotes(s) { + const t = s.trim(); + if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) { + return t.slice(1, -1); + } + return t; +} + +function parseEnvValue(text, key) { + const re = new RegExp(`^${key}=(.*)$`, 'm'); + const m = text.match(re); + if (!m) return null; + return stripQuotes(m[1]); +} + +function tryKey(name, key) { + const literalN = (key.match(/\\n/g) || []).length; + const realN = (key.match(/\n/g) || []).length; + const hasBegin = /BEGIN (RSA )?PRIVATE KEY/.test(key); + try { + crypto.createPrivateKey(key); + console.log(`${name}: OK begin=${hasBegin} literalN=${literalN} realN=${realN} len=${key.length}`); + return true; + } catch (e) { + console.log( + `${name}: FAIL ${e.message} begin=${hasBegin} literalN=${literalN} realN=${realN} len=${key.length} head=${JSON.stringify(key.slice(0, 48))}`, + ); + return false; + } +} + +const text = fs.readFileSync(path, 'utf8'); +const raw = parseEnvValue(text, 'WX_MCH_PRIVATE_KEY'); +if (!raw) { + console.log('MISSING WX_MCH_PRIVATE_KEY in', path); + process.exit(1); +} + +console.log('file=', path); +console.log('MOCK_PAY=', parseEnvValue(text, 'MOCK_PAY')); +console.log('WX_MINI_APP_ID=', parseEnvValue(text, 'WX_MINI_APP_ID')); +console.log('WX_APP_ID=', parseEnvValue(text, 'WX_APP_ID')); +console.log('WX_MCH_ID=', parseEnvValue(text, 'WX_MCH_ID')); +console.log('WX_PAY_NOTIFY_URL=', parseEnvValue(text, 'WX_PAY_NOTIFY_URL')); + +const variants = { + as_is: raw, + unescape_n: raw.replace(/\\n/g, '\n'), + unescape_twice: raw.replace(/\\\\n/g, '\\n').replace(/\\n/g, '\n'), + strip_cr_unesc: raw.replace(/\\n/g, '\n').replace(/\r/g, ''), + // dotenv style sometimes leaves surrounding quotes in process.env + quoted_unesc: stripQuotes(raw).replace(/\\n/g, '\n'), +}; + +let ok = false; +for (const [name, key] of Object.entries(variants)) { + if (tryKey(name, key)) ok = true; +} + +// also simulate dotenv load +try { + const dotenv = require('dotenv'); + const parsed = dotenv.parse(text); + const fromDotenv = parsed.WX_MCH_PRIVATE_KEY || ''; + console.log('dotenv_raw_literalN=', (fromDotenv.match(/\\n/g) || []).length, 'realN=', (fromDotenv.match(/\n/g) || []).length); + tryKey('dotenv_as_is', fromDotenv); + tryKey('dotenv_unescape', fromDotenv.replace(/\\n/g, '\n')); +} catch (e) { + console.log('dotenv skip', e.message); +} + +process.exit(ok ? 0 : 2); diff --git a/deploy/sync-api-env.sh b/deploy/sync-api-env.sh index 3e656a7..9f40e93 100644 --- a/deploy/sync-api-env.sh +++ b/deploy/sync-api-env.sh @@ -11,7 +11,7 @@ DEPLOY_HOST="" DEPLOY_USER="root" DEPLOY_PORT="22" DEPLOY_SSH_KEY="" -APP_ROOT="/opt/dukang-haoke" +APP_ROOT="/opt/dukang" TARGET="${1:-production}" case "$TARGET" in diff --git a/server/dukang-api/src/common/system-config/system-config.env.ts b/server/dukang-api/src/common/system-config/system-config.env.ts index 3d8b484..8f0e1f6 100644 --- a/server/dukang-api/src/common/system-config/system-config.env.ts +++ b/server/dukang-api/src/common/system-config/system-config.env.ts @@ -1,7 +1,10 @@ import { resolve } from 'path'; import { PrismaClient } from '@prisma/client'; +import { normalizePemEnv } from '../../integrations/wechat/wechat-pay.util'; import { SYSTEM_CONFIG_KEY_SET } from './system-config.registry'; +const PEM_ENV_KEYS = new Set(['WX_MCH_PRIVATE_KEY', 'WX_PLATFORM_CERT']); + function apiRoot() { return resolve(__dirname, '..', '..'); } @@ -11,6 +14,11 @@ export function resolveEnvFilePath() { return resolve(apiRoot(), isProduction ? '.env.production' : '.env'); } +function normalizeConfigValue(key: string, value: string): string { + if (PEM_ENV_KEYS.has(key)) return normalizePemEnv(value); + return value; +} + /** 启动前从 DB 覆盖 process.env(在 Nest 创建前调用) */ export async function preloadSystemConfigEnv(): Promise { const prisma = new PrismaClient(); @@ -18,7 +26,7 @@ export async function preloadSystemConfigEnv(): Promise { const rows = await prisma.systemConfig.findMany(); for (const row of rows) { if (SYSTEM_CONFIG_KEY_SET.has(row.configKey)) { - process.env[row.configKey] = row.value; + process.env[row.configKey] = normalizeConfigValue(row.configKey, row.value); } } return rows.length; @@ -41,7 +49,7 @@ export async function preloadSystemConfigEnv(): Promise { export function applyEnvOverlay(values: Record) { for (const [key, value] of Object.entries(values)) { if (SYSTEM_CONFIG_KEY_SET.has(key)) { - process.env[key] = value; + process.env[key] = normalizeConfigValue(key, value); } } } diff --git a/server/dukang-api/src/common/system-config/system-config.service.ts b/server/dukang-api/src/common/system-config/system-config.service.ts index 7f1ec1e..7a4e883 100644 --- a/server/dukang-api/src/common/system-config/system-config.service.ts +++ b/server/dukang-api/src/common/system-config/system-config.service.ts @@ -205,17 +205,31 @@ export class SystemConfigService implements OnModuleInit { } } - private normalizeByMeta(meta: { type: string }, raw: string): string { + private normalizeByMeta(meta: { key?: string; type: string }, raw: string): string { if (meta.type === 'boolean') { return raw === 'true' || raw === '1' ? 'true' : 'false'; } + if (meta.key === 'WX_MCH_PRIVATE_KEY' || meta.key === 'WX_PLATFORM_CERT') { + let value = raw.trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1).trim(); + } + return value.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n').replace(/\r\n/g, '\n').trim(); + } return raw; } } function formatEnvLine(key: string, value: string): string { - if (/[\s#"'\\]/.test(value)) { - return `${key}="${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; - } - return `${key}=${value}`; + const needsQuote = /[\s#"'\\]/.test(value) || value.includes('\n') || value.includes('\r'); + if (!needsQuote) return `${key}=${value}`; + const escaped = value + .replace(/\\/g, '\\\\') + .replace(/\r\n/g, '\\n') + .replace(/\n/g, '\\n') + .replace(/"/g, '\\"'); + return `${key}="${escaped}"`; } diff --git a/server/dukang-api/src/integrations/wechat/wechat-pay.util.ts b/server/dukang-api/src/integrations/wechat/wechat-pay.util.ts index 48d19ce..5848ee8 100644 --- a/server/dukang-api/src/integrations/wechat/wechat-pay.util.ts +++ b/server/dukang-api/src/integrations/wechat/wechat-pay.util.ts @@ -67,3 +67,22 @@ export function safeEqual(a: string, b: string): boolean { if (ba.length !== bb.length) return false; return timingSafeEqual(ba, bb); } + +/** + * 规范化 .env / system_config 中的 PEM: + * - 去掉外层引号(DB/表单常把整段含引号写入) + * - 把字面量 \\n 转成真实换行 + * OpenSSL 报 1E08010C DECODER unsupported 时多半是这两类污染。 + */ +export function normalizePemEnv(raw: string | undefined | null): string { + if (!raw) return ''; + let value = String(raw).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1).trim(); + } + value = value.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n').replace(/\r\n/g, '\n'); + return value.trim(); +} diff --git a/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts b/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts index e2256c1..48be237 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts @@ -7,6 +7,7 @@ import type { IWechatProvider, WechatCodeSession, WechatOAuthSession } from './w import { logWechatAuth, type WechatActorRef } from './wechat-log.util'; import { decryptPayResource, + normalizePemEnv, verifyPaySignature, type WechatPayNotifyEnvelope, } from './wechat-pay.util'; @@ -27,10 +28,10 @@ export class WechatApiProvider implements IWechatProvider { private readonly miniAppSecret = (process.env.WX_MINI_APP_SECRET ?? this.appSecret).trim(); private readonly mchId = process.env.WX_MCH_ID ?? ''; private readonly mchSerialNo = process.env.WX_MCH_SERIAL_NO ?? ''; - private readonly mchPrivateKey = (process.env.WX_MCH_PRIVATE_KEY ?? '').replace(/\\n/g, '\n'); + private readonly mchPrivateKey = normalizePemEnv(process.env.WX_MCH_PRIVATE_KEY); private readonly apiV3Key = process.env.WX_API_V3_KEY ?? ''; private readonly notifyUrl = process.env.WX_PAY_NOTIFY_URL ?? ''; - private readonly platformCert = (process.env.WX_PLATFORM_CERT ?? '').replace(/\\n/g, '\n'); + private readonly platformCert = normalizePemEnv(process.env.WX_PLATFORM_CERT); constructor( private readonly redis: RedisService,