Files
company-celebration/packages/server/src/services/wechat.service.ts
empty 83bf1d3a43 chore: sync various improvements and fixes
- Update gitignore and serena config
- Improve connection and voting stores
- Enhance admin routes and socket handling
- Update client-screen views
- Add auth middleware

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 23:31:38 +08:00

250 lines
6.9 KiB
TypeScript

import { v4 as uuidv4 } from 'uuid';
import { redis } from '../config/redis';
import { config } from '../config';
import { logger } from '../utils/logger';
import type {
WechatLoginState,
WechatAccessTokenResponse,
WechatUserInfo,
} from '@gala/shared/types';
import { createSessionToken } from '../utils/auth';
const WECHAT_STATE_PREFIX = 'wechat:state:';
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?: { appId: string; redirectUri: 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)
);
logger.info({ state, pcSocketId }, 'WeChat login state created');
return {
success: true,
data: {
appId: this.appId,
redirectUri: this.redirectUri,
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<WechatUserInfo | null> {
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 = await createSessionToken(
{ userId, userName, openid, role: 'user' },
SESSION_TOKEN_TTL
);
// 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<WechatLoginState | null> {
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();