Files
soul/lib/wechat-transfer.ts

213 lines
6.5 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
certSerialNo: string
}
function getConfig(): WechatTransferConfig {
const mchId = process.env.WECHAT_MCH_ID || process.env.WECHAT_MCHID || ''
const appId = process.env.WECHAT_APP_ID || process.env.WECHAT_APPID || 'wxb8bbb2b10dec74aa'
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 certSerialNo = process.env.WECHAT_MCH_CERT_SERIAL_NO || ''
return {
mchId,
appId,
apiV3Key,
privateKeyPath: keyPath,
privateKeyContent: keyContent,
certSerialNo,
}
}
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.privateKeyPath) {
const p = path.isAbsolute(cfg.privateKeyPath) ? cfg.privateKeyPath : path.join(process.cwd(), cfg.privateKeyPath)
return fs.readFileSync(p, 'utf8')
}
throw new Error('微信商户私钥未配置: WECHAT_MCH_PRIVATE_KEY 或 WECHAT_KEY_PATH')
}
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> {
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 || '请求失败',
}
}
/**
* 解密回调 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
}