功能级准入改为「权限键」,`requireRole` 退成默认模板。 权限组 - 权限目录(39 键:键 / 中文名 / 分类 / 默认角色)收口到 shared,路由挂 requirePermission / requireAnyPermission - 有效权限每请求实时算,改完分组立刻生效:ADMIN 全集且 permission:manage、system:manage 不可剥夺; CLASS_DEVICE 空集(不受分组影响);有分组 = 并集(因此也能用来**收回**权限);无分组 = 角色默认模板 - 新增「权限组」页:组增删改、成员分配、生效权限预览 - 班级维度的科目规则(assertCanManageSubjectContent)故意不可被权限组覆盖 客户端设备(ClassHelper 桌面端) - 设备身份是 deviceKey(首次启动生成的 UUID,saveConfig 拒绝改写、退出登录也保留) - 心跳走「DB 记状态 + 客户端定时拉」,不引入新的长连接:30 秒一次,在线窗口 90 秒 - 远控命令 shutdown / restart / logoff / lock:服务端有效期 10 分钟,客户端弹默认选中「取消」的对话框、 60 秒未确认按取消处理,执行后必须回执(重复回执幂等),同一台机器同一条命令只弹一次 - 新增「客户端设备」页:版本一览、一键推送更新、下发远控 客户端安装包由服务端下载并缓存(教室机常常没有外网,浏览器也受 CSP 限制) - POST /api/update/client/fetch 由服务端下载到 data/client-releases/<版本>/ 并登记 sha256 + size - 自动安装三条护栏:没有 sha256 就拒收、必须等到非上课且主窗口隐藏满 5 分钟、冒烟模式永不安装 大模型助手:只解析、不落库 - POST /api/ai/interpret 返回**待确认**的结构化动作(建作业 / 发通知 / 叫人), 真正落库仍调既有端点 —— 权限断言、上课时段紧急通知保护、ClassIsland 推送全部自动复用 - 模型给的 params 用与手动入口**同一份** zod 再校验(模型输出不可信) - API Key 走 AES-256-GCM(密钥由 JWT_SECRET 经 scrypt 派生)加密存 AppSetting,只进不出 - provider=mock 是离线确定性解析,自动化验收一律用它;每次解析写一条 AiAudit(不含密钥) 系统与运维 - 开发者模式(服务端内存缓存开关,热路径不查库;开启后 500 才附 error.stack)、运行诊断(绝不回传密钥) - 开机自启动运行期可改:Windows 改 HKCU\...\Run、Linux systemctl enable/disable, **非安装形态一律返回 supported:false**,不往开发机的注册表里写自启项 - 服务端侧栏折叠只用 CSS 收拢(不重建 DOM),关怀模式只放大字号与控件、不动任何几何令牌 客户端 - 崩溃自愈:渲染进程挂掉 / 超 10 秒无响应先静默重建主窗口(10 分钟最多 3 次), 未捕获异常只在「没在上课、没人看着」时才重启进程;冒烟模式绝不重启(会杀掉验证实例) - 上线补弹离线期间收到的消息只做一次:起点只在「可达 → 不可达」的边界写,恢复时取最近 5 条按时间升序推 发布内容批量 - POST /notifications/bulk、POST /homeworks/bulk:**单班失败不影响整批**,逐班返回结果 - 作业的科目名跨班**按名匹配**已有课程,找不到就整班记失败,绝不自动建课 (自动建课会把创建者写成任课老师,等于越权改任教关系) - 通知与作业同口径按「所属日期」归档,新增 /notifications/days 与 /notifications/creators BREAKING CHANGE: 能不能做某件事不再看角色名,而是看权限键(GET /api/permissions/me)。 未分组的账号按角色默认模板生效,行为与 1.2.0 一致;分组之后生效集合只由组的并集决定。 服务端首次启动会自动应用 1 个数据库迁移(权限组 3 表 / AppSetting / 客户端设备·命令·安装包 / AiAudit / Notification.assignDate / User.careMode,历史通知按本地日期回填),升级前请先备份。
182 lines
7.7 KiB
TypeScript
182 lines
7.7 KiB
TypeScript
import { Router } from 'express';
|
||
import { ApiError, sendOk } from '../../lib/http.js';
|
||
import { idParamSchema } from '../../lib/schemas.js';
|
||
import { authenticate, getAuthUser, 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());
|
||
|
||
/** 名单类导入(学生/教师账号)只有管理员能做;成绩类按班级权限判定 */
|
||
function assertImportKindAllowed(kind: (typeof IMPORT_KINDS)[number], role: string): void {
|
||
if ((kind === 'students' || kind === 'teachers') && role !== 'ADMIN') {
|
||
throw ApiError.forbidden(
|
||
kind === 'teachers' ? '只有管理员可以录入或导入教师账号' : '只有管理员可以导入学生名单',
|
||
);
|
||
}
|
||
}
|
||
|
||
/** 查询参数里的 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:schedule', 'import:class-teachers'), (req, res) => {
|
||
const kind = resolveImportKind(req.query.kind);
|
||
assertImportKindAllowed(kind, getAuthUser(req).role);
|
||
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:schedule', 'import:class-teachers'),
|
||
validate({ body: tableFileSchema }),
|
||
(req, res) => {
|
||
const input = validatedBody<TableFileInput>(req);
|
||
assertImportKindAllowed(input.kind, getAuthUser(req).role);
|
||
sendOk(res, previewTable(input), '表格解析成功');
|
||
},
|
||
);
|
||
|
||
/**
|
||
* POST /api/imports/table/commit - 执行导入(字段映射 + 写入模式 + 结果统计)
|
||
*
|
||
* 路由放行 ADMIN|TEACHER,真正的权限在 service 内按 kind + 班级二次校验:
|
||
* 成绩 → 管理员或本班班主任;学生名单 → 仅管理员;科任老师一律 403(需求 7)。
|
||
*/
|
||
router.post(
|
||
'/table/commit',
|
||
requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:schedule', 'import:class-teachers'),
|
||
validate({ body: tableCommitSchema }),
|
||
async (req, res) => {
|
||
const input = validatedBody<TableCommitInput>(req);
|
||
assertImportKindAllowed(input.kind, getAuthUser(req).role);
|
||
// 教师名单与班级无关;成绩/学生名单必须带班级,否则拒绝(避免写到"全局")
|
||
if (input.kind !== 'teachers' && !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', validate({ body: timeLayoutImportSchema }), async (req, res) => {
|
||
const input = validatedBody<TimeLayoutImportInput>(req);
|
||
// 预览也要校验班级权限(与导入一致),避免越权探测
|
||
const { assertCanManageSchedule } = await import('../../lib/access.js');
|
||
await assertCanManageSchedule(getAuthUser(req), input.classId);
|
||
sendOk(res, previewClassIslandTimeLayout(input.payload), '解析完成');
|
||
});
|
||
|
||
/** GET /api/imports/time-layout?classId= - 查看该班已导入的时间配置 */
|
||
router.get('/time-layout', 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', 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', 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', 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', 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,
|
||
});
|