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 { 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 { 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 { 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 { 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 { 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, 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, key: string) { const raw = headers[key] ?? headers[key.toLowerCase()]; if (Array.isArray(raw)) return raw[0]; return raw; } private async getAccessToken(): Promise { const cached = await this.redis.getJson(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_token(getPhoneNumber 等 wxa 接口必须用小程序 AppID) */ private async getMiniAccessToken(): Promise { 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(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 { const cached = await this.redis.getJson(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(url: string, init?: RequestInit): Promise { 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(url: string, init?: RequestInit): Promise { 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 { 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; } }