feat: 班级小助手 阶段1-3(monorepo + 后端 REST/Socket.IO + Web 管理端)
This commit is contained in:
commit
84ab49de10
102 files changed
+14751
No files matched your search
@@ -0,0 +1,80 @@
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
|
||||
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
createNotificationSchema,
|
||||
listNotificationsQuerySchema,
|
||||
notificationScopeQuerySchema,
|
||||
type CreateNotificationInput,
|
||||
} from './notifications.schemas.js';
|
||||
import * as notificationService from './notifications.service.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate());
|
||||
|
||||
/** GET /api/notifications?classId=&priority=&unreadOnly= - 通知列表 */
|
||||
router.get('/', validate({ query: listNotificationsQuerySchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const query = validatedQuery<{
|
||||
classId?: string;
|
||||
priority?: string;
|
||||
unreadOnly: boolean;
|
||||
keyword?: string;
|
||||
}>(req);
|
||||
sendOk(res, await notificationService.listNotifications(user, query), '获取通知列表成功');
|
||||
});
|
||||
|
||||
/** GET /api/notifications/unread-count?classId= - 未读数(客户端红点) */
|
||||
router.get('/unread-count', validate({ query: notificationScopeQuerySchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { classId } = validatedQuery<{ classId?: string }>(req);
|
||||
sendOk(res, { count: await notificationService.unreadCount(user, classId) }, '获取未读数成功');
|
||||
});
|
||||
|
||||
/** POST /api/notifications/read-all - 全部标为已读 */
|
||||
router.post('/read-all', validate({ body: notificationScopeQuerySchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { classId } = (req.body ?? {}) as { classId?: string };
|
||||
sendOk(res, await notificationService.markAllAsRead(user, classId), '已全部标为已读');
|
||||
});
|
||||
|
||||
/** POST /api/notifications - 发布通知(发布后广播 notification:new) */
|
||||
router.post(
|
||||
'/',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ body: createNotificationSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const dto = await notificationService.createNotification(user, req.body as CreateNotificationInput);
|
||||
sendCreated(res, dto, '通知发布成功,已实时推送给学生');
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/notifications/:id/read - 标记已读 */
|
||||
router.post('/:id/read', validate({ params: idParamSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
sendOk(res, await notificationService.markAsRead(user, id), '已标记为已读');
|
||||
});
|
||||
|
||||
/** DELETE /api/notifications/:id - 删除通知 */
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
await notificationService.deleteNotification(user, id);
|
||||
sendOk(res, { id }, '通知已删除');
|
||||
},
|
||||
);
|
||||
|
||||
export const notificationsModule = defineModule({
|
||||
name: 'notifications',
|
||||
basePath: '/notifications',
|
||||
router,
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { z } from 'zod';
|
||||
import { booleanFlagSchema } from '../../lib/schemas.js';
|
||||
|
||||
export const notificationPrioritySchema = z.enum(['LOW', 'NORMAL', 'HIGH', 'URGENT']);
|
||||
|
||||
export const listNotificationsQuerySchema = z.object({
|
||||
classId: z.string().min(1).optional(),
|
||||
priority: notificationPrioritySchema.optional(),
|
||||
unreadOnly: booleanFlagSchema,
|
||||
keyword: z.string().trim().max(64).optional(),
|
||||
});
|
||||
|
||||
export const createNotificationSchema = z.object({
|
||||
classId: z.string().min(1, '请选择班级'),
|
||||
title: z.string().trim().min(1, '请输入通知标题').max(120),
|
||||
content: z.string().min(1, '请输入通知内容').max(5000),
|
||||
priority: notificationPrioritySchema.optional(),
|
||||
});
|
||||
|
||||
export const notificationScopeQuerySchema = z.object({
|
||||
classId: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export type CreateNotificationInput = z.infer<typeof createNotificationSchema>;
|
||||
export type NotificationPriorityInput = z.infer<typeof notificationPrioritySchema>;
|
||||
@@ -0,0 +1,125 @@
|
||||
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
|
||||
import {
|
||||
assertClassAccess,
|
||||
assertClassWritable,
|
||||
classScopeWhere,
|
||||
resolveClassScope,
|
||||
} from '../../lib/access.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { toNotificationDto } from '../../lib/mappers.js';
|
||||
import { emitToClass } from '../../realtime/bus.js';
|
||||
import type { CreateNotificationInput } from './notifications.schemas.js';
|
||||
|
||||
const creatorSelect = { select: { id: true, name: true, username: true } } as const;
|
||||
|
||||
export interface ListNotificationOptions {
|
||||
classId?: string;
|
||||
priority?: string;
|
||||
unreadOnly?: boolean;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
export async function listNotifications(
|
||||
user: TokenPayload,
|
||||
options: ListNotificationOptions,
|
||||
): Promise<NotificationDto[]> {
|
||||
const scope = await resolveClassScope(user, options.classId);
|
||||
const isStaff = user.role === 'ADMIN' || user.role === 'TEACHER';
|
||||
|
||||
const notifications = await prisma.notification.findMany({
|
||||
where: {
|
||||
...classScopeWhere(scope),
|
||||
...(options.priority ? { priority: options.priority } : {}),
|
||||
...(options.keyword
|
||||
? { OR: [{ title: { contains: options.keyword } }, { content: { contains: options.keyword } }] }
|
||||
: {}),
|
||||
...(options.unreadOnly ? { reads: { none: { userId: user.sub } } } : {}),
|
||||
},
|
||||
include: {
|
||||
creator: creatorSelect,
|
||||
reads: isStaff ? true : { where: { userId: user.sub } },
|
||||
},
|
||||
orderBy: [{ createdAt: 'desc' }],
|
||||
take: 200,
|
||||
});
|
||||
|
||||
return notifications.map((item) => toNotificationDto(item, { userId: user.sub, withStatus: isStaff }));
|
||||
}
|
||||
|
||||
/** 发布通知并实时广播到 class:{classId} 房间 */
|
||||
export async function createNotification(
|
||||
user: TokenPayload,
|
||||
input: CreateNotificationInput,
|
||||
): Promise<NotificationDto> {
|
||||
await assertClassWritable(user, input.classId);
|
||||
|
||||
const created = await prisma.notification.create({
|
||||
data: {
|
||||
classId: input.classId,
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
priority: input.priority ?? 'NORMAL',
|
||||
createdBy: user.sub,
|
||||
},
|
||||
include: { creator: creatorSelect, reads: true },
|
||||
});
|
||||
|
||||
const dto = toNotificationDto(created, { userId: user.sub, withStatus: true });
|
||||
emitToClass(created.classId, SOCKET_EVENTS.notificationNew, dto);
|
||||
return dto;
|
||||
}
|
||||
|
||||
export async function deleteNotification(user: TokenPayload, notificationId: string): Promise<void> {
|
||||
const current = await prisma.notification.findUnique({ where: { id: notificationId } });
|
||||
if (!current) throw ApiError.notFound('通知不存在');
|
||||
await assertClassWritable(user, current.classId);
|
||||
await prisma.notification.delete({ where: { id: notificationId } });
|
||||
}
|
||||
|
||||
/** 标记单条通知已读 */
|
||||
export async function markAsRead(
|
||||
user: TokenPayload,
|
||||
notificationId: string,
|
||||
): Promise<{ notificationId: string; readAt: string }> {
|
||||
const notification = await prisma.notification.findUnique({ where: { id: notificationId } });
|
||||
if (!notification) throw ApiError.notFound('通知不存在');
|
||||
await assertClassAccess(user, notification.classId);
|
||||
|
||||
const read = await prisma.notificationRead.upsert({
|
||||
where: { notificationId_userId: { notificationId, userId: user.sub } },
|
||||
create: { notificationId, userId: user.sub },
|
||||
update: {},
|
||||
});
|
||||
|
||||
return { notificationId, readAt: read.readAt.toISOString() };
|
||||
}
|
||||
|
||||
/** 全部标为已读(可按班级限定) */
|
||||
export async function markAllAsRead(user: TokenPayload, classId?: string): Promise<{ marked: number }> {
|
||||
const scope = await resolveClassScope(user, classId);
|
||||
|
||||
const pending = await prisma.notification.findMany({
|
||||
where: { ...classScopeWhere(scope), reads: { none: { userId: user.sub } } },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
for (const item of pending) {
|
||||
await prisma.notificationRead.upsert({
|
||||
where: { notificationId_userId: { notificationId: item.id, userId: user.sub } },
|
||||
create: { notificationId: item.id, userId: user.sub },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
return { marked: pending.length };
|
||||
}
|
||||
|
||||
/** 未读数量(客户端红点) */
|
||||
export async function unreadCount(user: TokenPayload, classId?: string): Promise<number> {
|
||||
const scope = await resolveClassScope(user, classId);
|
||||
return prisma.notification.count({
|
||||
where: { ...classScopeWhere(scope), reads: { none: { userId: user.sub } } },
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user