|
@@ -0,0 +1,165 @@
|
|
|
|
|
+import { DataSource, Repository } from 'typeorm';
|
|
|
|
|
+import { UserEntity } from '@/server/modules/users/user.entity';
|
|
|
|
|
+import { File } from '@/server/modules/files/file.entity';
|
|
|
|
|
+import jwt from 'jsonwebtoken';
|
|
|
|
|
+import axios from 'axios';
|
|
|
|
|
+import { v4 as uuidv4 } from 'uuid';
|
|
|
|
|
+
|
|
|
|
|
+export class MiniAuthService {
|
|
|
|
|
+ private userRepository: Repository<UserEntity>;
|
|
|
|
|
+ private fileRepository: Repository<File>;
|
|
|
|
|
+
|
|
|
|
|
+ constructor(private dataSource: DataSource) {
|
|
|
|
|
+ this.userRepository = dataSource.getRepository(UserEntity);
|
|
|
|
|
+ this.fileRepository = dataSource.getRepository(File);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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 };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async updateUserProfile(userId: number, profile: { nickname?: string; avatarUrl?: string }): Promise<UserEntity> {
|
|
|
|
|
+ const user = await this.userRepository.findOne({
|
|
|
|
|
+ where: { id: userId },
|
|
|
|
|
+ relations: ['avatarFile']
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ if (!user) throw new Error('用户不存在');
|
|
|
|
|
+
|
|
|
|
|
+ if (profile.nickname) user.nickname = profile.nickname;
|
|
|
|
|
+
|
|
|
|
|
+ // 处理头像:如果用户没有头像且提供了小程序头像URL,则下载保存
|
|
|
|
|
+ if (profile.avatarUrl && !user.avatarFileId) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const avatarFileId = await this.downloadAndSaveAvatar(profile.avatarUrl);
|
|
|
|
|
+ if (avatarFileId) {
|
|
|
|
|
+ user.avatarFileId = avatarFileId;
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ // 头像下载失败不影响主要功能
|
|
|
|
|
+ console.error('头像下载失败:', error);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return await this.userRepository.save(user);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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;
|
|
|
|
|
+
|
|
|
|
|
+ if (!appId || !appSecret) {
|
|
|
|
|
+ throw new Error('微信小程序配置缺失');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const url = `https://api.weixin.qq.com/sns/jscode2session?appid=${appId}&secret=${appSecret}&js_code=${code}&grant_type=authorization_code`;
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const response = await axios.get(url, { timeout: 10000 });
|
|
|
|
|
+
|
|
|
|
|
+ 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
|
|
|
|
|
+ };
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ if (axios.isAxiosError(error)) {
|
|
|
|
|
+ throw new Error('微信服务器连接失败');
|
|
|
|
|
+ }
|
|
|
|
|
+ throw error;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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,
|
|
|
|
|
+ isDeleted: 0
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ return await this.userRepository.save(user);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async downloadAndSaveAvatar(avatarUrl: string): Promise<number | null> {
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 从URL下载头像
|
|
|
|
|
+ const response = await axios.get(avatarUrl, {
|
|
|
|
|
+ responseType: 'arraybuffer',
|
|
|
|
|
+ timeout: 10000
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const buffer = Buffer.from(response.data);
|
|
|
|
|
+
|
|
|
|
|
+ // 生成文件名
|
|
|
|
|
+ const fileName = `avatar_${uuidv4()}.jpg`;
|
|
|
|
|
+ const filePath = `avatars/${fileName}`;
|
|
|
|
|
+
|
|
|
|
|
+ // 上传到文件服务(这里模拟上传到MinIO)
|
|
|
|
|
+ // 实际项目中需要集成MinIO或其他文件存储服务
|
|
|
|
|
+ const fileRecord = this.fileRepository.create({
|
|
|
|
|
+ originalName: fileName,
|
|
|
|
|
+ fileName: fileName,
|
|
|
|
|
+ filePath: filePath,
|
|
|
|
|
+ fileSize: buffer.length,
|
|
|
|
|
+ mimeType: 'image/jpeg',
|
|
|
|
|
+ fileType: 'image',
|
|
|
|
|
+ extension: 'jpg',
|
|
|
|
|
+ md5: this.calculateMD5(buffer),
|
|
|
|
|
+ url: `${process.env.MINIO_ENDPOINT || ''}/avatars/${fileName}`
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const savedFile = await this.fileRepository.save(fileRecord);
|
|
|
|
|
+ return savedFile.id;
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('下载保存头像失败:', error);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private calculateMD5(buffer: Buffer): string {
|
|
|
|
|
+ // 简化的MD5计算,实际项目中应使用crypto模块
|
|
|
|
|
+ return require('crypto').createHash('md5').update(buffer).digest('hex');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private generateToken(user: UserEntity): string {
|
|
|
|
|
+ const payload = {
|
|
|
|
|
+ id: user.id,
|
|
|
|
|
+ username: user.username,
|
|
|
|
|
+ openid: user.openid
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ if (!process.env.JWT_SECRET) {
|
|
|
|
|
+ throw new Error('JWT配置缺失');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '7d' });
|
|
|
|
|
+ }
|
|
|
|
|
+}
|