mini-auth.service.mt.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  1. import { DataSource, Repository } from 'typeorm';
  2. import { UserEntityMt } from '@d8d/core-module-mt/user-module-mt';
  3. import { FileServiceMt } from '@d8d/core-module-mt/file-module-mt';
  4. import { SystemConfigServiceMt } from '@d8d/core-module-mt/system-config-module-mt';
  5. import { JWTUtil, redisUtil } from '@d8d/shared-utils';
  6. import axios from 'axios';
  7. import process from 'node:process'
  8. export class MiniAuthService {
  9. private userRepository: Repository<UserEntityMt>;
  10. private fileService: FileServiceMt;
  11. private systemConfigService: SystemConfigServiceMt;
  12. constructor(dataSource: DataSource) {
  13. this.userRepository = dataSource.getRepository(UserEntityMt);
  14. this.fileService = new FileServiceMt(dataSource);
  15. this.systemConfigService = new SystemConfigServiceMt(dataSource);
  16. }
  17. async miniLogin(code: string, tenantId?: number): Promise<{ token: string; user: UserEntityMt; isNewUser: boolean }> {
  18. // 1. 通过code获取openid和session_key
  19. const openidInfo = await this.getOpenIdByCode(code, tenantId);
  20. // 2. 查找或创建用户
  21. let user = await this.userRepository.findOne({
  22. where: { openid: openidInfo.openid }
  23. });
  24. let isNewUser = false;
  25. if (!user) {
  26. // 自动注册新用户
  27. user = await this.createMiniUser(openidInfo, tenantId);
  28. isNewUser = true;
  29. }
  30. // 3. 保存sessionKey到Redis
  31. await redisUtil.setSessionKey(user.id, openidInfo.session_key);
  32. // 4. 生成token
  33. const token = this.generateToken(user);
  34. return { token, user, isNewUser };
  35. }
  36. async updateUserProfile(userId: number, profile: { nickname?: string; avatarUrl?: string }): Promise<UserEntityMt> {
  37. const user = await this.userRepository.findOne({
  38. where: { id: userId },
  39. relations: ['avatarFile']
  40. });
  41. if (!user) throw new Error('用户不存在');
  42. if (profile.nickname) user.nickname = profile.nickname;
  43. // 处理头像:如果用户没有头像且提供了小程序头像URL,则下载保存
  44. if (profile.avatarUrl && !user.avatarFileId) {
  45. try {
  46. const avatarFileId = await this.downloadAndSaveAvatar(profile.avatarUrl, userId);
  47. if (avatarFileId) {
  48. user.avatarFileId = avatarFileId;
  49. }
  50. } catch (error) {
  51. // 头像下载失败不影响主要功能
  52. console.error('头像下载失败:', error);
  53. }
  54. }
  55. return await this.userRepository.save(user);
  56. }
  57. private async getOpenIdByCode(code: string, tenantId?: number): Promise<{ openid: string; unionid?: string; session_key: string }> {
  58. // 优先从系统配置获取,如果配置不存在则回退到环境变量
  59. let appId: string | null = null;
  60. let appSecret: string | null = null;
  61. if (tenantId !== undefined) {
  62. // 从系统配置获取
  63. const configKeys = ['wx.mini.app.id', 'wx.mini.app.secret'];
  64. const configs = await this.systemConfigService.getConfigsByKeys(configKeys, tenantId);
  65. appId = configs['wx.mini.app.id'];
  66. appSecret = configs['wx.mini.app.secret'];
  67. }
  68. // 如果系统配置中没有找到,回退到环境变量
  69. if (!appId) {
  70. appId = process.env.WX_MINI_APP_ID || null;
  71. }
  72. if (!appSecret) {
  73. appSecret = process.env.WX_MINI_APP_SECRET || null;
  74. }
  75. if (!appId || !appSecret) {
  76. throw new Error('微信小程序配置缺失');
  77. }
  78. const url = `https://api.weixin.qq.com/sns/jscode2session?appid=${appId}&secret=${appSecret}&js_code=${code}&grant_type=authorization_code`;
  79. try {
  80. const response = await axios.get(url, { timeout: 10000 });
  81. if (response.data.errcode) {
  82. throw new Error(`微信API错误: ${response.data.errmsg}`);
  83. }
  84. return {
  85. openid: response.data.openid,
  86. unionid: response.data.unionid,
  87. session_key: response.data.session_key
  88. };
  89. } catch (error) {
  90. if (axios.isAxiosError(error)) {
  91. throw new Error('微信服务器连接失败');
  92. }
  93. throw error;
  94. }
  95. }
  96. private async createMiniUser(openidInfo: { openid: string; unionid?: string }, tenantId?: number): Promise<UserEntityMt> {
  97. const user = this.userRepository.create({
  98. tenantId: tenantId || 1, // 默认租户ID为1,如果未提供
  99. username: `wx_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`,
  100. password: '', // 小程序用户不需要密码
  101. openid: openidInfo.openid,
  102. unionid: openidInfo.unionid,
  103. nickname: '微信用户',
  104. registrationSource: 'miniapp',
  105. isDisabled: 0,
  106. isDeleted: 0
  107. });
  108. return await this.userRepository.save(user);
  109. }
  110. private async downloadAndSaveAvatar(avatarUrl: string, userId: number): Promise<number | null> {
  111. try {
  112. const result = await this.fileService.downloadAndSaveFromUrl(
  113. avatarUrl,
  114. {
  115. uploadUserId: userId,
  116. customPath: `avatars/`,
  117. mimeType: 'image/jpeg'
  118. },
  119. { timeout: 10000 }
  120. );
  121. return result.file.id;
  122. } catch (error) {
  123. console.error('下载保存头像失败:', error);
  124. return null;
  125. }
  126. }
  127. private generateToken(user: UserEntityMt): string {
  128. return JWTUtil.generateToken({
  129. id: user.id,
  130. username: user.username,
  131. roles: user.roles,
  132. openid: user.openid || undefined
  133. });
  134. }
  135. /**
  136. * 解密小程序加密的手机号
  137. */
  138. async decryptPhoneNumber(encryptedData: string, iv: string, sessionKey: string): Promise<string> {
  139. // 参数验证
  140. if (!encryptedData || !iv || !sessionKey) {
  141. throw { code: 400, message: '加密数据或初始向量不能为空' };
  142. }
  143. try {
  144. // 使用Node.js内置crypto模块进行AES-128-CBC解密
  145. // 微信小程序手机号解密算法:AES-128-CBC,PKCS#7填充
  146. const crypto = await import('node:crypto');
  147. // 创建解密器
  148. const decipher = crypto.createDecipheriv(
  149. 'aes-128-cbc',
  150. Buffer.from(sessionKey, 'base64'),
  151. Buffer.from(iv, 'base64')
  152. );
  153. // 设置自动PKCS#7填充
  154. decipher.setAutoPadding(true);
  155. // 解密数据
  156. let decrypted = decipher.update(Buffer.from(encryptedData, 'base64'));
  157. decrypted = Buffer.concat([decrypted, decipher.final()]);
  158. // 解析解密后的JSON数据
  159. const decryptedStr = decrypted.toString('utf8');
  160. const phoneData = JSON.parse(decryptedStr);
  161. // 验证解密结果
  162. if (!phoneData.phoneNumber || typeof phoneData.phoneNumber !== 'string') {
  163. throw new Error('解密数据格式不正确');
  164. }
  165. return phoneData.phoneNumber;
  166. } catch (error) {
  167. console.error('手机号解密失败:', error);
  168. // 根据错误类型返回相应的错误信息
  169. if (error instanceof SyntaxError) {
  170. throw { code: 400, message: '解密数据格式错误' };
  171. } else if (error instanceof Error && error.message?.includes('wrong final block length')) {
  172. throw { code: 400, message: '解密数据长度不正确' };
  173. } else if (error instanceof Error && error.message?.includes('bad decrypt')) {
  174. throw { code: 400, message: '解密失败,请检查sessionKey是否正确' };
  175. } else {
  176. const errorMessage = error instanceof Error ? error.message : '未知错误';
  177. throw { code: 400, message: '手机号解密失败: ' + errorMessage };
  178. }
  179. }
  180. }
  181. /**
  182. * 发送微信模板消息
  183. */
  184. async sendTemplateMessage(params: {
  185. openid: string;
  186. templateId: string;
  187. page?: string;
  188. data: Record<string, { value: string }>;
  189. miniprogramState?: 'developer' | 'trial' | 'formal';
  190. tenantId?: number;
  191. }): Promise<any> {
  192. const { openid, templateId, page, data, miniprogramState = 'formal', tenantId } = params;
  193. console.log("ssssssss");
  194. // 获取微信小程序配置
  195. let appId: string | null = null;
  196. let appSecret: string | null = null;
  197. if (tenantId !== undefined) {
  198. // 从系统配置获取
  199. const configKeys = ['wx.mini.app.id', 'wx.mini.app.secret'];
  200. const configs = await this.systemConfigService.getConfigsByKeys(configKeys, tenantId);
  201. appId = configs['wx.mini.app.id'];
  202. appSecret = configs['wx.mini.app.secret'];
  203. console.log("appId:",appId)
  204. console.log("appSecret:",appSecret)
  205. }
  206. // 如果系统配置中没有找到,回退到环境变量
  207. if (!appId) {
  208. appId = process.env.WX_MINI_APP_ID || null;
  209. }
  210. if (!appSecret) {
  211. appSecret = process.env.WX_MINI_APP_SECRET || null;
  212. }
  213. if (!appId || !appSecret) {
  214. throw new Error('微信小程序配置缺失');
  215. }
  216. // 获取access_token
  217. const accessToken = await this.getAccessToken(appId, appSecret);
  218. // 构建模板消息请求数据
  219. const templateMessageData = {
  220. touser: openid,
  221. template_id: templateId,
  222. page: page || 'pages/index/index',
  223. data: data,
  224. miniprogram_state: miniprogramState
  225. };
  226. console.debug('发送微信模板消息:', {
  227. appId,
  228. openid,
  229. templateId,
  230. page,
  231. dataKeys: Object.keys(data)
  232. });
  233. // 调用微信模板消息API
  234. const url = `https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${accessToken}`;
  235. try {
  236. const response = await axios.post(url, templateMessageData, {
  237. timeout: 10000,
  238. headers: {
  239. 'Content-Type': 'application/json'
  240. }
  241. });
  242. if (response.data.errcode && response.data.errcode !== 0) {
  243. throw new Error(`微信模板消息发送失败: ${response.data.errmsg} (errcode: ${response.data.errcode})`);
  244. }
  245. console.debug('微信模板消息发送成功:', response.data);
  246. return response.data;
  247. } catch (error) {
  248. if (axios.isAxiosError(error)) {
  249. throw new Error(`微信服务器连接失败: ${error.message}`);
  250. }
  251. throw error;
  252. }
  253. }
  254. /**
  255. * 获取微信access_token(带缓存机制)
  256. */
  257. private async getAccessToken(appId: string, appSecret: string): Promise<string> {
  258. // 1. 首先尝试从Redis缓存获取
  259. const cachedToken = await redisUtil.getWechatAccessToken(appId);
  260. if (cachedToken) {
  261. console.debug(`使用缓存的微信access_token,appId: ${appId}`);
  262. return cachedToken;
  263. }
  264. console.debug(`缓存中未找到微信access_token,从API获取,appId: ${appId}`);
  265. // 2. 缓存中没有,调用微信API获取
  266. const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appId}&secret=${appSecret}`;
  267. try {
  268. const response = await axios.get(url, { timeout: 10000 });
  269. if (response.data.errcode) {
  270. throw new Error(`获取access_token失败: ${response.data.errmsg}`);
  271. }
  272. const accessToken = response.data.access_token;
  273. const expiresIn = response.data.expires_in || 7200; // 微信默认返回7200秒(2小时)
  274. // 3. 将获取到的access_token存入Redis缓存
  275. // 设置过期时间比微信返回的expires_in少100秒,确保安全
  276. const cacheExpiresIn = Math.max(expiresIn - 100, 600); // 最少缓存10分钟
  277. await redisUtil.setWechatAccessToken(appId, accessToken, cacheExpiresIn);
  278. console.debug(`微信access_token获取成功并已缓存,appId: ${appId}, 过期时间: ${cacheExpiresIn}秒`);
  279. return accessToken;
  280. } catch (error) {
  281. if (axios.isAxiosError(error)) {
  282. throw new Error('微信服务器连接失败,无法获取access_token');
  283. }
  284. throw error;
  285. }
  286. }
  287. /**
  288. * 强制刷新微信access_token(清除缓存并重新获取)
  289. */
  290. private async refreshAccessToken(appId: string, appSecret: string): Promise<string> {
  291. console.debug(`强制刷新微信access_token,appId: ${appId}`);
  292. // 1. 清除缓存
  293. await redisUtil.deleteWechatAccessToken(appId);
  294. // 2. 重新获取
  295. return await this.getAccessToken(appId, appSecret);
  296. }
  297. /**
  298. * 检查微信access_token缓存状态
  299. */
  300. private async checkAccessTokenCacheStatus(appId: string): Promise<{
  301. hasCache: boolean;
  302. ttl: number;
  303. isValid: boolean;
  304. }> {
  305. const hasCache = await redisUtil.isWechatAccessTokenValid(appId);
  306. const ttl = await redisUtil.getWechatAccessTokenTTL(appId);
  307. return {
  308. hasCache,
  309. ttl,
  310. isValid: hasCache && ttl > 60 // 剩余时间大于60秒认为有效
  311. };
  312. }
  313. }