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'; /** query_order 返回的订单状态。 */ const XPAY_STATUS: Record = { 0: '初始化', 1: '创建成功未支付', 2: '已支付待发货', 3: '发货中', 4: '已发货', 5: '已退款', 6: '已关闭', 7: '退款失败', 8: '用户退款完成', }; /** * 微信小程序虚拟支付客户端。 * * 签名规则(两套,缺一不可): * 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); /** access_token 缓存。有效期 7200 秒,提前 5 分钟换新的。 */ private tokenCache: { value: string; expiresAt: number } | null = null; 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'); } /** * 小程序 access_token。 * * 这些 /xpay/* 服务端接口要求 URL 上同时带 access_token 和 pay_sig: * https://api.weixin.qq.com/xpay/query_order?access_token=xxx&pay_sig=xxx * 只带 pay_sig 会返回 41001 access_token missing——查单和退款都会静默失败。 */ private async accessToken(): Promise { const now = Date.now(); if (this.tokenCache && this.tokenCache.expiresAt > now) return this.tokenCache.value; const url = `${XPAY_BASE}/cgi-bin/token?grant_type=client_credential` + `&appid=${encodeURIComponent(this.cfg.WX_APPID)}` + `&secret=${encodeURIComponent(this.cfg.WX_APPSECRET)}`; const res = await fetch(url); const json = (await res.json()) as { access_token?: string; expires_in?: number; errcode?: number; errmsg?: string; }; if (!json.access_token) { throw new AppError( 4002, `获取 access_token 失败:${json.errcode ?? '-'} ${json.errmsg ?? ''}`.trim(), 502, ); } const ttl = Math.max((json.expires_in ?? 7200) - 300, 60); this.tokenCache = { value: json.access_token, expiresAt: now + ttl * 1000 }; return json.access_token; } /** 拼出带 access_token 与 pay_sig 的接口地址。 */ private async signedUrl(uri: string, postBody: string): Promise { const token = await this.accessToken(); return `${XPAY_BASE}${uri}?access_token=${encodeURIComponent(token)}&pay_sig=${this.calcPaySig(uri, postBody)}`; } /** * 查单。用于发货推送丢失时兜底补发。 * 参数名是 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 res = await fetch(await this.signedUrl(uri, body), { 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; refundFeeFen: number; /** 0-5,微信只认这几个枚举值 */ reasonCode: string; /** 商家自定义数据,必填,退款原因的文字记在这里,query_order 可原样查回 */ bizMeta: string; }): Promise> { if (!this.enabled) throw new AppError(4002, '虚拟支付未配置', 503); // left_fee 是「当前单剩余可退金额」,接口必填,只能先查单拿到。 // 千万不要拿订单金额兜底——那等于编一个数发给微信,会被 268490016 打回来, // 而且把「这单根本不能退」这个真实原因盖掉了。 const queried = await this.queryOrder(input.outTradeNo, input.openid); const status = queried.order?.status; const statusText = status === undefined ? '未知' : XPAY_STATUS[status] ?? String(status); const leftFee = queried.order?.left_fee; if (leftFee === undefined || leftFee === null) { throw new AppError( 4002, `微信侧订单状态是「${statusText}」,没有可退金额,无法退款`, 409, ); } if (leftFee <= 0) { throw new AppError(4002, `该订单可退金额为 0(微信侧状态:${statusText}),无法退款`, 409); } if (input.refundFeeFen > leftFee) { throw new AppError( 4002, `退款金额 ${input.refundFeeFen} 分超过可退金额 ${leftFee} 分,无法退款`, 409, ); } const uri = '/xpay/refund_order'; // 字段名和取值都必须严格照文档,少一个必填字段整笔退款就被拒 const body = JSON.stringify({ openid: input.openid, order_id: input.outTradeNo, refund_order_id: input.refundOrderId, left_fee: leftFee, refund_fee: input.refundFeeFen, biz_meta: input.bizMeta.slice(0, 1024), refund_reason: input.reasonCode, /** 1-人工客服退款:后台管理员发起的都属于这一类 */ req_from: '1', env: this.env, }); const res = await fetch(await this.signedUrl(uri, body), { method: 'POST', headers: { 'content-type': 'application/json' }, body, }); const json = (await res.json()) as { errcode?: number; errmsg?: string }; // 以前不看返回码,微信拒了也照样告诉后台「已提交」,退款就这么静默消失了 if (json.errcode && json.errcode !== 0) { throw new AppError(4002, `微信退款接口拒绝:${json.errcode} ${json.errmsg ?? ''}`.trim(), 502); } return json as Record; } } export interface XpayQueryResult { errcode?: number; errmsg?: string; order?: { order_id?: string; wx_order_id?: string; status?: number; paid_time?: number; /** 当前单剩余可退金额,单位分 */ left_fee?: number; }; status?: number; wx_order_id?: string; }