import { prisma } from "../database"; import { Recording, Prisma } from "@prisma/client"; import { writeFile, unlink } from "fs/promises"; import { join } from "path"; import { cache } from "../cache"; import { logger } from "../utils/logger"; import { S3Client, DeleteObjectCommand } from "@aws-sdk/client-s3"; export interface CreateRecordingData { title: string; audioUrl: string; duration: number; fileSize: number; mimeType: string; userId: string; } export interface UpdateRecordingData { title?: string; duration?: number; } export interface RecordingWithUser { id: string; title: string; audioUrl: string; duration: number; fileSize: number; mimeType: string; createdAt: Date; user: { id: string; name: string | null; email: string | null; }; } // S3 删除工具函数 async function deleteS3File(audioUrl: string) { try { const s3 = new S3Client({ region: process.env.AWS_REGION || "us-east-1", credentials: { accessKeyId: process.env.AWS_ACCESS_KEY_ID!, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, }, }); const url = new URL(audioUrl); const bucket = url.hostname.split(".")[0]; const key = url.pathname.slice(1); // 去掉开头的 / await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key })); console.log(`S3 文件删除成功: ${bucket}/${key}`); } catch (err) { console.warn("S3 文件删除失败(忽略):", err); } } export class RecordingService { /** * 创建新录音 */ static async createRecording(data: CreateRecordingData): Promise { const startTime = Date.now(); try { const recording = await prisma.recording.create({ data, }); // 清除相关缓存 cache.delete(`recordings:user:${data.userId}`); cache.delete(`stats:user:${data.userId}`); // 清除可能的缓存变体 cache.delete(`recordings:user:${data.userId}:0:20:{"createdAt":"desc"}`); cache.delete(`recordings:user:${data.userId}:0:50:{"createdAt":"desc"}`); logger.logDbOperation("create", "recording", Date.now() - startTime); logger.logUserAction(data.userId, "create_recording", { recordingId: recording.id, }); return recording; } catch (error) { logger.error( "Failed to create recording", { userId: data.userId }, error as Error ); throw error; } } /** * 根据ID获取录音 */ static async getRecordingById(id: string): Promise { const cacheKey = `recording:${id}`; const cached = cache.get(cacheKey); if (cached) { return cached; } const startTime = Date.now(); try { const recording = await prisma.recording.findUnique({ where: { id }, }); if (recording) { cache.set(cacheKey, recording, 10 * 60 * 1000); // 缓存10分钟 } logger.logDbOperation("findUnique", "recording", Date.now() - startTime); return recording; } catch (error) { logger.error("Failed to get recording by ID", { id }, error as Error); throw error; } } /** * 根据用户ID获取录音列表 */ static async getRecordingsByUserId( userId: string, options?: { skip?: number; take?: number; orderBy?: Prisma.RecordingOrderByWithRelationInput; } ): Promise { const { skip = 0, take = 50, orderBy = { createdAt: "desc" }, } = options || {}; const cacheKey = `recordings:user:${userId}:${skip}:${take}:${JSON.stringify( orderBy )}`; const cached = cache.get(cacheKey); if (cached) { return cached; } const startTime = Date.now(); try { const recordings = await prisma.recording.findMany({ where: { userId }, skip, take, orderBy, include: { user: { select: { id: true, name: true, email: true, }, }, }, }); cache.set(cacheKey, recordings, 5 * 60 * 1000); // 缓存5分钟 logger.logDbOperation("findMany", "recording", Date.now() - startTime); return recordings; } catch (error) { logger.error( "Failed to get recordings by user ID", { userId }, error as Error ); throw error; } } /** * 更新录音信息 */ static async updateRecording( id: string, userId: string, data: UpdateRecordingData ): Promise { const startTime = Date.now(); console.log( `RecordingService.updateRecording - 开始更新录音: ${id}, 用户: ${userId}, 数据:`, data ); try { // 验证录音所有权 const recording = await prisma.recording.findFirst({ where: { id, userId }, }); console.log( `RecordingService.updateRecording - 查找录音结果:`, recording ? "找到" : "未找到" ); if (!recording) { throw new Error("录音不存在或无权限"); } console.log( `RecordingService.updateRecording - 开始更新数据库, 当前标题: "${recording.title}"` ); const updatedRecording = await prisma.recording.update({ where: { id }, data, }); console.log( `RecordingService.updateRecording - 数据库更新成功, 新标题: "${updatedRecording.title}"` ); // 清除相关缓存 cache.delete(`recording:${id}`); cache.delete(`recordings:user:${userId}`); // 清除可能的缓存变体 cache.delete(`recordings:user:${userId}:0:20:{"createdAt":"desc"}`); cache.delete(`recordings:user:${userId}:0:50:{"createdAt":"desc"}`); // 清除所有可能的录音列表缓存 const commonSkipValues = [0, 20, 50, 100]; const commonTakeValues = [20, 50, 100]; const commonOrderBy = ['{"createdAt":"desc"}', '{"createdAt":"asc"}']; for (const skip of commonSkipValues) { for (const take of commonTakeValues) { for (const orderBy of commonOrderBy) { cache.delete( `recordings:user:${userId}:${skip}:${take}:${orderBy}` ); } } } logger.logDbOperation("update", "recording", Date.now() - startTime); logger.logUserAction(userId, "update_recording", { recordingId: id, data, }); return updatedRecording; } catch (error) { console.error(`RecordingService.updateRecording - 更新失败:`, error); logger.error( "Failed to update recording", { id, userId }, error as Error ); throw error; } } /** * 删除录音 */ static async deleteRecording(id: string, userId: string): Promise { const startTime = Date.now(); try { console.log( `RecordingService: Attempting to delete recording ${id} for user ${userId}` ); // 验证录音所有权 const recording = await prisma.recording.findFirst({ where: { id, userId }, }); console.log( `RecordingService: Found recording:`, recording ? "Yes" : "No" ); if (!recording) { // 检查录音是否存在,但属于其他用户 const otherRecording = await prisma.recording.findUnique({ where: { id }, }); if (otherRecording) { console.log( `RecordingService: Recording exists but belongs to user ${otherRecording.userId}, not ${userId}` ); throw new Error("录音不存在或无权限"); } else { console.log(`RecordingService: Recording ${id} does not exist`); throw new Error("录音不存在"); } } // 先删除 S3 文件 await deleteS3File(recording.audioUrl); console.log(`RecordingService: Deleting recording from database`); // 删除数据库记录 await prisma.recording.delete({ where: { id }, }); // 清除相关缓存 - 更彻底的清除 cache.delete(`recording:${id}`); cache.delete(`recordings:user:${userId}`); cache.delete(`stats:user:${userId}`); // 清除可能的缓存变体 cache.delete(`recordings:user:${userId}:0:20:{"createdAt":"desc"}`); cache.delete(`recordings:user:${userId}:0:50:{"createdAt":"desc"}`); console.log(`RecordingService: Cache cleared`); logger.logDbOperation("delete", "recording", Date.now() - startTime); logger.logUserAction(userId, "delete_recording", { recordingId: id }); console.log(`RecordingService: Recording ${id} deleted successfully`); } catch (error) { console.error( `RecordingService: Failed to delete recording ${id}:`, error ); logger.error( "Failed to delete recording", { id, userId }, error as Error ); throw error; } } /** * 保存录音文件 */ static async saveRecordingFile( file: Buffer, filename: string ): Promise { const startTime = Date.now(); try { // 验证文件大小 (50MB 限制) const maxSize = 50 * 1024 * 1024; if (file.length > maxSize) { throw new Error("文件大小不能超过 50MB"); } // 验证文件名格式 const filenameRegex = /^recording-\d+\.webm$/; if (!filenameRegex.test(filename)) { throw new Error("文件名格式不正确"); } // 验证文件内容类型 - 更宽松的 WebM 验证 if (file.length < 4) { throw new Error("文件太小,无法验证格式"); } const webmHeader = file.slice(0, 4); const webmSignature = Buffer.from([0x1a, 0x45, 0xdf, 0xa3]); // 检查是否为 WebM 格式,但也允许其他音频格式 const isValidWebM = webmHeader.equals(webmSignature); const hasAudioExtension = filename.toLowerCase().endsWith(".webm") || filename.toLowerCase().endsWith(".mp3") || filename.toLowerCase().endsWith(".wav"); if (!isValidWebM && !hasAudioExtension) { logger.warn("File format validation failed", { filename, header: webmHeader.toString("hex"), expected: webmSignature.toString("hex"), }); // 不抛出错误,允许上传,但记录警告 } const uploadDir = join(process.cwd(), "public", "recordings"); const filePath = join(uploadDir, filename); await writeFile(filePath, file); logger.info("Recording file saved", { filename, size: file.length, duration: Date.now() - startTime, }); return `/recordings/${filename}`; } catch (error) { logger.error( "Failed to save recording file", { filename }, error as Error ); throw new Error("保存录音文件失败"); } } /** * 获取用户录音统计 */ static async getUserRecordingStats(userId: string): Promise<{ totalRecordings: number; totalDuration: number; totalFileSize: number; }> { const cacheKey = `stats:user:${userId}`; const cached = cache.get<{ totalRecordings: number; totalDuration: number; totalFileSize: number; }>(cacheKey); if (cached) { return cached; } const startTime = Date.now(); try { const stats = await prisma.recording.aggregate({ where: { userId }, _count: { id: true, }, _sum: { duration: true, fileSize: true, }, }); const result = { totalRecordings: stats._count.id, totalDuration: stats._sum.duration || 0, totalFileSize: stats._sum.fileSize || 0, }; cache.set(cacheKey, result, 10 * 60 * 1000); // 缓存10分钟 logger.logDbOperation("aggregate", "recording", Date.now() - startTime); return result; } catch (error) { logger.error( "Failed to get user recording stats", { userId }, error as Error ); throw error; } } /** * 批量删除用户录音 */ static async deleteUserRecordings(userId: string): Promise { const startTime = Date.now(); try { const recordings = await prisma.recording.findMany({ where: { userId }, }); // 删除所有录音文件 for (const recording of recordings) { try { const filePath = join(process.cwd(), "public", recording.audioUrl); await unlink(filePath); } catch { // 忽略文件删除错误 logger.warn("Failed to delete recording file during bulk delete", { filePath: recording.audioUrl, }); } } // 删除数据库记录 await prisma.recording.deleteMany({ where: { userId }, }); // 清除相关缓存 cache.delete(`recordings:user:${userId}`); cache.delete(`stats:user:${userId}`); logger.logDbOperation("deleteMany", "recording", Date.now() - startTime); logger.logUserAction(userId, "delete_all_recordings", { count: recordings.length, }); } catch (error) { logger.error( "Failed to delete user recordings", { userId }, error as Error ); throw error; } } }