import { v4 as uuidv4 } from 'uuid'; import jwt from 'jsonwebtoken'; import { redis } from '../config/redis'; import { config } from '../config'; import { logger } from '../utils/logger'; import type { WechatLoginState, WechatAccessTokenResponse, WechatUserInfo, } from '@gala/shared/types'; const WECHAT_STATE_PREFIX = 'wechat:state:'; const SESSION_TOKEN_PREFIX = 'session:'; const STATE_TTL = 5 * 60; // 5 minutes const SESSION_TOKEN_TTL = 24 * 60 * 60; // 24 hours class WechatService { private readonly appId: string; private readonly appSecret: string; private readonly redirectUri: string; constructor() { this.appId = config.wechat.appId; this.appSecret = config.wechat.appSecret; this.redirectUri = config.wechat.redirectUri; } /** * Check if WeChat config is valid */ isConfigured(): boolean { return !!(this.appId && this.appSecret && this.redirectUri); } /** * Generate WeChat OAuth2 authorization URL */ generateAuthUrl(state: string): string { const baseUrl = 'https://open.weixin.qq.com/connect/qrconnect'; const params = new URLSearchParams({ appid: this.appId, redirect_uri: this.redirectUri, response_type: 'code', scope: 'snsapi_login', state: state, }); return `${baseUrl}?${params.toString()}#wechat_redirect`; } /** * Create login state and store in Redis */ async createLoginState(pcSocketId: string): Promise<{ success: boolean; data?: { authUrl: string; state: string; expiresAt: number }; error?: string; }> { if (!this.isConfigured()) { return { success: false, error: 'WeChat not configured' }; } try { const state = uuidv4(); const now = Date.now(); const expiresAt = now + STATE_TTL * 1000; const stateData: WechatLoginState = { state, pcSocketId, createdAt: now, expiresAt, }; await redis.setex( `${WECHAT_STATE_PREFIX}${state}`, STATE_TTL, JSON.stringify(stateData) ); const authUrl = this.generateAuthUrl(state); logger.info({ state, pcSocketId }, 'WeChat login state created'); return { success: true, data: { authUrl, state, expiresAt }, }; } catch (error) { logger.error({ error }, 'Failed to create WeChat login state'); return { success: false, error: 'Failed to create login state' }; } } /** * Exchange code for access_token and openid */ async getAccessToken(code: string): Promise<{ success: boolean; data?: { openid: string; accessToken: string }; error?: string; }> { try { const url = 'https://api.weixin.qq.com/sns/oauth2/access_token'; const params = new URLSearchParams({ appid: this.appId, secret: this.appSecret, code: code, grant_type: 'authorization_code', }); const response = await fetch(`${url}?${params.toString()}`); const data: WechatAccessTokenResponse = await response.json(); if (data.errcode) { logger.error({ errcode: data.errcode, errmsg: data.errmsg }, 'WeChat access_token error'); return { success: false, error: data.errmsg || 'Failed to get access token' }; } if (!data.openid || !data.access_token) { return { success: false, error: 'Invalid response from WeChat' }; } logger.info({ openid: data.openid }, 'WeChat access_token obtained'); return { success: true, data: { openid: data.openid, accessToken: data.access_token }, }; } catch (error) { logger.error({ error }, 'Failed to get WeChat access token'); return { success: false, error: 'Failed to get access token' }; } } /** * Get user info from WeChat (optional, requires user consent) */ async getUserInfo(accessToken: string, openid: string): Promise { try { const url = 'https://api.weixin.qq.com/sns/userinfo'; const params = new URLSearchParams({ access_token: accessToken, openid: openid, lang: 'zh_CN', }); const response = await fetch(`${url}?${params.toString()}`); const data = await response.json(); if (data.errcode) { logger.error({ errcode: data.errcode, errmsg: data.errmsg }, 'WeChat userinfo error'); return null; } return data as WechatUserInfo; } catch (error) { logger.error({ error }, 'Failed to get WeChat user info'); return null; } } /** * Handle WeChat callback - validate state and exchange code */ async handleCallback(code: string, state: string): Promise<{ success: boolean; data?: { openid: string; pcSocketId: string; sessionToken: string; userId: string; userInfo?: WechatUserInfo; }; error?: string; }> { try { // Validate state const stateKey = `${WECHAT_STATE_PREFIX}${state}`; const stateDataStr = await redis.get(stateKey); if (!stateDataStr) { return { success: false, error: 'Invalid or expired state' }; } const stateData: WechatLoginState = JSON.parse(stateDataStr); if (Date.now() > stateData.expiresAt) { await redis.del(stateKey); return { success: false, error: 'State expired' }; } // Exchange code for access_token const tokenResult = await this.getAccessToken(code); if (!tokenResult.success || !tokenResult.data) { return { success: false, error: tokenResult.error }; } const { openid, accessToken } = tokenResult.data; // Optionally get user info const userInfo = await this.getUserInfo(accessToken, openid); // Generate user ID and session token const userId = `wx_${openid.slice(0, 16)}`; const userName = userInfo?.nickname || `微信用户_${openid.slice(-6)}`; const sessionToken = jwt.sign( { userId, userName, openid }, config.jwtSecret || 'gala-secret-key', { expiresIn: '24h' } ); // Store session await redis.setex( `${SESSION_TOKEN_PREFIX}${sessionToken}`, SESSION_TOKEN_TTL, JSON.stringify({ userId, userName, openid }) ); // Delete used state await redis.del(stateKey); logger.info({ openid, userId, pcSocketId: stateData.pcSocketId }, 'WeChat login successful'); return { success: true, data: { openid, pcSocketId: stateData.pcSocketId, sessionToken, userId, userInfo: userInfo || undefined, }, }; } catch (error) { logger.error({ error, state }, 'Failed to handle WeChat callback'); return { success: false, error: 'Failed to process login' }; } } /** * Get state data (for debugging/status check) */ async getStateData(state: string): Promise { try { const data = await redis.get(`${WECHAT_STATE_PREFIX}${state}`); return data ? JSON.parse(data) : null; } catch (error) { logger.error({ error, state }, 'Failed to get state data'); return null; } } } export const wechatService = new WechatService();