Files
dukang/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts
T
jacy bca1d8afea feat(promo): generate WeChat mini-program codes on create
Use getwxacodeunlimit with activity id as scene, upload PNG to OSS qrcode/, and show in HQ admin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 00:21:10 +08:00

594 lines
22 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 { 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,
} 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 };
const ACCESS_TOKEN_KEY = 'wechat:access_token';
const MINI_ACCESS_TOKEN_KEY = 'wechat:mini_access_token';
const JSAPI_TICKET_KEY = 'wechat:jsapi_ticket';
@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}&timestamp=${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 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(): 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;
}
/** 小程序 access_tokengetPhoneNumber 等 wxa 接口必须用小程序 AppID) */
private async getMiniAccessToken(): Promise<string> {
const appId = this.miniAppId;
const appSecret = this.miniAppSecret;
if (!appId || !appSecret) {
throw new InternalServerErrorException(
'小程序未配置:请设置 WX_MINI_APP_ID / WX_MINI_APP_SECRET',
);
}
const cached = await this.redis.getJson<TokenCache>(MINI_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', appId);
url.searchParams.set('secret', 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(
MINI_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 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>;
}
}