mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-02-27 06:54:43 +08:00
feat: init redis storage
This commit is contained in:
153
src/app/api/favorites/route.ts
Normal file
153
src/app/api/favorites/route.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { db, Favorite } from '@/lib/db';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
/**
|
||||
* GET /api/favorites
|
||||
*
|
||||
* 支持两种调用方式:
|
||||
* 1. 不带 query,返回全部收藏列表(Record<string, Favorite>)。
|
||||
* 2. 带 key=source+id,返回单条收藏(Favorite | null)。
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const key = searchParams.get('key');
|
||||
const user = searchParams.get('user');
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Missing user' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 查询单条收藏
|
||||
if (key) {
|
||||
const [source, id] = key.split('+');
|
||||
if (!source || !id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid key format' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const fav = await db.getFavorite(user, source, id);
|
||||
return NextResponse.json(fav, { status: 200 });
|
||||
}
|
||||
|
||||
// 查询全部收藏
|
||||
const favorites = await db.getAllFavorites(user);
|
||||
return NextResponse.json(favorites, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('获取收藏失败', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/favorites
|
||||
* body: { user?: string; key: string; favorite: Favorite }
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const {
|
||||
user,
|
||||
key,
|
||||
favorite,
|
||||
}: { user?: string; key: string; favorite: Favorite } = body;
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Missing user' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!key || !favorite) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing key or favorite' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证必要字段
|
||||
if (!favorite.title || !favorite.source_name) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid favorite data' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const [source, id] = key.split('+');
|
||||
if (!source || !id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid key format' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const favoriteWithoutUserId = {
|
||||
...favorite,
|
||||
save_time: favorite.save_time ?? Date.now(),
|
||||
} as Omit<Favorite, 'user_id'>;
|
||||
|
||||
await db.saveFavorite(user, source, id, favoriteWithoutUserId);
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('保存收藏失败', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/favorites
|
||||
*
|
||||
* 1. 不带 query -> 清空全部收藏
|
||||
* 2. 带 key=source+id -> 删除单条收藏
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const key = searchParams.get('key');
|
||||
const user = searchParams.get('user');
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Missing user' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (key) {
|
||||
// 删除单条
|
||||
const [source, id] = key.split('+');
|
||||
if (!source || !id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid key format' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
await db.deleteFavorite(user, source, id);
|
||||
} else {
|
||||
// 清空全部
|
||||
const all = await db.getAllFavorites(user);
|
||||
await Promise.all(
|
||||
Object.keys(all).map(async (k) => {
|
||||
const [s, i] = k.split('+');
|
||||
if (s && i) await db.deleteFavorite(user, s, i);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('删除收藏失败', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,66 @@
|
||||
/* eslint-disable no-console */
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
// 读取存储类型环境变量,默认 localstorage
|
||||
const STORAGE_TYPE =
|
||||
(process.env.NEXT_PUBLIC_STORAGE_TYPE as string | undefined) ||
|
||||
'localstorage';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const result = process.env.PASSWORD;
|
||||
// 本地 / localStorage 模式——仅校验固定密码
|
||||
if (STORAGE_TYPE === 'localstorage') {
|
||||
const envPassword = process.env.PASSWORD;
|
||||
|
||||
// 未配置 PASSWORD 时直接放行
|
||||
if (!envPassword) return NextResponse.json({ ok: true });
|
||||
|
||||
const { password } = await req.json();
|
||||
if (typeof password !== 'string') {
|
||||
return NextResponse.json({ error: '密码不能为空' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (password !== envPassword) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: '密码错误' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const { password } = await req.json();
|
||||
if (typeof password !== 'string') {
|
||||
// 数据库 / redis 模式——校验用户名并尝试连接数据库
|
||||
const { username, password } = await req.json();
|
||||
|
||||
if (!username || typeof username !== 'string') {
|
||||
return NextResponse.json({ error: '用户名不能为空' }, { status: 400 });
|
||||
}
|
||||
if (!password || typeof password !== 'string') {
|
||||
return NextResponse.json({ error: '密码不能为空' }, { status: 400 });
|
||||
}
|
||||
|
||||
const matched = password === result;
|
||||
// 校验用户密码
|
||||
try {
|
||||
const pass = await db.verifyUser(username, password);
|
||||
if (!pass) {
|
||||
return NextResponse.json(
|
||||
{ error: '用户名或密码错误' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!matched) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: '密码错误' },
|
||||
{ status: 401 }
|
||||
);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('数据库验证失败', err);
|
||||
return NextResponse.json({ error: '数据库错误' }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('登录接口异常', error);
|
||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,15 @@ import { PlayRecord } from '@/lib/db';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const records = await db.getAllPlayRecords();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const user = searchParams.get('user');
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Missing user' }, { status: 400 });
|
||||
}
|
||||
|
||||
const records = await db.getAllPlayRecords(user);
|
||||
return NextResponse.json(records, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('获取播放记录失败', err);
|
||||
@@ -23,7 +29,15 @@ export async function GET() {
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { key, record }: { key: string; record: PlayRecord } = body;
|
||||
const {
|
||||
user,
|
||||
key,
|
||||
record,
|
||||
}: { user?: string; key: string; record: PlayRecord } = body;
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Missing user' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!key || !record) {
|
||||
return NextResponse.json(
|
||||
@@ -61,7 +75,7 @@ export async function POST(request: NextRequest) {
|
||||
save_time: record.save_time,
|
||||
};
|
||||
|
||||
await db.savePlayRecord(source, id, recordWithoutUserId);
|
||||
await db.savePlayRecord(user, source, id, recordWithoutUserId);
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (err) {
|
||||
@@ -72,3 +86,46 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const key = searchParams.get('key');
|
||||
const user = searchParams.get('user');
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Missing user' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (key) {
|
||||
// 如果提供了 key,删除单条播放记录
|
||||
const [source, id] = key.split('+');
|
||||
if (!source || !id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid key format' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await db.deletePlayRecord(user, source, id);
|
||||
} else {
|
||||
// 未提供 key,则清空全部播放记录
|
||||
// 目前 DbManager 没有对应方法,这里直接遍历删除
|
||||
const all = await db.getAllPlayRecords(user);
|
||||
await Promise.all(
|
||||
Object.keys(all).map(async (k) => {
|
||||
const [s, i] = k.split('+');
|
||||
if (s && i) await db.deletePlayRecord(user, s, i);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('删除播放记录失败', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
79
src/app/api/searchhistory/route.ts
Normal file
79
src/app/api/searchhistory/route.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
// 最大保存条数(与客户端保持一致)
|
||||
const HISTORY_LIMIT = 20;
|
||||
|
||||
/**
|
||||
* GET /api/searchhistory
|
||||
* 返回 string[]
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const history = await db.getSearchHistory();
|
||||
return NextResponse.json(history, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('获取搜索历史失败', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/searchhistory
|
||||
* body: { keyword: string }
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const keyword: string = body.keyword?.trim();
|
||||
if (!keyword) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Keyword is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await db.addSearchHistory(keyword);
|
||||
|
||||
// 再次获取最新列表,确保客户端与服务端同步
|
||||
const history = await db.getSearchHistory();
|
||||
return NextResponse.json(history.slice(0, HISTORY_LIMIT), { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('添加搜索历史失败', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/searchhistory
|
||||
*
|
||||
* 1. 不带 keyword -> 清空全部搜索历史
|
||||
* 2. 带 keyword=<kw> -> 删除单条关键字
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const kw = searchParams.get('keyword')?.trim();
|
||||
|
||||
await db.deleteSearchHistory(kw || undefined);
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('删除搜索历史失败', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -33,8 +33,25 @@ export default function RootLayout({
|
||||
process.env.ANNOUNCEMENT ||
|
||||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。';
|
||||
|
||||
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取
|
||||
const runtimeConfig = {
|
||||
STORAGE_TYPE:
|
||||
process.env.STORAGE_TYPE ||
|
||||
process.env.NEXT_PUBLIC_STORAGE_TYPE ||
|
||||
'localstorage',
|
||||
};
|
||||
|
||||
return (
|
||||
<html lang='zh-CN' suppressHydrationWarning>
|
||||
<head>
|
||||
{/* 将配置序列化后直接写入脚本,浏览器端可通过 window.RUNTIME_CONFIG 获取 */}
|
||||
{/* eslint-disable-next-line @next/next/no-sync-scripts */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `window.RUNTIME_CONFIG = ${JSON.stringify(runtimeConfig)};`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={`${inter.className} min-h-screen bg-white text-gray-900 dark:bg-black dark:text-gray-200`}
|
||||
>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
@@ -10,28 +12,41 @@ function LoginPageClient() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [password, setPassword] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { siteName } = useSite();
|
||||
|
||||
// 当 STORAGE_TYPE 不为空且不为 localstorage 时,要求输入用户名
|
||||
const shouldAskUsername =
|
||||
typeof window !== 'undefined' &&
|
||||
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE &&
|
||||
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE !== 'localstorage';
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!password) return;
|
||||
if (!password || (shouldAskUsername && !username)) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
body: JSON.stringify({
|
||||
password,
|
||||
...(shouldAskUsername ? { username } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
// 保存密码以供后续请求使用
|
||||
// 保存密码和用户名以供后续请求使用
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('password', password);
|
||||
if (shouldAskUsername) {
|
||||
localStorage.setItem('username', username);
|
||||
}
|
||||
}
|
||||
|
||||
const redirect = searchParams.get('redirect') || '/';
|
||||
@@ -57,6 +72,23 @@ function LoginPageClient() {
|
||||
{siteName}
|
||||
</h1>
|
||||
<form onSubmit={handleSubmit} className='space-y-8'>
|
||||
{shouldAskUsername && (
|
||||
<div>
|
||||
<label htmlFor='username' className='sr-only'>
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
id='username'
|
||||
type='text'
|
||||
autoComplete='username'
|
||||
className='block w-full rounded-lg border-0 py-3 px-4 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-white/60 dark:ring-white/20 placeholder:text-gray-500 dark:placeholder:text-gray-400 focus:ring-2 focus:ring-green-500 focus:outline-none sm:text-base bg-white/60 dark:bg-zinc-800/60 backdrop-blur'
|
||||
placeholder='输入用户名'
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor='password' className='sr-only'>
|
||||
密码
|
||||
@@ -78,7 +110,7 @@ function LoginPageClient() {
|
||||
|
||||
<button
|
||||
type='submit'
|
||||
disabled={!password || loading}
|
||||
disabled={!password || loading || (shouldAskUsername && !username)}
|
||||
className='inline-flex w-full justify-center rounded-lg bg-green-600 py-3 text-base font-semibold text-white shadow-lg transition-all duration-200 hover:from-green-600 hover:to-blue-600 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
|
||||
Reference in New Issue
Block a user