import { Inject, Injectable, Logger } from '@nestjs/common'; import { createHmac } from 'node:crypto'; import { APP_CONFIG, type AppConfig } from '../../config/config'; import { AppError } from '../../common/errors'; const XPAY_BASE = 'https://api.weixin.qq.com'; /** * 微信小程序虚拟支付客户端。 * * 签名规则(两套,缺一不可): * paySig 服务端身份,密钥是后台的「现网 AppKey」:HMAC_SHA256(appkey, uri + '&' + postBody) * signature 用户态身份,密钥是该用户的 sessionKey:HMAC_SHA256(sessionKey, postBody) * * 支持现网与沙箱两套密钥,由 WX_PAY_ENV 决定当前使用哪一套(0 现网 / 1 沙箱)。 * 沙箱用于上线前试跑,不产生真实扣款。 * * 注意:postBody 必须与实际发出的字符串逐字节一致,不能格式化、不能改键顺序。 */ @Injectable() export class WxPayService { private readonly logger = new Logger(WxPayService.name); constructor(@Inject(APP_CONFIG) private readonly cfg: AppConfig) {} /** 当前支付环境:0 现网 / 1 沙箱。 */ get env(): number { return this.cfg.WX_PAY_ENV; } /** 当前环境对应的支付密钥。 */ private get appkey(): string { return this.env === 1 ? this.cfg.WX_PAY_APPKEY_SANDBOX : this.cfg.WX_PAY_APPKEY; } get enabled(): boolean { return this.cfg.WX_OFFER_ID !== '' && this.appkey !== ''; } get offerId(): string { return this.cfg.WX_OFFER_ID; } /** 服务端身份签名。uri 不带问号参数。 */ calcPaySig(uri: string, postBody: string): string { return createHmac('sha256', this.appkey).update(`${uri}&${postBody}`).digest('hex'); } /** 用户态签名。 */ calcSignature(postBody: string, sessionKey: string): string { return createHmac('sha256', sessionKey).update(postBody).digest('hex'); } /** * 查单。用于发货推送丢失时兜底补发。 * 参数名是 order_id(传自己的 outTradeNo),不是 out_trade_no。 */ async queryOrder(outTradeNo: string, openid: string): Promise { const uri = '/xpay/query_order'; const body = JSON.stringify({ openid, env: this.env, order_id: outTradeNo }); const paySig = this.calcPaySig(uri, body); const res = await fetch(`${XPAY_BASE}${uri}?pay_sig=${paySig}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body, }); const json = (await res.json()) as XpayQueryResult; if (json.errcode && json.errcode !== 0) { this.logger.warn(`查单失败 ${outTradeNo}: ${json.errcode} ${json.errmsg ?? ''}`); } return json; } /** * 主动退款(仅 Android 等非 iOS 终端可用;iOS 需用户向 App Store 申请,开发者无法发起)。 * * 注意:参数集合需以官方《虚拟支付 - 退款》文档核对后再上线启用, * 当前按已知字段拼装,失败时抛错而非静默。 */ async refundOrder(input: { outTradeNo: string; openid: string; refundOrderId: string; refundAmountFen: number; reason: string; }): Promise> { if (!this.enabled) throw new AppError(4002, '虚拟支付未配置', 503); const uri = '/xpay/refund_order'; const body = JSON.stringify({ openid: input.openid, env: this.env, order_id: input.outTradeNo, refund_order_id: input.refundOrderId, refund_amount: input.refundAmountFen, refund_reason: input.reason, }); const paySig = this.calcPaySig(uri, body); const res = await fetch(`${XPAY_BASE}${uri}?pay_sig=${paySig}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body, }); return (await res.json()) as Record; } } export interface XpayQueryResult { errcode?: number; errmsg?: string; order?: { order_id?: string; wx_order_id?: string; status?: number; paid_time?: number; }; status?: number; wx_order_id?: string; }