|
|
@@ -0,0 +1,361 @@
|
|
|
+基于对现有认证系统的分析,我来为您设计小程序 code-openid 自动注册登录的实现方案。
|
|
|
+
|
|
|
+## 当前系统分析
|
|
|
+
|
|
|
+### 现有认证流程
|
|
|
+1. **用户实体**:包含 `username`, `password`, `phone`, `email`, `nickname` 等字段
|
|
|
+2. **认证方式**:基于用户名+密码的传统登录
|
|
|
+3. **注册流程**:需要手动填写用户名、密码、邮箱
|
|
|
+4. **令牌机制**:JWT Token 认证
|
|
|
+
|
|
|
+### 小程序自动注册登录方案
|
|
|
+
|
|
|
+#### 1. 数据库模型扩展
|
|
|
+
|
|
|
+需要扩展用户实体以支持小程序登录:
|
|
|
+
|
|
|
+```typescript
|
|
|
+// 在 UserEntity 中添加小程序相关字段
|
|
|
+@Entity({ name: 'users' })
|
|
|
+export class UserEntity {
|
|
|
+ // ... 现有字段
|
|
|
+
|
|
|
+ @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: 'miniapp_avatar', type: 'varchar', length: 500, nullable: true, comment: '小程序头像URL' })
|
|
|
+ miniappAvatar!: string | null;
|
|
|
+
|
|
|
+ @Column({ name: 'registration_source', type: 'varchar', length: 20, default: 'web', comment: '注册来源: web, miniapp' })
|
|
|
+ registrationSource!: string;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+#### 2. 小程序登录服务
|
|
|
+
|
|
|
+创建小程序认证服务:
|
|
|
+
|
|
|
+```typescript
|
|
|
+// src/server/modules/auth/mini-auth.service.ts
|
|
|
+import { DataSource, Repository } from 'typeorm';
|
|
|
+import { UserEntity } from '../users/user.entity';
|
|
|
+import jwt from 'jsonwebtoken';
|
|
|
+import axios from 'axios';
|
|
|
+
|
|
|
+export class MiniAuthService {
|
|
|
+ private userRepository: Repository<UserEntity>;
|
|
|
+
|
|
|
+ constructor(private dataSource: DataSource) {
|
|
|
+ this.userRepository = dataSource.getRepository(UserEntity);
|
|
|
+ }
|
|
|
+
|
|
|
+ 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 };
|
|
|
+ }
|
|
|
+
|
|
|
+ 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!;
|
|
|
+
|
|
|
+ const url = `https://api.weixin.qq.com/sns/jscode2session?appid=${appId}&secret=${appSecret}&js_code=${code}&grant_type=authorization_code`;
|
|
|
+
|
|
|
+ const response = await axios.get(url);
|
|
|
+
|
|
|
+ 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
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ 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
|
|
|
+ });
|
|
|
+
|
|
|
+ return await this.userRepository.save(user);
|
|
|
+ }
|
|
|
+
|
|
|
+ private generateToken(user: UserEntity): string {
|
|
|
+ const payload = {
|
|
|
+ id: user.id,
|
|
|
+ username: user.username,
|
|
|
+ openid: user.openid
|
|
|
+ };
|
|
|
+ return jwt.sign(payload, process.env.JWT_SECRET!, { expiresIn: '7d' });
|
|
|
+ }
|
|
|
+
|
|
|
+ async updateUserProfile(userId: number, profile: { nickname?: string; avatar?: string }): Promise<UserEntity> {
|
|
|
+ const user = await this.userRepository.findOne({ where: { id: userId } });
|
|
|
+ if (!user) throw new Error('用户不存在');
|
|
|
+
|
|
|
+ if (profile.nickname) user.nickname = profile.nickname;
|
|
|
+ if (profile.avatar) user.miniappAvatar = profile.avatar;
|
|
|
+
|
|
|
+ return await this.userRepository.save(user);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+#### 3. API路由实现
|
|
|
+
|
|
|
+```typescript
|
|
|
+// src/server/api/auth/mini-login/post.ts
|
|
|
+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';
|
|
|
+
|
|
|
+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()
|
|
|
+ }),
|
|
|
+ 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
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+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 && result.isNewUser) {
|
|
|
+ await miniAuthService.updateUserProfile(result.user.id, {
|
|
|
+ nickname: userInfo.nickName,
|
|
|
+ avatar: userInfo.avatarUrl
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ return c.json({
|
|
|
+ token: result.token,
|
|
|
+ user: {
|
|
|
+ id: result.user.id,
|
|
|
+ username: result.user.username,
|
|
|
+ nickname: result.user.nickname,
|
|
|
+ avatar: result.user.miniappAvatar
|
|
|
+ },
|
|
|
+ isNewUser: result.isNewUser
|
|
|
+ });
|
|
|
+ } catch (error) {
|
|
|
+ return c.json({
|
|
|
+ code: 400,
|
|
|
+ message: error instanceof Error ? error.message : '登录失败'
|
|
|
+ }, 400);
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+export default app;
|
|
|
+```
|
|
|
+
|
|
|
+#### 4. 小程序端实现
|
|
|
+
|
|
|
+```typescript
|
|
|
+// mini/src/pages/login/index.tsx
|
|
|
+import { useState } from 'react'
|
|
|
+import Taro from '@tarojs/taro'
|
|
|
+import { View, Text, Button } from '@tarojs/components'
|
|
|
+import { useForm } from 'react-hook-form'
|
|
|
+import { zodResolver } from '@hookform/resolvers/zod'
|
|
|
+import { z } from 'zod'
|
|
|
+
|
|
|
+const miniLoginSchema = z.object({
|
|
|
+ code: z.string(),
|
|
|
+ userInfo: z.object({
|
|
|
+ nickName: z.string(),
|
|
|
+ avatarUrl: z.string()
|
|
|
+ }).optional()
|
|
|
+})
|
|
|
+
|
|
|
+export default function MiniLoginPage() {
|
|
|
+ const [loading, setLoading] = useState(false)
|
|
|
+
|
|
|
+ const handleLogin = async () => {
|
|
|
+ setLoading(true)
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 1. 获取用户信息
|
|
|
+ const userProfile = await Taro.getUserProfile({
|
|
|
+ desc: '用于完善用户资料'
|
|
|
+ })
|
|
|
+
|
|
|
+ // 2. 获取登录code
|
|
|
+ const loginRes = await Taro.login()
|
|
|
+
|
|
|
+ // 3. 调用后端API
|
|
|
+ const response = await Taro.request({
|
|
|
+ url: `${API_BASE_URL}/api/v1/auth/mini-login`,
|
|
|
+ method: 'POST',
|
|
|
+ data: {
|
|
|
+ code: loginRes.code,
|
|
|
+ userInfo: userProfile.userInfo
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ if (response.statusCode === 200) {
|
|
|
+ const { token, user, isNewUser } = response.data
|
|
|
+
|
|
|
+ // 4. 保存token
|
|
|
+ Taro.setStorageSync('token', token)
|
|
|
+ Taro.setStorageSync('userInfo', user)
|
|
|
+
|
|
|
+ // 5. 跳转到首页
|
|
|
+ Taro.switchTab({ url: '/pages/index/index' })
|
|
|
+
|
|
|
+ Taro.showToast({
|
|
|
+ title: isNewUser ? '注册成功' : '登录成功',
|
|
|
+ icon: 'success'
|
|
|
+ })
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ Taro.showToast({
|
|
|
+ title: '登录失败',
|
|
|
+ icon: 'none'
|
|
|
+ })
|
|
|
+ } finally {
|
|
|
+ setLoading(false)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <View className="min-h-screen flex items-center justify-center bg-gray-50">
|
|
|
+ <View className="w-80">
|
|
|
+ <Text className="text-2xl font-bold text-center mb-8">欢迎使用</Text>
|
|
|
+ <Button
|
|
|
+ type="primary"
|
|
|
+ loading={loading}
|
|
|
+ onClick={handleLogin}
|
|
|
+ className="w-full"
|
|
|
+ >
|
|
|
+ 微信一键登录
|
|
|
+ </Button>
|
|
|
+ </View>
|
|
|
+ </View>
|
|
|
+ )
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+#### 5. 环境配置
|
|
|
+
|
|
|
+```bash
|
|
|
+# .env
|
|
|
+WX_MINI_APP_ID=your_mini_app_id
|
|
|
+WX_MINI_APP_SECRET=your_mini_app_secret
|
|
|
+```
|
|
|
+
|
|
|
+#### 6. 路由注册
|
|
|
+
|
|
|
+```typescript
|
|
|
+// src/server/api/auth/index.ts
|
|
|
+import miniLoginRoute from './mini-login/post'
|
|
|
+
|
|
|
+const authRoutes = new OpenAPIHono()
|
|
|
+ .route('/', loginRoute)
|
|
|
+ .route('/', registerRoute)
|
|
|
+ .route('/', miniLoginRoute) // 新增小程序登录路由
|
|
|
+
|
|
|
+export default authRoutes
|
|
|
+```
|
|
|
+
|
|
|
+### 实现步骤总结
|
|
|
+
|
|
|
+1. **数据库迁移**:添加小程序相关字段
|
|
|
+2. **创建服务**:`MiniAuthService` 处理小程序登录逻辑
|
|
|
+3. **创建路由**:添加 `/api/v1/auth/mini-login` 端点
|
|
|
+4. **小程序端**:实现微信登录按钮和API调用
|
|
|
+5. **配置环境**:设置微信小程序的 AppID 和 AppSecret
|
|
|
+
|
|
|
+这个方案实现了:
|
|
|
+- ✅ 自动注册:首次登录自动创建用户
|
|
|
+- ✅ 自动登录:无需输入用户名密码
|
|
|
+- ✅ 用户信息获取:支持获取微信昵称和头像
|
|
|
+- ✅ 向后兼容:不影响现有的用户名密码登录
|
|
|
+- ✅ 安全可靠:使用微信官方API验证用户身份
|