增加漫画展馆功能

This commit is contained in:
mtvpls
2026-04-16 16:01:47 +08:00
parent 4fe9314c57
commit cb225c3291
33 changed files with 3242 additions and 5 deletions
+23
View File
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { db } from '@/lib/db';
export async function getAuthorizedUsername(request: NextRequest): Promise<string | NextResponse> {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (authInfo.username !== process.env.USERNAME) {
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
if (!userInfoV2) {
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
}
if (userInfoV2.banned) {
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
}
}
return authInfo.username;
}
+37
View File
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
import { suwayomiClient } from '@/lib/suwayomi.client';
import { getAuthorizedUsername } from '../_utils';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const username = await getAuthorizedUsername(request);
if (username instanceof NextResponse) return username;
try {
const { searchParams } = new URL(request.url);
const mangaId = searchParams.get('mangaId')?.trim();
const sourceId = searchParams.get('sourceId')?.trim();
if (!mangaId || !sourceId) {
return NextResponse.json({ error: '缺少 mangaId 或 sourceId' }, { status: 400 });
}
const detail = await suwayomiClient.getMangaDetail({
mangaId,
sourceId,
title: searchParams.get('title') || undefined,
cover: searchParams.get('cover') || undefined,
sourceName: searchParams.get('sourceName') || undefined,
description: searchParams.get('description') || undefined,
author: searchParams.get('author') || undefined,
status: searchParams.get('status') || undefined,
});
return NextResponse.json(detail);
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
}
+90
View File
@@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { MangaReadRecord } from '@/lib/manga.types';
import { getAuthorizedUsername } from '../_utils';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const username = await getAuthorizedUsername(request);
if (username instanceof NextResponse) return username;
try {
const key = new URL(request.url).searchParams.get('key');
if (key) {
const [sourceId, mangaId] = key.split('+');
if (!sourceId || !mangaId) {
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
}
const record = await db.getMangaReadRecord(username, sourceId, mangaId);
return NextResponse.json(record, { status: 200 });
}
const records = await db.getAllMangaReadRecords(username);
return NextResponse.json(records, { status: 200 });
} catch (error) {
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
const username = await getAuthorizedUsername(request);
if (username instanceof NextResponse) return username;
try {
const { key, record }: { key: string; record: MangaReadRecord } = await request.json();
if (!key || !record?.chapterId) {
return NextResponse.json({ error: 'Missing key or record' }, { status: 400 });
}
const [sourceId, mangaId] = key.split('+');
if (!sourceId || !mangaId) {
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
}
await db.saveMangaReadRecord(username, sourceId, mangaId, {
...record,
saveTime: record.saveTime ?? Date.now(),
});
if ((db as any).storage.cleanupOldMangaReadRecords) {
(db as any).storage.cleanupOldMangaReadRecords(username).catch((err: Error) => {
console.error('异步清理漫画阅读历史失败:', err);
});
}
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
const username = await getAuthorizedUsername(request);
if (username instanceof NextResponse) return username;
try {
const key = new URL(request.url).searchParams.get('key');
if (key) {
const [sourceId, mangaId] = key.split('+');
if (!sourceId || !mangaId) {
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
}
await db.deleteMangaReadRecord(username, sourceId, mangaId);
} else {
const all = await db.getAllMangaReadRecords(username);
await Promise.all(Object.keys(all).map(async (itemKey) => {
const [sourceId, mangaId] = itemKey.split('+');
if (sourceId && mangaId) {
await db.deleteMangaReadRecord(username, sourceId, mangaId);
}
}));
}
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
+65
View File
@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthorizedUsername } from '../_utils';
import { getSuwayomiConfig } from '@/lib/suwayomi.client';
export const runtime = 'nodejs';
function resolveUpstreamUrl(serverBaseUrl: string, pathOrUrl: string): string {
if (/^https?:\/\//i.test(pathOrUrl)) {
const target = new URL(pathOrUrl);
const base = new URL(serverBaseUrl);
if (target.origin !== base.origin) {
throw new Error('不允许代理非当前 Suwayomi 服务的地址');
}
return target.toString();
}
if (!pathOrUrl.startsWith('/')) {
pathOrUrl = `/${pathOrUrl}`;
}
return `${serverBaseUrl}${pathOrUrl}`;
}
export async function GET(request: NextRequest) {
const username = await getAuthorizedUsername(request);
if (username instanceof NextResponse) return username;
try {
const pathOrUrl = new URL(request.url).searchParams.get('path')?.trim();
if (!pathOrUrl) {
return NextResponse.json({ error: '缺少 path 参数' }, { status: 400 });
}
const config = await getSuwayomiConfig();
const upstreamUrl = resolveUpstreamUrl(config.serverBaseUrl, pathOrUrl);
const response = await fetch(upstreamUrl, {
headers: config.token ? { Authorization: `Bearer ${config.token}` } : undefined,
cache: 'no-store',
});
if (!response.ok) {
return NextResponse.json(
{ error: `Suwayomi 图片请求失败: ${response.status}` },
{ status: response.status }
);
}
const headers = new Headers();
const contentType = response.headers.get('content-type');
const cacheControl = response.headers.get('cache-control');
if (contentType) headers.set('content-type', contentType);
headers.set('cache-control', cacheControl || 'public, max-age=300');
return new NextResponse(response.body, {
status: 200,
headers,
});
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : '图片代理失败' },
{ status: 500 }
);
}
}
+24
View File
@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server';
import { suwayomiClient } from '@/lib/suwayomi.client';
import { getAuthorizedUsername } from '../_utils';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const username = await getAuthorizedUsername(request);
if (username instanceof NextResponse) return username;
try {
const chapterId = new URL(request.url).searchParams.get('chapterId')?.trim();
if (!chapterId) {
return NextResponse.json({ error: '缺少 chapterId' }, { status: 400 });
}
const pages = await suwayomiClient.getChapterPages(chapterId);
return NextResponse.json({ pages });
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
}
+28
View File
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
import { suwayomiClient } from '@/lib/suwayomi.client';
import { getAuthorizedUsername } from '../_utils';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const username = await getAuthorizedUsername(request);
if (username instanceof NextResponse) return username;
try {
const { searchParams } = new URL(request.url);
const q = searchParams.get('q')?.trim();
const sourceId = searchParams.get('sourceId')?.trim() || undefined;
const page = Number(searchParams.get('page') || '1');
if (!q) {
return NextResponse.json({ results: [] });
}
const results = await suwayomiClient.searchManga(q, sourceId, page);
return NextResponse.json({ results });
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
}
+83
View File
@@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { MangaShelfItem } from '@/lib/manga.types';
import { getAuthorizedUsername } from '../_utils';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const username = await getAuthorizedUsername(request);
if (username instanceof NextResponse) return username;
try {
const key = new URL(request.url).searchParams.get('key');
if (key) {
const [sourceId, mangaId] = key.split('+');
if (!sourceId || !mangaId) {
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
}
const item = await db.getMangaShelf(username, sourceId, mangaId);
return NextResponse.json(item, { status: 200 });
}
const records = await db.getAllMangaShelf(username);
return NextResponse.json(records, { status: 200 });
} catch (error) {
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
const username = await getAuthorizedUsername(request);
if (username instanceof NextResponse) return username;
try {
const { key, item }: { key: string; item: MangaShelfItem } = await request.json();
if (!key || !item?.title) {
return NextResponse.json({ error: 'Missing key or item' }, { status: 400 });
}
const [sourceId, mangaId] = key.split('+');
if (!sourceId || !mangaId) {
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
}
await db.saveMangaShelf(username, sourceId, mangaId, {
...item,
saveTime: item.saveTime ?? Date.now(),
});
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
const username = await getAuthorizedUsername(request);
if (username instanceof NextResponse) return username;
try {
const key = new URL(request.url).searchParams.get('key');
if (key) {
const [sourceId, mangaId] = key.split('+');
if (!sourceId || !mangaId) {
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
}
await db.deleteMangaShelf(username, sourceId, mangaId);
} else {
const all = await db.getAllMangaShelf(username);
await Promise.all(Object.keys(all).map(async (itemKey) => {
const [sourceId, mangaId] = itemKey.split('+');
if (sourceId && mangaId) {
await db.deleteMangaShelf(username, sourceId, mangaId);
}
}));
}
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
+20
View File
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server';
import { suwayomiClient } from '@/lib/suwayomi.client';
import { getAuthorizedUsername } from '../_utils';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const username = await getAuthorizedUsername(request);
if (username instanceof NextResponse) return username;
try {
const lang = new URL(request.url).searchParams.get('lang') || process.env.SUWAYOMI_DEFAULT_LANG || 'zh';
const sources = await suwayomiClient.getSources(lang);
return NextResponse.json({ sources });
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
}