feat(courseware,docs): 完善课件传输与岛编辑并统一全仓文档
- 增加课件上传、班级可见范围、设备补收与传输进度 - 整合自定义灵动岛编辑器和相关客户端交互 - 同步三端契约、数据库迁移、部署配置与隔离回归 - 按源码统一文档与官网说明,移除过时任务书和审查记录 - 保持产品版本为 1.4.0
This commit is contained in:
1 parent
536379036a
commit
c7f215a09c
127 files changed
+4334
-8482
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { COURSEWARE_TRANSFER_TIMEOUT_MS } from '@classhelper/shared';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createServer } from 'node:http';
|
||||
@@ -61,6 +62,8 @@ await refreshDeveloperMode();
|
||||
|
||||
const app = createApp();
|
||||
const httpServer = createServer(app);
|
||||
// 大课件允许较慢的内网上传;请求头仍沿用 Node 的独立超时限制。
|
||||
httpServer.requestTimeout = COURSEWARE_TRANSFER_TIMEOUT_MS;
|
||||
const io = initRealtime(httpServer);
|
||||
|
||||
/** 写入 PID 文件(安装包的 start.cmd / stop.cmd 依赖它精确控制进程) */
|
||||
|
||||
@@ -59,6 +59,10 @@ export const SNAPSHOT_TABLES = [
|
||||
'NotificationRead',
|
||||
'Grade',
|
||||
'ClientDevice',
|
||||
'Courseware',
|
||||
'CoursewareVisibility',
|
||||
'CoursewarePush',
|
||||
'CoursewareReceipt',
|
||||
'ClientCommand',
|
||||
'ClientRelease',
|
||||
'AiAudit',
|
||||
@@ -132,6 +136,8 @@ export function assertValidSnapshot(value: unknown): asserts value is DatabaseSn
|
||||
}
|
||||
for (const table of SNAPSHOT_TABLES) {
|
||||
const rows = (snapshot.data as Record<string, unknown>)[table];
|
||||
// 旧版本快照没有课件表;允许恢复,文件实体仍需单独备份。
|
||||
if (rows === undefined && table.startsWith('Courseware')) continue;
|
||||
if (!Array.isArray(rows)) {
|
||||
throw new Error(`快照缺少表 ${table} 的数据`);
|
||||
}
|
||||
@@ -174,7 +180,7 @@ export async function restoreSnapshot(
|
||||
}
|
||||
}
|
||||
for (const table of SNAPSHOT_TABLES) {
|
||||
const rows = snapshot.data[table].map((row) => {
|
||||
const rows = (snapshot.data[table] ?? []).map((row) => {
|
||||
if (table !== 'Class' || typeof row !== 'object' || row === null) return row;
|
||||
const copy = { ...(row as Record<string, unknown>) };
|
||||
delete copy.notificationChannel;
|
||||
|
||||
@@ -22,7 +22,7 @@ router.use(authenticate());
|
||||
*
|
||||
* 权限分成两把:`ai:manage`(设置,默认只有管理员)与 `ai:use`(用一句话办事,默认教师也有)。
|
||||
* **`/interpret` 只解析、绝不落库**:真正发布由前端确认后调既有端点,权限与上课时段保护
|
||||
* 都在那条路径上(AGENTS.md §5 第 5/14 条)。
|
||||
* 都在那条路径上(AGENTS.md 的现行工程约定)。
|
||||
*/
|
||||
router.get('/settings', requirePermission('ai:manage'), async (_req, res) => {
|
||||
sendOk(res, await aiService.getAiSettings(), '获取大模型设置成功');
|
||||
|
||||
@@ -24,7 +24,7 @@ import type { AiInterpretInput, UpdateAiSettingsInput } from './ai.schemas.js';
|
||||
* 设计边界(照 approved 计划):
|
||||
* 1. 模型只输出结构化意图(`{action, params, confidence}`),服务端用**与手动入口同一份 zod**
|
||||
* 二次校验后返回预览;真正落库由前端确认后调既有端点(`POST /homeworks` 等),
|
||||
* 于是权限断言、上课时段紧急通知保护全部自动复用(AGENTS.md §5 第 5/14 条);
|
||||
* 于是权限断言、上课时段紧急通知保护全部自动复用(AGENTS.md 的现行工程约定);
|
||||
* 2. **密钥只进不出**:`AppSetting` 里存的是 `encryptSecret()` 的密文,任何 GET 都不回传;
|
||||
* 3. `provider: 'mock'` 用确定性规则离线解析 —— 自动化验收**绝不访问外网**(§5 第 52 条),
|
||||
* 真实模型走 `provider: 'openai'` 时的 `{baseUrl}/chat/completions`。
|
||||
|
||||
@@ -30,7 +30,7 @@ router.post(
|
||||
*
|
||||
* 归档是**打标记 + 只读**:毕业班级的作业与通知原样留在库里,
|
||||
* 通过 `GET /:id/classes/:classId/content` 只读查看。
|
||||
* 「未毕业而升级的班级不归档」—— 升级走 `POST /api/classes/promote`,只改年级。
|
||||
* 年度升级走 `POST /api/term/promotion/run`,毕业班归档,其余升一级;本模块读取归档结果。
|
||||
*/
|
||||
|
||||
/** GET /api/archives - 届别列表 */
|
||||
|
||||
@@ -27,7 +27,7 @@ import type {
|
||||
/**
|
||||
* ClassHelper 桌面客户端设备的心跳 / 远控命令 / 更新下发。
|
||||
*
|
||||
* 范式(AGENTS.md §5 第 67 条):**DB 记状态 + 客户端定时拉取** ——
|
||||
* 范式(AGENTS.md 的现行工程约定):**DB 记状态 + 客户端定时拉取** ——
|
||||
* 因此本文件里没有任何"推送"动作:所有下发都只是改 `ClientDevice` / `ClientCommand` 的字段,
|
||||
* 由客户端下一次心跳(30 秒内)带走。
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Router } from 'express';
|
||||
import { sendOk, sendCreated } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import {
|
||||
authenticate,
|
||||
getAuthUser,
|
||||
requireRole,
|
||||
requirePermission,
|
||||
requireStaffPermission,
|
||||
} from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedQuery, validatedParams } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import { coursewareDeviceQuery, coursewareScopeSchema, coursewareUploadQuery } from './courseware.schemas.js';
|
||||
import type { CoursewareUploadRequest } from '@classhelper/shared';
|
||||
import * as service from './courseware.service.js';
|
||||
const router = Router();
|
||||
router.use(authenticate());
|
||||
const staff = [requireRole('ADMIN', 'TEACHER'), requirePermission('courseware:write')];
|
||||
router.get('/', requireStaffPermission('courseware:read'), async (req, res) =>
|
||||
sendOk(res, await service.listCourseware(getAuthUser(req))),
|
||||
);
|
||||
router.get('/classes', ...staff, async (req, res) =>
|
||||
sendOk(res, await service.teachingClasses(getAuthUser(req))),
|
||||
);
|
||||
router.get(
|
||||
'/transfers',
|
||||
requireRole('CLASS_DEVICE'),
|
||||
validate({ query: coursewareDeviceQuery }),
|
||||
async (req, res) =>
|
||||
sendOk(
|
||||
res,
|
||||
await service.pendingTransfers(getAuthUser(req), validatedQuery<{ deviceKey: string }>(req).deviceKey),
|
||||
),
|
||||
);
|
||||
router.post(
|
||||
'/transfers/:id/ack',
|
||||
requireRole('CLASS_DEVICE'),
|
||||
validate({ params: idParamSchema, body: coursewareDeviceQuery }),
|
||||
async (req, res) => {
|
||||
await service.acknowledgeTransfer(
|
||||
getAuthUser(req),
|
||||
validatedParams<{ id: string }>(req).id,
|
||||
validatedBody<{ deviceKey: string }>(req).deviceKey,
|
||||
);
|
||||
sendOk(res, null);
|
||||
},
|
||||
);
|
||||
router.post('/upload', ...staff, validate({ query: coursewareUploadQuery }), async (req, res) =>
|
||||
sendCreated(
|
||||
res,
|
||||
await service.uploadCourseware(
|
||||
getAuthUser(req),
|
||||
validatedQuery<{ metadata: CoursewareUploadRequest }>(req).metadata,
|
||||
req,
|
||||
),
|
||||
),
|
||||
);
|
||||
router.get(
|
||||
'/:id/download',
|
||||
requireStaffPermission('courseware:read'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res, next) => {
|
||||
const row = await service.readable(getAuthUser(req), validatedParams<{ id: string }>(req).id);
|
||||
res.setHeader('Cache-Control', 'private, no-store');
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
res.download(row.storagePath, row.name, { root: service.coursewareRoot, dotfiles: 'allow' }, (error) => {
|
||||
if (error && !res.headersSent) next(error);
|
||||
});
|
||||
},
|
||||
);
|
||||
router.patch(
|
||||
'/:id/visibility',
|
||||
...staff,
|
||||
validate({ params: idParamSchema, body: coursewareScopeSchema }),
|
||||
async (req, res) =>
|
||||
sendOk(
|
||||
res,
|
||||
await service.setVisibility(
|
||||
getAuthUser(req),
|
||||
validatedParams<{ id: string }>(req).id,
|
||||
validatedBody<{ classIds: string[] }>(req).classIds,
|
||||
),
|
||||
),
|
||||
);
|
||||
router.post(
|
||||
'/:id/push',
|
||||
...staff,
|
||||
validate({ params: idParamSchema, body: coursewareScopeSchema }),
|
||||
async (req, res) => {
|
||||
await service.pushCourseware(
|
||||
getAuthUser(req),
|
||||
validatedParams<{ id: string }>(req).id,
|
||||
validatedBody<{ classIds: string[] }>(req).classIds,
|
||||
);
|
||||
sendOk(res, null);
|
||||
},
|
||||
);
|
||||
router.delete('/:id', ...staff, validate({ params: idParamSchema }), async (req, res) => {
|
||||
await service.deleteCourseware(getAuthUser(req), validatedParams<{ id: string }>(req).id);
|
||||
sendOk(res, null);
|
||||
});
|
||||
router.get(
|
||||
'/:id',
|
||||
requireStaffPermission('courseware:read'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) =>
|
||||
sendOk(res, await service.getCourseware(getAuthUser(req), validatedParams<{ id: string }>(req).id)),
|
||||
);
|
||||
export const coursewareModule = defineModule({ name: 'courseware', basePath: '/courseware', router });
|
||||
@@ -0,0 +1,33 @@
|
||||
import { z } from 'zod';
|
||||
import { COURSEWARE_MAX_BYTES } from '@classhelper/shared';
|
||||
|
||||
export const coursewareScopeSchema = z.object({ classIds: z.array(z.string().min(1).max(100)).max(100) });
|
||||
export const coursewareUploadSchema = coursewareScopeSchema
|
||||
.extend({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(180)
|
||||
.refine(
|
||||
(name) => !/[<>:"/\\|?*\u0000-\u001f]/.test(name) && !name.endsWith('.') && !name.endsWith(' '),
|
||||
'文件名含无效字符',
|
||||
),
|
||||
size: z.number().int().min(1).max(COURSEWARE_MAX_BYTES),
|
||||
mode: z.enum(['cloud', 'push']),
|
||||
})
|
||||
.refine((input) => input.mode !== 'push' || input.classIds.length > 0, '推送时请选择授课班级');
|
||||
export const coursewareUploadQuery = z.object({
|
||||
metadata: z
|
||||
.string()
|
||||
.transform((value, ctx): unknown => {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
ctx.addIssue({ code: 'custom', message: '上传信息格式错误' });
|
||||
return z.NEVER;
|
||||
}
|
||||
})
|
||||
.pipe(coursewareUploadSchema),
|
||||
});
|
||||
export const coursewareDeviceQuery = z.object({ deviceKey: z.string().min(1).max(100) });
|
||||
@@ -0,0 +1,228 @@
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { randomUUID, createHash } from 'node:crypto';
|
||||
import { Transform } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import type { Request } from 'express';
|
||||
import {
|
||||
COURSEWARE_MAX_BYTES,
|
||||
coursewareCategory,
|
||||
dayKeyLocal,
|
||||
SOCKET_EVENTS,
|
||||
type CoursewareDto,
|
||||
type CoursewareUploadRequest,
|
||||
} from '@classhelper/shared';
|
||||
import { env } from '../../config/env.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import { assertCanManageSubjectContent, requireClassDeviceClassId } from '../../lib/access.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import type { Prisma } from '../../generated/prisma/client.js';
|
||||
import { emitToClass } from '../../realtime/bus.js';
|
||||
|
||||
export const coursewareRoot = path.resolve(
|
||||
process.env.COURSEWARE_DIR || path.join(env.serverRoot, '..', 'data', 'courseware'),
|
||||
);
|
||||
const include = {
|
||||
owner: { select: { id: true, name: true } },
|
||||
visibility: { include: { class: { select: { id: true, name: true } } } },
|
||||
} as const;
|
||||
type RecordWithRelations = Prisma.CoursewareGetPayload<{ include: typeof include }>;
|
||||
function dto(row: RecordWithRelations): CoursewareDto {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
size: row.size,
|
||||
category: row.category as CoursewareDto['category'],
|
||||
extension: row.extension,
|
||||
archiveDate: row.archiveDate,
|
||||
sha256: row.sha256,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
teacher: row.owner,
|
||||
classes: row.visibility.map((item) => item.class),
|
||||
};
|
||||
}
|
||||
export async function teachingClasses(user: TokenPayload) {
|
||||
return prisma.class.findMany({
|
||||
where: { archivedYearId: null, courses: { some: { teacherId: user.sub } } },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
async function validateClasses(user: TokenPayload, ids: string[]) {
|
||||
const classIds = [...new Set(ids)];
|
||||
const allowed = new Set((await teachingClasses(user)).map((item) => item.id));
|
||||
if (classIds.some((id) => !allowed.has(id))) throw ApiError.forbidden('只能选择自己任教的在读班级');
|
||||
for (const classId of classIds) {
|
||||
const course = await prisma.course.findFirst({
|
||||
where: { classId, teacherId: user.sub },
|
||||
select: { id: true },
|
||||
});
|
||||
await assertCanManageSubjectContent(user, classId, course?.id, '发布课件');
|
||||
}
|
||||
return classIds;
|
||||
}
|
||||
export async function readable(user: TokenPayload, id: string) {
|
||||
const row = await prisma.courseware.findUnique({ where: { id }, include });
|
||||
if (!row) throw ApiError.notFound('课件不存在');
|
||||
if (user.role === 'CLASS_DEVICE') {
|
||||
if (!row.visibility.some((item) => item.classId === requireClassDeviceClassId(user)))
|
||||
throw ApiError.forbidden('课件不在本班可见范围内');
|
||||
} else if (row.ownerId !== user.sub && user.role !== 'ADMIN')
|
||||
throw ApiError.forbidden('只能访问自己的课件');
|
||||
return row;
|
||||
}
|
||||
async function owned(user: TokenPayload, id: string) {
|
||||
const row = await readable(user, id);
|
||||
if (row.ownerId !== user.sub) throw ApiError.forbidden('只能修改自己的课件');
|
||||
return row;
|
||||
}
|
||||
export async function getCourseware(user: TokenPayload, id: string) {
|
||||
const item = dto(await readable(user, id));
|
||||
if (user.role === 'CLASS_DEVICE')
|
||||
item.classes = item.classes.filter((c) => c.id === requireClassDeviceClassId(user));
|
||||
return item;
|
||||
}
|
||||
export async function listCourseware(user: TokenPayload) {
|
||||
const classId = user.role === 'CLASS_DEVICE' ? requireClassDeviceClassId(user) : null;
|
||||
const rows = await prisma.courseware.findMany({
|
||||
where: classId
|
||||
? { visibility: { some: { classId } } }
|
||||
: user.role === 'ADMIN'
|
||||
? {}
|
||||
: { ownerId: user.sub },
|
||||
include,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
// 班级端只暴露本班的可见关系。
|
||||
return rows.map((row) => {
|
||||
const item = dto(row);
|
||||
if (classId) item.classes = item.classes.filter((c) => c.id === classId);
|
||||
return item;
|
||||
});
|
||||
}
|
||||
export async function uploadCourseware(user: TokenPayload, input: CoursewareUploadRequest, req: Request) {
|
||||
const classIds = await validateClasses(user, input.classIds);
|
||||
if (!req.is('application/octet-stream')) throw ApiError.badRequest('请使用二进制文件上传');
|
||||
if (req.headers['content-length'] && Number(req.headers['content-length']) !== input.size)
|
||||
throw ApiError.badRequest('文件长度与上传信息不符');
|
||||
const category = coursewareCategory(input.name),
|
||||
archiveDate = dayKeyLocal(new Date());
|
||||
const extension = path.extname(input.name).slice(1).toLowerCase();
|
||||
const relative = path.join(archiveDate, category, randomUUID());
|
||||
const file = path.join(coursewareRoot, relative),
|
||||
partial = `${file}.part`;
|
||||
await fsp.mkdir(path.dirname(file), { recursive: true });
|
||||
let bytes = 0;
|
||||
const hash = createHash('sha256');
|
||||
try {
|
||||
await pipeline(
|
||||
req,
|
||||
new Transform({
|
||||
transform(chunk: Buffer, _encoding, callback) {
|
||||
bytes += chunk.length;
|
||||
if (bytes > COURSEWARE_MAX_BYTES || bytes > input.size)
|
||||
return callback(ApiError.badRequest('课件超过上传大小限制'));
|
||||
hash.update(chunk);
|
||||
callback(null, chunk);
|
||||
},
|
||||
}),
|
||||
fs.createWriteStream(partial, { flags: 'wx' }),
|
||||
);
|
||||
if (bytes !== input.size) throw ApiError.badRequest('文件上传不完整,请重试');
|
||||
await fsp.rename(partial, file);
|
||||
// 上传耗时较长,完成后重新验证任课关系。
|
||||
await validateClasses(user, classIds);
|
||||
const row = await prisma.courseware.create({
|
||||
data: {
|
||||
ownerId: user.sub,
|
||||
name: input.name,
|
||||
size: bytes,
|
||||
category,
|
||||
extension,
|
||||
archiveDate,
|
||||
storagePath: relative,
|
||||
sha256: hash.digest('hex'),
|
||||
visibility: { create: classIds.map((classId) => ({ classId })) },
|
||||
pushes: input.mode === 'push' ? { create: classIds.map((classId) => ({ classId })) } : undefined,
|
||||
},
|
||||
include,
|
||||
});
|
||||
for (const classId of classIds) emitToClass(classId, SOCKET_EVENTS.coursewareChanged, { id: row.id });
|
||||
return dto(row);
|
||||
} catch (error) {
|
||||
await Promise.all([fsp.rm(partial, { force: true }), fsp.rm(file, { force: true })]);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
export async function setVisibility(user: TokenPayload, id: string, ids: string[]) {
|
||||
await owned(user, id);
|
||||
const classIds = await validateClasses(user, ids);
|
||||
const old = await prisma.coursewareVisibility.findMany({ where: { coursewareId: id } });
|
||||
const row = await prisma.$transaction(async (tx) => {
|
||||
await tx.coursewarePush.deleteMany({ where: { coursewareId: id, classId: { notIn: classIds } } });
|
||||
await tx.coursewareVisibility.deleteMany({ where: { coursewareId: id } });
|
||||
return tx.courseware.update({
|
||||
where: { id },
|
||||
data: { visibility: { create: classIds.map((classId) => ({ classId })) } },
|
||||
include,
|
||||
});
|
||||
});
|
||||
for (const classId of new Set([...classIds, ...old.map((v) => v.classId)]))
|
||||
emitToClass(classId, SOCKET_EVENTS.coursewareChanged, { id });
|
||||
return dto(row);
|
||||
}
|
||||
export async function pushCourseware(user: TokenPayload, id: string, ids: string[]) {
|
||||
const row = await owned(user, id);
|
||||
const classIds = await validateClasses(user, ids);
|
||||
if (!classIds.length || classIds.some((classId) => !row.visibility.some((v) => v.classId === classId)))
|
||||
throw ApiError.badRequest('推送班级必须在课件可见范围内');
|
||||
await prisma.$transaction(
|
||||
classIds.map((classId) => prisma.coursewarePush.create({ data: { coursewareId: id, classId } })),
|
||||
);
|
||||
for (const classId of classIds) emitToClass(classId, SOCKET_EVENTS.coursewareChanged, { id });
|
||||
}
|
||||
export async function deleteCourseware(user: TokenPayload, id: string) {
|
||||
const row = await owned(user, id);
|
||||
await prisma.courseware.delete({ where: { id } });
|
||||
await fsp.rm(path.join(coursewareRoot, row.storagePath), { force: true });
|
||||
for (const v of row.visibility) emitToClass(v.classId, SOCKET_EVENTS.coursewareChanged, { id });
|
||||
}
|
||||
export async function deviceFor(user: TokenPayload, deviceKey: string) {
|
||||
const classId = requireClassDeviceClassId(user);
|
||||
const device = await prisma.clientDevice.findUnique({ where: { deviceKey } });
|
||||
if (!device || device.classId !== classId) throw ApiError.forbidden('设备未登记或不属于当前班级');
|
||||
return { ...device, classId };
|
||||
}
|
||||
export async function pendingTransfers(user: TokenPayload, deviceKey: string) {
|
||||
const device = await deviceFor(user, deviceKey);
|
||||
const rows = await prisma.coursewarePush.findMany({
|
||||
where: {
|
||||
classId: device.classId,
|
||||
receipts: { none: { deviceId: device.id } },
|
||||
courseware: { visibility: { some: { classId: device.classId } } },
|
||||
},
|
||||
include: { courseware: { include } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 50,
|
||||
});
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
courseware: {
|
||||
...dto(row.courseware),
|
||||
classes: dto(row.courseware).classes.filter((c) => c.id === device.classId),
|
||||
},
|
||||
}));
|
||||
}
|
||||
export async function acknowledgeTransfer(user: TokenPayload, pushId: string, deviceKey: string) {
|
||||
const device = await deviceFor(user, deviceKey);
|
||||
const push = await prisma.coursewarePush.findFirst({ where: { id: pushId, classId: device.classId } });
|
||||
if (!push) throw ApiError.notFound('传输任务不存在');
|
||||
await readable(user, push.coursewareId);
|
||||
await prisma.coursewareReceipt.upsert({
|
||||
where: { pushId_deviceId: { pushId, deviceId: device.id } },
|
||||
create: { pushId, deviceId: device.id },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
@@ -77,7 +77,7 @@ router.post(
|
||||
* POST /api/homeworks/bulk - 一次给多个班级布置同一份作业(逐班结果)
|
||||
*
|
||||
* 与 POST / 的区别:批量发布是 Web 管理端的"多班同发"工作流,因此这里挂 `homework:write`;
|
||||
* 教室机器的班级账号仍走单班录入(POST /,权限在服务层判,见 AGENTS.md §5 第 20 条)。
|
||||
* 教室机器的班级账号仍走单班录入(POST /,权限在服务层判,见 AGENTS.md 的现行工程约定)。
|
||||
* 每个班的科目归属依然由 createHomework 里的 assertCanManageSubjectContent 逐个判定 ——
|
||||
* 批量不是绕过科目门的后门(§5 第 5 条)。
|
||||
*/
|
||||
|
||||
@@ -149,7 +149,7 @@ export async function getHomework(user: TokenPayload, homeworkId: string): Promi
|
||||
* - **选了科目** → 必须是该科的任课老师(班主任不教这一科也发不了);
|
||||
* - **没选科目** → 本班班主任或管理员;
|
||||
* - **ClassHelper 班级端** → 可以录(老师在教室电脑上顺手记一条是常见需求,
|
||||
* 见 AGENTS.md §5 第 20 条),班级端录入的作业归属该班班主任。
|
||||
* 见 AGENTS.md 的现行工程约定),班级端录入的作业归属该班班主任。
|
||||
*/
|
||||
export async function createHomework(user: TokenPayload, input: CreateHomeworkInput): Promise<HomeworkDto> {
|
||||
await assertCanManageSubjectContent(user, input.classId, input.courseId ?? null, '布置作业', {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { coursewareModule } from './courseware/courseware.module.js';
|
||||
import {
|
||||
classPlansModule,
|
||||
timeLayoutsModule,
|
||||
@@ -33,6 +34,7 @@ import type { ApiModule } from './module.types.js';
|
||||
* 临时下线:把 enabled 置为 false。
|
||||
*/
|
||||
export const apiModules: ApiModule[] = [
|
||||
coursewareModule,
|
||||
authModule,
|
||||
aiModule,
|
||||
classesModule,
|
||||
|
||||
@@ -18,11 +18,7 @@ import * as teacherService from './teachers.service.js';
|
||||
const router = Router();
|
||||
router.use(authenticate());
|
||||
|
||||
/**
|
||||
* 教师账号管理:**全部接口仅管理员**。
|
||||
* ClassHelper 班级端主体是班级,但教师账号决定谁是哪班班主任、谁能录课表/成绩,属于系统级配置,
|
||||
* 因此录入/修改/重置密码/删除都只开放给 ADMIN(路由层 + 前端菜单双重收敛)。
|
||||
*/
|
||||
/** 教师列表使用 teacher:read/teacher:manage,写入统一要求 teacher:manage;额外业务约束见 service。 */
|
||||
|
||||
/** GET /api/teachers?keyword= - 教师列表(教师管理页 + 分配协作教师时使用) */
|
||||
router.get(
|
||||
@@ -35,7 +31,7 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/teachers - 录入教师账号(仅管理员) */
|
||||
/** POST /api/teachers - 录入教师账号(需 teacher:manage) */
|
||||
router.post(
|
||||
'/',
|
||||
requirePermission('teacher:manage'),
|
||||
|
||||
@@ -104,11 +104,12 @@ export async function resetTeacherPassword(
|
||||
export async function deleteTeacher(userId: string): Promise<void> {
|
||||
const teacher = await findTeacherOrThrow(userId);
|
||||
|
||||
const [ownedClasses, courses, homeworks, notifications] = await Promise.all([
|
||||
const [ownedClasses, courses, homeworks, notifications, coursewares] = await Promise.all([
|
||||
prisma.class.count({ where: { teacherId: teacher.id } }),
|
||||
prisma.course.count({ where: { teacherId: teacher.id } }),
|
||||
prisma.homework.count({ where: { createdBy: teacher.id } }),
|
||||
prisma.notification.count({ where: { createdBy: teacher.id } }),
|
||||
prisma.courseware.count({ where: { ownerId: teacher.id } }),
|
||||
]);
|
||||
if (ownedClasses > 0) {
|
||||
throw ApiError.conflict(
|
||||
@@ -127,5 +128,7 @@ export async function deleteTeacher(userId: string): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
if (coursewares > 0) throw ApiError.conflict(`「${teacher.name}」还有 ${coursewares} 个课件,请先清理课件再删除账号`);
|
||||
|
||||
await prisma.user.delete({ where: { id: teacher.id } });
|
||||
}
|
||||
Reference in new issue
Block a user