微信SDK接通

This commit is contained in:
2026-07-01 19:56:44 +08:00
parent a1cbfc7241
commit aea1513836
38 changed files with 1610 additions and 92 deletions
@@ -0,0 +1,259 @@
import { createDecipheriv, createHash, createSign, randomBytes, randomUUID } from 'crypto';
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
import { loadAppConfig } from '@dukang/shared-types';
import { RedisService } from '../../common/redis/redis.service';
import type { IWechatProvider, WechatCodeSession, WechatOAuthSession } from './wechat.interface';
type TokenCache = { accessToken: string; expiresAt: number };
type TicketCache = { ticket: string; expiresAt: number };
const ACCESS_TOKEN_KEY = 'wechat:access_token';
const JSAPI_TICKET_KEY = 'wechat:jsapi_ticket';
@Injectable()
export class WechatApiProvider implements IWechatProvider {
private readonly logger = new Logger(WechatApiProvider.name);
private readonly config = loadAppConfig();
private readonly appId = process.env.WX_APP_ID ?? '';
private readonly appSecret = process.env.WX_APP_SECRET ?? '';
private readonly mchId = process.env.WX_MCH_ID ?? '';
private readonly mchSerialNo = process.env.WX_MCH_SERIAL_NO ?? '';
private readonly mchPrivateKey = (process.env.WX_MCH_PRIVATE_KEY ?? '').replace(/\\n/g, '\n');
private readonly apiV3Key = process.env.WX_API_V3_KEY ?? '';
private readonly notifyUrl = process.env.WX_PAY_NOTIFY_URL ?? '';
constructor(private readonly redis: RedisService) {}
isEnabled() {
return this.config.wechatAuthEnabled && !!this.appId && !!this.appSecret;
}
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): Promise<WechatCodeSession> {
const url = new URL('https://api.weixin.qq.com/sns/jscode2session');
url.searchParams.set('appid', this.appId);
url.searchParams.set('secret', this.appSecret);
url.searchParams.set('js_code', code);
url.searchParams.set('grant_type', 'authorization_code');
const data = await this.fetchJson<{
openid?: string;
unionid?: string;
session_key?: string;
errcode?: number;
errmsg?: string;
}>(url.toString());
if (!data.openid) {
throw new InternalServerErrorException(data.errmsg || '微信 code2session 失败');
}
return {
openId: data.openid,
unionId: data.unionid,
sessionKey: data.session_key,
};
}
async oauth2AccessToken(code: string): Promise<WechatOAuthSession> {
const url = new URL('https://api.weixin.qq.com/sns/oauth2/access_token');
url.searchParams.set('appid', this.appId);
url.searchParams.set('secret', this.appSecret);
url.searchParams.set('code', code);
url.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;
}>(url.toString());
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 createJssdkConfig(url: string) {
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}&timestamp=${timestamp}&url=${url}`;
const signature = createHash('sha1').update(raw).digest('hex');
return {
appId: this.appId,
timestamp,
nonceStr,
signature,
jsApiList: ['getLocation', 'scanQRCode', 'chooseWXPay'],
};
}
async getPhoneNumberByCode(code: string, platform: 'mini' | 'h5'): Promise<string> {
if (platform === 'h5') {
throw new InternalServerErrorException('H5 请使用短信绑定手机号');
}
const accessToken = await this.getAccessToken();
const url = `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 };
}>(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
});
const phone = data.phone_info?.purePhoneNumber || data.phone_info?.phoneNumber;
if (!phone) {
throw new InternalServerErrorException(data.errmsg || '获取手机号失败');
}
return phone;
}
async createJsapiPrepay(params: {
orderNo: string;
description: string;
amountFen: number;
openId: string;
notifyUrl: string;
}) {
if (!this.mchId || !this.mchPrivateKey || !this.apiV3Key) {
throw new InternalServerErrorException('微信支付商户配置不完整');
}
const notifyUrl = params.notifyUrl || this.notifyUrl;
const body = {
appid: this.appId,
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.fetchJson<{ 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('微信预支付下单失败');
}
const timeStamp = String(Math.floor(Date.now() / 1000));
const nonceStr = randomUUID().replace(/-/g, '');
const packageStr = `prepay_id=${res.prepay_id}`;
const message = `${this.appId}\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: this.appId,
timeStamp,
nonceStr,
package: packageStr,
signType: 'RSA' as const,
paySign,
};
}
private async getAccessToken(): Promise<string> {
const cached = await this.redis.getJson<TokenCache>(ACCESS_TOKEN_KEY);
if (cached && cached.expiresAt > Date.now()) return cached.accessToken;
const url = new URL('https://api.weixin.qq.com/cgi-bin/token');
url.searchParams.set('grant_type', 'client_credential');
url.searchParams.set('appid', this.appId);
url.searchParams.set('secret', this.appSecret);
const data = await this.fetchJson<{ access_token?: string; expires_in?: number; errcode?: number; errmsg?: string }>(
url.toString(),
);
if (!data.access_token) {
throw new InternalServerErrorException(data.errmsg || '获取 access_token 失败');
}
const ttl = Math.max((data.expires_in ?? 7200) - 300, 60);
await this.redis.setJson(
ACCESS_TOKEN_KEY,
{ 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 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>;
}
}