mini-auth.service.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. import { DataSource, Repository } from 'typeorm';
  2. import { UserEntity } from '@d8d/user-module';
  3. import { FileService } from '@d8d/file-module';
  4. import { JWTUtil, redisUtil } from '@d8d/shared-utils';
  5. import axios from 'axios';
  6. import process from 'node:process'
  7. export class MiniAuthService {
  8. private userRepository: Repository<UserEntity>;
  9. private fileService: FileService;
  10. constructor(dataSource: DataSource) {
  11. this.userRepository = dataSource.getRepository(UserEntity);
  12. this.fileService = new FileService(dataSource);
  13. }
  14. async miniLogin(code: string): Promise<{ token: string; user: UserEntity; isNewUser: boolean }> {
  15. // 1. 通过code获取openid和session_key
  16. const openidInfo = await this.getOpenIdByCode(code);
  17. // 2. 查找或创建用户
  18. let user = await this.userRepository.findOne({
  19. where: { openid: openidInfo.openid }
  20. });
  21. let isNewUser = false;
  22. if (!user) {
  23. // 自动注册新用户
  24. user = await this.createMiniUser(openidInfo);
  25. isNewUser = true;
  26. }
  27. // 3. 保存sessionKey到Redis
  28. await redisUtil.setSessionKey(user.id, openidInfo.session_key);
  29. // 4. 生成token
  30. const token = this.generateToken(user);
  31. return { token, user, isNewUser };
  32. }
  33. async updateUserProfile(userId: number, profile: { nickname?: string; avatarUrl?: string }): Promise<UserEntity> {
  34. const user = await this.userRepository.findOne({
  35. where: { id: userId },
  36. relations: ['avatarFile']
  37. });
  38. if (!user) throw new Error('用户不存在');
  39. if (profile.nickname) user.nickname = profile.nickname;
  40. // 处理头像:如果用户没有头像且提供了小程序头像URL,则下载保存
  41. if (profile.avatarUrl && !user.avatarFileId) {
  42. try {
  43. const avatarFileId = await this.downloadAndSaveAvatar(profile.avatarUrl, userId);
  44. if (avatarFileId) {
  45. user.avatarFileId = avatarFileId;
  46. }
  47. } catch (error) {
  48. // 头像下载失败不影响主要功能
  49. console.error('头像下载失败:', error);
  50. }
  51. }
  52. return await this.userRepository.save(user);
  53. }
  54. private async getOpenIdByCode(code: string): Promise<{ openid: string; unionid?: string; session_key: string }> {
  55. const appId = process.env.WX_MINI_APP_ID;
  56. const appSecret = process.env.WX_MINI_APP_SECRET;
  57. if (!appId || !appSecret) {
  58. throw new Error('微信小程序配置缺失');
  59. }
  60. const url = `https://api.weixin.qq.com/sns/jscode2session?appid=${appId}&secret=${appSecret}&js_code=${code}&grant_type=authorization_code`;
  61. try {
  62. const response = await axios.get(url, { timeout: 10000 });
  63. if (response.data.errcode) {
  64. throw new Error(`微信API错误: ${response.data.errmsg}`);
  65. }
  66. return {
  67. openid: response.data.openid,
  68. unionid: response.data.unionid,
  69. session_key: response.data.session_key
  70. };
  71. } catch (error) {
  72. if (axios.isAxiosError(error)) {
  73. throw new Error('微信服务器连接失败');
  74. }
  75. throw error;
  76. }
  77. }
  78. private async createMiniUser(openidInfo: { openid: string; unionid?: string }): Promise<UserEntity> {
  79. const user = this.userRepository.create({
  80. username: `wx_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`,
  81. password: '', // 小程序用户不需要密码
  82. openid: openidInfo.openid,
  83. unionid: openidInfo.unionid,
  84. nickname: '微信用户',
  85. registrationSource: 'miniapp',
  86. isDisabled: 0,
  87. isDeleted: 0
  88. });
  89. return await this.userRepository.save(user);
  90. }
  91. private async downloadAndSaveAvatar(avatarUrl: string, userId: number): Promise<number | null> {
  92. try {
  93. const result = await this.fileService.downloadAndSaveFromUrl(
  94. avatarUrl,
  95. {
  96. uploadUserId: userId,
  97. customPath: `avatars/`,
  98. mimeType: 'image/jpeg'
  99. },
  100. { timeout: 10000 }
  101. );
  102. return result.file.id;
  103. } catch (error) {
  104. console.error('下载保存头像失败:', error);
  105. return null;
  106. }
  107. }
  108. private generateToken(user: UserEntity): string {
  109. return JWTUtil.generateToken({
  110. id: user.id,
  111. username: user.username,
  112. roles: user.roles,
  113. openid: user.openid || undefined
  114. });
  115. }
  116. /**
  117. * 解密小程序加密的手机号
  118. */
  119. async decryptPhoneNumber(encryptedData: string, iv: string, sessionKey: string): Promise<string> {
  120. console.debug('手机号解密请求:', { encryptedData, iv, sessionKey });
  121. // 参数验证
  122. if (!encryptedData || !iv || !sessionKey) {
  123. throw { code: 400, message: '加密数据或初始向量不能为空' };
  124. }
  125. try {
  126. // 使用Node.js内置crypto模块进行AES-128-CBC解密
  127. // 微信小程序手机号解密算法:AES-128-CBC,PKCS#7填充
  128. const crypto = await import('node:crypto');
  129. // 创建解密器
  130. const decipher = crypto.createDecipheriv(
  131. 'aes-128-cbc',
  132. Buffer.from(sessionKey, 'base64'),
  133. Buffer.from(iv, 'base64')
  134. );
  135. // 设置自动PKCS#7填充
  136. decipher.setAutoPadding(true);
  137. // 解密数据
  138. let decrypted = decipher.update(Buffer.from(encryptedData, 'base64'));
  139. decrypted = Buffer.concat([decrypted, decipher.final()]);
  140. // 解析解密后的JSON数据
  141. const decryptedStr = decrypted.toString('utf8');
  142. const phoneData = JSON.parse(decryptedStr);
  143. // 验证解密结果
  144. if (!phoneData.phoneNumber || typeof phoneData.phoneNumber !== 'string') {
  145. throw new Error('解密数据格式不正确');
  146. }
  147. console.debug('手机号解密成功:', { phoneNumber: phoneData.phoneNumber });
  148. return phoneData.phoneNumber;
  149. } catch (error) {
  150. console.error('手机号解密失败:', error);
  151. // 根据错误类型返回相应的错误信息
  152. if (error instanceof SyntaxError) {
  153. throw { code: 400, message: '解密数据格式错误' };
  154. } else if (error instanceof Error && error.message?.includes('wrong final block length')) {
  155. throw { code: 400, message: '解密数据长度不正确' };
  156. } else if (error instanceof Error && error.message?.includes('bad decrypt')) {
  157. throw { code: 400, message: '解密失败,请检查sessionKey是否正确' };
  158. } else {
  159. const errorMessage = error instanceof Error ? error.message : '未知错误';
  160. throw { code: 400, message: '手机号解密失败: ' + errorMessage };
  161. }
  162. }
  163. }
  164. }