feat: integrate WeChat Open Platform QR code login

- Add WeChat service for OAuth2 authentication flow
- Add WeChat routes (/api/wechat/login, /api/wechat/callback)
- Add WeChat types for login state and responses
- Update EntryQRCode component to support WeChat login
- Add WeChat config options (appId, appSecret, redirectUri)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
empty
2026-02-03 14:38:54 +08:00
parent 5d7bf74101
commit 3246479643
7 changed files with 574 additions and 16 deletions

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { ref, onMounted, onUnmounted, computed } from 'vue';
import QRCode from 'qrcode';
import { useDisplayStore } from '../stores/display';
import type { WechatLoginSuccessPayload } from '@gala/shared/types';
const props = defineProps<{
mobileUrl: string;
@@ -8,14 +10,59 @@ const props = defineProps<{
const emit = defineEmits<{
close: [];
loginSuccess: [payload: WechatLoginSuccessPayload];
}>();
const displayStore = useDisplayStore();
const qrCodeDataUrl = ref<string>('');
const isLoading = ref(true);
const error = ref<string | null>(null);
const wechatAuthUrl = ref<string | null>(null);
const loginState = ref<string | null>(null);
// Use WeChat auth URL if available, otherwise fallback to mobile URL
const qrUrl = computed(() => wechatAuthUrl.value || props.mobileUrl);
// Fetch WeChat login URL from server
async function fetchWechatLoginUrl() {
isLoading.value = true;
error.value = null;
try {
const socket = displayStore.getSocket();
const socketId = socket?.id;
if (!socketId) {
console.warn('[EntryQRCode] Socket not connected, using fallback URL');
await generateQRCode(props.mobileUrl);
return;
}
const apiUrl = import.meta.env.VITE_API_URL || '';
const response = await fetch(`${apiUrl}/api/wechat/login?pcSocketId=${socketId}`);
const result = await response.json();
if (result.success && result.data) {
wechatAuthUrl.value = result.data.authUrl;
loginState.value = result.data.state;
await generateQRCode(result.data.authUrl);
console.log('[EntryQRCode] WeChat auth URL obtained');
} else {
console.warn('[EntryQRCode] WeChat not configured, using fallback URL');
await generateQRCode(props.mobileUrl);
}
} catch (err) {
console.error('[EntryQRCode] Failed to fetch WeChat login URL:', err);
// Fallback to mobile URL
await generateQRCode(props.mobileUrl);
}
}
// Generate QR code
async function generateQRCode() {
async function generateQRCode(url: string) {
try {
qrCodeDataUrl.value = await QRCode.toDataURL(props.mobileUrl, {
qrCodeDataUrl.value = await QRCode.toDataURL(url, {
width: 400,
margin: 2,
color: {
@@ -23,11 +70,21 @@ async function generateQRCode() {
light: '#ffffff',
},
});
isLoading.value = false;
} catch (err) {
console.error('Failed to generate QR code:', err);
error.value = '生成二维码失败';
isLoading.value = false;
}
}
// Handle WeChat login success event
function handleWechatLoginSuccess(payload: WechatLoginSuccessPayload) {
console.log('[EntryQRCode] WeChat login success:', payload);
emit('loginSuccess', payload);
emit('close');
}
// Handle ESC key to close
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
@@ -35,12 +92,30 @@ function handleKeydown(e: KeyboardEvent) {
}
}
// Setup WebSocket listener for login success
function setupSocketListener() {
const socket = displayStore.getSocket();
if (socket) {
socket.on('wechat:login_success' as any, handleWechatLoginSuccess);
}
}
// Cleanup WebSocket listener
function cleanupSocketListener() {
const socket = displayStore.getSocket();
if (socket) {
socket.off('wechat:login_success' as any, handleWechatLoginSuccess);
}
}
onMounted(() => {
generateQRCode();
fetchWechatLoginUrl();
setupSocketListener();
window.addEventListener('keydown', handleKeydown);
});
onUnmounted(() => {
cleanupSocketListener();
window.removeEventListener('keydown', handleKeydown);
});
</script>
@@ -59,12 +134,18 @@ onUnmounted(() => {
<!-- QR Code -->
<div class="qrcode-container">
<img v-if="qrCodeDataUrl" :src="qrCodeDataUrl" alt="入场二维码" class="qrcode-image" />
<div v-else class="qrcode-loading">生成中...</div>
<div v-if="isLoading" class="qrcode-loading">生成中...</div>
<div v-else-if="error" class="qrcode-error">{{ error }}</div>
<img v-else :src="qrCodeDataUrl" alt="入场二维码" class="qrcode-image" />
</div>
<!-- URL hint -->
<p class="url-hint">{{ mobileUrl }}</p>
<!-- WeChat logo hint -->
<div v-if="wechatAuthUrl" class="wechat-hint">
<svg class="wechat-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.269-.03-.406-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z"/>
</svg>
<span>微信扫码登录</span>
</div>
<!-- Instructions -->
<div class="instructions">
@@ -74,11 +155,11 @@ onUnmounted(() => {
</div>
<div class="step">
<span class="step-num">2</span>
<span class="step-text">填写姓名和部门</span>
<span class="step-text">{{ wechatAuthUrl ? '授权登录' : '填写姓名和部门' }}</span>
</div>
<div class="step">
<span class="step-num">3</span>
<span class="step-text">点击进入年会</span>
<span class="step-text">{{ wechatAuthUrl ? '自动进入年会' : '点击进入年会' }}</span>
</div>
</div>
@@ -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 {

View File

@@ -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) => {

View File

@@ -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;

View File

@@ -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 `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>微信登录${success ? '成功' : '失败'}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.container {
text-align: center;
padding: 40px;
}
.icon {
width: 80px;
height: 80px;
border-radius: 50%;
background: ${color};
display: flex;
align-items: center;
justify-content: center;
font-size: 40px;
margin: 0 auto 24px;
}
.message {
font-size: 24px;
margin-bottom: 16px;
}
.hint {
color: rgba(255,255,255,0.6);
font-size: 14px;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">${icon}</div>
<div class="message">${message}</div>
<div class="hint">${success ? '请返回大屏查看' : '请重新扫码'}</div>
</div>
<script>
${success ? 'setTimeout(() => window.close(), 3000);' : ''}
</script>
</body>
</html>
`.trim();
}
export default router;

View File

@@ -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<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 = 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<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();

View File

@@ -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';

View File

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