Files
soul-yongping/lib/wechat-transfer.ts

383 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 微信支付 V3 - 商家转账到零钱
* 文档: 开发文档/提现功能完整技术文档.md
*/
import crypto from 'crypto'
import fs from 'fs'
import path from 'path'
const BASE_URL = 'https://api.mch.weixin.qq.com'
export interface WechatTransferConfig {
mchId: string
appId: string
apiV3Key: string
privateKeyPath?: string
privateKeyContent?: string
/** 私钥文件 URL可选配置后会在首次调用时拉取并缓存 */
privateKeyUrl?: string
certSerialNo: string
}
// 与小程序支付、lib/payment/config 保持一致,复用同一套 env
const DEFAULT_MCH_ID = '1318592501'
const DEFAULT_APP_ID = 'wxb8bbb2b10dec74aa'
/** 从 apiclient_cert.pem 读取证书序列号(与 lib/payment 的 WECHAT_CERT_PATH 复用) */
function getCertSerialNoFromPath(certPath: string): string {
const p = path.isAbsolute(certPath) ? certPath : path.join(process.cwd(), certPath)
const pem = fs.readFileSync(p, 'utf8')
const cert = new crypto.X509Certificate(pem)
const raw = (cert.serialNumber || '').replace(/:/g, '').replace(/^0+/, '') || ''
return raw.toUpperCase()
}
function getConfig(): WechatTransferConfig {
const mchId = process.env.WECHAT_MCH_ID || process.env.WECHAT_MCHID || DEFAULT_MCH_ID
const appId = process.env.WECHAT_APP_ID || process.env.WECHAT_APPID || DEFAULT_APP_ID
const apiV3Key = process.env.WECHAT_API_V3_KEY || process.env.WECHAT_MCH_KEY || ''
const keyPath = process.env.WECHAT_KEY_PATH || process.env.WECHAT_MCH_PRIVATE_KEY_PATH || ''
const keyContent = process.env.WECHAT_MCH_PRIVATE_KEY || ''
const keyUrl = process.env.WECHAT_KEY_URL || ''
let certSerialNo = process.env.WECHAT_MCH_CERT_SERIAL_NO || ''
const certPath = process.env.WECHAT_CERT_PATH || ''
if (!certSerialNo && certPath && !certPath.startsWith('http')) {
try {
certSerialNo = getCertSerialNoFromPath(certPath)
} catch (e) {
console.warn('[wechat-transfer] 从证书文件读取序列号失败:', (e as Error).message)
}
}
return {
mchId,
appId,
apiV3Key,
privateKeyPath: keyPath,
privateKeyContent: keyContent,
privateKeyUrl: keyUrl,
certSerialNo,
}
}
/** 从 WECHAT_KEY_URL 拉取的私钥缓存(仅内存,不落盘) */
let privateKeyFromUrlCache: string | null = null
/** 若配置了 WECHAT_KEY_URL在发起转账前拉取私钥并缓存 */
export async function loadPrivateKeyFromUrlIfNeeded(): Promise<void> {
const cfg = getConfig()
if (!cfg.privateKeyUrl) return
if (cfg.privateKeyContent || (cfg.privateKeyPath && !cfg.privateKeyPath.startsWith('http'))) return
if (privateKeyFromUrlCache) return
const res = await fetch(cfg.privateKeyUrl)
if (!res.ok) throw new Error(`拉取私钥失败: ${res.status} ${cfg.privateKeyUrl}`)
const text = await res.text()
if (!text.includes('PRIVATE KEY')) throw new Error('WECHAT_KEY_URL 返回内容不是有效的 PEM 私钥')
privateKeyFromUrlCache = text
}
function getPrivateKey(): string {
const cfg = getConfig()
if (cfg.privateKeyContent) {
const key = cfg.privateKeyContent.replace(/\\n/g, '\n')
if (!key.includes('BEGIN')) {
return `-----BEGIN PRIVATE KEY-----\n${key}\n-----END PRIVATE KEY-----`
}
return key
}
if (cfg.privateKeyUrl && privateKeyFromUrlCache) return privateKeyFromUrlCache
if (cfg.privateKeyPath) {
if (cfg.privateKeyPath.startsWith('http://') || cfg.privateKeyPath.startsWith('https://')) {
if (privateKeyFromUrlCache) return privateKeyFromUrlCache
throw new Error('私钥来自 URL 尚未加载,请先调用 loadPrivateKeyFromUrlIfNeeded()')
}
const p = path.isAbsolute(cfg.privateKeyPath) ? cfg.privateKeyPath : path.join(process.cwd(), cfg.privateKeyPath)
return fs.readFileSync(p, 'utf8')
}
if (cfg.privateKeyUrl) {
throw new Error('已配置 WECHAT_KEY_URL 但私钥尚未拉取,请确保在转账前已调用 loadPrivateKeyFromUrlIfNeeded()')
}
throw new Error('微信商户私钥未配置: WECHAT_MCH_PRIVATE_KEY、WECHAT_KEY_PATH 或 WECHAT_KEY_URL')
}
function generateNonce(length = 32): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let s = ''
for (let i = 0; i < length; i++) {
s += chars.charAt(Math.floor(Math.random() * chars.length))
}
return s
}
/** 生成请求签名 */
function buildSignature(method: string, urlPath: string, timestamp: string, nonce: string, body: string): string {
const message = `${method}\n${urlPath}\n${timestamp}\n${nonce}\n${body}\n`
const key = getPrivateKey()
const sign = crypto.createSign('RSA-SHA256')
sign.update(message)
return sign.sign(key, 'base64')
}
/** 构建 Authorization 头 */
function buildAuthorization(timestamp: string, nonce: string, signature: string): string {
const cfg = getConfig()
return `WECHATPAY2-SHA256-RSA2048 mchid="${cfg.mchId}",nonce_str="${nonce}",signature="${signature}",timestamp="${timestamp}",serial_no="${cfg.certSerialNo}"`
}
export interface CreateTransferParams {
openid: string
amountFen: number
outDetailNo: string
outBatchNo?: string
transferRemark?: string
}
export interface CreateTransferResult {
success: boolean
outBatchNo?: string
batchId?: string
createTime?: string
batchStatus?: string
errorCode?: string
errorMessage?: string
}
/**
* 发起商家转账到零钱
*/
export async function createTransfer(params: CreateTransferParams): Promise<CreateTransferResult> {
await loadPrivateKeyFromUrlIfNeeded()
const cfg = getConfig()
if (!cfg.mchId || !cfg.appId || !cfg.apiV3Key || !cfg.certSerialNo) {
return { success: false, errorCode: 'CONFIG_ERROR', errorMessage: '微信转账配置不完整' }
}
const urlPath = '/v3/transfer/batches'
const outBatchNo = params.outBatchNo || `B${Date.now()}${Math.random().toString(36).slice(2, 8)}`
const body = {
appid: cfg.appId,
out_batch_no: outBatchNo,
batch_name: '提现',
batch_remark: params.transferRemark || '用户提现',
total_amount: params.amountFen,
total_num: 1,
transfer_detail_list: [
{
out_detail_no: params.outDetailNo,
transfer_amount: params.amountFen,
transfer_remark: params.transferRemark || '提现',
openid: params.openid,
},
],
transfer_scene_id: '1005',
transfer_scene_report_infos: [
{ info_type: '岗位类型', info_content: '兼职人员' },
{ info_type: '报酬说明', info_content: '当日兼职费' },
],
}
const bodyStr = JSON.stringify(body)
const timestamp = Math.floor(Date.now() / 1000).toString()
const nonce = generateNonce()
const signature = buildSignature('POST', urlPath, timestamp, nonce, bodyStr)
const authorization = buildAuthorization(timestamp, nonce, signature)
const res = await fetch(`${BASE_URL}${urlPath}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: authorization,
'User-Agent': 'Soul-Withdraw/1.0',
},
body: bodyStr,
})
const data = (await res.json()) as Record<string, unknown>
if (res.ok && res.status >= 200 && res.status < 300) {
return {
success: true,
outBatchNo: data.out_batch_no as string,
batchId: data.batch_id as string,
createTime: data.create_time as string,
batchStatus: data.batch_status as string,
}
}
return {
success: false,
errorCode: (data.code as string) || 'UNKNOWN',
errorMessage: (data.message as string) || (data.error as string) as string || '请求失败',
}
}
// ========== 用户确认模式:发起转账(需用户在小程序内确认收款)==========
// 文档: https://pay.weixin.qq.com/doc/v3/merchant/4012716434
export interface CreateTransferUserConfirmParams {
openid: string
amountFen: number
outBillNo: string
transferRemark?: string
}
export interface CreateTransferUserConfirmResult {
success: boolean
state?: string
packageInfo?: string
transferBillNo?: string
createTime?: string
errorCode?: string
errorMessage?: string
}
/** 获取转账结果通知地址 */
function getTransferNotifyUrl(): string {
const base = process.env.NEXT_PUBLIC_BASE_URL || process.env.VERCEL_URL || 'http://localhost:3000'
const host = base.startsWith('http') ? base : `https://${base}`
return `${host}/api/payment/wechat/transfer/notify`
}
/**
* 用户确认模式 - 发起转账
* 返回 WAIT_USER_CONFIRM 时需将 package_info 下发给小程序,用户调 wx.requestMerchantTransfer 确认收款
*/
export async function createTransferUserConfirm(
params: CreateTransferUserConfirmParams
): Promise<CreateTransferUserConfirmResult> {
await loadPrivateKeyFromUrlIfNeeded()
const cfg = getConfig()
if (!cfg.mchId || !cfg.appId) {
return { success: false, errorCode: 'CONFIG_ERROR', errorMessage: '微信转账配置不完整:缺少商户号或 AppID' }
}
if (!cfg.certSerialNo) {
const certPath = process.env.WECHAT_CERT_PATH || ''
const hint = certPath
? `已配置 WECHAT_CERT_PATH=${certPath} 但读取序列号失败,请检查文件存在且为有效 PEM。或直接在 .env 配置 WECHAT_MCH_CERT_SERIAL_NOopenssl x509 -in apiclient_cert.pem -noout -serial 获取)`
: '请在 .env 中配置 WECHAT_CERT_PATHapiclient_cert.pem 路径),或配置 WECHAT_MCH_CERT_SERIAL_NO证书序列号'
return { success: false, errorCode: 'CONFIG_ERROR', errorMessage: `微信转账配置不完整:缺少证书序列号。${hint}` }
}
try {
getPrivateKey()
} catch (e) {
return {
success: false,
errorCode: 'CONFIG_ERROR',
errorMessage: `微信转账配置不完整:商户私钥未配置。请在 .env 中配置 WECHAT_KEY_PATH、WECHAT_MCH_PRIVATE_KEY 或 WECHAT_KEY_URL`,
}
}
const urlPath = '/v3/fund-app/mch-transfer/transfer-bills'
const body = {
appid: cfg.appId,
out_bill_no: params.outBillNo,
transfer_scene_id: '1005',
openid: params.openid,
transfer_amount: params.amountFen,
transfer_remark: params.transferRemark || '提现',
notify_url: getTransferNotifyUrl(),
user_recv_perception: '提现',
transfer_scene_report_infos: [
{ info_type: '岗位类型', info_content: '兼职人员' },
{ info_type: '报酬说明', info_content: '当日兼职费' },
],
}
const bodyStr = JSON.stringify(body)
const timestamp = Math.floor(Date.now() / 1000).toString()
const nonce = generateNonce()
const signature = buildSignature('POST', urlPath, timestamp, nonce, bodyStr)
const authorization = buildAuthorization(timestamp, nonce, signature)
// 发起请求前打印:便于区分是请求微信缺参,还是管理端审核传参问题
console.log('[wechat-transfer] ========== 请求微信支付(用户确认模式)==========')
console.log('[wechat-transfer] URL:', `${BASE_URL}${urlPath}`)
console.log('[wechat-transfer] 请求体 body:', JSON.stringify(body, null, 2))
console.log('[wechat-transfer] 当前配置: mchId=', cfg.mchId, 'appId=', cfg.appId, 'certSerialNo=', cfg.certSerialNo ? `${cfg.certSerialNo.slice(0, 8)}...` : '(空)')
console.log('[wechat-transfer] notify_url:', body.notify_url)
console.log('[wechat-transfer] ========================================')
const res = await fetch(`${BASE_URL}${urlPath}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: authorization,
'User-Agent': 'Soul-Withdraw/1.0',
},
body: bodyStr,
})
const data = (await res.json()) as Record<string, unknown>
console.log('[wechat-transfer] 微信响应 status=', res.status, 'body=', JSON.stringify(data, null, 2))
if (res.ok && res.status >= 200 && res.status < 300) {
const state = (data.state as string) || ''
return {
success: true,
state,
packageInfo: data.package_info as string | undefined,
transferBillNo: data.transfer_bill_no as string | undefined,
createTime: data.create_time as string | undefined,
}
}
return {
success: false,
errorCode: (data.code as string) || 'UNKNOWN',
errorMessage: (data.message as string) || (data.error as string) as string || '请求失败',
}
}
/** 供小程序调起确认收款时使用:获取商户号与 AppID */
export function getTransferMchAndAppId(): { mchId: string; appId: string } {
const cfg = getConfig()
return { mchId: cfg.mchId, appId: cfg.appId }
}
/**
* 解密回调 resourceAEAD_AES_256_GCM
*/
export function decryptResource(
ciphertext: string,
nonce: string,
associatedData: string,
apiV3Key: string
): Record<string, unknown> {
if (apiV3Key.length !== 32) {
throw new Error('APIv3密钥必须为32字节')
}
const key = Buffer.from(apiV3Key, 'utf8')
const ct = Buffer.from(ciphertext, 'base64')
const authTag = ct.subarray(ct.length - 16)
const data = ct.subarray(0, ct.length - 16)
const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(nonce, 'utf8'))
decipher.setAuthTag(authTag)
decipher.setAAD(Buffer.from(associatedData, 'utf8'))
const dec = decipher.update(data) as Buffer
const final = decipher.final() as Buffer
const json = Buffer.concat([dec, final]).toString('utf8')
return JSON.parse(json) as Record<string, unknown>
}
/**
* 验证回调签名(需平台公钥,可选)
*/
export function verifyCallbackSignature(
timestamp: string,
nonce: string,
body: string,
signature: string,
publicKeyPem: string
): boolean {
const message = `${timestamp}\n${nonce}\n${body}\n`
const sigBuf = Buffer.from(signature, 'base64')
const verify = crypto.createVerify('RSA-SHA256')
verify.update(message)
return verify.verify(publicKeyPem, sigBuf)
}
export interface TransferNotifyDecrypted {
mch_id: string
out_bill_no: string
transfer_bill_no?: string
transfer_amount?: number
state: string
openid?: string
create_time?: string
update_time?: string
}