评论与评分系统
This commit is contained in:
parent
c2d5948301
commit
65e483b626
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
2
prisma/migrations/20250919131629_add_brief/migration.sql
Normal file
2
prisma/migrations/20250919131629_add_brief/migration.sql
Normal file
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."CircuitModel" ADD COLUMN "brief" TEXT;
|
||||
53
prisma/migrations/20250919141714_add_comment/migration.sql
Normal file
53
prisma/migrations/20250919141714_add_comment/migration.sql
Normal 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;
|
||||
@ -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])
|
||||
}
|
||||
|
||||
@ -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
22
scripts/resetPassword.ts
Normal 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());
|
||||
@ -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),
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 });
|
||||
});
|
||||
|
||||
@ -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 });
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user