Explorar el Código

✨ feat(auth): 完成微信小程序静默登录功能

- 实现静默登录机制,应用启动时自动尝试登录
- 处理静默登录失败场景,确保不影响用户体验
- 修复小程序登录API返回的用户类型不匹配问题
- 使用parseWithAwait处理异步字段,确保数据一致性

📝 docs(story): 更新微信小程序自动登录故事状态

- 将故事状态从"In Progress"更新为"Ready for Review"
- 标记"实现静默登录机制"任务为已完成
- 添加调试日志引用和完成笔记列表
- 更新文件列表状态,反映最新实现情况
yourname hace 3 meses
padre
commit
4ab7705291

+ 20 - 10
docs/stories/005.013.mini-program-auto-login.story.md

@@ -1,7 +1,7 @@
 # Story 5.13: 微信小程序环境自动登录获取openid
 
 ## Status
-In Progress
+Ready for Review
 
 ## Story
 **As a** 微信小程序用户
@@ -13,7 +13,7 @@ In Progress
 - [x] 实现openid获取API接口,接收小程序code
 - [x] 自动创建或关联用户账号
 - [x] 支持用户信息自动同步
-- [ ] 实现静默登录机制,用户无感知(应用启动时自动登录)
+- [x] 实现静默登录机制,用户无感知(应用启动时自动登录)
 - [x] 支持登录状态持久化
 - [x] 确保登录流程的安全性和可靠性
 - [x] 支持登录失败的重试机制
@@ -31,11 +31,11 @@ In Progress
   - [x] 实现登录状态管理和持久化
   - [x] 支持登录失败重试机制
   - [x] 添加错误处理和用户友好提示
-- [ ] 实现静默登录机制 (AC: 5)
-  - [ ] 在应用启动时检查登录状态
-  - [ ] 实现静默登录逻辑(无用户感知)
-  - [ ] 处理静默登录失败场景
-  - [ ] 确保静默登录不影响用户体验
+- [x] 实现静默登录机制 (AC: 5)
+  - [x] 在应用启动时检查登录状态
+  - [x] 实现静默登录逻辑(无用户感知)
+  - [x] 处理静默登录失败场景
+  - [x] 确保静默登录不影响用户体验
 - [ ] 完善登录状态管理 (AC: 6)
   - [ ] 优化token存储和刷新机制
   - [ ] 实现登录状态自动续期
@@ -120,14 +120,24 @@ In Progress
 James (Developer Agent)
 
 ### Debug Log References
+- 修复小程序登录API返回的用户类型不匹配问题:`packages/server/src/api/auth/mini-login/post.ts:98-119`
+- 实现静默登录机制:`mini/src/utils/auth.tsx:29-77`
+- 使用parseWithAwait处理异步字段:`packages/server/src/api/auth/mini-login/post.ts:116-117`
 
 ### Completion Notes List
+- ✅ 小程序登录API已实现,支持微信code换取openid和session_key
+- ✅ 自动创建或关联用户账号功能已实现
+- ✅ 用户信息自动同步和头像下载功能已实现
+- ✅ 前端微信登录页面已实现,支持完整的登录流程
+- ✅ 静默登录机制已实现,应用启动时自动尝试登录
+- ✅ 登录状态持久化和失败重试机制已实现
+- ✅ 修复了API返回类型不匹配的问题,确保前后端数据一致性
 
 ### File List
-- `packages/server/src/api/auth/mini-login/post.ts` - 小程序登录API路由
+- `packages/server/src/api/auth/mini-login/post.ts` - 小程序登录API路由(已修复类型问题)
 - `packages/server/src/modules/auth/mini-auth.service.ts` - 小程序认证服务
 - `mini/src/pages/login/wechat-login.tsx` - 微信登录页面
-- `mini/src/utils/auth.tsx` - 认证状态管理
-- `mini/src/app.tsx` - 应用入口(需要添加静默登录)
+- `mini/src/utils/auth.tsx` - 认证状态管理(已添加静默登录)
+- `mini/src/app.tsx` - 应用入口(已集成静默登录)
 
 ## QA Results

+ 44 - 1
mini/src/utils/auth.tsx

@@ -26,11 +26,52 @@ const queryClient = new QueryClient()
 export const AuthProvider: React.FC<PropsWithChildren> = ({ children }) => {
   const queryClient = useQueryClient()
 
+  // 静默登录mutation - 应用启动时自动尝试登录
+  const silentLoginMutation = useMutation<User | null, Error, void>({
+    mutationFn: async () => {
+      try {
+        // 尝试静默登录
+        const loginRes = await Taro.login()
+        if (!loginRes.code) {
+          return null // 静默登录失败,但不抛出错误
+        }
+
+        // 使用小程序code进行静默登录
+        const response = await authClient['mini-login'].$post({
+          json: {
+            code: loginRes.code
+            // 静默登录不请求用户信息
+          }
+        })
+
+        if (response.status === 200) {
+          const { token, user } = await response.json()
+          Taro.setStorageSync('mini_token', token)
+          Taro.setStorageSync('userInfo', JSON.stringify(user))
+          return user
+        }
+
+        return null // 静默登录失败
+      } catch (error) {
+        // 静默登录失败不抛出错误,不影响用户体验
+        console.debug('静默登录失败:', error)
+        return null
+      }
+    },
+    onSuccess: (user) => {
+      if (user) {
+        queryClient.setQueryData(['currentUser'], user)
+      }
+    }
+  })
+
   const { data: user, isLoading } = useQuery<User | null, Error>({
     queryKey: ['currentUser'],
     queryFn: async () => {
       const token = Taro.getStorageSync('mini_token')
       if (!token) {
+        // 如果没有token,尝试静默登录
+        silentLoginMutation.mutate()
         return null
       }
       try {
@@ -42,6 +83,8 @@ export const AuthProvider: React.FC<PropsWithChildren> = ({ children }) => {
         Taro.setStorageSync('userInfo', JSON.stringify(user))
         return user
       } catch (error) {
+        // token无效,尝试静默登录
+        silentLoginMutation.mutate()
         Taro.removeStorageSync('mini_token')
         Taro.removeStorageSync('userInfo')
         return null
@@ -160,7 +203,7 @@ export const AuthProvider: React.FC<PropsWithChildren> = ({ children }) => {
     logout: logoutMutation.mutateAsync,
     register: registerMutation.mutateAsync,
     updateUser,
-    isLoading: isLoading || loginMutation.isPending || registerMutation.isPending || logoutMutation.isPending,
+    isLoading: isLoading || loginMutation.isPending || registerMutation.isPending || logoutMutation.isPending || silentLoginMutation.isPending,
     isLoggedIn: !!user,
   }
 

+ 1 - 5
packages/server/src/api/auth/me/get.ts

@@ -2,11 +2,7 @@ import { createRoute, OpenAPIHono } from '@hono/zod-openapi'
 import { ErrorSchema } from '../../../utils/errorHandler'
 import { authMiddleware } from '../../../middleware/auth.middleware'
 import { AuthContext } from '../../../types/context'
-import { UserSchema } from '../../../modules/users/user.schema'
-
-const UserResponseSchema = UserSchema.omit({
-  password: true
-});
+import { UserResponseSchema } from '../../../modules/users/user.schema'
 
 const routeDef = createRoute({
   method: 'get',

+ 22 - 20
packages/server/src/api/auth/mini-login/post.ts

@@ -4,6 +4,8 @@ import { MiniAuthService } from '../../../modules/auth/mini-auth.service';
 import { AppDataSource } from '../../../data-source';
 import { ErrorSchema } from '../../../utils/errorHandler';
 import { UserEntity } from '../../../modules/users/user.entity';
+import { UserResponseSchema } from '../../../modules/users/user.schema';
+import { parseWithAwait } from '../../../utils/parseWithAwait';
 
 const MiniLoginSchema = z.object({
   code: z.string().openapi({
@@ -21,15 +23,7 @@ const MiniLoginResponseSchema = z.object({
     example: 'jwt.token.here',
     description: 'JWT Token'
   }),
-  user: z.object({
-    id: z.number(),
-    username: z.string(),
-    nickname: z.string().nullable(),
-    phone: z.string().nullable(),
-    email: z.string().nullable(),
-    avatarFileId: z.number().nullable(),
-    registrationSource: z.string()
-  }),
+  user: UserResponseSchema,
   isNewUser: z.boolean().openapi({
     example: true,
     description: '是否为新注册用户'
@@ -102,19 +96,27 @@ const app = new OpenAPIHono().openapi(miniLoginRoute, async (c) => {
       }
     }
     
-    return c.json({
+    // 获取完整的用户信息(包含所有字段)
+    const fullUser = await AppDataSource.getRepository(UserEntity).findOne({
+      where: { id: result.user.id },
+      relations: ['avatarFile', 'roles']
+    });
+
+    if (!fullUser) {
+      return c.json({ code: 500, message: '用户信息获取失败' }, 500);
+    }
+
+    // 构建用户响应数据
+    const userResponseData = {
       token: result.token,
-      user: {
-        id: result.user.id,
-        username: result.user.username,
-        nickname: result.user.nickname,
-        phone: result.user.phone,
-        email: result.user.email,
-        avatarFileId: result.user.avatarFileId,
-        registrationSource: result.user.registrationSource
-      },
+      user: fullUser,
       isNewUser: result.isNewUser
-    }, 200);
+    };
+
+    // 使用parseWithAwait处理异步字段
+    const validatedResponse = await parseWithAwait(MiniLoginResponseSchema, userResponseData);
+
+    return c.json(validatedResponse, 200);
   } catch (error) {
     const { code = 500, message = '登录失败' } = error as Error & { code?: number };
     return c.json({ code, message }, 500);