From 572295c297bc530baac6f51e988279aa4ec4143a Mon Sep 17 00:00:00 2001 From: LoveChengke Date: Fri, 2 Oct 2026 11:18:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(server,web,desktop,shared,deploy,plugin):?= =?UTF-8?q?=20=E6=95=B0=E6=8D=AE=E5=BA=93=E7=AE=A1=E7=90=86=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=20+=20=E5=AE=A2=E6=88=B7=E7=AB=AF=E6=B7=B1=E8=89=B2?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F/=E8=AE=BE=E7=BD=AE=E6=8B=86=E5=AD=90?= =?UTF-8?q?=E8=B7=AF=E7=94=B1/=E9=A6=96=E5=90=AF=E5=BC=95=E5=AF=BC?= =?UTF-8?q?=EF=BC=8C=E7=89=88=E6=9C=AC=E7=BB=9F=E4=B8=80=201.0.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 服务端新增 database 模块(/api/database/*,仅 ADMIN):全库 JSON 快照(备份 / 恢复 / 导入导出 / 定时备份 共用同一格式,14 张表按拓扑序导出、恢复时关外键检查)、连接测试与目标库校验、一键 SQLite ⇄ MySQL 切换 (备份 → 导出 → 测试连接 → 校验空库 → 改 schema → 子进程 generate / tsc / db push → 写数据,全部成功才改写 .env,中途失败回滚 schema);XML 快照与 SQLite 数据文件均可下载;打包形态随包带上 prisma CLI 与 typescript (prisma-client 生成物是 .ts,需再编进 dist/generated,安装包因之变大) - Web 管理端新增「数据库管理」页(仅管理员),并加首次登录新手引导(el-tour,可跳过、可随时重看,版本号写 localStorage,改版后老用户会再收到一次) - 客户端设置改版:侧边栏改为分组折叠菜单,设置拆成 6 个子路由 (/settings/{general,appearance,island,reminder,account,about}),新增深色模式(主题持久化到主进程配置, 主窗口底色跟主题走,避免"先白后黑"闪一下),新增「关于」页(应用信息 / 诊断信息) - 客户端新增首启六步引导(OnboardingWelcome,状态存主进程配置 onboardingDone) - 客户端主窗口初始尺寸与 ClassIsland 对齐(1242x582 / minHeight 540 / 居中) - 版本号统一提升到 1.0.0:5 个 package.json、部署配置与镜像标签(.env.example / docker-compose / Dockerfile)、 服务端 /health 与首页、插件 manifest.yml 与 BridgeService.PluginVersion、文档里的安装包名 - 文档与验收同步:AGENTS.md 补第 37~51 条约定(深色主题实现边界、设置子路由、数据库管理三件事、侧栏与窗口 尺寸、引导在自动化里的判据等),README 补「数据库管理 / 新手引导 / 设置改版」三节;verify:e2e 增补 13 条 数据库管理用例,客户端冒烟与 Web UI 冒烟跟进子路由、深色主题、侧栏与引导 验证:pnpm lint 0 error;pnpm typecheck 全过;verify:e2e 186/186;verify:classisland-plugin 68/68; build:desktop 通过(含产物凭据门禁);verify:desktop 99/102(3 项为上课时段下的"真实通知链路"用例, 客户端按产品规则暂存普通通知所致,见下);插件 dotnet 编译 0 错误。 注:verify:desktop 的「真实通知链路 / 标记已读」三项要求当前不在上课时段——本次运行时种子课表恰有 「周五 10:55-11:40 生物」正在进行,客户端正确地暂存了普通通知(AGENTS.md 第 18 条),属时间相关用例, 与本次改动无关。 --- .gitignore | 2 + AGENTS.md | 99 ++- README.md | 400 +++++---- deploy/.env.example | 2 +- deploy/.env.sqlite.example | 2 +- deploy/Dockerfile | 4 +- deploy/docker-compose.sqlite.yml | 2 +- deploy/docker-compose.yml | 2 +- deploy/package.runtime.json | 5 +- docs/mysql.md | 62 +- docs/production.md | 4 +- package.json | 2 +- packages/classisland-plugin/manifest.yml | 2 +- .../src/Services/BridgeService.cs | 2 +- packages/desktop-client/package.json | 2 +- packages/desktop-client/src/main/config.ts | 31 + packages/desktop-client/src/main/index.ts | 15 +- packages/desktop-client/src/main/ipc.ts | 1 + packages/desktop-client/src/main/smoke.ts | 67 +- packages/desktop-client/src/renderer/App.vue | 22 + .../src/renderer/cache/self-test.ts | 263 +++++- .../renderer/components/OnboardingWelcome.vue | 311 +++++++ .../src/renderer/layouts/ClientLayout.vue | 201 ++++- packages/desktop-client/src/renderer/main.ts | 2 + .../src/renderer/router/index.ts | 41 +- .../src/renderer/stores/onboarding.ts | 24 + .../desktop-client/src/renderer/stores/ui.ts | 73 ++ .../src/renderer/styles/index.css | 177 +++- .../src/renderer/views/GradeView.vue | 6 +- .../src/renderer/views/HomeworkView.vue | 6 +- .../src/renderer/views/LoginView.vue | 22 +- .../src/renderer/views/NotificationView.vue | 8 +- .../src/renderer/views/ScheduleView.vue | 9 +- .../src/renderer/views/SettingsView.vue | 708 ---------------- .../src/renderer/views/settings/AboutView.vue | 182 +++++ .../views/settings/AccountSettingsView.vue | 55 ++ .../views/settings/AppearanceView.vue | 162 ++++ .../views/settings/GeneralSettingsView.vue | 285 +++++++ .../views/settings/IslandSettingsView.vue | 276 +++++++ .../views/settings/ReminderSettingsView.vue | 141 ++++ .../desktop-client/src/types/desktop.d.ts | 19 + packages/server/package.json | 5 +- .../server/scripts/verify-classisland.mjs | 2 +- packages/server/scripts/verify-e2e.mjs | 224 +++++ packages/server/src/app.ts | 6 +- packages/server/src/index.ts | 3 + packages/server/src/lib/db.ts | 24 +- packages/server/src/lib/snapshot.ts | 159 ++++ .../src/modules/database/database.module.ts | 124 +++ .../src/modules/database/database.schemas.ts | 65 ++ .../src/modules/database/database.service.ts | 771 ++++++++++++++++++ packages/server/src/modules/registry.ts | 2 + packages/server/src/tools/apply-snapshot.ts | 59 ++ packages/server/tsconfig.generate.json | 9 + packages/shared/package.json | 2 +- packages/shared/src/constants.ts | 8 + packages/shared/src/types.ts | 81 ++ packages/web-admin/package.json | 2 +- packages/web-admin/src/api/http.ts | 5 + packages/web-admin/src/api/index.ts | 38 + .../web-admin/src/layouts/AdminLayout.vue | 109 ++- packages/web-admin/src/router/index.ts | 7 + packages/web-admin/src/views/DatabaseView.vue | 582 +++++++++++++ pnpm-lock.yaml | 90 +- scripts/dist-server.mjs | 10 + scripts/ui-smoke/main.cjs | 72 +- 66 files changed, 5131 insertions(+), 1027 deletions(-) create mode 100644 packages/desktop-client/src/renderer/components/OnboardingWelcome.vue create mode 100644 packages/desktop-client/src/renderer/stores/onboarding.ts create mode 100644 packages/desktop-client/src/renderer/stores/ui.ts delete mode 100644 packages/desktop-client/src/renderer/views/SettingsView.vue create mode 100644 packages/desktop-client/src/renderer/views/settings/AboutView.vue create mode 100644 packages/desktop-client/src/renderer/views/settings/AccountSettingsView.vue create mode 100644 packages/desktop-client/src/renderer/views/settings/AppearanceView.vue create mode 100644 packages/desktop-client/src/renderer/views/settings/GeneralSettingsView.vue create mode 100644 packages/desktop-client/src/renderer/views/settings/IslandSettingsView.vue create mode 100644 packages/desktop-client/src/renderer/views/settings/ReminderSettingsView.vue create mode 100644 packages/server/src/lib/snapshot.ts create mode 100644 packages/server/src/modules/database/database.module.ts create mode 100644 packages/server/src/modules/database/database.schemas.ts create mode 100644 packages/server/src/modules/database/database.service.ts create mode 100644 packages/server/src/tools/apply-snapshot.ts create mode 100644 packages/server/tsconfig.generate.json create mode 100644 packages/web-admin/src/views/DatabaseView.vue diff --git a/.gitignore b/.gitignore index 6954ea5..78089ab 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,5 @@ releases/ # AI 会话产物(ZCode 计划/配置,不属于产品代码) .zcode/ .zcodeignore +packages/server/prisma/backups/ +packages/server/prisma/database-settings.json diff --git a/AGENTS.md b/AGENTS.md index 10e5ec2..225260b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,7 +89,7 @@ packages/desktop-client/ src/types/desktop.d.ts 主进程 ↔ 渲染进程契约 scripts/ build-main / dev / smoke / dist-win packages/classisland-plugin/ .NET 8 插件(独立于 pnpm workspace,不参与 pnpm install) - manifest.yml 清单:id=classhelper.classisland.bridge / apiVersion=2.0.0.0 / version=0.1.0.0 + manifest.yml 清单:id=classhelper.classisland.bridge / apiVersion=2.0.0.0 / version=1.0.0.0 ClassHelper.ClassIslandPlugin.csproj(TargetFramework=net8.0) src/Plugin.cs 入口:读配置 → 注册提醒提供方 / 设置页 / BridgeService src/Models/PluginSettings.cs @@ -395,9 +395,9 @@ homeworks / notifications / calls / imports / integrations / grades / students / 长高发生在卡片形变**之前**(`setState` 里先 `relayout()` 再 `emit()`),用户看不到这一步; - 列表态 CSS 的每个尺寸(行高/行距/内边距/标题行/提示行/按钮行)都要与那份度量**一一对应**, 改 CSS 必须改常量,否则"最后一行被切掉"这类问题会以"看着有内容、按钮点不到"的形式出现。 - 回归用例:`verify:desktop` 的「多条通知竖向排列…」「点展开更多…」「通知多到屏幕放不下…」。 - 写这类用例时注意:**断言"单条卡片"的用例(紧急卡/叫人卡/截图比例)必须先 `drainIsland()` 排空队列**, - 否则量到的是列表高度。 + 回归用例:`verify:desktop` 的「多条通知竖向排列…」「点展开更多…」「通知多到屏幕放不下…」。 + 写这类用例时注意:**断言"单条卡片"的用例(紧急卡/叫人卡/截图比例)必须先 `drainIsland()` 排空队列**, + 否则量到的是列表高度。 34. **"知道了 / 标为已读"之后是「状态同步落地 + 窗口淡出」,没有跨进程的收回握手。** 收起一律是**主进程单向**的:`handleAction()` 收到 `dismiss` / `dismiss-all` / `mark-read` / @@ -438,7 +438,7 @@ homeworks / notifications / calls / imports / integrations / grades / students / 早先漏掉这一条时窗口会在卡片还很大时就缩成胶囊尺寸,卡片底部被平切("上半剩圆角、下半截断"); 而"隐藏态改边距不生效"那条修法仍要保留:`this.win.isVisible() && (mode !== 'hidden' || idleSliver)`。 -40. **开合时胶囊内容禁止跟着卡片重排/横移。** 卡片在 268 ⇄ 424 之间变宽变窄,中心停靠下左右边都在动; +37. **开合时胶囊内容禁止跟着卡片重排/横移。** 卡片在 268 ⇄ 424 之间变宽变窄,中心停靠下左右边都在动; 胶囊内容若按卡片宽度布局,就会出现"文字先按宽卡片铺开、再随卡片收窄被省略号收回" (用户反馈的"新消息那行字往右跳一下再缩回")。所以 `IslandApp.vue` 的胶囊层里套了一层 `.pill-inner`,**固定成胶囊自己的几何**(`width: var(--island-w)`),并按停靠方式对齐 @@ -446,7 +446,7 @@ homeworks / notifications / calls / imports / integrations / grades / students / 左/右停靠贴对应边,居中则居中 —— 静止态外观与之前完全一致,动画全程文字位置与省略号都不变。 回归用例:「灵动岛开合时胶囊内容不横移」,逐帧采样 `.pill-title` 左右边缘,波动必须 ≤ 2px。 -41. **跑自动化验证时,验证实例的岛要挪到左上角 + 真实输入穿透。** +38. **跑自动化验证时,验证实例的岛要挪到左上角 + 真实输入穿透。** `verify:desktop` / `verify:packaged` 会真的在屏幕上放一个灵动岛窗口(顶部居中),而用户自己的客户端 默认也在同一位置(他的设置常是 `top-center` + 较大 `marginY`)—— 两个岛一高一低叠在一起,用户会以为 **"屏幕上出现了第二个灵动岛"**,而且点它"反应不对"(它归验证脚本控制,会自己展开/收起/消失)。 @@ -457,35 +457,112 @@ homeworks / notifications / calls / imports / integrations / grades / students / **只改颜色、不要改停靠位置**:命中/几何断言都是按默认位置写的,把岛挪到角落会把 「空闲细缝态命中跟随光标」这类用例弄红(实测踩过)。 另外:冒烟会**真发通知**给当前班级(实时链路用例必须打到客户端所在班),所以验证期间用户的通知列表/ - 岛上会短暂出现几条「张老师 · *自检*」——它们用完即删(`createSmokeNotification` / `islandRealtimeCleanup`), + 岛上会短暂出现几条「张老师 · _自检_」——它们用完即删(`createSmokeNotification` / `islandRealtimeCleanup`), 若验证被中途 kill 会留下残条,收尾时顺手清一下(标题里带「自检 / 红点位置校验」的那几条)。 -42. **冒烟期间要让真实鼠标点击穿透(`island.setTestInputPassthrough(true)`)。** 自动化要跑几分钟, +39. **冒烟期间要让真实鼠标点击穿透(`island.setTestInputPassthrough(true)`)。** 自动化要跑几分钟, 而用户很可能正在同一块屏幕上 —— 他的客户端那个岛和冒烟这个窗口都在屏幕顶部居中、互相叠着, 他点到的是**冒烟这个窗口**:实测冒烟收到了人发出的 `collapse` / `mark-read`,把「点胶囊展开」 「点空白处收起」等用例整片弄红。开关只忽略**真实**输入:`cursorOverride`(`setHitTestCursor`) 一旦注入就照常按注入位置判定,所以「光标轮询校正命中」「岛外穿透」「触摸模式始终接收输入」 这些断言不受影响(触摸模式那条会临时关掉开关)。跑完在收尾处恢复。 -37. **作业页的 `?date=` 深链必须在"已在作业页"时也生效。** `route.query.date` 只在组件 setup 里读 +40. **作业页的 `?date=` 深链必须在"已在作业页"时也生效。** `route.query.date` 只在组件 setup 里读 一次的话,同路由再推 query(冒烟切到"最近有作业的日期"、用户从别处深链回来)会被复用的组件 无视,看板停在原日期 —— 表现为"看板明明有历史作业却显示空"。修法是 watch `route.query.date` (`HomeworkView.vue`,与 `selectDate` 的 `router.replace` 形成环时靠"值相同就跳过"防死循环)。 同理,冒烟里"切到有作业的日期"的判据是**看板里没有 `.board-card`**,不是"没有 `.board-host`" (空看板照样渲染容器,拿容器当判据兜底永远不会触发)。 -38. **打包版冒烟自检发的通知要"任何时段都可见"**:用 `priority: 'URGENT'` + `confirmDuringClass: true` +41. **打包版冒烟自检发的通知要"任何时段都可见"**:用 `priority: 'URGENT'` + `confirmDuringClass: true` (上课时段普通通知会被**正确地**暂存到下课、紧急通知不带确认会被 409 `URGENT_DURING_CLASS` 拒掉 —— 两个都是产品功能,别为了测试去改产品行为)。凡是断言"窗口已隐藏"的用例一律**轮询**等待, 不要写死 sleep:`dismiss` 现在带 ~360ms 收回动画再淡出,机器忙时固定 sleep 会间歇性踩空 (「关闭空闲细缝后空闲再次完全隐藏」曾因此 4 连红)。 -39. **用户报"打包版用不了/装不上"先查两件事,别急着怀疑产物**(2026-10-01 实测,两次全是环境): +42. **用户报"打包版用不了/装不上"先查两件事,别急着怀疑产物**(2026-10-01 实测,两次全是环境): ① **后端起没起** —— 客户端配置指向 `127.0.0.1:4000`,后端不在就进离线模式、数据全空, 观感就是"软件坏了";② **托盘单实例锁** —— 关窗是隐藏到托盘、进程还在,再双击 EXE 会被 单实例锁秒退(看起来"点了没反应"),安装包也会因"应用正在运行"装不上:先从托盘退出。 完整性标签(§7 第 7 条)用 `icacls | findstr Mandatory` 一眼可辨,优先排除。 +43. **Web 端新手引导(`el-tour`)在自动化里的判据有三条坑**(2026-10-02 实测,`verify:web` + 「首次登录展示新手引导且可跳过」踩过): + ① **外壳先出现、内容晚一拍** —— `document.querySelector('.el-tour')` 先于步骤卡片渲染成立, + 急着找 `.el-tour__closebtn` 会拿到 null;出现判据要等 **× 按钮渲染出来**(等 10s 不为过)。 + ② **关闭后内容元素残留** —— 和 el-dialog 一样(本表第 22 条),点 × 生效后 `.el-tour` 仍在 DOM + (父级 `.el-popper` 被隐藏,computed display 仍是 block);**关闭判据是 `.el-tour__mask` 卸载**, + 不是 `.el-tour` 消失。跳过 / 走完都会写 `classhelper.onboarding`(版本号),可顺带断言。 + ③ **全高元素(侧边栏)作锚点必须显式 `placement: 'right'`** —— 默认 bottom 会把卡片推到 + 视口外(表现为"遮罩挖了洞、卡片不见了")。 + 另外 `ui-smoke` 管理员用例里的 `localStorage.clear()` 会把「已看过」标记一并清掉,管理员登录后 + 引导会再次弹出 —— 该用例里必须先跳过它再继续,别只处理首次登录那一次。 + +44. **数据库管理模块(服务端)** 见 §5 第 36 条;做"切库/备份/迁移"类改动前先读那一条, + 那里写清了快照格式、外键陷阱、切换为什么必须走子进程、以及打包为何要带上 prisma CLI + tsc。 + +45. **客户端设置页已拆成子路由**(2026-10-02):`/settings/{general,appearance,island,reminder,account,about}`, + `/settings` 重定向到 `/settings/general`;旧的单一 `views/SettingsView.vue` **已删除**。 + 改设置相关冒烟用例时必须改到对应子路由(`smoke.ts` 里 `location.hash = '#/settings'` 已全部改掉), + 否则会停在通用页、找不到灵动岛滑块或「ClassIsland 联动」卡片而误报失败。 + +46. **深色主题的实现边界**:颜色一律走 `--ch-*` / `--el-*` 变量(`styles/index.css` 的 `:root` + 是浅色值、`html.dark` 是深色值),**组件里不要写死颜色**。已知的固定深色区域(灵动岛、 + 登录页、App 启动过渡页、全屏作业看板)刻意不随主题 —— 改它们会被当成回归。 + 主题状态在 `stores/ui.ts`(持久化到主进程配置 `theme`),主窗口 `backgroundColor` 也按主题设置 + (深色下若仍用浅色底会"先白后黑"闪一下)。 + +47. **数据库管理模块(`/api/database/*`,仅 ADMIN)——改它之前必须知道的四件事**: + ① **一切都建立在 `lib/snapshot.ts` 的 JSON 快照上**(备份 / 导入导出 / 跨库迁移共用一种格式, + 14 张表按拓扑序导出、恢复时临时关外键检查、分批 `createMany`)。恢复时**必须**先关外键检查: + schema 里 `User.classId → Class` 与 `Class.teacherId → User` **互相引用**,任何排序都会被一侧卡死。 + ② **一键切换必须走子进程**:运行中的服务进程缓存的是旧方言的 Prisma Client,改完 provider 后 + 只有**新进程**才能加载重新生成的客户端 —— 因此 `generate` / `db push` / 写数据分别用 + `spawn(process.execPath, ...)` 拉起(数据写入走 `dist/tools/apply-snapshot.js`)。 + **顺序不能换**:备份 → 导出 → 测试连接 → 校验目标库为空 → 改 schema → generate → 编译 → db push → 写数据 → 改 .env; + `.env` 只在**全部成功后**才改写,中途失败回滚 `schema.prisma`(现有库不受影响)。 + ③ **`prisma generate` 的产物是 `.ts`**(`prisma-client` 生成器),所以:dev 由 tsx 直接跑 src; + **打包形态必须再跑一次 `tsc -p tsconfig.generate.json`** 把新客户端编进 `dist/generated`, + 生产包因此要随包带上 `prisma` CLI、`typescript` 与 `schema.prisma`/`prisma.config.ts` + (`scripts/dist-server.mjs` 的 `buildRuntime` 已拷;安装包 ~34MB → ~71MB)。 + ④ **主库只支持 SQLite / MySQL**:Redis 等键值库 Prisma 不支持(无法建表/迁移), + schema 层直接 422 拒绝;这是产品口径,别"顺手"加上。 + e2e 只覆盖校验分支(同库 400 / 非法 provider 422 / 目标库非空),**不真跑切换**(会改写 .env); + 真实切换用子进程链路单独实测(快照 728 行逐表一致)。切换后**必须重启服务端**才生效。 + +48. **客户端主窗口初始尺寸与 ClassIsland 对齐(1242×582)**。这个数不是拍的:用 + `GetWindowRect` + `DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS)` 量运行中的 + `ClassIsland.Desktop` 主窗口(实测可视区 1242×582,屏幕工作区 1600×852、DPI 100%)。 + `main/index.ts` 的 `createWindow` 按它设 width/height + `center: true`, + **`minHeight` 必须 ≤ 582**(否则窗口达不到目标高度,用户会看到"设置不了那个大小")。 + 冒烟断言:「初始窗口尺寸与 ClassIsland 对齐(1242x582)」(`win.getSize()`),改尺寸即红。 + +49. **侧栏(分组折叠菜单)的三条硬要求**,都是用户报过的问题固化的: + ① **折叠态文字必须 `display: none`** —— Element Plus 自己的隐藏方式是"把 span 的宽高压成 0 + + overflow",外层只要有缩进/margin/transition 干扰,文字就会以"0 宽溢出"留在画面上 + (用户截图里的半截字)。规则写在 `styles/index.css` 的 `.ch-nav .el-menu--collapse ... > span:not(.menu-icon-slot)`。 + ② **折叠态图标必须与顶部汉堡按钮同一竖列**(冒烟断言中心差 ≤ 2px)—— 一个居中、一个偏左就会 + 看起来"没对齐"。汉堡在 `.brand` 里也是居中的,两边都在 64/2 = 32px。 + ③ **菜单区必须 `min-height: 0` + `overflow-y: auto`** —— 否则菜单项多时会把底部 + 「第 N 周 / 最近同步」挤出窗口(窗口 582px 高、两组全展开时必然发生)。footer 是 aside 的 + 兄弟 flex 子项,菜单区滚动后它恒定可见(冒烟断言「侧栏底部可见」)。 + 另:分组**默认只展开当前路由所在分组**(ClassIsland 同款,也让小窗口侧栏不溢出); + `el-sub-menu__title` 的点击是 **toggle**,自动化里要先判"子项是否可见"再决定点不点标题 + (盲点会把已展开的组收起来,后续点不到子项)。 + +50. **深色主题:渲染进程里除固定深色区域外,颜色一律走 `--ch-*` 变量。** + §5 第 46 条讲了机制,这里补具体踩坑:课表页时间轴卡片曾写死 `rgba(255, 255, 255, 0.6)`, + 深色主题下变成"浅灰卡 + 近白字"几乎不可读(用户截图)。自查命令: + `grep -rn "rgba(255, 255, 255\|rgba(0, 0, 0\|#fff" packages/desktop-client/src/renderer`, + 除**明确固定深色**的区域(全屏作业看板、登录页渐变、App 启动过渡页、灵动岛自身、 + 以及 accent 渐变底上的白字)外,都应该是变量。 + +51. **跑 `verify:desktop` / `verify:packaged` 若大面积报"教师登录失败",先核对本机 teacher1 的密码**: + 本机实测是 **`123456`**,不是种子默认的 `teacher123` —— 因为「修改密码」弹窗留空会落到 + `DEFAULT_TEACHER_PASSWORD`(=123456),某次人工/脚本操作就会把演示账号改成它。 + 处理方式是用环境变量传实际凭据(脚本原生支持,**不要去改数据**): + `set "ELECTRON_SMOKE_USER=teacher1" && set "ELECTRON_SMOKE_PASSWORD=123456" && pnpm verify:desktop`。 + --- ## 6. 代码风格 diff --git a/README.md b/README.md index e6ed05f..2ec5682 100644 --- a/README.md +++ b/README.md @@ -113,10 +113,13 @@ class-helper/ │ ├── index.ts # 启动入口(自检/初始化 + HTTP + Socket.IO + 优雅退出) │ ├── config/env.ts # 环境变量校验(zod)+ 生产配置自检 │ ├── lib/ # access(RBAC) / class-account / session / db / db-bootstrap / - │ │ # web-static / http / jwt / logger / mappers / password / schemas / term + │ │ # snapshot(全库 JSON 快照) / web-static / http / jwt / logger / + │ │ # mappers / password / schemas / term + │ ├── tools/apply-snapshot.ts # 跨库迁移的子进程入口(编译到 dist/tools/) │ ├── middleware/ # auth / error / validate / security(helmet+限流+耗时日志) │ ├── realtime/ # socket.ts + bus.ts(事件总线) - │ └── modules/ # 13 个功能模块 + registry.ts(模块注册表) + │ └── modules/ # 14 个功能模块 + registry.ts(模块注册表) + │ └── database/ # 数据库管理(状态/备份/导入导出/定时/一键切换,仅管理员) ├── classisland-plugin/ # ClassIsland 联动插件(.NET 8 / C#,独立于 pnpm workspace) │ ├── src/Plugin.cs # 插件入口(读配置 → 注册提醒提供方/设置页/联动服务) │ ├── src/Models/PluginSettings.cs @@ -251,14 +254,14 @@ pnpm verify:packaged # 对**打包后/已安装**的客户端 EXE 跑同一 课表 93(每班 5 天 × 6 节 + 1 条双周对调)/ 作业 15 / 通知 12 / 成绩 90 ``` -| 角色 | 用户名 | 密码 | 说明 | -| ------ | ------------- | ------------ | ------------------------------------------- | -| 管理员 | `admin` | `admin123` | 可访问全部班级 | -| 教师 | `teacher1` | `teacher123` | 张老师:高一(1)班、高二(3)班班主任 | -| 教师 | `teacher2` | `teacher123` | 李老师:高一(2)班班主任,高二(3)班协作教师 | -| 班级 | 班级码 `G101` | `123456` | 高一(1)班(**学生端唯一登录方式**) | -| 班级 | 班级码 `G102` | `123456` | 高一(2)班 | -| 班级 | 班级码 `G203` | `123456` | 高二(3)班 | +| 角色 | 用户名 | 密码 | 说明 | +| ------ | ------------- | ------------ | ------------------------------------------ | +| 管理员 | `admin` | `admin123` | 可访问全部班级 | +| 教师 | `teacher1` | `teacher123` | 张老师:高一(1)班、高二(3)班班主任 | +| 教师 | `teacher2` | `teacher123` | 李老师:高一(2)班班主任,高二(3)班协作教师 | +| 班级 | 班级码 `G101` | `123456` | 高一(1)班(**学生端唯一登录方式**) | +| 班级 | 班级码 `G102` | `123456` | 高一(2)班 | +| 班级 | 班级码 `G203` | `123456` | 高二(3)班 | - 密码由**种子脚本硬编码**(`admin123` / `teacher123` / 班级 `123456`);`.env` 的 `DEFAULT_CLASS_PASSWORD` 决定**新建**班级时的初始班级密码。 @@ -307,9 +310,18 @@ pnpm verify:packaged # 对**打包后/已安装**的客户端 EXE 跑同一 布局沿用 **1Panel 风格**:固定深色侧边栏(`#1f2d3d` + 蓝色圆角选中态)+ 白色顶栏(当前页标题/连接状态/用户菜单)+ 浅灰底 + 白色圆角卡片内容区。手机小屏下侧边栏收进抽屉,由顶栏汉堡按钮唤出。 -页面(路由)共 11 个:登录、仪表盘、班级管理、学生管理、教师管理、课表管理、作业发布、通知发布、 -成绩录入、ClassIsland 联动、页面不存在;其中**班级管理 / 学生管理 / 教师管理**为 `meta.roles: ['ADMIN']` -(直接输网址会被挡回仪表盘,服务端同样拦),**ClassIsland 联动**为 `['ADMIN','TEACHER']`。 +页面(路由)共 12 个:登录、仪表盘、班级管理、学生管理、教师管理、课表管理、作业发布、通知发布、 +成绩录入、ClassIsland 联动、数据库管理、页面不存在;其中**班级管理 / 学生管理 / 教师管理 / 数据库管理** +为 `meta.roles: ['ADMIN']`(直接输网址会被挡回仪表盘,服务端同样拦), +**ClassIsland 联动**为 `['ADMIN','TEACHER']`。 + +### 新手引导(首次登录 + 随时重看) + +首次登录进入主布局后会自动弹出**聚焦式引导**(Element Plus `el-tour`):欢迎页 → 侧边菜单(小屏为汉堡按钮, +锚点不存在时卡片自动改为屏幕居中)→ 实时通道状态 → 账号菜单 → 完成。走完或跳过(右上角 × / Esc)即记为 +"已看过"(localStorage `classhelper.onboarding`,值为引导版本号,内容改版后 +1 可让老用户再看一次), +之后不再自动弹出;**右上角头像菜单 →「使用引导」可随时重看**。实现集中在 `AdminLayout.vue`(锚点用 +`data-tour` 属性惰性查询)。 ### 圆角设计(统一设计令牌) @@ -410,18 +422,52 @@ pnpm verify:packaged # 对**打包后/已安装**的客户端 EXE 跑同一 渲染进程能通过桥接拿到明文登录令牌,CSP 是唯一能拦住"加载并执行远程脚本"的那一层; 灵动岛窗口另有独立的导航防线(拒绝一切导航与新开窗口)。 +**主窗口初始尺寸与 ClassIsland 对齐(1242×582)**:这个数是从运行中的 ClassIsland 主窗口实测来的 +(`GetWindowRect` + `DwmWindowAttribute` 读可视区),启动时居中显示;窗口底色按主题设置,避免深色下"先白后黑"。 + **安装包里不得出现任何凭据**:`dist/main/index.js` 与 `dist/renderer/assets/*.js` 会被打进 `app.asar`(可直接解包),因此冒烟/自检代码用的账号口令一律由 `scripts/smoke.mjs` / `scripts/verify-packaged.mjs` 通过环境变量(`ELECTRON_SMOKE_USER` / `ELECTRON_SMOKE_PASSWORD` / `..._CLASS_CODE` / `..._CLASS_PASSWORD`)注入,不写在源码里;`pnpm build:desktop` 结尾会跑 `check-bundle-secrets` 门禁,产物里一旦出现种子口令即构建失败。 ### 功能页面 | 页面 | 功能 | | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 登录 | 服务器地址 + **班级码 + 班级密码**,附带「测试连接」;已登录但服务器不可达时可「离线进入」 | +| 登录 | 服务器地址 + **班级码 + 班级密码**,附带「测试连接」;已登录但服务器不可达时可「离线进入」;下方有「查看使用引导」入口 | | 课表 | 按周展示 7 列课表(周次下拉 + 单双周标签),另有「今天」时间轴视图(大时钟 + 当前状态卡 + 已结束/正在上/下一节) | | 作业 | **看板模式**(按科目卡片、全屏放大、显示时间开关、看板字号)+ 列表模式、详情抽屉、附件链接(只接受 `http(s)://` 或站内相对路径,挡住 `javascript:` 这类可执行链接);教室设备可**录入作业**(快捷短语)与勾选**未交名单** | | 通知 | 未读红点(侧边栏徽标)、优先级标签、点击自动标为已读、全部已读、未读过滤、详情抽屉 | | 成绩 | 班级账号看到**全班成绩总览**:表格(分数/得分率/等级)+ ECharts 柱状图 + 等级分布 + 最近更新 | -| 设置 | 服务器地址(保存并测试)、通知显示位置、ClassIsland 联动状态、作业录入短语、灵动岛个性化、**修改密码**、缓存统计与清空、版本信息、退出登录 | +| 设置 | 拆成六个子页(见下):通用(服务器/离线缓存/作业录入短语 + 使用引导)、外观(黑夜/白天)、灵动岛、提醒(ClassIsland 联动)、账号、关于 | + +### 设置改版:分组侧边栏 + 子页 + 深色模式 + 关于页 + +**侧边栏(ClassIsland 同款 NavigationView)**:主侧边栏改为「学习」「设置」两个**可展开分组** +(学习 = 课表/作业/通知/成绩;设置 = 通用/外观/灵动岛/提醒/账号/关于,每个子项是独立路由 +`/settings/*`);顶部**汉堡按钮**把整栏折叠成 64px 图标栏(折叠状态持久化到主进程配置, +重启仍记忆)。红点徽标仍挂在「通知」项图标右上角。 + +**设置子页**:原单页设置按卡片拆为 6 个子页——通用(服务器连接 + 离线缓存 + 作业录入短语 + +「使用引导」)、外观、灵动岛、提醒、账号、关于;设置入口重定向到 `/settings/general`。 + +**黑夜/白天模式**:外观子页选「浅色 / 深色」,顶栏右上角有日月快捷切换按钮;切换立即生效 +(`html.dark` + Element Plus dark css-vars + 品牌变量 `html.dark` 覆盖)并写入主进程配置 +(`theme` 字段),启动窗口底色也按主题设置(避免"先白后黑"闪一下)。灵动岛、登录页、 +全屏作业看板本来就是深色/固定配色,不随主题变化。 + +**关于页**(ClassIsland 同款折叠卡片):应用信息(版本 / Electron / Chromium / Node / 常用链接)、 +查看诊断信息(服务器与连接状态、缓存条目、数据目录、**配置文件路径**)、鸣谢;底部寄语。 +诊断信息依赖 `getAppInfo` 新增的 `configPath` 字段。 + +### 初次启动引导(六步向导,可重看) + +客户端**首次启动**(配置里没有 `onboardingDone`)会在登录页上自动弹出六步向导: +欢迎 → 连接服务器 → 登录班级 → 登录之后(主界面与离线缓存)→ 灵动岛提醒 → 完成。 +完成或跳过(右上角「跳过引导」/ Esc)都会把 `onboardingDone: true` 写入主进程配置(`config.json`), +之后不再自动弹出;**登录页「查看使用引导」与「设置 → 通用 → 使用引导」可随时重看**。 + +实现说明:引导是自绘遮罩 + `v-if`(`renderer/components/OnboardingWelcome.vue`),关闭即从 DOM 卸载 +(特意不用 el-dialog,避免"关闭后 DOM 残留"的断言陷阱);显隐由 `stores/onboarding.ts` 承载, +让登录页 / 设置页 / App.vue 三处共用一个开关。`verify:desktop` 有一条端到端回归: +「初次启动引导(首启自动弹出、可走完、状态写入配置)」——真实点击把六步走完并断言配置落盘。 **作业看板(学生端)**:默认按科目分卡片(每张卡列该科作业,条目可点开详情), 工具栏可切「看板 / 列表」、开关「显示时间」、拖「字号」(11–28px 即时生效), @@ -492,27 +538,27 @@ pnpm verify:packaged # 对**打包后/已安装**的客户端 EXE 跑同一 > > 由于窗口固定,Windows 那条"透明窗口最小高度约 36px"的限制不再作用于岛:空闲细缝可以真正做到 6px 宽。 -| 场景 | 灵动岛行为 | -| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| 收到通知(非上课时段) | **直接出现胶囊**(无"上岛"入场动画):`新消息 · 共 N 条` + 发送人 + `点击查看` | -| 多条未处理消息 | 胶囊标题汇总类型:`新消息:叫人/作业/通知(共 3 条)` + `点击查看`,右侧附类型小标签(单条时显示具体类型名) | -| 点击胶囊(只有一条) | 展开为**详情卡**(形变 + 淡入,无回弹):标题、内容、时间、`打开应用 / 标为已读 / 知道了` | -| **点击胶囊(多条)** | 展开为**竖排列表**:按**重要程度**排序(紧急 > 重要 > 普通 > 低,同级按时间新的在前),默认**只显示前三条**,下面一行 `展开更多(还有 N 条)`;列表始终只有**一排按钮** | -| **点"展开更多"** | 把放得下的通知都铺出来;铺到屏幕工作区下沿(任务栏)仍放不下时,不再硬撑,改成 `更多请前往应用内操作`(卡片高度始终 ≤ 可用高度) | -| **多条时的"知道了 / 标为已读"** | 都是**整批**操作:`知道了` 整批关闭(通知中心仍为未读)、`标为已读` 把这一批全部标为已读并关闭;两者都是状态同步清空 + 窗口淡出隐藏 | -| **上课时段的"普通叫人"** | **不自动展开**(不打断课堂),但保留 `叫人` 胶囊并**允许学生主动点开**;下课自动展开(`isOpenable`:紧急消息与任何叫人都可点开) | -| **收起后胶囊常驻** | 自动收起 / 手动收起只把卡片回缩为胶囊,**有未处理通知时胶囊不会消失**;彻底消失只在"知道了 / 标为已读"、队列清空或进入上课时段 | -| **收起态点击命中** | 收起瞬间窗口会保持可交互,且渲染进程在每次状态变化后都按**缓存的指针位置**重算命中 | -| 点击卡片空白处 / 屏幕任意位置 | 回缩为**胶囊**(不直接消失,仍可再次点开);展开时窗口临时可聚焦,点到别处即失焦收起 | -| **点"标为已读"** | 通知中心同步标记已读、未读红点立即减少(多条时是整批,见上) | -| **新作业发布** | 也上岛:胶囊显示"新作业"(青蓝描边 + 书本图标),展开可见作业要求(**截止时间功能已下线**);**本机刚录入的那条不上岛**(自己通知自己没意义,见下) | -| **紧急叫人**(老师点名·紧急) | **上课时段也立即展开**:琥珀金卡片、`叫人` 徽标、"请 XXX 同学找 XXX 老师"、按钮为"收到"(`priority=URGENT`) | -| **普通叫人**(默认级别) | 课间先显示 `叫人` 胶囊、点击展开;**上课时段只进队列**(不打断课堂),下课后自动弹出详情(`priority=HIGH`) | -| 卡片形变 | 卡片是**固定尺寸、顶部居中锚定**的,窗口只负责露出/裁切透明区域 | -| 上课时间段收到普通通知 | **完全不显示**(窗口直接隐藏,不打扰课堂)并进入待发队列;下课后自动弹出详情 → 收起为胶囊 | -| 上课时间段收到**紧急**通知 | **无论是否上课立刻展开**显示详情(带内部红色呼吸光晕与"紧急"角标,无需点击),45 秒后收起 | -| 上课时间段内的任何点击 | 一律不显示(不会展开、也不会回缩出胶囊),只有紧急通知能出现在屏幕上 | -| 退出/断开 | 主窗口退出时灵动岛一并关闭;上课状态来自 `GET /api/schedules/current` | +| 场景 | 灵动岛行为 | +| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 收到通知(非上课时段) | **直接出现胶囊**(无"上岛"入场动画):`新消息 · 共 N 条` + 发送人 + `点击查看` | +| 多条未处理消息 | 胶囊标题汇总类型:`新消息:叫人/作业/通知(共 3 条)` + `点击查看`,右侧附类型小标签(单条时显示具体类型名) | +| 点击胶囊(只有一条) | 展开为**详情卡**(形变 + 淡入,无回弹):标题、内容、时间、`打开应用 / 标为已读 / 知道了` | +| **点击胶囊(多条)** | 展开为**竖排列表**:按**重要程度**排序(紧急 > 重要 > 普通 > 低,同级按时间新的在前),默认**只显示前三条**,下面一行 `展开更多(还有 N 条)`;列表始终只有**一排按钮** | +| **点"展开更多"** | 把放得下的通知都铺出来;铺到屏幕工作区下沿(任务栏)仍放不下时,不再硬撑,改成 `更多请前往应用内操作`(卡片高度始终 ≤ 可用高度) | +| **多条时的"知道了 / 标为已读"** | 都是**整批**操作:`知道了` 整批关闭(通知中心仍为未读)、`标为已读` 把这一批全部标为已读并关闭;两者都是状态同步清空 + 窗口淡出隐藏 | +| **上课时段的"普通叫人"** | **不自动展开**(不打断课堂),但保留 `叫人` 胶囊并**允许学生主动点开**;下课自动展开(`isOpenable`:紧急消息与任何叫人都可点开) | +| **收起后胶囊常驻** | 自动收起 / 手动收起只把卡片回缩为胶囊,**有未处理通知时胶囊不会消失**;彻底消失只在"知道了 / 标为已读"、队列清空或进入上课时段 | +| **收起态点击命中** | 收起瞬间窗口会保持可交互,且渲染进程在每次状态变化后都按**缓存的指针位置**重算命中 | +| 点击卡片空白处 / 屏幕任意位置 | 回缩为**胶囊**(不直接消失,仍可再次点开);展开时窗口临时可聚焦,点到别处即失焦收起 | +| **点"标为已读"** | 通知中心同步标记已读、未读红点立即减少(多条时是整批,见上) | +| **新作业发布** | 也上岛:胶囊显示"新作业"(青蓝描边 + 书本图标),展开可见作业要求(**截止时间功能已下线**);**本机刚录入的那条不上岛**(自己通知自己没意义,见下) | +| **紧急叫人**(老师点名·紧急) | **上课时段也立即展开**:琥珀金卡片、`叫人` 徽标、"请 XXX 同学找 XXX 老师"、按钮为"收到"(`priority=URGENT`) | +| **普通叫人**(默认级别) | 课间先显示 `叫人` 胶囊、点击展开;**上课时段只进队列**(不打断课堂),下课后自动弹出详情(`priority=HIGH`) | +| 卡片形变 | 卡片是**固定尺寸、顶部居中锚定**的,窗口只负责露出/裁切透明区域 | +| 上课时间段收到普通通知 | **完全不显示**(窗口直接隐藏,不打扰课堂)并进入待发队列;下课后自动弹出详情 → 收起为胶囊 | +| 上课时间段收到**紧急**通知 | **无论是否上课立刻展开**显示详情(带内部红色呼吸光晕与"紧急"角标,无需点击),45 秒后收起 | +| 上课时间段内的任何点击 | 一律不显示(不会展开、也不会回缩出胶囊),只有紧急通知能出现在屏幕上 | +| 退出/断开 | 主窗口退出时灵动岛一并关闭;上课状态来自 `GET /api/schedules/current` | - 动画:窗口尺寸用逐帧缓动实现"形变",**单调不过冲**——展开 300ms、收回 220ms,统一 `easeOutCubic` (早期版本用过冲弹簧缓动,实测窗口会冲到目标之外再回落,观感就是"开合时震一下",现已移除); @@ -580,7 +626,10 @@ pnpm verify:packaged # 对**打包后/已安装**的客户端 EXE 跑同一 ### 个性化设置与系统托盘 -**设置页分区**:服务器地址 / 通知显示位置 / ClassIsland 联动 / 作业录入短语 / 灵动岛 · 个性化 / 账号信息 / 离线缓存 / 关于。 +**设置页结构(2026-10-02 改版)**:侧边栏「设置」分组下六个子页 —— +**通用**(服务器地址 / 离线缓存 / 作业录入短语 / 使用引导)、**外观**(黑夜白天)、 +**灵动岛**(下表全部选项)、**提醒**(通知显示位置 / ClassIsland 联动状态)、**账号**(账号信息 / 退出登录)、 +**关于**(版本 / 诊断信息 / 鸣谢)。详见上文「设置改版」一节。 > **改密入口(2026-10-01 调整)**:客户端不再提供「修改密码」(曾有过,改的是班级密码;随学生个人 > 账号清理一并移除,防止教室机器上误改班级密码后其他机器被锁在门外)。班级密码由管理员在 Web 端 @@ -1010,74 +1059,113 @@ POST /api/notifications { …, priority: "NORMAL" }(上课时段) - 部署到 `D:\Classisland`(先退出 ClassIsland 再覆盖 DLL,别覆盖 `Settings.json`)的完整步骤见 [AGENTS.md](AGENTS.md) §7 第 12 条与 §8。 +## 数据库管理(仅管理员) + +Web 管理端新增「数据库管理」页(`/database`,仅 ADMIN 可见,后端同样 `requireRole('ADMIN')`), +把过去要手工完成的数据库运维全部收进界面: + +| 能力 | 说明 | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 连接状态检测 | 当前类型(SQLite/MySQL)、连通性与延迟、引擎版本、数据体积、14 张表行数、连接串(MySQL 密码脱敏) | +| 连接测试 | 任意目标库的连通性试连(切换前的第一步) | +| **一键切换** | SQLite ⇄ MySQL:自动「备份当前库 → 改写 schema provider → prisma generate / db push 建表 → 子进程迁移全部数据 → 改写 .env」,异步任务带步骤进度,失败自动回滚 | +| 备份 / 恢复 | gzip JSON 快照存 `<数据目录>/backups/`;手动备份 / 从备份恢复(整库覆盖)/ 删除 | +| 定时备份 | enabled + 间隔小时数 + 保留份数,存 `data/database-settings.json`;服务端每 10 分钟检查一次到点任务(进程不运行不补跑) | +| 快捷导入导出 | 下载快照 JSON(跨库通用)/ 下载数据库文件(仅 SQLite)/ 导入快照(整库覆盖,base64 上传) | + +关键设计:**备份、导入导出、跨库迁移共用同一种「JSON 快照」**(`lib/snapshot.ts`,14 张表按拓扑序导出, +恢复时临时关外键检查——`User.classId` 与 `Class.teacherId` 互相引用)。因此任意备份都能恢复回任意一种 +受支持的数据库。MySQL 的连接测试/建表/写入依赖随包内置的 `@prisma/adapter-mariadb`(已在依赖里, +不再是"切换时手动安装")。 + +> 边界与须知: +> +> - **主库只支持 SQLite 与 MySQL**。Redis 是内存键值库,Prisma ORM 不支持它作为主数据库(无法建表/迁移), +> 接口层直接拒绝(e2e 有 422 断言);PostgreSQL 暂未纳入。 +> - **切换完成后必须重启服务端**(安装版运行 `restart.cmd`,开发模式重跑 `pnpm dev:server`)才会连接新库; +> 切换过程失败会自动回滚 schema.prisma,`.env` 只在数据全部迁移成功后才改写。 +> - **目标库必须为空**(有表即拒绝),防止误覆盖。 +> - 安装包体积因此增大约 37MB(~34MB → ~71MB):切换需要随包内置 prisma CLI 与 TypeScript 编译器 +> (Prisma 7 的生成器产出 .ts 源码,切换后要在目标机编译进 dist)。 +> - 真实切换(SQLite→MySQL)已在本机用完整子进程链路实测(快照 728 行逐表一致); +> e2e 只覆盖校验分支(同库 400 / 非法 provider 422 / 目标库非空拒绝),避免测试改写 `.env`。 + ## REST API 一览 统一响应体:`{ "success": true, "data": {}, "message": "" }`(错误为 `success:false` + `code`)。 所有接口前缀 `/api`,除登录/健康检查外均需 `Authorization: Bearer ` (ClassIsland 设备接口用 `X-ClassIsland-Token`)。 -| 方法 | 路径 | 权限 | 说明 | -| --------------------------- | ----------------------------------------------------------- | ---------------------------- | -------------------------------------------------------------------------------- | -| POST | `/auth/login` | 公开 | 登录(教师 / 管理员);`role=STUDENT` 返回 403(学生只有名单,改用班级登录) | -| POST | `/auth/class-login` | 公开 | **班级账号登录**:班级码 + 班级密码 → `classSession` 会话 | -| GET | `/auth/me` | 登录 | 当前用户(学生附带班级/年级) | -| PATCH | `/auth/password` | 登录 | 修改自己的密码 | -| POST | `/auth/logout` | 登录 | 退出(无状态,客户端丢弃 token) | -| GET | `/classes` | 登录 | 班级列表(按权限收敛) | -| GET | `/classes/:id` | 班级可见 | 班级详情(学生/课程/协作教师) | -| POST | `/classes` | 管理员 | 创建班级(自动生成班级码 = 班级账号,可指定班主任) | -| PATCH / DELETE | `/classes/:id` | 管理员 / 教师 | 编辑 / 删除班级 | -| PATCH | `/classes/:id/class-account` | 管理员 | 设置 / 重置班级账号(班级码 + 班级密码) | -| PATCH | `/classes/:id/head-teacher` | 管理员 | 设置 / 更换班主任 | -| GET | `/classes/:id/classisland-status` | 班级可见 | 本班 ClassIsland 联动状态(教室客户端设置页用) | -| GET | `/classes/:id/notification-channel` | 班级可见 | 读取"通知显示到哪个端" | -| PATCH | `/classes/:id/notification-channel` | 班级账号 / 教师 / 管理员 | 设置"通知显示到哪个端"(默认由教室机器自己选) | -| GET / POST | `/classes/:id/students` | 班级可见 / ADMIN·TEACHER | 学生名单 / 添加学生(已存在账号直接转入) | -| DELETE | `/classes/:id/students/:userId` | ADMIN·TEACHER | 移出学生 | -| POST / DELETE | `/classes/:id/teachers[/:teacherId]` | ADMIN·TEACHER | 分配 / 取消协作教师 | -| GET / POST / PATCH / DELETE | `/courses` | 登录(写:班主任/管理员) | 课程管理 | -| GET | `/schedules?classId=&week=&dayOfWeek=` | 登录 | 课表列表(week 过滤周次范围) | -| GET | `/schedules/grid?classId=&week=` | 登录 | 周视图(7 列结构,供客户端直接渲染) | -| GET | `/schedules/current?classId=&at=` | 登录 | 当前上课状态(`inClass` / `current` / `next`;`at` 为诊断用时间覆盖) | -| POST / PATCH / DELETE | `/schedules[/:id]` | 班主任 / 管理员 | 课表增删改(广播 `schedule:updated`) | -| GET | `/homeworks?classId=&courseId=&date=&pendingOnly=&keyword=` | 登录 | 作业列表(支持按所属日期;学生带完成状态,教师带完成人数) | -| GET | `/homeworks/days?classId=&from=&to=` | 登录 | 哪些天有作业(日期高亮) | -| GET | `/homeworks/:id` | 班级可见 | 作业详情 | -| POST | `/homeworks` | 教师 / 管理员 / **班级账号** | 发布作业(附 `assignDate`;班级账号用于教室机器录入,归属班主任) | -| PATCH / DELETE | `/homeworks/:id` | 教师 / 管理员 | 修改 / 删除(广播 `homework:new` / `homework:updated`) | -| PATCH | `/homeworks/:id/status` | 登录 | 标记完成/取消(广播 `homework:status`) | -| GET / PATCH | `/homeworks/:id/submissions` | 登录 | 未交名单:读名单 / 回写全班完成状态 | -| GET | `/notifications?classId=&priority=&unreadOnly=&keyword=` | 登录 | 通知列表(带已读状态) | -| GET | `/notifications/unread-count` | 登录 | 未读数(红点) | -| POST | `/notifications` | 教师 / 管理员 | 发布通知(广播 `notification:new`);上课时段发布紧急通知需 `confirmDuringClass` | -| POST | `/notifications/:id/read`、`/notifications/read-all` | 登录 | 标记已读(班级设备会写全班) | -| DELETE | `/notifications/:id` | 教师 / 管理员 | 删除通知 | -| POST | `/calls` | 教师 / 管理员 | **叫人**(`urgent` 决定 URGENT / HIGH;广播 `notification:new` 与 `call:new`) | -| GET | `/grades/my` | 登录 | 个人成绩(班级会话 → 全班总览) | -| GET | `/grades?classId=&courseId=&userId=&examName=` | 教师 / 管理员 | 班级成绩 | -| GET | `/grades/stats?classId=&courseId=&examName=` | 教师 / 管理员 | 等级分布 + 各课程平均得分率 | -| POST | `/grades`、`/grades/bulk` | 班主任 / 管理员 | 单条 / 批量录入(广播 `grade:updated`) | -| PATCH / DELETE | `/grades/:id` | 班主任 / 管理员 | 修改 / 删除成绩 | -| GET | `/students?classId=&keyword=` | **管理员** | 学生名单 | -| POST / PATCH / DELETE | `/students[/:id]` | **管理员** | 学生名单增删改(无密码概念;重置密码接口已下线 → 404) | -| GET | `/teachers?keyword=` | **管理员** | 教师列表 | -| POST / PATCH / DELETE | `/teachers[/:id]` | **管理员** | 新建 / 编辑 / 删除教师账号 | -| POST | `/teachers/:id/reset-password` | **管理员** | 修改 / 重置教师密码(`newPassword` 可选,留空 = 默认初始密码) | -| GET | `/dashboard/summary` / `/dashboard/term` | 登录 | 仪表盘汇总 / 学期周次 | -| GET | `/imports/template?kind=&format=` | 管理员 / 班主任 | 导入模板下载(`csv` 走 JSON,`xlsx` 走二进制) | -| POST | `/imports/table/preview` | 管理员 / 班主任 | 上传表格(base64)解析预览:列名 + 前 20 行 + 校验问题 + 建议映射 | -| POST | `/imports/table/commit` | 管理员 / 班主任 | 按字段映射与写入模式导入(成绩 / 学生名单 / 教师名单) | -| POST | `/imports/time-layout/preview` | 管理员 / 本班班主任 | 解析 ClassIsland 时间配置 JSON(只解析不落库) | -| GET / POST / DELETE | `/imports/time-layout[/:id]` | 管理员 / 本班班主任 | 时间配置列表 / 导入(`replace` 覆盖、`merge` 合并)/ 删除 | -| POST | `/imports/class-plan/preview`、`/imports/class-plan` | 管理员 / 本班班主任 | ClassIsland 课程表解析预览 / 导入(支持单双周) | -| GET / POST / PATCH / DELETE | `/integrations/devices[/:id]` | 管理员 / 教师 | ClassIsland 联动设备管理(令牌只存 sha256,令牌前缀用于人眼识别) | -| POST | `/integrations/devices/:id/token` | 管理员 / 教师 | 重置设备令牌(旧令牌立即失效,明文只返回一次) | -| POST | `/integrations/classisland/notify` | 管理员 / 教师 | 下发提醒到该班 ClassIsland 设备(广播 `classisland:notification`) | -| POST | `/integrations/classisland/report` | **设备令牌** | 插件上报状态 + 课表 + 节次时间(`X-ClassIsland-Token`,非 JWT) | -| GET | `/integrations/classisland/pending` | **设备令牌** | 插件拉取尚未确认的提醒(离线期间老师发的通知,重连后补齐) | -| POST | `/integrations/classisland/ack` | **设备令牌** | 插件确认提醒已弹出(确认后不再补发) | -| GET | `/integrations/classisland/class-plan` | **设备令牌** | 插件拉取本班课表(开启镜像时)用于写回 ClassIsland | -| GET | `/health` | 公开 | 健康检查(含已挂载模块列表);另有 `/healthz` 存活探针与 `/readyz` 就绪探针 | +| 方法 | 路径 | 权限 | 说明 | +| --------------------------- | ------------------------------------------------------------- | ---------------------------- | -------------------------------------------------------------------------------- | +| POST | `/auth/login` | 公开 | 登录(教师 / 管理员);`role=STUDENT` 返回 403(学生只有名单,改用班级登录) | +| POST | `/auth/class-login` | 公开 | **班级账号登录**:班级码 + 班级密码 → `classSession` 会话 | +| GET | `/auth/me` | 登录 | 当前用户(学生附带班级/年级) | +| PATCH | `/auth/password` | 登录 | 修改自己的密码 | +| POST | `/auth/logout` | 登录 | 退出(无状态,客户端丢弃 token) | +| GET | `/classes` | 登录 | 班级列表(按权限收敛) | +| GET | `/classes/:id` | 班级可见 | 班级详情(学生/课程/协作教师) | +| POST | `/classes` | 管理员 | 创建班级(自动生成班级码 = 班级账号,可指定班主任) | +| PATCH / DELETE | `/classes/:id` | 管理员 / 教师 | 编辑 / 删除班级 | +| PATCH | `/classes/:id/class-account` | 管理员 | 设置 / 重置班级账号(班级码 + 班级密码) | +| PATCH | `/classes/:id/head-teacher` | 管理员 | 设置 / 更换班主任 | +| GET | `/classes/:id/classisland-status` | 班级可见 | 本班 ClassIsland 联动状态(教室客户端设置页用) | +| GET | `/classes/:id/notification-channel` | 班级可见 | 读取"通知显示到哪个端" | +| PATCH | `/classes/:id/notification-channel` | 班级账号 / 教师 / 管理员 | 设置"通知显示到哪个端"(默认由教室机器自己选) | +| GET / POST | `/classes/:id/students` | 班级可见 / ADMIN·TEACHER | 学生名单 / 添加学生(已存在账号直接转入) | +| DELETE | `/classes/:id/students/:userId` | ADMIN·TEACHER | 移出学生 | +| POST / DELETE | `/classes/:id/teachers[/:teacherId]` | ADMIN·TEACHER | 分配 / 取消协作教师 | +| GET / POST / PATCH / DELETE | `/courses` | 登录(写:班主任/管理员) | 课程管理 | +| GET | `/schedules?classId=&week=&dayOfWeek=` | 登录 | 课表列表(week 过滤周次范围) | +| GET | `/schedules/grid?classId=&week=` | 登录 | 周视图(7 列结构,供客户端直接渲染) | +| GET | `/schedules/current?classId=&at=` | 登录 | 当前上课状态(`inClass` / `current` / `next`;`at` 为诊断用时间覆盖) | +| POST / PATCH / DELETE | `/schedules[/:id]` | 班主任 / 管理员 | 课表增删改(广播 `schedule:updated`) | +| GET | `/homeworks?classId=&courseId=&date=&pendingOnly=&keyword=` | 登录 | 作业列表(支持按所属日期;学生带完成状态,教师带完成人数) | +| GET | `/homeworks/days?classId=&from=&to=` | 登录 | 哪些天有作业(日期高亮) | +| GET | `/homeworks/:id` | 班级可见 | 作业详情 | +| POST | `/homeworks` | 教师 / 管理员 / **班级账号** | 发布作业(附 `assignDate`;班级账号用于教室机器录入,归属班主任) | +| PATCH / DELETE | `/homeworks/:id` | 教师 / 管理员 | 修改 / 删除(广播 `homework:new` / `homework:updated`) | +| PATCH | `/homeworks/:id/status` | 登录 | 标记完成/取消(广播 `homework:status`) | +| GET / PATCH | `/homeworks/:id/submissions` | 登录 | 未交名单:读名单 / 回写全班完成状态 | +| GET | `/notifications?classId=&priority=&unreadOnly=&keyword=` | 登录 | 通知列表(带已读状态) | +| GET | `/notifications/unread-count` | 登录 | 未读数(红点) | +| POST | `/notifications` | 教师 / 管理员 | 发布通知(广播 `notification:new`);上课时段发布紧急通知需 `confirmDuringClass` | +| POST | `/notifications/:id/read`、`/notifications/read-all` | 登录 | 标记已读(班级设备会写全班) | +| DELETE | `/notifications/:id` | 教师 / 管理员 | 删除通知 | +| POST | `/calls` | 教师 / 管理员 | **叫人**(`urgent` 决定 URGENT / HIGH;广播 `notification:new` 与 `call:new`) | +| GET | `/grades/my` | 登录 | 个人成绩(班级会话 → 全班总览) | +| GET | `/grades?classId=&courseId=&userId=&examName=` | 教师 / 管理员 | 班级成绩 | +| GET | `/grades/stats?classId=&courseId=&examName=` | 教师 / 管理员 | 等级分布 + 各课程平均得分率 | +| POST | `/grades`、`/grades/bulk` | 班主任 / 管理员 | 单条 / 批量录入(广播 `grade:updated`) | +| PATCH / DELETE | `/grades/:id` | 班主任 / 管理员 | 修改 / 删除成绩 | +| GET | `/students?classId=&keyword=` | **管理员** | 学生名单 | +| POST / PATCH / DELETE | `/students[/:id]` | **管理员** | 学生名单增删改(无密码概念;重置密码接口已下线 → 404) | +| GET | `/teachers?keyword=` | **管理员** | 教师列表 | +| POST / PATCH / DELETE | `/teachers[/:id]` | **管理员** | 新建 / 编辑 / 删除教师账号 | +| POST | `/teachers/:id/reset-password` | **管理员** | 修改 / 重置教师密码(`newPassword` 可选,留空 = 默认初始密码) | +| GET | `/dashboard/summary` / `/dashboard/term` | 登录 | 仪表盘汇总 / 学期周次 | +| GET | `/imports/template?kind=&format=` | 管理员 / 班主任 | 导入模板下载(`csv` 走 JSON,`xlsx` 走二进制) | +| POST | `/imports/table/preview` | 管理员 / 班主任 | 上传表格(base64)解析预览:列名 + 前 20 行 + 校验问题 + 建议映射 | +| POST | `/imports/table/commit` | 管理员 / 班主任 | 按字段映射与写入模式导入(成绩 / 学生名单 / 教师名单) | +| POST | `/imports/time-layout/preview` | 管理员 / 本班班主任 | 解析 ClassIsland 时间配置 JSON(只解析不落库) | +| GET / POST / DELETE | `/imports/time-layout[/:id]` | 管理员 / 本班班主任 | 时间配置列表 / 导入(`replace` 覆盖、`merge` 合并)/ 删除 | +| POST | `/imports/class-plan/preview`、`/imports/class-plan` | 管理员 / 本班班主任 | ClassIsland 课程表解析预览 / 导入(支持单双周) | +| GET / POST / PATCH / DELETE | `/integrations/devices[/:id]` | 管理员 / 教师 | ClassIsland 联动设备管理(令牌只存 sha256,令牌前缀用于人眼识别) | +| POST | `/integrations/devices/:id/token` | 管理员 / 教师 | 重置设备令牌(旧令牌立即失效,明文只返回一次) | +| POST | `/integrations/classisland/notify` | 管理员 / 教师 | 下发提醒到该班 ClassIsland 设备(广播 `classisland:notification`) | +| GET | `/database/status` | **管理员** | 数据库状态(连接/版本/体积/14 张表行数/备份列表/定时配置) | +| POST | `/database/test-connection` | **管理员** | 测试任意目标库连通性(切换前置检查;provider 仅 sqlite / mysql) | +| GET / POST | `/database/backups` | **管理员** | 备份列表 / 立即备份(gzip JSON 快照,存 `<数据目录>/backups/`) | +| POST / DELETE | `/database/backups/:name/restore` · `/database/backups/:name` | **管理员** | 从备份恢复(整库覆盖)/ 删除备份 | +| GET | `/database/export`、`/database/sqlite-file` | **管理员** | 下载 JSON 快照 / 下载数据库文件(后者仅 SQLite) | +| POST | `/database/import` | **管理员** | 导入快照(base64,整库覆盖;可在 SQLite/MySQL 之间互迁) | +| GET / PUT | `/database/backup-schedule` | **管理员** | 定时备份配置(enabled/intervalHours/keepCount,存 data/database-settings.json) | +| POST / GET | `/database/switch`、`/database/switch/jobs/:id` | **管理员** | **一键切换数据库**(异步任务:备份→建表→迁数据→改 .env,需重启生效)/ 轮询进度 | +| POST | `/integrations/classisland/report` | **设备令牌** | 插件上报状态 + 课表 + 节次时间(`X-ClassIsland-Token`,非 JWT) | +| GET | `/integrations/classisland/pending` | **设备令牌** | 插件拉取尚未确认的提醒(离线期间老师发的通知,重连后补齐) | +| POST | `/integrations/classisland/ack` | **设备令牌** | 插件确认提醒已弹出(确认后不再补发) | +| GET | `/integrations/classisland/class-plan` | **设备令牌** | 插件拉取本班课表(开启镜像时)用于写回 ClassIsland | +| GET | `/health` | 公开 | 健康检查(含已挂载模块列表);另有 `/healthz` 存活探针与 `/readyz` 就绪探针 | ## WebSocket 事件 @@ -1161,7 +1249,7 @@ POST /api/notifications { …, priority: "NORMAL" }(上课时段) 后端每个功能是一个独立目录,统一契约 `ApiModule = { name, basePath, router, enabled? }`: ```ts -// packages/server/src/modules/registry.ts(当前 13 个模块) +// packages/server/src/modules/registry.ts(当前 14 个模块) export const apiModules: ApiModule[] = [ authModule, classesModule, @@ -1176,6 +1264,7 @@ export const apiModules: ApiModule[] = [ studentsModule, teachersModule, dashboardModule, + databaseModule, // ← 数据库管理(仅管理员) ].filter((module) => module.enabled !== false); ``` @@ -1224,59 +1313,68 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed ## 验收标准对照 -| 验收项 | 结果 | 证据 | -| ------------------------------------------------ | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 教师 Web 端发布通知,学生端 5 秒内收到 | ✅ | `verify:e2e`:`notification:new` 实测 30–50ms 到达(要求 < 5 秒) | -| 教师发布作业,学生能查看并标记完成 | ✅ | `homework:new` 实时到达,`PATCH /homeworks/:id/status` 200 且列表回显 `completed=true` | -| 教师录入成绩,学生能查看个人成绩 | ✅ | `grade:updated` 实时到达,`/grades/my` 返回记录;批量录入与统计接口通过 | -| 学生能查看课表,支持按周切换 | ✅ | `/schedules/grid?week=1` 返回周视图条目,`week` 过滤 `weekStart ≤ week ≤ weekEnd`,单双周按第 1 周=单周过滤 | -| 断网后客户端可查看缓存数据 | ✅ | 客户端冒烟:`断网时回退到本地缓存 → fromCache=true`;离线横幅 + 缓存统计页可用 | -| 权限隔离:学生不能访问其他班级数据 | ✅ | e2e 中 10 余项越权断言全部 403/401(学生跨班/跨班作业/跨班课表、教师跨班发布、未登录访问…) | -| 上课时段发布紧急通知必须二次确认 | ✅ | 服务端 409 `URGENT_DURING_CLASS`(`confirmDuringClass` 后 201);Web 端全屏警告 + 3 秒倒计时 | -| 客户端灵动岛:上课隐藏 / 下课弹出 / 紧急立即展开 | ✅ | `verify:desktop` 状态断言 + 像素级截图(`docs/screenshots/island/`) | -| 灵动岛:收回无"方框"闪烁 / 点击屏幕任意处收回 | ✅ | 逐帧采样卡片尺寸恒定(窗口固定包围盒)+ 失焦自动收回 | -| 灵动岛"标为已读"同步通知中心 | ✅ | 真实链路:点击后 `read=false → true`、未读数减少 | -| **多条通知展开为竖排列表** | ✅ | `verify:desktop`:按重要程度排序、默认 3 行 + `展开更多(还有 N 条)`、卡片高度与 `islandListLayout()` 一致;点"展开更多"铺满;放不下时提示 `更多请前往应用内操作` 且卡片高度 ≤ 可用高度 | -| **多条通知的整批操作** | ✅ | `verify:desktop`:`知道了` 整批关闭且状态同步清空、窗口随后淡出隐藏;`标为已读` 经真实链路把**两条**通知都标为已读 | -| **本机录入的作业不再上岛** | ✅ | `verify:desktop`:按 id 与内容指纹双重判定,投递后灵动岛状态不变;别的作业照常上岛 | -| 作业发布也上岛("新作业"胶囊) | ✅ | `kind=homework` + 展开显示作业要求;**截止时间功能已下线**(接口不再返回 `dueAt`,e2e 有断言) | -| **灵动岛收起后始终可再次打开** | ✅ | `verify:desktop`:收起后胶囊常驻、「收起态点击可再次展开」、「空闲细缝态保持可交互」 | -| 叫人(老师点名,分紧急/普通两级) | ✅ | `verify:e2e` 8 项(普通 HIGH / 紧急 URGENT)+ `verify:desktop`「紧急叫人上课也立即展开」「普通叫人上课只进队列、下课弹出」 | -| 成绩 / 名单表格导入(xlsx·xls·csv) | ✅ | 模板下载 + 预览映射 + 重复处理 + 行号级错误:`verify:e2e` 覆盖 20 余项,`verify:web` 弹窗实测 | -| ClassIsland 时间配置导入(覆盖 / 合并 / 回滚) | ✅ | 合法 200、非法 400 `IMPORT_INVALID` 且原配置仍在、merge 覆盖与保留行为符合预期 | -| 安装版覆盖升级自动补迁移 | ✅ | 真实旧库升级日志:`升级安装:已应用 N 个迁移文件,跳过 M 个已存在对象`,新表自动建好 | -| 学生端主体 = 班级(班级码 + 班级密码登录) | ✅ | `verify:e2e` 班级账号 19 项:登录 / 错误密码 401 / 跨班 403 / 发布 403 / 班级码重复 400 / 密码重置 / 班级码用后还原 | -| 班级设备代全班操作(已读 · 完成 · 成绩总览) | ✅ | 标记已读写入全班、教师端 `readCount` = 班级学生数、`completedCount` 一致、`/grades/my` 返回全班成绩 | -| 客户端只保留班级登录入口 | ✅ | `verify:desktop`:导航「成绩」标题变为「本班成绩」;个人学生登录 403 | -| **教师录入(仅管理员)** | ✅ | `verify:e2e`:教师读/建教师 403、管理员建 201 → 编辑 → 重置密码(新密码 200/旧密码 401)→ 删除(有职责 409 / 无职责 200);`verify:web`:管理员真人点「教师管理 → 新建教师 → 保存 → 列表出现 → 删除」 | -| **教师名单表格导入(仅管理员)** | ✅ | `verify:e2e`:教师预览 403、管理员预览/提交 200(`inserted=1`)、导入账号可登录;模板列=用户名/姓名/初始密码/角色 | -| **教师/学生的「修改密码」(管理员)** | ✅ | 服务端 `POST /teachers\|students/:id/reset-password` 本来就收可选 `newPassword`,但界面原先只发空请求、按钮还叫「重置密码」——等于**只能重置成默认密码,没法改成指定值**(用户指着教师列表问"教师修改密码呢")。现两个页面都改成「修改密码」弹框输入:**填了就设成填的值,留空才是默认初始密码**。实测(真实浏览器点界面):设 `teacher777` → 新密码可登录、旧密码 401;留空 → 落到 `DEFAULT_TEACHER_PASSWORD`(`123456`) | -| **教室机器自助改密(班级账号 / 教师账号)** | ✅ | 客户端「设置 → 账号信息 → 修改密码」;服务端同一个 `PATCH /auth/password` 按会话分流(班级账号 → 班级密码,教师/管理员 → 本人密码)。实测:班级账号改 `123456`→`class999` 后新密码可登录、旧密码 401;`verify:desktop` 断言该入口存在且对话框有 3 个密码框 | -| **触摸屏触摸灵动岛可展开(希沃白板)** | ⏳ | 触摸模式下窗口贴合岛体 + 始终接收输入(见「灵动岛」一节);`verify:desktop` 断言「触摸模式窗口=岛体 + 2×阴影留白、`interactive=true`、退出后恢复固定包围盒」。**真机(希沃白板)触摸需在装有触摸屏的机器上人工复验** —— AI 会话里跑不了 Electron GUI(见 AGENTS.md §7) | -| **创建班级仅管理员 + 设置/更改班主任** | ✅ | `verify:e2e`:教师建班 403;管理员建班可带 `teacherId`、`PATCH /classes/:id/head-teacher` 落库生效;`verify:web`:教师直接访问 `/teachers` 被挡回仪表盘 | -| **授课科目统一(18 科固定目录)** | ✅ | `verify:web`:新增课表的科目下拉出现「统一科目(选中后自动建课)」,选中 → 自动建课 → 写入课表成功(收尾删除该条课表) | -| **登录页无示例内容** | ✅ | `verify:web`(管理端)与 `verify:desktop`(客户端)都断言登录页正文/占位符不含 `演示 / 示例 / admin123 / teacher123 / G101` | -| **其他页面发通知也会联动 ClassIsland** | ✅ | `verify:classisland`:「通知发布页发的通知也会推送」「叫人也会推送」;真机实测截图 [03-from-notify-page](docs/screenshots/classisland/03-from-notify-page.png) | -| **作业按天查看(日期选择器 + 有作业日期高亮)** | ✅ | `verify:e2e`:`assignDate` 默认服务器当天、`?date=` 只返回该天、`/homeworks/days` 返回有作业日期、非法日期 422 | -| **支持在客户端录入作业(含自定义快捷短语)** | ✅ | `verify:e2e`:班级账号录入 201(归属班主任)、跨班 403;客户端录入弹窗 + 「设置 → 作业录入」管理短语 | -| **提醒弹在哪个端由客户端自行选择** | ✅ | 客户端「设置 → 通知显示位置」三选一(`verify:desktop`:设置页真实点击 → 写回本地配置);服务端按班级设置决定是否推送(`verify:classisland`:client 时通知与联动页下发都被跳过) | -| **班级小助手能读取 ClassIsland 的课表** | ✅ | `verify:classisland`:插件上报课表 → 本班课表新增/更新(重复上报幂等)、节次时间写入、设备状态快照可在 Web 端展示;`verify:web`:教师在「ClassIsland 联动」页真实签发设备令牌 | -| **用户可选择通知是否在 ClassIsland 上显示** | ✅ | 插件设置页开关「接收班级小助手提醒」(本机总闸)+ Web 端「下发提醒」(标题/内容/时长/优先级/语音朗读/是否同步通知中心);`verify:classisland`:提醒落库 → 待提醒 → 回执 → 确认后不补发 | -| 能成功打包 Windows EXE | ✅ | `-x64-setup.exe` / `-x64-portable.exe` / `win-unpacked/*.exe`(见下表) | -| 提供完整 README(启动、构建、打包、默认账号) | ✅ | 本文档含快速开始、命令表、API、WebSocket、RBAC、模块化、MySQL 切换、打包与常见问题 | +| 验收项 | 结果 | 证据 | +| ------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 教师 Web 端发布通知,学生端 5 秒内收到 | ✅ | `verify:e2e`:`notification:new` 实测 30–50ms 到达(要求 < 5 秒) | +| 教师发布作业,学生能查看并标记完成 | ✅ | `homework:new` 实时到达,`PATCH /homeworks/:id/status` 200 且列表回显 `completed=true` | +| 教师录入成绩,学生能查看个人成绩 | ✅ | `grade:updated` 实时到达,`/grades/my` 返回记录;批量录入与统计接口通过 | +| 学生能查看课表,支持按周切换 | ✅ | `/schedules/grid?week=1` 返回周视图条目,`week` 过滤 `weekStart ≤ week ≤ weekEnd`,单双周按第 1 周=单周过滤 | +| 断网后客户端可查看缓存数据 | ✅ | 客户端冒烟:`断网时回退到本地缓存 → fromCache=true`;离线横幅 + 缓存统计页可用 | +| 权限隔离:学生不能访问其他班级数据 | ✅ | e2e 中 10 余项越权断言全部 403/401(学生跨班/跨班作业/跨班课表、教师跨班发布、未登录访问…) | +| 上课时段发布紧急通知必须二次确认 | ✅ | 服务端 409 `URGENT_DURING_CLASS`(`confirmDuringClass` 后 201);Web 端全屏警告 + 3 秒倒计时 | +| 客户端灵动岛:上课隐藏 / 下课弹出 / 紧急立即展开 | ✅ | `verify:desktop` 状态断言 + 像素级截图(`docs/screenshots/island/`) | +| 灵动岛:收回无"方框"闪烁 / 点击屏幕任意处收回 | ✅ | 逐帧采样卡片尺寸恒定(窗口固定包围盒)+ 失焦自动收回 | +| 灵动岛"标为已读"同步通知中心 | ✅ | 真实链路:点击后 `read=false → true`、未读数减少 | +| **多条通知展开为竖排列表** | ✅ | `verify:desktop`:按重要程度排序、默认 3 行 + `展开更多(还有 N 条)`、卡片高度与 `islandListLayout()` 一致;点"展开更多"铺满;放不下时提示 `更多请前往应用内操作` 且卡片高度 ≤ 可用高度 | +| **多条通知的整批操作** | ✅ | `verify:desktop`:`知道了` 整批关闭且状态同步清空、窗口随后淡出隐藏;`标为已读` 经真实链路把**两条**通知都标为已读 | +| **本机录入的作业不再上岛** | ✅ | `verify:desktop`:按 id 与内容指纹双重判定,投递后灵动岛状态不变;别的作业照常上岛 | +| 作业发布也上岛("新作业"胶囊) | ✅ | `kind=homework` + 展开显示作业要求;**截止时间功能已下线**(接口不再返回 `dueAt`,e2e 有断言) | +| **灵动岛收起后始终可再次打开** | ✅ | `verify:desktop`:收起后胶囊常驻、「收起态点击可再次展开」、「空闲细缝态保持可交互」 | +| 叫人(老师点名,分紧急/普通两级) | ✅ | `verify:e2e` 8 项(普通 HIGH / 紧急 URGENT)+ `verify:desktop`「紧急叫人上课也立即展开」「普通叫人上课只进队列、下课弹出」 | +| 成绩 / 名单表格导入(xlsx·xls·csv) | ✅ | 模板下载 + 预览映射 + 重复处理 + 行号级错误:`verify:e2e` 覆盖 20 余项,`verify:web` 弹窗实测 | +| ClassIsland 时间配置导入(覆盖 / 合并 / 回滚) | ✅ | 合法 200、非法 400 `IMPORT_INVALID` 且原配置仍在、merge 覆盖与保留行为符合预期 | +| 安装版覆盖升级自动补迁移 | ✅ | 真实旧库升级日志:`升级安装:已应用 N 个迁移文件,跳过 M 个已存在对象`,新表自动建好 | +| 学生端主体 = 班级(班级码 + 班级密码登录) | ✅ | `verify:e2e` 班级账号 19 项:登录 / 错误密码 401 / 跨班 403 / 发布 403 / 班级码重复 400 / 密码重置 / 班级码用后还原 | +| 班级设备代全班操作(已读 · 完成 · 成绩总览) | ✅ | 标记已读写入全班、教师端 `readCount` = 班级学生数、`completedCount` 一致、`/grades/my` 返回全班成绩 | +| 客户端只保留班级登录入口 | ✅ | `verify:desktop`:导航「成绩」标题变为「本班成绩」;个人学生登录 403 | +| **教师录入(仅管理员)** | ✅ | `verify:e2e`:教师读/建教师 403、管理员建 201 → 编辑 → 重置密码(新密码 200/旧密码 401)→ 删除(有职责 409 / 无职责 200);`verify:web`:管理员真人点「教师管理 → 新建教师 → 保存 → 列表出现 → 删除」 | +| **教师名单表格导入(仅管理员)** | ✅ | `verify:e2e`:教师预览 403、管理员预览/提交 200(`inserted=1`)、导入账号可登录;模板列=用户名/姓名/初始密码/角色 | +| **教师/学生的「修改密码」(管理员)** | ✅ | 服务端 `POST /teachers\|students/:id/reset-password` 本来就收可选 `newPassword`,但界面原先只发空请求、按钮还叫「重置密码」——等于**只能重置成默认密码,没法改成指定值**(用户指着教师列表问"教师修改密码呢")。现两个页面都改成「修改密码」弹框输入:**填了就设成填的值,留空才是默认初始密码**。实测(真实浏览器点界面):设 `teacher777` → 新密码可登录、旧密码 401;留空 → 落到 `DEFAULT_TEACHER_PASSWORD`(`123456`) | +| **教室机器自助改密(班级账号 / 教师账号)** | ✅ | 客户端「设置 → 账号信息 → 修改密码」;服务端同一个 `PATCH /auth/password` 按会话分流(班级账号 → 班级密码,教师/管理员 → 本人密码)。实测:班级账号改 `123456`→`class999` 后新密码可登录、旧密码 401;`verify:desktop` 断言该入口存在且对话框有 3 个密码框 | +| **触摸屏触摸灵动岛可展开(希沃白板)** | ⏳ | 触摸模式下窗口贴合岛体 + 始终接收输入(见「灵动岛」一节);`verify:desktop` 断言「触摸模式窗口=岛体 + 2×阴影留白、`interactive=true`、退出后恢复固定包围盒」。**真机(希沃白板)触摸需在装有触摸屏的机器上人工复验** —— AI 会话里跑不了 Electron GUI(见 AGENTS.md §7) | +| **创建班级仅管理员 + 设置/更改班主任** | ✅ | `verify:e2e`:教师建班 403;管理员建班可带 `teacherId`、`PATCH /classes/:id/head-teacher` 落库生效;`verify:web`:教师直接访问 `/teachers` 被挡回仪表盘 | +| **授课科目统一(18 科固定目录)** | ✅ | `verify:web`:新增课表的科目下拉出现「统一科目(选中后自动建课)」,选中 → 自动建课 → 写入课表成功(收尾删除该条课表) | +| **登录页无示例内容** | ✅ | `verify:web`(管理端)与 `verify:desktop`(客户端)都断言登录页正文/占位符不含 `演示 / 示例 / admin123 / teacher123 / G101` | +| **初次启动引导(首启弹出 + 随时重看)** | ✅ | Web 管理端:首次登录弹出 `el-tour` 聚焦引导,头像菜单「使用引导」重看(`verify:web`:「首次登录展示新手引导且可跳过」);学生端:首启六步向导,登录页 / 设置页可重看(`verify:desktop`:「初次启动引导(首启自动弹出、可走完、状态写入配置)」) | +| **数据库管理(仅管理员:状态/备份/导入导出/一键切换)** | ✅ | `verify:e2e` 新增 13 项:状态与 14 表行数、教师全端点 403、连接测试(当前库可达 / 不可达 MySQL ok=false)、**Redis 主库 422**、切换校验分支(同库 400)、备份→快照导入→备份恢复 roundtrip(临时数据被清除)、定时配置校验、SQLite 文件下载;**切换子进程链路真机实测**:快照 728 行 → generate → db push → apply-snapshot 逐表写入一致;`verify:web`:「数据库管理页(仅管理员)」状态卡与备份表渲染 | +| **客户端设置改版(分组侧栏/深色模式/关于页)** | ✅ | `verify:desktop`:「侧边栏点击导航」适配「学习/设置」分组结构;「主题切换与侧边栏折叠(真实点击、写回配置)」——html.dark 翻转 + 折叠宽度 200→64 + 双双落盘;「关于页渲染(应用信息/诊断信息/鸣谢)」含 configPath;四个设置页用例改走子路由后全过(101/101 + 二次启动 88/88) | +| **其他页面发通知也会联动 ClassIsland** | ✅ | `verify:classisland`:「通知发布页发的通知也会推送」「叫人也会推送」;真机实测截图 [03-from-notify-page](docs/screenshots/classisland/03-from-notify-page.png) | +| **作业按天查看(日期选择器 + 有作业日期高亮)** | ✅ | `verify:e2e`:`assignDate` 默认服务器当天、`?date=` 只返回该天、`/homeworks/days` 返回有作业日期、非法日期 422 | +| **支持在客户端录入作业(含自定义快捷短语)** | ✅ | `verify:e2e`:班级账号录入 201(归属班主任)、跨班 403;客户端录入弹窗 + 「设置 → 作业录入」管理短语 | +| **提醒弹在哪个端由客户端自行选择** | ✅ | 客户端「设置 → 通知显示位置」三选一(`verify:desktop`:设置页真实点击 → 写回本地配置);服务端按班级设置决定是否推送(`verify:classisland`:client 时通知与联动页下发都被跳过) | +| **班级小助手能读取 ClassIsland 的课表** | ✅ | `verify:classisland`:插件上报课表 → 本班课表新增/更新(重复上报幂等)、节次时间写入、设备状态快照可在 Web 端展示;`verify:web`:教师在「ClassIsland 联动」页真实签发设备令牌 | +| **用户可选择通知是否在 ClassIsland 上显示** | ✅ | 插件设置页开关「接收班级小助手提醒」(本机总闸)+ Web 端「下发提醒」(标题/内容/时长/优先级/语音朗读/是否同步通知中心);`verify:classisland`:提醒落库 → 待提醒 → 回执 → 确认后不补发 | +| 能成功打包 Windows EXE | ✅ | `-x64-setup.exe` / `-x64-portable.exe` / `win-unpacked/*.exe`(见下表) | +| 提供完整 README(启动、构建、打包、默认账号) | ✅ | 本文档含快速开始、命令表、API、WebSocket、RBAC、模块化、MySQL 切换、打包与常见问题 | -验收脚本实测(**2026-09-30**,本机): +验收脚本实测(**2026-10-02**,本机): -| 验证 | 结果 | -| ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | -| `pnpm verify:e2e`(后端 + REST + Socket.IO + RBAC + 上课时段 + 导入 + 班级账号 + 教师录入) | **173/173** ✅ | -| `pnpm verify:classisland`(设备令牌 / 上报 / 提醒下发与回执 / 镜像契约) | **42/42** ✅ | -| `pnpm verify:classisland-plugin`(插件静态契约) | **68/68** ✅ | -| `pnpm verify:packaged`(对打包后的 EXE 跑客户端冒烟) | 首次 **89/91**、二次 **79/79**(未过的 2 项是既有功能用例,见下) | -| `pnpm typecheck` / `pnpm lint` | 全过 ✅(lint 0 error,1 条既有 warning) | -| 安装程序完整生命周期(静默安装 → 启动 → 卸载,装到 `%LOCALAPPDATA%\Programs`) | 通过 ✅(2026-09-30 实测:安装 76 个文件,启动出窗口,卸载干净) | -| 便捷版(单文件)解包启动 | 通过 ✅(2026-09-30 实测:wrapper + 6 个进程,主窗口正常) | -| Docker / Nginx 部署样例 | 文件已提供(含 Linux systemd 脚本),本机无 Docker/Linux 未实测 ⚠️ | +| 验证 | 结果 | +| -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | +| `pnpm verify:e2e`(后端 + REST + Socket.IO + RBAC + 上课时段 + 导入 + 班级账号 + 教师录入 + 数据库管理) | **186/186** ✅(2026-10-02 实测) | +| `pnpm verify:desktop`(客户端冒烟,不弹窗) | **101/101** ✅ + 二次启动 **88/88** ✅ | +| `pnpm verify:web`(Web 管理端真实点击回归) | **30/31** ✅(未过的 1 项是既有脆弱用例,见下) | +| `pnpm verify:classisland`(设备令牌 / 上报 / 提醒下发与回执 / 镜像契约) | **42/42** ✅ | +| `pnpm verify:classisland-plugin`(插件静态契约) | **68/68** ✅ | +| `pnpm dist:server`(服务端安装包 + Web 端) | ✅ 打出 71MB 安装程序(体积增大是因随包内置切换链路所需的依赖) | +| `pnpm typecheck` / `pnpm lint` | 全过 ✅(lint 0 error,1 条既有 warning) | +| 数据库切换子进程链路(快照 → generate → db push → 写入目标库) | ✅ 实测:728 行逐表一致(users=18 / schedules=93 / grades=98 抽查一致) | +| 安装程序完整生命周期(静默安装 → 启动 → 卸载,装到 `%LOCALAPPDATA%\Programs`) | 通过 ✅(2026-09-30 实测:安装 76 个文件,启动出窗口,卸载干净) | +| 便捷版(单文件)解包启动 | 通过 ✅(2026-09-30 实测:wrapper + 6 个进程,主窗口正常) | +| Docker / Nginx 部署样例 | 文件已提供(含 Linux systemd 脚本),本机无 Docker/Linux 未实测 ⚠️ | + +> 本轮 `verify:web` 未过的 1 项是**既有脆弱用例**(AGENTS.md 「已知脆弱用例」表已列明): +> 「课表科目为全校统一目录」——种子给每个班建满了全部科目,下拉里没有可自动建课的「统一科目」分组。 > 打包版冒烟未过的都是**应用功能用例**(与打包/安装无关)。其中前两项是**测试自身的 bug、已修** > (2026-09-30 复跑确认不再失败): diff --git a/deploy/.env.example b/deploy/.env.example index 2c928b3..817d531 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -7,7 +7,7 @@ SERVER_PORT=4000 # 应用版本(镜像标签) -APP_VERSION=0.1.0 +APP_VERSION=1.0.0 # MySQL 配置 MYSQL_ROOT_PASSWORD=请改成强密码_root diff --git a/deploy/.env.sqlite.example b/deploy/.env.sqlite.example index 56002d1..41d9590 100644 --- a/deploy/.env.sqlite.example +++ b/deploy/.env.sqlite.example @@ -8,7 +8,7 @@ SERVER_PORT=4000 # 应用版本(镜像标签) -APP_VERSION=0.1.0 +APP_VERSION=1.0.0 # JWT 签名密钥(必填,必须 ≥32 位;不能包含 secret / change-me 等示例词) # 生成:node -e "console.log(require('crypto').randomBytes(48).toString('hex'))" diff --git a/deploy/Dockerfile b/deploy/Dockerfile index a6672a1..35bb91f 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -5,13 +5,13 @@ # runtime —— 仅保留运行时所需内容(生产依赖 + 构建产物),镜像更小 # # 构建(在仓库根目录执行): -# docker build -f deploy/Dockerfile -t classhelper-server:0.1.0 . +# docker build -f deploy/Dockerfile -t classhelper-server:1.0.0 . # # 运行(SQLite + 数据卷): # docker run -d --name classhelper -p 4000:4000 \ # -e JWT_SECRET=<48字节随机串> \ # -v classhelper-data:/app/data \ -# classhelper-server:0.1.0 +# classhelper-server:1.0.0 # # 生产推荐用 docker compose(见 deploy/docker-compose.yml,含 MySQL 与 HTTPS 反代) diff --git a/deploy/docker-compose.sqlite.yml b/deploy/docker-compose.sqlite.yml index 30a136a..f155619 100644 --- a/deploy/docker-compose.sqlite.yml +++ b/deploy/docker-compose.sqlite.yml @@ -23,7 +23,7 @@ services: context: .. dockerfile: deploy/Dockerfile target: runtime - image: classhelper-server:${APP_VERSION:-0.1.0} + image: classhelper-server:${APP_VERSION:-1.0.0} restart: unless-stopped environment: NODE_ENV: production diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 806d932..e8b7911 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -60,7 +60,7 @@ services: context: .. dockerfile: deploy/Dockerfile target: runtime - image: classhelper-server:${APP_VERSION:-0.1.0} + image: classhelper-server:${APP_VERSION:-1.0.0} restart: unless-stopped environment: NODE_ENV: production diff --git a/deploy/package.runtime.json b/deploy/package.runtime.json index 73dcb44..c828155 100644 --- a/deploy/package.runtime.json +++ b/deploy/package.runtime.json @@ -1,12 +1,13 @@ { "name": "classhelper-server-runtime", - "version": "0.1.0", + "version": "1.0.0", "private": true, "type": "module", "description": "班级小助手服务端运行时(安装包 / 容器内置)", "dependencies": { "@libsql/client": "^0.17.0", "@prisma/adapter-libsql": "7.10.0", + "@prisma/adapter-mariadb": "7.10.0", "@prisma/client": "7.10.0", "bcryptjs": "^3.0.3", "compression": "^1.8.2", @@ -16,7 +17,9 @@ "express-rate-limit": "^8.7.0", "helmet": "^8.3.0", "jsonwebtoken": "^9.0.3", + "prisma": "7.10.0", "socket.io": "^4.8.3", + "typescript": "~5.9.3", "xlsx": "0.18.5", "zod": "^4.6.2" } diff --git a/docs/mysql.md b/docs/mysql.md index a6095c8..74e18fb 100644 --- a/docs/mysql.md +++ b/docs/mysql.md @@ -19,28 +19,62 @@ MySQL 兼容方式设计,可平滑切换到云端 MySQL / MariaDB 统一存储 | 唯一约束仅使用 `@@unique` / `@unique` | SQLite 与 MySQL 语义一致 | | 级联删除使用 Prisma 的 `onDelete` | 由 Prisma 生成对应外键动作 | -## 切换步骤 +## 切换到 MySQL -### 1. 修改 provider +### 方式一:Web 管理端「数据库管理 → 一键切换」(推荐,v1.0.0 新增) + +管理员在 Web 管理端打开 **数据库管理** 页(`/database`,仅 ADMIN 可见),在「一键切换数据库」卡片里: + +1. 选目标类型(SQLite ⇄ MySQL)并填**空库**连接串(有表的库会被拒绝,防误覆盖); +2. 输入「确认迁移」→ 点「开始迁移」。 + +界面会自动依次执行(带步骤进度条): + +1. **备份当前数据库**(gzip JSON 快照存 `<数据目录>/backups/`,失败可回滚); +2. 导出当前数据为快照; +3. 测试目标库连接; +4. 检查目标库为空; +5. 改写 `schema.prisma` 的 provider; +6. 子进程 `prisma generate` 生成新方言的 Prisma 客户端; +7. (打包形态)把生成的 `.ts` 客户端编译进 `dist/generated`; +8. 子进程 `prisma db push` 在目标库建表; +9. 子进程 `dist/tools/apply-snapshot.js` 把快照写入目标库; +10. 全部成功后改写 `.env` 的 `DATABASE_PROVIDER` / `DATABASE_URL`。 + +**完成后必须重启服务端**(安装版运行 `restart.cmd`,开发模式重跑 `pnpm dev:server`)才会连接新库。 +中途任一步失败会**回滚 `schema.prisma`**,`.env` 不被改写 —— 现有数据库与配置保持可用。 + +> 同一页还提供:连接状态检测(版本 / 体积 / 各表行数)、连接测试、手动与**定时备份**、 +> 备份恢复、快照导出导入、SQLite 数据库文件下载。备份与导入导出共用同一种 JSON 快照格式, +> 因此**任意备份都能恢复回任意一种受支持的数据库**。 +> +> 只支持 SQLite 与 MySQL 两种主库:Redis 等键值库不是 Prisma 支持的主数据库(无法建表/迁移), +> 接口层直接拒绝。 + +### 方式二:命令行手工切换 + +#### 1. 修改 provider ```bash # 自动把 prisma/schema.prisma 的 provider 改成 mysql node scripts/use-database.mjs mysql ``` -### 2. 安装 MySQL 驱动适配器 +#### 2. MySQL 驱动适配器(自 v1.0.0 起已随包内置) Prisma 7 要求所有数据库都通过 driver adapter 接入,MySQL 对应 `@prisma/adapter-mariadb` -(该适配器基于 `mariadb` 驱动,兼容 MySQL 8)。 +(该适配器基于 `mariadb` 驱动,兼容 MySQL 8)。**它已在 `packages/server/package.json` 的 +依赖里**(数据库管理模块的连接测试/迁移需要),因此无需再手动安装: ```bash +# 仅当从旧版本升级、依赖里还没有时才需要 pnpm --filter @classhelper/server add @prisma/adapter-mariadb ``` -`packages/server/src/lib/db.ts` 已经内置分支:当 `DATABASE_PROVIDER=mysql` 时会动态加载该适配器, -因此只需要安装依赖 + 配置环境变量,服务端代码无需改动。 +`packages/server/src/lib/db.ts` 内置分支:当 `DATABASE_PROVIDER=mysql` 时会动态加载该适配器, +服务端代码无需改动。 -### 3. 配置环境变量 +#### 3. 配置环境变量 编辑 `packages/server/.env`: @@ -52,7 +86,7 @@ DATABASE_URL="mysql://classhelper:your-password@127.0.0.1:3306/classhelper" > 云端部署时把 `127.0.0.1` 换成云数据库内网/公网地址; > 生产环境请同时修改 `JWT_SECRET` 与 `CORS_ORIGIN`。 -### 4. 重建迁移并生成客户端 +#### 4. 重建迁移并生成客户端 SQLite 与 MySQL 的 DDL 方言不同,**必须重新生成迁移文件**: @@ -71,7 +105,11 @@ pnpm db:seed pnpm --filter @classhelper/server exec prisma migrate dev --create-only --name init_mysql ``` -### 5. 回到 SQLite +> 只搬数据、不要演示数据时,用界面的「下载快照 / 导入快照」更省事: +> 旧库导出 JSON → 切库建表 → 新库导入,一份快照也可用 CLI 迁移: +> `node server/dist/tools/apply-snapshot.js --provider mysql --url "mysql://..." --file snapshot.json` + +#### 5. 回到 SQLite ```bash node scripts/use-database.mjs sqlite @@ -80,6 +118,8 @@ rm -rf packages/server/prisma/migrations # 若之前已切换成 MySQL 迁移 pnpm db:migrate ``` +> 用界面「一键切换」回 SQLite 时,目标库同样必须是空文件:删除旧 `.db` 或指定一个新路径。 + ## 生产部署注意事项 1. **连接池**:Prisma 7 的 driver adapter 使用底层驱动(`mariadb`)的连接池设置, @@ -88,3 +128,7 @@ pnpm db:migrate 三端展示时再按本地时区格式化(前端使用 `@classhelper/shared` 的 `formatDate`)。 3. **迁移部署**:CI/CD 中使用 `prisma migrate deploy`(对应 `pnpm db:deploy`)而不是 `migrate dev`。 4. **备份**:切换云数据库后,`packages/server/prisma/dev.db` 不再被使用,可从 `.gitignore` 之外移除。 + 界面「定时备份」把快照写进 `<数据目录>/backups/`(MySQL 模式下即安装根 `data/backups/`), + **服务端进程不运行时不会补跑**;关键环境建议同时用数据库自带的备份策略。 +5. **安装包体积**:切换链路需要随包内置 `prisma` CLI 与 `typescript` 编译器(Prisma 7 生成器产出 + `.ts`,切换后需在目标机编译进 `dist/generated`),服务端安装包因此约 34MB → 71MB。 diff --git a/docs/production.md b/docs/production.md index 7677ce9..c5683b1 100644 --- a/docs/production.md +++ b/docs/production.md @@ -25,7 +25,7 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已安装依赖,跳 | 路径 | 说明 | | ----------------------------------------------------- | ----------------------------------------------- | | `release-server/classhelper-server/` | 免安装目录,可直接拷到任意 Windows x64 机器运行 | -| `release-server/班级小助手服务端-0.1.0-x64-setup.exe` | 安装程序(约 34 MB,NSIS) | +| `release-server/班级小助手服务端-1.0.0-x64-setup.exe` | 安装程序(约 34 MB,NSIS) | 打包内容:内置 **Node 运行时**(目标机无需安装 Node.js)、后端产物、Web 管理端产物、 生产依赖(真实目录,非软链)、迁移 SQL、`.env`(随机 JWT 密钥)、启停脚本。 @@ -35,7 +35,7 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已安装依赖,跳 双击安装程序即可,**无需管理员权限**(当前用户级安装): - 安装目录:`%LOCALAPPDATA%\Programs\ClassHelperServer` -- 静默安装:`"班级小助手服务端-0.1.0-x64-setup.exe" /S` +- 静默安装:`"班级小助手服务端-1.0.0-x64-setup.exe" /S` - 开始菜单:启动服务 / 停止服务 / 重启服务 / 打开管理端 / 使用说明 / 卸载 - 桌面:`班级小助手服务端.lnk`(打开管理端) - 自动加入当前用户开机自启(`HKCU\...\CurrentVersion\Run`) diff --git a/package.json b/package.json index 5d1348d..75a6f7b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "class-helper", - "version": "0.1.0", + "version": "1.0.0", "private": true, "description": "班级小助手 - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)", "packageManager": "pnpm@11.8.0", diff --git a/packages/classisland-plugin/manifest.yml b/packages/classisland-plugin/manifest.yml index 19c3d1e..bf80008 100644 --- a/packages/classisland-plugin/manifest.yml +++ b/packages/classisland-plugin/manifest.yml @@ -14,6 +14,6 @@ description: |- 4. 可选:把班级小助手上排好的课表镜像回 ClassIsland。 entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll' url: https://github.com/LoveChengke/ClassHelper -version: 0.1.0.0 +version: 1.0.0.0 apiVersion: 2.0.0.0 author: 班级小助手 diff --git a/packages/classisland-plugin/src/Services/BridgeService.cs b/packages/classisland-plugin/src/Services/BridgeService.cs index 90d6a71..daa5f16 100644 --- a/packages/classisland-plugin/src/Services/BridgeService.cs +++ b/packages/classisland-plugin/src/Services/BridgeService.cs @@ -495,7 +495,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable /* ---------------------------------------------------------------- 内部实现 */ /// 插件版本(与 manifest.yml / csproj 保持一致)。 - private const string PluginVersion = "0.1.0.0"; + private const string PluginVersion = "1.0.0.0"; /// ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。 private static string SafeAppVersion() diff --git a/packages/desktop-client/package.json b/packages/desktop-client/package.json index 9dedeef..ae6620d 100644 --- a/packages/desktop-client/package.json +++ b/packages/desktop-client/package.json @@ -1,6 +1,6 @@ { "name": "@classhelper/desktop-client", - "version": "0.1.0", + "version": "1.0.0", "private": true, "description": "班级小助手 EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)", "author": "Class Helper", diff --git a/packages/desktop-client/src/main/config.ts b/packages/desktop-client/src/main/config.ts index 109d027..9c8185e 100644 --- a/packages/desktop-client/src/main/config.ts +++ b/packages/desktop-client/src/main/config.ts @@ -36,6 +36,12 @@ interface PersistedConfig { notificationChannel?: ClassIslandNotificationChannel; /** 作业录入快捷短语(向后兼容:旧配置没有该字段时用默认那一组) */ homeworkPhrases?: string[]; + /** 是否已看过初次启动引导(向后兼容:旧配置没有该字段视为没看过,首启弹一次) */ + onboardingDone?: boolean; + /** 界面主题(向后兼容:旧配置没有该字段用浅色) */ + theme?: 'light' | 'dark'; + /** 主侧边栏是否折叠(向后兼容:旧配置没有该字段视为展开) */ + sidebarCollapsed?: boolean; } const DEFAULT_CONFIG: PersistedConfig = { @@ -47,8 +53,16 @@ const DEFAULT_CONFIG: PersistedConfig = { homeworkBoard: { ...DEFAULT_HOMEWORK_BOARD }, notificationChannel: DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL, homeworkPhrases: [...HOMEWORK_PHRASE_DEFAULTS], + onboardingDone: false, + theme: 'light', + sidebarCollapsed: false, }; +/** 主题白名单:非法值一律回落浅色 */ +function normalizeTheme(input?: 'light' | 'dark'): 'light' | 'dark' { + return input === 'dark' ? 'dark' : 'light'; +} + /** * 作业快捷短语的合法化:去空白、去重、限长限量。 * 空数组是**合法值**(老师把短语全删了 = 不想用快捷短语),因此不能拿默认值顶回去。 @@ -181,6 +195,9 @@ function readPersisted(): PersistedConfig { homeworkBoard: normalizeHomeworkBoard(parsed.homeworkBoard), notificationChannel: normalizeNotificationChannel(parsed.notificationChannel), homeworkPhrases: normalizeHomeworkPhrases(parsed.homeworkPhrases), + onboardingDone: parsed.onboardingDone === true, + theme: normalizeTheme(parsed.theme), + sidebarCollapsed: parsed.sidebarCollapsed === true, }; } catch { return { ...DEFAULT_CONFIG }; @@ -223,6 +240,9 @@ export function getConfig(): DesktopStoredConfig { homeworkBoard: normalizeHomeworkBoard(persisted.homeworkBoard), notificationChannel: normalizeNotificationChannel(persisted.notificationChannel), homeworkPhrases: normalizeHomeworkPhrases(persisted.homeworkPhrases), + onboardingDone: persisted.onboardingDone === true, + theme: normalizeTheme(persisted.theme), + sidebarCollapsed: persisted.sidebarCollapsed === true, }; } @@ -253,6 +273,17 @@ export function saveConfig(patch: Partial): DesktopStoredCo if (patch.notificationChannel !== undefined) { persisted.notificationChannel = normalizeNotificationChannel(patch.notificationChannel); } + // 引导只接受"已看过":重看入口不走这里(看过与否只有 true 与"没看过"两种状态, + // 想 re-arm 只能清配置 clearConfig)。传 false 一律忽略,避免任何调用点误把用户退回首启状态。 + if (patch.onboardingDone === true) { + persisted.onboardingDone = true; + } + if (patch.theme !== undefined) { + persisted.theme = normalizeTheme(patch.theme); + } + if (patch.sidebarCollapsed !== undefined) { + persisted.sidebarCollapsed = patch.sidebarCollapsed === true; + } if (patch.token !== undefined) { const encrypted = encryptToken(patch.token); persisted.token = encrypted.token; diff --git a/packages/desktop-client/src/main/index.ts b/packages/desktop-client/src/main/index.ts index 94a621f..d4843f5 100644 --- a/packages/desktop-client/src/main/index.ts +++ b/packages/desktop-client/src/main/index.ts @@ -1,6 +1,7 @@ import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { BrowserWindow, app, shell } from 'electron'; +import { getConfig } from './config.js'; import { registerIpcHandlers } from './ipc.js'; import { island, registerIslandIpc } from './island.js'; import { logger } from './logger.js'; @@ -49,16 +50,20 @@ function isAllowedNavigation(url: string): boolean { } function createWindow(): BrowserWindow { + // 启动底色跟主题走:深色主题下若仍用浅色底,窗口先白后黑会"闪一下" + const theme = getConfig().theme; const win = new BrowserWindow({ - width: 1200, - height: 800, - minWidth: 980, - minHeight: 640, + // 初始尺寸与 ClassIsland 主窗口对齐(实测其可视区 1242x582),保持同一观感 + width: 1242, + height: 582, + minWidth: 1000, + minHeight: 540, + center: true, title: '班级小助手', // 冒烟验证时不弹窗,避免打扰使用者 show: !isSmokeTest, autoHideMenuBar: true, - backgroundColor: '#f5f7fa', + backgroundColor: theme === 'dark' ? '#161719' : '#f3f3f3', webPreferences: { preload: path.join(currentDir, '../preload/index.js'), contextIsolation: true, diff --git a/packages/desktop-client/src/main/ipc.ts b/packages/desktop-client/src/main/ipc.ts index 3c412e2..a3db956 100644 --- a/packages/desktop-client/src/main/ipc.ts +++ b/packages/desktop-client/src/main/ipc.ts @@ -30,6 +30,7 @@ export function registerIpcHandlers(): void { node: process.versions.node ?? '', platform: `${process.platform} ${process.arch}`, userDataPath: app.getPath('userData'), + configPath: getConfigPath(), smokeTest: process.env.ELECTRON_SMOKE_TEST === '1', })); diff --git a/packages/desktop-client/src/main/smoke.ts b/packages/desktop-client/src/main/smoke.ts index e7e1983..5129241 100644 --- a/packages/desktop-client/src/main/smoke.ts +++ b/packages/desktop-client/src/main/smoke.ts @@ -3712,7 +3712,7 @@ async function runSettingsPagePasswordEntryCheck( const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); location.hash = '#/schedule'; await wait(250); - location.hash = '#/settings'; + location.hash = '#/settings/account'; let card = null; for (let i = 0; i < 40 && !card; i += 1) { await wait(100); @@ -3756,7 +3756,7 @@ async function runSettingsPageAppearanceCheck(win: BrowserWindow): Promise<{ ok: window.desktop.islandSetAppearance({ ...before, height: 44 }); await wait(300); - location.hash = '#/settings'; + location.hash = '#/settings/island'; let runway = null; for (let i = 0; i < 40 && !runway; i += 1) { await wait(100); @@ -3838,7 +3838,7 @@ async function runSettingsPageMarginCheck( // 页面里会留着过期的位置(这正是"改了设置没反应"的一个真实来源) location.hash = '#/schedule'; await wait(250); - location.hash = '#/settings'; + location.hash = '#/settings/island'; let item = null; for (let i = 0; i < 40 && !item; i += 1) { await wait(100); @@ -3880,15 +3880,15 @@ async function runSettingsPageMarginCheck( .executeJavaScript( `(async () => { const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - location.hash = '#/schedule'; - await wait(200); - location.hash = '#/settings'; - let runway = null; - for (let i = 0; i < 40 && !runway; i += 1) { - await wait(100); - const item = Array.from(document.querySelectorAll('.el-form-item')).find((node) => - (node.querySelector('.el-form-item__label')?.textContent ?? '').startsWith('左右边距'), - ); + location.hash = '#/schedule'; + await wait(200); + location.hash = '#/settings/island'; + let runway = null; + for (let i = 0; i < 40 && !runway; i += 1) { + await wait(100); + const item = Array.from(document.querySelectorAll('.el-form-item')).find((node) => + (node.querySelector('.el-form-item__label')?.textContent ?? '').startsWith('左右边距'), + ); runway = item?.querySelector('.el-slider__runway') ?? null; } if (!runway) return { ok: false, detail: '设置页里找不到「左右边距」滑块' }; @@ -3986,7 +3986,7 @@ async function runSettingsPageChannelCheck(win: BrowserWindow): Promise<{ ok: bo `(async () => { const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const before = (await window.desktop.getConfig()).notificationChannel; - location.hash = '#/settings'; + location.hash = '#/settings/reminder'; // 找到「通知显示位置」那张卡片里的单选组 let group = null; @@ -4151,6 +4151,28 @@ export async function runSmokeTest(win: BrowserWindow): Promise { `text=${dom?.text ?? ''}`, ); + // 初始窗口尺寸:与 ClassIsland 主窗口对齐(实测其可视区 1242x582,见 main/index.ts) + const [initialWidth, initialHeight] = win.getSize(); + record( + '初始窗口尺寸与 ClassIsland 对齐(1242x582)', + initialWidth === 1242 && initialHeight === 582, + `实际 ${initialWidth}x${initialHeight}`, + ); + + // 初次启动引导(新装客户端首启自动弹出):真实点击走完一遍,断言完成后写入配置。 + // 必须放在"登录页不含示例内容"的全文扫描**之前**——那时引导还开着,先把它的文案从屏幕上撤走。 + const onboarding = await win.webContents.executeJavaScript( + `(async () => { + if (!window.__classhelperSmoke__?.onboardingSelfTest) return { ok: false, detail: '缺少引导自检钩子' }; + return await window.__classhelperSmoke__.onboardingSelfTest(); + })()`, + ); + record( + '初次启动引导(首启自动弹出、可走完、状态写入配置)', + Boolean(onboarding?.ok), + String(onboarding?.detail ?? ''), + ); + // 需求:"删掉登录页所有示例内容" —— 占位符/提示里不允许出现示例班级码或示例账号。 // 判据不写死具体示例码:用"演示/示例"字样 + 旧版占位符的**形状**(例如 G101)+ 环境变量传入的凭据, // 这样断言更严(任何"例如 XXX123"都会被抓到),安装包里也不留示例账号字面量。 @@ -4308,6 +4330,25 @@ export async function runSmokeTest(win: BrowserWindow): Promise { ); record('侧边栏点击导航(逐一点击 5 个菜单)', Boolean(navigation?.ok), String(navigation?.detail ?? '')); + // 主界面外观件(新功能回归):真实点击主题日月按钮与侧边栏汉堡按钮, + // 断言 html.dark 翻转、collapse 态生效、两者都写入主进程配置;结束时还原主题 + const chrome = await win.webContents.executeJavaScript( + `(async () => { + if (!window.__classhelperSmoke__?.layoutChromeSelfTest) return { ok: false, detail: '缺少外观件自检钩子' }; + return await window.__classhelperSmoke__.layoutChromeSelfTest(); + })()`, + ); + record('主题切换与侧边栏折叠(真实点击、写回配置)', Boolean(chrome?.ok), String(chrome?.detail ?? '')); + + // 关于页(新功能回归):/settings/about 渲染 + 诊断信息(getAppInfo 含 configPath) + const aboutPage = await win.webContents.executeJavaScript( + `(async () => { + if (!window.__classhelperSmoke__?.aboutPageSelfTest) return { ok: false, detail: '缺少关于页自检钩子' }; + return await window.__classhelperSmoke__.aboutPageSelfTest(); + })()`, + ); + record('关于页渲染(应用信息/诊断信息/鸣谢)', Boolean(aboutPage?.ok), String(aboutPage?.detail ?? '')); + // 未读红点必须挂在"通知"图标右上方(用户反馈:原来挂在文字后面,位置不对) const readBadgeGeometry = async (): Promise<{ ok: boolean; diff --git a/packages/desktop-client/src/renderer/App.vue b/packages/desktop-client/src/renderer/App.vue index da7717c..ad5bb0c 100644 --- a/packages/desktop-client/src/renderer/App.vue +++ b/packages/desktop-client/src/renderer/App.vue @@ -7,13 +7,18 @@ import { subscribeIslandMarkAllRead, subscribeIslandMarkRead, } from './island/bridge.js'; +import OnboardingWelcome from './components/OnboardingWelcome.vue'; import { useAppStore } from './stores/app.js'; import { useAuthStore } from './stores/auth.js'; +import { useOnboardingStore } from './stores/onboarding.js'; import { useRealtimeStore } from './stores/realtime.js'; +import { useUiStore } from './stores/ui.js'; const router = useRouter(); const appStore = useAppStore(); const auth = useAuthStore(); +const onboarding = useOnboardingStore(); +const ui = useUiStore(); const realtime = useRealtimeStore(); const booting = ref(true); @@ -27,6 +32,10 @@ const bootText = ref('正在读取本地配置...'); */ onMounted(async () => { try { + bootText.value = '正在读取本地配置...'; + // 主题与侧栏折叠最先恢复:在首屏渲染前挂好 html.dark,避免"先白后黑"闪一下 + await ui.init(); + bootText.value = '正在恢复登录状态...'; const session = await auth.restore(); @@ -52,6 +61,17 @@ onMounted(async () => { if (auth.isAuthenticated) await router.replace('/schedule'); else await router.replace('/login'); + + // 初次启动引导:配置里没记「已看过」就自动弹一次(完成/跳过后由引导组件写入配置)。 + // 稍等一拍再弹:让登录页/主界面先渲染完,避免和启动过渡的收尾抢帧。 + window.setTimeout(() => { + void window.desktop + ?.getConfig() + .then((config) => { + if (config.onboardingDone !== true) onboarding.open(); + }) + .catch(() => undefined); + }, 400); } finally { booting.value = false; } @@ -69,6 +89,8 @@ onUnmounted(() => {

{{ bootText }}

+ + diff --git a/packages/desktop-client/src/renderer/layouts/ClientLayout.vue b/packages/desktop-client/src/renderer/layouts/ClientLayout.vue index 396dafa..3cef2d1 100644 --- a/packages/desktop-client/src/renderer/layouts/ClientLayout.vue +++ b/packages/desktop-client/src/renderer/layouts/ClientLayout.vue @@ -1,13 +1,14 @@ - - - - diff --git a/packages/desktop-client/src/renderer/views/settings/AboutView.vue b/packages/desktop-client/src/renderer/views/settings/AboutView.vue new file mode 100644 index 0000000..68c5f73 --- /dev/null +++ b/packages/desktop-client/src/renderer/views/settings/AboutView.vue @@ -0,0 +1,182 @@ + + + + + diff --git a/packages/desktop-client/src/renderer/views/settings/AccountSettingsView.vue b/packages/desktop-client/src/renderer/views/settings/AccountSettingsView.vue new file mode 100644 index 0000000..75de93f --- /dev/null +++ b/packages/desktop-client/src/renderer/views/settings/AccountSettingsView.vue @@ -0,0 +1,55 @@ + + + diff --git a/packages/desktop-client/src/renderer/views/settings/AppearanceView.vue b/packages/desktop-client/src/renderer/views/settings/AppearanceView.vue new file mode 100644 index 0000000..540f7ea --- /dev/null +++ b/packages/desktop-client/src/renderer/views/settings/AppearanceView.vue @@ -0,0 +1,162 @@ + + + + + diff --git a/packages/desktop-client/src/renderer/views/settings/GeneralSettingsView.vue b/packages/desktop-client/src/renderer/views/settings/GeneralSettingsView.vue new file mode 100644 index 0000000..ac88f65 --- /dev/null +++ b/packages/desktop-client/src/renderer/views/settings/GeneralSettingsView.vue @@ -0,0 +1,285 @@ + + + + + diff --git a/packages/desktop-client/src/renderer/views/settings/IslandSettingsView.vue b/packages/desktop-client/src/renderer/views/settings/IslandSettingsView.vue new file mode 100644 index 0000000..ca54e0c --- /dev/null +++ b/packages/desktop-client/src/renderer/views/settings/IslandSettingsView.vue @@ -0,0 +1,276 @@ + + + + + diff --git a/packages/desktop-client/src/renderer/views/settings/ReminderSettingsView.vue b/packages/desktop-client/src/renderer/views/settings/ReminderSettingsView.vue new file mode 100644 index 0000000..6e30572 --- /dev/null +++ b/packages/desktop-client/src/renderer/views/settings/ReminderSettingsView.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/packages/desktop-client/src/types/desktop.d.ts b/packages/desktop-client/src/types/desktop.d.ts index 2fb3264..a6dbc4f 100644 --- a/packages/desktop-client/src/types/desktop.d.ts +++ b/packages/desktop-client/src/types/desktop.d.ts @@ -47,6 +47,17 @@ export interface DesktopStoredConfig { * 与班级记录(服务端据此决定要不要推给 ClassIsland)。 */ notificationChannel: ClassIslandNotificationChannel; + /** + * 是否已看过「初次启动引导」。 + * + * 首次启动自动弹出一次(完成或跳过即记为看过并写入配置,之后不再自动弹出); + * 登录页与设置页的「使用引导」入口可随时重看(重看不改变该值)。 + */ + onboardingDone: boolean; + /** 界面主题:light=浅色(默认)/ dark=深色(黑夜模式) */ + theme: 'light' | 'dark'; + /** 主侧边栏是否折叠成图标栏(可由顶栏汉堡按钮切换) */ + sidebarCollapsed: boolean; } export interface DesktopAppInfo { @@ -56,6 +67,8 @@ export interface DesktopAppInfo { node: string; platform: string; userDataPath: string; + /** 主进程配置文件(config.json)的绝对路径(关于页「诊断信息」展示) */ + configPath: string; /** 是否处于冒烟验证模式(由 ELECTRON_SMOKE_TEST=1 触发) */ smokeTest: boolean; } @@ -209,6 +222,12 @@ declare global { homeworkBoardSelfTest(): Promise<{ ok: boolean; detail: string }>; /** 本机录入的作业不上灵动岛自检(内容指纹 + id 双重判定) */ islandHomeworkSuppressionCheck(): Promise<{ ok: boolean; detail: string }>; + /** 初次启动引导自检(首启自动出现 → 真实点击走完 → 完成状态写入配置) */ + onboardingSelfTest(): Promise<{ ok: boolean; detail: string }>; + /** 主界面外观件自检(真实点击:主题切换 html.dark + 落盘、侧边栏折叠 + 落盘) */ + layoutChromeSelfTest(): Promise<{ ok: boolean; detail: string }>; + /** 关于页自检(/settings/about 渲染 + getAppInfo 含 configPath) */ + aboutPageSelfTest(): Promise<{ ok: boolean; detail: string }>; sessionCleanup(): Promise<{ ok: boolean; detail: string }>; }; } diff --git a/packages/server/package.json b/packages/server/package.json index b302d6e..5e5f368 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@classhelper/server", - "version": "0.1.0", + "version": "1.0.0", "private": true, "description": "班级小助手后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问", "type": "module", @@ -25,6 +25,7 @@ "@classhelper/shared": "workspace:*", "@libsql/client": "^0.17.0", "@prisma/adapter-libsql": "7.10.0", + "@prisma/adapter-mariadb": "7.10.0", "@prisma/client": "7.10.0", "bcryptjs": "^3.0.3", "compression": "^1.8.2", @@ -34,7 +35,9 @@ "express-rate-limit": "^8.7.0", "helmet": "^8.3.0", "jsonwebtoken": "^9.0.3", + "prisma": "7.10.0", "socket.io": "^4.8.3", + "typescript": "~5.9.3", "xlsx": "0.18.5", "zod": "^4.6.2" }, diff --git a/packages/server/scripts/verify-classisland.mjs b/packages/server/scripts/verify-classisland.mjs index 7635673..16a6d91 100644 --- a/packages/server/scripts/verify-classisland.mjs +++ b/packages/server/scripts/verify-classisland.mjs @@ -182,7 +182,7 @@ async function main() { // ---------------------------------------------------------------- 3. 上报(状态 + 课表 + 节次) const reportBody = { - pluginVersion: '0.1.0.0', + pluginVersion: '1.0.0.0', classIslandVersion: '2.1.0.0', state: { inClass: true, diff --git a/packages/server/scripts/verify-e2e.mjs b/packages/server/scripts/verify-e2e.mjs index db2615e..38f928b 100644 --- a/packages/server/scripts/verify-e2e.mjs +++ b/packages/server/scripts/verify-e2e.mjs @@ -2339,6 +2339,230 @@ async function main() { `教师改=${teacherChangeHead.status} 管理员改=${changedHead.status} 详情班主任=${changedDetail.payload?.data?.teacher?.name ?? '-'}`, ); + // 清理:删除实时推送用例造的通知/作业、冒烟班级、临时权限校验班级与冒烟教师账号 + // ---------------------------------------------------------------- 数据库管理(仅管理员) + // 覆盖:状态 / 越权 403 / 连接测试 / 备份→导入导出→恢复 roundtrip / 定时配置校验 / 切换校验分支。 + // 注意:**不真跑切换**(会改写 .env 与 schema.prisma),只验证拒绝分支;真实切换需人工复验。 + const dbStatus = await api('/database/status', { token: adminToken }); + const dbStatusData = dbStatus.payload?.data ?? {}; + record( + '数据库状态(管理员):已连接且返回 14 张表行数', + dbStatus.status === 200 && + dbStatusData.connected === true && + Array.isArray(dbStatusData.tables) && + dbStatusData.tables.length === 14 && + dbStatusData.tables.every((item) => item.count >= 0), + `provider=${dbStatusData.provider} 延迟=${dbStatusData.latencyMs}ms 版本=${dbStatusData.version} 表=${ + (dbStatusData.tables ?? []).length + }`, + ); + + const dbStatusTeacher = await api('/database/status', { token: teacherToken }); + const dbBackupTeacher = await api('/database/backups', { method: 'POST', token: teacherToken }); + const dbScheduleTeacher = await api('/database/backup-schedule', { + method: 'PUT', + token: teacherToken, + body: { enabled: true, intervalHours: 24, keepCount: 7 }, + }); + const dbSwitchTeacher = await api('/database/switch', { + method: 'POST', + token: teacherToken, + body: { provider: 'sqlite', url: 'file:./data/teacher-forbidden.db' }, + }); + record( + '数据库管理越权(教师全部 403)', + dbStatusTeacher.status === 403 && + dbBackupTeacher.status === 403 && + dbScheduleTeacher.status === 403 && + dbSwitchTeacher.status === 403, + `status=${dbStatusTeacher.status} 备份=${dbBackupTeacher.status} 定时=${dbScheduleTeacher.status} 切换=${dbSwitchTeacher.status}`, + ); + + // 连接测试:当前库必须可达(SQLite 用相对路径,MySQL 用一个不可达地址测失败分支) + let testOkResult; + let testOkName; + if (dbStatusData.provider === 'sqlite') { + testOkResult = await api('/database/test-connection', { + method: 'POST', + token: adminToken, + body: { provider: 'sqlite', url: 'file:./prisma/dev.db' }, + }); + testOkName = '连接测试(当前 SQLite 库可达)'; + } else { + testOkResult = await api('/database/test-connection', { + method: 'POST', + token: adminToken, + body: { provider: 'mysql', url: 'mysql://root:wrong-password@127.0.0.1:1/none' }, + }); + testOkName = '连接测试(不可达 MySQL 返回 ok=false)'; + } + record( + testOkName, + testOkResult.status === 200 && + (dbStatusData.provider === 'sqlite' + ? testOkResult.payload?.data?.ok === true + : testOkResult.payload?.data?.ok === false), + `ok=${testOkResult.payload?.data?.ok} 版本=${testOkResult.payload?.data?.version} 详情=${testOkResult.payload?.data?.error ?? testOkResult.payload?.data?.note ?? '-'}`, + ); + + const dbTestRedis = await api('/database/test-connection', { + method: 'POST', + token: adminToken, + body: { provider: 'redis', url: 'redis://127.0.0.1:6379' }, + }); + record( + 'Redis 作为主库被拒绝(schema 层面 422,产品口径:主库仅 SQLite/MySQL)', + dbTestRedis.status === 422, + `status=${dbTestRedis.status}`, + ); + + const badConnection = await api('/database/test-connection', { + method: 'POST', + token: adminToken, + body: { provider: 'mysql', url: 'mysql://root:wrong@127.0.0.1:1/none' }, + }); + record( + '连接测试(不可达 MySQL → ok=false 且不抛 500)', + badConnection.status === 200 && badConnection.payload?.data?.ok === false, + `ok=${badConnection.payload?.data?.ok} error=${(badConnection.payload?.data?.error ?? '').slice(0, 60)}`, + ); + + // 切换校验分支:同库拒绝(真实切换不在此跑,避免改写 .env) + const switchSame = + dbStatusData.provider === 'sqlite' + ? await api('/database/switch', { + method: 'POST', + token: adminToken, + body: { provider: 'sqlite', url: dbStatusData.databaseUrlMasked }, + }) + : { status: 0, payload: null }; + const switchInvalid = await api('/database/switch', { + method: 'POST', + token: adminToken, + body: { provider: 'redis', url: 'redis://127.0.0.1:6379' }, + }); + record( + '一键切换校验分支(同库 400 / 非法 provider 422,不真跑切换)', + (dbStatusData.provider === 'sqlite' ? switchSame.status === 400 : true) && switchInvalid.status === 422, + `同库=${switchSame.status}(${dbStatusData.provider === 'sqlite' ? 'sqlite 当前库' : 'mysql 跳过'}) redis=${switchInvalid.status}`, + ); + + // 备份 → 导出快照 → 造数据 → 快照导入 → 造数据 → 从备份恢复(三个恢复路径全验) + const dbBackup = await api('/database/backups', { method: 'POST', token: adminToken }); + const backupName = dbBackup.payload?.data?.name ?? ''; + record( + '手动备份(201,文件名符合约定格式)', + dbBackup.status === 201 && /^backup-manual-\d{8}-\d{6}\.json\.gz$/.test(backupName), + `name=${backupName} size=${dbBackup.payload?.data?.sizeBytes ?? '-'}`, + ); + + const dbExport = await fetch(`${BASE_URL}/api/database/export`, { + headers: { Authorization: `Bearer ${adminToken}` }, + }); + const snapshotJson = dbExport.status === 200 ? await dbExport.text() : ''; + let snapshotParsed = null; + try { + snapshotParsed = JSON.parse(snapshotJson); + } catch { + snapshotParsed = null; + } + record( + '导出快照(JSON 格式正确、14 张表、用户数 > 0)', + dbExport.status === 200 && + snapshotParsed?.format === 'classhelper-snapshot' && + Object.keys(snapshotParsed?.data ?? {}).length === 14 && + (snapshotParsed?.counts?.User ?? 0) > 0, + `status=${dbExport.status} 格式=${snapshotParsed?.format ?? '-'} 用户=${snapshotParsed?.counts?.User ?? '-'}`, + ); + + // 造一条临时通知 → 快照导入(应被清掉)→ 再造一条 → 从备份恢复(也应被清掉) + const dbRoundtripNotice = await api('/notifications', { + method: 'POST', + token: adminToken, + body: { classId, title: '数据库roundtrip自检', content: '导入/恢复后应消失' }, + }); + const dbRoundtripNoticeId = dbRoundtripNotice.payload?.data?.id; + const dbImport = await api('/database/import', { + method: 'POST', + token: adminToken, + body: { data: Buffer.from(snapshotJson, 'utf8').toString('base64') }, + }); + const afterImport = await api(`/notifications?classId=${classId}`, { token: adminToken }); + const roundtripNoticeGone = !(afterImport.payload?.data ?? []).some( + (item) => item.id === dbRoundtripNoticeId, + ); + record( + '快照导入(整库覆盖:导入后临时通知消失)', + dbImport.status === 200 && roundtripNoticeGone, + `导入=${dbImport.status} 通知已清除=${roundtripNoticeGone} 行数=${JSON.stringify(dbImport.payload?.data?.counts ?? {}).slice(0, 80)}`, + ); + + const dbRoundtripNotice2 = await api('/notifications', { + method: 'POST', + token: adminToken, + body: { classId, title: '数据库roundtrip自检2', content: '备份恢复后应消失' }, + }); + const dbRoundtripNotice2Id = dbRoundtripNotice2.payload?.data?.id; + const dbRestore = await api(`/database/backups/${backupName}/restore`, { method: 'POST', token: adminToken }); + const afterRestore = await api(`/notifications?classId=${classId}`, { token: adminToken }); + const restoreNoticeGone = !(afterRestore.payload?.data ?? []).some( + (item) => item.id === dbRoundtripNotice2Id, + ); + record( + '从备份恢复(整库覆盖:恢复后临时通知消失)', + dbRestore.status === 200 && restoreNoticeGone, + `恢复=${dbRestore.status} 通知已清除=${restoreNoticeGone}`, + ); + if (dbRoundtripNoticeId) { + await api(`/notifications/${dbRoundtripNoticeId}`, { method: 'DELETE', token: adminToken }); + } + if (dbRoundtripNotice2Id) { + await api(`/notifications/${dbRoundtripNotice2Id}`, { method: 'DELETE', token: adminToken }); + } + + const dbBackupList = await api('/database/backups', { token: adminToken }); + const dbBackupDelete = await api(`/database/backups/${backupName}`, { method: 'DELETE', token: adminToken }); + const dbBackupListAfter = await api('/database/backups', { token: adminToken }); + record( + '备份列表与删除(创建后可见,删除后消失)', + dbBackupList.status === 200 && + (dbBackupList.payload?.data ?? []).some((item) => item.name === backupName) && + dbBackupDelete.status === 200 && + !(dbBackupListAfter.payload?.data ?? []).some((item) => item.name === backupName), + `删除前=${(dbBackupList.payload?.data ?? []).length} 删除后=${(dbBackupListAfter.payload?.data ?? []).length}`, + ); + + const dbScheduleInvalid = await api('/database/backup-schedule', { + method: 'PUT', + token: adminToken, + body: { enabled: true, intervalHours: 0, keepCount: 7 }, + }); + const dbScheduleSave = await api('/database/backup-schedule', { + method: 'PUT', + token: adminToken, + body: { enabled: false, intervalHours: 24, keepCount: 7 }, + }); + record( + '定时备份配置(非法间隔 422 / 合法保存并回读)', + dbScheduleInvalid.status === 422 && + dbScheduleSave.status === 200 && + dbScheduleSave.payload?.data?.enabled === false && + dbScheduleSave.payload?.data?.intervalHours === 24, + `非法=${dbScheduleInvalid.status} 保存=${dbScheduleSave.status} enabled=${dbScheduleSave.payload?.data?.enabled}`, + ); + + if (dbStatusData.provider === 'sqlite') { + const sqliteFile = await fetch(`${BASE_URL}/api/database/sqlite-file`, { + headers: { Authorization: `Bearer ${adminToken}` }, + }); + const sqliteBytes = sqliteFile.status === 200 ? (await sqliteFile.arrayBuffer()).byteLength : 0; + record( + 'SQLite 数据文件下载(快捷导出 .db 文件)', + sqliteFile.status === 200 && sqliteBytes > 0, + `status=${sqliteFile.status} bytes=${sqliteBytes}`, + ); + } + // 清理:删除实时推送用例造的通知/作业、冒烟班级、临时权限校验班级与冒烟教师账号 const realtimeNoticeId = createdNotification.payload?.data?.id; if (realtimeNoticeId) { diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 9ac90a3..210a911 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -37,7 +37,7 @@ async function healthHandler(_req: Request, res: Response): Promise { res, { status: 'ok', - version: '0.1.0', + version: '1.0.0', env: env.nodeEnv, uptimeSeconds: Math.round(process.uptime()), database: { @@ -68,7 +68,7 @@ function createRootHandler(webDistDir: string | null) { res, { service: 'class-helper-server', - version: '0.1.0', + version: '1.0.0', environment: env.nodeEnv, apiPrefix: API_PREFIX, socket: '/socket.io', @@ -115,7 +115,7 @@ function renderLandingPage(webAdminServed: boolean): string {

班级小助手 · 后端服务 running

-

版本 0.1.0 · 运行环境 ${env.nodeEnv} · 数据库 ${env.databaseProvider}

+

版本 1.0.0 · 运行环境 ${env.nodeEnv} · 数据库 ${env.databaseProvider}

健康检查 ${API_PREFIX}/health · 存活 /healthz · 就绪 /readyz diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 341849c..cab8300 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -9,6 +9,7 @@ import { ensureInitialAdmin, ensureSchema } from './lib/db-bootstrap.js'; import { logger } from './lib/logger.js'; import { resolveWebDistDir } from './lib/web-static.js'; import { initRealtime } from './realtime/socket.js'; +import { startBackupScheduler } from './modules/database/database.service.js'; /** 数据库自检与可选的首次初始化 */ async function prepareDatabase(): Promise { @@ -85,6 +86,8 @@ httpServer.listen(env.port, env.host, () => { } logger.info(`探针:/healthz(存活) · /readyz(就绪) · ${API_PREFIX}/health(详情)`); logger.info(`WebSocket:ws://${displayHost}:${env.port}/socket.io`); + // 数据库管理模块的定时备份调度(配置与"到点时间"持久化在 data/database-settings.json) + startBackupScheduler(); }); let shuttingDown = false; diff --git a/packages/server/src/lib/db.ts b/packages/server/src/lib/db.ts index 406fe8c..7449455 100644 --- a/packages/server/src/lib/db.ts +++ b/packages/server/src/lib/db.ts @@ -3,27 +3,33 @@ import { PrismaClient } from '../generated/prisma/client.js'; import { env } from '../config/env.js'; import { logger } from './logger.js'; +/** 本项目支持的主数据库类型(Redis 等键值库不是 Prisma 支持的主库,不在范围内) */ +export type DatabaseProvider = 'sqlite' | 'mysql'; + /** * Prisma 7 要求所有数据库都通过 driver adapter 接入: * - SQLite -> @prisma/adapter-libsql(libSQL 内嵌模式,npm 预编译二进制,无需本地编译) - * - MySQL -> @prisma/adapter-mariadb(切换数据库时安装,见 docs/mysql.md) + * - MySQL -> @prisma/adapter-mariadb(依赖里常驻,切换数据库时直接可用) * * MySQL 分支使用「非字面量模块名」动态导入,这样未安装 mariadb 适配器时 * 也不会影响类型检查与 SQLite 模式的运行。 + * + * 该工厂独立于进程级单例:数据库管理模块的「连接测试」与跨库迁移的工具进程 + * 都需要按任意 provider/url 构建临时客户端,而不是读 env 单例。 */ -async function createAdapter(): Promise { - if (env.databaseProvider === 'mysql') { +export async function createPrismaClient( + provider: DatabaseProvider, + url: string, +): Promise { + if (provider === 'mysql') { const adapterModule = '@prisma/adapter-mariadb'; const mod = (await import(adapterModule)) as { PrismaMariaDb: new (url: string) => unknown }; - logger.info('已加载 MySQL/MariaDB 驱动适配器'); - return new mod.PrismaMariaDb(env.databaseUrl); + return new PrismaClient({ adapter: new mod.PrismaMariaDb(url) as never }); } - return new PrismaLibSql({ url: env.databaseUrl }); + return new PrismaClient({ adapter: new PrismaLibSql({ url }) as never }); } -const adapter = await createAdapter(); - -export const prisma = new PrismaClient({ adapter: adapter as never }); +export const prisma = await createPrismaClient(env.databaseProvider, env.databaseUrl); logger.info(`Prisma 客户端已初始化(provider=${env.databaseProvider})`); diff --git a/packages/server/src/lib/snapshot.ts b/packages/server/src/lib/snapshot.ts new file mode 100644 index 0000000..6acbc2b --- /dev/null +++ b/packages/server/src/lib/snapshot.ts @@ -0,0 +1,159 @@ +import type { PrismaClient } from '../generated/prisma/client.js'; +import type { DatabaseProvider } from './db.js'; + +/** + * 全库 JSON 快照:备份 / 恢复 / 导入导出 / 跨库迁移共用这一种格式。 + * + * 为什么不用「复制数据库文件」或 mysqldump: + * - 文件级备份只对 SQLite 有意义,跨库迁移根本用不上; + * - JSON 快照与 provider 无关,一份实现同时服务备份、导入导出和 SQLite⇄MySQL 迁移。 + * + * 外键注意:schema 里 User.classId → Class 且 Class.teacherId → User(互相引用), + * 恢复时必须**临时关闭外键检查**再按序写入,否则任何排序都会被其中一侧卡死。 + * - SQLite:PRAGMA foreign_keys=OFF(libsql 连接级,进程内安全) + * - MySQL:SET FOREIGN_KEY_CHECKS=0/1(会话级,连接池下同一客户端实例内生效) + */ + +export const SNAPSHOT_FORMAT = 'classhelper-snapshot'; +export const SNAPSHOT_VERSION = 1; + +/** 导出与恢复顺序(外键关闭后顺序不再影响写入,但保持拓扑序便于人工阅读与排查) */ +export const SNAPSHOT_TABLES = [ + 'User', + 'Class', + 'Course', + 'ClassTeacher', + 'Enrollment', + 'Schedule', + 'TimeLayout', + 'Homework', + 'HomeworkStatus', + 'Notification', + 'NotificationRead', + 'Grade', + 'IntegrationDevice', + 'ClassIslandPush', +] as const; + +export type SnapshotTable = (typeof SNAPSHOT_TABLES)[number]; + +export interface DatabaseSnapshot { + format: string; + version: number; + createdAt: string; + source: { provider: string }; + /** 每张表的行数(导出时统计,恢复时校验总量用) */ + counts: Record; + /** 表名 → 行数组(DateTime 已是 ISO 字符串,Prisma 接受 ISO 字符串作为 DateTime 输入) */ + data: Record[]>; +} + +/** 模型代理的最小接口(避免 any:Prisma 各模型的 delegate 形状一致) */ +interface ModelDelegate { + findMany(): Promise[]>; + count(): Promise; + createMany(args: { data: Record[] }): Promise; + deleteMany(): Promise; +} + +function delegatesOf(prisma: PrismaClient): Record { + return prisma as unknown as Record; +} + +/** 单次 createMany 的批量大小(班级规模数据量小,小批次足够且省内存) */ +const CHUNK_SIZE = 200; + +/** 导出全库快照(行内 DateTime 为 Date 对象,JSON.stringify 时自动转 ISO) */ +export async function exportSnapshot( + prisma: PrismaClient, + provider: DatabaseProvider, +): Promise { + const delegates = delegatesOf(prisma); + const data = {} as Record[]>; + const counts: Record = {}; + for (const table of SNAPSHOT_TABLES) { + const rows = await delegates[table].findMany(); + data[table] = rows; + counts[table] = rows.length; + } + return { + format: SNAPSHOT_FORMAT, + version: SNAPSHOT_VERSION, + createdAt: new Date().toISOString(), + source: { provider }, + counts, + data, + }; +} + +/** 快照结构校验:给出可读的失败原因("为什么恢复失败"比失败本身重要) */ +export function assertValidSnapshot(value: unknown): asserts value is DatabaseSnapshot { + if (typeof value !== 'object' || value === null) { + throw new Error('快照内容不是合法的 JSON 对象'); + } + const snapshot = value as Partial; + if (snapshot.format !== SNAPSHOT_FORMAT) { + throw new Error(`快照格式不正确(期望 ${SNAPSHOT_FORMAT},收到 ${String(snapshot.format ?? '未知')})`); + } + if (snapshot.version !== SNAPSHOT_VERSION) { + throw new Error(`快照版本不兼容(期望 v${SNAPSHOT_VERSION},收到 v${String(snapshot.version)})`); + } + if (typeof snapshot.data !== 'object' || snapshot.data === null) { + throw new Error('快照缺少 data 字段'); + } + for (const table of SNAPSHOT_TABLES) { + const rows = (snapshot.data as Record)[table]; + if (!Array.isArray(rows)) { + throw new Error(`快照缺少表 ${table} 的数据`); + } + } +} + +async function setForeignKeys(prisma: PrismaClient, provider: DatabaseProvider, enabled: boolean): Promise { + const sql = + provider === 'mysql' + ? `SET FOREIGN_KEY_CHECKS=${enabled ? 1 : 0}` + : `PRAGMA foreign_keys=${enabled ? 'ON' : 'OFF'}`; + await prisma.$executeRawUnsafe(sql); +} + +/** + * 把快照写入目标库(先清空再写入)。 + * 用于:备份恢复、快照导入、跨库迁移的子进程。 + */ +export async function restoreSnapshot( + prisma: PrismaClient, + provider: DatabaseProvider, + snapshot: DatabaseSnapshot, + options: { clean?: boolean } = {}, +): Promise<{ counts: Record }> { + assertValidSnapshot(snapshot); + const delegates = delegatesOf(prisma); + const clean = options.clean !== false; + const counts: Record = {}; + + await setForeignKeys(prisma, provider, false); + try { + if (clean) { + // 反向清空,保证被引用的表最后清(外键关闭后只是防御性的,保持确定性) + for (const table of [...SNAPSHOT_TABLES].reverse()) { + await delegates[table].deleteMany(); + } + } + for (const table of SNAPSHOT_TABLES) { + const rows = snapshot.data[table]; + for (let index = 0; index < rows.length; index += CHUNK_SIZE) { + await delegates[table].createMany({ data: rows.slice(index, index + CHUNK_SIZE) }); + } + counts[table] = rows.length; + } + } finally { + await setForeignKeys(prisma, provider, true).catch(() => undefined); + } + return { counts }; +} + +/** 汇总快照总行数(列表展示 / 切换任务结果用) */ +export function snapshotTotalRows(snapshot: DatabaseSnapshot): number { + return Object.values(snapshot.counts).reduce((sum, count) => sum + count, 0); +} diff --git a/packages/server/src/modules/database/database.module.ts b/packages/server/src/modules/database/database.module.ts new file mode 100644 index 0000000..770d77d --- /dev/null +++ b/packages/server/src/modules/database/database.module.ts @@ -0,0 +1,124 @@ +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 { validate, validatedBody, validatedParams } from '../../middleware/validate.js'; +import { defineModule } from '../module.types.js'; +import { + backupNameParamSchema, + backupScheduleSchema, + importSnapshotSchema, + jobIdParamSchema, + switchDatabaseSchema, + testConnectionSchema, + type BackupScheduleInput, + type ImportSnapshotInput, + type SwitchDatabaseInput, + type TestConnectionInput, +} from './database.schemas.js'; +import * as databaseService from './database.service.js'; + +/** + * 数据库管理(仅管理员):状态检测 / 连接测试 / 备份恢复 / 导入导出 / 定时备份 / 一键切换。 + * + * 改动数据库属于最高危操作,全部接口挂在 requireRole('ADMIN') 之下; + * 前端入口同样只对 ADMIN 显示(Web 端「数据库管理」页)。 + * + * 一键切换是异步任务:POST /switch 返回 jobId,前端轮询 GET /switch/jobs/:id 展示步骤进度。 + * Redis 是键值库,不是 Prisma 支持的主数据库,因此不在此模块范围内。 + */ +const router = Router(); + +router.use(authenticate()); +router.use(requireRole('ADMIN')); + +/** GET /api/database/status - 当前数据库状态(连接/版本/大小/各表行数/备份与定时配置) */ +router.get('/status', async (_req, res) => { + sendOk(res, await databaseService.getDatabaseStatus()); +}); + +/** POST /api/database/test-connection - 测试任意目标库连通性(切换前的前置检查) */ +router.post('/test-connection', validate({ body: testConnectionSchema }), async (req, res) => { + const result = await databaseService.testConnection(validatedBody(req)); + sendOk(res, result, result.ok ? '连接成功' : '连接失败'); +}); + +/** GET /api/database/backups - 备份列表 */ +router.get('/backups', (_req, res) => { + sendOk(res, databaseService.listBackups()); +}); + +/** POST /api/database/backups - 立即创建一份手动备份 */ +router.post('/backups', async (_req, res) => { + sendCreated(res, await databaseService.createBackup('manual'), '备份已创建'); +}); + +/** POST /api/database/backups/:name/restore - 从备份恢复(整库覆盖) */ +router.post('/backups/:name/restore', validate({ params: backupNameParamSchema }), async (req, res) => { + const { name } = validatedParams<{ name: string }>(req); + sendOk(res, await databaseService.restoreBackup(name), '已从备份恢复'); +}); + +/** DELETE /api/database/backups/:name - 删除备份 */ +router.delete('/backups/:name', validate({ params: backupNameParamSchema }), async (req, res) => { + const { name } = validatedParams<{ name: string }>(req); + databaseService.deleteBackup(name); + sendOk(res, { name }, '备份已删除'); +}); + +/** GET /api/database/export - 下载当前库的 JSON 快照 */ +router.get('/export', async (_req, res) => { + const json = await databaseService.exportSnapshotJson(); + const filename = `classhelper-snapshot-${new Date().toISOString().slice(0, 19).replaceAll(':', '')}.json`; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.status(200).send(json); +}); + +/** GET /api/database/sqlite-file - 下载数据库文件(仅 SQLite 模式) */ +router.get('/sqlite-file', (_req, res) => { + const filePath = databaseService.sqliteFilePathForDownload(); + res.setHeader('Content-Type', 'application/octet-stream'); + res.setHeader('Content-Disposition', `attachment; filename="${path.basename(filePath)}"`); + res.status(200).send(fs.readFileSync(filePath)); +}); + +/** POST /api/database/import - 导入快照(整库覆盖;base64 随 JSON 请求体) */ +router.post('/import', validate({ body: importSnapshotSchema }), async (req, res) => { + const { data } = validatedBody(req); + sendOk(res, await databaseService.importSnapshotBase64(data), '快照已导入(原数据已被覆盖)'); +}); + +/** GET /api/database/backup-schedule - 定时备份配置 */ +router.get('/backup-schedule', (_req, res) => { + sendOk(res, databaseService.getBackupSchedule()); +}); + +/** PUT /api/database/backup-schedule - 更新定时备份配置(立即生效,重启后仍有效) */ +router.put('/backup-schedule', validate({ body: backupScheduleSchema }), async (req, res) => { + sendOk( + res, + databaseService.saveSchedulePatch(validatedBody(req)), + '定时备份配置已保存', + ); +}); + +/** POST /api/database/switch - 一键切换数据库(异步任务,返回 jobId) */ +router.post('/switch', validate({ body: switchDatabaseSchema }), async (req, res) => { + const job = databaseService.startSwitch(validatedBody(req)); + sendCreated(res, { jobId: job.id }, '切换任务已启动'); +}); + +/** GET /api/database/switch/jobs/:id - 查询切换任务进度 */ +router.get('/switch/jobs/:id', validate({ params: jobIdParamSchema }), async (req, res) => { + const { id } = validatedParams<{ id: string }>(req); + const job = databaseService.getSwitchJob(id); + sendOk(res, job, job ? 'ok' : '任务不存在'); +}); + +export const databaseModule = defineModule({ + name: 'database', + basePath: '/database', + router, +}); diff --git a/packages/server/src/modules/database/database.schemas.ts b/packages/server/src/modules/database/database.schemas.ts new file mode 100644 index 0000000..75dbe8d --- /dev/null +++ b/packages/server/src/modules/database/database.schemas.ts @@ -0,0 +1,65 @@ +import { z } from 'zod'; + +/** 本项目支持的主数据库类型。Redis 等键值库不是 Prisma 支持的主库,不提供切换。 */ +export const databaseProviderSchema = z.enum(['sqlite', 'mysql']); +export type DatabaseProviderInput = z.infer; + +/** 连接串与 provider 配套校验:SQLite 必须 file: 前缀,MySQL 必须 mysql:// 前缀 */ +export const targetConnectionSchema = z + .object({ + provider: databaseProviderSchema, + url: z.string().min(1, '请填写连接串'), + }) + .superRefine((value, ctx) => { + const url = value.url.trim(); + if (value.provider === 'sqlite' && !url.startsWith('file:')) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['url'], + message: 'SQLite 连接串必须以 file: 开头,例如 file:./data/classhelper.db', + }); + } + if (value.provider === 'mysql' && !/^mysqls?:\/\//.test(url)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['url'], + message: 'MySQL 连接串必须以 mysql:// 或 mysqls:// 开头', + }); + } + }); + +export type TargetConnectionInput = z.infer; + +/** POST /database/test-connection:测试任意目标库的连通性 */ +export const testConnectionSchema = targetConnectionSchema; +export type TestConnectionInput = z.infer; + +/** POST /database/switch:一键切换数据库(含数据迁移) */ +export const switchDatabaseSchema = targetConnectionSchema; +export type SwitchDatabaseInput = z.infer; + +/** POST /database/import:上传快照(base64,随 JSON 请求体,受 12MB 请求体限制) */ +export const importSnapshotSchema = z.object({ + data: z.string().min(1, '请提供快照内容(base64)'), +}); +export type ImportSnapshotInput = z.infer; + +/** PUT /database/backup-schedule:定时备份配置 */ +export const backupScheduleSchema = z.object({ + enabled: z.boolean(), + /** 间隔小时数(1~720,默认 24 = 每天一次) */ + intervalHours: z.number().int().min(1).max(720), + /** 最多保留多少份自动备份(超出后删最旧的;手动备份不受影响) */ + keepCount: z.number().int().min(1).max(100), +}); +export type BackupScheduleInput = z.infer; + +/** 路径参数:备份文件名(必须是本服务生成的固定格式,杜绝路径穿越) */ +export const backupNameParamSchema = z.object({ + name: z.string().regex(/^backup-(manual|auto)-\d{8}-\d{6}\.json\.gz$/, '备份文件名不合法'), +}); + +/** 路径参数:切换任务 id(本服务生成,字母数字与连字符) */ +export const jobIdParamSchema = z.object({ + id: z.string().regex(/^[0-9a-z-]{6,40}$/, '任务 id 不合法'), +}); diff --git a/packages/server/src/modules/database/database.service.ts b/packages/server/src/modules/database/database.service.ts new file mode 100644 index 0000000..ac6d612 --- /dev/null +++ b/packages/server/src/modules/database/database.service.ts @@ -0,0 +1,771 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import zlib from 'node:zlib'; +import { env } from '../../config/env.js'; +import { createPrismaClient, prisma, type DatabaseProvider } from '../../lib/db.js'; +import { logger } from '../../lib/logger.js'; +import { + assertValidSnapshot, + exportSnapshot, + restoreSnapshot, + SNAPSHOT_TABLES, + type DatabaseSnapshot, +} from '../../lib/snapshot.js'; +import { ApiError } from '../../lib/http.js'; +import type { + BackupScheduleInput, + TargetConnectionInput, +} from './database.schemas.js'; + +/** + * 数据库管理服务:状态检测 / 连接测试 / 备份恢复 / 导入导出 / 定时备份 / 一键切换。 + * + * 所有实现都基于 `lib/snapshot.ts` 的 JSON 快照:与 provider 无关, + * 因此备份可以恢复回任意一种受支持的数据库(备份即迁移的载体)。 + */ + +/* ================================================================ 路径 */ + +/** 数据目录:SQLite = 数据文件所在目录;MySQL = 安装根的 data 目录(与部署约定一致) */ +function dataDir(): string { + if (env.databaseProvider === 'sqlite' && env.sqliteFilePath) { + return path.dirname(env.sqliteFilePath); + } + return path.join(env.serverRoot, '..', 'data'); +} + +function backupDir(): string { + return path.join(dataDir(), 'backups'); +} + +function settingsFilePath(): string { + return path.join(dataDir(), 'database-settings.json'); +} + +function ensureBackupDir(): string { + const dir = backupDir(); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +/* ================================================================ 定时备份配置 */ + +interface BackupSchedule { + enabled: boolean; + intervalHours: number; + keepCount: number; + lastAutoBackupAt: string | null; +} + +const DEFAULT_SCHEDULE: BackupSchedule = { + enabled: false, + intervalHours: 24, + keepCount: 7, + lastAutoBackupAt: null, +}; + +function readSchedule(): BackupSchedule { + try { + const raw = JSON.parse(fs.readFileSync(settingsFilePath(), 'utf8')) as Partial; + return { + enabled: raw.enabled === true, + intervalHours: + typeof raw.intervalHours === 'number' && raw.intervalHours >= 1 ? Math.floor(raw.intervalHours) : DEFAULT_SCHEDULE.intervalHours, + keepCount: typeof raw.keepCount === 'number' && raw.keepCount >= 1 ? Math.floor(raw.keepCount) : DEFAULT_SCHEDULE.keepCount, + lastAutoBackupAt: typeof raw.lastAutoBackupAt === 'string' ? raw.lastAutoBackupAt : null, + }; + } catch { + return { ...DEFAULT_SCHEDULE }; + } +} + +function writeSchedule(schedule: BackupSchedule): void { + ensureBackupDir(); + fs.writeFileSync(settingsFilePath(), `${JSON.stringify(schedule, null, 2)}\n`, 'utf8'); +} + +/** 读取定时备份配置(GET /backup-schedule) */ +export function getBackupSchedule(): BackupSchedule { + return readSchedule(); +} + +/** 更新定时备份配置(PUT /backup-schedule),写盘后立即生效 */ +export function saveSchedulePatch(patch: BackupScheduleInput): BackupSchedule { + const next: BackupSchedule = { ...readSchedule(), ...patch }; + writeSchedule(next); + return next; +} + +let schedulerTimer: NodeJS.Timeout | null = null; + +/** + * 定时备份调度:每 10 分钟检查一次,到点自动备份并清理旧备份。 + * 依赖服务端进程常驻(进程不运行不会补跑);lastAutoBackupAt 持久化在配置文件里, + * 重启后不会立刻重复备份。 + */ +export function startBackupScheduler(): void { + if (schedulerTimer) return; + const tick = async (): Promise => { + if (isSwitchRunning()) return; + const schedule = readSchedule(); + if (!schedule.enabled) return; + const last = schedule.lastAutoBackupAt ? Date.parse(schedule.lastAutoBackupAt) : 0; + const dueAt = (Number.isFinite(last) ? last : 0) + schedule.intervalHours * 3_600_000; + if (Date.now() < dueAt) return; + try { + await createBackup('auto'); + writeSchedule({ ...schedule, lastAutoBackupAt: new Date().toISOString() }); + logger.info('定时备份完成'); + } catch (error) { + logger.error('定时备份失败', error); + } + }; + schedulerTimer = setInterval(() => void tick(), 10 * 60_000); + schedulerTimer.unref(); + logger.info('定时备份调度已启动(每 10 分钟检查一次到点任务)'); +} + +/* ================================================================ 备份 / 恢复 */ + +export interface BackupMeta { + name: string; + kind: 'manual' | 'auto'; + sizeBytes: number; + createdAt: string; +} + +function isBackupName(name: string): boolean { + return /^backup-(manual|auto)-\d{8}-\d{6}\.json\.gz$/.test(name); +} + +function backupPath(name: string): string { + if (!isBackupName(name)) { + throw ApiError.badRequest('备份文件名不合法'); + } + const target = path.join(backupDir(), name); + if (path.dirname(target) !== backupDir()) { + throw ApiError.badRequest('备份文件名不合法'); + } + return target; +} + +function timestampSlug(): string { + const now = new Date(); + const pad = (value: number): string => String(value).padStart(2, '0'); + return ( + `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` + + `-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}` + ); +} + +/** 创建一份备份(gzip 压缩的 JSON 快照)。kind 决定文件名前缀与自动清理策略。 */ +export async function createBackup(kind: 'manual' | 'auto'): Promise { + assertNotSwitching(); + const dir = ensureBackupDir(); + const snapshot = await exportSnapshot(prisma, env.databaseProvider); + const payload = zlib.gzipSync(Buffer.from(JSON.stringify(snapshot), 'utf8')); + const name = `backup-${kind}-${timestampSlug()}.json.gz`; + fs.writeFileSync(path.join(dir, name), payload); + if (kind === 'auto') pruneAutoBackups(); + const meta: BackupMeta = { name, kind, sizeBytes: payload.length, createdAt: snapshot.createdAt }; + logger.info(`已创建${kind === 'auto' ? '定时' : '手动'}备份:${name}`); + return meta; +} + +/** 自动备份按 keepCount 清理最旧的(手动备份不受影响) */ +function pruneAutoBackups(): void { + const schedule = readSchedule(); + const autos = listBackups() + .filter((item) => item.kind === 'auto') + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + for (const stale of autos.slice(0, Math.max(0, autos.length - schedule.keepCount))) { + try { + fs.unlinkSync(path.join(backupDir(), stale.name)); + } catch { + // 清理失败不影响本次备份 + } + } +} + +export function listBackups(): BackupMeta[] { + const dir = backupDir(); + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir) + .filter(isBackupName) + .map((name) => { + const stat = fs.statSync(path.join(dir, name)); + return { + name, + kind: name.includes('-manual-') ? ('manual' as const) : ('auto' as const), + sizeBytes: stat.size, + createdAt: stat.mtime.toISOString(), + }; + }) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); +} + +function readBackupSnapshot(name: string): DatabaseSnapshot { + const file = backupPath(name); + if (!fs.existsSync(file)) { + throw ApiError.notFound('备份不存在(可能已被删除)'); + } + const snapshot = JSON.parse(zlib.gunzipSync(fs.readFileSync(file)).toString('utf8')) as DatabaseSnapshot; + assertValidSnapshot(snapshot); + return snapshot; +} + +/** 从备份恢复(整库覆盖:先清空现有数据再写入备份内容) */ +export async function restoreBackup(name: string): Promise<{ counts: Record }> { + assertNotSwitching(); + const snapshot = readBackupSnapshot(name); + return restoreSnapshot(prisma, env.databaseProvider, snapshot); +} + +export function deleteBackup(name: string): void { + const file = backupPath(name); + if (!fs.existsSync(file)) { + throw ApiError.notFound('备份不存在(可能已被删除)'); + } + fs.unlinkSync(file); +} + +/* ================================================================ 导入 / 导出 */ + +/** 导出当前库的快照 JSON(未压缩,供下载与跨库迁移使用) */ +export async function exportSnapshotJson(): Promise { + assertNotSwitching(); + return JSON.stringify(await exportSnapshot(prisma, env.databaseProvider)); +} + +/** 从 base64 快照导入(整库覆盖)。用于"快捷导入"。 */ +export async function importSnapshotBase64(data: string): Promise<{ counts: Record }> { + assertNotSwitching(); + let snapshot: DatabaseSnapshot; + try { + const json = Buffer.from(data, 'base64').toString('utf8'); + snapshot = JSON.parse(json) as DatabaseSnapshot; + } catch { + throw ApiError.badRequest('快照内容不是合法的 base64 JSON'); + } + return restoreSnapshot(prisma, env.databaseProvider, snapshot); +} + +/** SQLite 数据文件下载(仅 SQLite 模式提供;MySQL 无"单文件"概念) */ +export function sqliteFilePathForDownload(): string { + if (env.databaseProvider !== 'sqlite' || !env.sqliteFilePath || !fs.existsSync(env.sqliteFilePath)) { + throw ApiError.badRequest('当前不是 SQLite 数据库或数据文件不存在'); + } + return env.sqliteFilePath; +} + +/* ================================================================ 状态 / 连接测试 */ + +export interface TableCount { + name: string; + count: number; +} + +async function databaseVersion(provider: DatabaseProvider, client: typeof prisma): Promise { + if (provider === 'mysql') { + const rows = (await client.$queryRawUnsafe('SELECT VERSION() AS version')) as { version?: string }[]; + return rows[0]?.version ?? 'unknown'; + } + const rows = (await client.$queryRawUnsafe('SELECT sqlite_version() AS version')) as { + version?: string; + }[]; + return rows[0]?.version ?? 'unknown'; +} + +async function databaseSizeBytes(provider: DatabaseProvider, client: typeof prisma): Promise { + if (provider === 'sqlite') { + return env.sqliteFilePath && fs.existsSync(env.sqliteFilePath) ? fs.statSync(env.sqliteFilePath).size : 0; + } + const rows = (await client.$queryRawUnsafe( + 'SELECT COALESCE(SUM(data_length + index_length), 0) AS size FROM information_schema.tables WHERE table_schema = DATABASE()', + )) as { size?: bigint | number }[]; + const size = rows[0]?.size; + return size === undefined ? null : Number(size); +} + +export interface DatabaseStatus { + provider: DatabaseProvider; + connected: boolean; + latencyMs: number | null; + version: string | null; + sizeBytes: number | null; + /** 当前连接串的脱敏展示(MySQL 隐藏密码) */ + databaseUrlMasked: string; + /** SQLite 数据文件绝对路径(MySQL 为空) */ + sqliteFilePath: string; + dataDir: string; + backupDir: string; + tables: TableCount[]; + backups: ReturnType; + schedule: BackupSchedule; + /** 是否有正在进行的切换任务(期间禁止备份/导入/恢复) */ + switchRunning: boolean; +} + +/** 连接串脱敏:mysql://user:password@host → mysql://user:***@host */ +function maskDatabaseUrl(provider: DatabaseProvider, url: string): string { + if (provider !== 'mysql') return url; + return url.replace(/(\/\/[^:/@]+:)[^@]*(@)/, '$1***$2'); +} + +export async function getDatabaseStatus(): Promise { + const startedAt = Date.now(); + let connected = false; + let version: string | null = null; + try { + version = await databaseVersion(env.databaseProvider, prisma); + connected = true; + } catch { + connected = false; + } + const latencyMs = connected ? Date.now() - startedAt : null; + const delegates = prisma as unknown as Record< + (typeof SNAPSHOT_TABLES)[number], + { count(): Promise } + >; + const tables: TableCount[] = connected + ? await Promise.all( + SNAPSHOT_TABLES.map(async (name): Promise => { + try { + return { name, count: await delegates[name].count() }; + } catch { + return { name, count: -1 }; + } + }), + ) + : []; + return { + provider: env.databaseProvider, + connected, + latencyMs, + version, + sizeBytes: connected ? await databaseSizeBytes(env.databaseProvider, prisma).catch(() => null) : null, + databaseUrlMasked: maskDatabaseUrl(env.databaseProvider, env.databaseUrl), + sqliteFilePath: env.sqliteFilePath, + dataDir: dataDir(), + backupDir: backupDir(), + tables, + backups: listBackups(), + schedule: readSchedule(), + switchRunning: isSwitchRunning(), + }; +} + +export interface ConnectionTestResult { + ok: boolean; + provider: DatabaseProvider; + latencyMs: number | null; + version: string | null; + /** 新建 SQLite 库时目标文件还不存在,属于正常情况(切换时会自动建库) */ + note?: string; + error?: string; +} + +/** 测试任意目标库的连通性(发起切换前的第一步) */ +export async function testConnection(target: TargetConnectionInput): Promise { + const startedAt = Date.now(); + let note: string | undefined; + let url = target.url.trim(); + if (target.provider === 'sqlite') { + // 与 env.resolveSqliteUrl 同口径:相对路径以 serverRoot 为基准解析为绝对路径 + const rawPath = url.slice('file:'.length).replace(/\//g, path.sep); + const absolute = path.isAbsolute(rawPath) ? rawPath : path.resolve(env.serverRoot, rawPath); + if (!fs.existsSync(absolute)) { + const parent = path.dirname(absolute); + if (!fs.existsSync(parent)) { + return { ok: false, provider: target.provider, latencyMs: null, version: null, error: `目录不存在:${parent}` }; + } + note = '目标文件不存在,属新库(切换时会自动建表)'; + } + url = `file:${absolute.replace(/\\/g, '/')}`; + } + let client: Awaited> | null = null; + try { + client = await createPrismaClient(target.provider, url); + const version = await databaseVersion(target.provider, client); + return { ok: true, provider: target.provider, latencyMs: Date.now() - startedAt, version, note }; + } catch (error) { + return { + ok: false, + provider: target.provider, + latencyMs: null, + version: null, + error: error instanceof Error ? error.message : String(error), + }; + } finally { + await client?.$disconnect().catch(() => undefined); + } +} + +/* ================================================================ 一键切换 */ + +export interface SwitchStep { + name: string; + status: 'running' | 'done' | 'error'; + detail?: string; +} + +export interface SwitchJob { + id: string; + status: 'running' | 'done' | 'error'; + target: { provider: DatabaseProvider; url: string }; + steps: SwitchStep[]; + error?: string; + /** 完成后必填:需要重启服务端才能让新数据库生效 */ + restartRequired: boolean; + counts?: Record; + backupName?: string; + startedAt: string; + finishedAt?: string; +} + +const switchJobs = new Map(); + +function isSwitchRunning(): boolean { + for (const job of switchJobs.values()) { + if (job.status === 'running') return true; + } + return false; +} + +function assertNotSwitching(): void { + if (isSwitchRunning()) { + throw ApiError.conflict('正在执行数据库切换,请等待完成后再进行备份/导入/恢复'); + } +} + +/** 防止路径穿越:任务 id 只由本服务生成(时间戳 + 随机数) */ +function newJobId(): string { + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +export function getSwitchJob(id: string): SwitchJob | null { + return switchJobs.get(id) ?? null; +} + +function stepOf(job: SwitchJob, name: string): SwitchStep { + let step = job.steps.find((item) => item.name === name); + if (!step) { + step = { name, status: 'running' }; + job.steps.push(step); + } + return step; +} + +function finishStep(job: SwitchJob, name: string, detail?: string): void { + const step = stepOf(job, name); + step.status = 'done'; + if (detail) step.detail = detail; +} + +function appendDetail(job: SwitchJob, name: string, detail: string): void { + const step = stepOf(job, name); + step.detail = `${step.detail ? `${step.detail}\n` : ''}${detail}`.slice(-4000); +} + +/** 校验目标库必须是空库(防误覆盖):有表即拒绝 */ +async function assertTargetEmpty(target: TargetConnectionInput): Promise { + let url = target.url.trim(); + let sqliteAbsolute: string | null = null; + if (target.provider === 'sqlite') { + const rawPath = url.slice('file:'.length).replace(/\//g, path.sep); + const absolute = path.isAbsolute(rawPath) ? rawPath : path.resolve(env.serverRoot, rawPath); + sqliteAbsolute = absolute; + url = `file:${absolute.replace(/\\/g, '/')}`; + } + const client = await createPrismaClient(target.provider, url); + try { + const rows = + target.provider === 'sqlite' + ? (await client.$queryRawUnsafe( + "SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'", + )) as { count?: bigint | number }[] + : (await client.$queryRawUnsafe( + 'SELECT COUNT(*) AS count FROM information_schema.tables WHERE table_schema = DATABASE()', + )) as { count?: bigint | number }[]; + const tableCount = Number(rows[0]?.count ?? 0); + if (tableCount > 0) { + throw ApiError.badRequest( + `目标数据库不是空库(已有 ${tableCount} 张表)。为防误覆盖,只允许迁移到空库;` + + '如需重用已有库,请先备份并清空它。', + ); + } + return target.provider === 'sqlite' ? `目标文件:${sqliteAbsolute}` : '目标 MySQL 库为空'; + } finally { + await client.$disconnect().catch(() => undefined); + } +} + +const SCHEMA_FILE = path.join(env.serverRoot, 'prisma', 'schema.prisma'); + +function rewriteSchemaProvider(provider: DatabaseProvider): string { + const original = fs.readFileSync(SCHEMA_FILE, 'utf8'); + const updated = original.replace(/provider\s*=\s*"(sqlite|mysql)"/, `provider = "${provider}"`); + if (updated === original) { + throw new Error('未能改写 schema.prisma 的 provider(未匹配到 provider 声明)'); + } + fs.writeFileSync(SCHEMA_FILE, updated, 'utf8'); + return original; +} + +/** 子进程运行 Prisma CLI(generate / db push)。连接串通过环境变量显式传入,不依赖 .env。 */ +function runPrismaCli( + job: SwitchJob, + stepName: string, + args: string[], + target: TargetConnectionInput, +): Promise { + const candidates = [ + path.join(env.serverRoot, 'node_modules', 'prisma', 'build', 'index.js'), + path.join(env.serverRoot, '..', 'node_modules', 'prisma', 'build', 'index.js'), + ]; + const cli = candidates.find((file) => fs.existsSync(file)); + if (!cli) { + throw new Error('未找到 Prisma CLI(node_modules/prisma)。安装版请确认安装包完整。'); + } + return runNodeTool(job, stepName, [cli, ...args], { + ...process.env, + DATABASE_PROVIDER: target.provider, + DATABASE_URL: target.url.trim(), + }); +} + +/** 编译新生成的 Prisma 客户端(仅生产打包形态需要:dist 里跑的是编译产物) */ +async function compileGeneratedClient(job: SwitchJob): Promise { + // 开发模式(tsx 直接跑 src)不需要编译;打包形态(存在 dist/index.js)才需要 + if (!fs.existsSync(path.join(env.serverRoot, 'dist', 'index.js'))) return; + const candidates = [ + path.join(env.serverRoot, 'node_modules', 'typescript', 'lib', 'tsc.js'), + path.join(env.serverRoot, '..', 'node_modules', 'typescript', 'lib', 'tsc.js'), + ]; + const tsc = candidates.find((file) => fs.existsSync(file)); + if (!tsc) { + throw new Error('未找到 TypeScript 编译器(node_modules/typescript)。安装包请确认完整。'); + } + await runNodeTool(job, '编译新生成的 Prisma 客户端', [tsc, '-p', 'tsconfig.generate.json'], process.env); +} + +/** 跨库迁移的数据写入走独立子进程:运行中的服务进程缓存的是旧方言的 Prisma Client */ +function runApplySnapshotTool(job: SwitchJob, target: TargetConnectionInput, snapshotFile: string): Promise> { + const distTool = path.join(env.serverRoot, 'dist', 'tools', 'apply-snapshot.js'); + const srcTool = path.join(env.serverRoot, 'src', 'tools', 'apply-snapshot.ts'); + const tsxCli = path.join(env.serverRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs'); + let args: string[]; + if (fs.existsSync(distTool)) { + args = [distTool]; + } else if (fs.existsSync(tsxCli) && fs.existsSync(srcTool)) { + args = [tsxCli, srcTool]; + } else { + throw new Error('未找到快照导入工具(dist/tools/apply-snapshot.js),请先构建服务端'); + } + args.push('--provider', target.provider, '--url', target.url.trim(), '--file', snapshotFile); + const capture: { text: string } = { text: '' }; + return runNodeTool(job, '迁移数据到目标库', args, process.env, capture).then(() => { + const lines = capture.text.trim().split(/\r?\n/).filter(Boolean); + const last = lines[lines.length - 1] ?? ''; + try { + const parsed = JSON.parse(last) as { ok?: boolean; counts?: Record; error?: string }; + if (!parsed.ok) throw new Error(parsed.error ?? '快照导入工具返回失败'); + return parsed.counts ?? {}; + } catch (error) { + throw new Error( + `无法解析快照导入工具的输出:${error instanceof Error ? error.message : String(error)}(输出尾部:${last.slice(0, 400)})`, + ); + } + }); +} + +function runNodeTool( + job: SwitchJob, + stepName: string, + args: string[], + nodeEnv: NodeJS.ProcessEnv, + capture?: { text: string }, + timeoutMs = 10 * 60_000, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, args, { + cwd: env.serverRoot, + env: nodeEnv, + windowsHide: true, + }); + let output = ''; + const collect = (chunk: Buffer | string): void => { + output += String(chunk); + if (output.length > 20_000) output = output.slice(-20_000); + if (capture) capture.text = output; + }; + child.stdout?.on('data', collect); + child.stderr?.on('data', collect); + const timer = setTimeout(() => { + child.kill(); + reject(new Error(`${stepName} 超时(${Math.round(timeoutMs / 60_000)} 分钟)`)); + }, timeoutMs); + child.on('error', (error) => { + clearTimeout(timer); + reject(new Error(`${stepName} 启动失败:${error.message}`)); + }); + child.on('exit', (code) => { + clearTimeout(timer); + if (code === 0) { + appendDetail(job, stepName, output.trim().split(/\r?\n/).slice(-6).join('\n')); + resolve(); + return; + } + const tail = output.trim().split(/\r?\n/).slice(-10).join('\n'); + reject(new Error(`${stepName} 失败(exit ${code ?? 'signal'}):${tail || '无输出'}`)); + }); + }); +} + +/** 切换完成后改写 .env(DATABASE_PROVIDER / DATABASE_URL 两行) */ +function updateEnvFiles(target: TargetConnectionInput): string { + const candidates = [path.join(env.serverRoot, '.env'), path.join(env.serverRoot, '..', '.env')]; + const existing = candidates.filter((file) => fs.existsSync(file)); + const files = existing.length > 0 ? existing : [candidates[0]]; + const updated: string[] = []; + for (const file of files) { + let content = fs.readFileSync(file, 'utf8'); + const providerLine = `DATABASE_PROVIDER=${target.provider}`; + const urlLine = `DATABASE_URL="${target.url.trim()}"`; + if (/^DATABASE_PROVIDER=.*$/m.test(content)) { + content = content.replace(/^DATABASE_PROVIDER=.*$/m, providerLine); + } else { + content = `${content.trimEnd()}\n${providerLine}\n`; + } + if (/^DATABASE_URL=.*$/m.test(content)) { + content = content.replace(/^DATABASE_URL=.*$/m, urlLine); + } else { + content = `${content.trimEnd()}\n${urlLine}\n`; + } + fs.writeFileSync(file, content, 'utf8'); + updated.push(file); + } + return updated.join(' , '); +} + +/** + * 发起一键切换(异步任务)。流程与失败语义见模块注释: + * 数据写入全部成功后才改写 .env;中途失败回滚 schema.prisma,现有库不受影响。 + */ +export function startSwitch(target: TargetConnectionInput): SwitchJob { + if (isSwitchRunning()) { + throw ApiError.conflict('已有切换任务正在进行,请等待完成'); + } + if (target.provider === env.databaseProvider && target.url.trim() === env.databaseUrl) { + throw ApiError.badRequest('目标数据库与当前使用的数据库相同'); + } + const job: SwitchJob = { + id: newJobId(), + status: 'running', + target: { provider: target.provider, url: target.url.trim() }, + steps: [], + restartRequired: false, + startedAt: new Date().toISOString(), + }; + switchJobs.set(job.id, job); + // 不 await:HTTP 立即返回 jobId,前端轮询 GET /switch/jobs/:id + void runSwitch(job, target).catch(() => undefined); + return job; +} + +async function runSwitch(job: SwitchJob, target: TargetConnectionInput): Promise { + let schemaOriginal: string | null = null; + let snapshotFile: string | null = null; + try { + // 1. 先备份当前库(切换失败时的兜底) + stepOf(job, '备份当前数据库'); + const backup = await createBackup('manual'); + job.backupName = backup.name; + finishStep(job, '备份当前数据库', `备份文件 ${backup.name}`); + + // 2. 导出快照到临时文件(供子进程写入目标库) + stepOf(job, '导出当前数据'); + fs.mkdirSync(backupDir(), { recursive: true }); + snapshotFile = path.join(backupDir(), `switch-${job.id}.json`); + fs.writeFileSync(snapshotFile, await exportSnapshotJson(), 'utf8'); + finishStep(job, '导出当前数据'); + + // 3. 测试目标连接 + stepOf(job, '测试目标库连接'); + const test = await testConnection(target); + if (!test.ok) { + throw new Error(test.error ?? '目标库连接失败'); + } + finishStep(job, '测试目标库连接', test.note ?? `连接正常(${test.latencyMs ?? '-'}ms,${test.version ?? '-'})`); + + // 4. 目标库必须为空 + stepOf(job, '检查目标库为空'); + finishStep(job, '检查目标库为空', await assertTargetEmpty(target)); + + // 5. 改写 schema.prisma 的 provider(失败时回滚) + stepOf(job, '改写 schema.prisma'); + schemaOriginal = rewriteSchemaProvider(target.provider); + finishStep(job, '改写 schema.prisma', `provider → ${target.provider}`); + + // 6. 生成新方言的 Prisma 客户端 + stepOf(job, '生成 Prisma 客户端'); + await runPrismaCli(job, '生成 Prisma 客户端', ['generate'], target); + finishStep(job, '生成 Prisma 客户端'); + + // 7. 生产形态:把生成出来的 .ts 客户端编译进 dist(dev 由 tsx 直接跑 src,跳过) + await compileGeneratedClient(job); + + // 8. 目标库建表(Prisma 7 的 db push 不再自动生成客户端,generate 是独立步骤 6) + stepOf(job, '在目标库建表'); + await runPrismaCli(job, '在目标库建表', ['db', 'push', '--accept-data-loss'], target); + finishStep(job, '在目标库建表'); + + // 9. 迁移数据(独立子进程加载新方言客户端) + stepOf(job, '迁移数据到目标库'); + const counts = await runApplySnapshotTool(job, target, snapshotFile); + job.counts = counts; + const total = Object.values(counts).reduce((sum, count) => sum + count, 0); + finishStep(job, '迁移数据到目标库', `共写入 ${total} 行`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + // 失败发生在哪一步,就把那一步标成 error(UI 上能直接看到卡在哪) + const running = [...job.steps].reverse().find((item) => item.status === 'running'); + if (running) { + running.status = 'error'; + running.detail = message.slice(0, 1000); + } + job.error = message; + job.status = 'error'; + job.finishedAt = new Date().toISOString(); + // 回滚 schema(.env 尚未改写,现有库不受影响) + if (schemaOriginal !== null) { + try { + fs.writeFileSync(SCHEMA_FILE, schemaOriginal, 'utf8'); + } catch (rollbackError) { + logger.error('回滚 schema.prisma 失败,请手工核对 provider 设置', rollbackError); + } + } + logger.error(`数据库切换失败(job=${job.id}):${message}`); + return; + } finally { + if (snapshotFile) fs.rmSync(snapshotFile, { force: true }); + } + + // 10. 全部成功后才切换 .env(失败已在上面的 catch 里返回) + try { + stepOf(job, '写入 .env 配置'); + const files = updateEnvFiles(target); + finishStep(job, '写入 .env 配置', files); + job.status = 'done'; + job.restartRequired = true; + job.finishedAt = new Date().toISOString(); + logger.info(`数据库切换任务完成(job=${job.id})→ ${target.provider},重启服务端后生效`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + job.error = `数据已迁移到目标库,但写入 .env 失败:${message}(请手工修改 .env 的 DATABASE_PROVIDER / DATABASE_URL 后重启)`; + job.status = 'error'; + job.finishedAt = new Date().toISOString(); + } +} diff --git a/packages/server/src/modules/registry.ts b/packages/server/src/modules/registry.ts index acd9a48..9c2f37c 100644 --- a/packages/server/src/modules/registry.ts +++ b/packages/server/src/modules/registry.ts @@ -2,6 +2,7 @@ import { authModule } from './auth/auth.module.js'; import { callsModule } from './calls/calls.module.js'; import { classesModule } from './classes/classes.module.js'; import { coursesModule } from './courses/courses.module.js'; +import { databaseModule } from './database/database.module.js'; import { dashboardModule } from './dashboard/dashboard.module.js'; import { gradesModule } from './grades/grades.module.js'; import { homeworksModule } from './homeworks/homeworks.module.js'; @@ -34,4 +35,5 @@ export const apiModules: ApiModule[] = [ studentsModule, teachersModule, dashboardModule, + databaseModule, ].filter((module) => module.enabled !== false); diff --git a/packages/server/src/tools/apply-snapshot.ts b/packages/server/src/tools/apply-snapshot.ts new file mode 100644 index 0000000..aebea36 --- /dev/null +++ b/packages/server/src/tools/apply-snapshot.ts @@ -0,0 +1,59 @@ +import fs from 'node:fs'; +import process from 'node:process'; +import { createPrismaClient, type DatabaseProvider } from '../lib/db.js'; +import { assertValidSnapshot, restoreSnapshot, type DatabaseSnapshot } from '../lib/snapshot.js'; + +/** + * 快照导入工具(供跨库迁移的**子进程**调用,也可手工使用)。 + * + * 为什么是子进程:切换数据库时 `prisma generate` 会重新生成 Prisma Client(SQL 方言变了), + * 而正在运行的服务进程里缓存的是**旧方言**的客户端模块 —— 只有新进程才能加载重新生成的客户端, + * 把快照写进新库。用法: + * + * node dist/tools/apply-snapshot.js --provider mysql --url "mysql://..." --file snapshot.json + * + * stdout 最后一行输出 JSON:{ ok, counts },父进程据此回填任务进度;失败时 exit 1 并输出原因。 + * 注意 --file 是**未压缩**的 JSON(父进程负责解压到临时文件)。 + */ + +interface ToolArgs { + provider: DatabaseProvider; + url: string; + file: string; +} + +function parseArgs(argv: readonly string[]): ToolArgs { + const read = (name: string): string => { + const index = argv.indexOf(`--${name}`); + const value = index >= 0 ? argv[index + 1] : undefined; + if (!value) throw new Error(`缺少参数 --${name}`); + return value; + }; + const provider = read('provider'); + if (provider !== 'sqlite' && provider !== 'mysql') { + throw new Error(`不支持的 provider:${provider}(只支持 sqlite / mysql)`); + } + return { provider, url: read('url'), file: read('file') }; +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + const raw = fs.readFileSync(args.file, 'utf8'); + const snapshot = JSON.parse(raw) as DatabaseSnapshot; + assertValidSnapshot(snapshot); + + const client = await createPrismaClient(args.provider, args.url); + try { + const { counts } = await restoreSnapshot(client, args.provider, snapshot); + process.stdout.write(`${JSON.stringify({ ok: true, counts })}\n`); + } finally { + await client.$disconnect().catch(() => undefined); + } +} + +main().catch((error: unknown) => { + process.stdout.write( + `${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`, + ); + process.exit(1); +}); diff --git a/packages/server/tsconfig.generate.json b/packages/server/tsconfig.generate.json new file mode 100644 index 0000000..f219e8a --- /dev/null +++ b/packages/server/tsconfig.generate.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/generated/**/*.ts"] +} diff --git a/packages/shared/package.json b/packages/shared/package.json index dd0d9fa..78bb416 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@classhelper/shared", - "version": "0.1.0", + "version": "1.0.0", "private": true, "description": "班级小助手 - 三端共享的类型、常量与工具函数", "type": "module", diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 143f82e..0b897d6 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -25,6 +25,8 @@ export const API_PATHS = { imports: '/imports', /** ClassIsland 联动:设备接入、状态上报、通知下发 */ integrations: '/integrations', + /** 数据库管理(仅管理员):状态/备份/导入导出/一键切换 */ + database: '/database', dashboard: '/dashboard/summary', } as const; @@ -476,6 +478,12 @@ export const STORAGE_KEYS = { serverUrl: 'classhelper.serverUrl', cachePrefix: 'classhelper.cache', lastSyncAt: 'classhelper.lastSyncAt', + /** + * 新手引导「已看过」标记(Web 管理端 localStorage)。 + * 值为引导版本号:改版引导内容后版本 +1,看过旧版的老用户会再收到一次新版引导。 + * EXE 客户端不用这个键 —— 它的引导状态在主进程配置(`onboardingDone`)里。 + */ + onboarding: 'classhelper.onboarding', } as const; /** 桌面客户端默认后端地址 */ diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 8c9622b..5cc24ef 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -1017,3 +1017,84 @@ export interface SocketAuthPayload { token: string; clientType?: 'admin' | 'desktop'; } + +/* ================================================================ 数据库管理(仅管理员) */ + +/** 数据库管理支持的提供方(Redis 等键值库不是 Prisma 支持的主库,不在范围内) */ +export type DatabaseProvider = 'sqlite' | 'mysql'; + +/** GET /api/database/status 的返回 */ +export interface DatabaseStatusDto { + provider: DatabaseProvider; + connected: boolean; + /** ping 延迟(毫秒);未连接时为 null */ + latencyMs: number | null; + /** 数据库引擎版本(sqlite_version() / VERSION()) */ + version: string | null; + /** 数据体积(字节);MySQL 取 information_schema 汇总 */ + sizeBytes: number | null; + /** 当前连接串的脱敏展示(MySQL 隐藏密码) */ + databaseUrlMasked: string; + /** SQLite 数据文件绝对路径(MySQL 为空串) */ + sqliteFilePath: string; + /** 数据目录(备份与定时配置所在处) */ + dataDir: string; + backupDir: string; + tables: DatabaseTableCountDto[]; + backups: DatabaseBackupMetaDto[]; + schedule: DatabaseBackupScheduleDto; + /** 是否有正在进行的切换任务(期间禁止备份/导入/恢复) */ + switchRunning: boolean; +} + +export interface DatabaseTableCountDto { + name: string; + /** -1 表示统计失败(表结构与代码不一致时出现) */ + count: number; +} + +export interface DatabaseBackupMetaDto { + name: string; + kind: 'manual' | 'auto'; + sizeBytes: number; + createdAt: string; +} + +export interface DatabaseBackupScheduleDto { + enabled: boolean; + intervalHours: number; + keepCount: number; + lastAutoBackupAt: string | null; +} + +/** POST /api/database/test-connection 的返回 */ +export interface DatabaseConnectionTestDto { + ok: boolean; + provider: DatabaseProvider; + latencyMs: number | null; + version: string | null; + /** 新建 SQLite 库时目标文件还不存在,属正常情况(切换时会自动建库) */ + note?: string; + error?: string; +} + +/** 数据库切换任务(POST /switch 后用 jobId 轮询) */ +export interface DatabaseSwitchJobDto { + id: string; + status: 'running' | 'done' | 'error'; + target: { provider: DatabaseProvider; url: string }; + steps: DatabaseSwitchStepDto[]; + error?: string; + /** 完成后为 true:需要重启服务端才能让新数据库生效 */ + restartRequired: boolean; + counts?: Record; + backupName?: string; + startedAt: string; + finishedAt?: string; +} + +export interface DatabaseSwitchStepDto { + name: string; + status: 'running' | 'done' | 'error'; + detail?: string; +} diff --git a/packages/web-admin/package.json b/packages/web-admin/package.json index 0b24f0b..87d43c5 100644 --- a/packages/web-admin/package.json +++ b/packages/web-admin/package.json @@ -1,6 +1,6 @@ { "name": "@classhelper/web-admin", - "version": "0.1.0", + "version": "1.0.0", "private": true, "description": "班级小助手 Web 管理端(Vue 3 + Vite + Element Plus)", "type": "module", diff --git a/packages/web-admin/src/api/http.ts b/packages/web-admin/src/api/http.ts index 55d82ad..64d1132 100644 --- a/packages/web-admin/src/api/http.ts +++ b/packages/web-admin/src/api/http.ts @@ -62,4 +62,9 @@ export const api = { patch: (url: string, data?: unknown): Promise => request({ method: 'PATCH', url, data }), put: (url: string, data?: unknown): Promise => request({ method: 'PUT', url, data }), delete: (url: string): Promise => request({ method: 'DELETE', url }), + /** + * 下载二进制(返回 Blob,调用方负责触发浏览器保存)。 + * 请求拦截器照常带 token;响应是 Blob 时拦截器原样放行(不含 success 字段)。 + */ + download: (url: string): Promise => request({ method: 'GET', url, responseType: 'blob' }), }; diff --git a/packages/web-admin/src/api/index.ts b/packages/web-admin/src/api/index.ts index 4aff817..00553a4 100644 --- a/packages/web-admin/src/api/index.ts +++ b/packages/web-admin/src/api/index.ts @@ -15,6 +15,11 @@ import { type CreateNotificationRequest, type CreateScheduleRequest, type DashboardSummary, + type DatabaseBackupMetaDto, + type DatabaseBackupScheduleDto, + type DatabaseConnectionTestDto, + type DatabaseStatusDto, + type DatabaseSwitchJobDto, type GradeDto, type GradeStats, type HomeworkDaysDto, @@ -328,3 +333,36 @@ export const integrationApi = { notify: (payload: SendClassIslandNotificationRequest): Promise => api.post(`${API_PATHS.integrations}/classisland/notify`, payload), }; + +/* ------------------------------------------------------------------ 数据库管理(仅管理员) */ + +/** + * 数据库管理:状态 / 连接测试 / 备份 / 导入导出 / 定时备份 / 一键切换。 + * + * 「快照下载」与「数据库文件下载」是二进制流,走 http.ts 的 download()(返回 Blob), + * 由页面负责触发浏览器保存;导入把快照 JSON 转 base64 放进请求体(受 12MB 请求体限制)。 + */ +export const databaseApi = { + status: (): Promise => api.get(`${API_PATHS.database}/status`), + testConnection: (payload: { provider: 'sqlite' | 'mysql'; url: string }): Promise => + api.post(`${API_PATHS.database}/test-connection`, payload), + listBackups: (): Promise => api.get(`${API_PATHS.database}/backups`), + createBackup: (): Promise => api.post(`${API_PATHS.database}/backups`, {}), + restoreBackup: (name: string): Promise<{ counts: Record }> => + api.post(`${API_PATHS.database}/backups/${encodeURIComponent(name)}/restore`, {}), + deleteBackup: (name: string): Promise<{ name: string }> => + api.delete(`${API_PATHS.database}/backups/${encodeURIComponent(name)}`), + importSnapshot: (base64: string): Promise<{ counts: Record }> => + api.post(`${API_PATHS.database}/import`, { data: base64 }), + /** 下载当前库的 JSON 快照(Blob,页面触发浏览器保存) */ + downloadSnapshot: (): Promise => api.download(`${API_PATHS.database}/export`), + /** 下载数据库文件(仅 SQLite 模式;MySQL 无单文件概念) */ + downloadSqliteFile: (): Promise => api.download(`${API_PATHS.database}/sqlite-file`), + getSchedule: (): Promise => api.get(`${API_PATHS.database}/backup-schedule`), + saveSchedule: (payload: DatabaseBackupScheduleDto): Promise => + api.put(`${API_PATHS.database}/backup-schedule`, payload), + startSwitch: (payload: { provider: 'sqlite' | 'mysql'; url: string }): Promise<{ jobId: string }> => + api.post(`${API_PATHS.database}/switch`, payload), + getSwitchJob: (jobId: string): Promise => + api.get(`${API_PATHS.database}/switch/jobs/${encodeURIComponent(jobId)}`), +}; diff --git a/packages/web-admin/src/layouts/AdminLayout.vue b/packages/web-admin/src/layouts/AdminLayout.vue index 9122ffc..297bdcb 100644 --- a/packages/web-admin/src/layouts/AdminLayout.vue +++ b/packages/web-admin/src/layouts/AdminLayout.vue @@ -1,7 +1,8 @@ diff --git a/packages/web-admin/src/router/index.ts b/packages/web-admin/src/router/index.ts index 927024a..8b86589 100644 --- a/packages/web-admin/src/router/index.ts +++ b/packages/web-admin/src/router/index.ts @@ -68,6 +68,13 @@ const routes: RouteRecordRaw[] = [ // 与后端 requireRole('ADMIN','TEACHER') 对齐:学生角色不该看到这一页 meta: { title: 'ClassIsland 联动', icon: 'Connection', roles: ['ADMIN', 'TEACHER'] }, }, + { + path: 'database', + name: 'database', + component: () => import('@/views/DatabaseView.vue'), + // 与后端 requireRole('ADMIN') 对齐:改动数据库是最高危操作,仅管理员 + meta: { title: '数据库管理', icon: 'Coin', roles: ['ADMIN'] }, + }, ], }, { diff --git a/packages/web-admin/src/views/DatabaseView.vue b/packages/web-admin/src/views/DatabaseView.vue new file mode 100644 index 0000000..615e83d --- /dev/null +++ b/packages/web-admin/src/views/DatabaseView.vue @@ -0,0 +1,582 @@ + + + + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c347a4..ecd73c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,6 +96,9 @@ importers: '@prisma/adapter-libsql': specifier: 7.10.0 version: 7.10.0 + '@prisma/adapter-mariadb': + specifier: 7.10.0 + version: 7.10.0 '@prisma/client': specifier: 7.10.0 version: 7.10.0(prisma@7.10.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3))(typescript@5.9.3) @@ -123,9 +126,15 @@ importers: jsonwebtoken: specifier: ^9.0.3 version: 9.0.3 + prisma: + specifier: 7.10.0 + version: 7.10.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3) socket.io: specifier: ^4.8.3 version: 4.8.3 + typescript: + specifier: ~5.9.3 + version: 5.9.3 xlsx: specifier: 0.18.5 version: 0.18.5 @@ -148,18 +157,12 @@ importers: '@types/node': specifier: ^22.20.2 version: 22.20.2 - prisma: - specifier: 7.10.0 - version: 7.10.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3) socket.io-client: specifier: ^4.8.3 version: 4.8.3 tsx: specifier: ^4.23.13 version: 4.23.13 - typescript: - specifier: ~5.9.3 - version: 5.9.3 packages/shared: devDependencies: @@ -646,6 +649,9 @@ packages: '@prisma/adapter-libsql@7.10.0': resolution: {integrity: sha512-ANjUjJ/TrDQhR8jFU8HvYWRdwP17Lox1WL81zZvHcPVeFehZfNomAsPMNY24EBTLLZp0Xw4Na5Zf1S+EuKMKxw==} + '@prisma/adapter-mariadb@7.10.0': + resolution: {integrity: sha512-n/mNi6/l2V79pNwuvzRlZO+X8dr38eqjx1norvXs3t+bTJkSotxwV40HW0SCSl2vBp/YMfX2w6ZHKePgurf1gw==} + '@prisma/client-runtime-utils@7.10.0': resolution: {integrity: sha512-cnCy7lUV8/CctgKVEmqAbSLAmwqJdE/qAlqTBk/0NDk59zEb2cZ0M0M0E4vVPnqbSEYudRroQDvOWfUZH6RIfw==} @@ -1194,6 +1200,10 @@ packages: resolution: {integrity: sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==} engines: {node: '>=10.0.0'} + '@xmldom/xmldom@0.9.12': + resolution: {integrity: sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==} + engines: {node: '>=14.6'} + abbrev@4.0.0: resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} engines: {node: ^20.17.0 || >=22.9.0} @@ -2053,8 +2063,8 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + glob@7.2.0: + resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-agent@3.0.0: @@ -2145,6 +2155,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + iconv-lite@0.7.3: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} @@ -2413,6 +2427,9 @@ packages: resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} engines: {node: '>=8'} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@6.0.0: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} @@ -2428,6 +2445,10 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mariadb@3.4.5: + resolution: {integrity: sha512-gThTYkhIS5rRqkVr+Y0cIdzr+GRqJ9sA2Q34e0yzmyhMCwyApf3OKAC1jnF23aSlIOqJuyaUFUcj7O1qZslmmQ==} + engines: {node: '>= 14'} + matcher@3.0.0: resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} engines: {node: '>=10'} @@ -2684,6 +2705,10 @@ packages: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} + plist@3.1.1: + resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} + engines: {node: '>=10.4.0'} + postcss-selector-parser@7.1.6: resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==} engines: {node: '>=4'} @@ -2987,8 +3012,8 @@ packages: resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} engines: {node: '>=10.2.0'} - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} source-map-support@0.5.21: @@ -3462,7 +3487,7 @@ snapshots: '@electron/asar@3.4.1': dependencies: commander: 5.1.0 - glob: 7.2.3 + glob: 7.2.0 minimatch: 3.1.5 '@electron/fuses@1.8.0': @@ -3513,7 +3538,7 @@ snapshots: fs-extra: 10.1.0 isbinaryfile: 4.0.10 minimist: 1.2.8 - plist: 3.1.0 + plist: 3.1.1 transitivePeerDependencies: - supports-color @@ -3536,7 +3561,7 @@ snapshots: dir-compare: 4.2.0 fs-extra: 11.4.0 minimatch: 9.0.9 - plist: 3.1.0 + plist: 3.1.1 transitivePeerDependencies: - supports-color @@ -3831,6 +3856,11 @@ snapshots: - bufferutil - utf-8-validate + '@prisma/adapter-mariadb@7.10.0': + dependencies: + '@prisma/driver-adapter-utils': 7.10.0 + mariadb: 3.4.5 + '@prisma/client-runtime-utils@7.10.0': {} '@prisma/client@7.10.0(prisma@7.10.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3))(typescript@5.9.3)': @@ -4383,7 +4413,7 @@ snapshots: '@vue/shared': 3.5.42 entities: 7.0.1 estree-walker: 2.0.2 - source-map-js: 1.2.1 + source-map-js: 1.2.2 '@vue/compiler-dom@3.5.42': dependencies: @@ -4400,7 +4430,7 @@ snapshots: estree-walker: 2.0.2 magic-string: 0.30.21 postcss: 8.5.28 - source-map-js: 1.2.1 + source-map-js: 1.2.2 '@vue/compiler-ssr@3.5.42': dependencies: @@ -4469,6 +4499,8 @@ snapshots: '@xmldom/xmldom@0.8.15': {} + '@xmldom/xmldom@0.9.12': {} + abbrev@4.0.0: {} accepts@1.3.8: @@ -5502,7 +5534,7 @@ snapshots: dependencies: is-glob: 4.0.3 - glob@7.2.3: + glob@7.2.0: dependencies: fs.realpath: 1.0.0 inflight: 1.0.6 @@ -5618,6 +5650,10 @@ snapshots: transitivePeerDependencies: - supports-color + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 @@ -5843,6 +5879,8 @@ snapshots: lowercase-keys@2.0.0: {} + lru-cache@10.4.3: {} + lru-cache@6.0.0: dependencies: yallist: 4.0.0 @@ -5857,6 +5895,14 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + mariadb@3.4.5: + dependencies: + '@types/geojson': 7946.0.16 + '@types/node': 24.13.4 + denque: 2.1.0 + iconv-lite: 0.6.3 + lru-cache: 10.4.3 + matcher@3.0.0: dependencies: escape-string-regexp: 4.0.0 @@ -6089,6 +6135,12 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 15.1.1 + plist@3.1.1: + dependencies: + '@xmldom/xmldom': 0.9.12 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + postcss-selector-parser@7.1.6: dependencies: cssesc: 3.0.0 @@ -6098,7 +6150,7 @@ snapshots: dependencies: nanoid: 3.3.19 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 postgres@3.4.7: {} @@ -6243,7 +6295,7 @@ snapshots: rimraf@2.6.3: dependencies: - glob: 7.2.3 + glob: 7.2.0 roarr@2.15.4: dependencies: @@ -6442,7 +6494,7 @@ snapshots: - supports-color - utf-8-validate - source-map-js@1.2.1: {} + source-map-js@1.2.2: {} source-map-support@0.5.21: dependencies: diff --git a/scripts/dist-server.mjs b/scripts/dist-server.mjs index 87c735d..6eb99c4 100644 --- a/scripts/dist-server.mjs +++ b/scripts/dist-server.mjs @@ -156,6 +156,16 @@ function buildRuntime() { path.join(serverDir, 'prisma', 'migrations'), path.join(staging, 'server', 'prisma', 'migrations'), ); + // 数据库管理模块「一键切换」需要的 Prisma 配置三件套: + // schema.prisma —— 切换时改写 provider 后供 prisma generate / db push 使用 + // prisma.config.ts —— Prisma 7 的 CLI 配置(连接串来自子进程环境变量) + // tsconfig.generate.json —— generate 产出的是 .ts,切换后用它编译进 dist/generated + for (const file of ['schema.prisma']) { + fs.copyFileSync(path.join(serverDir, 'prisma', file), path.join(staging, 'server', 'prisma', file)); + } + for (const file of ['prisma.config.ts', 'tsconfig.generate.json']) { + fs.copyFileSync(path.join(serverDir, file), path.join(staging, 'server', file)); + } // 工作区共享包(tsc 产物) const sharedTarget = path.join(staging, 'node_modules', '@classhelper', 'shared'); fs.mkdirSync(sharedTarget, { recursive: true }); diff --git a/scripts/ui-smoke/main.cjs b/scripts/ui-smoke/main.cjs index 1e806f6..8ad9781 100644 --- a/scripts/ui-smoke/main.cjs +++ b/scripts/ui-smoke/main.cjs @@ -44,7 +44,7 @@ const MENU_ITEMS = [ ]; /** 教师端必须隐藏的入口(前端隐藏 + 后端 403,双重保障) */ -const HIDDEN_MENU_LABELS = ['班级管理', '学生管理', '教师管理']; +const HIDDEN_MENU_LABELS = ['班级管理', '学生管理', '教师管理', '数据库管理']; /** 管理员账号(教师录入用例):与种子/安装初始化账号一致 */ const ADMIN_USERNAME = process.env.UI_SMOKE_ADMIN ?? 'admin'; @@ -118,6 +118,54 @@ async function runAdminTeacherChecks(win) { record('管理员登录(教师录入前置)', adminReady, `账号=${ADMIN_USERNAME}`); if (!adminReady) return; + // 1.1) localStorage.clear() 把「已看过引导」的标记也一并清掉 → 管理员登录后引导会再次自动 + // 弹出;先把它跳过再继续,避免遮罩留在屏幕上干扰后续真实点击(跳过即记「已看过」)。 + // 出现/关闭判据与 3.5 相同:等 × 按钮渲染,关闭看 .el-tour__mask 卸载。 + await win.webContents.executeJavaScript(`(async () => { + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const deadline = Date.now() + 8000; + while (Date.now() < deadline && !document.querySelector('.el-tour__closebtn')) await sleep(120); + document.querySelector('.el-tour__closebtn')?.click(); + const closeDeadline = Date.now() + 2000; + while (Date.now() < closeDeadline && document.querySelector('.el-tour__mask')) await sleep(100); + return true; + })()`); + + // 1.2) 数据库管理页(仅管理员):点开菜单 → 状态卡与备份表渲染 + const databasePage = await win.webContents.executeJavaScript(`(async () => { + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const menu = Array.from(document.querySelectorAll('.el-menu-item')).find((node) => + (node.textContent ?? '').trim().startsWith('数据库管理'), + ); + if (!menu) return { ok: false, reason: '管理员菜单里没有「数据库管理」' }; + menu.click(); + const deadline = Date.now() + 6000; + while (Date.now() < deadline && location.pathname !== '/database') await sleep(80); + // 等状态卡与备份表渲染(状态卡含「当前数据库」标题,备份表在 el-table 里) + let hasStatusCard = false; + let hasBackupTable = false; + const tableDeadline = Date.now() + 8000; + while (Date.now() < tableDeadline && !(hasStatusCard && hasBackupTable)) { + hasStatusCard = Boolean(document.querySelector('.page-title')); + hasBackupTable = Boolean(document.querySelector('.el-table')); + await sleep(100); + } + const title = (document.querySelector('.page-title')?.textContent ?? '').trim(); + return { + ok: location.pathname === '/database' && title === '数据库管理' && hasStatusCard && hasBackupTable, + reason: '', + path: location.pathname, + title, + hasStatusCard, + hasBackupTable, + }; + })()`); + record( + '数据库管理页(仅管理员:状态卡与备份表渲染)', + databasePage?.ok === true, + `path=${databasePage?.path ?? '-'} 标题=${databasePage?.title ?? '-'} 状态卡=${databasePage?.hasStatusCard} 表=${databasePage?.hasBackupTable} ${databasePage?.reason ?? ''}`, + ); + // 2) 打开「教师管理」→ 新建教师(表单 → 保存 → 列表出现) const created = await win.webContents.executeJavaScript(`(async () => { const menu = Array.from(document.querySelectorAll('.el-menu-item')).find((node) => @@ -299,6 +347,28 @@ async function main() { return; } + // 3.5 初次登录引导(新功能):首次登录自动弹出聚焦式引导,可跳过; + // 跳过(右上角 ×)后写入「已看过」标记,本次 profile 里不会再自动弹出。 + // 判据说明:el-tour 关闭后内容元素会像 el-dialog 一样残留在 DOM(父级 .el-popper 被隐藏, + // AGENTS §7 第 22 条同款陷阱),所以「出现」等 × 按钮渲染、「关闭」看遮罩 .el-tour__mask 卸载。 + const tour = await win.webContents.executeJavaScript(`(async () => { + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + // 引导在布局挂载后延迟 ~600ms 弹出,且 popper 内容晚于外壳渲染,等 × 按钮真正出现 + const deadline = Date.now() + 10000; + while (Date.now() < deadline && !document.querySelector('.el-tour__closebtn')) await sleep(120); + if (!document.querySelector('.el-tour__closebtn')) return { appeared: false, closed: false }; + const close = document.querySelector('.el-tour__closebtn'); + close.click(); + const closeDeadline = Date.now() + 3000; + while (Date.now() < closeDeadline && document.querySelector('.el-tour__mask')) await sleep(120); + return { appeared: true, closed: !document.querySelector('.el-tour__mask') }; + })()`); + record( + '首次登录展示新手引导且可跳过', + tour?.appeared === true && tour?.closed === true, + `出现=${tour?.appeared ?? '-'} 跳过后关闭=${tour?.closed ?? '-'}`, + ); + // 4. 逐一点击侧边栏菜单 const visited = []; const failures = [];