Преглед изворни кода

✨ feat(auth): 新增微信登录功能

- 添加微信登录API路由和授权服务
- 创建微信登录按钮组件和回调页面
- 扩展用户实体添加微信相关字段
- 实现微信OAuth2授权流程
- 支持绑定和解绑微信账号功能
- 添加环境变量配置示例文件
yourname пре 6 месеци
родитељ
комит
6d50ef6d62

+ 37 - 0
.env.example

@@ -0,0 +1,37 @@
+# 应用配置
+APP_NAME='股票交易直播培训平台'
+
+# 即时通讯配置
+IM_APP_ID=your_im_app_id
+IM_APP_KEY=your_im_app_key
+IM_APP_SIGN=your_im_app_sign
+
+# RTC配置
+RTC_APP_ID=your_rtc_app_id
+RTC_APP_KEY=your_rtc_app_key
+
+# 股票API许可证
+STOCK_API_LICENSE=your_stock_api_license
+
+# 视频点播配置
+VOD_SECRET_ID=your_vod_secret_id
+VOD_SECRET_KEY=your_vod_secret_key
+
+# 微信服务号配置(公众号)- 网页授权登录
+WECHAT_MP_APP_ID=your_wechat_mp_app_id
+WECHAT_MP_APP_SECRET=your_wechat_mp_app_secret
+
+# 微信开放平台配置(可选,用于多端统一)
+WECHAT_OPEN_APP_ID=your_wechat_open_app_id
+WECHAT_OPEN_APP_SECRET=your_wechat_open_app_secret
+
+# 数据库配置(如果使用外部数据库)
+# DB_HOST=localhost
+# DB_PORT=3306
+# DB_USERNAME=root
+# DB_PASSWORD=password
+# DB_DATABASE=your_database
+
+# JWT配置(如果使用自定义JWT密钥)
+# JWT_SECRET=your_jwt_secret_key
+# JWT_EXPIRES_IN=7d

+ 7 - 2
src/client/api.ts

@@ -1,8 +1,9 @@
 import { hc } from 'hono/client'
 import type {
-  AuthRoutes, UserRoutes, RoleRoutes, FileRoutes, 
+  AuthRoutes, UserRoutes, RoleRoutes, FileRoutes,
   ClassroomDataRoutes, SubmissionRecordsRoutes,
-  StockDataRoutes, StockXunlianCodesRoutes, DateNotesRoutes, AliyunRoutes
+  StockDataRoutes, StockXunlianCodesRoutes, DateNotesRoutes, AliyunRoutes,
+  WechatAuthRoutes
 } from '@/server/api';
 import { axiosFetch } from './utils/axios-fetch';
 
@@ -45,3 +46,7 @@ export const dateNotesClient = hc<DateNotesRoutes>('/', {
 export const aliyunClient = hc<AliyunRoutes>('/', {
   fetch: axiosFetch,
 }).api.v1.aliyun;
+
+export const wechatAuthClient = hc<WechatAuthRoutes>('/', {
+  fetch: axiosFetch,
+}).api.v1.auth.wechat;

+ 64 - 0
src/client/components/WechatLoginButton.tsx

@@ -0,0 +1,64 @@
+import React from 'react';
+import { Button } from '@/client/components/ui/button';
+import { Wechat } from 'lucide-react';
+import { wechatAuthClient } from '@/client/api';
+import { toast } from 'sonner';
+
+interface WechatLoginButtonProps {
+  redirectUri: string;
+  scope?: 'snsapi_base' | 'snsapi_userinfo';
+  onSuccess?: (data: any) => void;
+  onError?: (error: Error) => void;
+  children?: React.ReactNode;
+  className?: string;
+  variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
+  size?: 'default' | 'sm' | 'lg' | 'icon';
+}
+
+const WechatLoginButton: React.FC<WechatLoginButtonProps> = ({
+  redirectUri,
+  scope = 'snsapi_userinfo',
+  onSuccess,
+  onError,
+  children = '微信登录',
+  className,
+  variant = 'default',
+  size = 'default'
+}) => {
+  const handleWechatLogin = async () => {
+    try {
+      // 使用RPC客户端获取微信授权URL
+      const response = await wechatAuthClient.authorize.$post({
+        json: { redirectUri, scope }
+      });
+
+      if (response.status !== 200) {
+        const errorData = await response.json();
+        throw new Error(errorData.message || '获取微信授权URL失败');
+      }
+
+      const { url } = await response.json();
+      
+      // 重定向到微信授权页面
+      window.location.href = url;
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '微信登录失败';
+      toast.error(errorMessage);
+      onError?.(error as Error);
+    }
+  };
+
+  return (
+    <Button
+      variant={variant}
+      size={size}
+      onClick={handleWechatLogin}
+      className={`bg-[#07C160] text-white hover:bg-[#06B456] border-none ${className}`}
+    >
+      <Wechat className="w-4 h-4 mr-2" />
+      {children}
+    </Button>
+  );
+};
+
+export default WechatLoginButton;

+ 22 - 2
src/client/mobile/pages/AuthPage.tsx

@@ -4,6 +4,8 @@ import { UserIcon } from '@heroicons/react/24/outline';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { useAuth } from '@/client/mobile/hooks/AuthProvider';
 import { authClient } from '@/client/api';
+import WechatLoginButton from '@/client/components/WechatLoginButton';
+import { toast } from 'sonner';
 
 const AuthPage: React.FC = () => {
   const { register, handleSubmit, formState: { errors } } = useForm();
@@ -125,13 +127,31 @@ const AuthPage: React.FC = () => {
                 <div className="w-full border-t border-gray-300"></div>
               </div>
               <div className="relative flex justify-center text-sm">
-                <span className="px-2 bg-white text-gray-500">便捷体验</span>
+                <span className="px-2 bg-white text-gray-500">或使用以下方式登录</span>
               </div>
             </div>
             
+            <div className="mt-4">
+              <WechatLoginButton
+                redirectUri={`${window.location.origin}/mobile/auth/callback`}
+                onSuccess={(data) => {
+                  localStorage.setItem('token', data.token);
+                  const returnUrl = getReturnUrl();
+                  localStorage.removeItem('mobile_return_url');
+                  navigate(returnUrl, { replace: true });
+                }}
+                onError={(error) => {
+                  toast.error(error.message);
+                }}
+                className="w-full"
+              >
+                微信一键登录
+              </WechatLoginButton>
+            </div>
+            
             <div className="mt-4 text-center">
               <p className="text-sm text-gray-600">
-                无需复杂操作,输入微信昵称即可快速开始使用
+                也可以输入微信昵称快速开始使用
               </p>
             </div>
           </div>

+ 110 - 0
src/client/mobile/pages/WechatCallbackPage.tsx

@@ -0,0 +1,110 @@
+import React, { useEffect, useState } from 'react';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+import { useAuth } from '@/client/mobile/hooks/AuthProvider';
+import { wechatAuthClient } from '@/client/api';
+import { toast } from 'sonner';
+
+const WechatCallbackPage: React.FC = () => {
+  const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
+  const [message, setMessage] = useState<string>('处理中...');
+  const navigate = useNavigate();
+  const [searchParams] = useSearchParams();
+  const { login } = useAuth();
+
+  useEffect(() => {
+    const handleWechatCallback = async () => {
+      try {
+        const code = searchParams.get('code');
+        const state = searchParams.get('state');
+        
+        if (!code) {
+          throw new Error('微信授权失败:未获取到授权码');
+        }
+
+        // 使用微信授权码进行登录
+        const response = await wechatAuthClient.login.$post({
+          json: { code }
+        });
+
+        if (response.status !== 200) {
+          const errorData = await response.json();
+          throw new Error(errorData.message || '微信登录失败');
+        }
+
+        const result = await response.json();
+        
+        // 保存token并登录
+        localStorage.setItem('token', result.token);
+        await login(result.user.username, result.token); // 使用token作为密码进行登录
+        
+        setStatus('success');
+        setMessage('微信登录成功!');
+        
+        // 跳转回来源页
+        const returnUrl = localStorage.getItem('mobile_return_url') || '/mobile';
+        localStorage.removeItem('mobile_return_url');
+        
+        setTimeout(() => {
+          navigate(returnUrl, { replace: true });
+        }, 1500);
+        
+      } catch (error) {
+        console.error('微信回调处理失败:', error);
+        setStatus('error');
+        setMessage(error instanceof Error ? error.message : '微信登录失败');
+        
+        toast.error('微信登录失败,请重试');
+        
+        setTimeout(() => {
+          navigate('/mobile/auth', { replace: true });
+        }, 3000);
+      }
+    };
+
+    handleWechatCallback();
+  }, [searchParams, navigate, login]);
+
+  const getStatusIcon = () => {
+    switch (status) {
+      case 'loading':
+        return (
+          <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
+        );
+      case 'success':
+        return (
+          <div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
+            <svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
+            </svg>
+          </div>
+        );
+      case 'error':
+        return (
+          <div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
+            <svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
+            </svg>
+          </div>
+        );
+    }
+  };
+
+  return (
+    <div className="flex justify-center items-center min-h-screen bg-gray-100">
+      <div className="text-center">
+        {getStatusIcon()}
+        <h2 className="mt-4 text-lg font-medium text-gray-900">
+          {status === 'loading' && '微信登录处理中...'}
+          {status === 'success' && '登录成功!'}
+          {status === 'error' && '登录失败'}
+        </h2>
+        <p className="mt-2 text-sm text-gray-600">{message}</p>
+        {status === 'loading' && (
+          <p className="mt-2 text-xs text-gray-500">请稍候,正在处理微信授权...</p>
+        )}
+      </div>
+    </div>
+  );
+};
+
+export default WechatCallbackPage;

+ 5 - 0
src/client/mobile/routes.tsx

@@ -10,6 +10,7 @@ import { ClassroomPage } from './pages/ClassroomPage';
 // import { StockXunlianCodesPage } from './pages/StockXunlianCodesPage';
 // import { DateNotesPage } from './pages/DateNotesPage';
 import AuthPage from './pages/AuthPage';
+import WechatCallbackPage from './pages/WechatCallbackPage';
 import StockHomePage from './pages/StockHomePage';
 import { XunlianPage } from './pages/XunlianPage';
 import { StockMain } from './components/stock/stock_main';
@@ -28,6 +29,10 @@ export const router = createBrowserRouter([
     path: '/mobile/login',
     element: <AuthPage />
   },
+  {
+    path: '/mobile/auth/callback',
+    element: <WechatCallbackPage />
+  },
   {
     path: '/mobile/register',
     element: <Navigate to="/mobile/login" replace />

+ 3 - 0
src/server/api.ts

@@ -11,6 +11,7 @@ import stockDataRoutes from './api/stock-data/index'
 import stockXunlianCodesRoutes from './api/stock-xunlian-codes/index'
 import dateNotesRoutes from './api/date-notes/index'
 import aliyunRoute from './api/aliyun/index'
+import wechatRoutes from './api/auth/wechat/index'
 import { AuthContext } from './types/context'
 import { AppDataSource } from './data-source'
 import { Hono } from 'hono'
@@ -91,6 +92,7 @@ const stockDataApi = api.route('/api/v1/stock-data', stockDataRoutes)
 const stockXunlianCodesApi = api.route('/api/v1/stock-xunlian-codes', stockXunlianCodesRoutes)
 const dateNotesApi = api.route('/api/v1/date-notes', dateNotesRoutes)
 const aliyunApi = api.route('/api/v1/aliyun', aliyunRoute)
+const wechatAuthApi = api.route('/api/v1/auth/wechat', wechatRoutes)
 
 export type AuthRoutes = typeof authRoutes
 export type UserRoutes = typeof userRoutes
@@ -102,6 +104,7 @@ export type StockDataRoutes = typeof stockDataApi
 export type StockXunlianCodesRoutes = typeof stockXunlianCodesApi
 export type DateNotesRoutes = typeof dateNotesApi
 export type AliyunRoutes = typeof aliyunApi
+export type WechatAuthRoutes = typeof wechatAuthApi
 
 app.route('/', api)
 export default app

+ 12 - 0
src/server/api/auth/wechat/index.ts

@@ -0,0 +1,12 @@
+import { OpenAPIHono } from '@hono/zod-openapi';
+import authorizeRoute from './wechat-authorize';
+import loginRoute from './wechat-login';
+import { bindApp, unbindApp } from './wechat-bind';
+
+const app = new OpenAPIHono()
+  .route('/', authorizeRoute)
+  .route('/', loginRoute)
+  .route('/', bindApp)
+  .route('/', unbindApp);
+
+export default app;

+ 76 - 0
src/server/api/auth/wechat/wechat-authorize.ts

@@ -0,0 +1,76 @@
+import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
+import { z } from '@hono/zod-openapi';
+import { WechatAuthService } from '@/server/modules/wechat/wechat-auth.service';
+import { ErrorSchema } from '@/server/utils/errorHandler';
+import { AppDataSource } from '@/server/data-source';
+import { UserService } from '@/server/modules/users/user.service';
+import { AuthService } from '@/server/modules/auth/auth.service';
+
+const AuthorizeSchema = z.object({
+  redirectUri: z.string().url().openapi({
+    example: 'https://example.com/auth/callback',
+    description: '回调地址'
+  }),
+  scope: z.enum(['snsapi_base', 'snsapi_userinfo']).default('snsapi_userinfo').openapi({
+    example: 'snsapi_userinfo',
+    description: '授权范围'
+  })
+});
+
+const AuthorizeResponseSchema = z.object({
+  url: z.string().url().openapi({
+    example: 'https://open.weixin.qq.com/connect/oauth2/authorize?appid=xxx',
+    description: '微信授权URL'
+  })
+});
+
+const userService = new UserService(AppDataSource);
+const authService = new AuthService(userService);
+const wechatAuthService = new WechatAuthService(userService, authService);
+
+const authorizeRoute = createRoute({
+  method: 'post',
+  path: '/authorize',
+  request: {
+    body: {
+      content: {
+        'application/json': {
+          schema: AuthorizeSchema
+        }
+      }
+    }
+  },
+  responses: {
+    200: {
+      description: '获取微信授权URL成功',
+      content: {
+        'application/json': {
+          schema: AuthorizeResponseSchema
+        }
+      }
+    },
+    400: {
+      description: '请求参数错误',
+      content: {
+        'application/json': {
+          schema: ErrorSchema
+        }
+      }
+    }
+  }
+});
+
+const app = new OpenAPIHono().openapi(authorizeRoute, async (c) => {
+  try {
+    const { redirectUri, scope } = await c.req.json();
+    const url = wechatAuthService.getAuthorizationUrl(redirectUri, scope);
+    return c.json({ url }, 200);
+  } catch (error) {
+    return c.json({ 
+      code: 400, 
+      message: error instanceof Error ? error.message : '获取授权URL失败' 
+    }, 400);
+  }
+});
+
+export default app;

+ 115 - 0
src/server/api/auth/wechat/wechat-bind.ts

@@ -0,0 +1,115 @@
+import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
+import { z } from '@hono/zod-openapi';
+import { WechatAuthService } from '@/server/modules/wechat/wechat-auth.service';
+import { ErrorSchema } from '@/server/utils/errorHandler';
+import { AppDataSource } from '@/server/data-source';
+import { UserService } from '@/server/modules/users/user.service';
+import { AuthService } from '@/server/modules/auth/auth.service';
+import { UserSchema } from '@/server/modules/users/user.schema';
+import { authMiddleware } from '@/server/middleware/auth.middleware';
+import { AuthContext } from '@/server/types/context';
+
+const BindWechatSchema = z.object({
+  code: z.string().min(1).openapi({
+    example: '0816TxlL1Qe3QY0qgDlL1pQxlL16TxlO',
+    description: '微信授权code'
+  })
+});
+
+const userService = new UserService(AppDataSource);
+const authService = new AuthService(userService);
+const wechatAuthService = new WechatAuthService(userService, authService);
+
+const bindWechatRoute = createRoute({
+  method: 'post',
+  path: '/bind',
+  middleware: [authMiddleware],
+  request: {
+    body: {
+      content: {
+        'application/json': {
+          schema: BindWechatSchema
+        }
+      }
+    }
+  },
+  responses: {
+    200: {
+      description: '绑定微信账号成功',
+      content: {
+        'application/json': {
+          schema: UserSchema
+        }
+      }
+    },
+    400: {
+      description: '请求参数错误',
+      content: {
+        'application/json': {
+          schema: ErrorSchema
+        }
+      }
+    },
+    401: {
+      description: '认证失败',
+      content: {
+        'application/json': {
+          schema: ErrorSchema
+        }
+      }
+    }
+  }
+});
+
+const unbindWechatRoute = createRoute({
+  method: 'post',
+  path: '/unbind',
+  middleware: [authMiddleware],
+  responses: {
+    200: {
+      description: '解绑微信账号成功',
+      content: {
+        'application/json': {
+          schema: UserSchema
+        }
+      }
+    },
+    401: {
+      description: '认证失败',
+      content: {
+        'application/json': {
+          schema: ErrorSchema
+        }
+      }
+    }
+  }
+});
+
+const bindApp = new OpenAPIHono<AuthContext>().openapi(bindWechatRoute, async (c) => {
+  try {
+    const user = c.get('user');
+    const { code } = await c.req.json();
+    const result = await wechatAuthService.bindWechatToUser(user.id, code);
+    return c.json(result, 200);
+  } catch (error) {
+    return c.json({ 
+      code: 400, 
+      message: error instanceof Error ? error.message : '绑定微信账号失败' 
+    }, 400);
+  }
+});
+
+const unbindApp = new OpenAPIHono<AuthContext>().openapi(unbindWechatRoute, async (c) => {
+  try {
+    const user = c.get('user');
+    const result = await wechatAuthService.unbindWechatFromUser(user.id);
+    return c.json(result, 200);
+  } catch (error) {
+    return c.json({ 
+      code: 400, 
+      message: error instanceof Error ? error.message : '解绑微信账号失败' 
+    }, 400);
+  }
+});
+
+export { bindApp, unbindApp };

+ 82 - 0
src/server/api/auth/wechat/wechat-login.ts

@@ -0,0 +1,82 @@
+import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
+import { z } from '@hono/zod-openapi';
+import { WechatAuthService } from '@/server/modules/wechat/wechat-auth.service';
+import { ErrorSchema } from '@/server/utils/errorHandler';
+import { AppDataSource } from '@/server/data-source';
+import { UserService } from '@/server/modules/users/user.service';
+import { AuthService } from '@/server/modules/auth/auth.service';
+import { UserSchema } from '@/server/modules/users/user.schema'
+
+const WechatLoginSchema = z.object({
+  code: z.string().min(1).openapi({
+    example: '0816TxlL1Qe3QY0qgDlL1pQxlL16TxlO',
+    description: '微信授权code'
+  })
+});
+
+const TokenResponseSchema = z.object({
+  token: z.string().openapi({
+    example: 'jwt.token.here',
+    description: 'JWT Token'
+  }),
+  user: UserSchema.pick({ password: true })
+})
+
+const userService = new UserService(AppDataSource);
+const authService = new AuthService(userService);
+const wechatAuthService = new WechatAuthService(userService, authService);
+
+const wechatLoginRoute = createRoute({
+  method: 'post',
+  path: '/login',
+  request: {
+    body: {
+      content: {
+        'application/json': {
+          schema: WechatLoginSchema
+        }
+      }
+    }
+  },
+  responses: {
+    200: {
+      description: '微信登录成功',
+      content: {
+        'application/json': {
+          schema: TokenResponseSchema
+        }
+      }
+    },
+    400: {
+      description: '请求参数错误',
+      content: {
+        'application/json': {
+          schema: ErrorSchema
+        }
+      }
+    },
+    401: {
+      description: '微信认证失败',
+      content: {
+        'application/json': {
+          schema: ErrorSchema
+        }
+      }
+    }
+  }
+});
+
+const app = new OpenAPIHono().openapi(wechatLoginRoute, async (c) => {
+  try {
+    const { code } = await c.req.json();
+    const result = await wechatAuthService.wechatLogin(code);
+    return c.json(result, 200);
+  } catch (error) {
+    return c.json({ 
+      code: 401, 
+      message: error instanceof Error ? error.message : '微信登录失败' 
+    }, 401);
+  }
+});
+
+export default app;

+ 24 - 0
src/server/modules/users/user.entity.ts

@@ -63,6 +63,30 @@ export class UserEntity {
   @UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
   updatedAt!: Date;
 
+  @Column({ name: 'wechat_openid', type: 'varchar', length: 255, nullable: true, comment: '微信开放平台openid' })
+  wechatOpenid!: string | null;
+
+  @Column({ name: 'wechat_unionid', type: 'varchar', length: 255, nullable: true, comment: '微信开放平台unionid' })
+  wechatUnionid!: string | null;
+
+  @Column({ name: 'wechat_nickname', type: 'varchar', length: 255, nullable: true, comment: '微信昵称' })
+  wechatNickname!: string | null;
+
+  @Column({ name: 'wechat_avatar', type: 'varchar', length: 500, nullable: true, comment: '微信头像URL' })
+  wechatAvatar!: string | null;
+
+  @Column({ name: 'wechat_sex', type: 'tinyint', nullable: true, comment: '微信性别(1:男,2:女,0:未知)' })
+  wechatSex!: number | null;
+
+  @Column({ name: 'wechat_province', type: 'varchar', length: 100, nullable: true, comment: '微信省份' })
+  wechatProvince!: string | null;
+
+  @Column({ name: 'wechat_city', type: 'varchar', length: 100, nullable: true, comment: '微信城市' })
+  wechatCity!: string | null;
+
+  @Column({ name: 'wechat_country', type: 'varchar', length: 100, nullable: true, comment: '微信国家' })
+  wechatCountry!: string | null;
+
   constructor(partial?: Partial<UserEntity>) {
     Object.assign(this, partial);
   }

+ 32 - 0
src/server/modules/users/user.schema.ts

@@ -69,6 +69,38 @@ export const UserSchema = z.object({
     example: UserType.STUDENT,
     description: '用户类型(teacher:老师,student:学生)'
   }),
+  wechatOpenid: z.string().max(255, '微信openid最多255个字符').nullable().openapi({
+    example: 'o6_bmjrPTlm6_2sgVt7hMZOPfL2M',
+    description: '微信开放平台openid'
+  }),
+  wechatUnionid: z.string().max(255, '微信unionid最多255个字符').nullable().openapi({
+    example: 'o6_bmasdasdsad6_2sgVt7hMZOPfL2M',
+    description: '微信开放平台unionid'
+  }),
+  wechatNickname: z.string().max(255, '微信昵称最多255个字符').nullable().openapi({
+    example: '微信用户',
+    description: '微信昵称'
+  }),
+  wechatAvatar: z.string().max(500, '微信头像URL最多500个字符').nullable().openapi({
+    example: 'http://thirdwx.qlogo.cn/mmopen/g3MonUZtNHkdmzicIlibx6iaFqAc56vxLSUfpb6n5WKSYVY0ChQKkiaJSgQ1dZuTOgvLLrhJbERQQ4eMsv84eavHiaiceqxibJxCfHe/46',
+    description: '微信头像URL'
+  }),
+  wechatSex: z.number().int('微信性别必须是整数').min(0, '微信性别最小值为0').max(2, '微信性别最大值为2').nullable().openapi({
+    example: 1,
+    description: '微信性别(1:男,2:女,0:未知)'
+  }),
+  wechatProvince: z.string().max(100, '微信省份最多100个字符').nullable().openapi({
+    example: '广东省',
+    description: '微信省份'
+  }),
+  wechatCity: z.string().max(100, '微信城市最多100个字符').nullable().openapi({
+    example: '深圳市',
+    description: '微信城市'
+  }),
+  wechatCountry: z.string().max(100, '微信国家最多100个字符').nullable().openapi({
+    example: '中国',
+    description: '微信国家'
+  }),
   createdAt: z.coerce.date().openapi({ description: '创建时间' }),
   updatedAt: z.coerce.date().openapi({ description: '更新时间' })
 });

+ 34 - 0
src/server/modules/users/user.service.ts

@@ -165,6 +165,40 @@ export class UserService {
     }
   }
 
+  /**
+   * 通过微信openid查找用户
+   * @param openid 微信openid
+   * @returns 用户信息或null
+   */
+  async getUserByWechatOpenid(openid: string): Promise<User | null> {
+    try {
+      return await this.userRepository.findOne({
+        where: { wechatOpenid: openid },
+        relations: ['roles', 'avatarFile']
+      });
+    } catch (error) {
+      console.error('Error getting user by wechat openid:', error);
+      throw new Error('Failed to get user by wechat openid');
+    }
+  }
+
+  /**
+   * 通过微信unionid查找用户
+   * @param unionid 微信unionid
+   * @returns 用户信息或null
+   */
+  async getUserByWechatUnionid(unionid: string): Promise<User | null> {
+    try {
+      return await this.userRepository.findOne({
+        where: { wechatUnionid: unionid },
+        relations: ['roles', 'avatarFile']
+      });
+    } catch (error) {
+      console.error('Error getting user by wechat unionid:', error);
+      throw new Error('Failed to get user by wechat unionid');
+    }
+  }
+
   /**
    * 生成唯一的用户名,如果用户名已存在则添加随机数字
    * @param username 基础用户名

+ 152 - 0
src/server/modules/wechat/wechat-auth.service.ts

@@ -0,0 +1,152 @@
+import axios from 'axios';
+import { UserService } from '../users/user.service';
+import { AuthService } from '../auth/auth.service';
+import { UserEntity as User } from '../users/user.entity';
+import debug from 'debug';
+
+const logger = {
+  info: debug('backend:wechat:info'),
+  error: debug('backend:wechat:error')
+};
+
+export class WechatAuthService {
+  private readonly appId: string = process.env.WECHAT_MP_APP_ID || '';
+  private readonly appSecret: string = process.env.WECHAT_MP_APP_SECRET || '';
+
+  constructor(
+    private readonly userService: UserService,
+    private readonly authService: AuthService
+  ) {}
+
+  // 获取授权URL
+  getAuthorizationUrl(redirectUri: string, scope: 'snsapi_base' | 'snsapi_userinfo' = 'snsapi_userinfo'): string {
+    const state = Math.random().toString(36).substring(2);
+    return `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${this.appId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${scope}&state=${state}#wechat_redirect`;
+  }
+
+  // 通过code获取access_token
+  async getAccessToken(code: string): Promise<any> {
+    const url = `https://api.weixin.qq.com/sns/oauth2/access_token?appid=${this.appId}&secret=${this.appSecret}&code=${code}&grant_type=authorization_code`;
+    const response = await axios.get(url);
+    return response.data;
+  }
+
+  // 获取用户信息
+  async getUserInfo(accessToken: string, openid: string): Promise<any> {
+    const url = `https://api.weixin.qq.com/sns/userinfo?access_token=${accessToken}&openid=${openid}&lang=zh_CN`;
+    const response = await axios.get(url);
+    return response.data;
+  }
+
+  // 微信登录/注册
+  async wechatLogin(code: string): Promise<{ token: string; user: User }> {
+    try {
+      // 1. 获取access_token
+      const tokenData = await this.getAccessToken(code);
+      if (tokenData.errcode) {
+        throw new Error(`微信认证失败: ${tokenData.errmsg}`);
+      }
+
+      const { access_token, openid, unionid } = tokenData;
+
+      // 2. 检查用户是否存在
+      let user = await this.userService.getUserByWechatOpenid(openid);
+
+      if (!user) {
+        // 3. 获取用户信息(首次登录)
+        const userInfo = await this.getUserInfo(access_token, openid);
+        
+        // 4. 创建新用户
+        user = await this.userService.createUser({
+          username: `wx_${openid.substring(0, 8)}`,
+          password: Math.random().toString(36).substring(2),
+          wechatOpenid: openid,
+          wechatUnionid: unionid,
+          wechatNickname: userInfo.nickname,
+          wechatAvatar: userInfo.headimgurl,
+          wechatSex: userInfo.sex,
+          wechatProvince: userInfo.province,
+          wechatCity: userInfo.city,
+          wechatCountry: userInfo.country,
+          nickname: userInfo.nickname
+        });
+      }
+
+      // 5. 生成JWT token
+      const token = this.authService.generateToken(user);
+      
+      return { token, user };
+    } catch (error) {
+      logger.error('微信登录失败:', error);
+      throw new Error(`微信登录失败: ${error instanceof Error ? error.message : String(error)}`);
+    }
+  }
+
+  // 绑定微信账号到现有用户
+  async bindWechatToUser(userId: number, code: string): Promise<User> {
+    try {
+      // 1. 获取access_token
+      const tokenData = await this.getAccessToken(code);
+      if (tokenData.errcode) {
+        throw new Error(`微信认证失败: ${tokenData.errmsg}`);
+      }
+
+      const { openid, unionid } = tokenData;
+
+      // 2. 检查微信账号是否已被绑定
+      const existingUser = await this.userService.getUserByWechatOpenid(openid);
+      if (existingUser && existingUser.id !== userId) {
+        throw new Error('该微信账号已被其他用户绑定');
+      }
+
+      // 3. 获取用户信息
+      const userInfo = await this.getUserInfo(tokenData.access_token, openid);
+
+      // 4. 更新用户信息
+      const user = await this.userService.updateUser(userId, {
+        wechatOpenid: openid,
+        wechatUnionid: unionid,
+        wechatNickname: userInfo.nickname,
+        wechatAvatar: userInfo.headimgurl,
+        wechatSex: userInfo.sex,
+        wechatProvince: userInfo.province,
+        wechatCity: userInfo.city,
+        wechatCountry: userInfo.country
+      });
+
+      if (!user) {
+        throw new Error('用户不存在');
+      }
+
+      return user;
+    } catch (error) {
+      logger.error('绑定微信账号失败:', error);
+      throw new Error(`绑定微信账号失败: ${error instanceof Error ? error.message : String(error)}`);
+    }
+  }
+
+  // 解绑微信账号
+  async unbindWechatFromUser(userId: number): Promise<User> {
+    try {
+      const user = await this.userService.updateUser(userId, {
+        wechatOpenid: null,
+        wechatUnionid: null,
+        wechatNickname: null,
+        wechatAvatar: null,
+        wechatSex: null,
+        wechatProvince: null,
+        wechatCity: null,
+        wechatCountry: null
+      });
+
+      if (!user) {
+        throw new Error('用户不存在');
+      }
+
+      return user;
+    } catch (error) {
+      logger.error('解绑微信账号失败:', error);
+      throw new Error(`解绑微信账号失败: ${error instanceof Error ? error.message : String(error)}`);
+    }
+  }
+}