Files
YelayMaths/yelaymaths_api/src/modules/payments/wxpay.service.ts
T
2026-09-30 15:08:16 +08:00

218 lines
7.7 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.
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<number, string> = {
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<string> {
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<string> {
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<XpayQueryResult> {
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<Record<string, unknown>> {
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<string, unknown>;
}
}
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;
}