评论与评分系统
This commit is contained in:
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
+118
-6
@@ -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<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 });
|
||||
});
|
||||
|
||||
+22
-1
@@ -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 });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user