1a0afb6d39
Open weappOrderConfirm in mini-user so users confirm in-app instead of service notice; verify via get_order before completing. Show operator/remark on admin order status timeline. Co-authored-by: Cursor <cursoragent@cursor.com>
765 lines
28 KiB
TypeScript
765 lines
28 KiB
TypeScript
import { createDecipheriv, createHash, createSign, randomBytes, randomUUID } from 'crypto';
|
||
import { BadRequestException, Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
|
||
import { loadAppConfig } from '@dukang/shared-types';
|
||
import { RedisService } from '../../common/redis/redis.service';
|
||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||
import type {
|
||
IWechatProvider,
|
||
WechatCodeSession,
|
||
WechatOAuthSession,
|
||
WechatWxaCodeUnlimitedInput,
|
||
WechatUploadShippingInfoInput,
|
||
WechatUploadShippingInfoResult,
|
||
WechatOrderShippingQueryResult,
|
||
WechatDeliveryCompany,
|
||
} from './wechat.interface';
|
||
import { logWechatAuth, type WechatActorRef } from './wechat-log.util';
|
||
import {
|
||
decryptPayResource,
|
||
normalizePemEnv,
|
||
verifyPaySignature,
|
||
type WechatPayNotifyEnvelope,
|
||
} from './wechat-pay.util';
|
||
|
||
type TokenCache = { accessToken: string; expiresAt: number };
|
||
type TicketCache = { ticket: string; expiresAt: number };
|
||
|
||
/** 使用 getStableAccessToken,与旧 cgi-bin/token 隔离,避免多端抢刷新导致 40001 */
|
||
const ACCESS_TOKEN_KEY = 'wechat:stable_access_token';
|
||
const MINI_ACCESS_TOKEN_KEY = 'wechat:mini_stable_access_token';
|
||
const JSAPI_TICKET_KEY = 'wechat:jsapi_ticket';
|
||
const TOKEN_INVALID_ERRCODES = new Set([40001, 40014, 42001]);
|
||
|
||
@Injectable()
|
||
export class WechatApiProvider implements IWechatProvider {
|
||
private readonly logger = new Logger(WechatApiProvider.name);
|
||
private readonly appId = process.env.WX_APP_ID ?? '';
|
||
private readonly appSecret = process.env.WX_APP_SECRET ?? '';
|
||
/** 小程序独立凭证;未配置时回退公众号/H5 的 WX_APP_ID(须与开发者工具 appid 一致) */
|
||
private readonly miniAppId = (process.env.WX_MINI_APP_ID ?? this.appId).trim();
|
||
private readonly miniAppSecret = (process.env.WX_MINI_APP_SECRET ?? this.appSecret).trim();
|
||
private readonly mchId = process.env.WX_MCH_ID ?? '';
|
||
private readonly mchSerialNo = process.env.WX_MCH_SERIAL_NO ?? '';
|
||
private readonly mchPrivateKey = normalizePemEnv(process.env.WX_MCH_PRIVATE_KEY);
|
||
private readonly apiV3Key = process.env.WX_API_V3_KEY ?? '';
|
||
private readonly notifyUrl = process.env.WX_PAY_NOTIFY_URL ?? '';
|
||
private readonly platformCert = normalizePemEnv(process.env.WX_PLATFORM_CERT);
|
||
|
||
constructor(
|
||
private readonly redis: RedisService,
|
||
private readonly prisma: PrismaService,
|
||
) {}
|
||
|
||
isEnabled() {
|
||
const cfg = loadAppConfig();
|
||
return !cfg.mockWechat && !!this.appId && !!this.appSecret;
|
||
}
|
||
|
||
isMock() {
|
||
return false;
|
||
}
|
||
|
||
isPayEnabled() {
|
||
const cfg = loadAppConfig();
|
||
return (
|
||
!cfg.mockPay &&
|
||
!!(this.miniAppId || this.appId) &&
|
||
!!this.mchId &&
|
||
!!this.mchSerialNo &&
|
||
!!this.mchPrivateKey &&
|
||
!!this.apiV3Key
|
||
);
|
||
}
|
||
|
||
getMchId() {
|
||
return this.mchId;
|
||
}
|
||
|
||
buildOAuthUrl(redirectUri: string, state: string, scope = 'snsapi_userinfo') {
|
||
const qs = new URLSearchParams({
|
||
appid: this.appId,
|
||
redirect_uri: redirectUri,
|
||
response_type: 'code',
|
||
scope,
|
||
state,
|
||
});
|
||
return `https://open.weixin.qq.com/connect/oauth2/authorize?${qs.toString()}#wechat_redirect`;
|
||
}
|
||
|
||
async code2Session(code: string, actorRef?: WechatActorRef): Promise<WechatCodeSession> {
|
||
const appId = this.miniAppId;
|
||
const appSecret = this.miniAppSecret;
|
||
if (!appId || !appSecret) {
|
||
throw new InternalServerErrorException(
|
||
'小程序微信登录未配置:请设置 WX_MINI_APP_ID / WX_MINI_APP_SECRET(或与 project.config.json appid 一致)',
|
||
);
|
||
}
|
||
const url = new URL('https://api.weixin.qq.com/sns/jscode2session');
|
||
url.searchParams.set('appid', appId);
|
||
url.searchParams.set('secret', '***');
|
||
url.searchParams.set('js_code', code);
|
||
url.searchParams.set('grant_type', 'authorization_code');
|
||
const apiUrl = new URL('https://api.weixin.qq.com/sns/jscode2session');
|
||
apiUrl.searchParams.set('appid', appId);
|
||
apiUrl.searchParams.set('secret', appSecret);
|
||
apiUrl.searchParams.set('js_code', code);
|
||
apiUrl.searchParams.set('grant_type', 'authorization_code');
|
||
const data = await this.fetchJson<{
|
||
openid?: string;
|
||
unionid?: string;
|
||
session_key?: string;
|
||
errcode?: number;
|
||
errmsg?: string;
|
||
}>(apiUrl.toString());
|
||
const ok = !!data.openid;
|
||
await logWechatAuth(this.prisma, {
|
||
scene: 'LOGIN',
|
||
requestUrl: url.toString(),
|
||
requestBody: { grant_type: 'authorization_code', platform: 'mini', appId },
|
||
responseBody: ok
|
||
? { openid: data.openid, unionid: data.unionid }
|
||
: { errcode: data.errcode, errmsg: data.errmsg },
|
||
externalNo: data.openid,
|
||
status: ok ? 'SUCCESS' : 'FAILED',
|
||
errorMessage: ok ? undefined : data.errmsg || '微信 code2session 失败',
|
||
actorRef,
|
||
});
|
||
if (!data.openid) {
|
||
const invalidCode = data.errcode === 40029 || /invalid code/i.test(data.errmsg ?? '');
|
||
const hint = invalidCode
|
||
? `(后端 appid=${appId},请确认 WX_MINI_APP_ID/SECRET 与小程序 project.config.json 一致;本地可 MOCK_WECHAT=true)`
|
||
: '';
|
||
throw new InternalServerErrorException((data.errmsg || '微信 code2session 失败') + hint);
|
||
}
|
||
return {
|
||
openId: data.openid,
|
||
unionId: data.unionid,
|
||
sessionKey: data.session_key,
|
||
};
|
||
}
|
||
|
||
async oauth2AccessToken(code: string, actorRef?: WechatActorRef): Promise<WechatOAuthSession> {
|
||
const maskedUrl = new URL('https://api.weixin.qq.com/sns/oauth2/access_token');
|
||
maskedUrl.searchParams.set('appid', this.appId);
|
||
maskedUrl.searchParams.set('secret', '***');
|
||
maskedUrl.searchParams.set('code', code);
|
||
maskedUrl.searchParams.set('grant_type', 'authorization_code');
|
||
const apiUrl = new URL('https://api.weixin.qq.com/sns/oauth2/access_token');
|
||
apiUrl.searchParams.set('appid', this.appId);
|
||
apiUrl.searchParams.set('secret', this.appSecret);
|
||
apiUrl.searchParams.set('code', code);
|
||
apiUrl.searchParams.set('grant_type', 'authorization_code');
|
||
const data = await this.fetchJson<{
|
||
openid?: string;
|
||
unionid?: string;
|
||
access_token?: string;
|
||
refresh_token?: string;
|
||
errcode?: number;
|
||
errmsg?: string;
|
||
}>(apiUrl.toString());
|
||
const ok = !!data.openid;
|
||
await logWechatAuth(this.prisma, {
|
||
scene: 'LOGIN',
|
||
requestUrl: maskedUrl.toString(),
|
||
requestBody: { grant_type: 'authorization_code', platform: 'h5' },
|
||
responseBody: ok
|
||
? { openid: data.openid, unionid: data.unionid }
|
||
: { errcode: data.errcode, errmsg: data.errmsg },
|
||
externalNo: data.openid,
|
||
status: ok ? 'SUCCESS' : 'FAILED',
|
||
errorMessage: ok ? undefined : data.errmsg || '微信 OAuth 失败',
|
||
actorRef,
|
||
});
|
||
if (!data.openid) {
|
||
throw new InternalServerErrorException(data.errmsg || '微信 OAuth 失败');
|
||
}
|
||
return {
|
||
openId: data.openid,
|
||
unionId: data.unionid,
|
||
accessToken: data.access_token,
|
||
refreshToken: data.refresh_token,
|
||
};
|
||
}
|
||
|
||
async fetchOAuthUserInfo(
|
||
accessToken: string,
|
||
openId: string,
|
||
actorRef?: WechatActorRef,
|
||
): Promise<import('./wechat.interface').WechatOAuthUserInfo> {
|
||
const maskedUrl = new URL('https://api.weixin.qq.com/sns/userinfo');
|
||
maskedUrl.searchParams.set('access_token', '***');
|
||
maskedUrl.searchParams.set('openid', openId);
|
||
maskedUrl.searchParams.set('lang', 'zh_CN');
|
||
const apiUrl = new URL('https://api.weixin.qq.com/sns/userinfo');
|
||
apiUrl.searchParams.set('access_token', accessToken);
|
||
apiUrl.searchParams.set('openid', openId);
|
||
apiUrl.searchParams.set('lang', 'zh_CN');
|
||
const data = await this.fetchJson<{
|
||
openid?: string;
|
||
nickname?: string;
|
||
headimgurl?: string;
|
||
unionid?: string;
|
||
errcode?: number;
|
||
errmsg?: string;
|
||
}>(apiUrl.toString());
|
||
const ok = !!data.openid;
|
||
await logWechatAuth(this.prisma, {
|
||
scene: 'USERINFO',
|
||
requestUrl: maskedUrl.toString(),
|
||
requestBody: { lang: 'zh_CN' },
|
||
responseBody: ok
|
||
? { openid: data.openid, nickname: data.nickname, unionid: data.unionid }
|
||
: { errcode: data.errcode, errmsg: data.errmsg },
|
||
externalNo: data.openid ?? openId,
|
||
status: ok ? 'SUCCESS' : 'FAILED',
|
||
errorMessage: ok ? undefined : data.errmsg || '微信用户信息获取失败',
|
||
actorRef,
|
||
});
|
||
if (!data.openid) {
|
||
throw new InternalServerErrorException(data.errmsg || '微信用户信息获取失败');
|
||
}
|
||
return {
|
||
openId: data.openid,
|
||
nickname: data.nickname,
|
||
headImgUrl: data.headimgurl,
|
||
unionId: data.unionid,
|
||
};
|
||
}
|
||
|
||
async createJssdkConfig(url: string, actorRef?: WechatActorRef) {
|
||
try {
|
||
const ticket = await this.getJsapiTicket();
|
||
const nonceStr = randomBytes(8).toString('hex');
|
||
const timestamp = Math.floor(Date.now() / 1000);
|
||
const raw = `jsapi_ticket=${ticket}&noncestr=${nonceStr}×tamp=${timestamp}&url=${url}`;
|
||
const signature = createHash('sha1').update(raw).digest('hex');
|
||
const config = {
|
||
appId: this.appId,
|
||
timestamp,
|
||
nonceStr,
|
||
signature,
|
||
jsApiList: ['getLocation', 'scanQRCode', 'chooseWXPay', 'chooseImage', 'getLocalImgData'],
|
||
};
|
||
await logWechatAuth(this.prisma, {
|
||
scene: 'JSSDK_CONFIG',
|
||
requestUrl: url.split('#')[0],
|
||
requestBody: { appId: this.appId },
|
||
responseBody: { appId: this.appId, timestamp, nonceStr },
|
||
status: 'SUCCESS',
|
||
actorRef,
|
||
});
|
||
return config;
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : String(err);
|
||
await logWechatAuth(this.prisma, {
|
||
scene: 'JSSDK_CONFIG',
|
||
requestUrl: url.split('#')[0],
|
||
requestBody: { appId: this.appId },
|
||
status: 'FAILED',
|
||
errorMessage: message,
|
||
actorRef,
|
||
});
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
async getWxaCodeUnlimited(input: WechatWxaCodeUnlimitedInput): Promise<Buffer> {
|
||
const scene = (input.scene ?? '').trim();
|
||
if (!scene || scene.length > 32) {
|
||
throw new BadRequestException('小程序码 scene 须为 1~32 个可见字符');
|
||
}
|
||
const accessToken = await this.getMiniAccessToken();
|
||
const page = (input.page ?? process.env.WX_MINI_PROMO_PAGE ?? 'pages/home/index').replace(
|
||
/^\//,
|
||
'',
|
||
);
|
||
const envFromCfg = process.env.WX_MINI_ENV_VERSION;
|
||
const envVersion: 'release' | 'trial' | 'develop' =
|
||
input.envVersion ??
|
||
(envFromCfg === 'trial' || envFromCfg === 'develop' || envFromCfg === 'release'
|
||
? envFromCfg
|
||
: 'release');
|
||
const body = {
|
||
scene,
|
||
page,
|
||
width: input.width ?? 430,
|
||
check_path: input.checkPath ?? false,
|
||
env_version: envVersion,
|
||
is_hyaline: input.isHyaline ?? false,
|
||
};
|
||
const apiUrl = `https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=${accessToken}`;
|
||
const res = await fetch(apiUrl, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
const buf = Buffer.from(await res.arrayBuffer());
|
||
// 失败时微信返回 JSON(以 { 开头),成功为 PNG 二进制
|
||
if (buf.length >= 1 && buf[0] === 0x7b /* '{' */) {
|
||
let errMsg = '生成小程序码失败';
|
||
try {
|
||
const err = JSON.parse(buf.toString('utf8')) as { errcode?: number; errmsg?: string };
|
||
errMsg = err.errmsg || errMsg;
|
||
this.logger.error(`getwxacodeunlimit failed: ${err.errcode} ${err.errmsg}`);
|
||
} catch {
|
||
this.logger.error(`getwxacodeunlimit non-image response: ${buf.toString('utf8').slice(0, 200)}`);
|
||
}
|
||
throw new InternalServerErrorException(errMsg);
|
||
}
|
||
return buf;
|
||
}
|
||
|
||
async uploadShippingInfo(input: WechatUploadShippingInfoInput): Promise<WechatUploadShippingInfoResult> {
|
||
const orderKey: Record<string, string | number> = {
|
||
order_number_type: input.orderNumberType,
|
||
};
|
||
if (input.orderNumberType === 2) {
|
||
if (!input.transactionId) {
|
||
throw new BadRequestException('微信支付单号不能为空');
|
||
}
|
||
orderKey.transaction_id = input.transactionId;
|
||
} else {
|
||
if (!input.mchId || !input.outTradeNo) {
|
||
throw new BadRequestException('商户号与商户单号不能为空');
|
||
}
|
||
orderKey.mchid = input.mchId;
|
||
orderKey.out_trade_no = input.outTradeNo;
|
||
}
|
||
|
||
const body = {
|
||
order_key: orderKey,
|
||
logistics_type: input.logisticsType,
|
||
delivery_mode: input.deliveryMode ?? 1,
|
||
shipping_list: input.shippingList.map((row) => {
|
||
const item: Record<string, unknown> = {
|
||
item_desc: row.itemDesc.slice(0, 120),
|
||
};
|
||
if (row.trackingNo) item.tracking_no = row.trackingNo;
|
||
if (row.expressCompany) item.express_company = row.expressCompany;
|
||
if (row.contact?.consignorContact || row.contact?.receiverContact) {
|
||
item.contact = {
|
||
...(row.contact.consignorContact
|
||
? { consignor_contact: row.contact.consignorContact }
|
||
: {}),
|
||
...(row.contact.receiverContact
|
||
? { receiver_contact: row.contact.receiverContact }
|
||
: {}),
|
||
};
|
||
}
|
||
return item;
|
||
}),
|
||
upload_time: input.uploadTime,
|
||
payer: { openid: input.payerOpenId },
|
||
};
|
||
|
||
const callOnce = async (accessToken: string) => {
|
||
const apiUrl = `https://api.weixin.qq.com/wxa/sec/order/upload_shipping_info?access_token=${accessToken}`;
|
||
return this.fetchJson<{ errcode?: number; errmsg?: string }>(apiUrl, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
};
|
||
|
||
let accessToken = await this.getMiniAccessToken();
|
||
let data = await callOnce(accessToken);
|
||
if (TOKEN_INVALID_ERRCODES.has(data.errcode ?? -1)) {
|
||
this.logger.warn(
|
||
`upload_shipping_info token invalid ${data.errcode}, refresh stable token and retry`,
|
||
);
|
||
accessToken = await this.getMiniAccessToken(true);
|
||
data = await callOnce(accessToken);
|
||
}
|
||
return {
|
||
errcode: data.errcode ?? -1,
|
||
errmsg: data.errmsg ?? 'unknown',
|
||
};
|
||
}
|
||
|
||
async getOrderShippingInfo(input: {
|
||
transactionId?: string;
|
||
mchId?: string;
|
||
outTradeNo?: string;
|
||
}): Promise<WechatOrderShippingQueryResult> {
|
||
const callOnce = async (accessToken: string) => {
|
||
const body: Record<string, string> = {};
|
||
if (input.transactionId) {
|
||
body.transaction_id = input.transactionId;
|
||
} else {
|
||
if (!input.mchId || !input.outTradeNo) {
|
||
throw new BadRequestException('请提供 transaction_id 或 mchid+out_trade_no');
|
||
}
|
||
body.merchant_id = input.mchId;
|
||
body.merchant_trade_no = input.outTradeNo;
|
||
}
|
||
const apiUrl = `https://api.weixin.qq.com/wxa/sec/order/get_order?access_token=${accessToken}`;
|
||
return this.fetchJson<{
|
||
errcode?: number;
|
||
errmsg?: string;
|
||
order?: {
|
||
transaction_id?: string;
|
||
merchant_trade_no?: string;
|
||
order_state?: number;
|
||
shipping?: { finish_shipping?: boolean };
|
||
};
|
||
}>(apiUrl, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
};
|
||
|
||
let accessToken = await this.getMiniAccessToken();
|
||
let data = await callOnce(accessToken);
|
||
if (TOKEN_INVALID_ERRCODES.has(data.errcode ?? -1)) {
|
||
accessToken = await this.getMiniAccessToken(true);
|
||
data = await callOnce(accessToken);
|
||
}
|
||
return {
|
||
errcode: data.errcode ?? 0,
|
||
errmsg: data.errmsg ?? 'ok',
|
||
orderState: data.order?.order_state,
|
||
transactionId: data.order?.transaction_id,
|
||
merchantTradeNo: data.order?.merchant_trade_no,
|
||
finishShipping: data.order?.shipping?.finish_shipping,
|
||
};
|
||
}
|
||
|
||
async getDeliveryList(): Promise<WechatDeliveryCompany[]> {
|
||
const accessToken = await this.getMiniAccessToken();
|
||
const apiUrl = `https://api.weixin.qq.com/cgi-bin/express/delivery/open_msg/get_delivery_list?access_token=${accessToken}`;
|
||
const data = await this.fetchJson<{
|
||
errcode?: number;
|
||
errmsg?: string;
|
||
delivery_list?: Array<{ delivery_id?: string; delivery_name?: string }>;
|
||
}>(apiUrl, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: '{}',
|
||
});
|
||
if (data.errcode && data.errcode !== 0) {
|
||
this.logger.warn(`get_delivery_list failed: ${data.errcode} ${data.errmsg}`);
|
||
return [];
|
||
}
|
||
return (data.delivery_list ?? [])
|
||
.filter((row) => row.delivery_id && row.delivery_name)
|
||
.map((row) => ({
|
||
deliveryId: row.delivery_id!,
|
||
deliveryName: row.delivery_name!,
|
||
}));
|
||
}
|
||
|
||
async getPhoneNumberByCode(code: string, platform: 'mini' | 'h5', actorRef?: WechatActorRef): Promise<string> {
|
||
if (platform === 'h5') {
|
||
throw new InternalServerErrorException('H5 请使用短信绑定手机号');
|
||
}
|
||
const accessToken = await this.getMiniAccessToken();
|
||
const apiUrl = `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${accessToken}`;
|
||
const data = await this.fetchJson<{
|
||
errcode?: number;
|
||
errmsg?: string;
|
||
phone_info?: { phoneNumber?: string; purePhoneNumber?: string };
|
||
}>(apiUrl, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ code }),
|
||
});
|
||
const phone = data.phone_info?.purePhoneNumber || data.phone_info?.phoneNumber;
|
||
const ok = !!phone;
|
||
await logWechatAuth(this.prisma, {
|
||
scene: 'BIND_PHONE',
|
||
requestUrl: 'https://api.weixin.qq.com/wxa/business/getuserphonenumber',
|
||
requestBody: { platform },
|
||
responseBody: ok ? { phone: `${phone!.slice(0, 3)}****${phone!.slice(-4)}` } : { errcode: data.errcode, errmsg: data.errmsg },
|
||
status: ok ? 'SUCCESS' : 'FAILED',
|
||
errorMessage: ok ? undefined : data.errmsg || '获取手机号失败',
|
||
actorRef,
|
||
});
|
||
if (!phone) {
|
||
throw new InternalServerErrorException(data.errmsg || '获取手机号失败');
|
||
}
|
||
return phone;
|
||
}
|
||
|
||
async createJsapiPrepay(params: {
|
||
orderNo: string;
|
||
description: string;
|
||
amountFen: number;
|
||
openId: string;
|
||
notifyUrl: string;
|
||
platform?: 'h5' | 'mini';
|
||
}) {
|
||
if (!this.isPayEnabled()) {
|
||
throw new InternalServerErrorException(
|
||
'微信支付未配置:请关闭 MOCK_PAY 并配置 WX_MCH_ID、WX_MCH_SERIAL_NO、WX_MCH_PRIVATE_KEY、WX_API_V3_KEY',
|
||
);
|
||
}
|
||
const notifyUrl = params.notifyUrl || this.notifyUrl;
|
||
if (!notifyUrl) {
|
||
throw new InternalServerErrorException('请配置 WX_PAY_NOTIFY_URL');
|
||
}
|
||
const platform = params.platform ?? 'h5';
|
||
const payAppId = platform === 'mini' ? this.miniAppId || this.appId : this.appId || this.miniAppId;
|
||
if (!payAppId) {
|
||
throw new InternalServerErrorException(
|
||
platform === 'mini'
|
||
? '小程序支付未配置:请设置 WX_MINI_APP_ID'
|
||
: '微信支付未配置:请设置 WX_APP_ID',
|
||
);
|
||
}
|
||
const body = {
|
||
appid: payAppId,
|
||
mchid: this.mchId,
|
||
description: params.description,
|
||
out_trade_no: params.orderNo,
|
||
notify_url: notifyUrl,
|
||
amount: { total: params.amountFen, currency: 'CNY' },
|
||
payer: { openid: params.openId },
|
||
};
|
||
const path = '/v3/pay/transactions/jsapi';
|
||
const payload = JSON.stringify(body);
|
||
const auth = this.signPayRequest('POST', path, payload);
|
||
const res = await this.fetchPayJson<{ prepay_id?: string }>(
|
||
`https://api.mch.weixin.qq.com${path}`,
|
||
{
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Accept: 'application/json',
|
||
Authorization: auth,
|
||
},
|
||
body: payload,
|
||
},
|
||
);
|
||
if (!res.prepay_id) {
|
||
throw new InternalServerErrorException('微信预支付下单失败');
|
||
}
|
||
this.logger.log(`JSAPI prepay ok mchid=${this.mchId} orderNo=${params.orderNo}`);
|
||
const timeStamp = String(Math.floor(Date.now() / 1000));
|
||
const nonceStr = randomUUID().replace(/-/g, '');
|
||
const packageStr = `prepay_id=${res.prepay_id}`;
|
||
const message = `${payAppId}\n${timeStamp}\n${nonceStr}\n${packageStr}\n`;
|
||
const sign = createSign('RSA-SHA256');
|
||
sign.update(message);
|
||
sign.end();
|
||
const paySign = sign.sign(this.mchPrivateKey, 'base64');
|
||
return {
|
||
appId: payAppId,
|
||
timeStamp,
|
||
nonceStr,
|
||
package: packageStr,
|
||
signType: 'RSA' as const,
|
||
paySign,
|
||
};
|
||
}
|
||
|
||
async parsePayNotification(
|
||
headers: Record<string, string | string[] | undefined>,
|
||
rawBody: string,
|
||
) {
|
||
if (!this.isPayEnabled()) {
|
||
throw new BadRequestException('微信支付未启用');
|
||
}
|
||
const signature = this.headerValue(headers, 'wechatpay-signature');
|
||
const timestamp = this.headerValue(headers, 'wechatpay-timestamp');
|
||
const nonce = this.headerValue(headers, 'wechatpay-nonce');
|
||
if (!signature || !timestamp || !nonce) {
|
||
throw new BadRequestException('微信回调签名头缺失');
|
||
}
|
||
if (this.platformCert) {
|
||
const valid = verifyPaySignature({
|
||
platformPublicKeyPem: this.platformCert,
|
||
timestamp,
|
||
nonce,
|
||
body: rawBody,
|
||
signature,
|
||
});
|
||
if (!valid) {
|
||
throw new BadRequestException('微信回调验签失败');
|
||
}
|
||
} else {
|
||
this.logger.warn('WX_PLATFORM_CERT 未配置,跳过回调 RSA 验签(仅建议开发环境)');
|
||
}
|
||
|
||
const envelope = JSON.parse(rawBody) as WechatPayNotifyEnvelope;
|
||
if (envelope.event_type !== 'TRANSACTION.SUCCESS') {
|
||
throw new BadRequestException(`忽略的事件类型: ${envelope.event_type}`);
|
||
}
|
||
const resource = decryptPayResource(
|
||
this.apiV3Key,
|
||
envelope.resource.associated_data ?? '',
|
||
envelope.resource.nonce,
|
||
envelope.resource.ciphertext,
|
||
);
|
||
if (resource.trade_state !== 'SUCCESS') {
|
||
throw new BadRequestException(`交易未成功: ${resource.trade_state}`);
|
||
}
|
||
return {
|
||
transactionId: resource.transaction_id,
|
||
outTradeNo: resource.out_trade_no,
|
||
tradeState: resource.trade_state,
|
||
amountFen: resource.amount?.total ?? resource.amount?.payer_total ?? 0,
|
||
};
|
||
}
|
||
|
||
private headerValue(headers: Record<string, string | string[] | undefined>, key: string) {
|
||
const raw = headers[key] ?? headers[key.toLowerCase()];
|
||
if (Array.isArray(raw)) return raw[0];
|
||
return raw;
|
||
}
|
||
|
||
private async getAccessToken(forceRefresh = false): Promise<string> {
|
||
return this.fetchStableAccessToken({
|
||
cacheKey: ACCESS_TOKEN_KEY,
|
||
appId: this.appId,
|
||
appSecret: this.appSecret,
|
||
forceRefresh,
|
||
label: '服务号',
|
||
});
|
||
}
|
||
|
||
/** 小程序 access_token(getPhoneNumber / 发货管理等 wxa 接口必须用小程序 AppID) */
|
||
private async getMiniAccessToken(forceRefresh = false): Promise<string> {
|
||
const appId = this.miniAppId;
|
||
const appSecret = this.miniAppSecret;
|
||
if (!appId || !appSecret) {
|
||
throw new InternalServerErrorException(
|
||
'小程序未配置:请设置 WX_MINI_APP_ID / WX_MINI_APP_SECRET',
|
||
);
|
||
}
|
||
return this.fetchStableAccessToken({
|
||
cacheKey: MINI_ACCESS_TOKEN_KEY,
|
||
appId,
|
||
appSecret,
|
||
forceRefresh,
|
||
label: '小程序',
|
||
});
|
||
}
|
||
|
||
/**
|
||
* @see https://developers.weixin.qq.com/miniprogram/dev/server/API/mp-access-token/api_getstableaccesstoken.html
|
||
*/
|
||
private async fetchStableAccessToken(opts: {
|
||
cacheKey: string;
|
||
appId: string;
|
||
appSecret: string;
|
||
forceRefresh: boolean;
|
||
label: string;
|
||
}): Promise<string> {
|
||
if (!opts.appId || !opts.appSecret) {
|
||
throw new InternalServerErrorException(`${opts.label}未配置 AppID/Secret`);
|
||
}
|
||
if (!opts.forceRefresh) {
|
||
const cached = await this.redis.getJson<TokenCache>(opts.cacheKey);
|
||
if (cached && cached.expiresAt > Date.now()) return cached.accessToken;
|
||
} else {
|
||
await this.redis.del(opts.cacheKey);
|
||
}
|
||
|
||
const data = await this.fetchJson<{
|
||
access_token?: string;
|
||
expires_in?: number;
|
||
errcode?: number;
|
||
errmsg?: string;
|
||
}>('https://api.weixin.qq.com/cgi-bin/stable_token', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
grant_type: 'client_credential',
|
||
appid: opts.appId,
|
||
secret: opts.appSecret,
|
||
force_refresh: !!opts.forceRefresh,
|
||
}),
|
||
});
|
||
if (!data.access_token) {
|
||
this.logger.error(
|
||
`getStableAccessToken ${opts.label} failed: ${data.errcode} ${data.errmsg}`,
|
||
);
|
||
throw new InternalServerErrorException(data.errmsg || `获取${opts.label} access_token 失败`);
|
||
}
|
||
// 稳定版会提前约 5 分钟轮换;本地再提前 5 分钟过期,避免踩边
|
||
const ttl = Math.max((data.expires_in ?? 7200) - 300, 60);
|
||
await this.redis.setJson(
|
||
opts.cacheKey,
|
||
{ accessToken: data.access_token, expiresAt: Date.now() + ttl * 1000 },
|
||
ttl,
|
||
);
|
||
return data.access_token;
|
||
}
|
||
|
||
private async getJsapiTicket(): Promise<string> {
|
||
const cached = await this.redis.getJson<TicketCache>(JSAPI_TICKET_KEY);
|
||
if (cached && cached.expiresAt > Date.now()) return cached.ticket;
|
||
|
||
const accessToken = await this.getAccessToken();
|
||
const url = new URL('https://api.weixin.qq.com/cgi-bin/ticket/getticket');
|
||
url.searchParams.set('access_token', accessToken);
|
||
url.searchParams.set('type', 'jsapi');
|
||
const data = await this.fetchJson<{ ticket?: string; expires_in?: number; errcode?: number; errmsg?: string }>(
|
||
url.toString(),
|
||
);
|
||
if (!data.ticket) {
|
||
throw new InternalServerErrorException(data.errmsg || '获取 jsapi_ticket 失败');
|
||
}
|
||
const ttl = Math.max((data.expires_in ?? 7200) - 300, 60);
|
||
await this.redis.setJson(
|
||
JSAPI_TICKET_KEY,
|
||
{ ticket: data.ticket, expiresAt: Date.now() + ttl * 1000 },
|
||
ttl,
|
||
);
|
||
return data.ticket;
|
||
}
|
||
|
||
private signPayRequest(method: string, path: string, body: string) {
|
||
const timestamp = Math.floor(Date.now() / 1000);
|
||
const nonce = randomUUID();
|
||
const message = `${method}\n${path}\n${timestamp}\n${nonce}\n${body}\n`;
|
||
const sign = createSign('RSA-SHA256');
|
||
sign.update(message);
|
||
sign.end();
|
||
const signature = sign.sign(this.mchPrivateKey, 'base64');
|
||
return `WECHATPAY2-SHA256-RSA2048 mchid="${this.mchId}",nonce_str="${nonce}",signature="${signature}",timestamp="${timestamp}",serial_no="${this.mchSerialNo}"`;
|
||
}
|
||
|
||
private async fetchPayJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||
const res = await fetch(url, init);
|
||
const text = await res.text();
|
||
let data: T & { code?: string; message?: string };
|
||
try {
|
||
data = JSON.parse(text) as T & { code?: string; message?: string };
|
||
} catch {
|
||
this.logger.error(`WeChat Pay invalid JSON (${res.status}): ${text.slice(0, 300)}`);
|
||
throw new InternalServerErrorException('微信支付接口响应异常');
|
||
}
|
||
if (!res.ok) {
|
||
const detail = data.message || data.code || text.slice(0, 200);
|
||
this.logger.error(`WeChat Pay API ${res.status}: ${detail}`);
|
||
throw new InternalServerErrorException(`微信支付下单失败: ${detail}`);
|
||
}
|
||
return data;
|
||
}
|
||
|
||
private async fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||
const res = await fetch(url, init);
|
||
const text = await res.text();
|
||
try {
|
||
return JSON.parse(text) as T;
|
||
} catch {
|
||
this.logger.error(`WeChat API invalid JSON: ${text.slice(0, 200)}`);
|
||
throw new InternalServerErrorException('微信接口响应异常');
|
||
}
|
||
}
|
||
|
||
/** 解密小程序敏感数据(备用) */
|
||
decryptData(sessionKey: string, encryptedData: string, iv: string): Record<string, unknown> {
|
||
const key = Buffer.from(sessionKey, 'base64');
|
||
const decipher = createDecipheriv('aes-128-cbc', key, Buffer.from(iv, 'base64'));
|
||
decipher.setAutoPadding(true);
|
||
const decoded = Buffer.concat([
|
||
decipher.update(Buffer.from(encryptedData, 'base64')),
|
||
decipher.final(),
|
||
]);
|
||
return JSON.parse(decoded.toString('utf8')) as Record<string, unknown>;
|
||
}
|
||
}
|