更新.gitignore以排除部署配置文件,删除不再使用的一键部署脚本,优化小程序部署流程,增强文档说明。
This commit is contained in:
212
lib/wechat-transfer.ts
Normal file
212
lib/wechat-transfer.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* 微信支付 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 || '请求失败',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密回调 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
|
||||
}
|
||||
Reference in New Issue
Block a user