Initial commit
This commit is contained in:
97
lib/auth.ts
Normal file
97
lib/auth.ts
Normal file
@ -0,0 +1,97 @@
|
||||
// lib/auth.ts
|
||||
|
||||
import { PrismaAdapter } from "@auth/prisma-adapter";
|
||||
import { prisma } from "./database";
|
||||
import GoogleProvider from "next-auth/providers/google";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import { UserService } from "./services/user.service";
|
||||
import { AuthOptions } from "next-auth";
|
||||
|
||||
export const authOptions: AuthOptions = {
|
||||
adapter: PrismaAdapter(prisma),
|
||||
|
||||
providers: [
|
||||
GoogleProvider({
|
||||
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
}),
|
||||
|
||||
CredentialsProvider({
|
||||
name: "Credentials",
|
||||
credentials: {
|
||||
email: { label: "Email", type: "text" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials?.password) {
|
||||
throw new Error("请输入邮箱和密码");
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await UserService.getUserByEmail(credentials.email);
|
||||
|
||||
if (!user || !user.hashedPassword) {
|
||||
throw new Error("用户不存在或未设置密码");
|
||||
}
|
||||
|
||||
const isPasswordValid = await UserService.verifyPassword(
|
||||
user,
|
||||
credentials.password
|
||||
);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
throw new Error("密码错误");
|
||||
}
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
},
|
||||
|
||||
callbacks: {
|
||||
async jwt({ token, user, account }) {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
token.email = user.email;
|
||||
token.name = user.name;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
|
||||
async session({ session, token }) {
|
||||
if (token?.id && session.user) {
|
||||
session.user.id = token.id as string;
|
||||
session.user.email = token.email as string;
|
||||
session.user.name = token.name as string;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
|
||||
async signIn({ user, account, profile }) {
|
||||
// 允许所有用户登录
|
||||
return true;
|
||||
},
|
||||
|
||||
async redirect({ url, baseUrl }) {
|
||||
// 确保重定向到正确的页面
|
||||
if (url.startsWith("/")) return `${baseUrl}${url}`;
|
||||
else if (new URL(url).origin === baseUrl) return url;
|
||||
return `${baseUrl}/dashboard`;
|
||||
},
|
||||
},
|
||||
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
},
|
||||
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
|
||||
debug: process.env.NODE_ENV === "development",
|
||||
};
|
||||
151
lib/cache/index.ts
vendored
Normal file
151
lib/cache/index.ts
vendored
Normal file
@ -0,0 +1,151 @@
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export interface CacheOptions {
|
||||
ttl?: number; // 生存时间(毫秒)
|
||||
maxSize?: number; // 最大缓存项数
|
||||
}
|
||||
|
||||
export interface CacheEntry<T> {
|
||||
value: T;
|
||||
timestamp: number;
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
class Cache {
|
||||
private cache = new Map<string, CacheEntry<unknown>>();
|
||||
private maxSize: number;
|
||||
private defaultTtl: number;
|
||||
|
||||
constructor(options: CacheOptions = {}) {
|
||||
this.maxSize = options.maxSize || 1000;
|
||||
this.defaultTtl = options.ttl || 5 * 60 * 1000; // 默认5分钟
|
||||
}
|
||||
|
||||
set<T>(key: string, value: T, ttl?: number): void {
|
||||
// 如果缓存已满,删除最旧的项
|
||||
if (this.cache.size >= this.maxSize) {
|
||||
this.evictOldest();
|
||||
}
|
||||
|
||||
const entry: CacheEntry<T> = {
|
||||
value,
|
||||
timestamp: Date.now(),
|
||||
ttl: ttl || this.defaultTtl,
|
||||
};
|
||||
|
||||
this.cache.set(key, entry);
|
||||
logger.debug("Cache set", { key, ttl: entry.ttl });
|
||||
}
|
||||
|
||||
get<T>(key: string): T | null {
|
||||
const entry = this.cache.get(key) as CacheEntry<T> | undefined;
|
||||
|
||||
if (!entry) {
|
||||
logger.debug("Cache miss", { key });
|
||||
return null;
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if (Date.now() - entry.timestamp > entry.ttl) {
|
||||
this.cache.delete(key);
|
||||
logger.debug("Cache expired", { key });
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.debug("Cache hit", { key });
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
has(key: string): boolean {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) return false;
|
||||
|
||||
// 检查是否过期
|
||||
if (Date.now() - entry.timestamp > entry.ttl) {
|
||||
this.cache.delete(key);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
delete(key: string): boolean {
|
||||
const deleted = this.cache.delete(key);
|
||||
if (deleted) {
|
||||
logger.debug("Cache deleted", { key });
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
logger.info("Cache cleared");
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.cache.size;
|
||||
}
|
||||
|
||||
private evictOldest(): void {
|
||||
let oldestKey: string | null = null;
|
||||
let oldestTime = Date.now();
|
||||
|
||||
for (const [key, entry] of this.cache.entries()) {
|
||||
if (entry.timestamp < oldestTime) {
|
||||
oldestTime = entry.timestamp;
|
||||
oldestKey = key;
|
||||
}
|
||||
}
|
||||
|
||||
if (oldestKey) {
|
||||
this.cache.delete(oldestKey);
|
||||
logger.debug("Cache evicted oldest", { key: oldestKey });
|
||||
}
|
||||
}
|
||||
|
||||
// 清理过期项
|
||||
cleanup(): void {
|
||||
const now = Date.now();
|
||||
let cleanedCount = 0;
|
||||
|
||||
for (const [key, entry] of this.cache.entries()) {
|
||||
if (now - entry.timestamp > entry.ttl) {
|
||||
this.cache.delete(key);
|
||||
cleanedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (cleanedCount > 0) {
|
||||
logger.info("Cache cleanup completed", { cleanedCount });
|
||||
}
|
||||
}
|
||||
|
||||
// 获取缓存统计信息
|
||||
getStats(): {
|
||||
size: number;
|
||||
maxSize: number;
|
||||
hitRate: number;
|
||||
missRate: number;
|
||||
} {
|
||||
return {
|
||||
size: this.cache.size,
|
||||
maxSize: this.maxSize,
|
||||
hitRate: 0, // 需要实现命中率统计
|
||||
missRate: 0, // 需要实现未命中率统计
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局缓存实例
|
||||
export const cache = new Cache({
|
||||
maxSize: 1000,
|
||||
ttl: 5 * 60 * 1000, // 5分钟
|
||||
});
|
||||
|
||||
// 定期清理过期项
|
||||
if (typeof window === "undefined") {
|
||||
// 仅在服务器端运行
|
||||
setInterval(() => {
|
||||
cache.cleanup();
|
||||
}, 60 * 1000); // 每分钟清理一次
|
||||
}
|
||||
75
lib/config/audio-config.ts
Normal file
75
lib/config/audio-config.ts
Normal file
@ -0,0 +1,75 @@
|
||||
// 音频配置管理 - 简化版
|
||||
export interface AudioFormat {
|
||||
mimeType: string;
|
||||
extension: string;
|
||||
codec: string;
|
||||
quality: "low" | "medium" | "high" | "lossless";
|
||||
maxBitrate: number;
|
||||
}
|
||||
|
||||
// 支持的音频格式配置
|
||||
export const SUPPORTED_AUDIO_FORMATS: Record<string, AudioFormat> = {
|
||||
webm: {
|
||||
mimeType: "audio/webm;codecs=opus",
|
||||
extension: ".webm",
|
||||
codec: "opus",
|
||||
quality: "high",
|
||||
maxBitrate: 128000,
|
||||
},
|
||||
ogg: {
|
||||
mimeType: "audio/ogg;codecs=opus",
|
||||
extension: ".ogg",
|
||||
codec: "opus",
|
||||
quality: "high",
|
||||
maxBitrate: 192000,
|
||||
},
|
||||
aac: {
|
||||
mimeType: "audio/aac",
|
||||
extension: ".aac",
|
||||
codec: "aac",
|
||||
quality: "high",
|
||||
maxBitrate: 256000,
|
||||
},
|
||||
};
|
||||
|
||||
// 获取浏览器支持的音频格式
|
||||
export function getSupportedFormats(): AudioFormat[] {
|
||||
const supported: AudioFormat[] = [];
|
||||
|
||||
// 检查 WebM 支持
|
||||
if (MediaRecorder.isTypeSupported("audio/webm;codecs=opus")) {
|
||||
supported.push(SUPPORTED_AUDIO_FORMATS.webm);
|
||||
}
|
||||
|
||||
// 检查 OGG 支持
|
||||
if (MediaRecorder.isTypeSupported("audio/ogg;codecs=opus")) {
|
||||
supported.push(SUPPORTED_AUDIO_FORMATS.ogg);
|
||||
}
|
||||
|
||||
// 检查 AAC 支持
|
||||
if (MediaRecorder.isTypeSupported("audio/aac")) {
|
||||
supported.push(SUPPORTED_AUDIO_FORMATS.aac);
|
||||
}
|
||||
|
||||
// 如果没有支持的格式,至少返回 WebM
|
||||
if (supported.length === 0) {
|
||||
supported.push(SUPPORTED_AUDIO_FORMATS.webm);
|
||||
}
|
||||
|
||||
return supported;
|
||||
}
|
||||
|
||||
// 获取最佳录音格式
|
||||
export function getBestRecordingFormat(): AudioFormat {
|
||||
const supported = getSupportedFormats();
|
||||
return supported[0] || SUPPORTED_AUDIO_FORMATS.webm;
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
}
|
||||
60
lib/config/index.ts
Normal file
60
lib/config/index.ts
Normal file
@ -0,0 +1,60 @@
|
||||
// 环境变量验证
|
||||
const validateEnv = () => {
|
||||
const requiredEnvVars = ["DATABASE_URL", "NEXTAUTH_SECRET", "NEXTAUTH_URL"];
|
||||
|
||||
const missingVars = requiredEnvVars.filter(
|
||||
(varName) => !process.env[varName]
|
||||
);
|
||||
|
||||
if (missingVars.length > 0) {
|
||||
throw new Error(`缺少必需的环境变量: ${missingVars.join(", ")}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 在开发环境中验证环境变量
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
validateEnv();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
app: {
|
||||
name: "录音应用",
|
||||
version: "1.0.0",
|
||||
environment: process.env.NODE_ENV || "development",
|
||||
},
|
||||
|
||||
database: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
|
||||
auth: {
|
||||
secret: process.env.NEXTAUTH_SECRET!,
|
||||
url: process.env.NEXTAUTH_URL!,
|
||||
google: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
},
|
||||
},
|
||||
|
||||
upload: {
|
||||
maxFileSize: 50 * 1024 * 1024, // 50MB
|
||||
allowedTypes: ["audio/webm", "audio/mp3", "audio/ogg", "audio/aac"],
|
||||
uploadDir: "public/recordings",
|
||||
},
|
||||
|
||||
api: {
|
||||
rateLimit: {
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100, // limit each IP to 100 requests per windowMs
|
||||
},
|
||||
},
|
||||
|
||||
features: {
|
||||
audioVisualization: true,
|
||||
recordingPause: true,
|
||||
fileDownload: true,
|
||||
userSettings: true,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type Config = typeof config;
|
||||
90
lib/contexts/theme-context.tsx
Normal file
90
lib/contexts/theme-context.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
type Theme = "light" | "dark" | "auto";
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>("light");
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 从 localStorage 获取保存的主题
|
||||
const savedTheme = localStorage.getItem("theme") as Theme;
|
||||
if (savedTheme && ["light", "dark", "auto"].includes(savedTheme)) {
|
||||
setTheme(savedTheme);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 保存主题到 localStorage
|
||||
localStorage.setItem("theme", theme);
|
||||
|
||||
// 应用主题
|
||||
const root = document.documentElement;
|
||||
const body = document.body;
|
||||
|
||||
if (theme === "auto") {
|
||||
// 跟随系统主题
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
setIsDark(e.matches);
|
||||
if (e.matches) {
|
||||
root.classList.add("dark");
|
||||
body.classList.add("dark");
|
||||
} else {
|
||||
root.classList.remove("dark");
|
||||
body.classList.remove("dark");
|
||||
}
|
||||
};
|
||||
|
||||
setIsDark(mediaQuery.matches);
|
||||
if (mediaQuery.matches) {
|
||||
root.classList.add("dark");
|
||||
body.classList.add("dark");
|
||||
} else {
|
||||
root.classList.remove("dark");
|
||||
body.classList.remove("dark");
|
||||
}
|
||||
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||
} else {
|
||||
// 手动设置主题
|
||||
setIsDark(theme === "dark");
|
||||
if (theme === "dark") {
|
||||
root.classList.add("dark");
|
||||
body.classList.add("dark");
|
||||
} else {
|
||||
root.classList.remove("dark");
|
||||
body.classList.remove("dark");
|
||||
}
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme,
|
||||
isDark,
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
9
lib/database.ts
Normal file
9
lib/database.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined
|
||||
}
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||
61
lib/errors/app-error.ts
Normal file
61
lib/errors/app-error.ts
Normal file
@ -0,0 +1,61 @@
|
||||
export class AppError extends Error {
|
||||
public readonly statusCode: number
|
||||
public readonly isOperational: boolean
|
||||
public readonly code?: string
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
statusCode: number = 500,
|
||||
code?: string,
|
||||
isOperational: boolean = true
|
||||
) {
|
||||
super(message)
|
||||
this.statusCode = statusCode
|
||||
this.code = code
|
||||
this.isOperational = isOperational
|
||||
|
||||
Error.captureStackTrace(this, this.constructor)
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends AppError {
|
||||
constructor(message: string, code?: string) {
|
||||
super(message, 400, code || 'VALIDATION_ERROR')
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthenticationError extends AppError {
|
||||
constructor(message: string = '未授权访问', code?: string) {
|
||||
super(message, 401, code || 'AUTHENTICATION_ERROR')
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthorizationError extends AppError {
|
||||
constructor(message: string = '权限不足', code?: string) {
|
||||
super(message, 403, code || 'AUTHORIZATION_ERROR')
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends AppError {
|
||||
constructor(message: string = '资源不存在', code?: string) {
|
||||
super(message, 404, code || 'NOT_FOUND_ERROR')
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError extends AppError {
|
||||
constructor(message: string, code?: string) {
|
||||
super(message, 409, code || 'CONFLICT_ERROR')
|
||||
}
|
||||
}
|
||||
|
||||
export class RateLimitError extends AppError {
|
||||
constructor(message: string = '请求过于频繁', code?: string) {
|
||||
super(message, 429, code || 'RATE_LIMIT_ERROR')
|
||||
}
|
||||
}
|
||||
|
||||
export class InternalServerError extends AppError {
|
||||
constructor(message: string = '服务器内部错误') {
|
||||
super(message, 500, 'INTERNAL_SERVER_ERROR', false)
|
||||
}
|
||||
}
|
||||
98
lib/middleware/api-logger.ts
Normal file
98
lib/middleware/api-logger.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export interface ApiLoggerOptions {
|
||||
logRequests?: boolean;
|
||||
logResponses?: boolean;
|
||||
logErrors?: boolean;
|
||||
excludePaths?: string[];
|
||||
}
|
||||
|
||||
export class ApiLogger {
|
||||
static async logRequest(
|
||||
request: NextRequest,
|
||||
response: NextResponse,
|
||||
duration: number,
|
||||
options: ApiLoggerOptions = {}
|
||||
): Promise<void> {
|
||||
const {
|
||||
logRequests = true,
|
||||
logResponses = true,
|
||||
logErrors = true,
|
||||
excludePaths = [],
|
||||
} = options;
|
||||
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
|
||||
// 跳过排除的路径
|
||||
if (excludePaths.some((excludePath) => path.startsWith(excludePath))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const method = request.method;
|
||||
const statusCode = response.status;
|
||||
const userAgent = request.headers.get("user-agent") || "Unknown";
|
||||
const ip =
|
||||
request.headers.get("x-forwarded-for") ||
|
||||
request.headers.get("x-real-ip") ||
|
||||
"Unknown";
|
||||
|
||||
// 记录请求
|
||||
if (logRequests) {
|
||||
logger.logApiRequest(method, path, statusCode, duration);
|
||||
}
|
||||
|
||||
// 记录错误
|
||||
if (logErrors && statusCode >= 400) {
|
||||
logger.error("API Error", {
|
||||
method,
|
||||
path,
|
||||
statusCode,
|
||||
duration,
|
||||
userAgent,
|
||||
ip,
|
||||
});
|
||||
}
|
||||
|
||||
// 记录响应统计
|
||||
if (logResponses) {
|
||||
logger.info("API Response", {
|
||||
method,
|
||||
path,
|
||||
statusCode,
|
||||
duration,
|
||||
userAgent,
|
||||
ip,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static createMiddleware(options: ApiLoggerOptions = {}) {
|
||||
return async (
|
||||
request: NextRequest,
|
||||
handler: () => Promise<NextResponse>
|
||||
) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const response = await handler();
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
await this.logRequest(request, response, duration, options);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
const errorResponse = NextResponse.json(
|
||||
{ error: "Internal Server Error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
|
||||
await this.logRequest(request, errorResponse, duration, options);
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
510
lib/services/recording.service.ts
Normal file
510
lib/services/recording.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
151
lib/services/user-settings.service.ts
Normal file
151
lib/services/user-settings.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
159
lib/services/user.service.ts
Normal file
159
lib/services/user.service.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
111
lib/utils/api-response.ts
Normal file
111
lib/utils/api-response.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { AppError } from "../errors/app-error";
|
||||
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: {
|
||||
message: string;
|
||||
code?: string;
|
||||
details?: unknown;
|
||||
};
|
||||
meta?: {
|
||||
timestamp: string;
|
||||
requestId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class ApiResponseHandler {
|
||||
/**
|
||||
* 成功响应
|
||||
*/
|
||||
static success<T>(
|
||||
data: T,
|
||||
statusCode: number = 200,
|
||||
meta?: { requestId?: string }
|
||||
): NextResponse<ApiResponse<T>> {
|
||||
const response: ApiResponse<T> = {
|
||||
success: true,
|
||||
data,
|
||||
meta: {
|
||||
timestamp: new Date().toISOString(),
|
||||
...meta,
|
||||
},
|
||||
};
|
||||
|
||||
return NextResponse.json(response, { status: statusCode });
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误响应
|
||||
*/
|
||||
static error(
|
||||
error: AppError | Error,
|
||||
meta?: { requestId?: string }
|
||||
): NextResponse<ApiResponse> {
|
||||
const isAppError = error instanceof AppError;
|
||||
const statusCode = isAppError ? error.statusCode : 500;
|
||||
const code = isAppError ? error.code : "INTERNAL_SERVER_ERROR";
|
||||
|
||||
const response: ApiResponse = {
|
||||
success: false,
|
||||
error: {
|
||||
message: error.message,
|
||||
code,
|
||||
details: isAppError && !error.isOperational ? error.stack : undefined,
|
||||
},
|
||||
meta: {
|
||||
timestamp: new Date().toISOString(),
|
||||
...meta,
|
||||
},
|
||||
};
|
||||
|
||||
return NextResponse.json(response, { status: statusCode });
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页响应
|
||||
*/
|
||||
static paginated<T>(
|
||||
data: T[],
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
},
|
||||
meta?: { requestId?: string }
|
||||
): NextResponse<ApiResponse<{ data: T[]; pagination: typeof pagination }>> {
|
||||
const response: ApiResponse<{ data: T[]; pagination: typeof pagination }> =
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
data,
|
||||
pagination,
|
||||
},
|
||||
meta: {
|
||||
timestamp: new Date().toISOString(),
|
||||
...meta,
|
||||
},
|
||||
};
|
||||
|
||||
return NextResponse.json(response, { status: 200 });
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建响应
|
||||
*/
|
||||
static created<T>(
|
||||
data: T,
|
||||
meta?: { requestId?: string }
|
||||
): NextResponse<ApiResponse<T>> {
|
||||
return this.success(data, 201, meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* 无内容响应
|
||||
*/
|
||||
static noContent(): NextResponse {
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
}
|
||||
6
lib/utils/cn.ts
Normal file
6
lib/utils/cn.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
148
lib/utils/logger.ts
Normal file
148
lib/utils/logger.ts
Normal file
@ -0,0 +1,148 @@
|
||||
import { config } from "../config";
|
||||
|
||||
export enum LogLevel {
|
||||
DEBUG = 0,
|
||||
INFO = 1,
|
||||
WARN = 2,
|
||||
ERROR = 3,
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: string;
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
context?: Record<string, unknown>;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
class Logger {
|
||||
private logLevel: LogLevel;
|
||||
|
||||
constructor() {
|
||||
this.logLevel =
|
||||
process.env.NODE_ENV === "development" ? LogLevel.DEBUG : LogLevel.INFO;
|
||||
}
|
||||
|
||||
private formatMessage(entry: LogEntry): string {
|
||||
const { timestamp, level, message, context, error } = entry;
|
||||
const levelName = LogLevel[level];
|
||||
const contextStr = context ? ` ${JSON.stringify(context)}` : "";
|
||||
const errorStr = error ? `\n${error.stack}` : "";
|
||||
|
||||
return `[${timestamp}] ${levelName}: ${message}${contextStr}${errorStr}`;
|
||||
}
|
||||
|
||||
private shouldLog(level: LogLevel): boolean {
|
||||
return level >= this.logLevel;
|
||||
}
|
||||
|
||||
private log(
|
||||
level: LogLevel,
|
||||
message: string,
|
||||
context?: Record<string, unknown>,
|
||||
error?: Error
|
||||
): void {
|
||||
if (!this.shouldLog(level)) return;
|
||||
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message,
|
||||
context,
|
||||
error,
|
||||
};
|
||||
|
||||
const formattedMessage = this.formatMessage(entry);
|
||||
|
||||
switch (level) {
|
||||
case LogLevel.DEBUG:
|
||||
console.debug(formattedMessage);
|
||||
break;
|
||||
case LogLevel.INFO:
|
||||
console.info(formattedMessage);
|
||||
break;
|
||||
case LogLevel.WARN:
|
||||
console.warn(formattedMessage);
|
||||
break;
|
||||
case LogLevel.ERROR:
|
||||
console.error(formattedMessage);
|
||||
break;
|
||||
}
|
||||
|
||||
// 在生产环境中,可以发送到外部日志服务
|
||||
if (process.env.NODE_ENV === "production" && level >= LogLevel.ERROR) {
|
||||
this.sendToExternalService(entry);
|
||||
}
|
||||
}
|
||||
|
||||
private sendToExternalService(entry: LogEntry): void {
|
||||
// 这里可以集成 Sentry, LogRocket 等外部日志服务
|
||||
// 示例:发送到 Sentry
|
||||
if (process.env.SENTRY_DSN) {
|
||||
// Sentry.captureException(entry.error || new Error(entry.message))
|
||||
}
|
||||
}
|
||||
|
||||
debug(message: string, context?: Record<string, unknown>): void {
|
||||
this.log(LogLevel.DEBUG, message, context);
|
||||
}
|
||||
|
||||
info(message: string, context?: Record<string, unknown>): void {
|
||||
this.log(LogLevel.INFO, message, context);
|
||||
}
|
||||
|
||||
warn(
|
||||
message: string,
|
||||
context?: Record<string, unknown>,
|
||||
error?: Error
|
||||
): void {
|
||||
this.log(LogLevel.WARN, message, context, error);
|
||||
}
|
||||
|
||||
error(
|
||||
message: string,
|
||||
context?: Record<string, unknown>,
|
||||
error?: Error
|
||||
): void {
|
||||
this.log(LogLevel.ERROR, message, context, error);
|
||||
}
|
||||
|
||||
// 记录 API 请求
|
||||
logApiRequest(
|
||||
method: string,
|
||||
url: string,
|
||||
statusCode: number,
|
||||
duration: number
|
||||
): void {
|
||||
this.info("API Request", {
|
||||
method,
|
||||
url,
|
||||
statusCode,
|
||||
duration: `${duration}ms`,
|
||||
});
|
||||
}
|
||||
|
||||
// 记录数据库操作
|
||||
logDbOperation(operation: string, table: string, duration: number): void {
|
||||
this.debug("Database Operation", {
|
||||
operation,
|
||||
table,
|
||||
duration: `${duration}ms`,
|
||||
});
|
||||
}
|
||||
|
||||
// 记录用户操作
|
||||
logUserAction(
|
||||
userId: string,
|
||||
action: string,
|
||||
details?: Record<string, unknown>
|
||||
): void {
|
||||
this.info("User Action", {
|
||||
userId,
|
||||
action,
|
||||
...details,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = new Logger();
|
||||
163
lib/utils/notifications.ts
Normal file
163
lib/utils/notifications.ts
Normal file
@ -0,0 +1,163 @@
|
||||
// 通知类型
|
||||
export type NotificationType = "success" | "error" | "warning" | "info";
|
||||
|
||||
export interface AppNotification {
|
||||
id: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
message: string;
|
||||
duration?: number;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
// 通知管理器
|
||||
class NotificationManager {
|
||||
private notifications: AppNotification[] = [];
|
||||
private listeners: ((notifications: AppNotification[]) => void)[] = [];
|
||||
|
||||
// 添加通知
|
||||
addNotification(
|
||||
type: NotificationType,
|
||||
title: string,
|
||||
message: string,
|
||||
duration: number = 5000
|
||||
): string {
|
||||
const id = `notification-${Date.now()}-${Math.random()}`;
|
||||
const notification: AppNotification = {
|
||||
id,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
duration,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
this.notifications.push(notification);
|
||||
this.notifyListeners();
|
||||
|
||||
// 自动移除通知
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
this.removeNotification(id);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
// 移除通知
|
||||
removeNotification(id: string): void {
|
||||
this.notifications = this.notifications.filter((n) => n.id !== id);
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
// 清空所有通知
|
||||
clearAll(): void {
|
||||
this.notifications = [];
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
// 获取所有通知
|
||||
getNotifications(): AppNotification[] {
|
||||
return [...this.notifications];
|
||||
}
|
||||
|
||||
// 添加监听器
|
||||
addListener(listener: (notifications: AppNotification[]) => void): void {
|
||||
this.listeners.push(listener);
|
||||
}
|
||||
|
||||
// 移除监听器
|
||||
removeListener(listener: (notifications: AppNotification[]) => void): void {
|
||||
this.listeners = this.listeners.filter((l) => l !== listener);
|
||||
}
|
||||
|
||||
// 通知所有监听器
|
||||
private notifyListeners(): void {
|
||||
this.listeners.forEach((listener) => listener(this.notifications));
|
||||
}
|
||||
|
||||
// 便捷方法
|
||||
success(title: string, message: string, duration?: number): string {
|
||||
return this.addNotification("success", title, message, duration);
|
||||
}
|
||||
|
||||
error(title: string, message: string, duration?: number): string {
|
||||
return this.addNotification("error", title, message, duration);
|
||||
}
|
||||
|
||||
warning(title: string, message: string, duration?: number): string {
|
||||
return this.addNotification("warning", title, message, duration);
|
||||
}
|
||||
|
||||
info(title: string, message: string, duration?: number): string {
|
||||
return this.addNotification("info", title, message, duration);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局通知管理器实例
|
||||
export const notificationManager = new NotificationManager();
|
||||
|
||||
// 浏览器通知 API
|
||||
export class BrowserNotifications {
|
||||
static async requestPermission(): Promise<boolean> {
|
||||
if (!("Notification" in window)) {
|
||||
console.warn("此浏览器不支持通知");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Notification.permission === "granted") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Notification.permission === "denied") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const permission = await Notification.requestPermission();
|
||||
return permission === "granted";
|
||||
}
|
||||
|
||||
static async showNotification(
|
||||
title: string,
|
||||
options?: NotificationOptions
|
||||
): Promise<globalThis.Notification | null> {
|
||||
if (!("Notification" in window)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Notification.permission !== "granted") {
|
||||
const granted = await this.requestPermission();
|
||||
if (!granted) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return new globalThis.Notification(title, {
|
||||
icon: "/favicon.ico",
|
||||
badge: "/favicon.ico",
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
static async showRecordingComplete(): Promise<void> {
|
||||
await this.showNotification("录音完成", {
|
||||
body: "您的录音已成功保存",
|
||||
tag: "recording-complete",
|
||||
});
|
||||
}
|
||||
|
||||
static async showUploadComplete(): Promise<void> {
|
||||
await this.showNotification("上传完成", {
|
||||
body: "录音文件已成功上传到服务器",
|
||||
tag: "upload-complete",
|
||||
});
|
||||
}
|
||||
|
||||
static async showUploadError(): Promise<void> {
|
||||
await this.showNotification("上传失败", {
|
||||
body: "录音文件上传失败,请重试",
|
||||
tag: "upload-error",
|
||||
});
|
||||
}
|
||||
}
|
||||
193
lib/utils/performance.ts
Normal file
193
lib/utils/performance.ts
Normal file
@ -0,0 +1,193 @@
|
||||
import { logger } from "./logger";
|
||||
|
||||
export interface PerformanceMetric {
|
||||
name: string;
|
||||
duration: number;
|
||||
timestamp: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
class PerformanceMonitor {
|
||||
private metrics: PerformanceMetric[] = [];
|
||||
private maxMetrics = 1000;
|
||||
|
||||
/**
|
||||
* 测量函数执行时间
|
||||
*/
|
||||
async measure<T>(
|
||||
name: string,
|
||||
fn: () => Promise<T>,
|
||||
metadata?: Record<string, unknown>
|
||||
): Promise<T> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const result = await fn();
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.recordMetric(name, duration, metadata);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
this.recordMetric(`${name}_error`, duration, {
|
||||
...metadata,
|
||||
error: true,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步测量函数执行时间
|
||||
*/
|
||||
measureSync<T>(
|
||||
name: string,
|
||||
fn: () => T,
|
||||
metadata?: Record<string, unknown>
|
||||
): T {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const result = fn();
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.recordMetric(name, duration, metadata);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
this.recordMetric(`${name}_error`, duration, {
|
||||
...metadata,
|
||||
error: true,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录性能指标
|
||||
*/
|
||||
recordMetric(
|
||||
name: string,
|
||||
duration: number,
|
||||
metadata?: Record<string, unknown>
|
||||
): void {
|
||||
const metric: PerformanceMetric = {
|
||||
name,
|
||||
duration,
|
||||
timestamp: Date.now(),
|
||||
metadata,
|
||||
};
|
||||
|
||||
this.metrics.push(metric);
|
||||
|
||||
// 限制指标数量
|
||||
if (this.metrics.length > this.maxMetrics) {
|
||||
this.metrics = this.metrics.slice(-this.maxMetrics / 2);
|
||||
}
|
||||
|
||||
// 记录慢查询
|
||||
if (duration > 1000) {
|
||||
logger.warn("Slow operation detected", {
|
||||
name,
|
||||
duration,
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取性能统计
|
||||
*/
|
||||
getStats(): {
|
||||
totalMetrics: number;
|
||||
averageDuration: number;
|
||||
slowestOperations: PerformanceMetric[];
|
||||
fastestOperations: PerformanceMetric[];
|
||||
operationCounts: Record<string, number>;
|
||||
} {
|
||||
if (this.metrics.length === 0) {
|
||||
return {
|
||||
totalMetrics: 0,
|
||||
averageDuration: 0,
|
||||
slowestOperations: [],
|
||||
fastestOperations: [],
|
||||
operationCounts: {},
|
||||
};
|
||||
}
|
||||
|
||||
const totalDuration = this.metrics.reduce(
|
||||
(sum, metric) => sum + metric.duration,
|
||||
0
|
||||
);
|
||||
const averageDuration = totalDuration / this.metrics.length;
|
||||
|
||||
// 按名称分组统计
|
||||
const operationCounts: Record<string, number> = {};
|
||||
this.metrics.forEach((metric) => {
|
||||
operationCounts[metric.name] = (operationCounts[metric.name] || 0) + 1;
|
||||
});
|
||||
|
||||
// 获取最慢的操作
|
||||
const slowestOperations = [...this.metrics]
|
||||
.sort((a, b) => b.duration - a.duration)
|
||||
.slice(0, 10);
|
||||
|
||||
// 获取最快的操作
|
||||
const fastestOperations = [...this.metrics]
|
||||
.sort((a, b) => a.duration - b.duration)
|
||||
.slice(0, 10);
|
||||
|
||||
return {
|
||||
totalMetrics: this.metrics.length,
|
||||
averageDuration,
|
||||
slowestOperations,
|
||||
fastestOperations,
|
||||
operationCounts,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理旧指标
|
||||
*/
|
||||
cleanup(maxAge: number = 24 * 60 * 60 * 1000): void {
|
||||
const cutoff = Date.now() - maxAge;
|
||||
this.metrics = this.metrics.filter((metric) => metric.timestamp > cutoff);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出性能报告
|
||||
*/
|
||||
generateReport(): string {
|
||||
const stats = this.getStats();
|
||||
|
||||
return `
|
||||
Performance Report
|
||||
==================
|
||||
|
||||
Total Metrics: ${stats.totalMetrics}
|
||||
Average Duration: ${stats.averageDuration.toFixed(2)}ms
|
||||
|
||||
Slowest Operations:
|
||||
${stats.slowestOperations
|
||||
.map((op) => ` ${op.name}: ${op.duration}ms`)
|
||||
.join("\n")}
|
||||
|
||||
Operation Counts:
|
||||
${Object.entries(stats.operationCounts)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([name, count]) => ` ${name}: ${count}`)
|
||||
.join("\n")}
|
||||
`.trim();
|
||||
}
|
||||
}
|
||||
|
||||
export const performanceMonitor = new PerformanceMonitor();
|
||||
|
||||
// 定期清理旧指标
|
||||
if (typeof window === "undefined") {
|
||||
setInterval(() => {
|
||||
performanceMonitor.cleanup();
|
||||
}, 60 * 60 * 1000); // 每小时清理一次
|
||||
}
|
||||
Reference in New Issue
Block a user