383 lines
14 KiB
TypeScript
383 lines
14 KiB
TypeScript
/**
|
||
* 微信支付 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_NO(openssl x509 -in apiclient_cert.pem -noout -serial 获取)`
|
||
: '请在 .env 中配置 WECHAT_CERT_PATH(apiclient_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 }
|
||
}
|
||
|
||
/**
|
||
* 解密回调 resource(AEAD_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
|
||
}
|