gcx/backend/services/auth-service/src/infrastructure/wechat/wechat.provider.ts

131 lines
4.6 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.

// ============================================================
// 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 — 微信开放平台移动应用 AppIDwx 开头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 获取的一次性 code5 分钟有效)
*/
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_userinfosnsapi_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'));
});
});
}
}