微信小程序支付遇到问题,因为没有 WX_MCH_PRIVATE_KEY

This commit is contained in:
2026-07-21 07:59:57 +08:00
parent ade68972a9
commit bc0b0cafd6
6 changed files with 129 additions and 10 deletions
+77
View File
@@ -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);
+1 -1
View File
@@ -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
@@ -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<number> {
const prisma = new PrismaClient();
@@ -18,7 +26,7 @@ export async function preloadSystemConfigEnv(): Promise<number> {
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<number> {
export function applyEnvOverlay(values: Record<string, string>) {
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);
}
}
}
@@ -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}"`;
}
@@ -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();
}
@@ -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,