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