// ============================================================ // WechatProvider — 微信开放平台 HTTP API 封装 // // 实现微信 OAuth 2.0 授权码模式(移动应用): // // Step 1 (客户端): 微信 SDK 向微信 App 发起授权 → 获得 code // Step 2 (本文件): code → access_token + openid + unionid // Step 3 (本文件): access_token + openid → 用户信息(昵称、头像等) // // 微信 API 特点: // - 返回格式始终是 200 OK;错误通过 errcode 字段区分 // - access_token 有效期 7200 秒(2小时) // - code 一次性使用,5 分钟内有效 // - unionid 仅在 snsapi_userinfo scope 且应用已接入开放平台时有值 // // 必要环境变量: // WECHAT_APP_ID — 微信开放平台移动应用 AppID(wx 开头,16位) // WECHAT_APP_SECRET — 应用密钥(32位,严格保密,不可泄露到客户端) // // 安全注意事项: // - APP_SECRET 只在服务端使用,绝不下发给 App // - code 交换 access_token 在服务端完成(防 MitM 攻击) // - 生产环境应对 code 做防重放检测(Redis 标记已使用的 code) // ============================================================ import { Injectable, Logger, BadRequestException } from '@nestjs/common'; import * as https from 'https'; /** 微信 access_token 接口响应 */ export interface WechatTokenResponse { access_token: string; expires_in: number; refresh_token: string; openid: string; scope: string; unionid?: string; // 绑定开放平台账号时才有 errcode?: number; errmsg?: string; } /** 微信用户信息接口响应 */ export interface WechatUserInfo { openid: string; nickname: string; sex: number; // 0=未知 1=男 2=女 province: string; city: string; country: string; headimgurl: string; // 用户头像 URL,最后一个数值为图片大小(0=640x640) privilege: string[]; unionid?: string; errcode?: number; errmsg?: string; } @Injectable() export class WechatProvider { private readonly logger = new Logger('WechatProvider'); private readonly appId = process.env.WECHAT_APP_ID; private readonly appSecret = process.env.WECHAT_APP_SECRET; /** * Step 2: 用 code 换取 access_token * * API: GET https://api.weixin.qq.com/sns/oauth2/access_token * ?appid=APPID&secret=SECRET&code=CODE&grant_type=authorization_code * * @param code 客户端从微信 SDK 获取的一次性 code(5 分钟有效) */ async exchangeCodeForToken(code: string): Promise { const url = `https://api.weixin.qq.com/sns/oauth2/access_token` + `?appid=${this.appId}&secret=${this.appSecret}` + `&code=${code}&grant_type=authorization_code`; const data = await this.httpsGet(url); const result = JSON.parse(data) as WechatTokenResponse; if (result.errcode) { this.logger.error(`WeChat token exchange failed: [${result.errcode}] ${result.errmsg}`); throw new BadRequestException(`微信授权失败: ${result.errmsg} (code: ${result.errcode})`); } this.logger.log(`WeChat token exchanged: openid=${result.openid.slice(0, 8)}...`); return result; } /** * Step 3: 用 access_token 获取微信用户信息 * * API: GET https://api.weixin.qq.com/sns/userinfo * ?access_token=TOKEN&openid=OPENID&lang=zh_CN * * 需要 scope = snsapi_userinfo(snsapi_base 只能获取 openid) * * @param accessToken 来自 exchangeCodeForToken 的 access_token * @param openid 来自 exchangeCodeForToken 的 openid */ async getUserInfo(accessToken: string, openid: string): Promise { const url = `https://api.weixin.qq.com/sns/userinfo` + `?access_token=${accessToken}&openid=${openid}&lang=zh_CN`; const data = await this.httpsGet(url); const result = JSON.parse(data) as WechatUserInfo; if (result.errcode) { this.logger.error(`WeChat userinfo failed: [${result.errcode}] ${result.errmsg}`); throw new BadRequestException(`获取微信用户信息失败: ${result.errmsg}`); } return result; } /** 简单的 HTTPS GET 封装(不引入外部 HTTP 库) */ private httpsGet(url: string): Promise { return new Promise((resolve, reject) => { const req = https.get(url, (res) => { let body = ''; res.on('data', (chunk) => (body += chunk)); res.on('end', () => resolve(body)); }); req.on('error', reject); req.setTimeout(10000, () => { req.destroy(); reject(new Error('WeChat API timeout')); }); }); } }