Files
ClassHelper/packages/server/src/modules/imports/imports.module.ts
T
LoveChengke 66be3a63b1 feat(authz): 重写权限模板分配与班级授权边界
拆分能力、分配范围和业务关系,固定管理员能力并收紧组织级写入口。
补齐内容、课表、设备会话和实时通道授权,兼容旧库分配及历史快照。
重写权限分配预览,按实际可操作范围展示维护入口,合并学生入口并独立学年升级页。
增加授权攻击矩阵及静态门禁,更新隔离回归与开发契约。

验证:授权矩阵 23 段通过,Node 24 下 Web 63/63,类型检查、lint 与文档严格构建通过。
2026-10-31 09:40:23 +08:00

232 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { assertPermission } from '../../lib/permissions.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { Router } from 'express';
import { ApiError, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import {
authenticate,
getAuthUser,
requirePermissionOrClassDevice,
requirePermission,
requireAnyPermission,
} from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
IMPORT_KINDS,
tableCommitSchema,
tableFileSchema,
timeLayoutImportSchema,
classPlanImportSchema,
timeLayoutQuerySchema,
type TableCommitInput,
type TableFileInput,
type TimeLayoutImportInput,
type ClassPlanImportInput,
} from './imports.schemas.js';
import { buildTemplateCsv, buildTemplateXlsx, commitTable, previewTable } from './table-import.service.js';
import { importClassPlan, previewClassPlan } from './class-plan.service.js';
import {
deleteTimeLayout,
importTimeLayout,
listTimeLayouts,
previewClassIslandTimeLayout,
} from './time-layout.service.js';
const router = Router();
router.use(authenticate());
/** 导入类型分别校验对应导入键,名单还校验管理键。 */
async function assertImportKindAllowed(
kind: (typeof IMPORT_KINDS)[number],
user: TokenPayload,
): Promise<void> {
const key = {
students: 'import:students',
teachers: 'import:teachers',
grades: 'grade:import',
classTeachers: 'import:class-teachers',
scheduleTimes: 'import:schedule',
} as const;
await assertPermission(user, key[kind]);
if (kind === 'students') await assertPermission(user, 'roster:manage');
if (kind === 'teachers') await assertPermission(user, 'teacher:manage');
}
/** 查询参数里的 kind 收敛为合法值(默认 grades,避免非法值被当成成绩悄悄写入) */
function resolveImportKind(value: unknown): (typeof IMPORT_KINDS)[number] {
return IMPORT_KINDS.includes(value as (typeof IMPORT_KINDS)[number])
? (value as (typeof IMPORT_KINDS)[number])
: 'grades';
}
/**
* GET /api/imports/template?kind=grades|students|teachers&format=csv|xlsx - 下载导入模板
* 成绩模板:管理员或班主任(空白模板,不含任何业务数据);学生/教师名单模板:对应导入与管理键
*/
router.get(
'/template',
requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:class-teachers'),
async (req, res) => {
const kind = resolveImportKind(req.query.kind);
await assertImportKindAllowed(kind, getAuthUser(req));
const format = req.query.format === 'xlsx' ? 'xlsx' : 'csv';
if (format === 'xlsx') {
const base64 = buildTemplateXlsx(kind);
res.setHeader('content-type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('content-disposition', `attachment; filename="template-${kind}.xlsx"`);
res.send(Buffer.from(base64, 'base64'));
return;
}
sendOk(
res,
{ kind, format, fileName: `template-${kind}.csv`, content: buildTemplateCsv(kind) },
'模板生成成功',
);
},
);
/**
* POST /api/imports/table/preview - 上传表格并预览(解析 + 必填列校验 + 建议字段映射)
* 成绩:管理员或班主任;学生/教师名单按对应导入与管理键校验
*/
router.post(
'/table/preview',
requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:class-teachers'),
validate({ body: tableFileSchema }),
async (req, res) => {
const input = validatedBody<TableFileInput>(req);
await assertImportKindAllowed(input.kind, getAuthUser(req));
sendOk(res, previewTable(input), '表格解析成功');
},
);
/**
* POST /api/imports/table/commit - 执行导入(字段映射 + 写入模式 + 结果统计)
*
* 路由放行 ADMIN|TEACHER,真正的权限在 service 内按 kind + 班级二次校验:
* 成绩按班级与科目收口;学生名单按管理键、导入键与班主任/全校范围校验。
*/
router.post(
'/table/commit',
requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:class-teachers'),
validate({ body: tableCommitSchema }),
async (req, res) => {
const input = validatedBody<TableCommitInput>(req);
await assertImportKindAllowed(input.kind, getAuthUser(req));
// 教师名单与班级无关;成绩/学生名单必须带班级,否则拒绝(避免写到"全局")
if (input.kind === 'grades' && !input.classId) {
throw ApiError.badRequest('请先选择要导入的班级');
}
const result = await commitTable(getAuthUser(req), input);
sendOk(
res,
result,
`导入完成:新增 ${result.inserted} / 更新 ${result.updated} / 跳过 ${result.skipped} / 失败 ${result.failed}`,
);
},
);
/**
* POST /api/imports/time-layout/preview - 预览 ClassIsland 课表时间 JSON(只解析不写库)
* 权限与"课表管理"一致:管理员或本班班主任
*/
router.post(
'/time-layout/preview',
requirePermission('import:schedule', 'schedule:write'),
validate({ body: timeLayoutImportSchema }),
async (req, res) => {
const input = validatedBody<TimeLayoutImportInput>(req);
// 预览也要校验班级权限(与导入一致),避免越权探测
const { assertCanImportSchedule } = await import('../../lib/access.js');
await assertCanImportSchedule(getAuthUser(req), input.classId);
sendOk(res, previewClassIslandTimeLayout(input.payload), '解析完成');
},
);
/** GET /api/imports/time-layout?classId= - 查看该班已导入的时间配置 */
router.get(
'/time-layout',
requirePermissionOrClassDevice('schedule:read'),
validate({ query: timeLayoutQuerySchema }),
async (req, res) => {
const { classId } = validatedQuery<{ classId: string }>(req);
sendOk(res, await listTimeLayouts(getAuthUser(req), classId), '获取时间配置成功');
},
);
/**
* POST /api/imports/time-layout - 导入 ClassIsland 时间配置
* mode=replace 覆盖 / merge 按开始时间合并;解析失败返回 400 且不修改原配置。
*/
router.post(
'/time-layout',
requirePermission('schedule:write', 'import:schedule'),
validate({ body: timeLayoutImportSchema }),
async (req, res) => {
const result = await importTimeLayout(getAuthUser(req), validatedBody<TimeLayoutImportInput>(req));
sendOk(
res,
result,
result.mode === 'merge'
? `合并导入成功:合并 ${result.merged} 节 / 新增 ${result.replaced} 节`
: `覆盖导入成功:共 ${result.replaced} 节`,
);
},
);
/** DELETE /api/imports/time-layout/:id - 删除某份时间配置 */
router.delete(
'/time-layout/:id',
requirePermission('import:schedule', 'schedule:write'),
validate({ params: idParamSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
await deleteTimeLayout(getAuthUser(req), id);
sendOk(res, { id }, '时间配置已删除');
},
);
/**
* POST /api/imports/class-plan/preview - 预览 ClassIsland 课程表 JSON(只解析不写库)
* 真实档案里单双周由 ClassPlan.TimeRule.WeekCountDiv / WeekCountDivTotal 表达。
*/
router.post(
'/class-plan/preview',
requirePermission('import:schedule', 'schedule:write'),
validate({ body: classPlanImportSchema }),
async (req, res) => {
const result = await previewClassPlan(getAuthUser(req), validatedBody<ClassPlanImportInput>(req));
sendOk(
res,
result,
`解析出 ${result.entries.length} 节课(${result.subjects.length} 个科目,缺 ${result.missingSubjects.length} 个待建课程)`,
);
},
);
/**
* POST /api/imports/class-plan - 导入 ClassIsland 课程表(支持单双周)
* replace 清空后写入 / merge 按 星期+开始时间+单双周 去重;解析失败返回 400 且不改动现有课表。
*/
router.post(
'/class-plan',
requirePermission('import:schedule', 'schedule:write'),
validate({ body: classPlanImportSchema }),
async (req, res) => {
const result = await importClassPlan(getAuthUser(req), validatedBody<ClassPlanImportInput>(req));
sendOk(
res,
result,
`导入成功:新增 ${result.created} 节 / 更新 ${result.updated} 节` +
`${result.createdCourses.length > 0 ? `(自动补建课程:${result.createdCourses.join('、')})` : ''}`,
);
},
);
export const importsModule = defineModule({
name: 'imports',
basePath: '/imports',
router,
});