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
@@ -6,9 +6,9 @@ import {
|
||||
type NotificationDto,
|
||||
type ScheduleDto,
|
||||
} from '@classhelper/shared';
|
||||
import { scheduleApi } from '../api/index.js';
|
||||
import { notificationApi, scheduleApi } from '../api/index.js';
|
||||
import { fetchWithCache } from '../cache/index.js';
|
||||
import { useAppStore } from '../stores/app.js';
|
||||
import { clearOfflineSince, readOfflineSince, useAppStore } from '../stores/app.js';
|
||||
import { useAuthStore } from '../stores/auth.js';
|
||||
import { useNotificationStore } from '../stores/notifications.js';
|
||||
import { useRealtimeStore } from '../stores/realtime.js';
|
||||
@@ -190,6 +190,81 @@ export function pushCallToIsland(call: NotificationDto): void {
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 补弹离线期间收到的消息(需求 11) */
|
||||
|
||||
/**
|
||||
* 补弹条数上限。
|
||||
*
|
||||
* 灵动岛的多条通知列表默认只显示 `ISLAND_LIST_METRICS.defaultVisibleRows`(3)条 +
|
||||
* 「展开更多(还有 N 条)」。所以一条不补、和一次补十几条,都是同样的阅读体验 ——
|
||||
* 封顶 5 条 = 3 条可见 + 2 条藏在"展开更多"里,正好是打开就能读完的量;
|
||||
* 补太多只会让"展开更多"变成一个没有意义的按钮。
|
||||
*/
|
||||
const REPLAY_MAX_COUNT = 5;
|
||||
|
||||
/**
|
||||
* 上线后补弹「离线期间收到的消息」(需求 11)。
|
||||
*
|
||||
* 调用时机:`App.vue` 订阅 `useAppStore().onServerRecovered`,也就是**可达性由不可达变为可达**
|
||||
* 的那一刻(断网期间的实时通道是断的,那些通知既没弹过、也没进过用户的视野)。
|
||||
*
|
||||
* 数据来源复用既有的通知接口(`notificationApi.list({ unreadOnly: true })`):
|
||||
* 客户端本来就是离线优先的(`fetchWithCache` 会把数据落 IndexedDB),**不为此新建数据源** ——
|
||||
* 补弹只需要"服务端现在有哪些未读",而通知列表本来就是这个问题的答案。
|
||||
*
|
||||
* 实现上有四条约定:
|
||||
* 1. **没有离线起点就直接返回 0**(首次启动 / 一直在线):离线起点只由
|
||||
* `stores/app.ts` 在"由可达变不可达"的那一刻写入,没有它就说明这段时间根本没断过网,
|
||||
* 此时补弹等于把**历史未读**全弹一遍,那不是「离线期间收到的」。
|
||||
* 2. **只补离线起点之后创建的**(`createdAt > offlineSince`),并且在服务端排序之外再排一次
|
||||
* **按时间升序**投递 —— 顺序投递才能让灵动岛的队列保持时间顺序。
|
||||
* 3. **取最近的 5 条**(升序列表的尾部):断网一天可能积压几十条,取最早的那 5 条等于
|
||||
* 让用户先看最旧的、最新的反而被"展开更多"挡住。取最近的 5 条更符合"我刚错过了什么"。
|
||||
* 4. **推完就清掉离线起点**:同一次离线只补一次,否则每次可达性抖动都会重复弹同一批。
|
||||
*
|
||||
* @returns 实际补弹的条数(0 = 没补)
|
||||
*/
|
||||
export async function replayMissedNotifications(): Promise<number> {
|
||||
// 冒烟保护:`verify:desktop` / `verify:packaged` 有一条「灵动岛队列」的断言链
|
||||
// (100+ 条断言量的是卡片的条数、顺序、高度与"展开更多"的分页),
|
||||
// 冒烟脚本会反复断开/恢复网络,注入的合成通知与这里的补弹会互相打乱队列,
|
||||
// 让那些断言随机变红。冒烟模式下**直接跳过补弹**(也不动离线起点),
|
||||
// 补弹本身由人工/单元路径覆盖,不进冒烟。
|
||||
if (window.desktop?.smokeTest === true) return 0;
|
||||
|
||||
const offlineSince = readOfflineSince();
|
||||
if (!offlineSince) return 0;
|
||||
const sinceMs = Date.parse(offlineSince);
|
||||
if (Number.isNaN(sinceMs)) {
|
||||
// 存储里是坏值(手工改过 / 旧版本写入格式不同):清掉,别让它永远挡住补弹
|
||||
clearOfflineSince();
|
||||
return 0;
|
||||
}
|
||||
|
||||
let unread: NotificationDto[];
|
||||
try {
|
||||
unread = await notificationApi.list({ unreadOnly: true });
|
||||
} catch {
|
||||
// 刚恢复就又断了(或服务端还没真正就绪):保留离线起点,等下一次恢复再补
|
||||
return 0;
|
||||
}
|
||||
|
||||
const missed = unread
|
||||
.filter((item) => {
|
||||
const at = Date.parse(item.createdAt);
|
||||
return !Number.isNaN(at) && at > sinceMs;
|
||||
})
|
||||
.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
|
||||
|
||||
const picked = missed.slice(-REPLAY_MAX_COUNT);
|
||||
for (const item of picked) pushNotificationToIsland(item);
|
||||
|
||||
// 无论补到几条都清掉:这一轮离线已经被处理过了
|
||||
clearOfflineSince();
|
||||
console.info(`[离线补弹] 补弹离线消息 ${picked.length} 条(离线起点 ${offlineSince})`);
|
||||
return picked.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 灵动岛点了"标为已读" → 在通知中心里也标记已读(并刷新未读红点)。
|
||||
* 由 App.vue 在挂载时订阅一次。
|
||||
|
||||
Reference in new issue
Block a user