Refactor code structure for improved readability and maintainability

This commit is contained in:
2026-03-23 11:12:56 +07:00
parent e345d9ccce
commit ffd177718f
39 changed files with 5258 additions and 520 deletions
@@ -0,0 +1,174 @@
import { NextResponse } from "next/server"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
type EnrichedItem = {
id: string
title: string
originalTitle: string
authorName: string
originalAuthorName: string
description: string
coverUrl?: string
status: "Đang ra" | "Hoàn thành" | "Tạm ngưng"
genresSuggested: string[]
firstPublishYear?: number
confidence: number
source: string
sourceUrl?: string
}
function normalizeText(value: string): string {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9\s]/g, " ")
.replace(/\s+/g, " ")
.trim()
}
function trimText(value: string, maxLength: number): string {
if (value.length <= maxLength) return value
return `${value.slice(0, maxLength - 1).trimEnd()}...`
}
function buildBatchPrompt(novels: any[]) {
const itemsText = novels.map(n => {
const shortDesc = trimText(n.description || "", 1500)
return `[ID: ${n.id}]\nTên: ${n.title}\nTên gốc: ${n.originalTitle || ""}\nTác giả: ${n.authorName}\nTác giả gốc: ${n.originalAuthorName || ""}\nThể loại: ${(n.genres || []).map((g: any) => g.genre.name).join(", ")}\nMô tả: ${shortDesc}`
}).join("\n\n---\n\n")
return [
"Bạn là biên tập viên truyện dịch tiếng Trung.",
"Nhiệm vụ: bổ sung thông tin bị thiếu (Tên gốc, Tác giả gốc, Mô tả, Thể loại) cho danh sách tác phẩm sau bằng cách tìm kiếm trên Qidian, JJWXC, v.v...",
"Trường 'description': Nếu mô tả gốc đã chi tiết, chỉ cần sửa chính tả. Nếu trống/ngắn, hãy viết mới 1 đoạn giới thiệu dài chi tiết bám sát nội dung gốc (KHÔNG tóm tắt kiểu chung chung).",
"Trạng thái (status) phải luôn là: Đang ra, Hoàn thành, hoặc Tạm ngưng.",
"Kết quả BẮT BUỘC là 1 JSON Object chứa mảng 'results'. Mỗi item trong mảng phải có key 'id' khớp với báo cáo.",
`Schema: {"results":[{"id":"","title":"","originalTitle":"","authorName":"","originalAuthorName":"","description":"","coverUrl":"","status":"Đang ra|Hoàn thành|Tạm ngưng","genresSuggested":[],"firstPublishYear":2020,"confidence":80,"source":"","sourceUrl":""}]}`,
"Dưới đây là danh sách truyện cần xử lý:\n",
itemsText
].join("\n")
}
function extractJsonCandidate(text: string): string {
const trimmed = text.trim()
if (!trimmed) return ""
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i)
if (fenced?.[1]) return fenced[1].trim()
const firstBrace = trimmed.indexOf("{")
const lastBrace = trimmed.lastIndexOf("}")
if (firstBrace >= 0 && lastBrace > firstBrace) {
return trimmed.slice(firstBrace, lastBrace + 1)
}
return trimmed
}
export async function POST(req: Request) {
const session = await getServerSession(authOptions)
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
return NextResponse.json({ error: "Không có quyền truy cập" }, { status: 401 })
}
const { novelIds } = await req.json()
if (!Array.isArray(novelIds) || novelIds.length === 0 || novelIds.length > 20) {
return NextResponse.json({ error: "novelIds không hợp lệ hoặc quá lớn (tối đa 20)" }, { status: 400 })
}
const accessWhere = session.user.role === "ADMIN"
? { id: { in: novelIds } }
: {
id: { in: novelIds },
OR: [
{ uploaderId: session.user.id },
{ uploaderId: null },
],
}
const novels = await prisma.novel.findMany({
where: accessWhere,
select: {
id: true,
title: true,
originalTitle: true,
authorName: true,
originalAuthorName: true,
description: true,
coverUrl: true,
status: true,
genres: { select: { genre: { select: { name: true } } } },
}
})
if (novels.length === 0) {
return NextResponse.json({ error: "Không tìm thấy truyện nào hợp lệ" }, { status: 404 })
}
const apiKey = process.env.DEEKSEEK_KEY?.trim() || process.env.DEEPSEEK_KEY?.trim()
const model = process.env.DEEPSEEK_MODEL?.trim() || "deepseek-chat"
if (!apiKey) {
return NextResponse.json({ error: "Thiếu DEEPSEEK_KEY" }, { status: 400 })
}
const prompt = buildBatchPrompt(novels)
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 90000) // 90 seconds for batch
const startedAt = Date.now()
try {
const res = await fetch("https://api.deepseek.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
temperature: 0.2,
max_tokens: 2500,
response_format: { type: "json_object" },
messages: [
{ role: "system", content: "You are a helpful assistant. You must output only valid standard JSON object following the prompt schema, without markdown formatting." },
{ role: "user", content: prompt },
],
}),
signal: controller.signal,
})
clearTimeout(timeout)
if (!res.ok) {
const errorText = await res.text().catch(() => "")
throw new Error(`HTTP ${res.status}: ${errorText.slice(0, 200)}`)
}
const data = await res.json()
const text = data.choices?.[0]?.message?.content?.trim() || ""
const jsonText = extractJsonCandidate(text)
let parsed: any = null
try {
parsed = JSON.parse(jsonText)
} catch {
throw new Error("Phản hồi JSON bị hỏng")
}
const results = Array.isArray(parsed?.results) ? parsed.results : []
return NextResponse.json({
success: true,
latencyMs: Date.now() - startedAt,
model,
count: results.length,
results,
sourceNovels: novels,
})
} catch (error: any) {
clearTimeout(timeout)
return NextResponse.json({ error: `DeepSeek Error: ${error.message}` }, { status: 500 })
}
}
+689
View File
@@ -0,0 +1,689 @@
import { NextResponse } from "next/server"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
type EnrichedItem = {
title: string
originalTitle: string
authorName: string
originalAuthorName: string
description: string
coverUrl?: string
status: "Đang ra" | "Hoàn thành" | "Tạm ngưng"
genresSuggested: string[]
firstPublishYear?: number
confidence: number
source: string
sourceUrl?: string
}
type AttemptStatus = {
provider: "google" | "openrouter" | "deepseek"
model: string
status: "success" | "failed" | "skipped"
message?: string
latencyMs?: number
}
type ProviderResult = {
provider: "google" | "openrouter" | "deepseek"
model: string
results: EnrichedItem[]
}
type GeminiResponse = {
candidates?: Array<{
content?: {
parts?: Array<{ text?: string }>
}
}>
}
type ChatResponse = {
choices?: Array<{
message?: {
content?: string
}
}>
}
type OpenRouterModelsResponse = {
data?: Array<{
id?: string
}>
}
const OPENROUTER_PAUSED = (process.env.OPENROUTER_PAUSED ?? "true").toLowerCase() !== "false"
function hasMeaningfulDescription(value: string): boolean {
const normalized = normalizeText(value)
if (!normalized || normalized.length < 40) return false
const placeholders = [
"chua co gioi thieu",
"khong co gioi thieu",
"dang cap nhat",
"dang update",
"updating",
"no description",
"khong ro",
"chua ro",
]
return !placeholders.some((item) => normalized.includes(item))
}
function buildGeneratedDescription(
title: string,
authorName: string,
genres: string[],
status: "Đang ra" | "Hoàn thành" | "Tạm ngưng"
): string {
const genreText = genres.length > 0 ? genres.slice(0, 3).join(", ") : "tiểu thuyết mạng"
const statusText = status === "Đang ra" ? "đang ra" : status === "Hoàn thành" ? "đã hoàn thành" : "tạm ngưng"
return trimText(
`${title} là một tác phẩm ${genreText} của ${authorName}. Câu chuyện được hệ thống tóm lược lại từ những thông tin đã tìm thấy trên web và hiện ở trạng thái ${statusText}. Đây là đoạn mô tả thay thế được tạo tự động khi mô hình chưa trả về phần giới thiệu đủ chi tiết, giúp biên tập viên có sẵn nội dung để rà soát và chỉnh sửa tiếp.`,
1200
)
}
function normalizeText(value: string): string {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9\s]/g, " ")
.replace(/\s+/g, " ")
.trim()
}
function trimText(value: string, maxLength: number): string {
if (value.length <= maxLength) return value
return `${value.slice(0, maxLength - 1).trimEnd()}...`
}
function inferStatus(subjects: string[], description: string): "Đang ra" | "Hoàn thành" | "Tạm ngưng" {
const haystack = normalizeText(`${subjects.join(" ")} ${description}`)
if (haystack.includes("completed") || haystack.includes("finished") || haystack.includes("full text")) {
return "Hoàn thành"
}
if (haystack.includes("hiatus") || haystack.includes("on hold") || haystack.includes("paused")) {
return "Tạm ngưng"
}
return "Đang ra"
}
function clampNumber(value: unknown, min: number, max: number, fallback: number): number {
const num = typeof value === "number" ? value : Number(value)
if (!Number.isFinite(num)) return fallback
return Math.max(min, Math.min(max, num))
}
function toOptionalUrl(value: unknown): string | undefined {
if (typeof value !== "string") return undefined
const trimmed = value.trim()
if (!trimmed) return undefined
if (!/^https?:\/\//i.test(trimmed)) return undefined
return trimmed
}
function coerceStatus(value: unknown, genres: string[], description: string): "Đang ra" | "Hoàn thành" | "Tạm ngưng" {
if (typeof value === "string") {
const normalized = normalizeText(value)
if (normalized.includes("hoan thanh") || normalized.includes("completed") || normalized.includes("finished")) {
return "Hoàn thành"
}
if (normalized.includes("tam") || normalized.includes("ngung") || normalized.includes("hiatus") || normalized.includes("paused")) {
return "Tạm ngưng"
}
if (normalized.includes("dang") || normalized.includes("ongoing")) {
return "Đang ra"
}
}
return inferStatus(genres, description)
}
function extractJsonCandidate(text: string): string {
const trimmed = text.trim()
if (!trimmed) return ""
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i)
if (fenced?.[1]) return fenced[1].trim()
const firstBrace = trimmed.indexOf("{")
const lastBrace = trimmed.lastIndexOf("}")
if (firstBrace >= 0 && lastBrace > firstBrace) {
return trimmed.slice(firstBrace, lastBrace + 1)
}
return trimmed
}
function toItem(raw: any, novelTitle: string): EnrichedItem | null {
const title = typeof raw?.title === "string" ? raw.title.trim() : ""
if (!title) return null
const originalTitle = typeof raw?.originalTitle === "string" && raw.originalTitle.trim()
? raw.originalTitle.trim()
: title
const authorName = typeof raw?.authorName === "string" && raw.authorName.trim()
? raw.authorName.trim()
: "Chưa rõ"
const originalAuthorName = typeof raw?.originalAuthorName === "string" && raw.originalAuthorName.trim()
? raw.originalAuthorName.trim()
: authorName
const genres = Array.isArray(raw?.genresSuggested)
? raw.genresSuggested
.map((g: unknown) => (typeof g === "string" ? g.trim() : ""))
.filter(Boolean)
.slice(0, 10)
: []
const status = coerceStatus(raw?.status, genres, typeof raw?.description === "string" ? raw.description.trim() : "")
const descriptionInput = typeof raw?.description === "string" ? raw.description.trim() : ""
const description = trimText(
hasMeaningfulDescription(descriptionInput)
? descriptionInput
: buildGeneratedDescription(title || novelTitle, authorName, genres, status),
1200
)
const year = Number(raw?.firstPublishYear)
const firstPublishYear = Number.isFinite(year) && year >= 1000 && year <= 2100 ? year : undefined
return {
title,
originalTitle,
authorName,
originalAuthorName,
description,
coverUrl: toOptionalUrl(raw?.coverUrl),
status,
genresSuggested: genres,
firstPublishYear,
confidence: clampNumber(raw?.confidence, 1, 99, 65),
source: typeof raw?.source === "string" && raw.source.trim() ? raw.source.trim() : "LLM Web Search",
sourceUrl: toOptionalUrl(raw?.sourceUrl),
}
}
function parseResultsFromText(text: string, novelTitle: string): EnrichedItem[] {
const jsonText = extractJsonCandidate(text)
if (!jsonText) return []
let parsed: any = null
try {
parsed = JSON.parse(jsonText)
} catch {
return []
}
const rows = Array.isArray(parsed?.results) ? parsed.results : []
return rows
.map((row: any) => toItem(row, novelTitle))
.filter((row: EnrichedItem | null): row is EnrichedItem => Boolean(row))
.slice(0, 6)
}
function buildFallbackResult(novelTitle: string): EnrichedItem {
return {
title: novelTitle,
originalTitle: novelTitle,
authorName: "Chưa rõ",
originalAuthorName: "Chưa rõ",
description: buildGeneratedDescription(novelTitle, "Chưa rõ", [], "Đang ra"),
coverUrl: undefined,
status: "Đang ra",
genresSuggested: [],
firstPublishYear: undefined,
confidence: 30,
source: "Fallback",
sourceUrl: undefined,
}
}
function buildPrompt(novel: {
title: string
originalTitle?: string | null
authorName: string
originalAuthorName?: string | null
description: string
genres: string[]
}) {
const shortDescription = trimText(novel.description || "", 1500)
return [
"Bạn là trợ lý biên tập truyện dịch từ truyện mạng Trung Quốc.",
"Hãy tìm thông tin trên web, ưu tiên các nguồn như Qidian, JJWXC, NovelUpdates, wiki fan và nhà xuất bản.",
"Trả về JSON thuần, không markdown, không giải thích thêm.",
"Bắt buộc viết các trường title, authorName, description, genresSuggested bằng tiếng Việt có dấu nếu đó là tên hoặc khái niệm đã có bản Việt hóa phổ biến.",
"Riêng originalTitle và originalAuthorName hãy giữ theo nguyên tác nếu tìm được.",
"Trường description là bắt buộc. Nếu 'Mô tả hiện tại' (dưới đây) đã dài và chi tiết, hãy giữ nguyên ý chính gốc và chỉ chỉnh sửa văn phong, bố cục cho mượt mà hoặc hấp dẫn hơn. TUYỆT ĐỐI KHÔNG tóm tắt ngắn đi nếu bản gốc đang đầy đủ.",
"Nếu 'Mô tả hiện tại' chống không hoặc quá sơ sài, hãy tìm kiếm nội dung về cốt truyện gốc trên mạng và viết một bài giới thiệu dài, chi tiết, bám sát các sự kiện thực tế trong truyện. KHÔNG viết chung chung kiểu 'đầy chông gai và thử thách'.",
"Trạng thái (status) phải luôn là 1 trong 3 giá trị: Đang ra, Hoàn thành, Tạm ngưng. Mảng genresSuggested chứa tối đa 10 thể loại phù hợp.",
`Schema: {"results":[{"title":"","originalTitle":"","authorName":"","originalAuthorName":"","description":"","coverUrl":"","status":"Đang ra|Hoàn thành|Tạm ngưng","genresSuggested":[],"firstPublishYear":2020,"confidence":80,"source":"","sourceUrl":""}]}`,
"Tối đa 3 kết quả, ưu tiên kết quả gần nhất với truyện hiện tại.",
`Tên hiện tại: ${novel.title}`,
`Tên gốc hiện tại: ${novel.originalTitle || ""}`,
`Tác giả hiện tại: ${novel.authorName}`,
`Tác giả gốc hiện tại: ${novel.originalAuthorName || ""}`,
`Thể loại hiện tại: ${novel.genres.join(", ")}`,
`Mô tả hiện tại: ${shortDescription}`,
].join(" ")
}
async function fetchJsonWithTimeout<T>(url: string, init: RequestInit, timeoutMs = 25000): Promise<T> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), timeoutMs)
try {
const res = await fetch(url, { ...init, signal: controller.signal, cache: "no-store" })
if (!res.ok) {
const text = await res.text().catch(() => "")
throw new Error(`HTTP ${res.status}: ${text.slice(0, 220)}`)
}
return (await res.json()) as T
} finally {
clearTimeout(timeout)
}
}
function summarizeError(error: unknown): string {
if (!(error instanceof Error)) return "Lỗi không xác định"
return error.message.slice(0, 220)
}
async function tryGoogle(prompt: string, novelTitle: string, attempts: AttemptStatus[]): Promise<ProviderResult | null> {
const apiKey = process.env.GOOGLE_AI_KEY?.trim()
const model = process.env.GOOGLE_AI_MODEL?.trim() || "gemini-2.0-flash"
if (!apiKey) {
attempts.push({ provider: "google", model, status: "skipped", message: "Thiếu GOOGLE_AI_KEY" })
return null
}
const startedAt = Date.now()
try {
const data = await fetchJsonWithTimeout<GeminiResponse>(
`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(apiKey)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [{ role: "user", parts: [{ text: prompt }] }],
tools: [{ googleSearch: {} }],
generationConfig: {
temperature: 0.2,
maxOutputTokens: 1400,
},
}),
},
22000
)
const text = (data.candidates || [])
.flatMap((candidate) => candidate.content?.parts || [])
.map((part) => part.text || "")
.join("\n")
.trim()
const results = parseResultsFromText(text, novelTitle)
if (results.length === 0) {
console.log("Google parsing failed. Raw text:", text)
throw new Error("Không có kết quả JSON hợp lệ")
}
attempts.push({
provider: "google",
model,
status: "success",
message: `${results.length} kết quả`,
latencyMs: Date.now() - startedAt,
})
return { provider: "google", model, results }
} catch (error) {
console.log("Google fetch error:", error)
attempts.push({
provider: "google",
model,
status: "failed",
message: summarizeError(error),
latencyMs: Date.now() - startedAt,
})
return null
}
}
async function resolveOpenRouterFreeModels(apiKey: string): Promise<string[]> {
const envModels = (process.env.OPENROUTER_FREE_MODELS || "")
.split(",")
.map((s) => s.trim())
.filter((s) => s.endsWith(":free"))
const dynamicModels: string[] = []
try {
const data = await fetchJsonWithTimeout<OpenRouterModelsResponse>(
"https://openrouter.ai/api/v1/models",
{
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
},
},
12000
)
for (const row of data.data || []) {
const id = (row.id || "").trim()
if (!id || !id.endsWith(":free")) continue
dynamicModels.push(id)
}
} catch {
// Ignore model-list errors and keep env list fallback.
}
const merged = Array.from(new Set([...envModels, ...dynamicModels]))
const preferredOrder = [
"google/gemma-2-9b-it:free",
"meta-llama/llama-3.1-8b-instruct:free",
"qwen/qwen2.5-7b-instruct:free",
]
merged.sort((a, b) => {
const ai = preferredOrder.findIndex((item) => item === a)
const bi = preferredOrder.findIndex((item) => item === b)
const ar = ai === -1 ? 999 : ai
const br = bi === -1 ? 999 : bi
if (ar !== br) return ar - br
return a.localeCompare(b)
})
return merged.slice(0, 10)
}
async function tryOpenRouter(prompt: string, novelTitle: string, attempts: AttemptStatus[]): Promise<ProviderResult | null> {
const apiKey = process.env.OPENROUTER_KEY?.trim()
if (!apiKey) {
attempts.push({ provider: "openrouter", model: "free-chain", status: "skipped", message: "Thiếu OPENROUTER_KEY" })
return null
}
const freeModels = await resolveOpenRouterFreeModels(apiKey)
if (freeModels.length === 0) {
attempts.push({ provider: "openrouter", model: "free-chain", status: "skipped", message: "Không có model free khả dụng" })
return null
}
for (const model of freeModels) {
const startedAt = Date.now()
try {
const data = await fetchJsonWithTimeout<ChatResponse>(
"https://openrouter.ai/api/v1/chat/completions",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
"HTTP-Referer": "http://localhost:3000",
"X-Title": "reader-mod-ai-tool",
},
body: JSON.stringify({
model,
temperature: 0.2,
max_tokens: 1400,
messages: [
{ role: "system", content: "Return only valid JSON." },
{ role: "user", content: prompt },
],
}),
},
22000
)
const text = data.choices?.[0]?.message?.content?.trim() || ""
const results = parseResultsFromText(text, novelTitle)
if (results.length === 0) {
console.log("OpenRouter parsing failed. Raw text:", text)
throw new Error("Không có kết quả JSON hợp lệ")
}
attempts.push({
provider: "openrouter",
model,
status: "success",
message: `${results.length} kết quả`,
latencyMs: Date.now() - startedAt,
})
return { provider: "openrouter", model, results }
} catch (error) {
console.log("OpenRouter fetch error:", error)
attempts.push({
provider: "openrouter",
model,
status: "failed",
message: summarizeError(error),
latencyMs: Date.now() - startedAt,
})
}
}
return null
}
async function tryDeepSeek(prompt: string, novelTitle: string, attempts: AttemptStatus[]): Promise<ProviderResult | null> {
const apiKey = process.env.DEEKSEEK_KEY?.trim() || process.env.DEEPSEEK_KEY?.trim()
const model = process.env.DEEPSEEK_MODEL?.trim() || "deepseek-chat"
if (!apiKey) {
attempts.push({ provider: "deepseek", model, status: "skipped", message: "Thiếu DEEKSEEK_KEY/DEEPSEEK_KEY" })
return null
}
const profiles = [
{ maxTokens: 1300, timeoutMs: 60000 },
{ maxTokens: 900, timeoutMs: 45000 },
]
for (const profile of profiles) {
const startedAt = Date.now()
try {
const data = await fetchJsonWithTimeout<ChatResponse>(
"https://api.deepseek.com/v1/chat/completions",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
temperature: 0.2,
max_tokens: profile.maxTokens,
response_format: { type: "json_object" },
messages: [
{ role: "system", content: "You are a helpful assistant. You must output only valid standard JSON object following the prompt schema, without markdown formatting." },
{ role: "user", content: prompt },
],
}),
},
profile.timeoutMs
)
const text = data.choices?.[0]?.message?.content?.trim() || ""
const results = parseResultsFromText(text, novelTitle)
if (results.length === 0) {
console.log("DeepSeek parsing failed. Raw text:", text)
throw new Error("Không có kết quả JSON hợp lệ")
}
attempts.push({
provider: "deepseek",
model,
status: "success",
message: `${results.length} kết quả`,
latencyMs: Date.now() - startedAt,
})
return { provider: "deepseek", model, results }
} catch (error) {
console.log("DeepSeek fetch error:", error)
attempts.push({
provider: "deepseek",
model,
status: "failed",
message: summarizeError(error),
latencyMs: Date.now() - startedAt,
})
}
}
return null
}
export async function GET(req: Request) {
const session = await getServerSession(authOptions)
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
return NextResponse.json({ error: "Không có quyền truy cập" }, { status: 401 })
}
const { searchParams } = new URL(req.url)
const novelId = searchParams.get("novelId")?.trim() || ""
if (!novelId) {
return NextResponse.json({ error: "Thiếu novelId" }, { status: 400 })
}
const accessWhere = session.user.role === "ADMIN"
? { id: novelId }
: {
id: novelId,
OR: [
{ uploaderId: session.user.id },
{ uploaderId: null },
],
}
const novel = await prisma.novel.findFirst({
where: accessWhere,
select: {
id: true,
title: true,
originalTitle: true,
slug: true,
authorName: true,
originalAuthorName: true,
description: true,
coverUrl: true,
status: true,
updatedAt: true,
genres: {
select: {
genre: {
select: {
name: true,
},
},
},
},
},
})
if (!novel) {
return NextResponse.json({ error: "Không tìm thấy truyện hoặc bạn không có quyền truy cập" }, { status: 404 })
}
const attempts: AttemptStatus[] = []
const currentNovel = {
id: novel.id,
title: novel.title,
originalTitle: novel.originalTitle,
slug: novel.slug,
authorName: novel.authorName,
originalAuthorName: novel.originalAuthorName,
description: novel.description,
coverUrl: novel.coverUrl,
status: novel.status,
updatedAt: novel.updatedAt,
genres: (novel.genres || []).map((row) => row.genre.name),
}
const prompt = buildPrompt(currentNovel)
// Tạm thời bỏ qua Google Gemini theo yêu cầu của user
attempts.push({
provider: "google",
model: "gemini-2.0-flash",
status: "skipped",
message: "Tạm bỏ qua theo yêu cầu người dùng.",
})
/*
const google = await tryGoogle(prompt, currentNovel.title, attempts)
if (google) {
return NextResponse.json({
novel: currentNovel,
provider: google.provider,
model: google.model,
attempts,
count: google.results.length,
results: google.results,
})
}
*/
const deepseek = await tryDeepSeek(prompt, currentNovel.title, attempts)
if (deepseek) {
return NextResponse.json({
novel: currentNovel,
provider: deepseek.provider,
model: deepseek.model,
attempts,
count: deepseek.results.length,
results: deepseek.results,
})
}
if (OPENROUTER_PAUSED) {
attempts.push({
provider: "openrouter",
model: "free-chain",
status: "skipped",
message: "Tạm dừng OpenRouter theo cấu hình",
})
} else {
const openrouter = await tryOpenRouter(prompt, currentNovel.title, attempts)
if (openrouter) {
return NextResponse.json({
novel: currentNovel,
provider: openrouter.provider,
model: openrouter.model,
attempts,
count: openrouter.results.length,
results: openrouter.results,
})
}
}
return NextResponse.json({
novel: currentNovel,
provider: "fallback",
model: "none",
attempts,
count: 1,
results: [buildFallbackResult(currentNovel.title)],
warning: "Google AI và DeepSeek đều thất bại",
})
}
@@ -0,0 +1,79 @@
import { NextResponse } from "next/server"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
export async function GET(req: Request) {
const session = await getServerSession(authOptions)
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
return NextResponse.json({ error: "Không có quyền truy cập" }, { status: 401 })
}
const { searchParams } = new URL(req.url)
const q = (searchParams.get("q") || "").trim()
const page = parseInt(searchParams.get("page") || "1", 10)
const take = 24
const skip = (Math.max(1, page) - 1) * take
const whereScope = session.user.role === "ADMIN"
? {}
: {
OR: [
{ uploaderId: session.user.id },
{ uploaderId: null },
],
}
const isMissingFilter = searchParams.get("missing") === "true"
const baseWhereOptions: any[] = [whereScope]
if (q.length > 0) {
baseWhereOptions.push({
OR: [
{ title: { contains: q, mode: "insensitive" } },
{ originalTitle: { contains: q, mode: "insensitive" } },
{ authorName: { contains: q, mode: "insensitive" } },
{ originalAuthorName: { contains: q, mode: "insensitive" } },
{ slug: { contains: q, mode: "insensitive" } },
],
})
} else if (!isMissingFilter) {
return NextResponse.json({ novels: [], hasMore: false })
}
if (isMissingFilter) {
baseWhereOptions.push({
OR: [
{ authorName: { in: ["", "Chưa rõ"] } },
{ description: "" },
{ description: { in: ["Chưa có giới thiệu", "Không có giới thiệu", "chưa có giới thiệu", "không có", "chưa rõ", "đang cập nhật"] } }
],
})
}
const rows = await prisma.novel.findMany({
where: {
AND: baseWhereOptions,
},
select: {
id: true,
title: true,
originalTitle: true,
slug: true,
authorName: true,
originalAuthorName: true,
description: true,
coverUrl: true,
status: true,
updatedAt: true,
},
orderBy: [{ updatedAt: "desc" }],
take: take + 1, // Fetch one extra to know if there's a next page
skip,
})
const hasMore = rows.length > take
const returnRows = hasMore ? rows.slice(0, take) : rows
return NextResponse.json({ novels: returnRows, hasMore })
}
@@ -0,0 +1,59 @@
import { NextResponse } from "next/server"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
export async function GET() {
const session = await getServerSession(authOptions)
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
return NextResponse.json({ error: "Không có quyền truy cập" }, { status: 401 })
}
const apiKey = process.env.DEEKSEEK_KEY?.trim() || process.env.DEEPSEEK_KEY?.trim()
const model = process.env.DEEPSEEK_MODEL?.trim() || "deepseek-chat"
if (!apiKey) {
return NextResponse.json({ error: "Chưa cấu hình API Key cho DeepSeek (DEEKSEEK_KEY / DEEPSEEK_KEY)" }, { status: 400 })
}
const startedAt = Date.now()
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 15000)
const res = await fetch("https://api.deepseek.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
temperature: 0.1,
max_tokens: 10,
messages: [{ role: "user", content: "Ping! Please reply with 'pong'." }],
}),
signal: controller.signal,
})
clearTimeout(timeout)
if (!res.ok) {
const text = await res.text().catch(() => "")
throw new Error(`HTTP ${res.status}: ${text.slice(0, 100)}`)
}
const data = await res.json()
const text = data.choices?.[0]?.message?.content?.trim() || ""
return NextResponse.json({
success: true,
message: `DeepSeek phản hồi thành công: "${text}"`,
latencyMs: Date.now() - startedAt,
model
})
} catch (error: any) {
return NextResponse.json({
error: `Kết nối DeepSeek thất bại: ${error.message}`
}, { status: 500 })
}
}
+63
View File
@@ -0,0 +1,63 @@
import { NextResponse } from "next/server"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import connectToMongoDB from "@/lib/mongoose"
import { Chapter } from "@/lib/models/chapter"
export async function POST(req: Request) {
const session = await getServerSession(authOptions)
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const data = await req.json()
const { novelId, fromNumber, toNumber } = data
if (!novelId || typeof fromNumber !== "number" || typeof toNumber !== "number") {
return NextResponse.json({ error: "Dữ liệu không hợp lệ" }, { status: 400 })
}
if (fromNumber > toNumber) {
return NextResponse.json({ error: "Chương bắt đầu không được lớn hơn chương kết thúc" }, { status: 400 })
}
// Xác minh truyện thuộc về Mod này (hoặc Admin)
const novel = await prisma.novel.findUnique({
where: { id: novelId }
})
if (!novel) {
return NextResponse.json({ error: "Truyện không tồn tại" }, { status: 404 })
}
if (session.user.role !== "ADMIN" && novel.uploaderId !== session.user.id) {
return NextResponse.json({ error: "Bạn không có quyền thao tác trên truyện này" }, { status: 403 })
}
await connectToMongoDB()
// Xóa các chương trong khoảng
const deleteResult = await Chapter.deleteMany({
novelId,
number: { $gte: fromNumber, $lte: toNumber }
})
// Cập nhật lại số lượng chương
const totalChapters = await Chapter.countDocuments({ novelId })
await prisma.novel.update({
where: { id: novelId },
data: { totalChapters },
})
return NextResponse.json({
success: true,
deletedCount: deleteResult.deletedCount,
totalChapters
})
} catch (error: any) {
console.error("Bulk Delete Chapters Error:", error)
return NextResponse.json({ error: "Lỗi hệ thống khi xóa chương: " + error.message }, { status: 500 })
}
}
+262
View File
@@ -0,0 +1,262 @@
import { NextResponse } from "next/server"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import connectToMongoDB from "@/lib/mongoose"
import { EditorRecommendation } from "@/lib/models/editor-recommendation"
const MAX_RECOMMENDATIONS_PER_EDITOR = 5
function normalizeText(value: any): string {
return typeof value === "string" ? value.trim() : ""
}
function isAllowedModerator(role: string) {
return role === "MOD" || role === "ADMIN"
}
export async function GET(req: Request) {
const session = await getServerSession(authOptions)
if (!session || !isAllowedModerator(session.user.role)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const url = new URL(req.url)
const q = normalizeText(url.searchParams.get("q"))
await connectToMongoDB()
const docs = (await EditorRecommendation.find({})
.sort({ createdAt: -1 })
.limit(1000)
.lean()) as Array<{
_id: any
novelId: string
editorId: string
createdAt?: Date
}>
const novelIds = Array.from(new Set(docs.map((doc) => doc.novelId).filter(Boolean)))
const editorIds = Array.from(new Set(docs.map((doc) => doc.editorId).filter(Boolean)))
const [novels, editors] = await Promise.all([
novelIds.length > 0
? prisma.novel.findMany({
where: { id: { in: novelIds } },
select: {
id: true,
title: true,
slug: true,
authorName: true,
coverUrl: true,
status: true,
totalChapters: true,
},
})
: Promise.resolve([]),
editorIds.length > 0
? prisma.user.findMany({
where: { id: { in: editorIds } },
select: { id: true, name: true },
})
: Promise.resolve([]),
])
const novelMap = new Map(novels.map((novel) => [novel.id, novel]))
const editorMap = new Map(editors.map((editor) => [editor.id, editor]))
const recommendationCountMap = new Map<string, number>()
for (const doc of docs) {
recommendationCountMap.set(doc.novelId, (recommendationCountMap.get(doc.novelId) || 0) + 1)
}
const items = docs
.map((doc) => {
const novel = novelMap.get(doc.novelId)
if (!novel) return null
const editor = editorMap.get(doc.editorId)
return {
id: String(doc._id),
createdAt: doc.createdAt || null,
recommendCount: recommendationCountMap.get(doc.novelId) || 0,
novel,
editor: {
id: doc.editorId,
name: editor?.name || "Biên tập viên",
},
}
})
.filter((item): item is NonNullable<typeof item> => Boolean(item))
const summary = Array.from(recommendationCountMap.entries())
.map(([novelId, recommendCount]) => {
const novel = novelMap.get(novelId)
if (!novel) return null
return { novel, recommendCount }
})
.filter((item): item is NonNullable<typeof item> => Boolean(item))
.sort((a, b) => b.recommendCount - a.recommendCount)
const myNovelIdSet = new Set(
docs.filter((doc) => doc.editorId === session.user.id).map((doc) => doc.novelId)
)
const myRecommendationCount = myNovelIdSet.size
const candidates = q
? await prisma.novel.findMany({
where: {
OR: [
{ title: { contains: q, mode: "insensitive" } },
{ slug: { contains: q, mode: "insensitive" } },
{ authorName: { contains: q, mode: "insensitive" } },
],
},
select: {
id: true,
title: true,
slug: true,
authorName: true,
coverUrl: true,
status: true,
totalChapters: true,
},
take: 20,
orderBy: [{ updatedAt: "desc" }],
})
: []
const candidateRows = candidates.map((novel) => ({
...novel,
alreadyRecommended: myNovelIdSet.has(novel.id),
recommendCount: recommendationCountMap.get(novel.id) || 0,
}))
return NextResponse.json({
items,
summary,
candidates: candidateRows,
myNovelIds: Array.from(myNovelIdSet),
currentUser: {
id: session.user.id,
role: session.user.role,
recommendationCount: myRecommendationCount,
maxRecommendationCount: MAX_RECOMMENDATIONS_PER_EDITOR,
},
})
} catch (error) {
console.error("Failed to fetch editor recommendations", error)
return NextResponse.json({ error: "Failed to fetch recommendations" }, { status: 500 })
}
}
export async function POST(req: Request) {
const session = await getServerSession(authOptions)
if (!session || !isAllowedModerator(session.user.role)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const body = await req.json()
const novelId = normalizeText(body?.novelId)
if (!novelId) {
return NextResponse.json({ error: "Thiếu ID truyện" }, { status: 400 })
}
const existedNovel = await prisma.novel.findUnique({
where: { id: novelId },
select: { id: true },
})
if (!existedNovel) {
return NextResponse.json({ error: "Truyện không tồn tại" }, { status: 404 })
}
await connectToMongoDB()
const existing = (await EditorRecommendation.findOne({
novelId,
editorId: session.user.id,
})
.select({ _id: 1 })
.lean()) as { _id: any } | null
if (existing) {
return NextResponse.json({ error: "Bạn đã đề cử truyện này rồi" }, { status: 409 })
}
const myRecommendationCount = await EditorRecommendation.countDocuments({
editorId: session.user.id,
})
if (myRecommendationCount >= MAX_RECOMMENDATIONS_PER_EDITOR) {
return NextResponse.json(
{ error: `Mỗi biên tập viên chỉ được đề cử tối đa ${MAX_RECOMMENDATIONS_PER_EDITOR} truyện` },
{ status: 400 }
)
}
try {
const created = await EditorRecommendation.create({
novelId,
editorId: session.user.id,
})
return NextResponse.json(
{
id: String(created._id),
novelId,
editorId: session.user.id,
},
{ status: 201 }
)
} catch (error: any) {
if (error?.code === 11000) {
return NextResponse.json({ error: "Bạn đã đề cử truyện này rồi" }, { status: 409 })
}
throw error
}
} catch (error) {
console.error("Failed to create editor recommendation", error)
return NextResponse.json({ error: "Failed to create recommendation" }, { status: 500 })
}
}
export async function DELETE(req: Request) {
const session = await getServerSession(authOptions)
if (!session || !isAllowedModerator(session.user.role)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const url = new URL(req.url)
const id = normalizeText(url.searchParams.get("id"))
if (!id) {
return NextResponse.json({ error: "Thiếu ID đề cử" }, { status: 400 })
}
await connectToMongoDB()
const existed = (await EditorRecommendation.findById(id).lean()) as {
_id: any
editorId: string
} | null
if (!existed) {
return NextResponse.json({ error: "Đề cử không tồn tại" }, { status: 404 })
}
if (session.user.role !== "ADMIN" && existed.editorId !== session.user.id) {
return NextResponse.json({ error: "Bạn không thể xóa đề cử của người khác" }, { status: 403 })
}
await EditorRecommendation.deleteOne({ _id: id })
return NextResponse.json({ success: true })
} catch (error) {
console.error("Failed to delete editor recommendation", error)
return NextResponse.json({ error: "Failed to delete recommendation" }, { status: 500 })
}
}
+212 -151
View File
@@ -116,18 +116,23 @@ function isRequestAbortedError(error: unknown): boolean {
}
const CHAPTER_REGEX_PRESETS: Record<string, string> = {
vi_chuong: "^(?:Chương|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$",
en_chapter: "^(?:Chapter|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$",
mix_chapter: "^(?:Chương|Chapter|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$",
bracket_chapter: "^\\[?\\s*(?:Chương|Chapter)\\s*\\d+(?:\\.\\d+)?\\s*\\]?[^\\n]*$",
vi_chuong_hoi: "^(?:Chương|Hồi|Tiết|Phần|Thứ|Quyển|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$",
mix_chapter: "^(?:Chương|Hồi|Tiết|Phần|Thứ|Quyển|Chapter|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$",
numeric_only: "^\\d+(?:\\.\\d+)?\\s*[\\.\\:\\-\\]\\)]?(?:\\s+|$)[^\\n]*$",
}
const LEGACY_CHAPTER_REGEX_PRESET_ALIASES: Record<string, keyof typeof CHAPTER_REGEX_PRESETS> = {
vi_chuong: "vi_chuong_hoi",
en_chapter: "mix_chapter",
bracket_chapter: "mix_chapter",
}
const NOISE_TITLE_REGEX = /^(?:mục lục|table of contents|toc|cover|bìa|copyright)$/i
const SIMPLE_CHAPTER_TITLE_REGEX = /^(?:ch(?:ương|apter)?|ch\.)\s*\d+(?:\.\d+)?\s*:?$/i
const SIMPLE_CHAPTER_TITLE_REGEX = /^(?:ch(?:ương|apter)?|ch\.|hồi|tiết|phần|thứ|quyển)\s*\d+(?:\.\d+)?\s*:?$/i
const GENERIC_SECTION_TITLE_REGEX = /^(?:m[uụ]c|section|sec\.?|part|ph[aầ]n)\s*[0-9ivxlcdm]+$/i
const WEAK_CHAPTER_TOC_TITLE_REGEX = /^(?:ch(?:ương|apter)?|ch\.)\s*\d+(?:\.\d+)?\s*[:\-–—]\s*(?:m[uụ]c|section|sec\.?|part)\s*[0-9ivxlcdm]+$/i
const CHAPTER_HEADING_LINE_REGEX = /^(?:\[?\s*)?(?:ch(?:ương|apter)?|ch\.)\s*([0-9]+)(?:\.[0-9]+)?(?:\s*\]?)*(?:(?:\s*[:\-–—\.]\s*|\s+)(.+))?$/i
const WEAK_CHAPTER_TOC_TITLE_REGEX = /^(?:ch(?:ương|apter)?|ch\.|hồi|tiết|phần|thứ|quyển)\s*\d+(?:\.\d+)?\s*[:\-–—]\s*(?:m[uụ]c|section|sec\.?|part)\s*[0-9ivxlcdm]+$/i
const CHAPTER_HEADING_LINE_REGEX = /^(?:\[?\s*)?(?:ch(?:ương|apter)?|ch\.|hồi|tiết|phần|thứ|quyển)?\s*([0-9]+)(?:\.[0-9]+)?(?:\s*\]?)*(?:(?:\s*[:\-–—\.]\s*|\s+)(.+))?$/i
const GENERIC_GENRE_TOKENS = new Set([
"book",
"books",
@@ -555,11 +560,14 @@ function resolveRegexPattern(formData: FormData): { regexInput: string; regexPre
return { regexInput: custom, regexPreset: preset || "custom" }
}
if (preset && CHAPTER_REGEX_PRESETS[preset]) {
return { regexInput: CHAPTER_REGEX_PRESETS[preset], regexPreset: preset }
if (preset) {
const normalizedPreset = (CHAPTER_REGEX_PRESETS[preset] ? preset : LEGACY_CHAPTER_REGEX_PRESET_ALIASES[preset]) || null
if (normalizedPreset && CHAPTER_REGEX_PRESETS[normalizedPreset]) {
return { regexInput: CHAPTER_REGEX_PRESETS[normalizedPreset], regexPreset: normalizedPreset }
}
}
return { regexInput: CHAPTER_REGEX_PRESETS.vi_chuong, regexPreset: "vi_chuong" }
return { regexInput: CHAPTER_REGEX_PRESETS.vi_chuong_hoi, regexPreset: "vi_chuong_hoi" }
}
function buildRegexFromInput(regexInput: string): { regex: RegExp; normalized: string } {
@@ -579,6 +587,7 @@ function buildRegexFromInput(regexInput: string): { regex: RegExp; normalized: s
const flagSet = new Set(flags.split(""))
flagSet.add("g")
flagSet.add("m")
flagSet.add("i")
const normalizedFlags = Array.from(flagSet).join("")
const regex = new RegExp(pattern, normalizedFlags)
@@ -740,6 +749,33 @@ function buildChaptersFromRegexSections(sections: EpubSection[], regex: RegExp):
return enrichVolumeMetadata(parsed)
}
function trimLeadingBeforeChapterOne(chapters: ParsedChapter[]): {
chapters: ParsedChapter[]
trimmedCount: number
} {
if (chapters.length === 0) {
return { chapters, trimmedCount: 0 }
}
const firstChapterOneIndex = chapters.findIndex((chapter) => {
const detected =
chapter.detectedChapterNumber ??
extractStrictChapterNumber(chapter.title) ??
extractChapterNumber(chapter.title)
return detected === 1
})
if (firstChapterOneIndex <= 0) {
return { chapters, trimmedCount: 0 }
}
return {
chapters: chapters.slice(firstChapterOneIndex),
trimmedCount: firstChapterOneIndex,
}
}
function withMissingChapterPlaceholders(chapters: ParsedChapter[]): {
chapters: ParsedChapter[]
insertedCount: number
@@ -763,7 +799,7 @@ function withMissingChapterPlaceholders(chapters: ParsedChapter[]): {
let canUseDetected =
detectedNumber !== null &&
detectedNumber > currentNumber &&
detectedNumber - currentNumber <= MAX_ALLOWED_GAP
(currentNumber === 0 || detectedNumber - currentNumber <= MAX_ALLOWED_GAP)
// Recover from noisy leading TOC entries such as "Mục 1" that shift numbering.
if (!canUseDetected && detectedNumber !== null && detectedNumber > 0 && detectedNumber <= currentNumber) {
@@ -778,22 +814,24 @@ function withMissingChapterPlaceholders(chapters: ParsedChapter[]): {
currentNumber = Math.max(0, currentNumber - 1)
}
canUseDetected = detectedNumber > currentNumber && detectedNumber - currentNumber <= MAX_ALLOWED_GAP
canUseDetected = detectedNumber > currentNumber && (currentNumber === 0 || detectedNumber - currentNumber <= MAX_ALLOWED_GAP)
}
if (canUseDetected && detectedNumber !== null) {
for (let missing = currentNumber + 1; missing < detectedNumber; missing++) {
insertedCount += 1
normalized.push({
title: `Chương ${missing} (Thiếu)`,
content: `[THIEU CHUONG ${missing}]\n\nNoi dung chuong nay dang thieu tu EPUB goc. Vui long bo sung sau.`,
detectedChapterNumber: missing,
finalNumber: missing,
volumeNumber: null,
volumeTitle: null,
volumeChapterNumber: null,
isPlaceholder: true,
})
if (currentNumber > 0) {
for (let missing = currentNumber + 1; missing < detectedNumber; missing++) {
insertedCount += 1
normalized.push({
title: `Chương ${missing} (Thiếu)`,
content: `[THIEU CHUONG ${missing}]\n\nNoi dung chuong nay dang thieu tu EPUB goc. Vui long bo sung sau.`,
detectedChapterNumber: missing,
finalNumber: missing,
volumeNumber: null,
volumeTitle: null,
volumeChapterNumber: null,
isPlaceholder: true,
})
}
}
detectedNumberAssignments += 1
@@ -971,6 +1009,7 @@ export async function POST(req: Request) {
const seriesIdInput = readFormText(formData, "seriesId")
const seriesNameInput = readFormText(formData, "seriesName")
const replaceExisting = String(formData.get("replaceExisting") || "").toLowerCase() === "true"
const appendTargetNovelId = String(formData.get("appendTargetNovelId") || "").trim()
if (!epubFile) {
return NextResponse.json({ error: "Thiếu file EPUB" }, { status: 400 })
@@ -1020,7 +1059,8 @@ export async function POST(req: Request) {
}
}
const gapFilled = withMissingChapterPlaceholders(chapters)
const leadingTrimmed = trimLeadingBeforeChapterOne(chapters)
const gapFilled = withMissingChapterPlaceholders(leadingTrimmed.chapters)
parsedData = {
metadata,
@@ -1033,6 +1073,7 @@ export async function POST(req: Request) {
regexPreset,
sourceSections: sections.length,
chaptersDetected: chapters.length,
trimmedBeforeChapterOne: leadingTrimmed.trimmedCount,
chaptersFinal: gapFilled.chapters.length,
insertedMissingChapters: gapFilled.insertedCount,
detectedMaxChapterNumber: gapFilled.detectedMax,
@@ -1086,150 +1127,170 @@ export async function POST(req: Request) {
})
}
const duplicatedNovel = await findNovelByTitleInsensitive(novelTitle)
const canReplaceDuplicated = duplicatedNovel
? canReplaceNovelByRole(session.user.role as UserRole, session.user.id, duplicatedNovel)
: false
if (duplicatedNovel && !replaceExisting) {
return NextResponse.json({
code: "DUPLICATE_TITLE",
error: `Truyện \"${duplicatedNovel.title}\" đã tồn tại`,
canReplace: canReplaceDuplicated,
existingNovel: {
id: duplicatedNovel.id,
title: duplicatedNovel.title,
slug: duplicatedNovel.slug,
},
}, { status: 409 })
}
if (duplicatedNovel && replaceExisting && !canReplaceDuplicated) {
return NextResponse.json({
code: "DUPLICATE_TITLE",
error: "Bạn không có quyền replace truyện đã tồn tại",
canReplace: false,
existingNovel: {
id: duplicatedNovel.id,
title: duplicatedNovel.title,
slug: duplicatedNovel.slug,
},
}, { status: 403 })
}
const resolvedGenreIds = await resolveGenreIdsFromNames(detectedGenreNames, true)
const selectedSeriesId = await resolveSeriesIdForEpubImport({
mode: seriesMode,
seriesId: seriesIdInput,
seriesName: seriesNameInput,
userRole: session.user.role,
userId: session.user.id,
})
const coverUrl = await saveCoverBufferToR2(cover)
let targetNovelId = duplicatedNovel?.id || ""
let targetNovelId = ""
let responseStatus = 201
let replaced = false
let isAppending = !!appendTargetNovelId
let finalCoverUrl: string | null = null
if (duplicatedNovel && replaceExisting) {
const updatedNovel = await prisma.$transaction(async (tx) => {
await tx.novel.update({
where: { id: duplicatedNovel.id },
data: {
title: novelTitle,
authorName: novelAuthor,
description: novelDesc,
status: importDefaultStatus,
coverUrl,
seriesId: selectedSeriesId,
totalChapters: chapters.length,
...(session.user.role === "MOD" ? { uploaderId: session.user.id } : {}),
},
})
if (isAppending) {
const targetNovel = await prisma.novel.findUnique({ where: { id: appendTargetNovelId } })
if (!targetNovel || !canReplaceNovelByRole(session.user.role as UserRole, session.user.id, targetNovel)) {
return NextResponse.json({ error: "Truyện không tồn tại hoặc không đủ quyền thao tác báo cáo bổ sung." }, { status: 403 })
}
targetNovelId = targetNovel.id
responseStatus = 200
finalCoverUrl = targetNovel.coverUrl
} else {
const duplicatedNovel = await findNovelByTitleInsensitive(novelTitle)
const canReplaceDuplicated = duplicatedNovel ? canReplaceNovelByRole(session.user.role as UserRole, session.user.id, duplicatedNovel) : false
await tx.novelGenre.deleteMany({
where: { novelId: duplicatedNovel.id },
})
if (duplicatedNovel && !replaceExisting) {
return NextResponse.json({
code: "DUPLICATE_TITLE",
error: `Truyện "${duplicatedNovel.title}" đã tồn tại`,
canReplace: canReplaceDuplicated,
existingNovel: { id: duplicatedNovel.id, title: duplicatedNovel.title, slug: duplicatedNovel.slug },
}, { status: 409 })
}
if (resolvedGenreIds.length > 0) {
await tx.novelGenre.createMany({
data: resolvedGenreIds.map((genreId) => ({
novelId: duplicatedNovel.id,
genreId,
})),
skipDuplicates: true,
})
}
if (duplicatedNovel && replaceExisting && !canReplaceDuplicated) {
return NextResponse.json({
code: "DUPLICATE_TITLE",
error: "Bạn không có quyền replace truyện đã tồn tại",
canReplace: false,
existingNovel: { id: duplicatedNovel.id, title: duplicatedNovel.title, slug: duplicatedNovel.slug },
}, { status: 403 })
}
return tx.novel.findUnique({ where: { id: duplicatedNovel.id } })
const resolvedGenreIds = await resolveGenreIdsFromNames(detectedGenreNames, true)
const selectedSeriesId = await resolveSeriesIdForEpubImport({
mode: seriesMode,
seriesId: seriesIdInput,
seriesName: seriesNameInput,
userRole: session.user.role,
userId: session.user.id,
})
if (!updatedNovel) {
throw new Error("Không thể replace truyện đã tồn tại")
}
const coverUrl = await saveCoverBufferToR2(cover)
finalCoverUrl = coverUrl
targetNovelId = duplicatedNovel?.id || ""
targetNovelId = updatedNovel.id
responseStatus = 200
replaced = true
if (duplicatedNovel && replaceExisting) {
const updatedNovel = await prisma.$transaction(async (tx) => {
await tx.novel.update({
where: { id: duplicatedNovel.id },
data: {
title: novelTitle,
authorName: novelAuthor,
description: novelDesc,
status: importDefaultStatus,
coverUrl,
seriesId: selectedSeriesId,
totalChapters: chapters.length,
...(session.user.role === "MOD" ? { uploaderId: session.user.id } : {}),
},
})
await tx.novelGenre.deleteMany({ where: { novelId: duplicatedNovel.id } })
if (resolvedGenreIds.length > 0) {
await tx.novelGenre.createMany({
data: resolvedGenreIds.map((genreId) => ({ novelId: duplicatedNovel.id, genreId })),
skipDuplicates: true,
})
}
return tx.novel.findUnique({ where: { id: duplicatedNovel.id } })
})
if (duplicatedNovel.coverUrl && duplicatedNovel.coverUrl !== coverUrl) {
await deleteR2ObjectByUrl(duplicatedNovel.coverUrl).catch(() => { })
}
} else {
// Generate base slug
const baseSlug = slugify(novelTitle)
let slug = baseSlug
let slugCounter = 1
if (!updatedNovel) throw new Error("Không thể replace truyện đã tồn tại")
// Đảm bảo slug là duy nhất
while (await prisma.novel.findUnique({ where: { slug } })) {
slug = `${baseSlug}-${slugCounter}`
slugCounter++
}
targetNovelId = updatedNovel.id
responseStatus = 200
replaced = true
const createData: any = {
title: novelTitle,
slug,
authorName: novelAuthor,
description: novelDesc,
status: importDefaultStatus,
coverUrl,
seriesId: selectedSeriesId,
uploaderId: session.user.id,
totalChapters: chapters.length,
}
if (resolvedGenreIds.length > 0) {
createData.genres = {
create: resolvedGenreIds.map((genreId) => ({
genre: { connect: { id: genreId } },
})),
if (duplicatedNovel.coverUrl && duplicatedNovel.coverUrl !== coverUrl) {
await deleteR2ObjectByUrl(duplicatedNovel.coverUrl).catch(() => { })
}
} else {
const baseSlug = slugify(novelTitle)
let slug = baseSlug
let slugCounter = 1
while (await prisma.novel.findUnique({ where: { slug } })) {
slug = `${baseSlug}-${slugCounter}`
slugCounter++
}
}
const createdNovel = await prisma.novel.create({ data: createData })
targetNovelId = createdNovel.id
const createData: any = {
title: novelTitle,
slug,
authorName: novelAuthor,
description: novelDesc,
status: importDefaultStatus,
coverUrl,
seriesId: selectedSeriesId,
uploaderId: session.user.id,
totalChapters: chapters.length,
}
if (resolvedGenreIds.length > 0) {
createData.genres = { create: resolvedGenreIds.map((genreId) => ({ genre: { connect: { id: genreId } } })) }
}
const createdNovel = await prisma.novel.create({ data: createData })
targetNovelId = createdNovel.id
}
}
// Lưu chapters xuống MongoDB
await connectToMongoDB()
await Chapter.deleteMany({ novelId: targetNovelId })
let insertedCount = 0
let updatedCount = 0
const chapterDocs = chapters.map((ch: any, i: number) => ({
novelId: targetNovelId,
number: ch.finalNumber || (i + 1),
volumeNumber: ch.volumeNumber ?? null,
volumeTitle: ch.volumeTitle ?? null,
volumeChapterNumber: ch.volumeChapterNumber ?? null,
title: ch.title,
content: ch.content,
views: 0,
}))
if (!isAppending) {
await Chapter.deleteMany({ novelId: targetNovelId })
const finalChapterDocs = chapters.map((ch: any, i: number) => ({
novelId: targetNovelId,
number: ch.finalNumber || (i + 1),
volumeNumber: ch.volumeNumber ?? null,
volumeTitle: ch.volumeTitle ?? null,
volumeChapterNumber: ch.volumeChapterNumber ?? null,
title: ch.title,
content: ch.content,
views: 0,
}))
if (finalChapterDocs.length > 0) {
await Chapter.insertMany(finalChapterDocs)
insertedCount = finalChapterDocs.length
}
} else {
const bulkOps = chapters.map((ch: any) => {
const candidateNumber = ch.detectedChapterNumber || ch.finalNumber
return {
updateOne: {
filter: { novelId: targetNovelId, number: candidateNumber },
update: {
$set: {
volumeNumber: ch.volumeNumber ?? null,
volumeTitle: ch.volumeTitle ?? null,
volumeChapterNumber: ch.volumeChapterNumber ?? null,
title: ch.title,
content: ch.content,
},
},
upsert: true,
}
}
})
if (bulkOps.length > 0) {
const writeResult = await Chapter.bulkWrite(bulkOps)
insertedCount = writeResult.upsertedCount || 0
updatedCount = writeResult.modifiedCount || 0
}
if (chapterDocs.length > 0) {
await Chapter.insertMany(chapterDocs)
const totalAfterMerge = await Chapter.countDocuments({ novelId: targetNovelId })
await prisma.novel.update({
where: { id: targetNovelId },
data: { totalChapters: totalAfterMerge },
})
}
const novelAfterWrite = await prisma.novel.findUnique({ where: { id: targetNovelId } })
@@ -1240,7 +1301,7 @@ export async function POST(req: Request) {
return NextResponse.json({
...novelAfterWrite,
parserInfo,
hasCoverFromEpub: !!coverUrl,
hasCoverFromEpub: !!finalCoverUrl,
detectedGenres: detectedGenreNames,
replaced,
}, { status: responseStatus })
+44 -24
View File
@@ -148,6 +148,13 @@ export async function PUT(req: Request) {
try {
const data = await req.json()
const { id, title, originalTitle, authorName, originalAuthorName, description, coverUrl, status, genreIds } = data
if (!id) {
return NextResponse.json({ error: "Thiếu ID truyện" }, { status: 400 })
}
const hasField = (field: string) => Object.prototype.hasOwnProperty.call(data, field)
const targetNovel = await prisma.novel.findFirst({
where: session.user.role === "ADMIN"
? { id }
@@ -169,6 +176,18 @@ export async function PUT(req: Request) {
const fixedSeriesId = targetNovel.seriesId
if (fixedSeriesId) {
const sharedData: Record<string, unknown> = {}
if (hasField("originalTitle")) sharedData.originalTitle = originalTitle
if (hasField("authorName")) sharedData.authorName = authorName
if (hasField("originalAuthorName")) sharedData.originalAuthorName = originalAuthorName
if (hasField("description")) sharedData.description = description
if (hasField("status")) sharedData.status = status
const ownData: Record<string, unknown> = {}
if (hasField("title")) ownData.title = title
if (hasField("coverUrl")) ownData.coverUrl = coverUrl
if (session.user.role === "MOD") ownData.uploaderId = session.user.id
const seriesNovels = await prisma.novel.findMany({
where: { seriesId: fixedSeriesId },
select: { id: true },
@@ -177,16 +196,12 @@ export async function PUT(req: Request) {
const updatedNovel = await prisma.$transaction(async (tx) => {
// Sync shared metadata for all novels in the same series.
await tx.novel.updateMany({
where: { id: { in: seriesNovelIds } },
data: {
originalTitle,
authorName,
originalAuthorName,
description,
status,
},
})
if (Object.keys(sharedData).length > 0) {
await tx.novel.updateMany({
where: { id: { in: seriesNovelIds } },
data: sharedData,
})
}
if (genreIds !== undefined) {
await tx.novelGenre.deleteMany({
@@ -203,31 +218,36 @@ export async function PUT(req: Request) {
}
// Only current novel keeps its own title and cover.
if (Object.keys(ownData).length === 0) {
return tx.novel.findUnique({ where: { id } })
}
return tx.novel.update({
where: { id },
data: {
title,
coverUrl,
...(session.user.role === "MOD" && { uploaderId: session.user.id }),
},
data: ownData,
})
})
return NextResponse.json(updatedNovel)
}
const updateData: Record<string, unknown> = {
seriesId: fixedSeriesId,
...(session.user.role === "MOD" && { uploaderId: session.user.id }),
}
if (hasField("title")) updateData.title = title
if (hasField("originalTitle")) updateData.originalTitle = originalTitle
if (hasField("authorName")) updateData.authorName = authorName
if (hasField("originalAuthorName")) updateData.originalAuthorName = originalAuthorName
if (hasField("description")) updateData.description = description
if (hasField("coverUrl")) updateData.coverUrl = coverUrl
if (hasField("status")) updateData.status = status
const updatedNovel = await prisma.novel.update({
where: { id },
data: {
title,
originalTitle,
authorName,
originalAuthorName,
description,
coverUrl,
status,
seriesId: fixedSeriesId,
...(session.user.role === "MOD" && { uploaderId: session.user.id }),
...updateData,
...(genreIds !== undefined && {
genres: {
deleteMany: {},