131 lines
4.6 KiB
TypeScript
131 lines
4.6 KiB
TypeScript
// ============================================================
|
||
// 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<WechatTokenResponse> {
|
||
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<WechatUserInfo> {
|
||
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<string> {
|
||
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'));
|
||
});
|
||
});
|
||
}
|
||
}
|