From 65e483b62660b7d509507869da98717267bb40da Mon Sep 17 00:00:00 2001 From: feie9454 Date: Fri, 19 Sep 2025 22:59:34 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AF=84=E8=AE=BA=E4=B8=8E=E8=AF=84=E5=88=86?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 +- .../20250919131629_add_brief/migration.sql | 2 + .../20250919141714_add_comment/migration.sql | 53 ++++++++ prisma/schema.prisma | 36 +++++ prisma/seed.ts | 2 +- scripts/resetPassword.ts | 22 ++++ src/lib/validators.ts | 19 +++ src/middlewares/basicAuth.ts | 6 +- src/routes/models.ts | 124 +++++++++++++++++- src/routes/users.ts | 23 +++- 10 files changed, 279 insertions(+), 11 deletions(-) create mode 100644 prisma/migrations/20250919131629_add_brief/migration.sql create mode 100644 prisma/migrations/20250919141714_add_comment/migration.sql create mode 100644 scripts/resetPassword.ts diff --git a/package.json b/package.json index 2ae6ac9..e6a2c3b 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", "prisma:studio": "prisma studio", - "db:seed": "prisma db seed" + "db:seed": "prisma db seed", + "user:reset": "bun scripts/resetPassword.ts" } } diff --git a/prisma/migrations/20250919131629_add_brief/migration.sql b/prisma/migrations/20250919131629_add_brief/migration.sql new file mode 100644 index 0000000..cd036d3 --- /dev/null +++ b/prisma/migrations/20250919131629_add_brief/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."CircuitModel" ADD COLUMN "brief" TEXT; diff --git a/prisma/migrations/20250919141714_add_comment/migration.sql b/prisma/migrations/20250919141714_add_comment/migration.sql new file mode 100644 index 0000000..55f7355 --- /dev/null +++ b/prisma/migrations/20250919141714_add_comment/migration.sql @@ -0,0 +1,53 @@ +-- CreateTable +CREATE TABLE "public"."Comment" ( + "id" TEXT NOT NULL, + "modelId" TEXT NOT NULL, + "authorId" TEXT NOT NULL, + "content" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Comment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."Rating" ( + "id" TEXT NOT NULL, + "modelId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "value" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Rating_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Comment_modelId_idx" ON "public"."Comment"("modelId"); + +-- CreateIndex +CREATE INDEX "Comment_authorId_idx" ON "public"."Comment"("authorId"); + +-- CreateIndex +CREATE INDEX "Comment_createdAt_idx" ON "public"."Comment"("createdAt"); + +-- CreateIndex +CREATE INDEX "Rating_modelId_idx" ON "public"."Rating"("modelId"); + +-- CreateIndex +CREATE INDEX "Rating_userId_idx" ON "public"."Rating"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Rating_modelId_userId_key" ON "public"."Rating"("modelId", "userId"); + +-- AddForeignKey +ALTER TABLE "public"."Comment" ADD CONSTRAINT "Comment_modelId_fkey" FOREIGN KEY ("modelId") REFERENCES "public"."CircuitModel"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Comment" ADD CONSTRAINT "Comment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Rating" ADD CONSTRAINT "Rating_modelId_fkey" FOREIGN KEY ("modelId") REFERENCES "public"."CircuitModel"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Rating" ADD CONSTRAINT "Rating_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 14984f2..42079d1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -19,6 +19,8 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt models CircuitModel[] + comments Comment[] + ratings Rating[] } model CircuitModel { @@ -27,12 +29,46 @@ model CircuitModel { author User @relation(fields: [authorId], references: [id], onDelete: Cascade) authorId String desc String // markdown + brief String? // short summary for listings model Json // JSON model data preview Bytes? // binary preview image previewMime String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + comments Comment[] + ratings Rating[] + @@index([authorId]) @@index([createdAt]) } + +model Comment { + id String @id @default(cuid()) + model CircuitModel @relation(fields: [modelId], references: [id], onDelete: Cascade) + modelId String + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) + authorId String + content String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([modelId]) + @@index([authorId]) + @@index([createdAt]) +} + +model Rating { + id String @id @default(cuid()) + model CircuitModel @relation(fields: [modelId], references: [id], onDelete: Cascade) + modelId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + value Int // 1-5 + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([modelId, userId]) + @@index([modelId]) + @@index([userId]) +} diff --git a/prisma/seed.ts b/prisma/seed.ts index ab5e332..dd0be39 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -37,7 +37,7 @@ async function main() { data: { title: s.title, desc: s.desc, - model: s.model as any, + model: s.model, preview: tinyPng, previewMime: 'image/png', authorId: user.id, diff --git a/scripts/resetPassword.ts b/scripts/resetPassword.ts new file mode 100644 index 0000000..3353095 --- /dev/null +++ b/scripts/resetPassword.ts @@ -0,0 +1,22 @@ +#!/usr/bin/env bun +import 'dotenv/config'; +import bcrypt from 'bcryptjs'; +import { prisma } from '../src/lib/prisma'; + +async function main() { + const [username, newPassword] = process.argv.slice(2); + if (!username || !newPassword) { + console.error('Usage: bun scripts/resetPassword.ts '); + process.exit(1); + } + const user = await prisma.user.findUnique({ where: { username } }); + if (!user) { + console.error(`User not found: ${username}`); + process.exit(2); + } + const hash = await bcrypt.hash(newPassword, 10); + await prisma.user.update({ where: { id: user.id }, data: { password: hash } }); + console.log(`Password reset for user: ${username}`); +} + +main().finally(() => prisma.$disconnect()); diff --git a/src/lib/validators.ts b/src/lib/validators.ts index 49cc0c5..2049a0b 100644 --- a/src/lib/validators.ts +++ b/src/lib/validators.ts @@ -13,9 +13,15 @@ export const UpdateProfileSchema = z.object({ avatarMime: z.string().optional(), }); +export const ChangePasswordSchema = z.object({ + currentPassword: z.string().min(6).max(128), + newPassword: z.string().min(6).max(128), +}); + export const CreateModelSchema = z.object({ title: z.string().min(1).max(200), desc: z.string().min(1), + brief: z.string().min(1).max(300).optional(), model: z.any(), previewBase64: z.string().regex(base64Regex, 'Invalid base64').optional(), previewMime: z.string().optional(), @@ -30,3 +36,16 @@ export const ListQuerySchema = z.object({ pageSize: z.coerce.number().int().min(1).max(100).default(20), sort: z.enum(['new', 'old']).default('new'), }); + +export const CreateCommentSchema = z.object({ + content: z.string().min(1).max(2000), +}); + +export const ListCommentsQuerySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(20), +}); + +export const SetRatingSchema = z.object({ + value: z.coerce.number().int().min(1).max(5), +}); diff --git a/src/middlewares/basicAuth.ts b/src/middlewares/basicAuth.ts index 2b131a8..a32c50f 100644 --- a/src/middlewares/basicAuth.ts +++ b/src/middlewares/basicAuth.ts @@ -18,8 +18,10 @@ export async function basicAuth(req: Request, res: Response, next: NextFunction) } const idx = decoded.indexOf(':'); if (idx === -1) return res.status(400).json({ error: 'Invalid Authorization payload' }); - const username = decoded.slice(0, idx); - const password = decoded.slice(idx + 1); + const username = decoded.slice(0, idx); + const password = decoded.slice(idx + 1); + + const user = await prisma.user.findUnique({ where: { username } }); if (!user) return res.status(401).json({ error: 'Invalid credentials' }); const ok = await bcrypt.compare(password, user.password); diff --git a/src/routes/models.ts b/src/routes/models.ts index 0b3e8e4..e250d56 100644 --- a/src/routes/models.ts +++ b/src/routes/models.ts @@ -1,7 +1,7 @@ import { Router, type Request, type Response } from 'express'; import { prisma } from '../lib/prisma'; import { basicAuth } from '../middlewares/basicAuth'; -import { CreateModelSchema, UpdateModelSchema, ListQuerySchema } from '../lib/validators'; +import { CreateModelSchema, UpdateModelSchema, ListQuerySchema, CreateCommentSchema, ListCommentsQuerySchema, SetRatingSchema } from '../lib/validators'; import { decodeBase64ToBuffer } from '../lib/base64'; import { chromium } from 'playwright'; import { readFile } from 'fs/promises'; @@ -12,7 +12,7 @@ const dirname = new URL('.', import.meta.url).pathname; async function generatePreviewImage(circuitModel: any) { const browser = await chromium.launch(); const page = await browser.newPage(); - + await page.setViewportSize({ width: 1024, height: 1024 }); // Load the lab page await page.goto(`http://localhost:${Number(process.env.PORT ?? 3000)}/lab?preview=true`, { waitUntil: 'load' }); @@ -37,11 +37,12 @@ export const router = Router(); router.post('/', basicAuth, async (req: Request, res: Response) => { const parsed = CreateModelSchema.safeParse(req.body); if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() }); - const { title, desc, model } = parsed.data; + const { title, desc, brief, model } = parsed.data; const created = await prisma.circuitModel.create({ data: { title, desc, + brief, model, preview: await generatePreviewImage(model), previewMime: 'image/png', @@ -59,10 +60,11 @@ router.put('/:id', basicAuth, async (req: Request, res: Response) => { if (owned.authorId !== (req as any).user.id) return res.status(403).json({ error: 'Forbidden' }); const parsed = UpdateModelSchema.safeParse(req.body); if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() }); - const { title, desc, model, previewBase64, previewMime } = parsed.data; + const { title, desc, brief, model, previewBase64, previewMime } = parsed.data; const data: any = {}; if (title !== undefined) data.title = title; if (desc !== undefined) data.desc = desc; + if (brief !== undefined) data.brief = brief; if (model !== undefined) data.model = model; if (previewBase64 !== undefined) { data.preview = decodeBase64ToBuffer(previewBase64); @@ -98,7 +100,17 @@ router.get('/:id', async (req: Request, res: Response) => { }, }); if (!m) return res.status(404).json({ error: 'Not found' }); - res.json(m); + // include rating summary + const ratingSummary = await prisma.rating.aggregate({ + where: { modelId: id }, + _avg: { value: true }, + _count: true, + }); + res.json({ + ...m, + avgRating: ratingSummary._avg.value ?? null, + ratingCount: (ratingSummary._count as number) ?? 0, + }); }); // List models with pagination and simple search @@ -124,9 +136,25 @@ router.get('/', async (req: Request, res: Response) => { authorId: true, createdAt: true, updatedAt: true, + desc: true, + brief: true }, }); - res.json({ page, pageSize, total, items }); + // attach ratings summary per item (batch aggregations) + const ids = items.map(i => i.id); + const ratingAggs = await (prisma as any).rating.groupBy({ + by: ['modelId'], + where: { modelId: { in: ids } }, + _avg: { value: true }, + _count: { _all: true }, + }); + const map: Record = {}; + for (const r of ratingAggs) { + // @ts-ignore dynamic groupBy types + map[r.modelId as string] = { avgRating: (r._avg as any).value ?? null, ratingCount: (r._count as any)._all ?? 0 }; + } + const enriched = items.map((i: any) => ({ ...i, avgRating: map[i.id]?.avgRating ?? null, ratingCount: map[i.id]?.ratingCount ?? 0 })); + res.json({ page, pageSize, total, items: enriched }); }); // Download model JSON @@ -147,3 +175,87 @@ router.get('/:id/preview', async (req: Request, res: Response) => { res.setHeader('Content-Type', m.previewMime ?? 'image/png'); res.end(Buffer.from(m.preview)); }); + +// Comments: list +router.get('/:id/comments', async (req: Request, res: Response) => { + const id = req.params.id; + const parsed = ListCommentsQuerySchema.safeParse(req.query); + if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() }); + const { page, pageSize } = parsed.data; + const where = { modelId: id }; + const total = await (prisma as any).comment.count({ where }); + const items = await (prisma as any).comment.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + select: { id: true, content: true, createdAt: true, updatedAt: true, authorId: true, author: { select: { username: true } } }, + }); + res.json({ page, pageSize, total, items: items.map((i: any) => ({ + id: i.id, + content: i.content, + createdAt: i.createdAt, + updatedAt: i.updatedAt, + authorId: i.authorId, + authorName: (i.author as any)?.username, + })) }); +}); + +// Comments: create +router.post('/:id/comments', basicAuth, async (req: Request, res: Response) => { + const id = req.params.id; + const parsed = CreateCommentSchema.safeParse(req.body); + if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() }); + const { content } = parsed.data; + // ensure model exists + const exists = await prisma.circuitModel.findUnique({ where: { id }, select: { id: true } }); + if (!exists) return res.status(404).json({ error: 'Not found' }); + const created = await (prisma as any).comment.create({ + data: { modelId: id, authorId: (req as any).user.id, content }, + select: { id: true }, + }); + res.status(201).json({ id: created.id }); +}); + +// Comments: delete (author only) +router.delete('/:id/comments/:commentId', basicAuth, async (req: Request, res: Response) => { + const { id, commentId } = req.params; + const c = await (prisma as any).comment.findUnique({ where: { id: commentId }, select: { authorId: true, modelId: true } }); + if (!c || c.modelId !== id) return res.status(404).json({ error: 'Not found' }); + if (c.authorId !== (req as any).user.id) return res.status(403).json({ error: 'Forbidden' }); + await (prisma as any).comment.delete({ where: { id: commentId } }); + res.status(204).end(); +}); + +// Rating: set/update current user's rating +router.post('/:id/rating', basicAuth, async (req: Request, res: Response) => { + const id = req.params.id; + const parsed = SetRatingSchema.safeParse(req.body); + if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() }); + const { value } = parsed.data; + const userId = (req as any).user.id; + // upsert rating + const r = await (prisma as any).rating.upsert({ + where: { modelId_userId: { modelId: id, userId } }, + update: { value }, + create: { modelId: id, userId, value }, + select: { id: true, value: true }, + }); + res.json(r); +}); + +// Rating: get my rating and summary +router.get('/:id/rating', async (req: Request, res: Response) => { + const id = req.params.id; + const summary = await (prisma as any).rating.aggregate({ where: { modelId: id }, _avg: { value: true }, _count: true }); + let my: number | null = null; + res.json({ avgRating: summary._avg.value ?? null, ratingCount: (summary._count as number) ?? 0 }); +}); + +// My rating (authenticated) +router.get('/:id/my-rating', basicAuth, async (req: Request, res: Response) => { + const id = req.params.id; + const userId = (req as any).user.id as string; + const r = await (prisma as any).rating.findUnique({ where: { modelId_userId: { modelId: id, userId } }, select: { value: true } }); + res.json({ myRating: r?.value ?? null }); +}); diff --git a/src/routes/users.ts b/src/routes/users.ts index a23bfb9..67ea1bd 100644 --- a/src/routes/users.ts +++ b/src/routes/users.ts @@ -1,6 +1,6 @@ import { Router, type Request, type Response } from 'express'; import { prisma } from '../lib/prisma'; -import { RegisterSchema, UpdateProfileSchema } from '../lib/validators'; +import { RegisterSchema, UpdateProfileSchema, ChangePasswordSchema } from '../lib/validators'; import bcrypt from 'bcryptjs'; import { basicAuth } from '../middlewares/basicAuth'; import { decodeBase64ToBuffer } from '../lib/base64'; @@ -41,6 +41,20 @@ router.put('/me', basicAuth, async (req: Request, res: Response) => { res.json({ id: user.id, username: user.username, updatedAt: user.updatedAt }); }); +// Change password +router.post('/change-password', basicAuth, async (req: Request, res: Response) => { + const parsed = ChangePasswordSchema.safeParse(req.body); + if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() }); + const { currentPassword, newPassword } = parsed.data; + const user = await prisma.user.findUnique({ where: { id: (req as any).user.id } }); + if (!user) return res.status(404).json({ error: 'User not found' }); + const ok = await bcrypt.compare(currentPassword, user.password); + if (!ok) return res.status(401).json({ error: 'Current password is incorrect' }); + const hash = await bcrypt.hash(newPassword, 10); + await prisma.user.update({ where: { id: user.id }, data: { password: hash } }); + return res.json({ success: true }); +}); + // Get avatar binary router.get('/:id/avatar', async (req: Request, res: Response) => { const u = await prisma.user.findUnique({ where: { id: req.params.id }, select: { avatar: true, avatarMime: true } }); @@ -48,3 +62,10 @@ router.get('/:id/avatar', async (req: Request, res: Response) => { res.setHeader('Content-Type', u.avatarMime ?? 'image/png'); res.end(Buffer.from(u.avatar)); }); + +// Get user by ID +router.get('/:id', async (req: Request, res: Response) => { + const u = await prisma.user.findUnique({ where: { id: req.params.id } }); + if (!u) return res.status(404).json({ error: 'User not found' }); + res.json({ id: u.id, username: u.username, createdAt: u.createdAt, updatedAt: u.updatedAt }); +});