评论与评分系统

This commit is contained in:
feie9454 2025-09-19 22:59:34 +08:00
parent c2d5948301
commit 65e483b626
10 changed files with 279 additions and 11 deletions

View File

@ -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"
}
}

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."CircuitModel" ADD COLUMN "brief" TEXT;

View File

@ -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;

View File

@ -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])
}

View File

@ -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,

22
scripts/resetPassword.ts Normal file
View File

@ -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 <username> <newPassword>');
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());

View File

@ -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),
});

View File

@ -20,6 +20,8 @@ export async function basicAuth(req: Request, res: Response, next: NextFunction)
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 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);

View File

@ -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';
@ -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<string, { avgRating: number | null; ratingCount: number }> = {};
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 });
});

View File

@ -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 });
});