feat: init redis storage

This commit is contained in:
shinya
2025-07-02 13:13:22 +08:00
parent 87b65fc7b4
commit 7b3afce1a1
11 changed files with 815 additions and 116 deletions

View 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 }
);
}
}

View File

@@ -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 });
}
}

View File

@@ -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 }
);
}
}

View 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 }
);
}
}

View File

@@ -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`}
>

View File

@@ -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 ? '登录中...' : '登录'}