diff --git a/packages/client-mobile/src/views/HomeView.vue b/packages/client-mobile/src/views/HomeView.vue index ae1882d..437d011 100644 --- a/packages/client-mobile/src/views/HomeView.vue +++ b/packages/client-mobile/src/views/HomeView.vue @@ -1,16 +1,120 @@ diff --git a/packages/client-screen/src/components/EntryQRCode.vue b/packages/client-screen/src/components/EntryQRCode.vue index 9e0e26e..2130055 100644 --- a/packages/client-screen/src/components/EntryQRCode.vue +++ b/packages/client-screen/src/components/EntryQRCode.vue @@ -1,8 +1,6 @@ @@ -140,7 +76,7 @@ onUnmounted(() => { -
+
@@ -155,11 +91,11 @@ onUnmounted(() => {
2 - {{ wechatAuthUrl ? '授权登录' : '填写姓名和部门' }} + 确认授权登录
3 - {{ wechatAuthUrl ? '自动进入年会' : '点击进入年会' }} + 自动进入年会
diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index cd5e975..9f5cd5c 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -3,6 +3,8 @@ import cors from 'cors'; import helmet from 'helmet'; import compression from 'compression'; import path from 'path'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; import { config } from './config'; import { logger } from './utils/logger'; import { errorHandler } from './middleware/errorHandler'; @@ -11,6 +13,12 @@ import voteRoutes from './routes/vote.routes'; import adminRoutes from './routes/admin.routes'; import scanRoutes from './routes/scan.routes'; import wechatRoutes from './routes/wechat.routes'; +import wechatMpRoutes from './routes/wechat-mp.routes'; +import publicRoutes from './routes/public.routes'; + +// ES Module __dirname equivalent +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); export const app: Application = express(); @@ -65,6 +73,8 @@ app.use('/api/vote', voteRoutes); app.use('/api/admin', adminRoutes); app.use('/api/scan', scanRoutes); app.use('/api/wechat', wechatRoutes); +app.use('/api/mp', wechatMpRoutes); +app.use('/api/public', publicRoutes); // 404 handler app.use((_req, res) => { diff --git a/packages/server/src/routes/wechat-mp.routes.ts b/packages/server/src/routes/wechat-mp.routes.ts new file mode 100644 index 0000000..6df5939 --- /dev/null +++ b/packages/server/src/routes/wechat-mp.routes.ts @@ -0,0 +1,109 @@ +import { Router, Request, Response } from 'express'; +import { wechatMpService } from '../services/wechat-mp.service'; +import { config } from '../config'; +import { logger } from '../utils/logger'; + +const router = Router(); + +/** + * GET /api/mp/auth-url + * 获取公众号网页授权URL + * 前端在微信环境中调用此接口,获取授权跳转URL + */ +router.get('/auth-url', (req: Request, res: Response) => { + try { + if (!wechatMpService.isConfigured()) { + return res.json({ + success: false, + error: 'WeChat MP not configured', + }); + } + + // 从query获取回调地址,默认使用移动端URL + const redirectUri = (req.query.redirect_uri as string) || config.mobileClientUrl; + + // 生成随机state防止CSRF + const state = Math.random().toString(36).slice(2, 15); + + // 使用snsapi_base静默授权(只获取openid) + const scope = (req.query.scope as 'snsapi_base' | 'snsapi_userinfo') || 'snsapi_base'; + + const authUrl = wechatMpService.generateAuthUrl(redirectUri, state, scope); + + logger.info({ redirectUri, scope }, 'Generated WeChat MP auth URL'); + + return res.json({ + success: true, + data: { + authUrl, + state, + }, + }); + } catch (error) { + logger.error({ error }, 'Failed to generate auth URL'); + return res.status(500).json({ + success: false, + error: 'Internal server error', + }); + } +}); + +/** + * POST /api/mp/login + * 用code完成登录 + * 前端在授权回调后,携带code调用此接口完成登录 + */ +router.post('/login', async (req: Request, res: Response) => { + try { + const { code } = req.body; + + if (!code) { + return res.status(400).json({ + success: false, + error: 'Missing code parameter', + }); + } + + if (!wechatMpService.isConfigured()) { + return res.json({ + success: false, + error: 'WeChat MP not configured', + }); + } + + const result = await wechatMpService.login(code); + + if (!result.success) { + return res.json({ + success: false, + error: result.error, + }); + } + + return res.json({ + success: true, + data: result.data, + }); + } catch (error) { + logger.error({ error }, 'Failed to process MP login'); + return res.status(500).json({ + success: false, + error: 'Internal server error', + }); + } +}); + +/** + * GET /api/mp/config + * 获取公众号配置状态(不返回敏感信息) + */ +router.get('/config', (_req: Request, res: Response) => { + return res.json({ + success: true, + data: { + configured: wechatMpService.isConfigured(), + }, + }); +}); + +export default router; diff --git a/packages/server/src/services/wechat-mp.service.ts b/packages/server/src/services/wechat-mp.service.ts new file mode 100644 index 0000000..de8cef8 --- /dev/null +++ b/packages/server/src/services/wechat-mp.service.ts @@ -0,0 +1,173 @@ +import { config } from '../config'; +import { logger } from '../utils/logger'; +import { createSessionToken } from '../utils/auth'; +import type { WechatAccessTokenResponse, WechatUserInfo } from '@gala/shared/types'; + +const SESSION_TOKEN_TTL = 24 * 60 * 60; // 24 hours + +/** + * 微信公众号网页授权服务 + * 用于在微信内H5页面进行OAuth授权登录 + */ +class WechatMpService { + private readonly appId: string; + private readonly appSecret: string; + + constructor() { + this.appId = config.wechatMp.appId; + this.appSecret = config.wechatMp.appSecret; + } + + /** + * 检查公众号配置是否有效 + */ + isConfigured(): boolean { + return !!(this.appId && this.appSecret); + } + + /** + * 生成公众号网页授权URL + * @param redirectUri 授权后回调地址 + * @param state 状态参数,用于防止CSRF + * @param scope 授权作用域: snsapi_base(静默授权) 或 snsapi_userinfo(需用户确认) + */ + generateAuthUrl( + redirectUri: string, + state: string, + scope: 'snsapi_base' | 'snsapi_userinfo' = 'snsapi_base' + ): string { + const baseUrl = 'https://open.weixin.qq.com/connect/oauth2/authorize'; + const params = new URLSearchParams({ + appid: this.appId, + redirect_uri: redirectUri, + response_type: 'code', + scope: scope, + state: state, + }); + return `${baseUrl}?${params.toString()}#wechat_redirect`; + } + + /** + * 用code换取access_token和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 = (await response.json()) as WechatAccessTokenResponse; + + if (data.errcode) { + logger.error({ errcode: data.errcode, errmsg: data.errmsg }, 'WeChat MP 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 MP access_token obtained'); + + return { + success: true, + data: { openid: data.openid, accessToken: data.access_token }, + }; + } catch (error) { + logger.error({ error }, 'Failed to get WeChat MP access token'); + return { success: false, error: 'Failed to get access token' }; + } + } + + /** + * 获取用户信息(snsapi_userinfo时可用) + */ + 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()) as { errcode?: number; errmsg?: string } & WechatUserInfo; + + if (data.errcode) { + logger.error({ errcode: data.errcode, errmsg: data.errmsg }, 'WeChat MP userinfo error'); + return null; + } + + return data as WechatUserInfo; + } catch (error) { + logger.error({ error }, 'Failed to get WeChat MP user info'); + return null; + } + } + + /** + * 用code完成登录,返回sessionToken + */ + async login(code: string): Promise<{ + success: boolean; + data?: { + openid: string; + sessionToken: string; + userId: string; + userName: string; + userInfo?: WechatUserInfo; + }; + error?: string; + }> { + try { + // 用code换取access_token + const tokenResult = await this.getAccessToken(code); + if (!tokenResult.success || !tokenResult.data) { + return { success: false, error: tokenResult.error }; + } + + const { openid, accessToken } = tokenResult.data; + + // 尝试获取用户信息(如果是snsapi_userinfo授权) + const userInfo = await this.getUserInfo(accessToken, openid); + + // 生成用户ID和名称 + const userId = `wx_${openid.slice(0, 16)}`; + const userName = userInfo?.nickname || `微信用户_${openid.slice(-6)}`; + + // 创建session token + const sessionToken = await createSessionToken( + { userId, userName, openid, role: 'user' }, + SESSION_TOKEN_TTL + ); + + logger.info({ openid, userId }, 'WeChat MP login successful'); + + return { + success: true, + data: { + openid, + sessionToken, + userId, + userName, + userInfo: userInfo || undefined, + }, + }; + } catch (error) { + logger.error({ error }, 'Failed to process WeChat MP login'); + return { success: false, error: 'Failed to process login' }; + } + } +} + +export const wechatMpService = new WechatMpService();