Initial commit

This commit is contained in:
theshy
2025-07-31 17:05:07 +08:00
parent 8fab3b19cc
commit 24f21144ab
91 changed files with 16311 additions and 159 deletions

View File

@ -0,0 +1,510 @@
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;
}
}
}

View File

@ -0,0 +1,151 @@
import { prisma } from "../database";
import { UserSettings } from "@prisma/client";
export interface CreateUserSettingsData {
userId: string;
defaultQuality?: string;
publicProfile?: boolean;
allowDownload?: boolean;
}
export interface UpdateUserSettingsData {
defaultQuality?: string;
publicProfile?: boolean;
allowDownload?: boolean;
}
export class UserSettingsService {
/**
* 获取用户设置
*/
static async getUserSettings(userId: string): Promise<UserSettings | null> {
try {
const settings = await prisma.userSettings.findUnique({
where: { userId },
});
return settings;
} catch (error) {
console.error("Failed to get user settings:", error);
throw error;
}
}
/**
* 创建用户设置
*/
static async createUserSettings(
data: CreateUserSettingsData
): Promise<UserSettings> {
try {
const settings = await prisma.userSettings.create({
data: {
userId: data.userId,
defaultQuality: data.defaultQuality ?? "medium",
publicProfile: data.publicProfile ?? false,
allowDownload: data.allowDownload ?? true,
},
});
return settings;
} catch (error) {
console.error("Failed to create user settings:", error);
throw error;
}
}
/**
* 更新用户设置
*/
static async updateUserSettings(
userId: string,
data: UpdateUserSettingsData
): Promise<UserSettings> {
try {
// 检查用户设置是否存在
let settings = await prisma.userSettings.findUnique({
where: { userId },
});
if (!settings) {
// 如果不存在,创建默认设置
settings = await this.createUserSettings({
userId,
...data,
});
} else {
// 更新现有设置
settings = await prisma.userSettings.update({
where: { userId },
data,
});
}
return settings;
} catch (error) {
console.error("Failed to update user settings:", error);
throw error;
}
}
/**
* 删除用户设置
*/
static async deleteUserSettings(userId: string): Promise<void> {
try {
await prisma.userSettings.delete({
where: { userId },
});
} catch (error) {
console.error("Failed to delete user settings:", error);
throw error;
}
}
/**
* 获取或创建用户设置
*/
static async getOrCreateUserSettings(userId: string): Promise<UserSettings> {
try {
let settings = await this.getUserSettings(userId);
if (!settings) {
settings = await this.createUserSettings({
userId,
});
}
return settings;
} catch (error) {
console.error("Failed to get or create user settings:", error);
throw error;
}
}
/**
* 重置用户设置为默认值
*/
static async resetUserSettings(userId: string): Promise<UserSettings> {
try {
const settings = await prisma.userSettings.upsert({
where: { userId },
update: {
defaultQuality: "medium",
publicProfile: false,
allowDownload: true,
},
create: {
userId,
defaultQuality: "medium",
publicProfile: false,
allowDownload: true,
},
});
return settings;
} catch (error) {
console.error("Failed to reset user settings:", error);
throw error;
}
}
}

View File

@ -0,0 +1,159 @@
import { prisma } from '../database'
import { User, Prisma } from '@prisma/client'
import bcrypt from 'bcrypt'
export interface CreateUserData {
email: string
name?: string
password?: string
image?: string
}
export interface UpdateUserData {
name?: string
email?: string
image?: string
}
export interface UserProfile {
id: string
name: string | null
email: string | null
image: string | null
createdAt: Date
updatedAt: Date
}
export class UserService {
/**
* 创建新用户
*/
static async createUser(data: CreateUserData): Promise<User> {
const { email, name, password, image } = data
// 验证邮箱格式
if (!this.isValidEmail(email)) {
throw new Error('邮箱格式不正确')
}
// 检查邮箱是否已存在
const existingUser = await prisma.user.findUnique({
where: { email }
})
if (existingUser) {
throw new Error('邮箱已被注册')
}
// 哈希密码(如果提供)
let hashedPassword: string | undefined
if (password) {
if (password.length < 6) {
throw new Error('密码长度至少6位')
}
hashedPassword = await bcrypt.hash(password, 12)
}
return prisma.user.create({
data: {
email,
name,
image,
hashedPassword
}
})
}
/**
* 根据ID获取用户
*/
static async getUserById(id: string): Promise<UserProfile | null> {
const user = await prisma.user.findUnique({
where: { id },
select: {
id: true,
name: true,
email: true,
image: true,
createdAt: true,
updatedAt: true
}
})
return user
}
/**
* 根据邮箱获取用户
*/
static async getUserByEmail(email: string): Promise<User | null> {
return prisma.user.findUnique({
where: { email }
})
}
/**
* 更新用户信息
*/
static async updateUser(id: string, data: UpdateUserData): Promise<UserProfile> {
const updateData: Prisma.UserUpdateInput = {}
if (data.name !== undefined) {
updateData.name = data.name
}
if (data.email !== undefined) {
if (!this.isValidEmail(data.email)) {
throw new Error('邮箱格式不正确')
}
updateData.email = data.email
}
if (data.image !== undefined) {
updateData.image = data.image
}
const user = await prisma.user.update({
where: { id },
data: updateData,
select: {
id: true,
name: true,
email: true,
image: true,
createdAt: true,
updatedAt: true
}
})
return user
}
/**
* 验证用户密码
*/
static async verifyPassword(user: User, password: string): Promise<boolean> {
if (!user.hashedPassword) {
return false
}
return bcrypt.compare(password, user.hashedPassword)
}
/**
* 删除用户
*/
static async deleteUser(id: string): Promise<void> {
await prisma.user.delete({
where: { id }
})
}
/**
* 验证邮箱格式
*/
private static isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
}