218 lines
7.7 KiB
TypeScript
218 lines
7.7 KiB
TypeScript
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;
|
||
}
|