Przeglądaj źródła

✨ feat(auth): 实现手机号解密功能和错误处理机制

- 集成微信SDK实现真实手机号解密功能,替换模拟数据
- 添加Redis存储和管理sessionKey,实现有效期控制
- 实现手机号获取失败自动重试机制,最多重试2次
- 优化错误提示,区分sessionKey过期和其他错误类型
- 增加错误状态显示UI,提升用户体验

🐛 fix(auth): 修复手机号获取失败问题

- 修复sessionKey获取逻辑,从Redis读取真实用户sessionKey
- 解决解密过程中的数据格式错误问题
- 修复手机号获取失败后没有适当提示的问题

🔧 chore(utils): 添加Redis工具类

- 实现Redis工具类,用于存储和管理用户sessionKey
- 添加sessionKey的设置、获取、删除和验证方法
- 设置默认2小时过期时间,与微信session_key有效期一致
yourname 3 miesięcy temu
rodzic
commit
e3da3f43db

+ 75 - 22
mini/src/pages/order/index.tsx

@@ -41,6 +41,8 @@ export default function OrderPage() {
   const [originalPrice, setOriginalPrice] = useState(0)
   const [isCharter] = useState(type === 'business-charter')
   const [showPassengerSelector, setShowPassengerSelector] = useState(false)
+  const [retryCount, setRetryCount] = useState(0)
+  const [lastError, setLastError] = useState<string>('')
 
   // 使用react-query获取路线数据
   const { data: schedule, isLoading: isLoadingRoute } = useQuery({
@@ -165,26 +167,56 @@ export default function OrderPage() {
 
         setPhoneNumber(result.phoneNumber)
         setHasPhoneNumber(true)
+        setRetryCount(0)
+        setLastError('')
         showToast({
           title: '手机号获取成功',
           icon: 'success',
           duration: 2000
         })
-      } catch (error) {
+      } catch (error: any) {
         console.error('手机号解密失败:', error)
+        const errorMessage = error.message || '手机号获取失败'
+        setLastError(errorMessage)
+
+        // 检查是否需要重试
+        if (retryCount < 2 && !errorMessage.includes('sessionKey已过期')) {
+          setRetryCount(prev => prev + 1)
+          showToast({
+            title: `${errorMessage},第${retryCount + 1}次重试`,
+            icon: 'none',
+            duration: 2000
+          })
+        } else {
+          showToast({
+            title: errorMessage.includes('sessionKey已过期')
+              ? '登录信息已过期,请重新登录'
+              : `${errorMessage},请重试`,
+            icon: 'error',
+            duration: 2000
+          })
+        }
+      }
+    } else {
+      console.error('获取手机号失败:', e.detail.errMsg)
+      const errorMessage = e.detail.errMsg || '获取手机号失败'
+      setLastError(errorMessage)
+
+      // 检查是否需要重试
+      if (retryCount < 2) {
+        setRetryCount(prev => prev + 1)
         showToast({
-          title: '手机号获取失败,请重试',
+          title: `${errorMessage},第${retryCount + 1}次重试`,
+          icon: 'none',
+          duration: 2000
+        })
+      } else {
+        showToast({
+          title: `${errorMessage},请重试`,
           icon: 'error',
           duration: 2000
         })
       }
-    } else {
-      console.error('获取手机号失败:', e.detail.errMsg)
-      showToast({
-        title: '获取手机号失败,请重试',
-        icon: 'error',
-        duration: 2000
-      })
     }
   }
 
@@ -565,19 +597,40 @@ export default function OrderPage() {
                   </View>
                 </View>
               ) : (
-                <Button
-                  variant="default"
-                  size="lg"
-                  openType="getPhoneNumber"
-                  onGetPhoneNumber={handleGetPhoneNumber}
-                  className="w-full"
-                  data-testid="get-phone-button"
-                >
-                  <View className="flex items-center justify-center">
-                    <View className="i-heroicons-phone-20-solid w-5 h-5 mr-2" />
-                    微信一键获取手机号
-                  </View>
-                </Button>
+                <View className="space-y-3">
+                  <Button
+                    variant="default"
+                    size="lg"
+                    openType="getPhoneNumber"
+                    onGetPhoneNumber={handleGetPhoneNumber}
+                    className="w-full"
+                    data-testid="get-phone-button"
+                  >
+                    <View className="flex items-center justify-center">
+                      <View className="i-heroicons-phone-20-solid w-5 h-5 mr-2" />
+                      {retryCount > 0 ? `第${retryCount + 1}次获取手机号` : '微信一键获取手机号'}
+                    </View>
+                  </Button>
+
+                  {lastError && (
+                    <View className="bg-red-50 p-3 rounded-lg border border-red-200">
+                      <View className="flex items-start gap-2">
+                        <View className="i-heroicons-exclamation-triangle-20-solid w-4 h-4 text-red-500 mt-0.5" />
+                        <Text className="text-sm text-red-700 flex-1">{lastError}</Text>
+                      </View>
+                      {retryCount >= 2 && (
+                        <View className="mt-2 text-center">
+                          <Text className="text-xs text-red-600">
+                            {lastError.includes('sessionKey已过期')
+                              ? '请重新登录小程序后重试'
+                              : '请检查网络后重试'
+                            }
+                          </Text>
+                        </View>
+                      )}
+                    </View>
+                  )}
+                </View>
               )}
             </CardContent>
           </Card>

+ 7 - 3
packages/server/src/api/auth/phone-decrypt/post.ts

@@ -5,6 +5,7 @@ import { ErrorSchema } from '../../../utils/errorHandler';
 import { UserEntity } from '../../../modules/users/user.entity';
 import { authMiddleware } from '../../../middleware/auth.middleware';
 import { MiniAuthService } from '../../../modules/auth/mini-auth.service';
+import { redisUtil } from '../../../utils/redis.util';
 import { AuthContext } from '../../../types/context';
 
 const PhoneDecryptSchema = z.object({
@@ -114,9 +115,12 @@ const app = new OpenAPIHono<AuthContext>().openapi(phoneDecryptRoute, async (c)
     // 创建 MiniAuthService 实例
     const miniAuthService = new MiniAuthService(AppDataSource);
 
-    // TODO: 需要从用户会话中获取 sessionKey
-    // 目前暂时使用模拟的 sessionKey
-    const sessionKey = 'mock_session_key_for_testing';
+    // 从Redis获取用户的sessionKey
+    const sessionKey = await redisUtil.getSessionKey(user.id);
+
+    if (!sessionKey) {
+      return c.json({ code: 400, message: 'sessionKey已过期,请重新登录' }, 400);
+    }
 
     // 使用 MiniAuthService 进行手机号解密
     const decryptedPhoneNumber = await miniAuthService.decryptPhoneNumber(

+ 60 - 15
packages/server/src/modules/auth/mini-auth.service.ts

@@ -2,6 +2,7 @@ import { DataSource, Repository } from 'typeorm';
 import { UserEntity } from '../users/user.entity';
 import { FileService } from '../files/file.service';
 import { JWTUtil } from '../../utils/jwt.util';
+import { redisUtil } from '../../utils/redis.util';
 import axios from 'axios';
 import process from 'node:process'
 
@@ -15,25 +16,28 @@ export class MiniAuthService {
   }
 
   async miniLogin(code: string): Promise<{ token: string; user: UserEntity; isNewUser: boolean }> {
-    // 1. 通过code获取openid
+    // 1. 通过code获取openid和session_key
     const openidInfo = await this.getOpenIdByCode(code);
-    
+
     // 2. 查找或创建用户
-    let user = await this.userRepository.findOne({ 
-      where: { openid: openidInfo.openid } 
+    let user = await this.userRepository.findOne({
+      where: { openid: openidInfo.openid }
     });
-    
+
     let isNewUser = false;
-    
+
     if (!user) {
       // 自动注册新用户
       user = await this.createMiniUser(openidInfo);
       isNewUser = true;
     }
-    
-    // 3. 生成token
+
+    // 3. 保存sessionKey到Redis
+    await redisUtil.setSessionKey(user.id, openidInfo.session_key);
+
+    // 4. 生成token
     const token = this.generateToken(user);
-    
+
     return { token, user, isNewUser };
   }
 
@@ -135,17 +139,58 @@ export class MiniAuthService {
    * 解密小程序加密的手机号
    */
   async decryptPhoneNumber(encryptedData: string, iv: string, sessionKey: string): Promise<string> {
-    // TODO: 集成微信小程序SDK进行实际的手机号解密
-    // 这里返回模拟的手机号用于开发测试
     console.debug('手机号解密请求:', { encryptedData, iv, sessionKey });
 
-    // 模拟解密过程
+    // 参数验证
     if (!encryptedData || !iv || !sessionKey) {
       throw { code: 400, message: '加密数据或初始向量不能为空' };
     }
 
-    // 在实际环境中,这里应该调用微信SDK进行解密
-    // 返回模拟的手机号
-    return '13800138000';
+    try {
+      // 使用Node.js内置crypto模块进行AES-128-CBC解密
+      // 微信小程序手机号解密算法:AES-128-CBC,PKCS#7填充
+      const crypto = await import('node:crypto');
+
+      // 创建解密器
+      const decipher = crypto.createDecipheriv(
+        'aes-128-cbc',
+        Buffer.from(sessionKey, 'base64'),
+        Buffer.from(iv, 'base64')
+      );
+
+      // 设置自动PKCS#7填充
+      decipher.setAutoPadding(true);
+
+      // 解密数据
+      let decrypted = decipher.update(Buffer.from(encryptedData, 'base64'));
+      decrypted = Buffer.concat([decrypted, decipher.final()]);
+
+      // 解析解密后的JSON数据
+      const decryptedStr = decrypted.toString('utf8');
+      const phoneData = JSON.parse(decryptedStr);
+
+      // 验证解密结果
+      if (!phoneData.phoneNumber || typeof phoneData.phoneNumber !== 'string') {
+        throw new Error('解密数据格式不正确');
+      }
+
+      console.debug('手机号解密成功:', { phoneNumber: phoneData.phoneNumber });
+      return phoneData.phoneNumber;
+
+    } catch (error) {
+      console.error('手机号解密失败:', error);
+
+      // 根据错误类型返回相应的错误信息
+      if (error instanceof SyntaxError) {
+        throw { code: 400, message: '解密数据格式错误' };
+      } else if (error instanceof Error && error.message?.includes('wrong final block length')) {
+        throw { code: 400, message: '解密数据长度不正确' };
+      } else if (error instanceof Error && error.message?.includes('bad decrypt')) {
+        throw { code: 400, message: '解密失败,请检查sessionKey是否正确' };
+      } else {
+        const errorMessage = error instanceof Error ? error.message : '未知错误';
+        throw { code: 400, message: '手机号解密失败: ' + errorMessage };
+      }
+    }
   }
 }

+ 64 - 0
packages/server/src/utils/redis.util.ts

@@ -0,0 +1,64 @@
+import { createClient, RedisClientType } from 'redis';
+
+class RedisUtil {
+  private client: RedisClientType | null = null;
+  private static instance: RedisUtil;
+
+  private constructor() {}
+
+  public static getInstance(): RedisUtil {
+    if (!RedisUtil.instance) {
+      RedisUtil.instance = new RedisUtil();
+    }
+    return RedisUtil.instance;
+  }
+
+  async connect(): Promise<RedisClientType> {
+    if (!this.client) {
+      this.client = createClient({
+        url: process.env.REDIS_URL || 'redis://127.0.0.1:6379'
+      });
+
+      this.client.on('error', (err) => {
+        console.error('Redis Client Error:', err);
+      });
+
+      await this.client.connect();
+    }
+    return this.client;
+  }
+
+  async disconnect(): Promise<void> {
+    if (this.client) {
+      await this.client.disconnect();
+      this.client = null;
+    }
+  }
+
+  async setSessionKey(userId: number, sessionKey: string, ttlSeconds: number = 7200): Promise<void> {
+    const client = await this.connect();
+    const key = `session_key:${userId}`;
+    await client.set(key, sessionKey, {
+      EX: ttlSeconds // 默认2小时过期,与微信session_key有效期一致
+    });
+  }
+
+  async getSessionKey(userId: number): Promise<string | null> {
+    const client = await this.connect();
+    const key = `session_key:${userId}`;
+    return await client.get(key);
+  }
+
+  async deleteSessionKey(userId: number): Promise<void> {
+    const client = await this.connect();
+    const key = `session_key:${userId}`;
+    await client.del(key);
+  }
+
+  async isSessionKeyValid(userId: number): Promise<boolean> {
+    const sessionKey = await this.getSessionKey(userId);
+    return !!sessionKey;
+  }
+}
+
+export const redisUtil = RedisUtil.getInstance();