-
Notifications
You must be signed in to change notification settings - Fork 0
Database setup #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 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.tsxRepository: lijianhua7/bookified Length of output: 4923 🌐 Web query:
💡 Result: The handleUpload function from Citations:
🏁 Script executed: # Check if onUploadCompleted performs any server-side size validation
rg -n -A10 'onUploadCompleted|uploadedPdfBlob|uploadedCoverBlob' app/api/upload/route.tsRepository: lijianhua7/bookified Length of output: 458 在服务端按内容类型分别限制文件大小。 当前上传路由对所有文件类型使用统一的 建议方案: 🤖 Prompt for AI Agents |
||
| }; | ||
| }, | ||
| // 上传完成后的回调 | ||
| 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 }); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -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: [ | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 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:
💡 Result: To delete a blob using Vercel Blob ( Citations:
🏁 Script executed: #!/bin/bash
# 搜索上传、删除、createBook、saveBookSegments 相关逻辑
# 使用正确的语法(--type ts 而不是 tsx)
rg -n '\b(upload|del|deleteBlob|createBook|saveBookSegments)\b' --type tsRepository: 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 -200Repository: 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 tsRepository: lijianhua7/bookified Length of output: 1202 在上传失败时清理已上传的 Blob。 PDF 和封面上传后(第 123-149 行),若 建议:
🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| // 创建图书 | ||
| 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 ( | ||
|
|
||
| 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 } |
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 字段上的单字段索引与下面的复合索引重复,建议删除。
保留这些重复索引会放大写入代价(每次 ♻️ 建议修改- 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| // 防止每次 Next.js 开发环境热更时重复创建模型 | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const BookSegment = models.BookSegment || model("BookSegment", BookSegmentSchema); | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| export default BookSegment; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# 查看 createBook / checkBookExists 里 slug 的使用语义
rg -nP 'slug' --type=ts -C3 lib/actionsRepository: lijianhua7/bookified Length of output: 1432 🏁 Script executed: # View the complete book.model.ts schema definition
cat -n database/models/book.model.tsRepository: 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.tsRepository: 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 2Repository: lijianhua7/bookified Length of output: 636 🏁 Script executed: # Find CreateBook type definition
rg -nP 'type CreateBook|interface CreateBook' --type=ts -A 10Repository: 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 2Repository: 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 5Repository: 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 -20Repository: lijianhua7/bookified Length of output: 466
当前
代码在 建议按用户隔离 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 });同时建议更新查询逻辑为 🤖 Prompt for AI Agents |
||
| 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; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
数据获取失败时用户无感知。
当
getAllBooks()返回success: false(数据库连接异常等)时,页面会静默渲染成"无书"状态,看起来和"空书架"完全一致,既误导用户也让前端难以排障。建议至少:📝 Committable suggestion
🤖 Prompt for AI Agents