diff --git a/packages/client-screen/src/components/EntryQRCode.vue b/packages/client-screen/src/components/EntryQRCode.vue index e84c11e..9e0e26e 100644 --- a/packages/client-screen/src/components/EntryQRCode.vue +++ b/packages/client-screen/src/components/EntryQRCode.vue @@ -1,6 +1,8 @@ @@ -59,12 +134,18 @@ onUnmounted(() => {
- 入场二维码 -
生成中...
+
生成中...
+
{{ error }}
+ 入场二维码
- -

{{ mobileUrl }}

+ +
+ + + + 微信扫码登录 +
@@ -74,11 +155,11 @@ onUnmounted(() => {
2 - 填写姓名和部门 + {{ wechatAuthUrl ? '授权登录' : '填写姓名和部门' }}
3 - 点击进入年会 + {{ wechatAuthUrl ? '自动进入年会' : '点击进入年会' }}
@@ -163,7 +244,8 @@ onUnmounted(() => { display: block; } -.qrcode-loading { +.qrcode-loading, +.qrcode-error { width: 300px; height: 300px; display: flex; @@ -173,11 +255,23 @@ onUnmounted(() => { font-size: 18px; } -.url-hint { - font-size: 14px; - color: $color-text-muted; - margin-bottom: 32px; - font-family: monospace; +.qrcode-error { + color: #ff4d4f; +} + +.wechat-hint { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + color: #07c160; + font-size: 16px; + margin-bottom: 24px; +} + +.wechat-icon { + width: 24px; + height: 24px; } .instructions { diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 64d0b3c..97c9391 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -9,6 +9,7 @@ import { requestLogger } from './middleware/requestLogger'; import voteRoutes from './routes/vote.routes'; import adminRoutes from './routes/admin.routes'; import scanRoutes from './routes/scan.routes'; +import wechatRoutes from './routes/wechat.routes'; export const app: Application = express(); @@ -59,6 +60,7 @@ app.get('/health', (_req, res) => { app.use('/api/vote', voteRoutes); app.use('/api/admin', adminRoutes); app.use('/api/scan', scanRoutes); +app.use('/api/wechat', wechatRoutes); // 404 handler app.use((_req, res) => { diff --git a/packages/server/src/config/index.ts b/packages/server/src/config/index.ts index ab5c438..01412b1 100644 --- a/packages/server/src/config/index.ts +++ b/packages/server/src/config/index.ts @@ -50,6 +50,13 @@ export const config = { batchSize: 100, intervalMs: 1000, }, + + // WeChat Open Platform + wechat: { + appId: process.env.WECHAT_APP_ID || '', + appSecret: process.env.WECHAT_APP_SECRET || '', + redirectUri: process.env.WECHAT_REDIRECT_URI || '', + }, } as const; export type Config = typeof config; diff --git a/packages/server/src/routes/wechat.routes.ts b/packages/server/src/routes/wechat.routes.ts new file mode 100644 index 0000000..42de669 --- /dev/null +++ b/packages/server/src/routes/wechat.routes.ts @@ -0,0 +1,145 @@ +import { Router, Request, Response } from 'express'; +import { wechatService } from '../services/wechat.service'; +import { getIO } from '../socket'; +import { logger } from '../utils/logger'; + +const router = Router(); + +/** + * GET /api/wechat/login + * Generate WeChat login QR code URL + */ +router.get('/login', async (req: Request, res: Response) => { + const { pcSocketId } = req.query; + + if (!pcSocketId || typeof pcSocketId !== 'string') { + return res.status(400).json({ + success: false, + error: 'pcSocketId is required', + }); + } + + const result = await wechatService.createLoginState(pcSocketId); + + if (!result.success) { + return res.status(500).json(result); + } + + res.json(result); +}); + +/** + * GET /api/wechat/callback + * WeChat OAuth2 callback handler + */ +router.get('/callback', async (req: Request, res: Response) => { + const { code, state } = req.query; + + if (!code || !state || typeof code !== 'string' || typeof state !== 'string') { + logger.warn({ code, state }, 'Invalid WeChat callback parameters'); + return res.status(400).send(renderCallbackPage(false, 'Invalid parameters')); + } + + const result = await wechatService.handleCallback(code, state); + + if (!result.success || !result.data) { + logger.error({ error: result.error }, 'WeChat callback failed'); + return res.status(400).send(renderCallbackPage(false, result.error || 'Login failed')); + } + + const { openid, pcSocketId, sessionToken, userId, userInfo } = result.data; + + // Notify PC client via WebSocket + try { + const io = getIO(); + io.to(pcSocketId).emit('wechat:login_success' as any, { + openid, + sessionToken, + userId, + userName: userInfo?.nickname || `微信用户_${openid.slice(-6)}`, + userInfo, + }); + logger.info({ pcSocketId, userId }, 'WeChat login success notification sent'); + } catch (error) { + logger.error({ error, pcSocketId }, 'Failed to notify PC client'); + } + + // Return success page + res.send(renderCallbackPage(true, 'Login successful')); +}); + +/** + * GET /api/wechat/status + * Check WeChat configuration status + */ +router.get('/status', (_req: Request, res: Response) => { + res.json({ + configured: wechatService.isConfigured(), + }); +}); + +/** + * Render callback result page + */ +function renderCallbackPage(success: boolean, message: string): string { + const color = success ? '#52c41a' : '#ff4d4f'; + const icon = success ? '✓' : '✗'; + + return ` + + + + + + 微信登录${success ? '成功' : '失败'} + + + +
+
${icon}
+
${message}
+
${success ? '请返回大屏查看' : '请重新扫码'}
+
+ + + + `.trim(); +} + +export default router; diff --git a/packages/server/src/services/wechat.service.ts b/packages/server/src/services/wechat.service.ts new file mode 100644 index 0000000..3f67710 --- /dev/null +++ b/packages/server/src/services/wechat.service.ts @@ -0,0 +1,255 @@ +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(); diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index a043ec0..87d8fc6 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -4,3 +4,4 @@ export * from './vote.types'; export * from './draw.types'; export * from './admin.types'; export * from './scan-login.types'; +export * from './wechat.types'; diff --git a/packages/shared/src/types/wechat.types.ts b/packages/shared/src/types/wechat.types.ts new file mode 100644 index 0000000..b70cd8f --- /dev/null +++ b/packages/shared/src/types/wechat.types.ts @@ -0,0 +1,54 @@ +// WeChat Open Platform login types + +export interface WechatLoginState { + state: string; + pcSocketId: string; + createdAt: number; + expiresAt: number; +} + +export interface WechatLoginResponse { + success: boolean; + data?: { + authUrl: string; + state: string; + expiresAt: number; + }; + error?: string; +} + +export interface WechatCallbackResult { + success: boolean; + openid?: string; + pcSocketId?: string; + error?: string; +} + +export interface WechatAccessTokenResponse { + access_token?: string; + expires_in?: number; + refresh_token?: string; + openid?: string; + scope?: string; + errcode?: number; + errmsg?: string; +} + +export interface WechatUserInfo { + openid: string; + nickname?: string; + sex?: number; + province?: string; + city?: string; + country?: string; + headimgurl?: string; + privilege?: string[]; + unionid?: string; +} + +export interface WechatLoginSuccessPayload { + openid: string; + sessionToken: string; + userId: string; + userInfo?: WechatUserInfo; +}