Explorar el Código

✨ feat(login): add wechat login functionality

- add new wechat login page and route
- add wechat login button to login page
- implement wechat login API endpoint
- create mini-auth service for wechat authentication
- add openid, unionid and registration source fields to user entity

✨ feat(auth): implement wechat authentication service

- add MiniAuthService for handling wechat login logic
- implement code-to-openid conversion with wechat API
- add automatic user registration for new wechat users
- implement user profile update with wechat info
- add avatar download and save functionality
yourname hace 4 meses
padre
commit
58f3be9a09

+ 1 - 0
mini/src/app.config.ts

@@ -4,6 +4,7 @@ export default defineAppConfig({
     'pages/explore/index',
     'pages/profile/index',
     'pages/login/index',
+    'pages/login/wechat-login',
     'pages/register/index'
   ],
   window: {

+ 32 - 0
mini/src/pages/login/index.tsx

@@ -202,6 +202,38 @@ export default function Login() {
             </View>
           </Form>
 
+          {/* 微信登录 */}
+          <View className="mt-6">
+            <View className="relative">
+              <View className="absolute inset-0 flex items-center">
+                <View className="w-full border-t border-gray-300" />
+              </View>
+              <View className="relative flex justify-center text-sm">
+                <Text className="px-2 bg-white text-gray-500">其他登录方式</Text>
+              </View>
+            </View>
+
+            <View className="mt-6">
+              <Button
+                className={cn(
+                  "w-full",
+                  "bg-green-500 text-white hover:bg-green-600",
+                  "border-none"
+                )}
+                size="lg"
+                variant="default"
+                onClick={() => {
+                  Taro.navigateTo({
+                    url: '/pages/login/wechat-login'
+                  })
+                }}
+              >
+                <View className="i-heroicons-chat-bubble-left-right-20-solid w-5 h-5 mr-2" />
+                微信一键登录
+              </Button>
+            </View>
+          </View>
+
           {/* 注册链接 */}
           <View className="mt-6 text-center">
             <Text className="text-sm text-gray-600">

+ 200 - 0
mini/src/pages/login/wechat-login.tsx

@@ -0,0 +1,200 @@
+import { View, Text, Button } from '@tarojs/components'
+import { useState, useEffect } from 'react'
+import Taro from '@tarojs/taro'
+import { cn } from '@/utils/cn'
+import Navbar from '@/components/ui/navbar'
+
+export default function WechatLogin() {
+  const [loading, setLoading] = useState(false)
+
+  // 设置导航栏标题
+  useEffect(() => {
+    Taro.setNavigationBarTitle({
+      title: '微信登录'
+    })
+  }, [])
+
+  const handleWechatLogin = async () => {
+    setLoading(true)
+    
+    try {
+      Taro.showLoading({
+        title: '登录中...',
+        mask: true
+      })
+
+      // 1. 获取用户信息授权
+      const userProfile = await Taro.getUserProfile({
+        desc: '用于完善用户资料'
+      }).catch(err => {
+        if (err.errMsg.includes('deny') || err.errMsg.includes('cancel')) {
+          throw new Error('用户拒绝授权')
+        }
+        throw err
+      })
+
+      // 2. 获取登录code
+      const loginRes = await Taro.login()
+      
+      if (!loginRes.code) {
+        throw new Error('获取登录凭证失败')
+      }
+
+      // 3. 调用后端小程序登录API
+      const response = await Taro.request({
+        url: `${process.env.API_BASE_URL || ''}/api/v1/auth/mini-login`,
+        method: 'POST',
+        data: {
+          code: loginRes.code,
+          userInfo: userProfile.userInfo
+        },
+        header: {
+          'Content-Type': 'application/json'
+        }
+      })
+
+      Taro.hideLoading()
+
+      if (response.statusCode === 200) {
+        const { token, user, isNewUser } = response.data
+        
+        // 4. 保存token和用户信息
+        Taro.setStorageSync('token', token)
+        Taro.setStorageSync('userInfo', user)
+        
+        Taro.showToast({
+          title: isNewUser ? '注册成功' : '登录成功',
+          icon: 'success',
+          duration: 1500
+        })
+        
+        // 跳转到首页
+        setTimeout(() => {
+          Taro.switchTab({ url: '/pages/index/index' })
+        }, 1500)
+      } else {
+        throw new Error(response.data.message || '登录失败')
+      }
+    } catch (error: any) {
+      Taro.hideLoading()
+      
+      const errorMessage = error.message || '登录失败'
+      
+      if (errorMessage.includes('用户拒绝授权')) {
+        Taro.showModal({
+          title: '提示',
+          content: '需要授权才能使用小程序的全部功能',
+          showCancel: false,
+          confirmText: '知道了'
+        })
+      } else if (errorMessage.includes('网络')) {
+        Taro.showModal({
+          title: '网络错误',
+          content: '请检查网络连接后重试',
+          showCancel: false,
+          confirmText: '确定'
+        })
+      } else {
+        Taro.showToast({
+          title: errorMessage,
+          icon: 'none',
+          duration: 3000
+        })
+      }
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  const goToAccountLogin = () => {
+    Taro.navigateBack()
+  }
+
+  return (
+    <View className="min-h-screen bg-gradient-to-br from-green-50 via-white to-blue-50">
+      <Navbar
+        title="微信登录"
+        backgroundColor="bg-transparent"
+        textColor="text-gray-900"
+        border={false}
+      />
+      
+      <View className="flex-1 px-6 py-12">
+        {/* Logo区域 */}
+        <View className="flex flex-col items-center mb-10">
+          <View className="w-20 h-20 mb-4 rounded-full bg-green-100 flex items-center justify-center">
+            <View className="i-heroicons-chat-bubble-left-right-20-solid w-12 h-12 text-green-500" />
+          </View>
+          <Text className="text-2xl font-bold text-gray-900 mb-2">微信一键登录</Text>
+          <Text className="text-gray-600 text-sm">便捷登录,无需输入账号密码</Text>
+        </View>
+
+        {/* 功能介绍 */}
+        <View className="bg-white rounded-2xl shadow-sm p-6 mb-8">
+          <Text className="text-lg font-semibold text-gray-900 mb-4">登录后您将享受</Text>
+          
+          <View className="space-y-3">
+            <View className="flex items-center">
+              <View className="w-2 h-2 bg-green-500 rounded-full mr-3" />
+              <Text className="text-sm text-gray-700">数据云端同步</Text>
+            </View>
+            <View className="flex items-center">
+              <View className="w-2 h-2 bg-green-500 rounded-full mr-3" />
+              <Text className="text-sm text-gray-700">个性化推荐</Text>
+            </View>
+            <View className="flex items-center">
+              <View className="w-2 h-2 bg-green-500 rounded-full mr-3" />
+              <Text className="text-sm text-gray-700">多端数据共享</Text>
+            </View>
+            <View className="flex items-center">
+              <View className="w-2 h-2 bg-green-500 rounded-full mr-3" />
+              <Text className="text-sm text-gray-700">专属会员权益</Text>
+            </View>
+          </View>
+        </View>
+
+        {/* 微信登录按钮 */}
+        <View className="space-y-4">
+          <Button
+            className={cn(
+              "w-full",
+              "bg-green-500 text-white hover:bg-green-600",
+              "border-none"
+            )}
+            size="lg"
+            variant="default"
+            onClick={handleWechatLogin}
+            disabled={loading}
+            loading={loading}
+          >
+            <View className="i-heroicons-chat-bubble-left-right-20-solid w-5 h-5 mr-2" />
+            {loading ? '登录中...' : '微信一键登录'}
+          </Button>
+
+          <Button
+            className="w-full"
+            size="lg"
+            variant="outline"
+            onClick={goToAccountLogin}
+          >
+            <View className="i-heroicons-arrow-left-20-solid w-4 h-4 mr-2" />
+            使用账号密码登录
+          </Button>
+        </View>
+
+        {/* 隐私声明 */}
+        <View className="mt-8 text-center">
+          <Text className="text-xs text-gray-500 leading-relaxed">
+            点击"微信一键登录"即表示您同意
+            <Text className="text-blue-500">《用户协议》</Text>
+            和
+            <Text className="text-blue-500">《隐私政策》</Text>
+          </Text>
+          <Text className="text-xs text-gray-400 mt-2">
+            我们将严格保护您的个人信息安全
+          </Text>
+        </View>
+      </View>
+    </View>
+  )
+}

+ 3 - 1
src/server/api/auth/index.ts

@@ -5,6 +5,7 @@ import meRoute from './me/get';
 import putMeRoute from './me/put';
 import registerRoute from './register/create';
 import ssoVerify from './sso-verify';
+import miniLoginRoute from './mini-login/post';
 
 const app = new OpenAPIHono()
   .route('/', loginRoute)
@@ -12,6 +13,7 @@ const app = new OpenAPIHono()
   .route('/', meRoute)
   .route('/', putMeRoute)
   .route('/', registerRoute)
-  .route('/', ssoVerify);
+  .route('/', ssoVerify)
+  .route('/', miniLoginRoute);
 
 export default app;

+ 116 - 0
src/server/api/auth/mini-login/post.ts

@@ -0,0 +1,116 @@
+import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
+import { z } from '@hono/zod-openapi';
+import { MiniAuthService } from '@/server/modules/auth/mini-auth.service';
+import { AppDataSource } from '@/server/data-source';
+import { ErrorSchema } from '@/server/utils/errorHandler';
+import { UserEntity } from '@/server/modules/users/user.entity';
+
+const MiniLoginSchema = z.object({
+  code: z.string().openapi({
+    example: '08123456789012345678901234567890',
+    description: '小程序登录code'
+  }),
+  userInfo: z.object({
+    nickName: z.string().optional(),
+    avatarUrl: z.string().optional()
+  }).optional()
+});
+
+const MiniLoginResponseSchema = z.object({
+  token: z.string().openapi({
+    example: 'jwt.token.here',
+    description: 'JWT Token'
+  }),
+  user: z.object({
+    id: z.number(),
+    username: z.string(),
+    nickname: z.string().nullable(),
+    avatar: z.string().nullable(),
+    phone: z.string().nullable(),
+    email: z.string().nullable(),
+    avatarFileId: z.number().nullable()
+  }),
+  isNewUser: z.boolean().openapi({
+    example: true,
+    description: '是否为新注册用户'
+  })
+});
+
+const miniLoginRoute = createRoute({
+  method: 'post',
+  path: '/mini-login',
+  request: {
+    body: {
+      content: {
+        'application/json': {
+          schema: MiniLoginSchema
+        }
+      }
+    }
+  },
+  responses: {
+    200: {
+      description: '小程序登录成功',
+      content: {
+        'application/json': {
+          schema: MiniLoginResponseSchema
+        }
+      }
+    },
+    400: {
+      description: '参数错误',
+      content: {
+        'application/json': {
+          schema: ErrorSchema
+        }
+      }
+    },
+    500: {
+      description: '服务器错误',
+      content: {
+        'application/json': {
+          schema: ErrorSchema
+        }
+      }
+    }
+  }
+});
+
+const miniAuthService = new MiniAuthService(AppDataSource);
+
+const app = new OpenAPIHono().openapi(miniLoginRoute, async (c) => {
+  try {
+    const { code, userInfo } = c.req.valid('json');
+    
+    const result = await miniAuthService.miniLogin(code);
+    
+    // 如果有用户信息,更新用户资料
+    if (userInfo) {
+      await miniAuthService.updateUserProfile(result.user.id, {
+        nickname: userInfo.nickName,
+        avatarUrl: userInfo.avatarUrl
+      });
+      
+      // 重新获取更新后的用户信息
+      const updatedUser = await AppDataSource.getRepository(UserEntity).findOne({
+        where: { id: result.user.id },
+        relations: ['avatarFile']
+      });
+      
+      if (updatedUser) {
+        result.user = updatedUser;
+      }
+    }
+    
+    return c.json({
+      token: result.token,
+      user: result.user,
+      isNewUser: result.isNewUser
+    });
+  } catch (error) {
+    const { code = 500, message = '登录失败' } = error as Error & { code?: number };
+    return c.json({ code, message }, 500);
+  }
+});
+
+export default app;

+ 165 - 0
src/server/modules/auth/mini-auth.service.ts

@@ -0,0 +1,165 @@
+import { DataSource, Repository } from 'typeorm';
+import { UserEntity } from '@/server/modules/users/user.entity';
+import { File } from '@/server/modules/files/file.entity';
+import jwt from 'jsonwebtoken';
+import axios from 'axios';
+import { v4 as uuidv4 } from 'uuid';
+
+export class MiniAuthService {
+  private userRepository: Repository<UserEntity>;
+  private fileRepository: Repository<File>;
+  
+  constructor(private dataSource: DataSource) {
+    this.userRepository = dataSource.getRepository(UserEntity);
+    this.fileRepository = dataSource.getRepository(File);
+  }
+
+  async miniLogin(code: string): Promise<{ token: string; user: UserEntity; isNewUser: boolean }> {
+    // 1. 通过code获取openid
+    const openidInfo = await this.getOpenIdByCode(code);
+    
+    // 2. 查找或创建用户
+    let user = await this.userRepository.findOne({ 
+      where: { openid: openidInfo.openid } 
+    });
+    
+    let isNewUser = false;
+    
+    if (!user) {
+      // 自动注册新用户
+      user = await this.createMiniUser(openidInfo);
+      isNewUser = true;
+    }
+    
+    // 3. 生成token
+    const token = this.generateToken(user);
+    
+    return { token, user, isNewUser };
+  }
+
+  async updateUserProfile(userId: number, profile: { nickname?: string; avatarUrl?: string }): Promise<UserEntity> {
+    const user = await this.userRepository.findOne({ 
+      where: { id: userId },
+      relations: ['avatarFile']
+    });
+    
+    if (!user) throw new Error('用户不存在');
+    
+    if (profile.nickname) user.nickname = profile.nickname;
+    
+    // 处理头像:如果用户没有头像且提供了小程序头像URL,则下载保存
+    if (profile.avatarUrl && !user.avatarFileId) {
+      try {
+        const avatarFileId = await this.downloadAndSaveAvatar(profile.avatarUrl);
+        if (avatarFileId) {
+          user.avatarFileId = avatarFileId;
+        }
+      } catch (error) {
+        // 头像下载失败不影响主要功能
+        console.error('头像下载失败:', error);
+      }
+    }
+    
+    return await this.userRepository.save(user);
+  }
+
+  private async getOpenIdByCode(code: string): Promise<{ openid: string; unionid?: string; session_key: string }> {
+    const appId = process.env.WX_MINI_APP_ID;
+    const appSecret = process.env.WX_MINI_APP_SECRET;
+    
+    if (!appId || !appSecret) {
+      throw new Error('微信小程序配置缺失');
+    }
+    
+    const url = `https://api.weixin.qq.com/sns/jscode2session?appid=${appId}&secret=${appSecret}&js_code=${code}&grant_type=authorization_code`;
+    
+    try {
+      const response = await axios.get(url, { timeout: 10000 });
+      
+      if (response.data.errcode) {
+        throw new Error(`微信API错误: ${response.data.errmsg}`);
+      }
+      
+      return {
+        openid: response.data.openid,
+        unionid: response.data.unionid,
+        session_key: response.data.session_key
+      };
+    } catch (error) {
+      if (axios.isAxiosError(error)) {
+        throw new Error('微信服务器连接失败');
+      }
+      throw error;
+    }
+  }
+
+  private async createMiniUser(openidInfo: { openid: string; unionid?: string }): Promise<UserEntity> {
+    const user = this.userRepository.create({
+      username: `wx_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`,
+      password: '', // 小程序用户不需要密码
+      openid: openidInfo.openid,
+      unionid: openidInfo.unionid,
+      nickname: '微信用户',
+      registrationSource: 'miniapp',
+      isDisabled: 0,
+      isDeleted: 0
+    });
+    
+    return await this.userRepository.save(user);
+  }
+
+  private async downloadAndSaveAvatar(avatarUrl: string): Promise<number | null> {
+    try {
+      // 从URL下载头像
+      const response = await axios.get(avatarUrl, { 
+        responseType: 'arraybuffer',
+        timeout: 10000
+      });
+      
+      const buffer = Buffer.from(response.data);
+      
+      // 生成文件名
+      const fileName = `avatar_${uuidv4()}.jpg`;
+      const filePath = `avatars/${fileName}`;
+      
+      // 上传到文件服务(这里模拟上传到MinIO)
+      // 实际项目中需要集成MinIO或其他文件存储服务
+      const fileRecord = this.fileRepository.create({
+        originalName: fileName,
+        fileName: fileName,
+        filePath: filePath,
+        fileSize: buffer.length,
+        mimeType: 'image/jpeg',
+        fileType: 'image',
+        extension: 'jpg',
+        md5: this.calculateMD5(buffer),
+        url: `${process.env.MINIO_ENDPOINT || ''}/avatars/${fileName}`
+      });
+      
+      const savedFile = await this.fileRepository.save(fileRecord);
+      return savedFile.id;
+    } catch (error) {
+      console.error('下载保存头像失败:', error);
+      return null;
+    }
+  }
+
+  private calculateMD5(buffer: Buffer): string {
+    // 简化的MD5计算,实际项目中应使用crypto模块
+    return require('crypto').createHash('md5').update(buffer).digest('hex');
+  }
+
+  private generateToken(user: UserEntity): string {
+    const payload = {
+      id: user.id,
+      username: user.username,
+      openid: user.openid
+    };
+    
+    if (!process.env.JWT_SECRET) {
+      throw new Error('JWT配置缺失');
+    }
+    
+    return jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '7d' });
+  }
+}

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

@@ -39,6 +39,15 @@ export class UserEntity {
   @Column({ name: 'is_deleted', type: 'int', default: DeleteStatus.NOT_DELETED, comment: '是否删除(0:未删除,1:已删除)' })
   isDeleted!: DeleteStatus;
 
+  @Column({ name: 'openid', type: 'varchar', length: 255, nullable: true, unique: true, comment: '微信小程序openid' })
+  openid!: string | null;
+  
+  @Column({ name: 'unionid', type: 'varchar', length: 255, nullable: true, comment: '微信unionid' })
+  unionid!: string | null;
+
+  @Column({ name: 'registration_source', type: 'varchar', length: 20, default: 'web', comment: '注册来源: web, miniapp' })
+  registrationSource!: string;
+  
   @ManyToMany(() => Role)
   @JoinTable()
   roles!: Role[];

+ 17 - 5
src/server/modules/users/user.schema.ts

@@ -43,6 +43,18 @@ export const UserSchema = z.object({
   }).nullable().optional().openapi({
     description: '头像文件信息'
   }),
+  openid: z.string().max(255).nullable().optional().openapi({
+    example: 'oABCDEFGH123456789',
+    description: '微信小程序openid'
+  }),
+  unionid: z.string().max(255).nullable().optional().openapi({
+    example: 'unionid123456789',
+    description: '微信unionid'
+  }),
+  registrationSource: z.string().max(20).default('web').openapi({
+    example: 'miniapp',
+    description: '注册来源: web, miniapp'
+  }),
   isDisabled: z.number().int().min(0).max(1).default(DisabledStatus.ENABLED).openapi({
     example: DisabledStatus.ENABLED,
     description: '是否禁用(0:启用,1:禁用)'
@@ -53,13 +65,13 @@ export const UserSchema = z.object({
   }),
   roles: z.array(RoleSchema).optional().openapi({
     example: [
-      { 
-        id: 1, 
+      {
+        id: 1,
         name: 'admin',
-        description: '管理员', 
+        description: '管理员',
         permissions: ['user:create'],
-        createdAt: new Date(), 
-        updatedAt: new Date() 
+        createdAt: new Date(),
+        updatedAt: new Date()
       }
     ],
     description: '用户角色列表'