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:
LoveChengke committed 2026-10-11 11:49:32 +08:00
1 parent e51879667a
commit 817af02357
131 files changed
+12995 -538

No files matched your search

+56 -1
View File
@@ -1,4 +1,11 @@
import type { GradeLevelType, NotificationPriority, StudentGender, StudentStatus, UserRole } from './types.js';
import type {
ClientCommandKind,
GradeLevelType,
NotificationPriority,
StudentGender,
StudentStatus,
UserRole,
} from './types.js';
/** API 前缀 */
export const API_PREFIX = '/api';
@@ -33,6 +40,14 @@ export const API_PATHS = {
database: '/database',
/** 更新检查:Web 端经服务端转发查 GitHub 上的最新 Release(浏览器受 CSP 限制不能直连) */
update: '/update',
/** 权限组:权限目录、组的增删改、成员分配、当前账号生效权限 */
permissions: '/permissions',
/** ClassHelper 客户端设备:心跳、设备清单、统一下发更新与远控指令 */
clients: '/clients',
/** 大模型助手:设置(仅管理员)、一句话解析、使用记录 */
ai: '/ai',
/** 系统设置与诊断:开发者模式、服务端开机自启、运行诊断(仅管理员) */
system: '/system',
dashboard: '/dashboard/summary',
} as const;
@@ -654,3 +669,43 @@ export const UPDATE_CHECK_CACHE_TTL_MS = 30 * 60 * 1000;
/** 单次检查的超时:教室机 / 机房的网络常常很慢,但也不能把界面卡住 */
export const UPDATE_CHECK_TIMEOUT_MS = 8_000;
/* -------------------------------------------- 客户端设备(ClassHelper 桌面端) */
/**
* 桌面客户端向服务端上报心跳的间隔(30 秒,与班级端在线状态刷新一致)。
*
* 为什么**不用 Socket.IO 事件**下发更新与远控:教室机的连接经常断(代理、休眠、重启),
* 而「这台机器什么版本、要不要更新、有没有待执行命令」必须下次上线就能补上,
* 所以统一用「DB 记状态 + 客户端定时拉取」的范式(与 `IntegrationDevice.requestScheduleReport` 同一套路),
* 不引入新的长连接握手。
*/
export const CLIENT_HEARTBEAT_INTERVAL_MS = 30_000;
/** 「在线」判定窗口:容忍连续丢两次心跳(30 秒 × 3) */
export const CLIENT_ONLINE_WINDOW_MS = 90_000;
/** 远控命令种类:只有这四种,全部用系统命令实现 */
export const CLIENT_COMMAND_KINDS: readonly ClientCommandKind[] = ['shutdown', 'restart', 'logoff', 'lock'];
export const CLIENT_COMMAND_LABELS: Record<ClientCommandKind, string> = {
shutdown: '关机',
restart: '重启',
logoff: '注销',
lock: '锁屏',
};
/** 命令有效期:超过这个时间还没被拉走就作废(避免机器离线几天后一开机就被昨天的命令关掉) */
export const CLIENT_COMMAND_EXPIRE_MS = 10 * 60 * 1000;
/** 客户端回执里 result 的长度上限(一句中文足够) */
export const CLIENT_COMMAND_RESULT_MAX = 200;
/**
* 自动安装更新前的「空闲」判据:主窗口已隐藏满这么久才装。
* 上课时段(渲染进程上报的课表状态)与窗口可见都会把安装往后推。
*/
export const CLIENT_UPDATE_IDLE_MS = 5 * 60 * 1000;
/** 服务端缓存客户端安装包的目录(相对 `<serverRoot>/../data`,见 update 模块) */
export const CLIENT_RELEASE_DIR = 'client-releases';
+243
View File
@@ -145,6 +145,249 @@ export function canImport(role: UserRole, classRole: ClassRole, kind: ImportKind
return canManageGrades(classRole);
}
/* -------------------------------------------------------------------------- */
/* 权限键目录(权限组的唯一事实来源) */
/* -------------------------------------------------------------------------- */
/**
* 权限键:三端共用的**唯一权限标识**。
*
* - 服务端:路由级用 `requirePermission(key)`,班级维度的细门仍走下面那些
* `canXxx`(科目归属这类**数据完整性**规则故意不可被权限组覆盖);
* - Web 管理端:按钮/菜单显隐、权限组管理页的勾选项;
* - 桌面客户端:功能入口显隐。
*
* 键名用 `领域:动作` 的形式,新增功能要**先在这里加键**,再去两端用。
* **不要**在页面或服务里硬编码角色判断来代替它(AGENTS.md §5 第 3 条)。
*/
export const PERMISSION_KEYS = [
// 班级与人员
'class:read',
'class:manage',
'roster:manage',
'teacher:manage',
'schedule:read',
'schedule:write',
// 内容发布
'homework:read',
'homework:write',
'notification:read',
'notification:publish',
'notification:delete',
'call:create',
// 成绩与导入
'grade:read',
'grade:write',
'grade:import',
'student:grade-query',
'import:students',
'import:teachers',
'import:schedule',
'import:class-teachers',
// 设备与联动
'device:manage',
'classisland:notify',
'client:read',
'client:manage',
'client:update-push',
'remote:control',
// 学期与归档
'term:read',
'term:write',
'archive:manage',
'dashboard:read',
// 系统与安全
'database:manage',
'update:check',
'update:force',
'ai:use',
'ai:manage',
'permission:manage',
'system:manage',
'log:read',
'developer:mode',
] as const;
export type PermissionKey = (typeof PERMISSION_KEYS)[number];
/** 权限键的中文名(权限组管理页与文档展示,别再各写一份) */
export const PERMISSION_LABELS: Record<PermissionKey, string> = {
'class:read': '查看班级与班级列表',
'class:manage': '创建 / 修改 / 删除班级(含班级账号密码)',
'roster:manage': '学生名单管理(增删 / 调班 / 导入)',
'teacher:manage': '教师账号管理(新建 / 导入 / 重置密码 / 删除)',
'schedule:read': '查看课表',
'schedule:write': '课表与课程编辑',
'homework:read': '查看作业',
'homework:write': '发布 / 修改 / 删除作业(仍按任教科目录入)',
'notification:read': '查看通知',
'notification:publish': '发布通知',
'notification:delete': '删除通知',
'call:create': '叫人',
'grade:read': '查看成绩与成绩汇总',
'grade:write': '录入 / 修改成绩(仍按任教科目录入)',
'grade:import': '导入成绩',
'student:grade-query': '按学号查询学生成绩明细',
'import:students': '导入学生名单',
'import:teachers': '导入教师名单',
'import:schedule': '导入课表时间配置',
'import:class-teachers': '导入班级任课关系',
'device:manage': '联动设备管理(ClassIsland 设备令牌 / 课表上报)',
'classisland:notify': '向 ClassIsland 下发提醒',
'client:read': '查看 ClassHelper 客户端在线状态与版本',
'client:manage': '管理客户端设备(改名 / 停用 / 删除)',
'client:update-push': '向客户端统一下发更新',
'remote:control': '远控客户端电脑(关机 / 重启 / 注销 / 锁屏)',
'term:read': '查看学期周次',
'term:write': '修改学期周次与开学日期',
'archive:manage': '毕业归档',
'dashboard:read': '查看统计看板',
'database:manage': '数据库快照 / 导出 / 切库',
'update:check': '检查服务端更新',
'update:force': '强制检查更新(绕过缓存)',
'ai:use': '使用大模型指令(一句话发布)',
'ai:manage': '大模型设置(开关 / 服务地址 / 密钥 / 模型)',
'permission:manage': '权限组与用户权限管理',
'system:manage': '全局系统设置(含开发者模式)',
'log:read': '查看服务端日志',
'developer:mode': '开发者模式',
};
/** 权限组管理页的分组展示(只影响排序与标题,不改变语义) */
export const PERMISSION_CATEGORIES: readonly { title: string; keys: readonly PermissionKey[] }[] = [
{
title: '班级与人员',
keys: ['class:read', 'class:manage', 'roster:manage', 'teacher:manage', 'schedule:read', 'schedule:write'],
},
{
title: '内容发布',
keys: [
'homework:read',
'homework:write',
'notification:read',
'notification:publish',
'notification:delete',
'call:create',
],
},
{
title: '成绩与导入',
keys: [
'grade:read',
'grade:write',
'grade:import',
'student:grade-query',
'import:students',
'import:teachers',
'import:schedule',
'import:class-teachers',
],
},
{
title: '设备与联动',
keys: ['device:manage', 'classisland:notify', 'client:read', 'client:manage', 'client:update-push', 'remote:control'],
},
{ title: '学期与统计', keys: ['term:read', 'term:write', 'archive:manage', 'dashboard:read'] },
{
title: '系统与安全',
keys: [
'database:manage',
'update:check',
'update:force',
'ai:use',
'ai:manage',
'permission:manage',
'system:manage',
'log:read',
'developer:mode',
],
},
];
/**
* 每个权限键的**默认角色**(镜像改造前各路由的 `requireRole(...)`,逐条对齐)。
*
* 这是"没有分过任何权限组"时的回落模板:行为与权限组功能上线前完全一致。
* 一旦管理员给某个账号分了组,生效集合就**只由组的并集决定**(可以比默认更少)。
*
* 注意 `CLASS_DEVICE` 不出现在任何默认里:班级端的能力由班级维度断言
* (`canManageSubjectContent(..., { allowClassDevice })` 等)决定,不受权限组影响。
*/
export const PERMISSION_DEFAULT_ROLES: Record<PermissionKey, readonly UserRole[]> = {
'class:read': ['ADMIN', 'TEACHER'],
'class:manage': ['ADMIN'],
'roster:manage': ['ADMIN', 'TEACHER'],
'teacher:manage': ['ADMIN'],
'schedule:read': ['ADMIN', 'TEACHER'],
'schedule:write': ['ADMIN', 'TEACHER'],
'homework:read': ['ADMIN', 'TEACHER'],
'homework:write': ['ADMIN', 'TEACHER'],
'notification:read': ['ADMIN', 'TEACHER'],
'notification:publish': ['ADMIN', 'TEACHER'],
'notification:delete': ['ADMIN', 'TEACHER'],
'call:create': ['ADMIN', 'TEACHER'],
'grade:read': ['ADMIN', 'TEACHER'],
'grade:write': ['ADMIN', 'TEACHER'],
'grade:import': ['ADMIN', 'TEACHER'],
'student:grade-query': ['ADMIN', 'TEACHER'],
'import:students': ['ADMIN', 'TEACHER'],
'import:teachers': ['ADMIN'],
'import:schedule': ['ADMIN', 'TEACHER'],
'import:class-teachers': ['ADMIN'],
'device:manage': ['ADMIN', 'TEACHER'],
'classisland:notify': ['ADMIN', 'TEACHER'],
'client:read': ['ADMIN', 'TEACHER'],
'client:manage': ['ADMIN'],
'client:update-push': ['ADMIN'],
'remote:control': ['ADMIN'],
'term:read': ['ADMIN', 'TEACHER'],
'term:write': ['ADMIN'],
'archive:manage': ['ADMIN'],
'dashboard:read': ['ADMIN', 'TEACHER'],
'database:manage': ['ADMIN'],
'update:check': ['ADMIN', 'TEACHER'],
'update:force': ['ADMIN'],
'ai:use': ['ADMIN', 'TEACHER'],
'ai:manage': ['ADMIN'],
'permission:manage': ['ADMIN'],
'system:manage': ['ADMIN'],
'log:read': ['ADMIN'],
'developer:mode': ['ADMIN'],
};
/** 全部权限键(管理员的生效集合;也用在校验 zod 的枚举上) */
export const ALL_PERMISSION_KEYS: readonly PermissionKey[] = PERMISSION_KEYS;
/**
* 角色默认权限清单(由 {@link PERMISSION_DEFAULT_ROLES} 派生)。
*
* `ADMIN` 是全集(服务端还会强制补上 `permission:manage` / `system:manage`,防止把自己锁死)。
*/
export const ROLE_DEFAULT_PERMISSIONS: Record<UserRole, readonly PermissionKey[]> = {
ADMIN: PERMISSION_KEYS,
TEACHER: PERMISSION_KEYS.filter((key) => PERMISSION_DEFAULT_ROLES[key].includes('TEACHER')),
CLASS_DEVICE: [],
};
/** 取某个角色的默认权限集合(无分组时的回落模板) */
export function defaultPermissionsForRole(role: UserRole): PermissionKey[] {
return [...ROLE_DEFAULT_PERMISSIONS[role]];
}
/** 判断权限集合里是否有某个键(`Set` 或数组都接受) */
export function hasPermission(permissions: ReadonlySet<string> | readonly string[], key: PermissionKey): boolean {
const list: readonly string[] = Array.isArray(permissions) ? permissions : [...permissions];
return list.includes(key);
}
/** 判断是否含任意一个键(服务端 requirePermission 是"全部满足",这里留给页面做"任一"判断) */
export function hasAnyPermission(
permissions: ReadonlySet<string> | readonly string[],
keys: readonly PermissionKey[],
): boolean {
return keys.some((key) => hasPermission(permissions, key));
}
/** 人类可读的权限矩阵(README / 设置页展示,单一事实来源) */
export const PERMISSION_MATRIX: readonly {
action: string;
+436 -2
View File
@@ -4,6 +4,10 @@
* 后端返回值与前端调用方都必须依赖它,避免契约漂移。
*/
// 只导入类型(PermissionKey 由 permissions.ts 的 PERMISSION_KEYS 推导):
// 两个文件之间是**类型层面**的相互引用,编译后都会被抹掉,运行时不存在循环依赖。
import type { PermissionKey } from './permissions.js';
/**
* 账号角色:管理员 / 教师 / ClassHelper 班级端。
*
@@ -733,6 +737,13 @@ export interface NotificationDto {
priority: NotificationPriority;
createdBy: string;
createdAt: string;
/**
* 通知所属日期(YYYY-MM-DD,**不带时区**)。
*
* 与 createdAt 的区别:createdAt 是"什么时候发的",assignDate 是"这天的通知"。
* Web 端与客户端的按天归档、日期筛选都用它(晚间发布不会被 UTC 切片算到第二天)。
*/
assignDate: string;
creator?: ClassTeacherBrief | null;
/** 当前用户是否已读 */
read?: boolean;
@@ -777,6 +788,99 @@ export interface NotificationQueryParams {
priority?: NotificationPriority;
unreadOnly?: boolean;
keyword?: string;
/** 只看某一天(YYYY-MM-DD),与 from/to 二选一 */
date?: string;
/** 日期区间起点(YYYY-MM-DD,含当天) */
from?: string;
/** 日期区间终点(YYYY-MM-DD,含当天) */
to?: string;
/** 只看某位发布人的通知 */
createdBy?: string;
}
/** 某一天有通知(用于按天归档的日期列表) */
export interface NotificationDaySummary {
/** YYYY-MM-DD */
date: string;
/** 当天通知条数 */
count: number;
}
/** GET /api/notifications/days 的返回 */
export interface NotificationDaysDto {
days: NotificationDaySummary[];
}
/** 通知发布人汇总(按发布人归档时的筛选项) */
export interface NotificationCreatorSummary {
id: string;
name: string;
username: string;
/** 该发布人在当前筛选范围内的通知条数 */
count: number;
}
/** GET /api/notifications/creators 的返回 */
export interface NotificationCreatorsDto {
creators: NotificationCreatorSummary[];
}
/**
* 批量发布的单班结果。
*
* 批量发布**逐班判定**:某个班没有权限、或正在上课而通知是紧急的,都不会让整批失败 ——
* 失败的那个班带着错误码与中文原因回到这里,成功的班照常落库并实时推送。
*/
export interface BulkPublishClassResult {
classId: string;
ok: boolean;
/** 成功时:新建的实体 id(通知 / 作业) */
id?: string;
/** 失败时:错误码(FORBIDDEN / URGENT_DURING_CLASS / CLASS_NOT_FOUND …) */
code?: string;
/** 失败时:可直接展示给用户的中文原因 */
message?: string;
/** 失败时的附加信息(如 URGENT_DURING_CLASS 的当前课程与下一次课) */
details?: Record<string, unknown>;
}
/** 批量发布结果汇总 */
export interface BulkPublishResult {
total: number;
succeeded: number;
failed: number;
results: BulkPublishClassResult[];
}
/** POST /api/notifications/bulk:一次给多个班级发同一条通知 */
export interface BulkCreateNotificationRequest {
/** 目标班级 id 列表(1~50 个) */
classIds: string[];
title: string;
content: string;
priority?: NotificationPriority;
/** 有班级正在上课且优先级为紧急时必须置 true(前端二次确认后重试) */
confirmDuringClass?: boolean;
}
/** POST /api/homeworks/bulk:一次给多个班级布置同一份作业 */
export interface BulkCreateHomeworkRequest {
/** 目标班级 id 列表(1~50 个) */
classIds: string[];
courseId?: string | null;
/**
* 按**科目名称**指定科目(批量场景用这个,而不是 courseId)。
*
* 课程是"班级内"的:同一门「语文」在每个班是不同的 Course 记录,跨班不可能共用一个 courseId。
* 传名称后由服务端在每个目标班里查同名课程;某个班没有这门课时**只让那个班失败**
* (结果里带「该班级没有…这门课程」),**不会自动建课**。与 courseId 同时传时以本字段为准。
*/
subjectName?: string | null;
title: string;
content: string;
/** 作业所属日期(YYYY-MM-DD);不传按服务器当天处理 */
assignDate?: string | null;
attachmentUrl?: string | null;
}
/* ------------------------------------------------------------------ 成绩 */
@@ -979,8 +1083,15 @@ export interface IslandState {
inClass: boolean;
/** 当前课的结束时间(HH:mm),用于"下课后自动弹出" */
currentPeriodEnd: string | null;
/** 本轮展示原因:新消息 / 下课后补发 / 紧急插播 / 叫人 */
reason: 'new' | 'after-class' | 'urgent' | 'call' | null;
/**
* 本轮展示原因:新消息 / 下课后补发 / 紧急插播 / 叫人 / 收回中。
*
* `closing` 是"用户点了知道了 / 标为已读"之后的**收回动画**阶段:状态在同一帧就已经落地
* (`active` 清空、`queued` 清空),主进程只是把形态切回胶囊让渲染进程把卡片收回去,
* 待 `CLOSE_ANIMATION_MS` 之后再淡出隐藏。它不是跨进程握手,渲染进程也不需要任何回执
* (见 AGENTS.md §5 第 34 条)。
*/
reason: 'new' | 'after-class' | 'urgent' | 'call' | 'closing' | null;
/**
* 多条通知时是否已点过"展开更多"(展开后显示全部放得下的通知)。
* 由主进程持有:卡片高度与窗口包围盒都由它决定,渲染进程只负责照此渲染。
@@ -1601,3 +1712,326 @@ export interface UpdateInfo {
assets: UpdateAssetDto[];
checkedAt: string;
}
/* ------------------------------------------------------------------ 权限组 */
/**
* 一个权限组:名字 + 描述 + 一组权限键(`PermissionGroupItem`)。
*
* 生效规则见服务端 `lib/permissions.ts` 的 `loadEffectivePermissions`:
* 账号**有分组**时取所属组的键并集(因此能比角色默认更少 ⇒ 可以用来收权);
* **没有任何分组**时回落角色默认模板(逐条镜像权限组上线前各路由的 requireRole)。
*/
export interface PermissionGroupDto {
id: string;
name: string;
description: string;
keys: PermissionKey[];
memberCount: number;
/** 内置组(种子创建)不可删除,只允许改成员与键 */
builtin: boolean;
createdAt: string;
updatedAt: string;
}
/** 权限页里的一个成员(管理员/教师账号) */
export interface PermissionMemberDto {
id: string;
username: string;
name: string;
role: UserRole;
/** 所属权限组;空数组 = 走角色默认模板 */
groupIds: string[];
/** 生效权限数量(ADMIN 恒为全集;班级端账号恒为 0,见权限页的说明) */
permissionCount: number;
/** 该账号固定拥有全部权限,不接受分配(管理员) */
fixed: boolean;
}
/** `GET /api/permissions/catalog`:权限键目录(与 shared 的常量一一对应,供权限页渲染) */
export interface PermissionCatalogDto {
keys: PermissionKey[];
labels: Record<PermissionKey, string>;
categories: { title: string; keys: PermissionKey[] }[];
/** 每个键"默认给哪些角色"(权限页的提示文案用) */
defaultRoles: Record<PermissionKey, UserRole[]>;
/** 每个角色的默认模板("没有分组时会是什么") */
roleDefaults: Record<UserRole, PermissionKey[]>;
/** 管理员不可被剥夺的键(前端置灰、服务端也会合并回去) */
unremovable: PermissionKey[];
}
export interface CreatePermissionGroupRequest {
name: string;
description?: string;
keys?: PermissionKey[];
}
export interface UpdatePermissionGroupRequest {
name?: string;
description?: string;
keys?: PermissionKey[];
}
/** `PUT /api/permissions/users/:id/groups`:整体替换某账号的权限组 */
export interface AssignUserGroupsRequest {
groupIds: string[];
}
/**
* `GET /api/permissions/me`:当前账号的生效权限。
*
* 两端用它决定"入口显不显示",而**不是**再写一套权限规则(AGENTS.md §5 第 3 条):
* 真正的拦截始终在服务端。
*/
export interface MyPermissionsDto {
role: UserRole;
permissions: PermissionKey[];
/** true = 权限来自权限组;false = 走角色默认模板(未分配任何组) */
fromGroups: boolean;
}
/* ------------------------------------- 客户端设备 / 远控 / 客户端更新 */
/** 远控命令种类(与 shared 的常量 `CLIENT_COMMAND_KINDS` 一一对应) */
export type ClientCommandKind = 'shutdown' | 'restart' | 'logoff' | 'lock';
/** 一条下发给教室客户端电脑的命令 */
export interface ClientCommandDto {
id: string;
deviceId: string;
kind: ClientCommandKind;
/** 给人看的说明:动作描述 + 管理员填的备注 */
payload: string;
createdAt: string;
/** 过期时间:客户端拉到时已过期就丢弃并回执「已过期」(避免机器离线几天后突然关机) */
expiresAt: string | null;
ackedAt: string | null;
/** 执行结果说明(未回执为空串) */
result: string;
}
/** Web 端「客户端设备」列表里的一台机器 */
export interface ClientDeviceDto {
id: string;
classId: string | null;
className: string | null;
deviceKey: string;
name: string;
/** 本机版本(客户端主进程 `app.getVersion()`) */
clientVersion: string;
platform: string;
/** 是否接受服务端统一下发的自动更新 */
autoUpdate: boolean;
/** 服务端指定的目标版本;`''` = 不指定 */
updateTargetVersion: string;
/** 已下载待安装的版本;`''` = 无 */
pendingVersion: string;
lastHeartbeatAt: string | null;
lastIp: string;
/** 客户端上报的状态说明(如「上课中」「窗口已隐藏」) */
stateNote: string;
/** 由最后一次心跳与 `CLIENT_ONLINE_WINDOW_MS` 判定 */
online: boolean;
/** 该设备当前待执行(未回执、未撤销、未过期)的命令;没有则 null */
pendingCommand: ClientCommandDto | null;
createdAt: string;
updatedAt: string;
}
/** `POST /api/clients/heartbeat`:客户端每 30 秒一次的上报 */
export interface ClientHeartbeatRequest {
deviceKey: string;
name?: string;
clientVersion?: string;
platform?: string;
pendingVersion?: string | null;
stateNote?: string;
}
/** 服务端为某个版本缓存好的安装包信息(教室没有外网时也走服务端下载) */
export interface ClientUpdateInfoDto {
version: string;
fileName: string;
size: number;
sha256: string;
notes: string;
/** 下载路径(**含 `/api` 前缀**;客户端用「服务器地址 + 它」直接下载) */
url: string;
}
/** `POST /api/clients/heartbeat` 的返回:更新下发与远控命令都在这里带回 */
export interface ClientHeartbeatResponse {
deviceId: string;
serverTime: string;
/** 服务端要求升到的版本;null = 不要求 */
updateTargetVersion: string | null;
/** 目标版本的安装包信息;null = 服务端还没有这份安装包(客户端只能提示手动更新) */
update: ClientUpdateInfoDto | null;
/** 待执行命令(最早的一条);null = 无 */
command: ClientCommandDto | null;
}
/** `POST /api/clients/commands/:id/ack`:客户端执行后的回执 */
export interface ClientCommandAckRequest {
/** 既是身份也是护栏:班级端令牌全班共用,靠它确认回执来自命令指向的那台机器 */
deviceKey: string;
ok: boolean;
result?: string;
}
/** 一台设备上的操作结果(远控命令 / 更新推送共用) */
export interface ClientTargetResult {
deviceId: string;
deviceName: string;
ok: boolean;
code?: string;
message?: string;
}
/** 批量下发结果(远控命令 / 更新推送共用) */
export interface ClientPushResult {
total: number;
succeeded: number;
failed: number;
results: ClientTargetResult[];
}
/** `POST /api/clients/commands`:给选中的电脑下发远控命令 */
export interface CreateClientCommandRequest {
deviceIds: string[];
kind: ClientCommandKind;
/** 备注(会显示在被控机器上的二次确认弹窗里) */
note?: string;
}
/** `POST /api/clients/updates`:推送更新(按设备、按班级或全部) */
export interface PushClientUpdateRequest {
deviceIds?: string[];
classIds?: string[];
all?: boolean;
/** 目标版本;省略则用服务端缓存的最新版本 */
version?: string | null;
/** true = 取消下发(回到"不要求更新"的状态) */
clear?: boolean;
}
/** `GET /api/update/client/releases`:服务端已缓存的客户端安装包 */
export interface ClientReleaseDto {
version: string;
fileName: string;
size: number;
sha256: string;
notes: string;
/** 文件是否真的在磁盘上(登记的元数据可能因为手工清理而失效) */
fileReady: boolean;
createdAt: string;
updatedAt: string;
}
/* ------------------------------------------------- 全局设置 / 服务端系统状态 */
/** `GET/PUT /api/system/settings`:全局设置(目前只有开发者模式,仅 ADMIN 可改) */
export interface SystemSettingsDto {
/** 开发者模式:错误响应带堆栈、日志降到 debug 级 */
developerMode: boolean;
}
/** `GET/PUT /api/system/autostart`:服务端开机自启状态与切换 */
export interface AutostartStatusDto {
/** 当前形态是否支持切换(开发机/免安装/容器里不支持 —— 避免把开发机的自启项写坏) */
supported: boolean;
enabled: boolean;
/** run-key=Windows 注册表 Run 项 / scheduled-task / systemd / unsupported */
mechanism: 'run-key' | 'scheduled-task' | 'systemd' | 'unsupported';
/** 给人看的说明:命令、路径,或"为什么不支持" */
detail: string;
}
/** `GET /api/system/diagnostics`:开发者模式下的运行诊断(**不含任何密钥**) */
export interface SystemDiagnosticsDto {
version: string;
nodeVersion: string;
platform: string;
uptimeSeconds: number;
databaseProvider: string;
serverRoot: string;
dataDir: string;
clientReleaseCount: number;
onlineClientCount: number;
moduleCount: number;
developerMode: boolean;
}
/* ------------------------------------------------------------------ 大模型助手 */
/** 大模型服务端实现:openai=真接口 / mock=离线确定性结果(自动化验收用,不联网) */
export type AiProvider = 'openai' | 'mock';
/** `GET/PUT /api/ai/settings`:大模型设置(**API Key 只进不出**) */
export interface AiSettingsDto {
enabled: boolean;
provider: AiProvider;
baseUrl: string;
model: string;
/** 是否已配置密钥(界面据此显示"已配置/未配置",永不回传密钥本身) */
hasApiKey: boolean;
/** 密钥尾 4 位(便于确认换对了,空串表示未配置) */
keyHint: string;
/** 追加到系统提示词的学校自定义要求 */
systemPromptExtra: string;
}
export interface UpdateAiSettingsRequest {
enabled?: boolean;
provider?: AiProvider;
baseUrl?: string;
model?: string;
/** 传字符串=换成新密钥;传 null=清除;不传=保持不变 */
apiKey?: string | null;
systemPromptExtra?: string;
}
/** 助手能执行的动作(都是既有发布入口的封装,绝不直接写库) */
export type AiActionKind = 'create_homework' | 'create_notification' | 'call_student' | 'unknown';
/** `POST /api/ai/interpret`:只解析、不落库;确认后由前端调既有端点 */
export interface AiInterpretRequest {
/** 老师原话,例如「给高一二班布置作业,把卷子写完」 */
text: string;
/** 有上下文班级时一并给出(助手据此缩小候选) */
classId?: string;
}
/** 助手解析出的一次动作(params 用与手动入口**同一份 zod 校验**后再返回) */
export interface AiInterpretResult {
action: AiActionKind;
/** 可直接提交给既有端点的参数(已通过 zod 校验) */
params: Record<string, unknown>;
/** 给人看的预览(班级名 / 科目名 / 学生名等已解析成中文) */
preview: {
summary: string;
className?: string | null;
courseName?: string | null;
studentName?: string | null;
warnings: string[];
};
/** 模型自评置信度 0~1(低于 0.5 时界面提示"请仔细核对") */
confidence: number;
/** 一律为 true:助手的结果必须由老师确认后才落库 */
needConfirm: boolean;
/** 模型原始回复(仅在开发者模式下回传,便于排查格式问题) */
rawReply?: string | null;
}
/** `GET /api/ai/audit`:助手使用审计(谁在什么时候让助手做了什么) */
export interface AiAuditDto {
id: string;
userId: string;
userName: string;
prompt: string;
intent: string;
status: string;
message: string;
createdAt: string;
}
+21
View File
@@ -524,3 +524,24 @@ export function isNewerVersion(
if (!normalizeVersion(latest)) return false;
return compareVersions(latest, current) > 0;
}
/**
* 客户端安装包的**文件名约定**。
*
* 与 GitHub Release 上上传的 ASCII 名、以及官网下载页的离线兜底直链
* **必须一致**(AGENTS.md §4.4:改名要三处一起改)。
*/
export function clientReleaseFileName(version: string): string {
return `ClassHelper-${normalizeVersion(version)}-x64-client-setup.exe`;
}
/**
* 服务端从 GitHub 拉取客户端安装包的直链。
*
* 服务端替教室机器"代下"再缓存(教室常常没有外网)—— Web 端**不能**直连 GitHub
* (CSP 的 connect-src 只有 self / ws / CORS_ORIGIN,见 AGENTS.md §5 第 52 条)。
*/
export function clientReleaseDownloadUrl(version: string): string {
const tag = `v${normalizeVersion(version)}`;
return `https://github.com/LoveChengke/ClassHelper/releases/download/${tag}/${clientReleaseFileName(version)}`;
}