feat(permissions,clients,ai,system,server,web,client,shared)!: 权限组 + 客户端设备远控与自动更新 + 大模型助手 + 系统诊断(1.3.0)
功能级准入改为「权限键」,`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,历史通知按本地日期回填),升级前请先备份。
This commit is contained in:
1 parent
e51879667a
commit
817af02357
131 files changed
+12995
-538
No files matched your search
@@ -7,6 +7,7 @@ import { env } from './config/env.js';
|
||||
import { disconnectPrisma, pingDatabase } from './lib/db.js';
|
||||
import { ensureInitialAdmin, ensureSchema } from './lib/db-bootstrap.js';
|
||||
import { logger } from './lib/logger.js';
|
||||
import { refreshDeveloperMode } from './lib/settings.js';
|
||||
import { resolveWebDistDir } from './lib/web-static.js';
|
||||
import { initRealtime } from './realtime/socket.js';
|
||||
import { startBackupScheduler } from './modules/database/database.service.js';
|
||||
@@ -51,6 +52,9 @@ async function prepareDatabase(): Promise<void> {
|
||||
|
||||
await prepareDatabase();
|
||||
|
||||
// 开发者模式的内存缓存:错误中间件每请求都要问一次,不可能为此查库(见 lib/settings.ts)
|
||||
await refreshDeveloperMode();
|
||||
|
||||
const app = createApp();
|
||||
const httpServer = createServer(app);
|
||||
const io = initRealtime(httpServer);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { BulkPublishClassResult, BulkPublishResult } from '@classhelper/shared';
|
||||
import { ApiError } from './http.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
/**
|
||||
* 批量发布的公共收尾:把单个班级的失败翻译成可直接展示给用户的结果。
|
||||
*
|
||||
* 批量发布是**逐班判定**的 —— 某个班没有权限、或正在上课而通知是紧急的,
|
||||
* 都不该让整批失败:成功的班照常落库并实时推送,失败的班把自己的错误码与中文原因带回来,
|
||||
* 由 Web 端在结果弹窗里逐行列出。因此这里保留 ApiError 的 code/details 原样透传。
|
||||
*/
|
||||
export function toBulkFailure(classId: string, error: unknown, action = '发布'): BulkPublishClassResult {
|
||||
if (error instanceof ApiError) {
|
||||
return {
|
||||
classId,
|
||||
ok: false,
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
...(error.details === undefined ? {} : { details: error.details as Record<string, unknown> }),
|
||||
};
|
||||
}
|
||||
|
||||
logger.error(
|
||||
`批量${action}出现未预期错误:班级=${classId} 原因=${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return { classId, ok: false, code: 'INTERNAL_ERROR', message: `${action}失败,请稍后重试` };
|
||||
}
|
||||
|
||||
/** 汇总逐班结果(succeeded/failed 由结果本身统计,调用方不必自己数) */
|
||||
export function summarizeBulk(results: BulkPublishClassResult[]): BulkPublishResult {
|
||||
const succeeded = results.filter((item) => item.ok).length;
|
||||
return { total: results.length, succeeded, failed: results.length - succeeded, results };
|
||||
}
|
||||
|
||||
/** 批量目标班级:去重并保持调用方给的顺序(多选控件重复提交时会带重复 id) */
|
||||
export function normalizeBulkClassIds(classIds: readonly string[]): string[] {
|
||||
return [...new Set(classIds)];
|
||||
}
|
||||
@@ -19,6 +19,14 @@ export type ApiErrorCode =
|
||||
| 'FORBIDDEN'
|
||||
| 'NOT_FOUND'
|
||||
| 'CONFLICT'
|
||||
/** 课程:该班级已有同名课程(前端应复用 `details.courseId`,而不是另建一门) */
|
||||
| 'COURSE_EXISTS'
|
||||
/** 大模型:功能未开启 / 还没配置服务地址与密钥 */
|
||||
| 'AI_NOT_CONFIGURED'
|
||||
/** 大模型:调用超时或网络不可达 */
|
||||
| 'AI_TIMEOUT'
|
||||
/** 大模型:返回的内容不是可用的结构化动作(已记日志,不把原始回复回传给前端) */
|
||||
| 'AI_INVALID_OUTPUT'
|
||||
| 'TOO_MANY_REQUESTS'
|
||||
/** 上课时间段发布紧急通知需要二次确认(前端据此弹出全屏警告 + 3 秒倒计时) */
|
||||
| 'URGENT_DURING_CLASS'
|
||||
|
||||
@@ -378,6 +378,8 @@ export interface NotificationLike {
|
||||
priority: string;
|
||||
createdBy: string;
|
||||
createdAt: Date;
|
||||
/** 通知所属日期(YYYY-MM-DD);老库可能为空串,DTO 层按 createdAt 本地日期兜底 */
|
||||
assignDate?: string;
|
||||
creator?: TeacherBriefLike | null;
|
||||
reads?: NotificationReadLike[];
|
||||
}
|
||||
@@ -403,6 +405,8 @@ export function toNotificationDto(
|
||||
priority: item.priority as NotificationPriority,
|
||||
createdBy: item.createdBy,
|
||||
createdAt: toIso(item.createdAt),
|
||||
// 老库兜底:assignDate 为空时按 createdAt 的本地日期回退,前端不必处理空值
|
||||
assignDate: item.assignDate || dayKeyLocal(item.createdAt),
|
||||
creator: item.creator ? toTeacherBrief(item.creator) : null,
|
||||
read: own ? true : false,
|
||||
readAt: toIsoOrNull(own?.readAt),
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
PERMISSION_KEYS,
|
||||
PERMISSION_LABELS,
|
||||
ROLE_DEFAULT_PERMISSIONS,
|
||||
defaultPermissionsForRole,
|
||||
type PermissionKey,
|
||||
} from '@classhelper/shared';
|
||||
import { prisma } from './db.js';
|
||||
import type { TokenPayload } from './jwt.js';
|
||||
|
||||
/**
|
||||
* 生效权限集合的**唯一加载入口**(权限组功能的全部运行时逻辑都收在这里)。
|
||||
*
|
||||
* 语义(与 shared 的 `PERMISSION_DEFAULT_ROLES` 一一对应):
|
||||
* - `ADMIN`:全集。另外**永远**保留 {@link ADMIN_UNREMOVABLE_PERMISSIONS},避免管理员
|
||||
* 把自己(或最后一个管理员)锁在权限页之外;
|
||||
* - `CLASS_DEVICE`(ClassHelper 班级端):**空集,且不受权限组影响** —— 班级端的会话主体是
|
||||
* 班级而非账号,它的能力由班级维度断言(`canManageSubjectContent(..., { allowClassDevice })`
|
||||
* 等)决定,给某个班级端"分配权限组"没有意义;
|
||||
* - 其他账号:**有分组**时取这些组的键并集(因此可以比默认更少 ⇒ 也能用来收回权限);
|
||||
* **没有任何分组**时回落角色默认模板(逐条镜像权限组上线前各路由的 `requireRole`)。
|
||||
*
|
||||
* 每次请求实时查询(不缓存):权限改动**立即生效**,代价是每请求多一条 `findMany`
|
||||
* (账号规模很小,这个代价换"改了立刻生效"是值的)。
|
||||
*/
|
||||
export async function loadEffectivePermissions(user: TokenPayload): Promise<Set<PermissionKey>> {
|
||||
const permissions = new Set<PermissionKey>();
|
||||
|
||||
if (user.classSession || user.role === 'CLASS_DEVICE') return permissions;
|
||||
|
||||
if (user.role === 'ADMIN') {
|
||||
for (const key of PERMISSION_KEYS) permissions.add(key);
|
||||
return permissions;
|
||||
}
|
||||
|
||||
const assignments = await prisma.userPermissionGroup.findMany({
|
||||
where: { userId: user.sub },
|
||||
select: { group: { select: { items: { select: { key: true } } } } },
|
||||
});
|
||||
|
||||
if (assignments.length === 0) {
|
||||
for (const key of defaultPermissionsForRole(user.role)) permissions.add(key);
|
||||
return permissions;
|
||||
}
|
||||
|
||||
const known = new Set<string>(PERMISSION_KEYS);
|
||||
for (const assignment of assignments) {
|
||||
for (const item of assignment.group.items) {
|
||||
// 库里可能留着已下线的键(旧版本写入的),这里静默忽略,避免整账号权限异常
|
||||
if (known.has(item.key)) permissions.add(item.key as PermissionKey);
|
||||
}
|
||||
}
|
||||
return permissions;
|
||||
}
|
||||
|
||||
/** 管理员不可被剥夺的权限键(防锁死) */
|
||||
export const ADMIN_UNREMOVABLE_PERMISSIONS: readonly PermissionKey[] = ['permission:manage', 'system:manage'];
|
||||
|
||||
/** 角色默认模板(供权限页展示"没分组时会是什么") */
|
||||
export function roleDefaultPermissions(role: TokenPayload['role']): readonly PermissionKey[] {
|
||||
return ROLE_DEFAULT_PERMISSIONS[role];
|
||||
}
|
||||
|
||||
/** 把权限键翻译成中文提示(403 文案与权限页共用) */
|
||||
export function permissionLabel(key: PermissionKey): string {
|
||||
return PERMISSION_LABELS[key];
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
/**
|
||||
* 全局设置里"密钥类"值的加解密(目前只有大模型的 API Key)。
|
||||
*
|
||||
* 为什么不用明文存 `AppSetting`:那张表会进备份/导出/跨库迁移的快照,
|
||||
* 明文密钥等于把它写进了每一次备份。这里用 **AES-256-GCM**,密钥由 `JWT_SECRET`
|
||||
* 经 scrypt 派生(不需要新的环境变量),密文带 `v1:` 前缀便于将来轮换算法。
|
||||
*
|
||||
* 代价:`JWT_SECRET` 一改,旧密文就解不开了 —— 这是有意的(换密钥本来就该重新填密钥),
|
||||
* `decryptSecret()` 解不开时返回空串,AI 设置页会显示"未配置"。
|
||||
*/
|
||||
const KEY = crypto.scryptSync(env.jwtSecret, 'classhelper-app-setting', 32);
|
||||
|
||||
export function encryptSecret(plain: string): string {
|
||||
if (!plain) return '';
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', KEY, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
|
||||
return `v1:${iv.toString('base64')}:${cipher.getAuthTag().toString('base64')}:${encrypted.toString('base64')}`;
|
||||
}
|
||||
|
||||
export function decryptSecret(payload: string): string {
|
||||
if (!payload.startsWith('v1:')) return '';
|
||||
const [, ivBase64 = '', tagBase64 = '', dataBase64 = ''] = payload.split(':');
|
||||
try {
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', KEY, Buffer.from(ivBase64, 'base64'));
|
||||
decipher.setAuthTag(Buffer.from(tagBase64, 'base64'));
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(dataBase64, 'base64')),
|
||||
decipher.final(),
|
||||
]).toString('utf8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { prisma } from './db.js';
|
||||
|
||||
/**
|
||||
* 全局设置(`AppSetting` 表的唯一读写入口)。
|
||||
*
|
||||
* 值一律存字符串:布尔存 `'true'`/`'false'`,复杂结构存 JSON 文本。
|
||||
* 键名集中在这里,避免各模块手写字符串导致拼错后"设置静默不生效"。
|
||||
*/
|
||||
export const SETTING_KEYS = {
|
||||
/** 服务端开发者模式(仅 ADMIN 可改):错误响应带堆栈、日志降到 debug 级 */
|
||||
developerMode: 'system.developerMode',
|
||||
/** 大模型功能开关与配置(见 modules/ai) */
|
||||
aiEnabled: 'ai.enabled',
|
||||
/** 服务端解析器的实现:openai=真接口 / mock=离线确定性结果(自动化验收用) */
|
||||
aiProvider: 'ai.provider',
|
||||
aiBaseUrl: 'ai.baseUrl',
|
||||
aiModel: 'ai.model',
|
||||
/** 加密后的 API Key(见 lib/secrets.ts;接口只进不出) */
|
||||
aiApiKey: 'ai.apiKey',
|
||||
/** 追加到系统提示词末尾的学校自定义要求(可空) */
|
||||
aiSystemPromptExtra: 'ai.systemPromptExtra',
|
||||
} as const;
|
||||
|
||||
export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS];
|
||||
|
||||
export async function getSetting(key: SettingKey): Promise<string> {
|
||||
const row = await prisma.appSetting.findUnique({ where: { key }, select: { value: true } });
|
||||
return row?.value ?? '';
|
||||
}
|
||||
|
||||
/** 批量读(避免逐键一条 SQL) */
|
||||
export async function getSettings(keys: readonly SettingKey[]): Promise<Record<string, string>> {
|
||||
const rows = await prisma.appSetting.findMany({
|
||||
where: { key: { in: [...keys] } },
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
const map: Record<string, string> = {};
|
||||
for (const key of keys) map[key] = '';
|
||||
for (const row of rows) map[row.key] = row.value;
|
||||
return map;
|
||||
}
|
||||
|
||||
export async function setSetting(key: SettingKey, value: string): Promise<void> {
|
||||
await prisma.appSetting.upsert({
|
||||
where: { key },
|
||||
create: { key, value },
|
||||
update: { value },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getBooleanSetting(key: SettingKey, fallback = false): Promise<boolean> {
|
||||
const raw = await getSetting(key);
|
||||
if (raw === '') return fallback;
|
||||
return raw === 'true' || raw === '1';
|
||||
}
|
||||
|
||||
export async function setBooleanSetting(key: SettingKey, value: boolean): Promise<void> {
|
||||
await setSetting(key, value ? 'true' : 'false');
|
||||
if (key === SETTING_KEYS.developerMode) developerModeCache = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 开发者模式的内存缓存。
|
||||
*
|
||||
* 为什么缓存:错误中间件与日志级别是**热路径**(每个出错请求都要问一次),
|
||||
* 不可能为此每请求查一次 `AppSetting`。缓存由三处维护:启动时 `refreshDeveloperMode()`、
|
||||
* 管理员改设置时(`setBooleanSetting` 顺手更新)、以及需要时可手动刷新。
|
||||
*/
|
||||
let developerModeCache = false;
|
||||
|
||||
export function isDeveloperMode(): boolean {
|
||||
return developerModeCache;
|
||||
}
|
||||
|
||||
export async function refreshDeveloperMode(): Promise<void> {
|
||||
developerModeCache = await getBooleanSetting(SETTING_KEYS.developerMode, false);
|
||||
}
|
||||
@@ -26,9 +26,18 @@ export const SNAPSHOT_VERSION = 1;
|
||||
* `StudentClassTransfer` 存调班与转出历史;`ArchivedYear` 是毕业归档的届别档案。
|
||||
* 历史上那两张 `ClassTeacher`(无科目的协作关系)与 `Enrollment`(与 Student.classId 双写)
|
||||
* 已随 2026-10-06 的重构删除。
|
||||
*
|
||||
* 2026-10-10 新增:权限组(`PermissionGroup` / `PermissionGroupItem` / `UserPermissionGroup`)、
|
||||
* 全局设置 `AppSetting`(大模型配置与开发者模式开关)、客户端设备与远控
|
||||
* (`ClientDevice` / `ClientCommand`)、客户端安装包缓存 `ClientRelease`、大模型审计 `AiAudit`。
|
||||
* **加新表一定同步这里**,否则备份/换库会静默丢表。
|
||||
*/
|
||||
export const SNAPSHOT_TABLES = [
|
||||
'User',
|
||||
'PermissionGroup',
|
||||
'PermissionGroupItem',
|
||||
'UserPermissionGroup',
|
||||
'AppSetting',
|
||||
'ArchivedYear',
|
||||
'Class',
|
||||
'Student',
|
||||
@@ -43,6 +52,10 @@ export const SNAPSHOT_TABLES = [
|
||||
'Grade',
|
||||
'IntegrationDevice',
|
||||
'ClassIslandPush',
|
||||
'ClientDevice',
|
||||
'ClientCommand',
|
||||
'ClientRelease',
|
||||
'AiAudit',
|
||||
] as const;
|
||||
|
||||
export type SnapshotTable = (typeof SNAPSHOT_TABLES)[number];
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import type { Request, RequestHandler } from 'express';
|
||||
import type { UserRole } from '@classhelper/shared';
|
||||
import type { PermissionKey, UserRole } from '@classhelper/shared';
|
||||
import { PERMISSION_LABELS } from '@classhelper/shared';
|
||||
import { prisma } from '../lib/db.js';
|
||||
import { ApiError } from '../lib/http.js';
|
||||
import { loadEffectivePermissions } from '../lib/permissions.js';
|
||||
import { extractBearerToken, verifyToken, type TokenPayload } from '../lib/jwt.js';
|
||||
|
||||
/**
|
||||
* 认证中间件:解析 Bearer token 并回查数据库,
|
||||
* 保证角色 / 班级变更(例如管理员换了班主任)立即生效,不被过期载荷影响。
|
||||
*
|
||||
* 2026-10-10 起**顺带装载生效权限集合**(`req.auth.permissions`):
|
||||
* 角色判据换成权限键判据(`requirePermission`),角色只用来决定"没有分组时的默认模板"。
|
||||
*/
|
||||
export function authenticate(): RequestHandler {
|
||||
return async (req, _res, next) => {
|
||||
@@ -24,18 +29,16 @@ export function authenticate(): RequestHandler {
|
||||
});
|
||||
if (!record) throw ApiError.unauthorized('班级不存在或已被删除');
|
||||
|
||||
req.auth = {
|
||||
token,
|
||||
user: {
|
||||
sub: record.id,
|
||||
username: record.code,
|
||||
name: record.name,
|
||||
role: 'CLASS_DEVICE',
|
||||
classId: record.id,
|
||||
classSession: true,
|
||||
classCode: record.code,
|
||||
},
|
||||
const user: TokenPayload = {
|
||||
sub: record.id,
|
||||
username: record.code,
|
||||
name: record.name,
|
||||
role: 'CLASS_DEVICE',
|
||||
classId: record.id,
|
||||
classSession: true,
|
||||
classCode: record.code,
|
||||
};
|
||||
req.auth = { token, user, permissions: await loadEffectivePermissions(user) };
|
||||
next();
|
||||
return;
|
||||
}
|
||||
@@ -47,16 +50,14 @@ export function authenticate(): RequestHandler {
|
||||
});
|
||||
if (!user) throw ApiError.unauthorized('账号不存在或已被删除');
|
||||
|
||||
req.auth = {
|
||||
token,
|
||||
user: {
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
role: user.role as UserRole,
|
||||
classId: null,
|
||||
},
|
||||
const authUser: TokenPayload = {
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
role: user.role as UserRole,
|
||||
classId: null,
|
||||
};
|
||||
req.auth = { token, user: authUser, permissions: await loadEffectivePermissions(authUser) };
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -64,7 +65,7 @@ export function authenticate(): RequestHandler {
|
||||
};
|
||||
}
|
||||
|
||||
/** 角色守卫 */
|
||||
/** 角色守卫(仍保留:少数场景要按角色而不是按权限键判断,如班级端专属入口) */
|
||||
export function requireRole(...roles: UserRole[]): RequestHandler {
|
||||
return (req, _res, next) => {
|
||||
const user = req.auth?.user;
|
||||
@@ -80,8 +81,64 @@ export function requireRole(...roles: UserRole[]): RequestHandler {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限守卫:要求当前登录主体拥有**全部**列出的权限键。
|
||||
*
|
||||
* 权限集合来自 {@link loadEffectivePermissions}(权限组并集,或角色默认模板)。
|
||||
* 提示文案用中文权限名,用户看到的是"缺少「导入成绩」权限"而不是一堆键名。
|
||||
*/
|
||||
export function requirePermission(...keys: PermissionKey[]): RequestHandler {
|
||||
return (req, _res, next) => {
|
||||
if (!req.auth?.user) {
|
||||
next(ApiError.unauthorized());
|
||||
return;
|
||||
}
|
||||
const permissions = req.auth.permissions ?? new Set<PermissionKey>();
|
||||
const missing = keys.filter((key) => !permissions.has(key));
|
||||
if (missing.length > 0) {
|
||||
const labels = missing.map((key) => `「${PERMISSION_LABELS[key]}」`).join('');
|
||||
next(ApiError.forbidden(`当前账号缺少${labels}权限,请联系管理员在「权限组」里分配`));
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限守卫(任一满足):只要拥有其中**一个**权限键就放行。
|
||||
*
|
||||
* 用在"同一入口服务多种能力"的接口上:如导入模板按 `kind` 区分能力,
|
||||
* 只要有一种导入能力就能取模板(具体哪种 kind 能不能导,由 service 里的 kind 断言决定)。
|
||||
*/
|
||||
export function requireAnyPermission(...keys: PermissionKey[]): RequestHandler {
|
||||
return (req, _res, next) => {
|
||||
if (!req.auth?.user) {
|
||||
next(ApiError.unauthorized());
|
||||
return;
|
||||
}
|
||||
const permissions = req.auth.permissions ?? new Set<PermissionKey>();
|
||||
if (keys.length > 0 && !keys.some((key) => permissions.has(key))) {
|
||||
const labels = keys.map((key) => `「${PERMISSION_LABELS[key]}」`).join('、');
|
||||
next(ApiError.forbidden(`当前账号缺少权限(${labels} 中至少需要一项),请联系管理员在「权限组」里分配`));
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/** 取出当前登录用户,未认证时抛 401 */
|
||||
export function getAuthUser(req: Request): TokenPayload {
|
||||
if (!req.auth?.user) throw ApiError.unauthorized();
|
||||
return req.auth.user;
|
||||
}
|
||||
|
||||
/** 取出当前登录主体的生效权限集合(未认证时抛 401) */
|
||||
export function getAuthPermissions(req: Request): ReadonlySet<PermissionKey> {
|
||||
if (!req.auth?.user) throw ApiError.unauthorized();
|
||||
return req.auth.permissions ?? new Set<PermissionKey>();
|
||||
}
|
||||
|
||||
/** 当前主体是否有某个权限键(页面/服务里做条件分支用,找不到也当无权限) */
|
||||
export function hasAuthPermission(req: Request, key: PermissionKey): boolean {
|
||||
return (req.auth?.permissions ?? new Set<PermissionKey>()).has(key);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { ZodError } from 'zod';
|
||||
import type { ApiErrorResponse } from '@classhelper/shared';
|
||||
import { ApiError } from '../lib/http.js';
|
||||
import { logger } from '../lib/logger.js';
|
||||
import { isDeveloperMode } from '../lib/settings.js';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
function sendError(
|
||||
@@ -36,7 +37,21 @@ export const errorHandler: ErrorRequestHandler = (error, req, res, _next) => {
|
||||
|
||||
if (error instanceof ApiError) {
|
||||
if (error.status >= 500) logger.error(error.message, error.details);
|
||||
sendError(res, error.status, error.message, error.code, error.details);
|
||||
/**
|
||||
* 开发者模式:把 `error.stack` 一并回传,否则"服务端 500"在浏览器里只有一句中文,
|
||||
* 排查时还得去服务器上翻日志。**500 才带**、且只在管理员显式打开开发者模式时生效
|
||||
* (开关见 `modules/system`),普通用户看到的东西一个字段都不多。
|
||||
*/
|
||||
const withStack =
|
||||
isDeveloperMode() && error.status >= 500 && error.stack
|
||||
? {
|
||||
...(error.details && typeof error.details === 'object'
|
||||
? (error.details as Record<string, unknown>)
|
||||
: { details: error.details }),
|
||||
stack: error.stack,
|
||||
}
|
||||
: error.details;
|
||||
sendError(res, error.status, error.message, error.code, withStack);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Router } from 'express';
|
||||
import { API_PATHS } from '@classhelper/shared';
|
||||
import { sendOk } from '../../lib/http.js';
|
||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
aiAuditQuerySchema,
|
||||
aiInterpretSchema,
|
||||
updateAiSettingsSchema,
|
||||
type AiAuditQuery,
|
||||
type AiInterpretInput,
|
||||
type UpdateAiSettingsInput,
|
||||
} from './ai.schemas.js';
|
||||
import * as aiService from './ai.service.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate());
|
||||
|
||||
/**
|
||||
* 大模型助手。
|
||||
*
|
||||
* 权限分成两把:`ai:manage`(设置,默认只有管理员)与 `ai:use`(用一句话办事,默认教师也有)。
|
||||
* **`/interpret` 只解析、绝不落库**:真正发布由前端确认后调既有端点,权限与上课时段保护
|
||||
* 都在那条路径上(AGENTS.md §5 第 5/14 条)。
|
||||
*/
|
||||
router.get('/settings', requirePermission('ai:manage'), async (_req, res) => {
|
||||
sendOk(res, await aiService.getAiSettings(), '获取大模型设置成功');
|
||||
});
|
||||
|
||||
router.put(
|
||||
'/settings',
|
||||
requirePermission('ai:manage'),
|
||||
validate({ body: updateAiSettingsSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const input = validatedBody<UpdateAiSettingsInput>(req);
|
||||
sendOk(res, await aiService.updateAiSettings(user, input), '大模型设置已保存');
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/interpret',
|
||||
requirePermission('ai:use'),
|
||||
validate({ body: aiInterpretSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const input = validatedBody<AiInterpretInput>(req);
|
||||
sendOk(res, await aiService.interpret(user, input), '助手已解析,请确认后发布');
|
||||
},
|
||||
);
|
||||
|
||||
router.get('/audit', requirePermission('ai:manage'), validate({ query: aiAuditQuerySchema }), async (req, res) => {
|
||||
const query = validatedQuery<AiAuditQuery>(req);
|
||||
sendOk(res, await aiService.listAudit(query.limit ?? 50), '获取助手使用记录成功');
|
||||
});
|
||||
|
||||
export const aiModule = defineModule({
|
||||
name: 'ai',
|
||||
basePath: API_PATHS.ai,
|
||||
router,
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/** 大模型服务端实现:openai=真接口 / mock=离线确定性结果(自动化验收用,不联网) */
|
||||
export const AI_PROVIDERS = ['openai', 'mock'] as const;
|
||||
|
||||
/** `PUT /api/ai/settings`:字段都可选;`apiKey` 传字符串=换新、null=清除、不传=保持 */
|
||||
export const updateAiSettingsSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
provider: z.enum(AI_PROVIDERS).optional(),
|
||||
baseUrl: z.string().trim().max(300).optional(),
|
||||
model: z.string().trim().max(120).optional(),
|
||||
apiKey: z.string().trim().max(300).nullable().optional(),
|
||||
systemPromptExtra: z.string().trim().max(1000).optional(),
|
||||
});
|
||||
|
||||
/** `POST /api/ai/interpret`:一句中文原话 */
|
||||
export const aiInterpretSchema = z.object({
|
||||
text: z.string().trim().min(1, '请说一句要做的事').max(500, '一句话最多 500 个字'),
|
||||
classId: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export const aiAuditQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(200).optional(),
|
||||
});
|
||||
|
||||
export type UpdateAiSettingsInput = z.infer<typeof updateAiSettingsSchema>;
|
||||
export type AiInterpretInput = z.infer<typeof aiInterpretSchema>;
|
||||
export type AiAuditQuery = z.infer<typeof aiAuditQuerySchema>;
|
||||
@@ -0,0 +1,367 @@
|
||||
import { dayKeyLocal, type AiActionKind, type AiAuditDto, type AiInterpretResult, type AiSettingsDto } from '@classhelper/shared';
|
||||
import { z } from 'zod';
|
||||
import { getAccessibleClassIds } from '../../lib/access.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { decryptSecret, encryptSecret } from '../../lib/secrets.js';
|
||||
import { SETTING_KEYS, getSetting, getSettings, setSetting } from '../../lib/settings.js';
|
||||
import { createHomeworkSchema } from '../homeworks/homeworks.schemas.js';
|
||||
import { createNotificationSchema } from '../notifications/notifications.schemas.js';
|
||||
import type { AiInterpretInput, UpdateAiSettingsInput } from './ai.schemas.js';
|
||||
|
||||
/**
|
||||
* 大模型助手:**只解析、不落库**。
|
||||
*
|
||||
* 设计边界(照 approved 计划):
|
||||
* 1. 模型只输出结构化意图(`{action, params, confidence}`),服务端用**与手动入口同一份 zod**
|
||||
* 二次校验后返回预览;真正落库由前端确认后调既有端点(`POST /homeworks` 等),
|
||||
* 于是权限断言、上课时段紧急通知保护、ClassIsland 推送全部自动复用(AGENTS.md §5 第 5/14 条);
|
||||
* 2. **密钥只进不出**:`AppSetting` 里存的是 `encryptSecret()` 的密文,任何 GET 都不回传;
|
||||
* 3. `provider: 'mock'` 用确定性规则离线解析 —— 自动化验收**绝不访问外网**(§5 第 52 条),
|
||||
* 真实模型走 `provider: 'openai'` 时的 `{baseUrl}/chat/completions`。
|
||||
*/
|
||||
const DEFAULT_TIMEOUT_MS = 20_000;
|
||||
|
||||
/** 与手动入口同源的参数校验(助手不得放宽任何字段约束) */
|
||||
const homeworkParamsSchema = createHomeworkSchema.pick({
|
||||
classId: true,
|
||||
courseId: true,
|
||||
title: true,
|
||||
content: true,
|
||||
assignDate: true,
|
||||
});
|
||||
const notificationParamsSchema = createNotificationSchema.pick({
|
||||
classId: true,
|
||||
title: true,
|
||||
content: true,
|
||||
priority: true,
|
||||
});
|
||||
const callParamsSchema = z.object({
|
||||
classId: z.string().min(1),
|
||||
studentId: z.string().min(1).optional(),
|
||||
studentName: z.string().trim().max(40).optional(),
|
||||
message: z.string().trim().max(200).optional(),
|
||||
});
|
||||
|
||||
const modelReplySchema = z.object({
|
||||
action: z.enum(['create_homework', 'create_notification', 'call_student', 'unknown']),
|
||||
params: z.record(z.string(), z.unknown()).default({}),
|
||||
confidence: z.number().min(0).max(1).optional(),
|
||||
reason: z.string().max(200).optional(),
|
||||
});
|
||||
|
||||
export async function getAiSettings(): Promise<AiSettingsDto> {
|
||||
const values = await getSettings([
|
||||
SETTING_KEYS.aiEnabled,
|
||||
SETTING_KEYS.aiProvider,
|
||||
SETTING_KEYS.aiBaseUrl,
|
||||
SETTING_KEYS.aiModel,
|
||||
SETTING_KEYS.aiApiKey,
|
||||
SETTING_KEYS.aiSystemPromptExtra,
|
||||
]);
|
||||
const apiKey = decryptSecret(values[SETTING_KEYS.aiApiKey] ?? '');
|
||||
return {
|
||||
enabled: values[SETTING_KEYS.aiEnabled] === 'true',
|
||||
provider: values[SETTING_KEYS.aiProvider] === 'mock' ? 'mock' : 'openai',
|
||||
baseUrl: values[SETTING_KEYS.aiBaseUrl] ?? '',
|
||||
model: values[SETTING_KEYS.aiModel] ?? '',
|
||||
hasApiKey: apiKey.length > 0,
|
||||
keyHint: apiKey.length >= 4 ? apiKey.slice(-4) : '',
|
||||
systemPromptExtra: values[SETTING_KEYS.aiSystemPromptExtra] ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateAiSettings(
|
||||
user: TokenPayload,
|
||||
input: UpdateAiSettingsInput,
|
||||
): Promise<AiSettingsDto> {
|
||||
if (input.enabled !== undefined) await setSetting(SETTING_KEYS.aiEnabled, input.enabled ? 'true' : 'false');
|
||||
if (input.provider !== undefined) await setSetting(SETTING_KEYS.aiProvider, input.provider);
|
||||
if (input.baseUrl !== undefined) await setSetting(SETTING_KEYS.aiBaseUrl, input.baseUrl.trim());
|
||||
if (input.model !== undefined) await setSetting(SETTING_KEYS.aiModel, input.model.trim());
|
||||
if (input.systemPromptExtra !== undefined) {
|
||||
await setSetting(SETTING_KEYS.aiSystemPromptExtra, input.systemPromptExtra.trim());
|
||||
}
|
||||
if (input.apiKey !== undefined) {
|
||||
// 传 null = 清除;传字符串 = 换成新密钥(空串按清除处理)
|
||||
await setSetting(SETTING_KEYS.aiApiKey, input.apiKey ? encryptSecret(input.apiKey.trim()) : '');
|
||||
}
|
||||
logger.info(`大模型设置已更新(操作人=${user.username},密钥${input.apiKey ? '已更新' : '未改'})`);
|
||||
return getAiSettings();
|
||||
}
|
||||
|
||||
interface AssistantContext {
|
||||
classes: { id: string; name: string }[];
|
||||
courses: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
async function resolveContext(user: TokenPayload, classId?: string): Promise<AssistantContext> {
|
||||
const classIds = await getAccessibleClassIds(user);
|
||||
const classes = await prisma.class.findMany({
|
||||
where: { id: { in: classIds } },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: 'asc' },
|
||||
take: 80,
|
||||
});
|
||||
const courses = classId
|
||||
? await prisma.course.findMany({ where: { classId }, select: { id: true, name: true } })
|
||||
: [];
|
||||
return { classes, courses };
|
||||
}
|
||||
|
||||
function buildSystemPrompt(context: AssistantContext, extra: string): string {
|
||||
return [
|
||||
'你是班级管理系统的指令解析器。把老师的一句中文转成严格 JSON,不要输出任何解释。',
|
||||
'可选动作(action):create_homework(布置作业)、create_notification(发通知)、call_student(叫学生来)、unknown(看不懂)。',
|
||||
'输出格式:{"action":"…","params":{…},"confidence":0~1}。',
|
||||
'create_homework 的 params:{classId, courseId?, title, content, assignDate?(YYYY-MM-DD)}。',
|
||||
'create_notification 的 params:{classId, title, content, priority?(LOW|NORMAL|HIGH|URGENT)}。',
|
||||
'call_student 的 params:{classId, studentName, message?}。',
|
||||
`今天日期:${dayKeyLocal(new Date())}。`,
|
||||
`可用班级:${context.classes.map((item) => `${item.name}(${item.id})`).join('、') || '无'}。`,
|
||||
context.courses.length
|
||||
? `候选班级的科目:${context.courses.map((item) => `${item.name}(${item.id})`).join('、')}。`
|
||||
: '未指定班级时 courseId 留空。',
|
||||
'信息不足时把 confidence 压低并在 reason 里说明缺什么;不要编造不存在于上面的班级/科目 id。',
|
||||
extra ? `学校额外要求:${extra}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/** 离线确定性解析(provider=mock):只做关键词判断,供自动化与"没配模型时试跑"用 */
|
||||
function mockInterpret(text: string, context: AssistantContext): z.infer<typeof modelReplySchema> {
|
||||
// 优先用句子里**真正提到的**班级(「给2026级1班布置作业」);都没提到才退回按名称升序的第一个。
|
||||
// (曾经只取第一个,结果本机取到了已归档的 2023级1班,与句子里说的班级无关。)
|
||||
const mentioned = context.classes.find((item) => text.includes(item.name));
|
||||
const classId = mentioned?.id ?? context.classes[0]?.id ?? '';
|
||||
if (!classId) return { action: 'unknown', params: {}, confidence: 0.1, reason: '当前账号没有可发布的班级' };
|
||||
const course = context.courses.find((item) => text.includes(item.name));
|
||||
|
||||
if (/作业|布置|卷子|练习|预习|背诵/.test(text)) {
|
||||
return {
|
||||
action: 'create_homework',
|
||||
params: {
|
||||
classId,
|
||||
courseId: course?.id ?? null,
|
||||
title: text.length <= 40 ? text : `${text.slice(0, 38)}…`,
|
||||
content: text,
|
||||
assignDate: dayKeyLocal(new Date()),
|
||||
},
|
||||
confidence: 0.55,
|
||||
};
|
||||
}
|
||||
if (/通知|告知|提醒|传达/.test(text)) {
|
||||
return {
|
||||
action: 'create_notification',
|
||||
params: {
|
||||
classId,
|
||||
title: text.length <= 40 ? text : `${text.slice(0, 38)}…`,
|
||||
content: text,
|
||||
priority: /紧急|立即|马上/.test(text) ? 'URGENT' : 'NORMAL',
|
||||
},
|
||||
confidence: 0.55,
|
||||
};
|
||||
}
|
||||
if (/叫|找|来一趟|办公室/.test(text)) {
|
||||
const matched = /\S{2,4}(?=同学|来|到)/.exec(text);
|
||||
return {
|
||||
action: 'call_student',
|
||||
params: { classId, studentName: matched?.[0] ?? '', message: text },
|
||||
confidence: 0.4,
|
||||
reason: '离线示例解析只按关键词猜测学生姓名',
|
||||
};
|
||||
}
|
||||
return { action: 'unknown', params: {}, confidence: 0.2, reason: '没听懂要做什么' };
|
||||
}
|
||||
|
||||
async function callOpenAi(
|
||||
input: AiInterpretInput,
|
||||
extension: { baseUrl: string; model: string; apiKey: string; prompt: string },
|
||||
): Promise<z.infer<typeof modelReplySchema>> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${extension.baseUrl.replace(/\/+$/, '')}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${extension.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: extension.model || 'gpt-4o-mini',
|
||||
temperature: 0.2,
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{ role: 'system', content: extension.prompt },
|
||||
{ role: 'user', content: input.text },
|
||||
],
|
||||
}),
|
||||
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new ApiError(504, 'AI_TIMEOUT', `无法连接大模型服务:${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw ApiError.badRequest(`大模型服务返回 HTTP ${response.status}(请检查服务地址、模型名与密钥)`);
|
||||
}
|
||||
|
||||
const body = (await response.json()) as { choices?: { message?: { content?: string } }[] };
|
||||
const content = body.choices?.[0]?.message?.content ?? '';
|
||||
try {
|
||||
return modelReplySchema.parse(JSON.parse(content));
|
||||
} catch {
|
||||
logger.warn(`大模型返回无法解析:${content.slice(0, 200)}`);
|
||||
throw new ApiError(502, 'AI_INVALID_OUTPUT', '大模型返回的内容不是可用的指令,请换一种说法');
|
||||
}
|
||||
}
|
||||
|
||||
/** 把校验通过的参数解析成"给人看的预览"(班级/科目/学生名都换成中文) */
|
||||
async function buildPreview(
|
||||
action: AiActionKind,
|
||||
params: Record<string, unknown>,
|
||||
): Promise<AiInterpretResult['preview']> {
|
||||
const warnings: string[] = [];
|
||||
const classId = typeof params.classId === 'string' ? params.classId : '';
|
||||
const classRecord = classId
|
||||
? await prisma.class.findUnique({ where: { id: classId }, select: { name: true } })
|
||||
: null;
|
||||
|
||||
if (action === 'create_homework') {
|
||||
const courseId = typeof params.courseId === 'string' ? params.courseId : '';
|
||||
const course = courseId
|
||||
? await prisma.course.findUnique({ where: { id: courseId }, select: { name: true } })
|
||||
: null;
|
||||
if (!course) warnings.push('没有指定科目:只有班主任/管理员能发不指定科目的作业');
|
||||
return {
|
||||
summary: `给${classRecord?.name ?? '未知班级'}布置作业:${String(params.title ?? '')}`,
|
||||
className: classRecord?.name ?? null,
|
||||
courseName: course?.name ?? null,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
if (action === 'create_notification') {
|
||||
return {
|
||||
summary: `给${classRecord?.name ?? '未知班级'}发通知:${String(params.title ?? '')}`,
|
||||
className: classRecord?.name ?? null,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
if (action === 'call_student') {
|
||||
const studentName = typeof params.studentName === 'string' ? params.studentName : '';
|
||||
const student = studentName && classId
|
||||
? await prisma.student.findFirst({
|
||||
where: { classId, name: studentName },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
: null;
|
||||
if (!student) warnings.push(studentName ? `没找到学生「${studentName}」` : '没识别出学生姓名');
|
||||
return {
|
||||
summary: `叫${student?.name ?? (studentName || '某位同学')}到老师这里`,
|
||||
className: classRecord?.name ?? null,
|
||||
studentName: student?.name ?? (studentName || null),
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
return { summary: '没听懂要做什么,请换一种说法', warnings: ['助手没有解析出可用动作'] };
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ai/interpret - 解析一句中文,返回**待确认**的结构化动作。
|
||||
* 绝不写业务表;只写一条 `AiAudit`(谁在什么时候让助手做了什么)。
|
||||
*/
|
||||
export async function interpret(user: TokenPayload, input: AiInterpretInput): Promise<AiInterpretResult> {
|
||||
const settings = await getAiSettings();
|
||||
if (!settings.enabled) {
|
||||
throw new ApiError(400, 'AI_NOT_CONFIGURED', '助手功能还没开启,请管理员在「大模型设置」里打开');
|
||||
}
|
||||
if (settings.provider === 'openai' && (!settings.hasApiKey || !settings.baseUrl)) {
|
||||
throw new ApiError(400, 'AI_NOT_CONFIGURED', '还没配置大模型服务地址或密钥,请管理员在「大模型设置」里填写');
|
||||
}
|
||||
|
||||
const context = await resolveContext(user, input.classId);
|
||||
const reply =
|
||||
settings.provider === 'mock'
|
||||
? mockInterpret(input.text, context)
|
||||
: await callOpenAi(input, {
|
||||
baseUrl: settings.baseUrl,
|
||||
model: settings.model,
|
||||
apiKey: decryptSecret(await getSetting(SETTING_KEYS.aiApiKey)),
|
||||
prompt: buildSystemPrompt(context, settings.systemPromptExtra),
|
||||
});
|
||||
|
||||
// 用与手动入口同一份 zod 再校验一次:模型给的东西**不可信**
|
||||
let action: AiActionKind = 'unknown';
|
||||
let params: Record<string, unknown> = {};
|
||||
const warnings: string[] = [];
|
||||
try {
|
||||
if (reply.action === 'create_homework') {
|
||||
const parsed = homeworkParamsSchema.parse({ ...reply.params, classId: reply.params.classId ?? input.classId });
|
||||
action = 'create_homework';
|
||||
params = parsed as unknown as Record<string, unknown>;
|
||||
} else if (reply.action === 'create_notification') {
|
||||
const parsed = notificationParamsSchema.parse({
|
||||
...reply.params,
|
||||
classId: reply.params.classId ?? input.classId,
|
||||
});
|
||||
action = 'create_notification';
|
||||
params = parsed as unknown as Record<string, unknown>;
|
||||
} else if (reply.action === 'call_student') {
|
||||
const parsed = callParamsSchema.parse({ ...reply.params, classId: reply.params.classId ?? input.classId });
|
||||
action = 'call_student';
|
||||
params = parsed as unknown as Record<string, unknown>;
|
||||
}
|
||||
} catch (error) {
|
||||
warnings.push('助手给出的参数不完整,请手动补全后再发布');
|
||||
logger.warn(`助手参数校验失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
const preview = await buildPreview(action, params);
|
||||
preview.warnings = [...preview.warnings, ...warnings];
|
||||
if (settings.provider === 'mock') preview.warnings.push('当前是离线示例解析(未配置真实模型),请仔细核对');
|
||||
|
||||
await prisma.aiAudit.create({
|
||||
data: {
|
||||
userId: user.sub,
|
||||
prompt: input.text,
|
||||
intent: action,
|
||||
status: 'pending',
|
||||
message: preview.summary,
|
||||
rawReply: JSON.stringify(reply).slice(0, 2000),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
action,
|
||||
params,
|
||||
preview,
|
||||
confidence: reply.confidence ?? 0.5,
|
||||
needConfirm: true,
|
||||
rawReply: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** GET /api/ai/audit - 助手使用审计(最近 N 条) */
|
||||
export async function listAudit(limit = 50): Promise<AiAuditDto[]> {
|
||||
const rows = await prisma.aiAudit.findMany({ orderBy: { createdAt: 'desc' }, take: limit });
|
||||
// `AiAudit.userId` 是普通字符串列(schema 里没有建 User 关系),所以姓名另外查一次
|
||||
const userIds = [...new Set(rows.map((row) => row.userId))];
|
||||
const users = userIds.length
|
||||
? await prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true } })
|
||||
: [];
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
userName: users.find((user) => user.id === row.userId)?.name ?? '',
|
||||
prompt: row.prompt,
|
||||
intent: row.intent,
|
||||
status: row.status,
|
||||
message: row.message,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
@@ -25,13 +25,18 @@ router.use(authenticate());
|
||||
*/
|
||||
|
||||
/** GET /api/archives - 届别列表 */
|
||||
router.get('/', requireRole('ADMIN'), validate({ query: listArchivesQuerySchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
sendOk(res, await archiveService.listArchivedYears(user), '获取届别列表成功');
|
||||
});
|
||||
router.get(
|
||||
'/',
|
||||
requirePermission('archive:manage'),
|
||||
validate({ query: listArchivesQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
sendOk(res, await archiveService.listArchivedYears(user), '获取届别列表成功');
|
||||
},
|
||||
);
|
||||
|
||||
/** GET /api/archives/summary - 归档页顶部汇总 + 可归档的届别建议 */
|
||||
router.get('/summary', requireRole('ADMIN'), async (req, res) => {
|
||||
router.get('/summary', requirePermission('archive:manage'), async (req, res) => {
|
||||
sendOk(
|
||||
res,
|
||||
{
|
||||
@@ -43,16 +48,21 @@ router.get('/summary', requireRole('ADMIN'), async (req, res) => {
|
||||
});
|
||||
|
||||
/** GET /api/archives/:id - 某届详情(班级 + 毕业生 + 转出的学生) */
|
||||
router.get('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
sendOk(res, await archiveService.getArchivedYearDetail(user, id), '获取届别详情成功');
|
||||
});
|
||||
router.get(
|
||||
'/:id',
|
||||
requirePermission('archive:manage'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
sendOk(res, await archiveService.getArchivedYearDetail(user, id), '获取届别详情成功');
|
||||
},
|
||||
);
|
||||
|
||||
/** GET /api/archives/:id/classes/:classId/content - 归档班级的作业与通知(只读) */
|
||||
router.get(
|
||||
'/:id/classes/:classId/content',
|
||||
requireRole('ADMIN'),
|
||||
requirePermission('archive:manage'),
|
||||
validate({ params: archiveClassParamSchema, query: archivedContentQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -67,19 +77,29 @@ router.get(
|
||||
);
|
||||
|
||||
/** POST /api/archives - 执行毕业归档(按届别整届归档) */
|
||||
router.post('/', requireRole('ADMIN'), validate({ body: createArchiveSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const result = await archiveService.createArchive(user, validatedBody(req));
|
||||
sendCreated(res, result, `${result.name} 已归档,共 ${result.classCount} 个班级`);
|
||||
});
|
||||
router.post(
|
||||
'/',
|
||||
requirePermission('archive:manage'),
|
||||
validate({ body: createArchiveSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const result = await archiveService.createArchive(user, validatedBody(req));
|
||||
sendCreated(res, result, `${result.name} 已归档,共 ${result.classCount} 个班级`);
|
||||
},
|
||||
);
|
||||
|
||||
/** DELETE /api/archives/:id - 撤销归档(管理员误操作的退路,不删任何数据) */
|
||||
router.delete('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
await archiveService.deleteArchive(user, id);
|
||||
sendOk(res, { id }, '已撤销归档,班级与学生回到在读状态');
|
||||
});
|
||||
router.delete(
|
||||
'/:id',
|
||||
requirePermission('archive:manage'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
await archiveService.deleteArchive(user, id);
|
||||
sendOk(res, { id }, '已撤销归档,班级与学生回到在读状态');
|
||||
},
|
||||
);
|
||||
|
||||
export const archivesModule = defineModule({
|
||||
name: 'archives',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
import { sendCreated } from '../../lib/http.js';
|
||||
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
|
||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import { createCallSchema, type CreateCallInput } from './calls.schemas.js';
|
||||
@@ -13,7 +13,7 @@ router.use(authenticate());
|
||||
* POST /api/calls - 叫人(老师点名让学生过来)
|
||||
* ClassHelper 班级端会在灵动岛上立即弹出"请 XXX 同学找 XXX 老师",无论是否在上课。
|
||||
*/
|
||||
router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createCallSchema }), async (req, res) => {
|
||||
router.post('/', requirePermission('call:create'), validate({ body: createCallSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
sendCreated(res, await callService.createCall(user, req.body as CreateCallInput), '已通知该学生');
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import {
|
||||
validate,
|
||||
validatedBody,
|
||||
@@ -56,7 +56,7 @@ router.get('/', validate({ query: listClassesQuerySchema }), async (req, res) =>
|
||||
*/
|
||||
router.post(
|
||||
'/promote',
|
||||
requireRole('ADMIN'),
|
||||
requirePermission('class:manage'),
|
||||
validate({ body: promoteClassesSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -73,7 +73,7 @@ router.get('/:id', validate({ params: idParamSchema }), async (req, res) => {
|
||||
});
|
||||
|
||||
/** POST /api/classes - 创建班级(称呼由入学年份 + 班号生成,如「2026级1班」) */
|
||||
router.post('/', requireRole('ADMIN'), validate({ body: createClassSchema }), async (req, res) => {
|
||||
router.post('/', requirePermission('class:manage'), validate({ body: createClassSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
sendCreated(res, await classService.createClass(user, req.body as CreateClassInput), '班级创建成功');
|
||||
});
|
||||
@@ -81,7 +81,7 @@ router.post('/', requireRole('ADMIN'), validate({ body: createClassSchema }), as
|
||||
/** PATCH /api/classes/:id - 编辑班级(年级 / 届别 / 教学周数),仅管理员 */
|
||||
router.patch(
|
||||
'/:id',
|
||||
requireRole('ADMIN'),
|
||||
requirePermission('class:manage'),
|
||||
validate({ params: idParamSchema, body: updateClassSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -93,7 +93,7 @@ router.patch(
|
||||
/** DELETE /api/classes/:id - 删除班级 */
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireRole('ADMIN'),
|
||||
requirePermission('class:manage'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -109,7 +109,7 @@ router.delete(
|
||||
*/
|
||||
router.patch(
|
||||
'/:id/class-account',
|
||||
requireRole('ADMIN'),
|
||||
requirePermission('class:manage'),
|
||||
validate({ params: idParamSchema, body: updateClassAccountSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -128,7 +128,7 @@ router.patch(
|
||||
*/
|
||||
router.patch(
|
||||
'/:id/head-teacher',
|
||||
requireRole('ADMIN'),
|
||||
requirePermission('class:manage'),
|
||||
validate({ params: idParamSchema, body: assignHeadTeacherSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -147,7 +147,7 @@ router.patch(
|
||||
*/
|
||||
router.put(
|
||||
'/:id/subject-teachers',
|
||||
requireRole('ADMIN'),
|
||||
requirePermission('class:manage'),
|
||||
validate({ params: idParamSchema, body: assignSubjectTeachersSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -244,7 +244,7 @@ router.get('/:id/students', validate({ params: idParamSchema }), async (req, res
|
||||
/** POST /api/classes/:id/students - 把学生加入班级(按学号,已存在则转入并留调班痕迹) */
|
||||
router.post(
|
||||
'/:id/students',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('roster:manage'),
|
||||
validate({ params: idParamSchema, body: addStudentSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -256,7 +256,7 @@ router.post(
|
||||
/** DELETE /api/classes/:id/students/:studentId - 移出学生(回到「未分班」,名单记录保留) */
|
||||
router.delete(
|
||||
'/:id/students/:studentId',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('roster:manage'),
|
||||
validate({ params: classStudentParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
clientCommandAckSchema,
|
||||
clientHeartbeatSchema,
|
||||
createClientCommandSchema,
|
||||
listClientDevicesQuerySchema,
|
||||
pushClientUpdateSchema,
|
||||
type ClientCommandAckInput,
|
||||
type ClientHeartbeatInput,
|
||||
type CreateClientCommandInput,
|
||||
type ListClientDevicesQuery,
|
||||
type PushClientUpdateInput,
|
||||
} from './clients.schemas.js';
|
||||
import * as clientService from './clients.service.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate());
|
||||
|
||||
/**
|
||||
* POST /api/clients/heartbeat - 客户端每 30 秒上报一次
|
||||
*
|
||||
* **不挂权限键**:上报方是教室里的班级端会话(角色 CLASS_DEVICE,权限集为空)。
|
||||
* 返回值里同时带回"要不要更新 + 安装包信息 + 一条待执行命令",客户端一次请求全拿到。
|
||||
*/
|
||||
router.post('/heartbeat', validate({ body: clientHeartbeatSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const input = validatedBody<ClientHeartbeatInput>(req);
|
||||
sendOk(res, await clientService.heartbeat(user, input, req.ip ?? ''), '心跳已上报');
|
||||
});
|
||||
|
||||
/** POST /api/clients/commands/:id/ack - 执行回执(同样不挂权限键,靠 deviceKey 归属校验) */
|
||||
router.post(
|
||||
'/commands/:id/ack',
|
||||
validate({ params: idParamSchema, body: clientCommandAckSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
const input = validatedBody<ClientCommandAckInput>(req);
|
||||
sendOk(res, await clientService.ackCommand(id, input), '回执已记录');
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/clients/commands - 下发远控命令(关机 / 重启 / 注销 / 锁屏)
|
||||
*
|
||||
* 权限键 `remote:control`(默认只有管理员有):这是唯一能远程动别人电脑的接口。
|
||||
*/
|
||||
router.post(
|
||||
'/commands',
|
||||
requirePermission('remote:control'),
|
||||
validate({ body: createClientCommandSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const input = validatedBody<CreateClientCommandInput>(req);
|
||||
sendCreated(res, await clientService.createCommands(user, input), '命令已下发,将在这台电脑下次心跳时送达');
|
||||
},
|
||||
);
|
||||
|
||||
/** GET /api/clients/devices?classId= - Web 端设备列表(版本 / 在线 / 待执行命令) */
|
||||
router.get(
|
||||
'/devices',
|
||||
requirePermission('client:manage'),
|
||||
validate({ query: listClientDevicesQuerySchema }),
|
||||
async (req, res) => {
|
||||
const query = validatedQuery<ListClientDevicesQuery>(req);
|
||||
sendOk(res, await clientService.listDevices({ classId: query.classId }), '获取设备列表成功');
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/clients/updates - 统一下发客户端更新 */
|
||||
router.post(
|
||||
'/updates',
|
||||
requirePermission('client:update-push'),
|
||||
validate({ body: pushClientUpdateSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const input = validatedBody<PushClientUpdateInput>(req);
|
||||
sendOk(res, await clientService.pushUpdate(user, input), '更新已下发,将在这台电脑下次心跳时送达');
|
||||
},
|
||||
);
|
||||
|
||||
/** DELETE /api/clients/devices/:id - 移除设备记录(换机 / 旧机器清理) */
|
||||
router.delete(
|
||||
'/devices/:id',
|
||||
requirePermission('client:manage'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
sendOk(res, await clientService.deleteDevice(id), '设备已移除');
|
||||
},
|
||||
);
|
||||
|
||||
export const clientsModule = defineModule({
|
||||
name: 'clients',
|
||||
basePath: '/clients',
|
||||
router,
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { CLIENT_COMMAND_KINDS, CLIENT_COMMAND_RESULT_MAX, type ClientCommandKind } from '@classhelper/shared';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* 远控命令种类。用 `CLIENT_COMMAND_KINDS` 而不是再写一遍字面量数组:
|
||||
* 常量是 `readonly`,`z.enum` 要的是可变元组,所以这里显式转一次类型。
|
||||
*/
|
||||
const clientCommandKindSchema = z.enum(
|
||||
CLIENT_COMMAND_KINDS as unknown as [ClientCommandKind, ...ClientCommandKind[]],
|
||||
);
|
||||
|
||||
/** 设备标识(客户端主进程生成并持久化的 UUID) */
|
||||
const deviceKeySchema = z.string().trim().min(8, '设备标识不合法').max(80, '设备标识不合法');
|
||||
|
||||
/** `POST /api/clients/heartbeat`:客户端每 30 秒一次的上报 */
|
||||
export const clientHeartbeatSchema = z.object({
|
||||
deviceKey: deviceKeySchema,
|
||||
name: z.string().trim().max(60).optional(),
|
||||
clientVersion: z.string().trim().max(40).optional(),
|
||||
platform: z.string().trim().max(40).optional(),
|
||||
pendingVersion: z.string().trim().max(40).nullable().optional(),
|
||||
stateNote: z.string().trim().max(120).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* `POST /api/clients/commands/:id/ack`:执行回执。
|
||||
*
|
||||
* 带上 `deviceKey` 是为了"命令回执必须是那台机器签的字":一个班的班级端令牌
|
||||
* 全班共用,光凭命令 id 无法区分是班里哪一台。
|
||||
*/
|
||||
export const clientCommandAckSchema = z.object({
|
||||
deviceKey: deviceKeySchema,
|
||||
ok: z.boolean(),
|
||||
result: z.string().trim().max(CLIENT_COMMAND_RESULT_MAX).optional(),
|
||||
});
|
||||
|
||||
/** `POST /api/clients/commands`:给选中的电脑下发远控命令 */
|
||||
export const createClientCommandSchema = z.object({
|
||||
deviceIds: z.array(z.string().min(1)).min(1, '请选择要操作的电脑').max(200, '一次最多操作 200 台'),
|
||||
kind: clientCommandKindSchema,
|
||||
note: z.string().trim().max(80, '备注最多 80 个字').optional(),
|
||||
});
|
||||
|
||||
/** `GET /api/clients/devices?classId=` */
|
||||
export const listClientDevicesQuerySchema = z.object({
|
||||
classId: z.string().trim().min(1).optional(),
|
||||
});
|
||||
|
||||
/** `POST /api/clients/updates`:推送更新(按设备 / 按班级 / 全部) */
|
||||
export const pushClientUpdateSchema = z.object({
|
||||
deviceIds: z.array(z.string().min(1)).max(500).optional(),
|
||||
classIds: z.array(z.string().min(1)).max(200).optional(),
|
||||
all: z.boolean().optional(),
|
||||
version: z.string().trim().max(40).nullable().optional(),
|
||||
clear: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type ClientHeartbeatInput = z.infer<typeof clientHeartbeatSchema>;
|
||||
export type ClientCommandAckInput = z.infer<typeof clientCommandAckSchema>;
|
||||
export type CreateClientCommandInput = z.infer<typeof createClientCommandSchema>;
|
||||
export type ListClientDevicesQuery = z.infer<typeof listClientDevicesQuerySchema>;
|
||||
export type PushClientUpdateInput = z.infer<typeof pushClientUpdateSchema>;
|
||||
@@ -0,0 +1,372 @@
|
||||
import {
|
||||
CLIENT_COMMAND_EXPIRE_MS,
|
||||
CLIENT_COMMAND_LABELS,
|
||||
CLIENT_ONLINE_WINDOW_MS,
|
||||
type ClientCommandAckRequest,
|
||||
type ClientCommandDto,
|
||||
type ClientCommandKind,
|
||||
type ClientDeviceDto,
|
||||
type ClientHeartbeatResponse,
|
||||
type ClientPushResult,
|
||||
type ClientTargetResult,
|
||||
} from '@classhelper/shared';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import * as clientReleases from '../update/client-releases.service.js';
|
||||
import type { ClientHeartbeatInput, CreateClientCommandInput, PushClientUpdateInput } from './clients.schemas.js';
|
||||
|
||||
/**
|
||||
* ClassHelper 桌面客户端设备的心跳 / 远控命令 / 更新下发。
|
||||
*
|
||||
* 范式(AGENTS.md §5 第 67 条):**DB 记状态 + 客户端定时拉取** ——
|
||||
* 与 `IntegrationDevice.requestScheduleReport` 同一套路,不新增长连接握手。
|
||||
* 因此本文件里没有任何"推送"动作:所有下发都只是改 `ClientDevice` / `ClientCommand` 的字段,
|
||||
* 由客户端下一次心跳(30 秒内)带走。
|
||||
*/
|
||||
|
||||
type DeviceRow = {
|
||||
id: string;
|
||||
classId: string | null;
|
||||
deviceKey: string;
|
||||
name: string;
|
||||
clientVersion: string;
|
||||
platform: string;
|
||||
autoUpdate: boolean;
|
||||
updateTargetVersion: string;
|
||||
pendingVersion: string;
|
||||
lastHeartbeatAt: Date | null;
|
||||
lastIp: string;
|
||||
stateNote: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
class?: { id: string; name: string } | null;
|
||||
};
|
||||
|
||||
type CommandRow = {
|
||||
id: string;
|
||||
deviceId: string;
|
||||
kind: string;
|
||||
payload: string;
|
||||
createdAt: Date;
|
||||
expiresAt: Date | null;
|
||||
ackedAt: Date | null;
|
||||
result: string;
|
||||
};
|
||||
|
||||
/** 备注存 JSON 文本(`payload` 字段是预留结构,见 schema 注释) */
|
||||
function encodePayload(note: string): string {
|
||||
return JSON.stringify({ note });
|
||||
}
|
||||
|
||||
function decodePayload(raw: string): string {
|
||||
if (!raw) return '';
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { note?: unknown };
|
||||
return typeof parsed.note === 'string' ? parsed.note : '';
|
||||
} catch {
|
||||
// 兼容手工写进库的纯文本
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function toCommandDto(row: CommandRow): ClientCommandDto {
|
||||
const kind = row.kind as ClientCommandKind;
|
||||
const label = CLIENT_COMMAND_LABELS[kind] ?? row.kind;
|
||||
const note = decodePayload(row.payload);
|
||||
return {
|
||||
id: row.id,
|
||||
deviceId: row.deviceId,
|
||||
kind,
|
||||
payload: note ? `${label}(${note})` : label,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
expiresAt: row.expiresAt ? row.expiresAt.toISOString() : null,
|
||||
ackedAt: row.ackedAt ? row.ackedAt.toISOString() : null,
|
||||
result: row.result,
|
||||
};
|
||||
}
|
||||
|
||||
/** 待执行的命令:未回执、未撤销、未过期 */
|
||||
function pendingCommandWhere(deviceIds: string[], now: Date) {
|
||||
return {
|
||||
deviceId: { in: deviceIds },
|
||||
ackedAt: null,
|
||||
cancelledAt: null,
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||
};
|
||||
}
|
||||
|
||||
function summarize(results: ClientTargetResult[]): ClientPushResult {
|
||||
const succeeded = results.filter((item) => item.ok).length;
|
||||
return { total: results.length, succeeded, failed: results.length - succeeded, results };
|
||||
}
|
||||
|
||||
/** 班级端会话(含班级账号)的代表主体就是那个班;教师/管理员个人电脑不绑定班级 */
|
||||
function resolveClassId(user: TokenPayload): string | null {
|
||||
return user.classSession || user.role === 'CLASS_DEVICE' ? user.sub : null;
|
||||
}
|
||||
|
||||
async function findPendingCommand(deviceId: string, now: Date): Promise<CommandRow | null> {
|
||||
const rows = (await prisma.clientCommand.findMany({
|
||||
where: pendingCommandWhere([deviceId], now),
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 1,
|
||||
})) as unknown as CommandRow[];
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/clients/heartbeat - 客户端上报心跳,服务端在同一响应里带回
|
||||
* 「要不要更新 + 目标版本的安装包信息 + 一条待执行命令」。
|
||||
*/
|
||||
export async function heartbeat(
|
||||
user: TokenPayload,
|
||||
input: ClientHeartbeatInput,
|
||||
ip: string,
|
||||
): Promise<ClientHeartbeatResponse> {
|
||||
const now = new Date();
|
||||
const heartbeatAt = {
|
||||
classId: resolveClassId(user),
|
||||
name: input.name?.trim() || '未命名设备',
|
||||
clientVersion: input.clientVersion?.trim() ?? '',
|
||||
platform: input.platform?.trim() ?? '',
|
||||
pendingVersion: input.pendingVersion?.trim() ?? '',
|
||||
stateNote: input.stateNote?.trim() ?? '',
|
||||
lastHeartbeatAt: now,
|
||||
lastIp: ip,
|
||||
};
|
||||
|
||||
const device = (await prisma.clientDevice.upsert({
|
||||
where: { deviceKey: input.deviceKey },
|
||||
create: { deviceKey: input.deviceKey, ...heartbeatAt },
|
||||
update: heartbeatAt,
|
||||
})) as unknown as DeviceRow;
|
||||
|
||||
const targetVersion = device.updateTargetVersion.trim();
|
||||
const [command, update] = await Promise.all([
|
||||
findPendingCommand(device.id, now),
|
||||
targetVersion ? clientReleases.resolveUpdateInfo(targetVersion) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
return {
|
||||
deviceId: device.id,
|
||||
serverTime: now.toISOString(),
|
||||
updateTargetVersion: targetVersion || null,
|
||||
update,
|
||||
command: command ? toCommandDto(command) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** POST /api/clients/commands/:id/ack - 客户端执行完远控命令后的回执 */
|
||||
export async function ackCommand(
|
||||
commandId: string,
|
||||
input: ClientCommandAckRequest,
|
||||
): Promise<ClientCommandDto> {
|
||||
const command = (await prisma.clientCommand.findUnique({
|
||||
where: { id: commandId },
|
||||
include: { device: { select: { deviceKey: true, name: true } } },
|
||||
})) as (CommandRow & { device: { deviceKey: string; name: string } }) | null;
|
||||
if (!command) throw ApiError.notFound('命令不存在');
|
||||
|
||||
if (command.device.deviceKey !== input.deviceKey) {
|
||||
throw ApiError.forbidden('这条命令不是发给这台电脑的');
|
||||
}
|
||||
// 幂等:客户端重试回执不报错,也不覆盖第一次的结果
|
||||
if (command.ackedAt) return toCommandDto(command);
|
||||
|
||||
const detail = input.result?.trim();
|
||||
const result = `${input.ok ? '已执行' : '执行失败'}${detail ? `:${detail}` : ''}`;
|
||||
const updated = (await prisma.clientCommand.update({
|
||||
where: { id: commandId },
|
||||
data: { ackedAt: new Date(), result },
|
||||
})) as unknown as CommandRow;
|
||||
|
||||
logger.info(`远控命令回执:${command.device.name || command.device.deviceKey} ${result}`);
|
||||
return toCommandDto(updated);
|
||||
}
|
||||
|
||||
/** GET /api/clients/devices - Web 端「客户端设备」列表(含待执行命令) */
|
||||
export async function listDevices(options: { classId?: string } = {}): Promise<ClientDeviceDto[]> {
|
||||
const rows = (await prisma.clientDevice.findMany({
|
||||
where: options.classId ? { classId: options.classId } : {},
|
||||
include: { class: { select: { id: true, name: true } } },
|
||||
orderBy: [{ lastHeartbeatAt: 'desc' }, { createdAt: 'desc' }],
|
||||
})) as unknown as DeviceRow[];
|
||||
|
||||
const now = new Date();
|
||||
const commands = (await prisma.clientCommand.findMany({
|
||||
where: pendingCommandWhere(
|
||||
rows.map((row) => row.id),
|
||||
now,
|
||||
),
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})) as unknown as CommandRow[];
|
||||
|
||||
// 每台设备只留最早的一条(与心跳取的是同一条,界面上看到的才是真会执行的那条)
|
||||
const pendingByDevice = new Map<string, CommandRow>();
|
||||
for (const command of commands) {
|
||||
if (!pendingByDevice.has(command.deviceId)) pendingByDevice.set(command.deviceId, command);
|
||||
}
|
||||
|
||||
return rows.map((row) => {
|
||||
const heartbeat = row.lastHeartbeatAt;
|
||||
const pending = pendingByDevice.get(row.id);
|
||||
return {
|
||||
id: row.id,
|
||||
classId: row.classId,
|
||||
className: row.class?.name ?? null,
|
||||
deviceKey: row.deviceKey,
|
||||
name: row.name,
|
||||
clientVersion: row.clientVersion,
|
||||
platform: row.platform,
|
||||
autoUpdate: row.autoUpdate,
|
||||
updateTargetVersion: row.updateTargetVersion,
|
||||
pendingVersion: row.pendingVersion,
|
||||
lastHeartbeatAt: heartbeat ? heartbeat.toISOString() : null,
|
||||
lastIp: row.lastIp,
|
||||
stateNote: row.stateNote,
|
||||
online: Boolean(heartbeat && now.getTime() - heartbeat.getTime() < CLIENT_ONLINE_WINDOW_MS),
|
||||
pendingCommand: pending ? toCommandDto(pending) : null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 命令去重后的目标设备(保持请求顺序,便于结果一一对应) */
|
||||
async function findTargetDevices(deviceIds: string[]): Promise<Map<string, DeviceRow>> {
|
||||
const wanted = [...new Set(deviceIds)];
|
||||
const rows = (await prisma.clientDevice.findMany({
|
||||
where: { id: { in: wanted } },
|
||||
select: { id: true, name: true, deviceKey: true },
|
||||
})) as unknown as DeviceRow[];
|
||||
return new Map(rows.map((row) => [row.id, row]));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/clients/commands - 下发远控命令(关机/重启/注销/锁屏)
|
||||
*
|
||||
* 同一台机器上还没执行的旧命令会被**自动撤销**:否则"先关机、后重启"会排队执行,
|
||||
* 机器刚关就被下一条叫起来,看起来像失控。客户端侧还有一次弹窗二次确认。
|
||||
*/
|
||||
export async function createCommands(
|
||||
user: TokenPayload,
|
||||
input: CreateClientCommandInput,
|
||||
): Promise<ClientPushResult> {
|
||||
const targets = await findTargetDevices(input.deviceIds);
|
||||
const expiresAt = new Date(Date.now() + CLIENT_COMMAND_EXPIRE_MS);
|
||||
const note = input.note?.trim() ?? '';
|
||||
const results: ClientTargetResult[] = [];
|
||||
|
||||
for (const deviceId of [...new Set(input.deviceIds)]) {
|
||||
const device = targets.get(deviceId);
|
||||
if (!device) {
|
||||
results.push({
|
||||
deviceId,
|
||||
deviceName: '',
|
||||
ok: false,
|
||||
code: 'DEVICE_NOT_FOUND',
|
||||
message: '设备不存在(可能已被移除)',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
await prisma.clientCommand.updateMany({
|
||||
where: { deviceId, ackedAt: null, cancelledAt: null },
|
||||
data: { cancelledAt: new Date(), result: '已被新的命令取代' },
|
||||
});
|
||||
await prisma.clientCommand.create({
|
||||
data: { deviceId, kind: input.kind, payload: encodePayload(note), createdBy: user.sub, expiresAt },
|
||||
});
|
||||
results.push({ deviceId, deviceName: device.name, ok: true });
|
||||
}
|
||||
|
||||
const summary = summarize(results);
|
||||
logger.info(
|
||||
`下发远控命令 ${input.kind}:目标 ${summary.total} 台,成功 ${summary.succeeded} 台(发起人=${user.username})`,
|
||||
);
|
||||
return summary;
|
||||
}
|
||||
|
||||
/** 更新推送的目标集合:优先设备,其次班级,最后"全部" */
|
||||
async function resolveUpdateTargets(input: PushClientUpdateInput): Promise<DeviceRow[]> {
|
||||
const deviceIds = [...new Set(input.deviceIds ?? [])];
|
||||
if (deviceIds.length > 0) {
|
||||
const rows = (await prisma.clientDevice.findMany({
|
||||
where: { id: { in: deviceIds } },
|
||||
select: { id: true, name: true },
|
||||
})) as unknown as DeviceRow[];
|
||||
return rows;
|
||||
}
|
||||
|
||||
const classIds = [...new Set(input.classIds ?? [])];
|
||||
if (classIds.length > 0) {
|
||||
return (await prisma.clientDevice.findMany({
|
||||
where: { classId: { in: classIds } },
|
||||
select: { id: true, name: true },
|
||||
})) as unknown as DeviceRow[];
|
||||
}
|
||||
|
||||
if (input.all) {
|
||||
return (await prisma.clientDevice.findMany({ select: { id: true, name: true } })) as unknown as DeviceRow[];
|
||||
}
|
||||
|
||||
throw ApiError.badRequest('请选择要推送的设备、班级,或选择"全部设备"');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/clients/updates - 统一下发客户端更新
|
||||
*
|
||||
* `version` 省略时用服务端缓存里最新的那个版本;`clear: true` 取消下发。
|
||||
* 只改 `updateTargetVersion`,真正的下载与安装由客户端按自己的空闲判据决定
|
||||
* (上课时段 / 窗口可见都不装)。
|
||||
*/
|
||||
export async function pushUpdate(
|
||||
user: TokenPayload,
|
||||
input: PushClientUpdateInput,
|
||||
): Promise<ClientPushResult> {
|
||||
const clear = input.clear === true;
|
||||
let target = '';
|
||||
|
||||
if (!clear) {
|
||||
target = (input.version ?? '').trim() || (await clientReleases.latestReleaseVersion());
|
||||
if (!target) {
|
||||
throw ApiError.badRequest('服务端还没有缓存任何客户端安装包,请先下载或登记一份再推送');
|
||||
}
|
||||
const info = await clientReleases.resolveUpdateInfo(target);
|
||||
if (!info) throw ApiError.badRequest(`服务端缺少 v${target} 的安装包文件,请重新下载或登记`);
|
||||
}
|
||||
|
||||
const devices = await resolveUpdateTargets(input);
|
||||
if (devices.length === 0) throw ApiError.badRequest('没有匹配到任何客户端设备');
|
||||
|
||||
const results: ClientTargetResult[] = [];
|
||||
for (const device of devices) {
|
||||
await prisma.clientDevice.update({
|
||||
where: { id: device.id },
|
||||
data: { updateTargetVersion: target },
|
||||
});
|
||||
results.push({ deviceId: device.id, deviceName: device.name, ok: true });
|
||||
}
|
||||
|
||||
const summary = summarize(results);
|
||||
logger.info(
|
||||
clear
|
||||
? `取消更新下发:${summary.total} 台设备(发起人=${user.username})`
|
||||
: `推送客户端更新 v${target}:${summary.total} 台设备(发起人=${user.username})`,
|
||||
);
|
||||
return summary;
|
||||
}
|
||||
|
||||
/** DELETE /api/clients/devices/:id - 移除一台设备记录(换机 / 旧机器清理) */
|
||||
export async function deleteDevice(deviceId: string): Promise<{ id: string }> {
|
||||
const device = await prisma.clientDevice.findUnique({
|
||||
where: { id: deviceId },
|
||||
select: { id: true, name: true, deviceKey: true },
|
||||
});
|
||||
if (!device) throw ApiError.notFound('设备不存在');
|
||||
await prisma.clientDevice.delete({ where: { id: deviceId } });
|
||||
logger.info(`移除客户端设备:${device.name || device.deviceKey}`);
|
||||
return { id: deviceId };
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
@@ -26,7 +26,7 @@ router.get('/', validate({ query: listCoursesQuerySchema }), async (req, res) =>
|
||||
/** POST /api/courses - 新增课程 */
|
||||
router.post(
|
||||
'/',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('schedule:write'),
|
||||
validate({ body: createCourseSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -37,7 +37,7 @@ router.post(
|
||||
/** PATCH /api/courses/:id - 修改课程 */
|
||||
router.patch(
|
||||
'/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('schedule:write'),
|
||||
validate({ params: idParamSchema, body: updateCourseSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -49,7 +49,7 @@ router.patch(
|
||||
/** DELETE /api/courses/:id - 删除课程 */
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('schedule:write'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CourseDto } from '@classhelper/shared';
|
||||
import type { CourseDto } from '@classhelper/shared';
|
||||
import { assertCanManageSchedule, classScopeWhere, resolveClassScope } from '../../lib/access.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
@@ -21,6 +21,28 @@ export async function listCourses(user: TokenPayload, classId?: string): Promise
|
||||
export async function createCourse(user: TokenPayload, input: CreateCourseInput): Promise<CourseDto> {
|
||||
await assertCanManageSchedule(user, input.classId);
|
||||
|
||||
/*
|
||||
* 同名去重(一个班级里科目名唯一)。
|
||||
*
|
||||
* 这条不是"友好提示",是**权限护栏**:`course.create` 会把创建者写成这门课的任课老师,
|
||||
* 于是只要能再建一门同名的「数学」,创建者就凭空成了「数学」的老师 ——
|
||||
* 后面那道 `assertCanManageSubjectContent` 的科目门随即形同虚设
|
||||
* (可以布置该科作业、录入该科成绩、删该科内容)。这就是"非该科科任老师
|
||||
* 能布置其他科作业"的入口。
|
||||
*
|
||||
* 处理办法是把**已有**课程的 id 回给前端(409 + details.courseId),让前端复用它;
|
||||
* 权限判定于是回到"你是不是这门课的任课老师"这个唯一事实上。
|
||||
*/
|
||||
const existing = await prisma.course.findFirst({
|
||||
where: { classId: input.classId, name: input.name },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ApiError(409, 'COURSE_EXISTS', `该班级已有名为「${input.name}」的课程`, {
|
||||
courseId: existing.id,
|
||||
});
|
||||
}
|
||||
|
||||
const created = await prisma.course.create({
|
||||
data: { name: input.name, classId: input.classId, teacherId: user.sub },
|
||||
include: { teacher: teacherSelect },
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { authenticate, requireRole } from '../../middleware/auth.js';
|
||||
import { authenticate, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedParams } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
@@ -22,7 +22,7 @@ import * as databaseService from './database.service.js';
|
||||
/**
|
||||
* 数据库管理(仅管理员):状态检测 / 连接测试 / 备份恢复 / 导入导出 / 定时备份 / 一键切换。
|
||||
*
|
||||
* 改动数据库属于最高危操作,全部接口挂在 requireRole('ADMIN') 之下;
|
||||
* 改动数据库属于最高危操作,全部接口挂在 requirePermission('database:manage') 之下;
|
||||
* 前端入口同样只对 ADMIN 显示(Web 端「数据库管理」页)。
|
||||
*
|
||||
* 一键切换是异步任务:POST /switch 返回 jobId,前端轮询 GET /switch/jobs/:id 展示步骤进度。
|
||||
@@ -31,7 +31,7 @@ import * as databaseService from './database.service.js';
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate());
|
||||
router.use(requireRole('ADMIN'));
|
||||
router.use(requirePermission('database:manage'));
|
||||
|
||||
/** GET /api/database/status - 当前数据库状态(连接/版本/大小/各表行数/备份与定时配置) */
|
||||
router.get('/status', async (_req, res) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
@@ -56,7 +56,7 @@ router.get(
|
||||
/** GET /api/grades/stats?classId=&courseId=&examName= - 成绩统计(图表) */
|
||||
router.get(
|
||||
'/stats',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('grade:read'),
|
||||
validate({ query: statsQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -68,7 +68,7 @@ router.get(
|
||||
/** GET /api/grades?classId=&courseId=&studentId=&examName= - 成绩列表(教师) */
|
||||
router.get(
|
||||
'/',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('grade:read'),
|
||||
validate({ query: listGradesQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -83,7 +83,7 @@ router.get(
|
||||
);
|
||||
|
||||
/** POST /api/grades - 录入单条成绩(仅自己任教科目) */
|
||||
router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createGradeSchema }), async (req, res) => {
|
||||
router.post('/', requirePermission('grade:write'), validate({ body: createGradeSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
sendCreated(
|
||||
res,
|
||||
@@ -100,7 +100,7 @@ router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createGradeSc
|
||||
*/
|
||||
router.patch(
|
||||
'/levels',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('grade:write'),
|
||||
validate({ body: updateGradeLevelsSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -112,7 +112,7 @@ router.patch(
|
||||
/** POST /api/grades/bulk - 批量录入 / 导入成绩 */
|
||||
router.post(
|
||||
'/bulk',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('grade:write'),
|
||||
validate({ body: bulkCreateGradeSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -124,7 +124,7 @@ router.post(
|
||||
/** PATCH /api/grades/:id - 修改成绩(含单个改等级) */
|
||||
router.patch(
|
||||
'/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('grade:write'),
|
||||
validate({ params: idParamSchema, body: updateGradeSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -136,7 +136,7 @@ router.patch(
|
||||
/** DELETE /api/grades/:id - 删除成绩 */
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('grade:write'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
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 { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
bulkCreateHomeworkSchema,
|
||||
createHomeworkSchema,
|
||||
listHomeworkDaysQuerySchema,
|
||||
listHomeworksQuerySchema,
|
||||
updateHomeworkSchema,
|
||||
updateHomeworkStatusSchema,
|
||||
updateHomeworkSubmissionsSchema,
|
||||
type BulkCreateHomeworkInput,
|
||||
type CreateHomeworkInput,
|
||||
type ListHomeworkDaysInput,
|
||||
type UpdateHomeworkInput,
|
||||
@@ -71,10 +73,32 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/homeworks/bulk - 一次给多个班级布置同一份作业(逐班结果)
|
||||
*
|
||||
* 与 POST / 的区别:批量发布是 Web 管理端的"多班同发"工作流,因此这里挂 `homework:write`;
|
||||
* 教室机器的班级账号仍走单班录入(POST /,权限在服务层判,见 AGENTS.md §5 第 20 条)。
|
||||
* 每个班的科目归属依然由 createHomework 里的 assertCanManageSubjectContent 逐个判定 ——
|
||||
* 批量不是绕过科目门的后门(§5 第 5 条)。
|
||||
*/
|
||||
router.post(
|
||||
'/bulk',
|
||||
requirePermission('homework:write'),
|
||||
validate({ body: bulkCreateHomeworkSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const result = await homeworkService.bulkCreateHomeworks(
|
||||
user,
|
||||
req.body as BulkCreateHomeworkInput,
|
||||
);
|
||||
sendCreated(res, result, `批量发布完成:成功 ${result.succeeded} 个班,失败 ${result.failed} 个班`);
|
||||
},
|
||||
);
|
||||
|
||||
/** PATCH /api/homeworks/:id - 修改作业 */
|
||||
router.patch(
|
||||
'/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('homework:write'),
|
||||
validate({ params: idParamSchema, body: updateHomeworkSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -90,7 +114,7 @@ router.patch(
|
||||
/** DELETE /api/homeworks/:id - 删除作业 */
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('homework:write'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
|
||||
@@ -37,6 +37,26 @@ export const createHomeworkSchema = z.object({
|
||||
assignDate: dayKeySchema.nullish(),
|
||||
});
|
||||
|
||||
/**
|
||||
* 一次给多个班级布置同一份作业(批量发布)。
|
||||
*
|
||||
* 上限 50 个班是刻意设的:逐班串行落库 + 逐班判定科目归属,
|
||||
* 一批塞几百个班会长时间占住请求。
|
||||
*/
|
||||
export const bulkCreateHomeworkSchema = z.object({
|
||||
classIds: z
|
||||
.array(z.string().min(1))
|
||||
.min(1, '请至少选择一个班级')
|
||||
.max(50, '一次最多发布 50 个班级'),
|
||||
courseId: z.string().min(1).nullish(),
|
||||
/** 按科目名称指定(跨班只能用名称:课程是班级内的,同一门课在每个班是不同的记录) */
|
||||
subjectName: z.string().trim().min(1).max(50).nullish(),
|
||||
title: z.string().trim().min(1, '请输入作业标题').max(120),
|
||||
content: z.string().min(1, '请输入作业内容').max(5000),
|
||||
assignDate: dayKeySchema.nullish(),
|
||||
attachmentUrl: attachmentUrlSchema,
|
||||
});
|
||||
|
||||
export const updateHomeworkSchema = z.object({
|
||||
courseId: z.string().min(1).nullish(),
|
||||
/** 改作业所属日期(把作业挪到另一天) */
|
||||
@@ -62,6 +82,7 @@ export const updateHomeworkSubmissionsSchema = z.object({
|
||||
|
||||
export type ListHomeworkDaysInput = z.infer<typeof listHomeworkDaysQuerySchema>;
|
||||
export type CreateHomeworkInput = z.infer<typeof createHomeworkSchema>;
|
||||
export type BulkCreateHomeworkInput = z.infer<typeof bulkCreateHomeworkSchema>;
|
||||
export type UpdateHomeworkInput = z.infer<typeof updateHomeworkSchema>;
|
||||
export type UpdateHomeworkStatusInput = z.infer<typeof updateHomeworkStatusSchema>;
|
||||
export type UpdateHomeworkSubmissionsInput = z.infer<typeof updateHomeworkSubmissionsSchema>;
|
||||
@@ -2,6 +2,9 @@ import {
|
||||
SOCKET_EVENTS,
|
||||
dayKeyLocal,
|
||||
shiftDayKey,
|
||||
type BulkCreateHomeworkRequest,
|
||||
type BulkPublishClassResult,
|
||||
type BulkPublishResult,
|
||||
type HomeworkDaysDto,
|
||||
type HomeworkDto,
|
||||
type HomeworkStatusDto,
|
||||
@@ -15,9 +18,11 @@ import {
|
||||
isClassDevice,
|
||||
resolveClassScope,
|
||||
} from '../../lib/access.js';
|
||||
import { normalizeBulkClassIds, summarizeBulk, toBulkFailure } from '../../lib/bulk.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { toHomeworkDto, toHomeworkStatusDto } from '../../lib/mappers.js';
|
||||
import { personalIdWhere, resolvePersonalIds } from '../../lib/session.js';
|
||||
import { emitToClass } from '../../realtime/bus.js';
|
||||
@@ -182,6 +187,63 @@ export async function createHomework(user: TokenPayload, input: CreateHomeworkIn
|
||||
return dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量布置作业:一次给多个班级发同一份内容。
|
||||
*
|
||||
* 逐班串行调用 `createHomework`(复用同一套科目归属断言 + 实时广播 + 班级端归属规则),
|
||||
* **某个班失败只记进结果**:例如批量里含一个"语文由别的老师任教"的班,
|
||||
* 那个班带着「不是你任教的科目」的失败原因回来,其余班照常发布。
|
||||
*
|
||||
* 科目用 `subjectName` 而不是 courseId:课程是班级内的,同一门课在每个班是不同的记录,
|
||||
* 跨班只能用名称逐个查(查不到只让那个班失败,不自动建课)。
|
||||
*/
|
||||
export async function bulkCreateHomeworks(
|
||||
user: TokenPayload,
|
||||
input: BulkCreateHomeworkRequest,
|
||||
): Promise<BulkPublishResult> {
|
||||
const classIds = normalizeBulkClassIds(input.classIds);
|
||||
const results: BulkPublishClassResult[] = [];
|
||||
|
||||
for (const classId of classIds) {
|
||||
try {
|
||||
// 批量跨班只能用科目「名称」定位课程:同一门课在每个班是不同的 Course 记录。
|
||||
// 该班没有这门课时只让这个班失败 —— 不自动建课(否则一次批量会在 N 个班里凭空
|
||||
// 建出课程,还会让发起人在这些班里变成"任课老师",绕过科目维度的写权限)。
|
||||
let courseId = input.courseId ?? null;
|
||||
if (input.subjectName) {
|
||||
const course = await prisma.course.findFirst({
|
||||
where: { classId, name: input.subjectName },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!course) {
|
||||
throw ApiError.badRequest(
|
||||
`该班级没有「${input.subjectName}」这门课程(跨班发布按科目名称匹配,请先让班主任/管理员在本班建课并分配任课老师,或改选其他科目)`,
|
||||
);
|
||||
}
|
||||
courseId = course.id;
|
||||
}
|
||||
|
||||
const dto = await createHomework(user, {
|
||||
classId,
|
||||
courseId,
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
assignDate: input.assignDate ?? null,
|
||||
attachmentUrl: input.attachmentUrl ?? null,
|
||||
});
|
||||
results.push({ classId, ok: true, id: dto.id });
|
||||
} catch (error) {
|
||||
results.push(toBulkFailure(classId, error, '布置作业'));
|
||||
}
|
||||
}
|
||||
|
||||
const summary = summarizeBulk(results);
|
||||
logger.info(
|
||||
`批量布置作业:目标 ${summary.total} 个班级,成功 ${summary.succeeded} 个,失败 ${summary.failed} 个(发起人=${user.username})`,
|
||||
);
|
||||
return summary;
|
||||
}
|
||||
|
||||
export async function updateHomework(
|
||||
user: TokenPayload,
|
||||
homeworkId: string,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { ApiError, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.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 {
|
||||
@@ -48,7 +48,7 @@ function resolveImportKind(value: unknown): (typeof IMPORT_KINDS)[number] {
|
||||
* GET /api/imports/template?kind=grades|students|teachers&format=csv|xlsx - 下载导入模板
|
||||
* 成绩模板:管理员或班主任(空白模板,不含任何业务数据);学生/教师名单模板:仅管理员
|
||||
*/
|
||||
router.get('/template', requireRole('ADMIN', 'TEACHER'), (req, res) => {
|
||||
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';
|
||||
@@ -72,7 +72,7 @@ router.get('/template', requireRole('ADMIN', 'TEACHER'), (req, res) => {
|
||||
*/
|
||||
router.post(
|
||||
'/table/preview',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:schedule', 'import:class-teachers'),
|
||||
validate({ body: tableFileSchema }),
|
||||
(req, res) => {
|
||||
const input = validatedBody<TableFileInput>(req);
|
||||
@@ -89,7 +89,7 @@ router.post(
|
||||
*/
|
||||
router.post(
|
||||
'/table/commit',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:schedule', 'import:class-teachers'),
|
||||
validate({ body: tableCommitSchema }),
|
||||
async (req, res) => {
|
||||
const input = validatedBody<TableCommitInput>(req);
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { hashPassword } from '../../lib/password.js';
|
||||
import { resolveUserClassRole } from '../../lib/access.js';
|
||||
import { canImport } from '@classhelper/shared';
|
||||
import { canImport, canManageSubjectContent } from '@classhelper/shared';
|
||||
import type { TableCommitInput, TableFileInput } from './imports.schemas.js';
|
||||
|
||||
/**
|
||||
@@ -337,9 +337,19 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
|
||||
const byName = new Map(students.map((item) => [item.name.toLowerCase(), item]));
|
||||
const courses = await prisma.course.findMany({
|
||||
where: { classId },
|
||||
select: { id: true, name: true },
|
||||
select: { id: true, name: true, teacherId: true },
|
||||
});
|
||||
|
||||
/*
|
||||
* 科目维度的第二道门(`assertImportAllowed` 只到"班级"这一层)。
|
||||
*
|
||||
* 成绩是**逐行落到具体科目**上的,而班主任并不自动是本班所有科目的老师 ——
|
||||
* 少了这道检查,班主任就能导入别的老师任教科目的成绩,班里会平白多出一份
|
||||
* 不属于那门课的记录(且覆盖语义的 append/overwrite 会把它写进同一张表)。
|
||||
* 判定沿用 shared 的 canManageSubjectContent,与 Web 端单条录入、作业发布**同一份规则**。
|
||||
*/
|
||||
const classRole = await resolveUserClassRole(user, classId);
|
||||
|
||||
const errors: ImportRowError[] = [];
|
||||
const warnings: string[] = [];
|
||||
let inserted = 0;
|
||||
@@ -390,6 +400,23 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
|
||||
if (courseName && !course)
|
||||
warnings.push(`第 ${rowNumber} 行:科目「${courseName}」不存在,已按"不指定科目"导入`);
|
||||
|
||||
// 该科目的成绩能不能由**当前这个人**写(管理员全通;挂了科目就只能是该科任课老师)
|
||||
const canWriteCourse = canManageSubjectContent({
|
||||
classRole,
|
||||
hasSubject: Boolean(course),
|
||||
isSubjectTeacherOfCourse: course ? course.teacherId === user.sub : false,
|
||||
allowClassDevice: false,
|
||||
});
|
||||
if (!canWriteCourse) {
|
||||
errors.push({
|
||||
row: rowNumber,
|
||||
message: course
|
||||
? `「${course.name}」不是你任教的科目,不能导入该科目的成绩`
|
||||
: '不指定科目的成绩只有班主任或管理员可以导入,请填写科目列后再导入',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 表格里带等级列就按"自定义等级"落库(教师明确给了等级),否则按百分制换算
|
||||
const levelType = levelText ? 'custom' : 'percent';
|
||||
const existing = await prisma.grade.findFirst({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
import { ApiError, sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
|
||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import * as gradeService from '../grades/grades.service.js';
|
||||
@@ -117,7 +117,7 @@ router.use(authenticate());
|
||||
*/
|
||||
router.get(
|
||||
'/devices',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('device:manage'),
|
||||
validate({ query: listDevicesQuerySchema }),
|
||||
async (req, res) => {
|
||||
const { classId } = validatedQuery<{ classId?: string }>(req);
|
||||
@@ -131,14 +131,14 @@ router.get(
|
||||
* 管理员看全部班级;普通教师看自己担任班主任的班级 ∪ 任课的班级。
|
||||
* 每条含:班级、设备名/设备号、在线/离线、最后在线时间、最后心跳时间。
|
||||
*/
|
||||
router.get('/classisland/status', requireRole('ADMIN', 'TEACHER'), async (req, res) => {
|
||||
router.get('/classisland/status', requirePermission('device:manage'), async (req, res) => {
|
||||
sendOk(res, await integrationService.listClassHelperStatus(getAuthUser(req)), '获取在线状态成功');
|
||||
});
|
||||
|
||||
/** POST /api/integrations/devices - 新建设备并一次性返回明文令牌(管理员或本班班主任) */
|
||||
router.post(
|
||||
'/devices',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('device:manage'),
|
||||
validate({ body: createDeviceSchema }),
|
||||
async (req, res) => {
|
||||
const result = await integrationService.createDevice(
|
||||
@@ -152,7 +152,7 @@ router.post(
|
||||
/** PATCH /api/integrations/devices/:id - 改名 / 启停 / 调整同步开关 */
|
||||
router.patch(
|
||||
'/devices/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('device:manage'),
|
||||
validate({ params: deviceIdParamSchema, body: updateDeviceSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
@@ -174,7 +174,7 @@ router.patch(
|
||||
*/
|
||||
router.post(
|
||||
'/devices/:id/request-schedule',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('device:manage'),
|
||||
validate({ params: deviceIdParamSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
@@ -186,7 +186,7 @@ router.post(
|
||||
/** POST /api/integrations/devices/:id/token - 重置令牌(旧令牌立即失效) */
|
||||
router.post(
|
||||
'/devices/:id/token',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('device:manage'),
|
||||
validate({ params: deviceIdParamSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
@@ -197,7 +197,7 @@ router.post(
|
||||
/** DELETE /api/integrations/devices/:id - 删除设备 */
|
||||
router.delete(
|
||||
'/devices/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('device:manage'),
|
||||
validate({ params: deviceIdParamSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
@@ -209,7 +209,7 @@ router.delete(
|
||||
/** POST /api/integrations/classisland/notify - 把提醒下发到该班的 ClassIsland 设备 */
|
||||
router.post(
|
||||
'/classisland/notify',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('classisland:notify'),
|
||||
validate({ body: sendNotificationSchema }),
|
||||
async (req, res) => {
|
||||
const result = await integrationService.sendNotification(
|
||||
|
||||
@@ -1,32 +1,46 @@
|
||||
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 { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
bulkCreateNotificationSchema,
|
||||
createNotificationSchema,
|
||||
listNotificationsQuerySchema,
|
||||
notificationArchiveQuerySchema,
|
||||
notificationScopeQuerySchema,
|
||||
type BulkCreateNotificationInput,
|
||||
type CreateNotificationInput,
|
||||
type ListNotificationQuery,
|
||||
type NotificationArchiveQuery,
|
||||
} from './notifications.schemas.js';
|
||||
import * as notificationService from './notifications.service.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate());
|
||||
|
||||
/** GET /api/notifications?classId=&priority=&unreadOnly= - 通知列表 */
|
||||
/** GET /api/notifications?classId=&priority=&unreadOnly=&date=&from=&to=&createdBy= - 通知列表 */
|
||||
router.get('/', validate({ query: listNotificationsQuerySchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const query = validatedQuery<{
|
||||
classId?: string;
|
||||
priority?: string;
|
||||
unreadOnly: boolean;
|
||||
keyword?: string;
|
||||
}>(req);
|
||||
const query = validatedQuery<ListNotificationQuery>(req);
|
||||
sendOk(res, await notificationService.listNotifications(user, query), '获取通知列表成功');
|
||||
});
|
||||
|
||||
/** GET /api/notifications/days?classId=&from=&to=&createdBy= - 按天归档(日期 + 条数) */
|
||||
router.get('/days', validate({ query: notificationArchiveQuerySchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const query = validatedQuery<NotificationArchiveQuery>(req);
|
||||
sendOk(res, await notificationService.listNotificationDays(user, query), '获取通知日期归档成功');
|
||||
});
|
||||
|
||||
/** GET /api/notifications/creators?classId=&from=&to=&date= - 按发布人归档 */
|
||||
router.get('/creators', validate({ query: notificationArchiveQuerySchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const query = validatedQuery<NotificationArchiveQuery>(req);
|
||||
sendOk(res, await notificationService.listNotificationCreators(user, query), '获取通知发布人归档成功');
|
||||
});
|
||||
|
||||
/** GET /api/notifications/unread-count?classId= - 未读数(客户端红点) */
|
||||
router.get('/unread-count', validate({ query: notificationScopeQuerySchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -44,7 +58,7 @@ router.post('/read-all', validate({ body: notificationScopeQuerySchema }), async
|
||||
/** POST /api/notifications - 发布通知(发布后广播 notification:new) */
|
||||
router.post(
|
||||
'/',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('notification:publish'),
|
||||
validate({ body: createNotificationSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -53,6 +67,21 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/notifications/bulk - 一次给多个班级发同一条通知(逐班结果,不整批回滚) */
|
||||
router.post(
|
||||
'/bulk',
|
||||
requirePermission('notification:publish'),
|
||||
validate({ body: bulkCreateNotificationSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const result = await notificationService.bulkCreateNotifications(
|
||||
user,
|
||||
req.body as BulkCreateNotificationInput,
|
||||
);
|
||||
sendCreated(res, result, `批量发布完成:成功 ${result.succeeded} 个班,失败 ${result.failed} 个班`);
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/notifications/:id/read - 标记已读 */
|
||||
router.post('/:id/read', validate({ params: idParamSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -63,7 +92,7 @@ router.post('/:id/read', validate({ params: idParamSchema }), async (req, res) =
|
||||
/** DELETE /api/notifications/:id - 删除通知 */
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('notification:delete'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
|
||||
@@ -1,13 +1,36 @@
|
||||
import { z } from 'zod';
|
||||
import { isDayKey } from '@classhelper/shared';
|
||||
import { booleanFlagSchema } from '../../lib/schemas.js';
|
||||
|
||||
export const notificationPrioritySchema = z.enum(['LOW', 'NORMAL', 'HIGH', 'URGENT']);
|
||||
|
||||
/** 本地日期(YYYY-MM-DD):按天归档/筛选用它,不要用 createdAt 的 UTC 切片 */
|
||||
const dayKeySchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.refine(isDayKey, { message: '日期格式应为 YYYY-MM-DD' });
|
||||
|
||||
export const listNotificationsQuerySchema = z.object({
|
||||
classId: z.string().min(1).optional(),
|
||||
priority: notificationPrioritySchema.optional(),
|
||||
unreadOnly: booleanFlagSchema,
|
||||
keyword: z.string().trim().max(64).optional(),
|
||||
/** 只看某一天;与 from/to 二选一 */
|
||||
date: dayKeySchema.optional(),
|
||||
/** 日期区间(含边界) */
|
||||
from: dayKeySchema.optional(),
|
||||
to: dayKeySchema.optional(),
|
||||
/** 只看某位发布人 */
|
||||
createdBy: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
/** GET /notifications/days 与 GET /notifications/creators 的筛选条件(不含 date 自身) */
|
||||
export const notificationArchiveQuerySchema = z.object({
|
||||
classId: z.string().min(1).optional(),
|
||||
priority: notificationPrioritySchema.optional(),
|
||||
createdBy: z.string().min(1).optional(),
|
||||
from: dayKeySchema.optional(),
|
||||
to: dayKeySchema.optional(),
|
||||
});
|
||||
|
||||
export const createNotificationSchema = z.object({
|
||||
@@ -26,5 +49,25 @@ export const notificationScopeQuerySchema = z.object({
|
||||
classId: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* 一次给多个班级发同一条通知(批量发布)。
|
||||
*
|
||||
* 上限 50 个班是刻意设的:批量发布是逐班串行落库 + 逐班推 ClassIsland,
|
||||
* 一次塞几百个班会长时间占住请求,也会把教室大屏刷屏。
|
||||
*/
|
||||
export const bulkCreateNotificationSchema = z.object({
|
||||
classIds: z
|
||||
.array(z.string().min(1))
|
||||
.min(1, '请至少选择一个班级')
|
||||
.max(50, '一次最多发布 50 个班级'),
|
||||
title: z.string().trim().min(1, '请输入通知标题').max(120),
|
||||
content: z.string().min(1, '请输入通知内容').max(5000),
|
||||
priority: notificationPrioritySchema.optional(),
|
||||
confirmDuringClass: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type CreateNotificationInput = z.infer<typeof createNotificationSchema>;
|
||||
export type NotificationPriorityInput = z.infer<typeof notificationPrioritySchema>;
|
||||
export type ListNotificationQuery = z.infer<typeof listNotificationsQuerySchema>;
|
||||
export type NotificationArchiveQuery = z.infer<typeof notificationArchiveQuerySchema>;
|
||||
export type BulkCreateNotificationInput = z.infer<typeof bulkCreateNotificationSchema>;
|
||||
@@ -1,4 +1,15 @@
|
||||
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
|
||||
import {
|
||||
SOCKET_EVENTS,
|
||||
dayKeyLocal,
|
||||
type BulkCreateNotificationRequest,
|
||||
type BulkPublishClassResult,
|
||||
type BulkPublishResult,
|
||||
type NotificationCreatorSummary,
|
||||
type NotificationCreatorsDto,
|
||||
type NotificationDaysDto,
|
||||
type NotificationDto,
|
||||
} from '@classhelper/shared';
|
||||
import { normalizeBulkClassIds, summarizeBulk, toBulkFailure } from '../../lib/bulk.js';
|
||||
import {
|
||||
assertClassAccess,
|
||||
assertCanPublishNotification,
|
||||
@@ -23,6 +34,37 @@ export interface ListNotificationOptions {
|
||||
priority?: string;
|
||||
unreadOnly?: boolean;
|
||||
keyword?: string;
|
||||
date?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
createdBy?: string;
|
||||
}
|
||||
|
||||
/** 归档筛选条件:按天 / 日期区间 / 发布人 / 优先级(assignDate 是本地日期字符串,字典序即时间序) */
|
||||
function archiveWhere(options: {
|
||||
priority?: string;
|
||||
createdBy?: string;
|
||||
date?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
}): Record<string, unknown> {
|
||||
if (options.date) {
|
||||
return {
|
||||
...(options.priority ? { priority: options.priority } : {}),
|
||||
...(options.createdBy ? { createdBy: options.createdBy } : {}),
|
||||
assignDate: options.date,
|
||||
};
|
||||
}
|
||||
|
||||
const range = {
|
||||
...(options.from ? { gte: options.from } : {}),
|
||||
...(options.to ? { lte: options.to } : {}),
|
||||
};
|
||||
return {
|
||||
...(options.priority ? { priority: options.priority } : {}),
|
||||
...(options.createdBy ? { createdBy: options.createdBy } : {}),
|
||||
...(Object.keys(range).length > 0 ? { assignDate: range } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listNotifications(
|
||||
@@ -38,7 +80,7 @@ export async function listNotifications(
|
||||
const notifications = await prisma.notification.findMany({
|
||||
where: {
|
||||
...classScopeWhere(scope),
|
||||
...(options.priority ? { priority: options.priority } : {}),
|
||||
...archiveWhere(options),
|
||||
...(options.keyword
|
||||
? { OR: [{ title: { contains: options.keyword } }, { content: { contains: options.keyword } }] }
|
||||
: {}),
|
||||
@@ -55,6 +97,98 @@ export async function listNotifications(
|
||||
return notifications.map((item) => toNotificationDto(item, { studentIds: personalIds, withStatus: isStaff }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按天归档:列出「有通知的日期 + 当天条数」,供 Web 端左侧日期栏使用。
|
||||
*
|
||||
* 用 `assignDate`(本地日期)而不是 `createdAt`:晚上发的通知不该被算到第二天。
|
||||
* 传了 `date` 也刻意忽略 —— 日期栏是"归档索引",不该被当前选中的那天过滤掉。
|
||||
*/
|
||||
export async function listNotificationDays(
|
||||
user: TokenPayload,
|
||||
options: { classId?: string; priority?: string; createdBy?: string; from?: string; to?: string },
|
||||
): Promise<NotificationDaysDto> {
|
||||
const scope = await resolveClassScope(user, options.classId);
|
||||
const grouped = await prisma.notification.groupBy({
|
||||
by: ['assignDate'],
|
||||
where: { ...classScopeWhere(scope), ...archiveWhere(options) },
|
||||
_count: { _all: true },
|
||||
});
|
||||
|
||||
const days = grouped
|
||||
.map((item) => ({ date: item.assignDate, count: item._count._all }))
|
||||
.filter((item) => item.date !== '')
|
||||
.sort((left, right) => right.date.localeCompare(left.date));
|
||||
|
||||
return { days };
|
||||
}
|
||||
|
||||
/** 按发布人归档:列出当前筛选范围内的发布人及其条数(供"按发布人看通知"用) */
|
||||
export async function listNotificationCreators(
|
||||
user: TokenPayload,
|
||||
options: { classId?: string; priority?: string; date?: string; from?: string; to?: string },
|
||||
): Promise<NotificationCreatorsDto> {
|
||||
const scope = await resolveClassScope(user, options.classId);
|
||||
const grouped = await prisma.notification.groupBy({
|
||||
by: ['createdBy'],
|
||||
where: { ...classScopeWhere(scope), ...archiveWhere(options) },
|
||||
_count: { _all: true },
|
||||
});
|
||||
|
||||
const owners = await prisma.user.findMany({
|
||||
where: { id: { in: grouped.map((item) => item.createdBy) } },
|
||||
select: { id: true, name: true, username: true },
|
||||
});
|
||||
|
||||
const creators: NotificationCreatorSummary[] = grouped
|
||||
.map((item) => {
|
||||
const owner = owners.find((candidate) => candidate.id === item.createdBy);
|
||||
return {
|
||||
id: item.createdBy,
|
||||
name: owner?.name ?? '未知用户',
|
||||
username: owner?.username ?? '',
|
||||
count: item._count._all,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => right.count - left.count || left.name.localeCompare(right.name, 'zh-CN'));
|
||||
|
||||
return { creators };
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量发布通知:一次给多个班级发同一条内容。
|
||||
*
|
||||
* 逐班串行调用 `createNotification`(复用同一套权限断言 + 上课保护 + 实时广播 + ClassIsland 推送),
|
||||
* 某个班失败只记进结果,不影响其他班 —— 前端拿到逐班结果后自己决定怎么展示与重试。
|
||||
*/
|
||||
export async function bulkCreateNotifications(
|
||||
user: TokenPayload,
|
||||
input: BulkCreateNotificationRequest,
|
||||
): Promise<BulkPublishResult> {
|
||||
const classIds = normalizeBulkClassIds(input.classIds);
|
||||
const results: BulkPublishClassResult[] = [];
|
||||
|
||||
for (const classId of classIds) {
|
||||
try {
|
||||
const dto = await createNotification(user, {
|
||||
classId,
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
priority: input.priority,
|
||||
confirmDuringClass: input.confirmDuringClass,
|
||||
});
|
||||
results.push({ classId, ok: true, id: dto.id });
|
||||
} catch (error) {
|
||||
results.push(toBulkFailure(classId, error, '发布通知'));
|
||||
}
|
||||
}
|
||||
|
||||
const summary = summarizeBulk(results);
|
||||
logger.info(
|
||||
`批量发布通知:目标 ${summary.total} 个班级,成功 ${summary.succeeded} 个,失败 ${summary.failed} 个(发起人=${user.username})`,
|
||||
);
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布通知并实时广播到 class:{classId} 房间。
|
||||
*
|
||||
@@ -92,6 +226,8 @@ export async function createNotification(
|
||||
content: input.content,
|
||||
priority,
|
||||
createdBy: user.sub,
|
||||
// 按天归档用「本地日期」:晚间发布的通知不会被 UTC 切片算到第二天(与作业同口径)
|
||||
assignDate: dayKeyLocal(new Date()),
|
||||
},
|
||||
include: { creator: creatorSelect, reads: true },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedParams } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
assignUserGroupsSchema,
|
||||
createPermissionGroupSchema,
|
||||
updatePermissionGroupSchema,
|
||||
type AssignUserGroupsInput,
|
||||
type CreatePermissionGroupInput,
|
||||
type UpdatePermissionGroupInput,
|
||||
} from './permissions.schemas.js';
|
||||
import * as permissionService from './permissions.service.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate());
|
||||
|
||||
/**
|
||||
* GET /api/permissions/me - 当前账号的生效权限。
|
||||
*
|
||||
* **故意不挂权限键**:任何登录账号都要能读自己的权限(两端据此决定入口显不显示),
|
||||
* 而"能读自己有什么权限"本身不是什么机密。班级端会话返回空集。
|
||||
*/
|
||||
router.get('/me', async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
sendOk(res, await permissionService.myPermissions(user), '获取当前权限成功');
|
||||
});
|
||||
|
||||
/** GET /api/permissions/catalog - 权限键目录(权限页整棵勾选树的唯一来源) */
|
||||
router.get('/catalog', requirePermission('permission:manage'), async (_req, res) => {
|
||||
sendOk(res, permissionService.permissionCatalog(), '获取权限目录成功');
|
||||
});
|
||||
|
||||
/** GET /api/permissions/groups - 权限组列表 */
|
||||
router.get('/groups', requirePermission('permission:manage'), async (_req, res) => {
|
||||
sendOk(res, await permissionService.listGroups(), '获取权限组成功');
|
||||
});
|
||||
|
||||
/** POST /api/permissions/groups - 新建权限组 */
|
||||
router.post(
|
||||
'/groups',
|
||||
requirePermission('permission:manage'),
|
||||
validate({ body: createPermissionGroupSchema }),
|
||||
async (req, res) => {
|
||||
const input = validatedBody<CreatePermissionGroupInput>(req);
|
||||
sendCreated(res, await permissionService.createGroup(input), '权限组已创建');
|
||||
},
|
||||
);
|
||||
|
||||
/** PATCH /api/permissions/groups/:id - 改名 / 改描述 / 整体替换权限键 */
|
||||
router.patch(
|
||||
'/groups/:id',
|
||||
requirePermission('permission:manage'),
|
||||
validate({ params: idParamSchema, body: updatePermissionGroupSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
const input = validatedBody<UpdatePermissionGroupInput>(req);
|
||||
sendOk(res, await permissionService.updateGroup(id, input), '权限组已更新');
|
||||
},
|
||||
);
|
||||
|
||||
/** DELETE /api/permissions/groups/:id - 删除权限组(内置组不允许删除) */
|
||||
router.delete(
|
||||
'/groups/:id',
|
||||
requirePermission('permission:manage'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
await permissionService.deleteGroup(id);
|
||||
sendOk(res, { id }, '权限组已删除,原成员回到角色默认权限');
|
||||
},
|
||||
);
|
||||
|
||||
/** GET /api/permissions/users - 成员列表(含所属权限组与生效权限数量) */
|
||||
router.get('/users', requirePermission('permission:manage'), async (_req, res) => {
|
||||
sendOk(res, await permissionService.listMembers(), '获取成员成功');
|
||||
});
|
||||
|
||||
/** PUT /api/permissions/users/:id/groups - 整体替换某账号的权限组 */
|
||||
router.put(
|
||||
'/users/:id/groups',
|
||||
requirePermission('permission:manage'),
|
||||
validate({ params: idParamSchema, body: assignUserGroupsSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
const input = validatedBody<AssignUserGroupsInput>(req);
|
||||
sendOk(res, await permissionService.assignUserGroups(id, input), '权限组分配已保存');
|
||||
},
|
||||
);
|
||||
|
||||
export const permissionsModule = defineModule({
|
||||
name: 'permissions',
|
||||
basePath: '/permissions',
|
||||
router,
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { PERMISSION_KEYS, type PermissionKey } from '@classhelper/shared';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* 权限键校验:必须是 shared 的 `PERMISSION_KEYS` 之一。
|
||||
*
|
||||
* 用 refine 而不是 `z.enum(PERMISSION_KEYS)`:后者需要一个非空元组类型,
|
||||
* 而 `PERMISSION_KEYS` 是 `as const` 推导出来的只读元组,转换写法又长又脆。
|
||||
*
|
||||
* 写入口一律拒绝未知键;**读出口静默忽略**(库里可能留着已下线的键)——
|
||||
* 见 `lib/permissions.ts` 的 known 集合。
|
||||
*/
|
||||
export const permissionKeySchema = z
|
||||
.string()
|
||||
.refine((value): value is PermissionKey => (PERMISSION_KEYS as readonly string[]).includes(value), {
|
||||
message: '未知的权限键',
|
||||
});
|
||||
|
||||
/** 一次最多 200 个键(目录现在不到 40 个,留余量同时挡住离谱的请求体) */
|
||||
const keysSchema = z.array(permissionKeySchema).max(200, '一次最多提交 200 个权限键');
|
||||
|
||||
export const createPermissionGroupSchema = z.object({
|
||||
name: z.string().trim().min(1, '请输入权限组名称').max(40, '权限组名称最多 40 个字'),
|
||||
description: z.string().trim().max(200, '描述最多 200 个字').optional(),
|
||||
keys: keysSchema.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* 改权限组:字段都是可选的,**传了就整体替换**。
|
||||
*
|
||||
* `keys` 是"整体替换"而不是增删(前端权限页本来就是一次提交整棵勾选树),
|
||||
* 这样不会出现"看着没勾上、其实还生效"的幽灵权限。
|
||||
*/
|
||||
export const updatePermissionGroupSchema = z.object({
|
||||
name: z.string().trim().min(1, '请输入权限组名称').max(40, '权限组名称最多 40 个字').optional(),
|
||||
description: z.string().trim().max(200, '描述最多 200 个字').optional(),
|
||||
keys: keysSchema.optional(),
|
||||
});
|
||||
|
||||
/** 给账号分配权限组:整体替换;传空数组 = 回到角色默认模板 */
|
||||
export const assignUserGroupsSchema = z.object({
|
||||
groupIds: z.array(z.string().min(1)).max(50, '一次最多分配 50 个权限组'),
|
||||
});
|
||||
|
||||
export type CreatePermissionGroupInput = z.infer<typeof createPermissionGroupSchema>;
|
||||
export type UpdatePermissionGroupInput = z.infer<typeof updatePermissionGroupSchema>;
|
||||
export type AssignUserGroupsInput = z.infer<typeof assignUserGroupsSchema>;
|
||||
@@ -0,0 +1,326 @@
|
||||
import {
|
||||
PERMISSION_CATEGORIES,
|
||||
PERMISSION_DEFAULT_ROLES,
|
||||
PERMISSION_KEYS,
|
||||
PERMISSION_LABELS,
|
||||
ROLE_DEFAULT_PERMISSIONS,
|
||||
type AssignUserGroupsRequest,
|
||||
type CreatePermissionGroupRequest,
|
||||
type MyPermissionsDto,
|
||||
type PermissionCatalogDto,
|
||||
type PermissionGroupDto,
|
||||
type PermissionKey,
|
||||
type PermissionMemberDto,
|
||||
type UpdatePermissionGroupRequest,
|
||||
type UserRole,
|
||||
} from '@classhelper/shared';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { ADMIN_UNREMOVABLE_PERMISSIONS, loadEffectivePermissions } from '../../lib/permissions.js';
|
||||
|
||||
/**
|
||||
* 权限组服务:组 / 键 / 成员三件事的全部读写都收在这里。
|
||||
*
|
||||
* 生效权限的计算**不在本文件**,而在 `lib/permissions.ts` 的 `loadEffectivePermissions`
|
||||
* (鉴权中间件每请求调用一次);本文件只负责"编辑这些组",两者共用同一份 shared 常量。
|
||||
*/
|
||||
|
||||
const GROUP_SELECT = {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
builtin: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
items: { select: { key: true } },
|
||||
_count: { select: { users: true } },
|
||||
} as const;
|
||||
|
||||
type GroupRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
builtin: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
items: { key: string }[];
|
||||
_count: { users: number };
|
||||
};
|
||||
|
||||
const MEMBER_SELECT = {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
role: true,
|
||||
permissionGroups: { select: { groupId: true } },
|
||||
} as const;
|
||||
|
||||
type MemberRow = {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string;
|
||||
role: string;
|
||||
permissionGroups: { groupId: string }[];
|
||||
};
|
||||
|
||||
/** 目录顺序(key → 序号):组的键与"我的权限"都按它排序,前端渲染才稳定 */
|
||||
const KEY_ORDER = new Map<string, number>(PERMISSION_KEYS.map((key, index) => [key, index]));
|
||||
|
||||
function knownKey(key: string): key is PermissionKey {
|
||||
return KEY_ORDER.has(key);
|
||||
}
|
||||
|
||||
function byCatalogOrder(a: string, b: string): number {
|
||||
return (KEY_ORDER.get(a) ?? Number.MAX_SAFE_INTEGER) - (KEY_ORDER.get(b) ?? Number.MAX_SAFE_INTEGER);
|
||||
}
|
||||
|
||||
/** 去重 + 丢掉已下线的键 + 按目录顺序排序 */
|
||||
function normalizeKeys(keys: readonly string[] | undefined): PermissionKey[] {
|
||||
const unique = new Set<PermissionKey>();
|
||||
for (const key of keys ?? []) if (knownKey(key)) unique.add(key);
|
||||
return [...unique].sort(byCatalogOrder);
|
||||
}
|
||||
|
||||
function toGroupDto(row: GroupRow): PermissionGroupDto {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
builtin: row.builtin,
|
||||
keys: row.items.map((item) => item.key).filter(knownKey).sort(byCatalogOrder),
|
||||
memberCount: row._count.users,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** GET /api/permissions/catalog - 权限键目录(权限页整棵勾选树都按它渲染) */
|
||||
export function permissionCatalog(): PermissionCatalogDto {
|
||||
const roles = Object.keys(ROLE_DEFAULT_PERMISSIONS) as UserRole[];
|
||||
return {
|
||||
keys: [...PERMISSION_KEYS],
|
||||
labels: { ...PERMISSION_LABELS },
|
||||
categories: PERMISSION_CATEGORIES.map((category) => ({
|
||||
title: category.title,
|
||||
keys: [...category.keys],
|
||||
})),
|
||||
defaultRoles: Object.fromEntries(
|
||||
PERMISSION_KEYS.map((key) => [key, [...PERMISSION_DEFAULT_ROLES[key]]]),
|
||||
) as Record<PermissionKey, UserRole[]>,
|
||||
roleDefaults: Object.fromEntries(
|
||||
roles.map((role) => [role, [...ROLE_DEFAULT_PERMISSIONS[role]]]),
|
||||
) as Record<UserRole, PermissionKey[]>,
|
||||
unremovable: [...ADMIN_UNREMOVABLE_PERMISSIONS],
|
||||
};
|
||||
}
|
||||
|
||||
/** GET /api/permissions/groups - 权限组列表(内置组排在最前,其余按创建时间) */
|
||||
export async function listGroups(): Promise<PermissionGroupDto[]> {
|
||||
const rows = await prisma.permissionGroup.findMany({
|
||||
select: GROUP_SELECT,
|
||||
orderBy: [{ builtin: 'desc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
return (rows as GroupRow[]).map(toGroupDto);
|
||||
}
|
||||
|
||||
export async function createGroup(input: CreatePermissionGroupRequest): Promise<PermissionGroupDto> {
|
||||
const name = input.name.trim();
|
||||
const sameName = await prisma.permissionGroup.findUnique({ where: { name }, select: { id: true } });
|
||||
if (sameName) throw ApiError.conflict(`已存在名为「${name}」的权限组`);
|
||||
|
||||
const keys = normalizeKeys(input.keys);
|
||||
const created = await prisma.permissionGroup.create({
|
||||
data: {
|
||||
name,
|
||||
description: input.description?.trim() ?? '',
|
||||
items: { create: keys.map((key) => ({ key })) },
|
||||
},
|
||||
select: GROUP_SELECT,
|
||||
});
|
||||
logger.info(`新建权限组「${name}」(${keys.length} 个权限键)`);
|
||||
return toGroupDto(created as GroupRow);
|
||||
}
|
||||
|
||||
export async function updateGroup(
|
||||
groupId: string,
|
||||
input: UpdatePermissionGroupRequest,
|
||||
): Promise<PermissionGroupDto> {
|
||||
const current = await prisma.permissionGroup.findUnique({
|
||||
where: { id: groupId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
if (!current) throw ApiError.notFound('权限组不存在');
|
||||
|
||||
const name = input.name?.trim();
|
||||
if (name && name !== current.name) {
|
||||
const sameName = await prisma.permissionGroup.findUnique({ where: { name }, select: { id: true } });
|
||||
if (sameName) throw ApiError.conflict(`已存在名为「${name}」的权限组`);
|
||||
}
|
||||
|
||||
const keys = input.keys ? normalizeKeys(input.keys) : null;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.permissionGroup.update({
|
||||
where: { id: groupId },
|
||||
data: {
|
||||
...(name ? { name } : {}),
|
||||
...(input.description !== undefined ? { description: input.description.trim() } : {}),
|
||||
},
|
||||
});
|
||||
// 键是整体替换:先清后建。漏传某个键就等于"取消勾选",不会留下幽灵权限。
|
||||
if (keys) {
|
||||
await tx.permissionGroupItem.deleteMany({ where: { groupId } });
|
||||
if (keys.length > 0) {
|
||||
await tx.permissionGroupItem.createMany({ data: keys.map((key) => ({ groupId, key })) });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const updated = await prisma.permissionGroup.findUniqueOrThrow({
|
||||
where: { id: groupId },
|
||||
select: GROUP_SELECT,
|
||||
});
|
||||
logger.info(
|
||||
`更新权限组「${(updated as GroupRow).name}」(${keys ? `权限键 ${keys.length} 个` : '未改权限键'})`,
|
||||
);
|
||||
return toGroupDto(updated as GroupRow);
|
||||
}
|
||||
|
||||
export async function deleteGroup(groupId: string): Promise<{ id: string }> {
|
||||
const current = await prisma.permissionGroup.findUnique({
|
||||
where: { id: groupId },
|
||||
select: { id: true, name: true, builtin: true, _count: { select: { users: true } } },
|
||||
});
|
||||
if (!current) throw ApiError.notFound('权限组不存在');
|
||||
if (current.builtin) {
|
||||
throw ApiError.badRequest(`「${current.name}」是内置权限组,不能删除(可以改名或调整里面的权限)`);
|
||||
}
|
||||
|
||||
// 键与成员关系都是 onDelete: Cascade,直接删组即可;
|
||||
// 组内成员随后"没有任何分组",会回落角色默认模板(不会变成空权限)。
|
||||
await prisma.permissionGroup.delete({ where: { id: groupId } });
|
||||
logger.info(
|
||||
`删除权限组「${current.name}」(原成员 ${current._count.users} 个,已回到角色默认权限)`,
|
||||
);
|
||||
return { id: groupId };
|
||||
}
|
||||
|
||||
/** 把一行 User(含分组关联)转成权限页用的成员 DTO */
|
||||
function toMemberDto(
|
||||
row: MemberRow,
|
||||
keysOfGroup: Map<string, PermissionKey[]>,
|
||||
): PermissionMemberDto {
|
||||
const groupIds = row.permissionGroups.map((link) => link.groupId);
|
||||
const union = new Set<PermissionKey>();
|
||||
for (const groupId of groupIds) for (const key of keysOfGroup.get(groupId) ?? []) union.add(key);
|
||||
|
||||
const role = row.role as UserRole;
|
||||
const permissionCount =
|
||||
role === 'ADMIN'
|
||||
? PERMISSION_KEYS.length
|
||||
: groupIds.length === 0
|
||||
? ROLE_DEFAULT_PERMISSIONS[role].length
|
||||
: union.size;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
name: row.name,
|
||||
role,
|
||||
groupIds,
|
||||
permissionCount,
|
||||
fixed: role === 'ADMIN',
|
||||
};
|
||||
}
|
||||
|
||||
/** GET /api/permissions/users - 成员列表(管理员/教师账号;班级端账号不参与权限组) */
|
||||
export async function listMembers(): Promise<PermissionMemberDto[]> {
|
||||
const [rows, groups] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where: { role: { in: ['ADMIN', 'TEACHER'] } },
|
||||
select: MEMBER_SELECT,
|
||||
orderBy: [{ role: 'asc' }, { name: 'asc' }],
|
||||
}),
|
||||
prisma.permissionGroup.findMany({ select: { id: true, items: { select: { key: true } } } }),
|
||||
]);
|
||||
|
||||
const keysOfGroup = new Map<string, PermissionKey[]>(
|
||||
groups.map((group) => [
|
||||
group.id,
|
||||
group.items.map((item) => item.key).filter(knownKey).sort(byCatalogOrder),
|
||||
]),
|
||||
);
|
||||
|
||||
return (rows as MemberRow[]).map((row) => toMemberDto(row, keysOfGroup));
|
||||
}
|
||||
|
||||
/** PUT /api/permissions/users/:id/groups - 整体替换某账号的权限组 */
|
||||
export async function assignUserGroups(
|
||||
userId: string,
|
||||
input: AssignUserGroupsRequest,
|
||||
): Promise<PermissionMemberDto> {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId }, select: MEMBER_SELECT });
|
||||
if (!user) throw ApiError.notFound('账号不存在');
|
||||
|
||||
const row = user as MemberRow;
|
||||
if (row.role === 'ADMIN') {
|
||||
throw ApiError.badRequest('管理员固定拥有全部权限,不需要(也不能)分配权限组');
|
||||
}
|
||||
if (row.role === 'CLASS_DEVICE') {
|
||||
throw ApiError.badRequest(
|
||||
'ClassHelper 班级端账号的能力由"班级内角色"决定(见权限矩阵),不参与权限组分配',
|
||||
);
|
||||
}
|
||||
|
||||
const groupIds = [...new Set(input.groupIds)];
|
||||
if (groupIds.length > 0) {
|
||||
const found = await prisma.permissionGroup.count({ where: { id: { in: groupIds } } });
|
||||
if (found !== groupIds.length) throw ApiError.badRequest('有权限组不存在,请刷新后重试');
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.userPermissionGroup.deleteMany({ where: { userId } });
|
||||
if (groupIds.length > 0) {
|
||||
await tx.userPermissionGroup.createMany({
|
||||
data: groupIds.map((groupId) => ({ userId, groupId })),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`分配权限组:${row.username} → ${groupIds.length > 0 ? `${groupIds.length} 个组` : '角色默认模板'}`,
|
||||
);
|
||||
|
||||
const groups = await prisma.permissionGroup.findMany({
|
||||
select: { id: true, items: { select: { key: true } } },
|
||||
});
|
||||
const keysOfGroup = new Map<string, PermissionKey[]>(
|
||||
groups.map((group) => [
|
||||
group.id,
|
||||
group.items.map((item) => item.key).filter(knownKey).sort(byCatalogOrder),
|
||||
]),
|
||||
);
|
||||
return toMemberDto({ ...row, permissionGroups: groupIds.map((groupId) => ({ groupId })) }, keysOfGroup);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/permissions/me - 当前账号的生效权限。
|
||||
*
|
||||
* 两端按它决定"入口显不显示"(真正的拦截始终在服务端)。
|
||||
* 班级端会话恒为空集:它的能力由班级维度断言决定。
|
||||
*/
|
||||
export async function myPermissions(user: TokenPayload): Promise<MyPermissionsDto> {
|
||||
const permissions = await loadEffectivePermissions(user);
|
||||
const fromGroups =
|
||||
user.classSession || user.role === 'CLASS_DEVICE' || user.role === 'ADMIN'
|
||||
? false
|
||||
: (await prisma.userPermissionGroup.count({ where: { userId: user.sub } })) > 0;
|
||||
|
||||
return {
|
||||
role: user.role,
|
||||
permissions: [...permissions].sort(byCatalogOrder),
|
||||
fromGroups,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { aiModule } from './ai/ai.module.js';
|
||||
import { archivesModule } from './archives/archives.module.js';
|
||||
import { authModule } from './auth/auth.module.js';
|
||||
import { callsModule } from './calls/calls.module.js';
|
||||
import { classesModule } from './classes/classes.module.js';
|
||||
import { clientsModule } from './clients/clients.module.js';
|
||||
import { coursesModule } from './courses/courses.module.js';
|
||||
import { databaseModule } from './database/database.module.js';
|
||||
import { dashboardModule } from './dashboard/dashboard.module.js';
|
||||
@@ -10,8 +12,10 @@ import { homeworksModule } from './homeworks/homeworks.module.js';
|
||||
import { importsModule } from './imports/imports.module.js';
|
||||
import { integrationsModule } from './integrations/integrations.module.js';
|
||||
import { notificationsModule } from './notifications/notifications.module.js';
|
||||
import { permissionsModule } from './permissions/permissions.module.js';
|
||||
import { schedulesModule } from './schedules/schedules.module.js';
|
||||
import { studentsModule } from './students/students.module.js';
|
||||
import { systemModule } from './system/system.module.js';
|
||||
import { teachersModule } from './teachers/teachers.module.js';
|
||||
import { termModule } from './term/term.module.js';
|
||||
import { updateModule } from './update/update.module.js';
|
||||
@@ -26,16 +30,20 @@ import type { ApiModule } from './module.types.js';
|
||||
*/
|
||||
export const apiModules: ApiModule[] = [
|
||||
authModule,
|
||||
aiModule,
|
||||
classesModule,
|
||||
clientsModule,
|
||||
coursesModule,
|
||||
schedulesModule,
|
||||
homeworksModule,
|
||||
notificationsModule,
|
||||
permissionsModule,
|
||||
callsModule,
|
||||
importsModule,
|
||||
integrationsModule,
|
||||
gradesModule,
|
||||
studentsModule,
|
||||
systemModule,
|
||||
teachersModule,
|
||||
archivesModule,
|
||||
termModule,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
@@ -58,7 +58,7 @@ router.get('/current', validate({ query: classStatusQuerySchema }), async (req,
|
||||
/** POST /api/schedules - 新增课表 */
|
||||
router.post(
|
||||
'/',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('schedule:write'),
|
||||
validate({ body: createScheduleSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -73,7 +73,7 @@ router.post(
|
||||
/** PATCH /api/schedules/:id - 修改课表 */
|
||||
router.patch(
|
||||
'/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('schedule:write'),
|
||||
validate({ params: idParamSchema, body: updateScheduleSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -89,7 +89,7 @@ router.patch(
|
||||
/** DELETE /api/schedules/:id - 删除课表 */
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('schedule:write'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
@@ -33,7 +33,7 @@ router.use(authenticate());
|
||||
*/
|
||||
router.get(
|
||||
'/',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('roster:manage'),
|
||||
validate({ query: listStudentsQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -45,7 +45,7 @@ router.get(
|
||||
/** GET /api/students/transfers - 调班 / 转出历史(管理员看全部,也可按学生或班级过滤) */
|
||||
router.get(
|
||||
'/transfers',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('roster:manage'),
|
||||
validate({ query: listTransfersQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -62,7 +62,7 @@ router.get(
|
||||
*/
|
||||
router.post(
|
||||
'/transfer',
|
||||
requireRole('ADMIN'),
|
||||
requirePermission('class:manage'),
|
||||
validate({ body: transferStudentsSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -74,7 +74,7 @@ router.post(
|
||||
/** POST /api/students/transfer-out - 学生转出(学籍离开本校,不是调班) */
|
||||
router.post(
|
||||
'/transfer-out',
|
||||
requireRole('ADMIN'),
|
||||
requirePermission('class:manage'),
|
||||
validate({ body: transferOutStudentsSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -86,7 +86,7 @@ router.post(
|
||||
/** GET /api/students/:id/transfers - 单个学生的调班 / 转出历史 */
|
||||
router.get(
|
||||
'/:id/transfers',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('roster:manage'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -99,7 +99,7 @@ router.get(
|
||||
/** POST /api/students - 新建学生(名单记录,不是账号) */
|
||||
router.post(
|
||||
'/',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('roster:manage'),
|
||||
validate({ body: createStudentSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -114,7 +114,7 @@ router.post(
|
||||
/** PATCH /api/students/:id - 修改学生信息 / 调班 */
|
||||
router.patch(
|
||||
'/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('roster:manage'),
|
||||
validate({ params: idParamSchema, body: updateStudentSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -130,7 +130,7 @@ router.patch(
|
||||
/** DELETE /api/students/:id - 删除学生名单记录 */
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('roster:manage'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Router } from 'express';
|
||||
import { API_PATHS } from '@classhelper/shared';
|
||||
import { sendOk } from '../../lib/http.js';
|
||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedBody } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
updateAutostartSchema,
|
||||
updateSystemSettingsSchema,
|
||||
type UpdateAutostartInput,
|
||||
type UpdateSystemSettingsInput,
|
||||
} from './system.schemas.js';
|
||||
import * as systemService from './system.service.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate());
|
||||
|
||||
/**
|
||||
* 系统设置与运维接口(**全部仅 ADMIN**,权限键 `system:manage`)。
|
||||
*
|
||||
* 这些接口改的是"这台服务器怎么运行"(开发者模式、开机自启),
|
||||
* 与业务数据无关,因此不走任何班级维度断言。
|
||||
*/
|
||||
router.get('/settings', requirePermission('system:manage'), async (_req, res) => {
|
||||
sendOk(res, await systemService.getSystemSettings(), '获取系统设置成功');
|
||||
});
|
||||
|
||||
router.put(
|
||||
'/settings',
|
||||
requirePermission('system:manage'),
|
||||
validate({ body: updateSystemSettingsSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const input = validatedBody<UpdateSystemSettingsInput>(req);
|
||||
sendOk(res, await systemService.updateSystemSettings(user, input), '系统设置已保存');
|
||||
},
|
||||
);
|
||||
|
||||
router.get('/autostart', requirePermission('system:manage'), async (_req, res) => {
|
||||
sendOk(res, await systemService.getAutostartStatus(), '获取开机自启状态成功');
|
||||
});
|
||||
|
||||
router.put(
|
||||
'/autostart',
|
||||
requirePermission('system:manage'),
|
||||
validate({ body: updateAutostartSchema }),
|
||||
async (req, res) => {
|
||||
const input = validatedBody<UpdateAutostartInput>(req);
|
||||
sendOk(res, await systemService.updateAutostart(input), '开机自启设置已生效');
|
||||
},
|
||||
);
|
||||
|
||||
/** 运行诊断:开发者模式下排查"服务端到底跑的哪份代码、数据在哪" */
|
||||
router.get('/diagnostics', requirePermission('system:manage'), async (_req, res) => {
|
||||
sendOk(res, await systemService.getDiagnostics(), '获取运行诊断成功');
|
||||
});
|
||||
|
||||
export const systemModule = defineModule({
|
||||
name: 'system',
|
||||
basePath: API_PATHS.system,
|
||||
router,
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/** `PUT /api/system/settings`:目前只有开发者模式(字段都可选,传了就改) */
|
||||
export const updateSystemSettingsSchema = z.object({
|
||||
developerMode: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/** `PUT /api/system/autostart`:开/关服务端开机自启 */
|
||||
export const updateAutostartSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
export type UpdateSystemSettingsInput = z.infer<typeof updateSystemSettingsSchema>;
|
||||
export type UpdateAutostartInput = z.infer<typeof updateAutostartSchema>;
|
||||
@@ -0,0 +1,153 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
CLIENT_ONLINE_WINDOW_MS,
|
||||
type AutostartStatusDto,
|
||||
type SystemDiagnosticsDto,
|
||||
type SystemSettingsDto,
|
||||
} from '@classhelper/shared';
|
||||
import { env } from '../../config/env.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { SETTING_KEYS, getBooleanSetting, setBooleanSetting } from '../../lib/settings.js';
|
||||
import { APP_VERSION } from '../../lib/version.js';
|
||||
import type { UpdateAutostartInput, UpdateSystemSettingsInput } from './system.schemas.js';
|
||||
|
||||
/**
|
||||
* 服务端系统设置:开发者模式、开机自启、运行诊断。
|
||||
*
|
||||
* 为什么"开机自启"要做成接口而不是只在安装器里勾一下:教室服务器换机器、改过安装目录、
|
||||
* 或者管理员当时没勾,事后都得能补 —— 安装器只负责**默认值**,运行期由这里改。
|
||||
* 它改的是**操作系统**的东西(注册表 Run 项 / systemd 单元),所以:
|
||||
* 1. 只在"装好的形态"里放行(开发机、免安装包、容器里返回 `supported:false`);
|
||||
* 2. 失败原因是运维性质的(权限不足、单元文件不存在),一律带原文回给管理员,不吞成 500。
|
||||
*/
|
||||
const RUN_KEY_PATH = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
|
||||
const RUN_KEY_NAME = 'ClassHelperServer';
|
||||
const SYSTEMD_UNIT = 'classhelper';
|
||||
|
||||
function runCommand(file: string, args: string[]): { ok: boolean; output: string } {
|
||||
const result = spawnSync(file, args, { encoding: 'utf8', windowsHide: true });
|
||||
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim();
|
||||
return { ok: result.status === 0, output: output || (result.error ? result.error.message : '') };
|
||||
}
|
||||
|
||||
/** 安装器写在安装目录里的启动脚本:它存在才算"装好的形态" */
|
||||
function startCmdPath(): string {
|
||||
return path.join(path.dirname(process.execPath), 'start.cmd');
|
||||
}
|
||||
|
||||
export async function getSystemSettings(): Promise<SystemSettingsDto> {
|
||||
return { developerMode: await getBooleanSetting(SETTING_KEYS.developerMode, false) };
|
||||
}
|
||||
|
||||
export async function updateSystemSettings(
|
||||
user: TokenPayload,
|
||||
input: UpdateSystemSettingsInput,
|
||||
): Promise<SystemSettingsDto> {
|
||||
if (input.developerMode !== undefined) {
|
||||
await setBooleanSetting(SETTING_KEYS.developerMode, input.developerMode);
|
||||
logger.info(`开发者模式已${input.developerMode ? '开启' : '关闭'}(操作人=${user.username})`);
|
||||
}
|
||||
return getSystemSettings();
|
||||
}
|
||||
|
||||
export async function getAutostartStatus(): Promise<AutostartStatusDto> {
|
||||
if (process.platform === 'win32') {
|
||||
const cmd = startCmdPath();
|
||||
if (!fs.existsSync(cmd)) {
|
||||
return {
|
||||
supported: false,
|
||||
enabled: false,
|
||||
mechanism: 'unsupported',
|
||||
detail: '未检测到安装形态(缺少 start.cmd):开发环境或免安装目录不提供开机自启切换',
|
||||
};
|
||||
}
|
||||
const query = runCommand('reg.exe', ['query', RUN_KEY_PATH, '/v', RUN_KEY_NAME]);
|
||||
return {
|
||||
supported: true,
|
||||
enabled: query.ok,
|
||||
mechanism: 'run-key',
|
||||
detail: `注册表 HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\${RUN_KEY_NAME} → "${cmd}"`,
|
||||
};
|
||||
}
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
const unit = `/etc/systemd/system/${SYSTEMD_UNIT}.service`;
|
||||
if (!fs.existsSync(unit)) {
|
||||
return {
|
||||
supported: false,
|
||||
enabled: false,
|
||||
mechanism: 'unsupported',
|
||||
detail: `未找到 ${unit}(不是 systemd 安装形态)`,
|
||||
};
|
||||
}
|
||||
const query = runCommand('systemctl', ['is-enabled', SYSTEMD_UNIT]);
|
||||
return {
|
||||
supported: true,
|
||||
enabled: query.ok,
|
||||
mechanism: 'systemd',
|
||||
detail: `systemctl is-enabled ${SYSTEMD_UNIT}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
supported: false,
|
||||
enabled: false,
|
||||
mechanism: 'unsupported',
|
||||
detail: `当前平台(${process.platform})不支持切换开机自启`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateAutostart(input: UpdateAutostartInput): Promise<AutostartStatusDto> {
|
||||
const current = await getAutostartStatus();
|
||||
if (!current.supported) throw ApiError.badRequest(current.detail);
|
||||
|
||||
if (current.mechanism === 'run-key') {
|
||||
const cmd = startCmdPath();
|
||||
const result = input.enabled
|
||||
? runCommand('reg.exe', ['add', RUN_KEY_PATH, '/v', RUN_KEY_NAME, '/t', 'REG_SZ', '/d', `"${cmd}"`, '/f'])
|
||||
: runCommand('reg.exe', ['delete', RUN_KEY_PATH, '/v', RUN_KEY_NAME, '/f']);
|
||||
if (!result.ok) throw ApiError.internal(`写入开机自启项失败:${result.output}`);
|
||||
} else {
|
||||
const result = runCommand('systemctl', [input.enabled ? 'enable' : 'disable', SYSTEMD_UNIT]);
|
||||
if (!result.ok) {
|
||||
throw ApiError.badRequest(
|
||||
`systemctl ${input.enabled ? 'enable' : 'disable'} ${SYSTEMD_UNIT} 失败:${result.output}(服务是否以特权用户运行?)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`服务端开机自启已${input.enabled ? '开启' : '关闭'}(机制=${current.mechanism})`);
|
||||
return getAutostartStatus();
|
||||
}
|
||||
|
||||
export async function getDiagnostics(): Promise<SystemDiagnosticsDto> {
|
||||
const now = Date.now();
|
||||
// 延迟 import:registry 会 import 本模块,顶层互相引用在 ESM 下会拿到未初始化的绑定
|
||||
const { apiModules } = await import('../registry.js');
|
||||
const [clientReleaseCount, onlineClientCount, settings] = await Promise.all([
|
||||
prisma.clientRelease.count(),
|
||||
prisma.clientDevice.count({
|
||||
where: { lastHeartbeatAt: { gt: new Date(now - CLIENT_ONLINE_WINDOW_MS) } },
|
||||
}),
|
||||
getSystemSettings(),
|
||||
]);
|
||||
|
||||
return {
|
||||
version: APP_VERSION,
|
||||
nodeVersion: process.versions.node,
|
||||
platform: `${process.platform}-${process.arch}`,
|
||||
uptimeSeconds: Math.round(process.uptime()),
|
||||
databaseProvider: env.databaseProvider,
|
||||
serverRoot: env.serverRoot,
|
||||
dataDir: path.resolve(env.serverRoot, '..', 'data'),
|
||||
clientReleaseCount,
|
||||
onlineClientCount,
|
||||
moduleCount: apiModules.length,
|
||||
developerMode: settings.developerMode,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, requireRole } from '../../middleware/auth.js';
|
||||
import { authenticate, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
@@ -25,20 +25,30 @@ router.use(authenticate());
|
||||
*/
|
||||
|
||||
/** GET /api/teachers?keyword= - 教师列表(教师管理页 + 分配协作教师时使用) */
|
||||
router.get('/', requireRole('ADMIN'), validate({ query: listTeachersQuerySchema }), async (req, res) => {
|
||||
const { keyword } = validatedQuery<{ keyword?: string }>(req);
|
||||
sendOk(res, await teacherService.listTeachers(keyword), '获取教师列表成功');
|
||||
});
|
||||
router.get(
|
||||
'/',
|
||||
requirePermission('teacher:manage'),
|
||||
validate({ query: listTeachersQuerySchema }),
|
||||
async (req, res) => {
|
||||
const { keyword } = validatedQuery<{ keyword?: string }>(req);
|
||||
sendOk(res, await teacherService.listTeachers(keyword), '获取教师列表成功');
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/teachers - 录入教师账号(仅管理员) */
|
||||
router.post('/', requireRole('ADMIN'), validate({ body: createTeacherSchema }), async (req, res) => {
|
||||
sendCreated(res, await teacherService.createTeacher(req.body as CreateTeacherInput), '教师账号创建成功');
|
||||
});
|
||||
router.post(
|
||||
'/',
|
||||
requirePermission('teacher:manage'),
|
||||
validate({ body: createTeacherSchema }),
|
||||
async (req, res) => {
|
||||
sendCreated(res, await teacherService.createTeacher(req.body as CreateTeacherInput), '教师账号创建成功');
|
||||
},
|
||||
);
|
||||
|
||||
/** PATCH /api/teachers/:id - 编辑教师(姓名 / 用户名 / 角色) */
|
||||
router.patch(
|
||||
'/:id',
|
||||
requireRole('ADMIN'),
|
||||
requirePermission('teacher:manage'),
|
||||
validate({ params: idParamSchema, body: updateTeacherSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
@@ -49,7 +59,7 @@ router.patch(
|
||||
/** POST /api/teachers/:id/reset-password - 重置教师密码 */
|
||||
router.post(
|
||||
'/:id/reset-password',
|
||||
requireRole('ADMIN'),
|
||||
requirePermission('teacher:manage'),
|
||||
validate({ params: idParamSchema, body: resetTeacherPasswordSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
@@ -59,11 +69,16 @@ router.post(
|
||||
);
|
||||
|
||||
/** DELETE /api/teachers/:id - 删除教师账号(有班级职责/课程时拒绝,见 service) */
|
||||
router.delete('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
await teacherService.deleteTeacher(id);
|
||||
sendOk(res, { id }, '教师账号已删除');
|
||||
});
|
||||
router.delete(
|
||||
'/:id',
|
||||
requirePermission('teacher:manage'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
await teacherService.deleteTeacher(id);
|
||||
sendOk(res, { id }, '教师账号已删除');
|
||||
},
|
||||
);
|
||||
|
||||
export const teachersModule = defineModule({
|
||||
name: 'teachers',
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
} from './teachers.schemas.js';
|
||||
|
||||
/**
|
||||
* 教师管理(**仅管理员**,路由层已用 requireRole('ADMIN') 兜住):
|
||||
* 教师管理(**仅管理员**,路由层已用 requirePermission('teacher:manage') 兜住):
|
||||
* 录入教师是管理员专属操作,班主任与科任老师都没有该权限。
|
||||
* 学生名单在 students 模块,两个模块结构保持一致(列表 / 新建 / 编辑 / 重置密码 / 删除)。
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
import { sendOk } from '../../lib/http.js';
|
||||
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
|
||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
@@ -23,7 +23,7 @@ router.use(authenticate());
|
||||
* - **写**:仅管理员 —— 周次是全校口径,改错会让所有班的课表整体错位;
|
||||
* - `classId` 留空 = 全校默认;某个班有自己的配置时优先用它。
|
||||
*/
|
||||
router.get('/', requireRole('ADMIN', 'TEACHER'), validate({ query: termQuerySchema }), async (req, res) => {
|
||||
router.get('/', requirePermission('term:read'), validate({ query: termQuerySchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { classId } = validatedQuery<{ classId?: string }>(req);
|
||||
sendOk(res, await termService.getTermWeeks(user, classId), '获取学期周次成功');
|
||||
@@ -32,7 +32,7 @@ router.get('/', requireRole('ADMIN', 'TEACHER'), validate({ query: termQuerySche
|
||||
/** PUT /api/term - 保存逐周区间(整表覆盖,仅管理员) */
|
||||
router.put(
|
||||
'/',
|
||||
requireRole('ADMIN'),
|
||||
requirePermission('term:write'),
|
||||
validate({ body: updateTermWeeksSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -51,7 +51,7 @@ router.put(
|
||||
*/
|
||||
router.post(
|
||||
'/auto',
|
||||
requireRole('ADMIN'),
|
||||
requirePermission('term:write'),
|
||||
validate({ body: autoTermWeeksSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -70,7 +70,7 @@ router.post(
|
||||
*/
|
||||
router.get(
|
||||
'/holidays',
|
||||
requireRole('ADMIN'),
|
||||
requirePermission('term:write'),
|
||||
validate({ query: holidayQuerySchema }),
|
||||
async (req, res) => {
|
||||
const { year } = validatedQuery<{ year?: number }>(req);
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import {
|
||||
API_PREFIX,
|
||||
CLIENT_RELEASE_DIR,
|
||||
clientReleaseDownloadUrl,
|
||||
clientReleaseFileName,
|
||||
compareVersions,
|
||||
type ClientReleaseDto,
|
||||
type ClientUpdateInfoDto,
|
||||
} from '@classhelper/shared';
|
||||
import { env } from '../../config/env.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import type { FetchClientReleaseInput, RegisterClientReleaseInput } from './update.schemas.js';
|
||||
|
||||
/**
|
||||
* 客户端安装包在服务端的缓存。
|
||||
*
|
||||
* 为什么要有这一层:教室机器常常**没有外网**,而"统一下发客户端更新"必须能落到
|
||||
* 那些机器上。于是服务端替它们代下(或由管理员手工放一份)并缓存到
|
||||
* `data/client-releases/<版本>/`,客户端再从 `GET /api/update/client/download/:version` 拉。
|
||||
*
|
||||
* 两个入口都**不传二进制**(服务端没有 multipart,也不打算为这一件事加):
|
||||
* - `fetch`:服务端自己去 GitHub Releases 下(Web 端受 CSP 约束不能直连 GitHub,见 §5 第 52 条);
|
||||
* - `register`:文件已由管理员放进磁盘(内网分发、U 盘、CI 产物),服务端只算 sha256 并登记。
|
||||
*/
|
||||
|
||||
/** 代下大文件时的超时(安装包约 100MB,机房线路慢要留足) */
|
||||
const FETCH_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
type ReleaseRow = {
|
||||
id: string;
|
||||
version: string;
|
||||
fileName: string;
|
||||
sha256: string;
|
||||
size: number;
|
||||
notes: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
function releaseRoot(): string {
|
||||
// 与 database 模块的备份目录同口径:<serverRoot>/../data
|
||||
const dir = path.join(env.serverRoot, '..', 'data', CLIENT_RELEASE_DIR);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function releaseDir(version: string): string {
|
||||
return path.join(releaseRoot(), version);
|
||||
}
|
||||
|
||||
function releaseFilePath(version: string, fileName: string): string {
|
||||
return path.join(releaseDir(version), fileName);
|
||||
}
|
||||
|
||||
function toReleaseDto(row: ReleaseRow): ClientReleaseDto {
|
||||
return {
|
||||
version: row.version,
|
||||
fileName: row.fileName,
|
||||
size: row.size,
|
||||
sha256: row.sha256,
|
||||
notes: row.notes,
|
||||
fileReady: fs.existsSync(releaseFilePath(row.version, row.fileName)),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function sha256Of(file: string): Promise<string> {
|
||||
const hash = crypto.createHash('sha256');
|
||||
await pipeline(fs.createReadStream(file), hash);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
/** GET /api/update/client/releases - 已缓存的安装包(按版本从新到旧) */
|
||||
export async function listReleases(): Promise<ClientReleaseDto[]> {
|
||||
const rows = (await prisma.clientRelease.findMany()) as unknown as ReleaseRow[];
|
||||
return rows
|
||||
.sort((a, b) => compareVersions(b.version, a.version))
|
||||
.map((row) => toReleaseDto(row));
|
||||
}
|
||||
|
||||
/** 服务端缓存的**最新**版本(推送更新时省略 version 就用它) */
|
||||
export async function latestReleaseVersion(): Promise<string> {
|
||||
const rows = (await prisma.clientRelease.findMany()) as unknown as ReleaseRow[];
|
||||
const ready = rows.filter((row) => fs.existsSync(releaseFilePath(row.version, row.fileName)));
|
||||
ready.sort((a, b) => compareVersions(b.version, a.version));
|
||||
return ready[0]?.version ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 某个版本的下载信息(心跳响应里带给客户端)。
|
||||
* 文件不在磁盘上时返回 null —— 客户端据此提示"服务端还没有这份安装包"。
|
||||
*/
|
||||
export async function resolveUpdateInfo(version: string): Promise<ClientUpdateInfoDto | null> {
|
||||
const wanted = version.trim();
|
||||
if (!wanted) return null;
|
||||
const row = (await prisma.clientRelease.findUnique({ where: { version: wanted } })) as ReleaseRow | null;
|
||||
if (!row) return null;
|
||||
if (!fs.existsSync(releaseFilePath(row.version, row.fileName))) return null;
|
||||
return {
|
||||
version: row.version,
|
||||
fileName: row.fileName,
|
||||
size: row.size,
|
||||
sha256: row.sha256,
|
||||
notes: row.notes,
|
||||
// 含 /api 前缀:客户端用「服务器地址 + 它」直接下载
|
||||
url: `${API_PREFIX}/update/client/download/${encodeURIComponent(row.version)}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** 绝对路径(下载路由用);文件不存在返回 null */
|
||||
export async function resolveReleaseFile(version: string): Promise<{ file: string; fileName: string } | null> {
|
||||
const row = (await prisma.clientRelease.findUnique({ where: { version } })) as ReleaseRow | null;
|
||||
if (!row) return null;
|
||||
const file = releaseFilePath(row.version, row.fileName);
|
||||
if (!fs.existsSync(file)) return null;
|
||||
return { file, fileName: row.fileName };
|
||||
}
|
||||
|
||||
/** 登记一份已经在磁盘上的安装包:算 sha256 / 大小后落库(可重复登记,会覆盖) */
|
||||
export async function registerRelease(
|
||||
userId: string,
|
||||
input: RegisterClientReleaseInput,
|
||||
): Promise<ClientReleaseDto> {
|
||||
const version = input.version;
|
||||
// 文件名默认走发布命名约定(与 GitHub Release、官网兜底直链一致)
|
||||
const known = (await prisma.clientRelease.findUnique({ where: { version } })) as ReleaseRow | null;
|
||||
const fileName = input.fileName ?? known?.fileName ?? clientReleaseFileName(version);
|
||||
const file = releaseFilePath(version, fileName);
|
||||
|
||||
if (!fs.existsSync(file)) {
|
||||
throw ApiError.badRequest(
|
||||
`没找到安装包文件「${fileName}」,请先把它放进 data/${CLIENT_RELEASE_DIR}/${version}/ 再登记`,
|
||||
);
|
||||
}
|
||||
|
||||
const stat = fs.statSync(file);
|
||||
const sha256 = await sha256Of(file);
|
||||
const row = (await prisma.clientRelease.upsert({
|
||||
where: { version },
|
||||
create: {
|
||||
version,
|
||||
fileName,
|
||||
sha256,
|
||||
size: stat.size,
|
||||
notes: input.notes?.trim() ?? '',
|
||||
createdBy: userId,
|
||||
},
|
||||
update: {
|
||||
fileName,
|
||||
sha256,
|
||||
size: stat.size,
|
||||
...(input.notes !== undefined ? { notes: input.notes.trim() } : {}),
|
||||
},
|
||||
})) as ReleaseRow;
|
||||
|
||||
logger.info(`登记客户端安装包 v${version}(${fileName},${stat.size} 字节,sha256=${sha256.slice(0, 12)}…)`);
|
||||
return toReleaseDto(row);
|
||||
}
|
||||
|
||||
/** 让服务端从 GitHub 拉一份安装包并登记(教室无外网时由服务端代下) */
|
||||
export async function fetchReleaseFromGithub(
|
||||
userId: string,
|
||||
input: FetchClientReleaseInput,
|
||||
): Promise<ClientReleaseDto> {
|
||||
const version = input.version;
|
||||
const fileName = clientReleaseFileName(version);
|
||||
const url = clientReleaseDownloadUrl(version);
|
||||
const target = releaseFilePath(version, fileName);
|
||||
|
||||
fs.mkdirSync(releaseDir(version), { recursive: true });
|
||||
logger.info(`开始从 GitHub 拉取客户端安装包 v${version}:${url}`);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
redirect: 'follow',
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
headers: { 'User-Agent': 'ClassHelper-Server' },
|
||||
});
|
||||
} catch (error) {
|
||||
throw ApiError.badRequest(
|
||||
`下载失败:${error instanceof Error ? error.message : String(error)}。` +
|
||||
`可以把安装包手工放进 data/${CLIENT_RELEASE_DIR}/${version}/ 再点「登记」`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw ApiError.badRequest(
|
||||
`GitHub 上没有 v${version} 的客户端安装包(HTTP ${response.status},文件名应为 ${fileName})`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await pipeline(Readable.fromWeb(response.body as never), fs.createWriteStream(target));
|
||||
} catch (error) {
|
||||
fs.rmSync(target, { force: true });
|
||||
throw ApiError.badRequest(`写入安装包失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
// 落盘后统一走登记:sha256 / 大小 / 落库都在同一处算
|
||||
return registerRelease(userId, { version, fileName });
|
||||
}
|
||||
|
||||
/** 删除一份缓存的安装包(元数据 + 磁盘文件) */
|
||||
export async function deleteRelease(version: string): Promise<{ version: string }> {
|
||||
const row = (await prisma.clientRelease.findUnique({ where: { version } })) as ReleaseRow | null;
|
||||
if (!row) throw ApiError.notFound('服务端没有这个版本的安装包');
|
||||
await prisma.clientRelease.delete({ where: { version } });
|
||||
fs.rmSync(releaseDir(version), { recursive: true, force: true });
|
||||
logger.info(`删除客户端安装包 v${version}(${row.fileName})`);
|
||||
return { version };
|
||||
}
|
||||
@@ -1,8 +1,17 @@
|
||||
import { Router } from 'express';
|
||||
import { sendOk } from '../../lib/http.js';
|
||||
import { authenticate, getAuthUser } from '../../middleware/auth.js';
|
||||
import { sendCreated, sendOk, ApiError } from '../../lib/http.js';
|
||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedParams } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import * as clientReleases from './client-releases.service.js';
|
||||
import * as updateService from './update.service.js';
|
||||
import {
|
||||
clientReleaseVersionParamSchema,
|
||||
fetchClientReleaseSchema,
|
||||
registerClientReleaseSchema,
|
||||
type FetchClientReleaseInput,
|
||||
type RegisterClientReleaseInput,
|
||||
} from './update.schemas.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate());
|
||||
@@ -25,6 +34,86 @@ router.get('/check', async (req, res) => {
|
||||
sendOk(res, await updateService.checkForUpdates({ force }), '更新检查完成');
|
||||
});
|
||||
|
||||
/* ------------------------------- 客户端安装包的服务端缓存(统一下发更新的基础) */
|
||||
|
||||
/**
|
||||
* GET /api/update/client/releases - 服务端已缓存了哪些客户端版本
|
||||
*
|
||||
* 教室机器没有外网,所以"统一推送更新"必须有这一层中继:服务端替它们把安装包拉下来,
|
||||
* 客户端再从 `/client/download/:version` 取。
|
||||
*/
|
||||
router.get('/client/releases', requirePermission('client:manage'), async (_req, res) => {
|
||||
sendOk(res, await clientReleases.listReleases(), '获取安装包列表成功');
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/update/client/fetch - 让服务端从 GitHub 下载某个版本的客户端安装包并登记
|
||||
*
|
||||
* 服务端(Node)不受浏览器 CSP 约束,这是唯一能"一键代下"的地方;
|
||||
* 下不动(学校出口没有外网)时退回 `POST /client/releases` 手工登记。
|
||||
*/
|
||||
router.post(
|
||||
'/client/fetch',
|
||||
requirePermission('client:update-push'),
|
||||
validate({ body: fetchClientReleaseSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const input = validatedBody<FetchClientReleaseInput>(req);
|
||||
sendCreated(res, await clientReleases.fetchReleaseFromGithub(user.sub, input), '安装包已下载并登记');
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/update/client/releases - 登记一份已经放进磁盘的安装包
|
||||
*
|
||||
* 文件要求放在 `<serverRoot>/../data/client-releases/<版本>/` 下(文件名默认按发布约定推导)。
|
||||
* 这条路径也是自动化验收用的:不需要外网、不需要传 100MB 二进制。
|
||||
*/
|
||||
router.post(
|
||||
'/client/releases',
|
||||
requirePermission('client:update-push'),
|
||||
validate({ body: registerClientReleaseSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const input = validatedBody<RegisterClientReleaseInput>(req);
|
||||
sendCreated(res, await clientReleases.registerRelease(user.sub, input), '安装包已登记');
|
||||
},
|
||||
);
|
||||
|
||||
/** DELETE /api/update/client/releases/:version - 删除缓存(元数据 + 磁盘文件) */
|
||||
router.delete(
|
||||
'/client/releases/:version',
|
||||
requirePermission('client:update-push'),
|
||||
validate({ params: clientReleaseVersionParamSchema }),
|
||||
async (req, res) => {
|
||||
const { version } = validatedParams<{ version: string }>(req);
|
||||
sendOk(res, await clientReleases.deleteRelease(version), '安装包已删除');
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/update/client/download/:version - 下载安装包(客户端自动更新走它)
|
||||
*
|
||||
* **故意不挂权限键**:下载方是教室里的班级端会话(角色 CLASS_DEVICE,权限集为空),
|
||||
* 它代表那个班;真正的保护是"必须已登录 + 限流中间件"。
|
||||
* `res.download` 自带 Content-Length / Range 支持,不需要另写流式助手。
|
||||
*/
|
||||
router.get(
|
||||
'/client/download/:version',
|
||||
validate({ params: clientReleaseVersionParamSchema }),
|
||||
async (req, res) => {
|
||||
const { version } = validatedParams<{ version: string }>(req);
|
||||
const found = await clientReleases.resolveReleaseFile(version);
|
||||
if (!found) throw ApiError.notFound('服务端没有缓存这个版本的安装包');
|
||||
res.download(found.file, found.fileName, (error) => {
|
||||
if (error) {
|
||||
// 客户端中途取消下载也会走到这里,只记日志,不要往已发出的响应里再写东西
|
||||
res.locals.downloadError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const updateModule = defineModule({
|
||||
name: 'update',
|
||||
basePath: '/update',
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* 安装包文件名/版本号只允许这些字符。
|
||||
*
|
||||
* 它们会被拼进磁盘路径(`data/client-releases/<版本>/<文件名>`),
|
||||
* 所以必须挡住 `..`、`/`、`\` —— 否则一个 `version=../../` 就能写到仓库外面去。
|
||||
*/
|
||||
const SAFE_SEGMENT = /^[0-9A-Za-z._-]+$/;
|
||||
|
||||
export const clientReleaseVersionSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入版本号')
|
||||
.max(40, '版本号最多 40 个字符')
|
||||
.refine((value) => SAFE_SEGMENT.test(value), {
|
||||
message: '版本号只能包含字母、数字、点、下划线与短横线',
|
||||
});
|
||||
|
||||
/** `POST /api/update/client/fetch`:让服务端替教室机器从 GitHub 拉一份安装包 */
|
||||
export const fetchClientReleaseSchema = z.object({
|
||||
version: clientReleaseVersionSchema,
|
||||
});
|
||||
|
||||
/** `POST /api/update/client/releases`:登记一份已经放进磁盘的安装包(手工兜底与自动化用) */
|
||||
export const registerClientReleaseSchema = z.object({
|
||||
version: clientReleaseVersionSchema,
|
||||
fileName: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(120)
|
||||
.refine((value) => SAFE_SEGMENT.test(value), { message: '文件名只能包含字母、数字、点、下划线与短横线' })
|
||||
.optional(),
|
||||
notes: z.string().trim().max(500).optional(),
|
||||
});
|
||||
|
||||
export const clientReleaseVersionParamSchema = z.object({ version: clientReleaseVersionSchema });
|
||||
|
||||
export type FetchClientReleaseInput = z.infer<typeof fetchClientReleaseSchema>;
|
||||
export type RegisterClientReleaseInput = z.infer<typeof registerClientReleaseSchema>;
|
||||
+4
-1
@@ -1,8 +1,9 @@
|
||||
import type { PermissionKey } from '@classhelper/shared';
|
||||
import type { TokenPayload } from '../lib/jwt.js';
|
||||
|
||||
/**
|
||||
* 扩展 Express 的 Request 类型。
|
||||
* - auth:认证中间件解析出的当前用户
|
||||
* - auth:认证中间件解析出的当前用户 + 生效权限集合
|
||||
* - validatedQuery / validatedParams:校验中间件产出的类型安全数据
|
||||
* (Express 5 中 req.query 是只读 getter,不能直接覆盖,因此单独存放)
|
||||
*/
|
||||
@@ -12,6 +13,8 @@ declare global {
|
||||
auth?: {
|
||||
token: string;
|
||||
user: TokenPayload;
|
||||
/** 生效权限集合(权限组并集,或角色默认模板;班级端为空集) */
|
||||
permissions?: ReadonlySet<PermissionKey>;
|
||||
};
|
||||
validatedQuery?: unknown;
|
||||
validatedParams?: unknown;
|
||||
|
||||
Reference in new issue
Block a user