511 lines
13 KiB
TypeScript
511 lines
13 KiB
TypeScript
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<Recording> {
|
|
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<Recording | null> {
|
|
const cacheKey = `recording:${id}`;
|
|
const cached = cache.get<Recording>(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<RecordingWithUser[]> {
|
|
const {
|
|
skip = 0,
|
|
take = 50,
|
|
orderBy = { createdAt: "desc" },
|
|
} = options || {};
|
|
const cacheKey = `recordings:user:${userId}:${skip}:${take}:${JSON.stringify(
|
|
orderBy
|
|
)}`;
|
|
|
|
const cached = cache.get<RecordingWithUser[]>(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<Recording> {
|
|
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<void> {
|
|
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<string> {
|
|
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<void> {
|
|
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;
|
|
}
|
|
}
|
|
}
|