Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions app/(root)/page.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import BookCard from "@/components/BookCard";
import HeroSection from "@/components/HeroSection";
import { sampleBooks } from "@/lib/constants";
import { getAllBooks } from "@/lib/actions/book.actions";

export default async function Page() {
const bookResult = await getAllBooks();
const books = bookResult.success ? bookResult.data ?? [] : [];
Comment on lines +5 to +7

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

数据获取失败时用户无感知。

getAllBooks() 返回 success: false(数据库连接异常等)时,页面会静默渲染成"无书"状态,看起来和"空书架"完全一致,既误导用户也让前端难以排障。建议至少:

  • 在服务端记录/上报错误;
  • 或向下游 UI 传递一个错误状态以渲染出明确的错误/重试区块,而不是伪装成空集合。
-  const bookResult = await getAllBooks();
-  const books = bookResult.success ? bookResult.data ?? [] : [];
+  const bookResult = await getAllBooks();
+  if (!bookResult.success) {
+    // TODO: 渲染带重试按钮的错误态
+    console.error("获取书籍失败", bookResult.error);
+  }
+  const books = bookResult.success ? bookResult.data ?? [] : [];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export default async function Page() {
const bookResult = await getAllBooks();
const books = bookResult.success ? bookResult.data ?? [] : [];
export default async function Page() {
const bookResult = await getAllBooks();
if (!bookResult.success) {
// TODO: 渲染带重试按钮的错误态
console.error("获取书籍失败", bookResult.error);
}
const books = bookResult.success ? bookResult.data ?? [] : [];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/`(root)/page.tsx around lines 5 - 7, The Page server component currently
treats a failed getAllBooks() the same as an empty result, losing error
visibility; update Page to detect when bookResult.success is false (check
bookResult.error or similar), record/report the error (e.g., call a
logging/reporting helper) and pass an explicit error flag or error message down
to the UI instead of converting to an empty array (i.e., keep books =
bookResult.data ?? [] only when success is true, otherwise set an error state
like booksError and render a retry/error UI), referencing the Page function and
the bookResult variable to locate where to add logging/reporting and the new
error prop for the UI.


export default function Page() {
return (
<div className="container wrapper">
<main className="container wrapper">
<HeroSection />

<div className="library-books-grid">
{sampleBooks.map((book) => (
{books.map((book) => (
<BookCard
key={book._id}
title={book.title}
Expand All @@ -18,6 +21,6 @@ export default function Page() {
/>
))}
</div>
</div>
</main>
);
}
52 changes: 52 additions & 0 deletions app/api/upload/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { MAX_FILE_SIZE } from "@/lib/constants";
import { auth } from "@clerk/nextjs/server";
import { handleUpload, HandleUploadBody } from "@vercel/blob/client";
import { NextRequest, NextResponse } from "next/server";

export async function POST(request: NextRequest): Promise<NextResponse> {
const body = (await request.json()) as HandleUploadBody;

try {
// 处理上传请求
const jsonResponse = await handleUpload({
token: process.env.BLOB_READ_WRITE_TOKEN,
body,
request,
// 在生成上传令牌之前执行的函数
onBeforeGenerateToken: async () => {
// 检查用户是否登录
const { userId } = await auth();
if (!userId) {
throw new Error("Unauthorized: 用户未登录");
}

return {
allowedContentTypes: [
"application/pdf",
"image/jpeg",
"image/png",
"image/webp",
],
addRandomSuffix: true,
maximumSizeInBytes: MAX_FILE_SIZE,
tokenPayload: JSON.stringify({ userId }),
Comment on lines +23 to +32

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 预期:能看到前端/常量存在 10MB 图片限制,但上传路由只使用 50MB MAX_FILE_SIZE。
rg -n -C3 'MAX_IMAGE_SIZE|MAX_FILE_SIZE|maximumSizeInBytes|allowedContentTypes|handleUpload' app/api/upload/route.ts lib/constants.ts components/UploadForm.tsx

Repository: lijianhua7/bookified

Length of output: 4923


🌐 Web query:

@vercel/blob handleUpload onBeforeGenerateToken maximumSizeInBytes content type pathname documentation

💡 Result:

The handleUpload function from @vercel/blob/client is used in server routes for client-side uploads to Vercel Blob. The onBeforeGenerateToken callback is required and receives pathname (the proposed blob path), clientPayload (optional string from client), and possibly multipart (from examples). It authenticates/authorizes the upload and returns an object configuring the client token. Supported return options from official docs and examples include: - allowedContentTypes: array of MIME types (e.g., ['image/jpeg', 'image/png']) - restricts file types. - tokenPayload: string payload sent to onUploadCompleted. - validUntil: timestamp (number) for token expiration (e.g., Math.floor(Date.now / 1000) + 3600). - addRandomSuffix: boolean to append random suffix to pathname. - pathname: string to override the pathname. - access: 'public' or 'private'. - callbackUrl: string for custom upload completion webhook. maximumSizeInBytes (number, e.g., 100_000_000 for 100MB) appears in GitHub issue examples (vercel/storage #664) for limiting upload size, but is not documented in official Vercel docs (as of 2026-02 sources). contentType can be set explicitly (from StackOverflow examples), otherwise inferred from pathname extension or file. Example usage: onBeforeGenerateToken: async (pathname, clientPayload) => { // Auth check here return { allowedContentTypes: ['image/*'], maximumSizeInBytes: 100_000_000, // From community examples tokenPayload: JSON.stringify({ userId: '123' }), addRandomSuffix: true }; } Primary docs: https://vercel.com/docs/vercel-blob/client-upload and https://vercel.com/docs/vercel-blob/using-blob-sdk. For full options, check handleUpload SDK reference linked in client upload docs (not fully surfaced in search snippets).

Citations:


🏁 Script executed:

# Check if onUploadCompleted performs any server-side size validation
rg -n -A10 'onUploadCompleted|uploadedPdfBlob|uploadedCoverBlob' app/api/upload/route.ts

Repository: lijianhua7/bookified

Length of output: 458


在服务端按内容类型分别限制文件大小。

当前上传路由对所有文件类型使用统一的 maximumSizeInBytes: MAX_FILE_SIZE(50MB),但前端对图片强制实施 10MB 限制。通过绕过客户端表单验证,可上传 50MB 的图片文件,违反设计限制。onUploadCompleted 回调中也无服务端大小验证,该限制完全得不到保障。

建议方案:
onBeforeGenerateToken 中根据 allowedContentTypespathname 检查内容类型,为图片返回 maximumSizeInBytes: 10 * 1024 * 1024,为 PDF 返回 50MB。若 @vercel/blob 当前版本不支持此参数或行为不符预期,建议拆分为两个独立上传端点(/api/upload/pdf/api/upload/cover)分别限制大小。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/upload/route.ts` around lines 23 - 32, 当前上传路由在生成上传 token 时一律使用
maximumSizeInBytes: MAX_FILE_SIZE 导致服务端对图片没有 10MB 限制;请在 onBeforeGenerateToken
中根据请求的内容类型(或 pathname)判断若为图片类型(image/jpeg, image/png, image/webp)则将
maximumSizeInBytes 设置为 10 * 1024 * 1024,若为 application/pdf 则设置为 50 * 1024 *
1024,确保 tokenPayload/返回的 token 包含该限制;如果当前 `@vercel/blob` 版本不支持为不同类型动态设置
maximumSizeInBytes,则改为实现两个独立端点(例如 upload/pdf 和 upload/cover)各自返回不同的
maximumSizeInBytes,并在 onUploadCompleted 中再次校验实际文件大小
与对应类型的最大值(参考符号:onBeforeGenerateToken,onUploadCompleted,allowedContentTypes,maximumSizeInBytes,MAX_FILE_SIZE,tokenPayload)。

};
},
// 上传完成后的回调
onUploadCompleted: async ({ blob, tokenPayload }) => {
console.log("文件已上传至 Blob", blob.url);

const payload = tokenPayload ? JSON.parse(tokenPayload) : null;
const userId = payload?.userId;

// TODO: 上报PostHog
},
});

return NextResponse.json(jsonResponse);
} catch (e) {
const message = e instanceof Error ? e.message : "未知错误";
const status = message.includes("Unauthorized") ? 401 : 500;
return NextResponse.json({ error: message }, { status });
}
}
3 changes: 2 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ClerkProvider } from "@clerk/nextjs";
import "./globals.css";
import NavBar from "@/components/NavBar";
import { CLERK_AUTH_APPEARANCE_OVERRIDE } from "@/lib/constants";

import { Toaster } from "@/components/ui/sonner"

const ibmPlexSerif = IBM_Plex_Serif({
variable: "--font-ibm-plex-serif",
Expand Down Expand Up @@ -40,6 +40,7 @@ export default function RootLayout({
<ClerkProvider appearance={CLERK_AUTH_APPEARANCE_OVERRIDE}>
<NavBar />
{children}
<Toaster />
</ClerkProvider>
</body>
</html>
Expand Down
153 changes: 142 additions & 11 deletions components/UploadForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,13 @@ import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Upload, Image as ImageIcon, X } from "lucide-react";
import { cn } from "@/lib/utils";

import { cn, parsePDFFile } from "@/lib/utils";
import {
MAX_FILE_SIZE,
ACCEPTED_PDF_TYPES,
MAX_IMAGE_SIZE,
ACCEPTED_IMAGE_TYPES,
} from "@/lib/constants";
import {
Form,
FormControl,
Expand All @@ -16,6 +21,11 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useAuth } from "@clerk/nextjs";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import { checkBookExists, createBook, saveBookSegments } from "@/lib/actions/book.actions";
import { upload } from "@vercel/blob/client";

const VOICES = {
male: [
Expand All @@ -42,34 +52,155 @@ const VOICES = {
};

const formSchema = z.object({
pdfFile: z.any().refine((file) => file, "请选择一本 PDF 文件"),
coverImage: z.any().optional(),
title: z.string().min(1, "标题是必填项"),
author: z.string().min(1, "作者是必填项"),
title: z.string().min(1, "标题是必填项").max(100, "标题不能超过 100 个字符"),
author: z.string().min(1, "作者是必填项").max(100, "作者名不能超过 100 个字符"),
voice: z.string().min(1, "请选择一个语音"),
pdfFile: z
.instanceof(File, { message: "请选择一本 PDF 文件" })
.refine((file) => file.size <= MAX_FILE_SIZE, "文件大小不能超过 50MB")
.refine((file) => ACCEPTED_PDF_TYPES.includes(file.type), "仅支持 PDF 格式的文件"),
coverImage: z
.instanceof(File)
.optional()
.refine((file) => !file || file.size <= MAX_IMAGE_SIZE, "图片大小不能超过 10MB")
.refine(
(file) => !file || ACCEPTED_IMAGE_TYPES.includes(file.type),
"仅支持 .jpg、.jpeg、.png 和 .webp 格式的图片",
),
});

export default function UploadForm() {
const [isLoading, setIsLoading] = useState(false);
const [pdfFilename, setPdfFilename] = useState<string | null>(null);
const [coverFilename, setCoverFilename] = useState<string | null>(null);
const { userId } = useAuth();
const router = useRouter();

const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
title: "",
author: "",
voice: "",
pdfFile: undefined,
coverImage: undefined,
},
});

function onSubmit(values: z.infer<typeof formSchema>) {
async function onSubmit(data: z.infer<typeof formSchema>) {
// 检查用户是否登录
if (!userId) {
return toast.error("上传图书前请先登录!");
}

setIsLoading(true);
// Simulate API call
setTimeout(() => {

// PostHog -> Track Book Uploads ...

try {
// 检查图书是否已存在
const existsCheck = await checkBookExists(data.title);
if (existsCheck.exists && existsCheck.book) {
toast.info("同名的图书已存在!");
// 清空表单
form.reset();
// 已存在就跳转到该图书的详情页面
router.push(`/books/${existsCheck.book.slug}`);
return;
}

// 格式化文件名
const fileTitle = data.title.replace(/\s+/g, "-").toLowerCase();
// 解析 PDF 文件
const pdfFile = data.pdfFile;
const parsedPdf = await parsePDFFile(pdfFile);
if (parsedPdf.content.length === 0) {
toast.error("无法解析 PDF 文件,请上传有效的 PDF 文件!");
return;
}

// 上传pdf
const uploadedPdfBlob = await upload(fileTitle, pdfFile, {
access: "public",
handleUploadUrl: "/api/upload",
contentType: "application/pdf",
})

// 上传封面
let coverUrl: string;

if (data.coverImage) {
const coverFile = data.coverImage;
const uploadedCoverBlob = await upload(`${fileTitle}_cover.png`, coverFile, {
access: "public",
handleUploadUrl: "/api/upload",
contentType: coverFile.type,
})
coverUrl = uploadedCoverBlob.url;
} else {
const response = await fetch(parsedPdf.cover);
const blob = await response.blob();

const uploadedCoverBlob = await upload(`${fileTitle}_cover.png`, blob, {
access: "public",
handleUploadUrl: "/api/upload",
contentType: "image/png",
})
coverUrl = uploadedCoverBlob.url;
Comment on lines +123 to +149

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 检查项目里是否已有 Vercel Blob 删除或上传回滚逻辑。
rg -n -C3 --type=ts --type=tsx '\b(upload|del|deleteBlob|createBook|saveBookSegments)\b'

Repository: lijianhua7/bookified

Length of output: 91


🌐 Web query:

@vercel/blob delete blob del API documentation

💡 Result:

To delete a blob using Vercel Blob (@vercel/blob), use the del function from the SDK. It supports deleting a single blob or multiple blobs by URL or pathname. ## SDK Usage (del method) Install: npm install @vercel/blob Basic example (single blob): import { del } from '@vercel/blob'; await del('https://example.blob.vercel-storage.com/blob-id'); // or by pathname await del('/path/to/blob'); Multiple blobs: await del([ 'https://blob1.vercel-storage.com/...', 'https://blob2.vercel-storage.com/...', '/path/to/blob' ]); Options: - token: Read-write token (defaults to BLOB_READ_WRITE_TOKEN env var) - ifMatch: ETag for conditional delete (throws BlobPreconditionFailedError if mismatch; single URL only) - abortSignal: To cancel the operation Returns void. Deletes are free (no billing, but count toward rate limits). Due to caching, blobs may take up to 60 seconds to disappear from CDN. Example API route: import { del } from '@vercel/blob'; export async function DELETE(request: Request) { const { searchParams } = new URL(request.url); const urlToDelete = searchParams.get('url')!; await del(urlToDelete); return new Response(null, { status: 200 }); } For bulk delete all blobs, use list + del in batches with rate limit handling. ## CLI Usage vercel blob del images/old-logo.png or by URL/pathname. ## Dashboard Delete files via the file browser in Vercel Dashboard > Storage > Blob store. Note: Cannot delete stores directly via SDK/CLI del; use vercel blob delete-store or dashboard. Official docs: vercel.com/docs/vercel-blob (overview), vercel.com/docs/[storage/]vercel-blob/using-blob-sdk (SDK reference with del details), vercel.com/docs/cli/blob (CLI).

Citations:


🏁 Script executed:

#!/bin/bash
# 搜索上传、删除、createBook、saveBookSegments 相关逻辑
# 使用正确的语法(--type ts 而不是 tsx)
rg -n '\b(upload|del|deleteBlob|createBook|saveBookSegments)\b' --type ts

Repository: lijianhua7/bookified

Length of output: 2993


🏁 Script executed:

#!/bin/bash
# 也在 tsx 文件中搜索(不用 --type)
rg -n '\b(upload|del|deleteBlob|createBook|saveBookSegments)\b' '.*\.(tsx|ts)$'

Repository: lijianhua7/bookified

Length of output: 117


🏁 Script executed:

#!/bin/bash
# 读取 UploadForm.tsx 文件内容,查看完整的上传和错误处理逻辑
cat -n components/UploadForm.tsx | head -200

Repository: lijianhua7/bookified

Length of output: 6788


🏁 Script executed:

#!/bin/bash
# 检查是否存在任何 cleanup、rollback、error handler 等相关逻辑
rg -n 'catch|error|cleanup|rollback|unlink|delete|remove' components/ --type ts

Repository: lijianhua7/bookified

Length of output: 1202


在上传失败时清理已上传的 Blob。

PDF 和封面上传后(第 123-149 行),若 createBook() 失败(第 166 行)、返回 alreadyExists: true(第 170 行)或 saveBookSegments() 失败(第 189 行),这些公开 Blob 会变成孤儿对象。当前代码缺少清理逻辑,这些文件会持续产生存储成本和隐私风险。

建议:

  1. 在导入中添加 import { del } from "@vercel/blob"
  2. 在失败分支中调用 del([uploadedPdfBlob.url, coverUrl]) 进行清理
  3. 或在 API 路由中实现清理端点,从 try-catch 块调用
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/UploadForm.tsx` around lines 123 - 149, The uploaded PDF and cover
blobs (uploadedPdfBlob, uploadedCoverBlob, coverUrl produced by upload()) are
not cleaned up when createBook() fails, returns alreadyExists, or
saveBookSegments() throws, leaving orphaned public blobs; import del from
"@vercel/blob" and call del([uploadedPdfBlob.url, coverUrl]) in each failure
path (the catch around createBook(), the branch handling createBook returning
alreadyExists: true, and the catch around saveBookSegments()) or invoke a
cleanup API endpoint from those failure handlers so both uploadedPdfBlob.url and
coverUrl are deleted on any failure.

}

// 创建图书
const book = await createBook({
clerkId: userId,
title: data.title,
author: data.author,
persona: data.voice,
fileURL: uploadedPdfBlob.url,
fileBlobKey: uploadedPdfBlob.pathname,
coverURL: coverUrl,
fileSize: pdfFile.size,
})

// 创建失败
if (!book.success) {
throw new Error("创建图书失败");
}

// 创建的图书已存在
if (book.alreadyExists) {
toast.info("同名的图书已存在!");
// 清空表单
form.reset();
// 已存在就跳转到该图书的详情页面
router.push(`/books/${book.data.slug}`);
return;
}

// 创建成功
// 保存图书片段
const segments = await saveBookSegments(
book.data._id,
userId,
parsedPdf.content,
)

if (!segments.success) {
toast.error("保存图书片段失败");
throw new Error("保存图书片段失败");
}

toast.success("图书创建成功!");
// 清空表单
form.reset();
// 创建新书成功跳转到首页
router.push(`/`);

} catch (e) {
console.error(e);
toast.error("上传图书失败,请稍后重试!");
} finally {
setIsLoading(false);
console.log("Form Submitted:", values);
}, 2000);
}
}

return (
Expand Down
49 changes: 49 additions & 0 deletions components/ui/sonner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"use client"

import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"

const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()

return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}

export { Toaster }
23 changes: 23 additions & 0 deletions database/models/book-segment.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { IBookSegment } from "@/type";
import { Schema, models, model } from "mongoose";

// 创建 BookSegmentSchema
const BookSegmentSchema = new Schema<IBookSegment>({
clerkId: {type: String, required: true},
bookId: {type: Schema.Types.ObjectId, ref: "Book", required: true, index: true},
content: {type: String, required: true},
segmentIndex: {type: Number, required: true, index: true},
pageNumber: {type: Number, index: true},
wordCount: {type: Number, required: true},
}, {timestamps: true})

// 为常见查询场景创建复合字段索引,上面定义的"index: true"是单字段索引
// 当 Vapi 朗读一本书时,它会按顺序获取段落。而这个复合索引可以让查找即时完成,而不是扫描数据集中的每个数据段。
BookSegmentSchema.index({bookId: 1, segmentIndex: 1}, {unique: true});
BookSegmentSchema.index({bookId: 1, pageNumber: 1});
BookSegmentSchema.index({bookId: 1, content: 'text'});
Comment on lines +7 to +18

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

字段上的单字段索引与下面的复合索引重复,建议删除。

  • bookId 的单字段索引已被复合索引 {bookId:1, segmentIndex:1}(以及其他 bookId 前缀的复合索引)完全覆盖,MongoDB 可以直接用复合索引的前缀匹配。
  • segmentIndex 单独作为查询键的场景极少(几乎总会带 bookId 作为条件),同样被 {bookId:1, segmentIndex:1} 覆盖。
  • pageNumber 同理被 {bookId:1, pageNumber:1} 覆盖。

保留这些重复索引会放大写入代价(每次 insertMany 都要维护更多 B-tree)并占用额外存储/内存,却不带来查询收益。

♻️ 建议修改
-    bookId: {type: Schema.Types.ObjectId, ref: "Book", required: true, index: true},
+    bookId: {type: Schema.Types.ObjectId, ref: "Book", required: true},
     content: {type: String, required: true},
-    segmentIndex: {type: Number, required: true, index: true},
-    pageNumber: {type: Number, index: true},
+    segmentIndex: {type: Number, required: true},
+    pageNumber: {type: Number},
     wordCount: {type: Number, required: true},
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
bookId: {type: Schema.Types.ObjectId, ref: "Book", required: true, index: true},
content: {type: String, required: true},
segmentIndex: {type: Number, required: true, index: true},
pageNumber: {type: Number, index: true},
wordCount: {type: Number, required: true},
}, {timestamps: true})
// 为常见查询场景创建复合字段索引,上面定义的"index: true"是单字段索引
// 当 Vapi 朗读一本书时,它会按顺序获取段落。而这个复合索引可以让查找即时完成,而不是扫描数据集中的每个数据段。
BookSegmentSchema.index({bookId: 1, segmentIndex: 1}, {unique: true});
BookSegmentSchema.index({bookId: 1, pageNumber: 1});
BookSegmentSchema.index({bookId: 1, content: 'text'});
bookId: {type: Schema.Types.ObjectId, ref: "Book", required: true},
content: {type: String, required: true},
segmentIndex: {type: Number, required: true},
pageNumber: {type: Number},
wordCount: {type: Number, required: true},
}, {timestamps: true})
// 为常见查询场景创建复合字段索引,上面定义的"index: true"是单字段索引
// 当 Vapi 朗读一本书时,它会按顺序获取段落。而这个复合索引可以让查找即时完成,而不是扫描数据集中的每个数据段。
BookSegmentSchema.index({bookId: 1, segmentIndex: 1}, {unique: true});
BookSegmentSchema.index({bookId: 1, pageNumber: 1});
BookSegmentSchema.index({bookId: 1, content: 'text'});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@database/models/book-segment.model.ts` around lines 7 - 18, The single-field
indexes on bookId, segmentIndex and pageNumber are redundant because the
compound indexes BookSegmentSchema.index({bookId:1, segmentIndex:1}) and
BookSegmentSchema.index({bookId:1, pageNumber:1}) already cover those prefixes;
remove the per-field index flags (remove index: true from bookId, segmentIndex
and pageNumber in the schema) so only the compound indexes remain, reducing
write overhead and storage while preserving query plans that use the
bookId-prefixed compound indexes.


// 防止每次 Next.js 开发环境热更时重复创建模型
const BookSegment = models.BookSegment || model("BookSegment", BookSegmentSchema);

export default BookSegment;
22 changes: 22 additions & 0 deletions database/models/book.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { IBook } from "@/type";
import { Schema, models, model } from "mongoose";

// 创建 BookSchema
const BookSchema = new Schema<IBook>({
clerkId: {type: String, required: true},
title: {type: String, required: true},
slug: {type: String, required: true, unique: true, lowercase: true, trim: true},
Comment on lines +6 to +8

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 查看 createBook / checkBookExists 里 slug 的使用语义
rg -nP 'slug' --type=ts -C3 lib/actions

Repository: lijianhua7/bookified

Length of output: 1432


🏁 Script executed:

# View the complete book.model.ts schema definition
cat -n database/models/book.model.ts

Repository: lijianhua7/bookified

Length of output: 984


🏁 Script executed:

# Check the createBook function signature and how clerkId is handled
rg -nA 20 'export.*createBook|function createBook' lib/actions/book.actions.ts

Repository: lijianhua7/bookified

Length of output: 734


🏁 Script executed:

# Search for all Book.findOne/find queries to understand slug usage patterns
rg -nP 'Book\.(findOne|find|findById).*slug' lib/actions --type=ts -A 2 -B 2

Repository: lijianhua7/bookified

Length of output: 636


🏁 Script executed:

# Find CreateBook type definition
rg -nP 'type CreateBook|interface CreateBook' --type=ts -A 10

Repository: lijianhua7/bookified

Length of output: 438


🏁 Script executed:

# Check how createBook is called - is clerkId being passed?
rg -nP 'createBook\(' --type=ts -B 2 -A 2

Repository: lijianhua7/bookified

Length of output: 318


🏁 Script executed:

# Search for update/patch operations on books
rg -nP 'Book\.update|Book\.findByIdAndUpdate|Book\.findOneAndUpdate' lib/actions --type=ts -B 2 -A 5

Repository: lijianhua7/bookified

Length of output: 480


🏁 Script executed:

# Check all Book operations to ensure comprehensive view
rg -nP 'Book\.' lib/actions/book.actions.ts --type=ts | head -20

Repository: lijianhua7/bookified

Length of output: 466


slug 的全局唯一约束与"每用户一份书架"的设计不符。

当前 slug: { unique: true } 强制 slug 在整个数据库中全局唯一。然而,CreateBook 类型定义表明 clerkId 是必填项,代码逻辑实际上在为每个用户存储独立的书籍记录。这导致矛盾:

  • 用户 A 上传标题 "moby-dick" → 存储为 slug="moby-dick",clerkId=user_A
  • 用户 B 尝试上传相同标题 → 因为 slug 全局唯一约束,创建失败

代码在 lib/actions/book.actions.ts 第 64 行进行存在性检查时,仅查询 Book.findOne({ slug }),未包含 clerkId,这进一步确认了问题。

建议按用户隔离 slug 唯一性:

♻️ 建议修改
-    slug: {type: String, required: true, unique: true, lowercase: true, trim: true},
+    slug: {type: String, required: true, lowercase: true, trim: true},
...
 }, {timestamps: true})
+
+// 每个用户下 slug 唯一
+BookSchema.index({ clerkId: 1, slug: 1 }, { unique: true });

同时建议更新查询逻辑为 Book.findOne({ clerkId: data.clerkId, slug }) 以确保用户隔离。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@database/models/book.model.ts` around lines 6 - 8, The schema currently
enforces slug: { unique: true } which conflicts with per-user books; remove the
global unique constraint on the slug field in the Book schema (retain
lowercase/trim) and instead add a compound unique index on { clerkId, slug } to
enforce slug-uniqueness per user; then update the existence check in the
Book-creation flow (the Book.findOne lookup used in the create flow) to include
clerkId (use Book.findOne({ clerkId: data.clerkId, slug })) so lookups and
uniqueness align with the per-user design.

author: {type: String, required: true},
persona: {type: String},
fileURL: {type: String, required: true},
fileBlobKey: {type: String, required: true},
coverURL: {type: String},
coverBlobKey: {type: String},
fileSize: {type: Number, required: true},
totalSegments: {type: Number, default: 0},
}, {timestamps: true})

// 防止每次 Next.js 开发环境热更时重复创建模型
const Book = models.Book || model("Book", BookSchema);

export default Book;
Loading