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;
+}