feat: implement WeChat MP OAuth login

- Add wechat-mp.service.ts for MP web authorization
- Add wechat-mp.routes.ts with /api/mp endpoints
- Update EntryQRCode.vue to show H5 URL QR code
- Update HomeView.vue with WeChat auth detection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
empty
2026-02-03 21:33:32 +08:00
parent b53e732ffa
commit 9b11f99fed
5 changed files with 415 additions and 83 deletions

View File

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

View File

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

View File

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