diff --git a/.github/workflows/release-linux-server.yml b/.github/workflows/release-linux-server.yml new file mode 100644 index 0000000..8e0bdd7 --- /dev/null +++ b/.github/workflows/release-linux-server.yml @@ -0,0 +1,116 @@ +name: 发布 Linux 服务端安装包 + +# 为什么单独有这个 workflow: +# Linux 包的依赖(@libsql/linux-x64-gnu、@prisma/adapter-libsql 等)是平台相关的, +# 在 Windows 开发机上 npm install 出来的 node_modules 装到 Linux 跑不起来, +# 所以 Linux 包必须在 Linux 上构建。这个 workflow 用 ubuntu runner 产出 +# classhelper-server-linux-x64-<版本>.tar.gz 并挂到 GitHub Release 上, +# 服务器上的 `classhelper upgrade` 就是从这里下载 + 校验 sha256 的。 +# +# 用法: +# 1. 先在本地按惯例打出 Windows 包并发 Release(vX.Y.Z); +# 2. 到 Actions 页面手动运行本 workflow,填 tag(例如 v1.1.0); +# 3. 跑完 Release 里会多出 Linux 包与更新后的 SHA256SUMS-<版本>.txt。 +# +# 也可以只跑构建不挂 Release(attach=false),产物在 workflow 的 Artifacts 里。 + +on: + workflow_dispatch: + inputs: + tag: + description: '要构建并挂载的 Release tag(例如 v1.1.0)' + required: true + type: string + attach: + description: '构建后上传到该 Release' + required: false + default: true + type: boolean + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + build-linux-package: + runs-on: ubuntu-22.04 + timeout-minutes: 40 + + steps: + - name: 确定 tag + id: ref + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT" + echo "attach=${{ inputs.attach }}" >> "$GITHUB_OUTPUT" + else + echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + echo "attach=true" >> "$GITHUB_OUTPUT" + fi + + - name: 检出代码 + uses: actions/checkout@v4 + with: + ref: ${{ steps.ref.outputs.tag }} + + - name: 安装 Node 22 + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: 安装 pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.8.0 + run_install: false + + - name: 安装依赖 + run: pnpm install --frozen-lockfile + + - name: 构建 Linux 安装包 + run: pnpm dist:server:linux + + - name: 展示产物 + run: ls -la release-server/ + + - name: 上传构建产物(Artifacts) + uses: actions/upload-artifact@v4 + with: + name: classhelper-server-linux-x64 + path: | + release-server/classhelper-server-linux-x64-*.tar.gz + release-server/classhelper-server-linux-x64-*.tar.gz.sha256 + if-no-files-found: error + retention-days: 30 + + - name: 挂到 Release 并更新 SHA256SUMS + if: steps.ref.outputs.attach == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.ref.outputs.tag }} + run: | + set -euo pipefail + VERSION="${TAG#v}" + TARBALL="release-server/classhelper-server-linux-x64-${VERSION}.tar.gz" + SUMS="release-server/SHA256SUMS-${VERSION}.txt" + + if [ ! -f "$TARBALL" ]; then + echo "::error::没有找到 $TARBALL(tag 与 package.json 的版本号对得上吗?)" + exit 1 + fi + + # 取回已有的校验清单(Windows 包那几行),把本次的 Linux 包追加/替换进去 + gh release download "$TAG" --pattern "SHA256SUMS-*.txt" --dir release-server --clobber || true + DOWNLOADED="$(ls release-server/SHA256SUMS-*.txt 2>/dev/null | head -n 1 || true)" + if [ -n "$DOWNLOADED" ] && [ "$DOWNLOADED" != "$SUMS" ]; then mv "$DOWNLOADED" "$SUMS"; fi + grep -v "classhelper-server-linux-x64-${VERSION}.tar.gz" "$SUMS" 2>/dev/null > "${SUMS}.tmp" || true + cat "${SUMS}.tmp" > "$SUMS" 2>/dev/null || true + rm -f "${SUMS}.tmp" + sha256sum "$TARBALL" | awk '{print $1" "$2}' | sed "s#release-server/##" >> "$SUMS" + sort -u -k2 "$SUMS" -o "$SUMS" + + gh release upload "$TAG" "$TARBALL" "$SUMS" --clobber + echo "已上传:" + cat "$SUMS" diff --git a/AGENTS.md b/AGENTS.md index 225260b..5265429 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,11 +55,18 @@ scripts/ dist-server.mjs 服务端 + Web 管理端打包(产物落在 release-server/) nsis/server-installer.nsi 服务端 NSIS 安装脚本 verify-packaged.mjs 对**打包后/已安装**的客户端 EXE 复跑冒烟 + check-icons.mjs 图标注册表静态门禁(漏注册会静默渲染空白,见 §5 第 53 条) ui-smoke/{run,main.cjs,live-probe.mjs} Web 管理端真实点击回归(Electron 驱动) lib/{electron-env,node-runtime}.mjs 启动 Electron / 定位真实 node.exe -deploy/ Dockerfile / docker-compose{,.sqlite}.yml / nginx.conf / install-linux.sh - classhelper.service / package.runtime.json / .env{,.sqlite}.example -docs/ production.md(生产部署)/ mysql.md(MySQL 切换)/ winisland-design-tokens.md +deploy/ Dockerfile / docker-compose{,.sqlite}.yml / nginx.conf / package.runtime.json + install.sh(Linux 一键安装器,TUI)/ install-linux.sh(转发壳) + classhelper(运维命令本体)/ verify-linux.sh(Linux 验收,自统计 N/N) + tools/admin-cli.mjs(离线改密 / SQLite 一致性快照) + profile.d/ logrotate.d/ systemd/(装到 /etc 的模板) + Dockerfile.linux-package(安装包内那份,--mode docker 用) + classhelper.service(systemd 模板,5 个占位符)/ .env{,.sqlite}.example +docs/ production.md(生产部署)/ linux-deploy.md(Linux 部署与运维) + mysql.md(MySQL 切换)/ winisland-design-tokens.md screenshots/{island,client,web-mobile,classisland}/ packages/shared/src/ types.ts / constants.ts / permissions.ts / utils.ts / index.ts packages/server/ @@ -69,27 +76,27 @@ packages/server/ homework_assign_date / push_kind / schedule_source) prisma/seed.ts 种子数据(会清空业务表后重建演示数据) prisma.config.ts Prisma 7 配置:schema / migrations / seed / 连接串 - scripts/verify-e2e.mjs 后端端到端验收(条数由脚本统计,实测 173 项) + scripts/verify-e2e.mjs 后端端到端验收(条数由脚本统计,2026-10-05 实测 190 项) scripts/verify-classisland.mjs 联动链路验收(实测 42 项) scripts/apply-column-migrations.cjs 旧库补列(手工覆盖 dist 部署时用) src/app.ts Express 装配(探针 → 限流 → 模块挂载 → 静态托管 → 兜底) src/index.ts 启动入口(自检 + HTTP + Socket.IO + 优雅退出) src/config/env.ts zod 环境变量校验 + 生产自检 src/lib/ access(RBAC) class-account db db-bootstrap http jwt logger mappers - password schemas session term web-static + password schemas session term version(读 package.json) web-static src/middleware/ auth validate error security src/realtime/ socket.ts(房间)+ bus.ts(事件总线) - src/modules/ 13 个功能模块 + registry.ts + module.types.ts -packages/web-admin/src/ api stores router layouts views(11) components(4) composables styles config.ts + src/modules/ 15 个功能模块 + registry.ts + module.types.ts +packages/web-admin/src/ api stores router layouts views(12) components(5) composables styles config.ts packages/desktop-client/ - src/main/ 主进程:index / config(含作业短语与看板偏好)/ ipc / island / tray / logger / smoke + src/main/ 主进程:index / config(含作业短语与看板偏好)/ ipc / island / tray / update / logger / smoke src/preload/ contextBridge 白名单桥(index.ts + island.ts,**不暴露 ipcRenderer 本体**) src/island/ 灵动岛渲染进程:IslandApp.vue / spring.ts / squircle.ts / main.ts src/renderer/ 学生端渲染进程:api / cache(IndexedDB) / stores / views / island / router 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=1.0.0.0 + manifest.yml 清单:id=classhelper.classisland.bridge / apiVersion=2.0.0.0 / version=1.1.0.0 ClassHelper.ClassIslandPlugin.csproj(TargetFramework=net8.0) src/Plugin.cs 入口:读配置 → 注册提醒提供方 / 设置页 / BridgeService src/Models/PluginSettings.cs @@ -174,6 +181,7 @@ pnpm build:classisland-plugin | `pnpm dist:classisland-plugin` | 打包插件为 `.cipx` 并归集到 `releases/classisland-plugin/` | | `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) | | `pnpm lint` / `pnpm lint:fix` | ESLint | +| `pnpm check:icons` | 图标注册表门禁(两端 build 已内联,这里可单独跑;见 §5 第 53 条) | | `pnpm format` / `pnpm format:check` | Prettier(`format:check` 在 master 基线上本就失败,见 §7 第 21 条) | | `pnpm db:generate` | 生成 Prisma Client | | `pnpm db:migrate` | `prisma migrate dev`(本机不可靠,别用;见 §7) | @@ -181,13 +189,14 @@ pnpm build:classisland-plugin | `pnpm db:seed` / `pnpm db:reset` | 写种子 / 重置并重播种子 | | `pnpm db:studio` | Prisma Studio | | `pnpm db:switch:mysql` / `db:switch:sqlite` | 改写 `schema.prisma` 的 provider(配合 `docs/mysql.md`) | -| `pnpm verify:e2e` | 后端端到端验收(**需后端已启动**;2026-09-30 实测 173 项全过) | +| `pnpm verify:e2e` | 后端端到端验收(**需后端已启动**;2026-10-05 实测 190 项全过) | | `pnpm verify:web` | Web 管理端真实点击回归(Electron 驱动,需后端已启动且 Web 产物已构建) | | `pnpm verify:desktop` | 客户端冒烟(Electron,无人工点击) | | `pnpm verify:packaged` | 对**打包后/已安装**的客户端 EXE 跑同一套冒烟(`--exe` 指定路径) | | `pnpm verify:classisland` | ClassIsland 联动链路(设备令牌 / 上报 / 提醒下发与回执 / 镜像契约 / 幽灵行清理;实测 42 项) | | `pnpm verify:classisland-plugin` | 插件静态契约校验(清单一致性 / 注册完整性 / C# DTO ↔ 服务端 zod 与路由 / 已修复坑的护栏;实测 68 项) | | `pnpm dist:server` / `dist:win` / `dist:dir` / `dist:all` | 打包服务端安装程序 / 客户端安装包 / 免安装目录 / 全部 | +| `pnpm dist:server:linux` | 打包 **Linux 服务端安装包**(`classhelper-server-linux-x64-<版本>.tar.gz` + `.sha256`)—— 见 §5 第 59 条 | | `pnpm icons` | 生成应用图标(Electron 渲染 SVG → PNG/ICO) | ### 端口 @@ -204,6 +213,7 @@ pnpm build:classisland-plugin ``` release-server/ pnpm dist:server 的输出(免安装目录 + NSIS 安装程序) + pnpm dist:server:linux 的输出(classhelper-server-linux-x64-<版本>.tar.gz + .sha256) packages/desktop-client/release/ pnpm dist:win / dist:dir 的输出(win-unpacked + setup.exe + portable.exe) releases/ 本仓库约定的归集处(把上面的产物拷进 client/ server/ classisland-plugin/) ``` @@ -214,6 +224,7 @@ node packages/desktop-client/scripts/dist-win.mjs -c.directories.output=<绝对 # 服务端:输出路径写死在 release-server/,打完自行移进 releases/server/ pnpm dist:server # 首次会跑一次 npm install;重试可加 --reuse-deps 跳过 +pnpm dist:server:linux # Linux 安装包(**必须在 Linux 上跑**,见 §5 第 59 条 ④) # ClassIsland 插件:构建脚本自己归集到 releases/classisland-plugin/ pnpm dist:classisland-plugin @@ -224,8 +235,9 @@ pnpm dist:classisland-plugin ## 5. 架构硬约定(改代码前必须知道) 1. **模块注册表是唯一入口。** 服务端每个功能是 `src/modules//`(`*.module.ts` 路由、 - `*.schemas.ts` zod 校验、`*.service.ts` 业务)。当前 13 个:`auth / classes / courses / schedules / -homeworks / notifications / calls / imports / integrations / grades / students / teachers / dashboard`。 + `*.schemas.ts` zod 校验、`*.service.ts` 业务)。当前 15 个:`auth / classes / courses / schedules / +homeworks / notifications / calls / imports / integrations / grades / students / teachers / dashboard / +database / update`。 新增/下线功能**只改 `src/modules/registry.ts`**(加一行 / 改 `enabled: false`),不要动 `app.ts`。 2. **统一响应与校验。** 用 `src/lib/http.ts` 的 `sendOk / sendCreated / …` 返回,用 `middleware/validate.ts` 挂 zod schema;错误交给 `middleware/error.ts`,不要各写一套。 @@ -360,7 +372,10 @@ homeworks / notifications / calls / imports / integrations / grades / students / 主进程把窗口**贴合岛体**(`windowBox` = 当前形态 + 2×阴影留白)并**始终接收输入** (`setInteractive` 在触摸模式下把入参强制为 true)。三条护栏别动: -- **变小要等岛真的收小**(`cardFitsFormSize` + 渲染进程上报的岛体矩形)—— 立刻缩小会把形变中的卡片裁掉; +- **窗口可见期间只增不减**:`syncTouchLayout()` 里 `if (shrinking && this.win.isVisible()) return;` + —— 需要变小一律等窗口隐藏之后再做(`syncWindow()` 的"不可见时对齐包围盒"那条)。立刻缩小既会裁掉 + 形变中的卡片,又会因为 DWM 还没重画桌面而留下上一帧(用户拍照反馈过"收回一瞬间闪一下"); + (注:`cardFitsFormSize()` 这个"按岛体矩形判断能不能缩"的旧判据**已无调用**,别再照它理解现状。) - `expectedHitRect` 必须按**窗口实际尺寸**算偏移(触摸模式下窗口不再是固定包围盒); - 失焦宽限期用 `BLUR_GRACE_TOUCH_MS`(1200ms):触摸没有光标,`isCursorOnIsland()` 恒为 false, 而实测假失焦出现在 70~520ms(跨过 500ms 边界 ⇒ 会把刚展开的岛缩回胶囊)。 @@ -427,16 +442,17 @@ homeworks / notifications / calls / imports / integrations / grades / students / 回执拿到 id 后再补记一次,投递前用 id / 指纹双重判定。改作业录入入口时别忘了在请求前调 `markHomeworkCreatedLocally()`。回归用例:「本机录入的作业不再上灵动岛」。 -36. **触摸模式下收回动画期间,窗口必须一动不动**(`syncTouchLayout()` 开头 `if (this.closing) return;`)。 +36. **触摸模式下收回动画期间,窗口必须一动不动**(窗口可见期间一律不缩窗口,见 `syncTouchLayout()`)。 触摸模式窗口是"贴合岛体"的,而收回时卡片正在缩小:若跟着缩窗口,**中心停靠下窗口 x 还要右移**, "让出去"的那块区域会留着上一帧(DWM 还没重画桌面)——同一帧里能看到"旧展开卡的左半截 + 新胶囊" 两份画面(用户拍照反馈的"收回一瞬间闪一下")。窗口尺寸一律等**隐藏之后**再对齐 (`syncWindow` 里"不可见时对齐包围盒"那条,不可见时改尺寸不会有残影)。 回归用例:「触摸模式收回:动画期间窗口不动」逐帧采样窗口矩形(只在"窗口可见 / 正在收回"时追究, 收完隐藏后对齐尺寸是正常的);「收回动画期间卡片不被窗口裁切」。 - 另:`cardFitsFormSize()` 的判据里**不能**再塞 `closing` —— 那条早退已经把收回整个排除了, - 早先漏掉这一条时窗口会在卡片还很大时就缩成胶囊尺寸,卡片底部被平切("上半剩圆角、下半截断"); - 而"隐藏态改边距不生效"那条修法仍要保留:`this.win.isVisible() && (mode !== 'hidden' || idleSliver)`。 + 另:**别再去代码里找 `closing` 或 `cardFitsFormSize()`** —— 自研的那层"收回快照"(§5 第 34 条已说明 + 整体删除)与"按岛体矩形判断能否缩小"这个旧判据都已不在调用链上,最终判据只有上面那条 + `shrinking && isVisible` 早退。"隐藏态改边距不生效"则是由 `setAppearance()` 无条件 `relayout()` + 加 `applyWindowLayout()` 的"矩形没变就不调 setBounds"守卫保证的,同样不需要额外的可见性判断。 37. **开合时胶囊内容禁止跟着卡片重排/横移。** 卡片在 268 ⇄ 424 之间变宽变窄,中心停靠下左右边都在动; 胶囊内容若按卡片宽度布局,就会出现"文字先按宽卡片铺开、再随卡片收窄被省略号收回" @@ -537,16 +553,44 @@ homeworks / notifications / calls / imports / integrations / grades / students / **`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 同款,也让小窗口侧栏不溢出); +49. **侧栏是"一级项 + 一个设置分组",折叠后是图标导轨 —— 五条硬要求**,都是用户报过的问题固化的: + ① **菜单结构**:课表 / 作业 / 通知 / 成绩是**一级 `el-menu-item`**(2026-10-05 按用户要求把原来的 + 「学习」分组拆掉:分组标题白占一行、还得多点一次展开,折叠成导轨时尤其明显);只有「设置」 + 保留 `el-sub-menu`(6 个子项平铺太长)。`layoutNavigationSelfTest` 里有断言盯着 + "一级项不能还挂在任何 `.el-sub-menu` 里"。 + ② **折叠是 CSS 收拢动画,不用 `el-menu` 的 `:collapse`** —— 那个 prop 会把每个分组的子项 + 卸载进浮层(一次结构切换)并让整条菜单重排一遍:既做不出连续形变,也是"点汉堡卡一下"的渲染侧来源。 + 现在折叠只做三件事:栏宽(`.aside` 的 `width` 过渡)、文字(`min-width:0` 让 flex 压得动 + + `overflow:hidden` 裁切 + `opacity` 淡出)、图标位置(`padding-left` 过渡)。**全程没有结构变化、 + 没有 `v-if` 增删**,导轨里分组标题与子项都以图标列出(**不再有悬停浮层**)。 + 时长/缓动/图标-文字间距统一在 `styles/index.css` 的 `--nav-dur` / `--nav-ease` / `--nav-gap`。 + ③ **折叠态图标必须与顶部汉堡按钮同一竖列**(冒烟断言中心差 ≤ 2px,实测 11 个图标全 0.0px)。 + 几何:64px 栏、菜单项左右各 8px margin、折叠后 padding 左右各 12px ⇒ 图标中心 = 8+12+12 = 32px + = 64/2;汉堡靠 `padding-left: 18px` 位移到位(**不能用 `justify-content: center`**,它不可过渡)。 + ④ **导轨必须"一眼看全",不许出现滚动**(用户要求"不要用滚轮、删掉滚动条"): + - 折叠态页脚要**把高度也收掉**(`.is-collapsed .aside-footer` 的 `max-height: 0` + + **`min-height: 0`** —— flex 子项默认 `min-height: auto`,而 CSS 里 min-height 优先于 max-height, + 不显式归零就压不下去)。留着那 ~70px 的话,11 行图标(462px)在 540px 的最小窗口里放不下。 + - 导轨里用 `.is-collapsed .menu { scrollbar-width: none }` 隐藏滚动条;展开态**不隐藏** —— + 那一栏确实会超(设置组展开约 606px > 582px),滚动条是"下面还有内容"的唯一提示。 + - 冒烟断言「导轨无溢出」(先展开设置组造出 11 行的最坏情况,再断言 `scrollHeight ≤ clientHeight`)。 + ⑤ **折叠态文字不能"看得见"** —— 判据已从"没被布局"(老的 `display:none`)改成"看得见" + (有宽度且未透明):现在靠 `overflow:hidden` 裁切,文字仍参与布局。冒烟断言「文字残影=无」。 + (另:菜单区仍要 `min-height: 0` + `overflow-y: auto`,footer 是 aside 的兄弟 flex 子项, + 菜单区滚动后它恒定可见 —— 冒烟断言「侧栏底部可见」。) + + 改折叠相关样式时的两个坑: + - **压 Element Plus 的层级缩进必须照抄它的选择器形状**:EP 用**三个不同选择器**分别算 + 「一级项 20px / 分组标题 20px / 分组内子项 40px」(特异度 0-4-0 / 0-4-0 / 0-5-0): + `.el-menu--vertical:not(.el-menu--collapse):not(.el-menu--popup-container) .el-menu-item` + (把 `…__title`、以及多加一层 `.el-sub-menu` 的变体都算上)。写成 + `.ch-nav.is-collapsed .el-sub-menu__title` 这种 0-3-0 的一条都压不住 —— **踩过两次**: + 第一版漏了分组标题(偏 8.0px),把「学习」拆成一级项后又漏了一级项(同样偏 8.0px)。 + 现在 `index.css` 里那两条(0-5-0 / 0-6-0)把三种形状全覆盖。 + - 图标与文字的间距放在**文字的 `margin-left`** 上,别用 EP 给图标挂的 `margin-right: 5px` + (那会让图标在 24px 的槽里偏左 2.5px)。 + + 另:分组**默认只展开当前路由所在分组**(落在四个一级项上时一个都不展开); `el-sub-menu__title` 的点击是 **toggle**,自动化里要先判"子项是否可见"再决定点不点标题 (盲点会把已展开的组收起来,后续点不到子项)。 @@ -563,6 +607,165 @@ homeworks / notifications / calls / imports / integrations / grades / students / 处理方式是用环境变量传实际凭据(脚本原生支持,**不要去改数据**): `set "ELECTRON_SMOKE_USER=teacher1" && set "ELECTRON_SMOKE_PASSWORD=123456" && pnpm verify:desktop`。 + **注意两个演示账号的口令不一定一起漂**(2026-10-04 实测):本机 `teacher1 = 123456`、 + `teacher2` **仍是 `teacher123`**。因此 `verify:e2e`(口令硬编码、不支持环境变量)不能简单地 + 全局替换 `teacher123` —— 那样 teacher2 会 401,直接挂掉 9 条科任老师用例。 + 正确做法是**只替换 teacher1 那一行**的临时副本,跑完即删: + + ```bash + sed "/username: 'teacher1'/s/'teacher123'/'123456'/" packages/server/scripts/verify-e2e.mjs \ + > packages/server/scripts/.verify-e2e-local.tmp.mjs + node packages/server/scripts/.verify-e2e-local.tmp.mjs # 副本必须放在 scripts/ 内 + rm packages/server/scripts/.verify-e2e-local.tmp.mjs + ``` + + 副本**必须放在 `packages/server/scripts/` 里**(不能放 `.cache/`):脚本要 `import 'socket.io-client'`, + 换个目录就 `ERR_MODULE_NOT_FOUND`。 + +52. **更新检查:三端共用一套版本口径,但检查路径与"失败即正常"是硬约束。** + ① **版本号单一来源**:服务端版本由 `lib/version.ts` 读 package.json 得到 + (打包后读的是运行时清单,即 `dist-server.mjs` 用根 package.json 写的那个), + `app.ts` 里**不要再写字面量版本号**(原先有三处 `'1.0.0'`,而仓库里有 5 份 package.json,必漂)。 + 比较逻辑放 `@classhelper/shared` 的 `compareVersions` / `isNewerVersion`,三端共用。 + ② **检查路径不对称是 CSP 逼的,不是随手选的**:Web 端**不能**直连 GitHub + (`securityHeaders()` 的 `connect-src` 只有 `'self'` / ws / CORS_ORIGIN),必须走 + `GET /api/update/check`;客户端在主进程直连 GitHub(渲染进程的 CSP 与导航防线不要为这个开洞)。 + ③ **`ok:false` 是设计内的结果**:机房与教室机器常常只有内网,离线/超时/限流一律收敛成 + `ok:false` + 一句人话,**失败结果同样进缓存**(否则断网点一次按钮就白等一个超时)。 + ④ **自动化里绝不查外网**:冒烟跳过启动自动检查,`verify:*` 的断言只验"结构完整 + 能降级", + 断言"一定有新版本"会把用例绑死在外网可达上。`?force=1` 绕过缓存**只对管理员生效**, + 否则任意登录用户都能刷爆 GitHub 匿名限流(60 次/小时/IP)。 + +53. **Element Plus 图标按需注册,漏注册有构建门禁。** 两端都不再 `import * as ElementPlusIconsVue` + 全量注册 293 个图标(实测全量打包 minified 203KB,实际只用 22 / 33 个),而是各有一份 + `packages/web-admin/src/icons.ts` 与 `packages/desktop-client/src/renderer/icons.ts` 显式列名单。 + **不能改成"模板里逐个 import"**:本项目的图标是按**字符串名**引用的(菜单/路由配置里的 + `icon: 'Odometer'`、`:icon="'Search'"`、``),Element Plus 会用 + `` 去解析**全局组件名**,图标必须以全局组件存在。 + 漏注册的后果是**界面静默渲染成空白**(只在控制台一条 warning),所以在构建前加了静态门禁 + `scripts/check-icons.mjs`(已挂进两端 `build` 脚本,根上也可 `pnpm check:icons`): + 它对比"源码里用到的图标 ∪"与"注册表里的名单",有遗漏即 exit 1 并指出出现位置。 + 新增视图时直接用图标名即可,忘了加进注册表会在构建阶段被拦下。 + +54. **服务端 dist 是运行产物,不带声明与 sourcemap。** `packages/server/tsconfig.build.json` 与 + `tsconfig.generate.json` 都显式关掉 `declaration / declarationMap / sourceMap` 并开 `removeComments` + —— 根 `tsconfig.base.json` 默认是给"库"用的(三个都开),照搬到服务端会让 dist 从约 0.7MB + 膨胀到 3.8MB(其中 `.d.ts` 1.5MB + `.d.ts.map` 1.1MB + `.js.map` 0.5MB,全是运行时用不到的)。 + 需要 sourcemap 调试时用 `tsconfig.json`(`noEmit`)即可。 + **`tsconfig.generate.json` 还必须是自包含的(不 extends 任何配置)**:它随包发布到安装目录的 + `server/` 一层,而 `tsconfig.json` / `tsconfig.base.json` 两级基配置都不在包里 —— 一旦 extends + 就会在打包形态下报 `TS5083 Cannot read file .../server/tsconfig.json`,数据库「一键切换」 + 走到"编译新客户端"这一步直接失败(2026-10-05 实测踩到并修掉)。 + +55. **服务端安装包的体积靠"运行时裁剪"压,规则写在 `scripts/dist-server.mjs` 的 `pruneRuntime()`。** + 打包顺序是 `installDependencies() → buildRuntime() → pruneRuntime() → buildInstaller()`, + 裁剪在最后一份文件就位、打包之前做。当前四条规则与收益(实测删 2329 项 / 省 101MB): + + | 规则 | 省 | 依据(改之前先看这里) | + | ---------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------- | + | `@prisma/client/runtime` 白名单,只留 sqlite/mysql 的 ESM 方言包 | 61.4MB | 生成的客户端按 **provider 写死**动态 import(`internal/class.ts`),换库重新 generate 只会指向对应方言;`_small_bg` 与 CJS 版永不加载 | + | `node_modules/**/*.map` | 19.0MB | Node 没开 `--enable-source-maps` | + | `prisma/build/` 只留 sqlite/mysql 查询编译器 | 15.0MB | 部署形态里 CLI 只被用于 `generate` 与 `db push` | + | 文档(保留 LICENSE/NOTICE)与测试/示例目录 | 5.2MB | 不会被 require | + + **两条硬护栏(都踩过)**:① 整棵 `@types/**` 不参与裁剪 —— 类型包里的 `test/`、`docs/` 是真实声明 + 文件(`@types/node/test/reporters.d.ts` 被 `test.d.ts` 引用),按"测试目录"删掉会让数据库切换的 + `tsc` 报 TS6053;② 名字命中 `DROP_DIRS` 的目录,只要里面含 `*.d.ts` 就跳过。 + 改完必须按下面的清单复验(尤其数据库管理模块,它是唯一会调 Prisma CLI 的地方)。 + +56. **`verify:classisland` 开头会先"抽干"本班的历史积压提醒。** `/integrations/classisland/pending` + 是「本班全部未确认提醒,按时间升序取**前 20 条**」,而 `verify:desktop` / `verify:web` 等冒烟会 + **真的往演示班发通知**(见 §5 第 38 条)——那些条没被确认就会一直堆着,攒够 20 条之后 + 本脚本刚发的提醒会被挤出窗口,表现为「待提醒里找不到刚发的那条」,跟代码对不对毫无关系 + (2026-10-05 实测:库里积了 45 条历次冒烟残条,本脚本从 42/42 掉到 35/40)。 + 现在脚本用 `drainPending()` 按**真实插件重连后的行为**(补弹并逐条确认)先清空,再开始断言, + 可见范围与插件一致(只看 `deviceId` 为空的本班广播 + 本设备自己的推送)。 + **别把这个 drain 删掉**,否则这个门禁会重新变成"跑几次就红"的随机失败。 + +57. **桌面端配置(`config.json`)是"常驻内存 + 异步合并落盘",别再当成同步写。** + `main/config.ts` 里配置**只读一次盘**(懒初始化),`getConfig()` / `saveConfig()` 之后一律走内存缓存 + (连已解密的 token 一起缓存,不再每次过一遍 DPAPI);落盘改成"记下待写 → 120ms 合并窗口 → + `fs.promises.writeFile`",同一拍内的多次改动只写最后一次,多次写入之间串行、不会互相覆盖; + 退出前由 `flushConfigSync()`(挂在 `main/index.ts` 的 `shutdownResources()` 里)同步兜底写一次。 + + **为什么必须这样**:侧栏折叠、主题切换、通知渠道、作业看板偏好、登录/登出**全都走 `saveConfig`**, + 而它原先一次调用要在**主进程主线程**上做「`readFileSync` + `JSON.parse` + 8 个 normalize」**两遍** + (开头读一遍、结尾 `getConfig()` 又读一遍)+ `writeFileSync` + DPAPI 解密一遍 —— 主进程是 Electron 的 + 消息泵进程,被它摁住的那一拍渲染进程也跟着掉帧。用户反馈的「点侧栏汉堡展开/收回时卡一下」 + 就是这一拍(2026-10-05 定位并修掉;当时先误查到灵动岛,那块与它无关)。 + + 三条硬约束: + ① **缓存必须懒初始化**:`main/index.ts` 会先 `app.setPath('userData', smokeProfile)` 再读配置, + 在模块顶层立即读盘会读错文件; + ② **`saveConfig()` 返回时数据可能还没落盘**(最多晚 120ms)。要"读回刚存的值"一律用 `getConfig()` + (读的就是缓存里的最新值),别去读文件;正常退出(托盘 → 退出)会 flush,只有进程被强杀才会丢 + 最后 120ms 内的改动; + ③ 别把落盘写成"每次保存挂一个 promise 链"—— 拖滑块/连点会排队几十次写。`clearConfig()`(退出登录) + 走 `immediate` 分支,不参与合并窗口。 + +58. **顶栏右侧(主题切换按钮 + 用户名下拉)必须是 flex 容器。** Element Plus 给 `.el-button` 的是 + `vertical-align: middle`、给 `.el-dropdown` 的是 `vertical-align: top` —— 两个 inline-level + 子元素放在普通 block 容器里会各按各的垂直对齐规则落位,错开几像素(用户反馈的"**深色模式没对齐**", + 其实两种主题下都偏,只是深色下更显眼)。`.header-right` 必须写 + `display:flex; align-items:center; gap`,与 Web 管理端 `AdminLayout` 的同名容器同一套写法 + (那边一开始就是对的,桌面端当初漏了这条规则)。回归断言在 `layoutChromeSelfTest` 里: + 量主题按钮与 `.user-chip` 的**中线差**,要求 ≤ 1.5px(实测 0.0px)。 + +59. **Linux 交付链有六条硬约定**(`deploy/install.sh`、`deploy/classhelper`、`scripts/dist-server.mjs` + 的 `--platform linux`、`deploy/verify-linux.sh`)。改这条链路前先读这一节,每条都对应一个踩过的坑: + + ① **配置真身在 `/etc/classhelper/config.env`,安装目录里的 `.env` 必须是指向它的软链。** + Web 端「数据库管理 → 一键切换」在运行中改写的是 **`<安装目录>/.env`**(`database.service.ts`), + 而 systemd 是通过 `EnvironmentFile=` 注入同一份配置的;dotenv 不覆盖已存在的环境变量, + 所以两处一旦不是同一个文件,切换就会**看起来成功、重启后又连回旧库**(极难排查)。 + 权限同时满足"配置文件 600 / 配置目录 700"与服务进程可读写(属主给 `classhelper`)。 + `classhelper doctor` 里有专门一条断言盯着这条软链。 + + ② **systemd 单元的 `ReadWritePaths` 必须同时包含安装目录与配置目录。** + 服务端不是只写 `data/`:一键切库会改写 `server/prisma/schema.prisma`、把新客户端编译进 + `server/dist/generated`,并改写 `config.env`。`ProtectSystem=full` 下不显式放行就会失败。 + 占位符有 5 个:`__NODE__ __DIR__ __USER__ __CONFIG__ __CONFIGDIR__`(少替换一个, + systemd 会拿字面量当路径)。 + + ③ **升级/回滚替换程序文件必须用"改名 + 就位",不能 `rm -rf` 再 `cp`。** + 升级时正在运行的就是 `bin/classhelper` 自己:先 `mv bin bin.old-…` 再放新目录, + 运行中的进程继续读旧 inode;直接删掉同名文件会让 bash 读到半个脚本(报莫名其妙的语法错误)。 + + ④ **Linux 包只能在 Linux 上构建,产物名固定。** `@libsql/linux-x64-gnu`、`@prisma/adapter-libsql` + 是平台相关依赖,Windows 上装的 `node_modules` 拷过去跑不起来 ⇒ 由 + `.github/workflows/release-linux-server.yml`(ubuntu runner)出包。命名约定 + `classhelper-server-linux--<版本>.tar.gz` + 同名 `.sha256`(旁边那份、以及 Release 里的 + `SHA256SUMS-<版本>.txt`),`classhelper upgrade` 按这个名字拼下载地址,**改名即断升级链路**。 + 非 Linux 上 `--platform linux` 也能跑(会加 npm 的 `--os=linux --cpu=x64` 按目标平台解析依赖), + 但那是**应急**,已在 2026-10-05 实测核对过包内容:`@libsql/linux-x64-gnu`/`-musl` 就位、无 win32 残留、 + 服务端与全部运维命令可用;**只有 Web 端「一键切换数据库」存疑** —— 那条链路要 prisma CLI 的原生 + schema engine,CLI 运行期按平台扫 `@prisma/engines/schema-engine-debian-openssl-3.0.x` 这类路径, + 而交叉包里只有 `schema-engine-windows.exe`。交叉构建会写入 `.cross-built` 标记, + `classhelper doctor` 据此提示(别改成静默),`upgrade` 替换程序文件时会按新包有无该标记来增删。 + 正式产物仍走 CI。 + + ⑤ **密码永远走 stdin,命令行一律不接受密码;调用服务端模块的工具必须压掉它的 stdout 噪声。** + `classhelper password` 用 `read -s` 优先读 `/dev/tty`、无 tty 回退 stdin(这样既能交互, + 也能被验收脚本用管道喂 —— 都不进 history/ps)。输入循环**必须有界**:stdin 结束时 `read` + 返回非 0,不看这个标记就会在 EOF 上死循环(`verify-linux.sh` 里有一条"不合规时不卡死"的回归)。 + 写配置(`config set`)同理:值经临时文件传给 `awk`,不进 argv;敏感键强制交互输入, + `key rotate` 自己生成的密钥走内部写函数。另外 `tools/admin-cli.mjs` 复用了服务端的 + `config/env.ts` 与 `lib/logger.ts`,**这两个模块会往 stdout 打日志**(dotenv 的 + `◇ injected env …` 提示、Prisma 初始化一行)—— 调它时必须带上 + `LOG_LEVEL=error DOTENV_CONFIG_QUIET=true`,否则 `dump-hash` 写出的"原哈希备份"里会多一行日志, + 变成非法 JSON,**改密失败自动回滚整条链路静默失效**(dotenv 不覆盖已有环境变量,所以这两个 + 变量能生效,已实测)。 + + ⑥ **内置 Node 要按 glibc 选构建。** Node 官方 linux-x64 二进制要求 glibc ≥ 2.28, + CentOS 7 是 2.17 —— 装上去的表现为"服务起来就退出、日志什么都没有"。安装器检测 glibc, + 低于 2.28 时自动改用 unofficial-builds 的 `-glibc-217` 变体。CentOS 7 仍属尽力而为。 + + 两条写这类脚本时的通用坑(本仓库踩过):**`set -e` 下 `[ cond ] && 可能失败的命令` 会 + 在条件为假时……其实不会退出**(bash 对 `&&` 列表里非最后一条失败是豁免的),但 + `A && B` 里 B 失败、以及**用 `$(函数)` 捕获输出时把日志一起捕获**(所以安装器用全局 + `STAGE_DIR` 而不是 `echo` 返回路径)这两类是实打实的 bug;`die` 要先 `trap - ERR` 再退出, + 免得 ERR 钩子再补一句误导的"第 N 行执行失败"。 + --- ## 6. 代码风格 @@ -578,6 +781,14 @@ homeworks / notifications / calls / imports / integrations / grades / students / - **提交信息**:Conventional Commits + 模块 scope,描述用中文,可多 scope,例如 `fix(island): 胶囊"点不动"的根因——窗口必须始终可激活`、 `feat(teachers,classes,schedules,web): 教师录入(仅管理员)`。 +- **图表不引第三方库。** 全项目只有三张图(桌面端成绩页 1 张柱状图;Web 端成绩页柱状 + 折线各 1 张), + 历史上用 echarts 全量引入,占了桌面端渲染产物 1.12MB(35%)与 Web 端 1.13MB。现在换成 + 自包含的轻量组件:`packages/desktop-client/src/renderer/components/ScoreBarChart.vue`、 + `packages/web-admin/src/components/{ScoreBarChart,ScoreLineChart}.vue` —— + 柱状图是纯 HTML/CSS(柱高走百分比、刻度与网格线用**零高度 flex 行 + space-between** 对齐), + 折线图是 `viewBox="0 0 100 100" + preserveAspectRatio="none"` 的 SVG(数据点用 HTML 绝对定位, + 免得非等比拉伸把圆点压成椭圆)。两者都是响应式的,**不要给它们加重算尺寸的 resize 监听**。 + 新增图表请沿用这个做法,不要重新引入图表库。 - **改动交付形态 / 新增功能后**,同步更新 `README.md`(它兼作产品说明书与验收对照表)。 --- @@ -730,6 +941,24 @@ homeworks / notifications / calls / imports / integrations / grades / students / 同理,断言岛体尺寸别写死"胶囊尺寸 + 留白":那一刻是胶囊还是卡片取决于前面用例留下的状态, 应改成**与形态无关的不变量**(如"窗口 = 卡片实测尺寸 + 2×阴影留白")。 +23. **客户端安装包有硬底:`班级小助手.exe` 单独压缩后就有 85~90MB,"压到 60MB"做不到 —— 别再为这个反复折腾。** + 实测(2026-10-05,Electron 44.3.0):`win-unpacked` 372MB,其中 `班级小助手.exe`(就是 electron.exe, + rcedit 只加了 74KB)**234.7MB**;brotli-9 抽样压缩后估算它单独就有 85~90MB。也就是说把渲染产物 + 从 3.2MB 压到 2.1MB、把图标从 293 个减到 42 个,对安装包只影响约 1MB。 + 剩下能动的只有这些(合计约 12MB,风险不成比例,**当前一律不做**): + `dxcompiler.dll + dxil.dll` 26MB(D3D12 着色器编译)、`vk_swiftshader + vulkan-1` 6.2MB、 + `d3dcompiler_47.dll` 4.5MB(D3D11 回退)、`chrome_200_percent.pak` 1.2MB(高 DPI)、`ffmpeg.dll` 3MB。 + 教室机器多是老显卡 / 希沃一体机 / 虚拟机,删掉这些就是拿渲染可靠性换体积。 + **唯一做的一条**是语言包:`electron-builder.yml` 的 `electronLanguages: [zh-CN]` + (官方选项,`app-builder-lib` 的 `removeUnusedLanguagesIfNeeded()` 会精确删掉其余 54 个 + `locales/*.pak`)。实测 `win-unpacked` 372MB → **323MB**、`setup.exe`/`portable.exe` + 107.2MB → **98.7MB / 98.4MB**(表中口径均为 MiB,与本机 `ls -la` / `du` 一致)。 + + 要真正做到 60MB 只能换渲染运行时(WebView2 / Tauri,安装包可到 5~15MB),代价是把 + `src/main/island.ts`(1840 行)里全部原生窗口行为重写一遍 —— 逐像素透明、鼠标穿透、 + 窗口贴合岛体、触摸模式、多屏/DPI,全是 §5 第 10/16/21/22/30/32/36/37 条踩过坑的地方, + 且 `verify:desktop` / `verify:packaged` 的整套回归要重做。属于独立立项,不是顺手优化。 + ### 已知脆弱用例(不是环境问题,别去"修环境") | 用例 | 触发条件 | @@ -748,7 +977,7 @@ pnpm lint # ESLint(0 error 为底线) pnpm typecheck # 全仓库类型检查 pnpm dev:server # 另开终端 -pnpm verify:e2e # 后端端到端(2026-09-30 实测 173 项全过) +pnpm verify:e2e # 后端端到端(2026-10-05 实测 190 项全过) pnpm verify:classisland # ClassIsland 联动链路(实测 42 项) pnpm verify:classisland-plugin # 插件静态契约(不需要后端,实测 68 项) pnpm build:desktop && pnpm verify:desktop # 客户端冒烟(AI 会话可跑,先看 §7 第 7 条) @@ -758,6 +987,25 @@ pnpm build && pnpm verify:web # Web 端真实点击回归(同上 条目数由脚本自行统计并打印(`=== 结果:N/N 项通过 ===`、`ClassIsland 联动插件静态校验:N/N 项通过`), 文档不要写死容易过期的总数。 +改动到 **Linux 交付链**(`deploy/install.sh`、`deploy/classhelper`、`deploy/tools/`、 +`scripts/dist-server.mjs` 的 linux 分支、systemd/profile.d/logrotate 模板)时: + +```bash +bash -n deploy/install.sh deploy/classhelper deploy/verify-linux.sh deploy/install-linux.sh # 语法(Windows 上就能跑) +pnpm dist:server:linux # 只能在 Linux 上跑;开发机是 Windows 时到服务器/CI 上验证 + +# 真机(任意一台能 SSH 的 Linux,容器也行但 systemd 用例会跳过) +scp release-server/classhelper-server-linux-x64-*.tar.gz deploy/{install.sh,verify-linux.sh} root@<主机>:/tmp/ +ssh root@<主机> 'bash /tmp/install.sh --check' +ssh root@<主机> "printf '%s\n' '<密码>' | bash /tmp/install.sh --yes --admin-password-stdin --package /tmp/classhelper-server-linux-x64-*.tar.gz" +ssh root@<主机> "bash /tmp/verify-linux.sh --admin-password-stdin" < <(printf '%s\n' '<密码>') +``` + +`verify-linux.sh` 会**真的**改一次管理员密码再回滚、真的发一条备份,跑完机器仍是可用状态; +升级/回滚链路要加 `--full --package <新版本包>`(会真的替换一次程序文件)。 +**改过这条链路就要在真机上跑一遍**——脚本写在 Windows 上跑不出问题,`set -e` 的坑、 +systemd 的 `EnvironmentFile` 解析、glibc 差异都只有在 Linux 上才暴露。 + 改动到 ClassIsland 插件时,额外跑 `pnpm dist:classisland-plugin` 确认能打出 `.cipx`; 本机已装 ClassIsland 2.1.0.1(`D:\Classisland`)与 .NET 8 SDK(8.0.425),因此**真机验证是可行的** (直接 `pnpm build:classisland-plugin` 编译,再按 §7 第 12 条部署): @@ -777,4 +1025,28 @@ Start-Process D:\Classisland\ClassIsland.exe -WorkingDirectory D:\Classisland 并用 `pnpm verify:packaged --exe "<客户端路径>"` 对**打包后的副本**复验 —— 开发产物通过不等于用户机器上的副本通过。 +**改过 `pruneRuntime()`(§5 第 55 条)时,还要对免安装目录本身跑一遍**(开发形态跑通不代表裁剪后跑通)。 +从 `release-server/classhelper-server/` 起,用**自带的 node.exe**: + +```bash +# ① 起服务:探针通 + 能真实查表(AUTO_MIGRATE 会建库;PORT 用环境变量避开开发端口) +cd release-server/classhelper-server && PORT=4100 ./node.exe server/dist/index.js +curl.exe -s --noproxy '*' http://127.0.0.1:4100/healthz +curl.exe -s --noproxy '*' -X POST http://127.0.0.1:4100/api/auth/login \ + -H "content-type: application/json" -d '{"username":"admin","password":"admin123"}' + +# ② 数据库「一键切换」的三步子进程链路(唯一会调用 Prisma CLI 的地方) +cd server +DATABASE_PROVIDER=sqlite DATABASE_URL="file:./prune-test.db" \ + ../node.exe ../node_modules/prisma/build/index.js generate +../node.exe ../node_modules/typescript/lib/tsc.js -p tsconfig.generate.json +DATABASE_PROVIDER=sqlite DATABASE_URL="file:./prune-test.db" \ + ../node.exe ../node_modules/prisma/build/index.js db push --accept-data-loss +``` + +跑完把这几个测试残留删掉,别带进交付目录:`server/src`、`server/prune-test.db`, +并把 `data/`、`logs/` 清空。 +(`prisma db push` 是破坏性命令,Prisma 7.10 的 AI 代理护栏会要求显式同意; +这里的目标是脚本刚建出来的空临时库,不是真实数据。) + 自测本地接口记得绕过代理(见 §7 第 1 条)。 diff --git a/README.md b/README.md index 7167153..1209783 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # 班级小助手(Class Helper) 班级信息管理系统。一个 pnpm monorepo,一份代码产出四个交付物:**后端服务**、**Web 管理端**(教师 / 管理员)、 -**桌面客户端**(学生端 / 教室机器)与 **ClassIsland 联动插件**(装在教室的 ClassIsland 上)。**当前版本 1.0.0**。 +**桌面客户端**(学生端 / 教室机器)与 **ClassIsland 联动插件**(装在教室的 ClassIsland 上)。**当前版本 1.1.0**。 核心链路: @@ -40,6 +40,7 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/ - [4.7 表格与 ClassIsland 导入](#47-表格与-classisland-导入) - [4.8 ClassIsland 联动](#48-classisland-联动) - [4.9 数据库管理](#49-数据库管理仅管理员) + - [4.10 更新检查](#410-更新检查) - [5. 技术参考](#5-技术参考) - [5.1 目录结构](#51-目录结构) - [5.2 技术栈](#52-技术栈) @@ -75,6 +76,7 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/ | 产物 | 产物文件位置(打包后) | 用途 | | ------------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | | **服务端 + Web 管理端安装程序** | `release-server/班级小助手服务端-<版本>-x64-setup.exe` | 装到教师电脑 / 校服务器即完整系统,**内置 Node 运行时**,双击安装、开机自启、自动建库建号 | +| **服务端 Linux 安装包** | `release-server/classhelper-server-linux-x64-<版本>.tar.gz` | 给 `deploy/install.sh` 用(另见同名 `.sha256`);由 Actions 在 Linux 上构建后挂 Release | | **学生客户端安装程序** | `packages/desktop-client/release/班级小助手-<版本>-x64-setup.exe` | 学生机安装(NSIS) | | **学生客户端单文件版** | `packages/desktop-client/release/班级小助手-<版本>-x64-portable.exe` | 免安装直接运行(U 盘分发) | | Web 管理端(PWA) | 由服务端在 `/` 直接托管 | 浏览器打开即用,可在 Edge/Chrome 中「安装为应用」;已适配手机小屏 | @@ -83,14 +85,15 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/ > 这些产物**都不入库**(`release-server/`、`packages/*/release/`、`releases/` 均已忽略)。 > 仓库约定是把它们归集到根目录 `releases/`(`client/`、`server/`、`classisland-plugin/`),详见 [AGENTS.md](AGENTS.md) §4。 -四种部署形态按场景选(完整步骤见 [docs/production.md](docs/production.md)): +四种部署形态按场景选(Linux 完整步骤见 [docs/linux-deploy.md](docs/linux-deploy.md), +其余见 [docs/production.md](docs/production.md)): -| 场景 | 形态 | 入口 | -| ----------------------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------- | -| 学校机房 / 教师电脑(Windows 单机) | Windows 服务端安装程序(内置 Node,双击即用) | `release-server/班级小助手服务端-<版本>-x64-setup.exe` | -| 云服务器 / 多终端共享(推荐长期) | Docker Compose + MySQL(另有 SQLite 单容器版) | `deploy/Dockerfile`、`deploy/docker-compose{,.sqlite}.yml` | -| 已有 Linux 服务器 | systemd + SQLite 一键脚本(含 Nginx 反代与 WebSocket 配置) | `sudo bash deploy/install-linux.sh`(先 `--check` 体检)、`deploy/classhelper.service` | -| 自定义 / 已有 Node 环境 | 手动部署免安装目录 | `pnpm dist:server` → `release-server/classhelper-server/` | +| 场景 | 形态 | 入口 | +| ----------------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| 学校机房 / 教师电脑(Windows 单机) | Windows 服务端安装程序(内置 Node,双击即用) | `release-server/班级小助手服务端-<版本>-x64-setup.exe` | +| 云服务器 / 多终端共享(推荐长期) | Docker Compose + MySQL(另有 SQLite 单容器版) | `deploy/Dockerfile`、`deploy/docker-compose{,.sqlite}.yml` | +| 已有 Linux 服务器 | **一键安装器 + `classhelper` 运维命令**(systemd / docker) | `sudo bash deploy/install.sh`(先 `--check` 体检);运维见 `classhelper help` | +| 自定义 / 已有 Node 环境 | 手动部署免安装目录 | `pnpm dist:server` → `release-server/classhelper-server/` | ## 3. 快速开始 @@ -320,16 +323,17 @@ Element Plus 也通过对齐它的 `--el-border-radius-*` 一并改造: | 课表 | 按周展示 7 列课表(周次下拉 + 单双周标签),另有「今天」时间轴视图(大时钟 + 当前状态卡 + 已结束 / 正在上 / 下一节) | | 作业 | **看板模式**(按科目卡片、全屏放大、显示时间开关、看板字号)+列表模式、详情抽屉、附件链接(只接受 `http(s)://` 或站内相对路径);教室设备可**录入作业**与勾选**未交名单** | | 通知 | 未读红点(侧边栏徽标)、优先级标签、点击自动标为已读、全部已读、未读过滤、详情抽屉 | -| 成绩 | 班级账号看到**全班成绩总览**:表格(分数 / 得分率 / 等级)+ ECharts 柱状图 + 等级分布 + 最近更新 | +| 成绩 | 班级账号看到**全班成绩总览**:表格(分数 / 得分率 / 等级)+ 柱状图 + 等级分布 + 最近更新(图表为内置 HTML/SVG 实现,不引图表库) | | 设置 | 六个子页:通用、外观、灵动岛、提醒、账号、关于(见下) | #### 设置改版:分组侧边栏 + 子页 + 深色模式 + 关于页 -**侧边栏(ClassIsland 同款 NavigationView)**:主侧边栏改为「学习」「设置」两个**可展开分组** -(学习 = 课表 / 作业 / 通知 / 成绩;设置 = 通用 / 外观 / 灵动岛 / 提醒 / 账号 / 关于,每个子项是独立路由 -`/settings/*`);顶部**汉堡按钮**把整栏折叠成 64px 图标栏(折叠状态持久化到主进程配置,重启仍记忆)。 -折叠态隐藏文字、图标与汉堡按钮同一竖列、菜单区可滚动(底部「第 N 周 / 最近同步」恒定可见)。 -红点徽标仍挂在「通知」项图标右上方。 +**侧边栏(ClassIsland 同款 NavigationView)**:主侧边栏是**四个一级项**(课表 / 作业 / 通知 / 成绩) +加上一个**可展开分组**「设置」(通用 / 外观 / 灵动岛 / 提醒 / 账号 / 关于,每个子项是独立路由 +`/settings/*`);顶部**汉堡按钮**把整栏收成 64px **图标导轨**(折叠状态持久化到主进程配置,重启仍记忆)。 +收放是连续的 CSS 过渡(栏宽 + 文字收放 + 图标位移,260ms),全程没有结构切换;导轨里文字淡出、 +分组子项与一级项都以图标列出、图标与汉堡按钮同一竖列,**整条导轨一眼看全、不出滚动条** +(页脚在折叠态连高度一起收掉)。红点徽标仍挂在「通知」项图标右上方。 **设置子页**:原单页设置按卡片拆为 6 个子页 —— 通用(服务器连接 + 离线缓存 + 作业录入短语 +「使用引导」)、 外观、灵动岛、提醒、账号、关于;`/settings` 重定向到 `/settings/general`。 @@ -750,11 +754,48 @@ MySQL 的连接测试 / 建表 / 写入依赖随包内置的 `@prisma/adapter-ma > (422,e2e 有断言);PostgreSQL 暂未纳入。 > - **切换完成后必须重启服务端**(安装版运行 `restart.cmd`,开发模式重跑 `pnpm dev:server`)才会连接新库; > 切换失败会自动回滚 `schema.prisma`,`.env` 只在数据全部迁移成功后才改写;**目标库必须为空**(有表即拒绝)。 -> - 安装包体积因此增大约 37MB(~34MB → ~71MB):切换需要随包内置 prisma CLI 与 TypeScript 编译器 +> - 安装包体积因此增大(约 34MB → **62MB**):切换需要随包内置 prisma CLI 与 TypeScript 编译器 > (Prisma 7 的生成器产出 .ts 源码,切换后要在目标机编译进 dist)。 +> 打包末尾会做一次**运行时裁剪**(删掉 Prisma 为其它数据库方言准备的查询编译器副本等死载荷, +> 见 `scripts/dist-server.mjs` 的 `pruneRuntime()`),免安装目录 469MB → 335MB。 > - 真实切换(SQLite→MySQL)已在本机用完整子进程链路实测(快照 728 行逐表一致);e2e 只覆盖校验分支 > (同库 400 / 非法 provider 422 / 目标库非空拒绝),避免测试改写 `.env`。 +### 4.10 更新检查 + +交付物是通过 **GitHub Releases** 分发的(服务端 / 客户端安装包、便携版、插件 `.cipx`), +因此"有没有新版本"就是问那条 Release:拉 `releases/latest`(不含草稿与预发布)、 +把 `tag_name` 与本机版本比一下,比本机新就提示并给一个「前往下载」。 + +| 端 | 入口 | 检查路径 | +| ------------------- | --------------------------------------------------------------- | ---------------------------------------------------- | +| 服务端 / Web 管理端 | 顶栏用户菜单 →「检查更新」 | 浏览器 **→ 服务端 `GET /api/update/check` → GitHub** | +| 桌面客户端 | 启动后自动一次(延迟 5 秒)+「设置 → 关于」里的「检查更新」按钮 | 主进程 **→ GitHub**(不经后端) | + +两端的检查路径不同是**被约束逼出来的**,不是随手选的: + +- **Web 端不能直连 GitHub**。服务端下发的 CSP(`middleware/security.ts`)里 `connect-src` + 只放行 `'self'` 与 WebSocket,浏览器发往 `api.github.com` 的请求会被直接拦掉。 + 走服务端转发还有个好处:所有管理员共享同一份缓存(GitHub 匿名限流是 60 次/小时/IP)。 +- **客户端在主进程做**。渲染进程的 CSP 与导航防线是安全模型里最要紧的一层, + 不让它去连外部域名、解析外部 JSON;主进程本来就在做网络与文件。 + +**"查不到"是设计内的结果,不是错误**:机房服务器与教室机器常常只有内网,请求会超时/失败。 +此时接口返回 `ok: false` + 一句人话(「无法连接 GitHub,本机可能没有外网」), +界面按"暂时查不到更新"呈现并给重试入口,而不是弹红字报错。**失败结果同样进缓存**, +否则断网时每点一次按钮都要白等一个超时。 + +| 细节 | 说明 | +| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 版本比较 | `@classhelper/shared` 的 `compareVersions` / `isNewerVersion`,三端共用一套口径(避免"客户端说有新版、服务端说没有")。数字相同、只有后缀不同时**带后缀的算更新** —— 对应 `v0.1.0-fix` 这种"同版本号的修复发布" | +| 缓存 | 两端各缓存 30 分钟;`?force=1` 绕过服务端缓存,但**仅管理员生效**(否则任意登录用户都能刷爆匿名限流) | +| 权限 | 任何已登录用户都能查(信息来自公开仓库);`force` 除外 | +| 提示一次就够 | 客户端启动检查发现新版才推提示;点「忽略此版本」记进 `config.json`,同一版本不再提示(「关于」页仍可手动查) | +| 版本号单一来源 | 服务端版本由 `lib/version.ts` 读 package.json 得到(原先 `app.ts` 里硬编码了三处 `'1.0.0'`,与 5 份 package.json 一定会漂移) | +| **自动化里不查外网** | 冒烟模式跳过启动自动检查;`verify:e2e` / `verify:desktop` / `verify:web` 的断言都只验"结构完整 + 能降级",**不断言"一定有新版本"**(那会把用例绑死在外网可达上) | + +只提示、不自动下载、不自动安装 —— 安装包仍由人从 Release 页取(`SHA256SUMS-<版本>.txt` 可核对)。 + ## 5. 技术参考 ### 5.1 目录结构 @@ -779,11 +820,18 @@ class-helper/ │ ├── nsis/server-installer.nsi# 安装程序脚本模板(版本号由 dist-server 注入) │ └── ui-smoke/ # Web 管理端 UI 真实点击回归(Electron 驱动 + 上课时段探针) ├── deploy/ # 生产部署:Dockerfile / docker-compose{,.sqlite}.yml / nginx.conf -│ ├── install-linux.sh # Linux 一键部署(systemd + SQLite,含 .env 生成与就绪探针) -│ ├── classhelper.service # systemd 单元模板(脚本会替换 __NODE__/__DIR__/__USER__) +│ ├── install.sh # Linux 一键安装器(TUI;native/docker 双形态;自带 --check 体检) +│ ├── install-linux.sh # 兼容壳:转发到 install.sh(旧命令仍可用) +│ ├── classhelper # 运维命令本体(装到 <安装目录>/bin,软链 /usr/local/bin/classhelper) +│ ├── verify-linux.sh # Linux 安装/改密/备份/升级/回滚验收(自统计 N/N 项) +│ ├── tools/admin-cli.mjs # 离线账号工具(改密、列账号、SQLite 一致性快照) +│ ├── profile.d/ logrotate.d/ systemd/ # /etc 下的环境变量、日志轮转、定时备份模板 +│ ├── Dockerfile.linux-package # 安装包内那份 Dockerfile(--mode docker 用它构建镜像) +│ ├── classhelper.service # systemd 单元模板(替换 __NODE__/__DIR__/__USER__/__CONFIG__) │ ├── package.runtime.json # 运行时依赖清单(安装包 / 容器 / Linux 部署共用) │ └── .env.example / .env.sqlite.example ├── docs/ +│ ├── linux-deploy.md # Linux 部署与运维指南(安装/命令/升级回滚/备份/验收/安全清单) │ ├── production.md # 生产部署指南(四种形态 + Linux systemd + 运维 + 安全清单) │ ├── mysql.md # MySQL 切换指南 │ ├── winisland-design-tokens.md # 灵动岛照 WinIsland 提取的设计 token @@ -807,12 +855,13 @@ class-helper/ │ ├── config/env.ts # 环境变量校验(zod)+ 生产配置自检 │ ├── lib/ # access(RBAC) / class-account / session / db / db-bootstrap / │ │ # snapshot(全库 JSON 快照) / web-static / http / jwt / logger / - │ │ # mappers / password / schemas / term + │ │ # mappers / password / schemas / term / version(读 package.json) │ ├── tools/apply-snapshot.ts # 跨库迁移的子进程入口(编译到 dist/tools/) │ ├── middleware/ # auth / error / validate / security(helmet+限流+耗时日志) │ ├── realtime/ # socket.ts + bus.ts(事件总线) - │ └── modules/ # 14 个功能模块 + registry.ts(模块注册表) - │ └── database/ # 数据库管理(状态/备份/导入导出/定时/一键切换,仅管理员) + │ └── modules/ # 15 个功能模块 + registry.ts(模块注册表) + │ ├── database/ # 数据库管理(状态/备份/导入导出/定时/一键切换,仅管理员) + │ └── update/ # 更新检查(查 GitHub 最新 Release,服务端唯一出网请求) ├── classisland-plugin/ # ClassIsland 联动插件(.NET 8 / C#,独立于 pnpm workspace) │ ├── manifest.yml # 清单(id / version / apiVersion / entranceAssembly) │ ├── src/Plugin.cs # 入口(读配置 → 注册提醒提供方 / 设置页 / 联动服务) @@ -852,7 +901,7 @@ class-helper/ | 实时 | Socket.IO | 4.8 | | 认证 | jsonwebtoken + bcryptjs | 9.0 / 3.0 | | 校验 | zod | 4.6 | -| Web 端 | Vue + Vite + Element Plus + Pinia + Vue Router + Axios + ECharts | 3.5 / 8.3 / 2.14 / 4.0 / 5.3 / 1.20 / 6.1 | +| Web 端 | Vue + Vite + Element Plus + Pinia + Vue Router + Axios | 3.5 / 8.3 / 2.14 / 4.0 / 5.3 / 1.20 | | 客户端 | Electron + Vue + Element Plus + Pinia + Socket.IO Client + IndexedDB | 44.3 / 3.5 / 2.14 / 4.0 / 4.8 | | 插件 | .NET 8 / C# + Avalonia(ClassIsland 插件 API 2.0) | net8.0(本机 SDK 8.0.425) | | 打包 | electron-builder(nsis / portable)+ NSIS | 26.15 | @@ -933,6 +982,7 @@ class-helper/ | GET | `/integrations/classisland/pending` | **设备令牌** | 插件拉取尚未确认的提醒(离线期间老师发的通知,重连后补齐) | | POST | `/integrations/classisland/ack` | **设备令牌** | 插件确认提醒已弹出(确认后不再补发) | | GET | `/integrations/classisland/class-plan` | **设备令牌** | 插件拉取本班课表(开启镜像时)用于写回 ClassIsland | +| GET | `/update/check` | 登录 | 更新检查(服务端代查 GitHub 最新 Release;`?force=1` 绕过缓存仅管理员生效) | | GET | `/health` | 公开 | 健康检查(含版本号与已挂载模块);另有 `/healthz` 存活与 `/readyz` 就绪探针 | ### 5.4 WebSocket 事件 @@ -1004,7 +1054,7 @@ class-helper/ 后端每个功能是一个独立目录,统一契约 `ApiModule = { name, basePath, router, enabled? }`: ```ts -// packages/server/src/modules/registry.ts(当前 14 个模块) +// packages/server/src/modules/registry.ts(当前 15 个模块) export const apiModules: ApiModule[] = [ authModule, classesModule, @@ -1020,6 +1070,7 @@ export const apiModules: ApiModule[] = [ teachersModule, dashboardModule, databaseModule, // ← 数据库管理(仅管理员) + updateModule, // ← 更新检查(查 GitHub 最新 Release) ].filter((module) => module.enabled !== false); ``` @@ -1062,67 +1113,71 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed ### 6.1 验收标准对照 -| 验收项 | 结果 | 证据 | -| ------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 教师 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:desktop` 断言「窗口 = 岛体 + 2×阴影留白、`interactive=true`、退出后恢复固定包围盒」。**真机(触摸屏)需人工复验** | -| 叫人(老师点名,分紧急 / 普通两级) | ✅ | `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/:id/reset-password` 收可选 `newPassword`,界面「修改密码」填了就设成填的值、留空才是默认初始密码(`123456`)。**学生没有账号,`POST /students/:id/reset-password` 已下线(404,e2e 有负断言)** | -| **客户端不提供改密入口(有意为之)** | ✅ | 按用户要求移除:防教室机器误改班级密码把其他机器锁在门外。`verify:desktop` 断言账号页「修改密码」按钮不存在、只有「退出登录」;班级密码由管理员在 Web 端维护 | -| **创建班级仅管理员 + 设置 / 更改班主任** | ✅ | `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:desktop`:「侧边栏点击导航」适配「学习 / 设置」分组;「主题切换与侧边栏折叠(真实点击、写回配置)」——`html.dark` 翻转 + 折叠宽度 200→64 + 双双落盘;「关于页渲染」含 configPath | -| **数据库管理(仅管理员:状态 / 备份 / 导入导出 / 一键切换)** | ✅ | `verify:e2e` 13 项:状态与 14 表行数、教师全端点 403、连接测试(当前库可达 / 不可达 MySQL ok=false)、**Redis 主库 422**、切换校验分支(同库 400)、备份 → 快照导入 → 备份恢复 roundtrip(临时数据被清除)、定时配置校验、SQLite 文件下载;**切换子进程链路真机实测**:快照 728 行 → generate → db push → apply-snapshot 逐表一致;`verify:web`:数据库管理页(仅管理员)状态卡与备份表渲染 | -| **其他页面发通知也会联动 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`(见 [7. 打包与交付](#7-打包与交付)) | -| 提供完整 README(启动、构建、打包、默认账号) | ✅ | 本文档含快速开始、命令表、API、WebSocket、RBAC、模块化、数据库、打包与常见问题 | +| 验收项 | 结果 | 证据 | +| ------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 教师 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:desktop` 断言「窗口 = 岛体 + 2×阴影留白、`interactive=true`、退出后恢复固定包围盒」。**真机(触摸屏)需人工复验** | +| 叫人(老师点名,分紧急 / 普通两级) | ✅ | `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/:id/reset-password` 收可选 `newPassword`,界面「修改密码」填了就设成填的值、留空才是默认初始密码(`123456`)。**学生没有账号,`POST /students/:id/reset-password` 已下线(404,e2e 有负断言)** | +| **客户端不提供改密入口(有意为之)** | ✅ | 按用户要求移除:防教室机器误改班级密码把其他机器锁在门外。`verify:desktop` 断言账号页「修改密码」按钮不存在、只有「退出登录」;班级密码由管理员在 Web 端维护 | +| **创建班级仅管理员 + 设置 / 更改班主任** | ✅ | `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:desktop`:「侧边栏点击导航」适配「学习 / 设置」分组;「主题切换与侧边栏折叠(真实点击、写回配置)」——`html.dark` 翻转 + 折叠宽度 200→64 + 双双落盘;「关于页渲染」含 configPath | +| **数据库管理(仅管理员:状态 / 备份 / 导入导出 / 一键切换)** | ✅ | `verify:e2e` 13 项:状态与 14 表行数、教师全端点 403、连接测试(当前库可达 / 不可达 MySQL ok=false)、**Redis 主库 422**、切换校验分支(同库 400)、备份 → 快照导入 → 备份恢复 roundtrip(临时数据被清除)、定时配置校验、SQLite 文件下载;**切换子进程链路真机实测**:快照 728 行 → generate → db push → apply-snapshot 逐表一致;`verify:web`:数据库管理页(仅管理员)状态卡与备份表渲染 | +| **更新检查(服务端 / Web 端 / 客户端)** | ✅ | `verify:e2e` 4 项:未登录 401、管理员结构完整且本机版本取自 package.json、教师可查但 `force` 仅管理员生效、健康检查暴露版本号与 15 个模块;`verify:desktop` 4 项:IPC 返回结构正确(无外网时降级 `ok=false` 且带原因)、结果带缓存、冒烟模式跳过启动自动检查、关于页出现「检查更新」;`verify:web`:「顶栏用户菜单 → 弹窗给出结论并可关闭」。**真机联网实测**:`GET /api/update/check` → `ok=true`、`latestVersion=1.0.0`、5 个附件、更新说明 1358 字 | +| **其他页面发通知也会联动 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`(见 [7. 打包与交付](#7-打包与交付)) | +| 提供完整 README(启动、构建、打包、默认账号) | ✅ | 本文档含快速开始、命令表、API、WebSocket、RBAC、模块化、数据库、打包与常见问题 | ### 6.2 最近一次实测 -| 验证 | 结果 | -| --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | -| `pnpm verify:e2e`(后端 + 实时 + RBAC + 上课时段 + 导入 + 班级账号 + 数据库管理) | **186/186** ✅(2026-10-02 实测) | -| `pnpm verify:desktop`(客户端冒烟,不弹窗) | **99/102**(2026-10-02 实测;3 项为上课时段下的时序相关用例,见下) | -| `pnpm verify:web`(Web 管理端真实点击回归) | **30/31**(2026-10-02 实测;未过的 1 项是既有脆弱用例「课表科目为全校统一目录」,见下) | -| `pnpm verify:classisland`(设备令牌 / 上报 / 提醒下发与回执 / 镜像契约) | **42/42** ✅ | -| `pnpm verify:classisland-plugin`(插件静态契约) | **68/68** ✅ | -| `pnpm typecheck` / `pnpm lint` | 全过 ✅(lint 0 error,1 条既有 warning) | -| `pnpm build:desktop`(含产物凭据门禁 `check-bundle-secrets`) | 通过 ✅(60 个产物文件里无种子凭据字面量) | -| 插件 `dotnet` 编译(`pnpm build:classisland-plugin`) | 0 错误 ✅(1 条预期内的 Avalonia `AVLN3001` 警告) | -| 数据库切换子进程链路(快照 → generate → db push → 写入目标库) | ✅ 实测:728 行逐表一致(users / schedules / grades 抽查一致) | -| `pnpm dist:server`(服务端安装包 + Web 端) | ✅ 打出 ~71MB 安装程序(体积增大是因随包内置切换链路所需的 prisma CLI 与 tsc) | -| 安装程序完整生命周期(静默安装 → 启动 → 卸载) | 通过 ✅(安装到 `%LOCALAPPDATA%\Programs`,卸载干净) | -| 便捷版(单文件)解包启动 | 通过 ✅(wrapper + 多个进程,主窗口正常) | -| Docker / Nginx 部署样例 | 文件已提供(含 Linux systemd 脚本),本机无 Docker / Linux 未实测 ⚠️ | +| 验证 | 结果 | +| -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| `pnpm verify:e2e`(后端 + 实时 + RBAC + 上课时段 + 导入 + 班级账号 + 数据库管理 + 更新检查) | **190/190** ✅(2026-10-05 实测) | +| `pnpm verify:desktop`(客户端冒烟,不弹窗) | **105/105** ✅(在线轮)+ **89/89** ✅(二次启动轮),0 失败(2026-10-05 实测) | +| `pnpm verify:web`(Web 管理端真实点击回归) | **32/33**(2026-10-05 实测;未过的 1 项是既有脆弱用例「课表科目为全校统一目录」,见下) | +| `pnpm verify:classisland`(设备令牌 / 上报 / 提醒下发与回执 / 镜像契约) | **42/42** ✅(2026-10-05 实测) | +| `pnpm verify:classisland-plugin`(插件静态契约) | **68/68** ✅ | +| `pnpm typecheck` / `pnpm lint` / `pnpm check:icons` | 全过 ✅(lint 0 error **0 warning**) | +| `pnpm build:desktop`(含产物凭据门禁 `check-bundle-secrets`) | 通过 ✅(61 个产物文件里无种子凭据字面量) | +| `pnpm verify:packaged --exe …`(对打包副本复验) | **105/105** ✅ + **89/89** ✅(2026-10-05 实测,0 失败) | +| 裁剪后的服务端运行时(起服务 → 登录查表 → `prisma generate` → `tsc` → `db push`) | 通过 ✅(2026-10-05 实测,建出 14 张表;详见 AGENTS.md §8) | +| 插件 `dotnet` 编译(`pnpm build:classisland-plugin`) | 0 错误 ✅(1 条预期内的 Avalonia `AVLN3001` 警告) | +| 数据库切换子进程链路(快照 → generate → db push → 写入目标库) | ✅ 实测:728 行逐表一致(users / schedules / grades 抽查一致) | +| `pnpm dist:server`(服务端安装包 + Web 端) | ✅ 打出 ~62MB 安装程序(随包内置切换链路所需的 prisma CLI 与 tsc,并在打包末尾做运行时裁剪) | +| 安装程序完整生命周期(静默安装 → 启动 → 卸载) | 通过 ✅(安装到 `%LOCALAPPDATA%\Programs`,卸载干净) | +| 便捷版(单文件)解包启动 | 通过 ✅(wrapper + 多个进程,主窗口正常) | +| Docker / Nginx 部署样例 | 文件已提供;本机无 Docker 未实测 ⚠️ | +| Linux 一键安装 + `classhelper` 运维命令(`deploy/install.sh` / `deploy/classhelper` / `pnpm dist:server:linux`) | 脚本与文档已交付,本地静态检查通过(`bash -n`、ESLint 0 error);**Linux 实机验收待做** ⚠️(清单见 [linux-deploy.md](docs/linux-deploy.md) §10,`deploy/verify-linux.sh` 可自动跑) | ### 6.3 已知的时序 / 环境相关用例 @@ -1148,6 +1203,25 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed ## 7. 打包与交付 +### 体积与性能(2026-10-05 优化后实测) + +| 产物 / 指标 | 优化前 | 优化后 | 做了什么 | +| ----------------------------------- | -------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | +| 客户端渲染产物 `dist/renderer` | 3.2MB | **2.1MB** | 成绩页图表从 echarts 全量(1.12MB,占 35%)换成内置 HTML/SVG 组件;Element Plus 图标从全量注册 293 个改为按需 42/33 个 | +| 客户端主进程产物体积 | 242KB | 242KB | 未变(冒烟代码拆分会重复 `island`/`config` 单例,esbuild 的 CJS 打包无法代码分割,故保留) | +| 客户端 `win-unpacked` | 372MB | **323MB** | `electronLanguages: [zh-CN]`,只留中文语言包(其余 54 个 `locales/*.pak` 共 48MB) | +| 客户端 `setup.exe` / `portable.exe` | 107MB | **98MB** | 同上。**安装包有硬底**:`班级小助手.exe`(electron.exe)压缩后本身就有 85~90MB,60MB 不可达 | +| 服务端 `dist` | 3.8MB | **0.6MB** | `tsconfig.build.json` 关掉 `declaration`/`declarationMap`/`sourceMap`(占原体积 82%) | +| 服务端免安装目录 | 436MB | **335MB** | 打包末尾的运行时裁剪:删掉 Prisma 为 postgresql/cockroachdb/sqlserver 准备的查询编译器副本、`*.map`、文档与测试夹具(共 2329 项 / 101MB) | +| 服务端安装程序 | 71MB | **62MB** | 同上 | +| Web 端前端产物 | 3.3MB | **2.1MB** | 同样是 echarts → 内置 SVG/CSS 图表 + 图标按需 | +| 灵动岛主进程常驻开销 | 每 60ms 2 次系统调用 | 每 60ms 1 次 | 光标位置每跳只读一次(命中判定与"跟随鼠标屏幕"共用);窗口不可见时整跳 | + +> 没有做的事,以及为什么:Element Plus 仍全量引入(按需引入要改所有视图的样式引入方式与 locale 配置, +> 收益约 200 到 350KB JS,风险不成比例);服务端静态资源不做预压缩;客户端不再删 GPU 回退组件 +> (dxcompiler / swiftshader / d3dcompiler,共约 37MB 未压缩,教室老显卡与虚拟机靠它们)。 +> 详见 [AGENTS.md](AGENTS.md) 的 §5 第 53 至 56 条与 §7 第 23 条。 + ### 客户端(Windows EXE) ```bash @@ -1179,10 +1253,10 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS | 产物 | 大小 | 冒烟结果 | | -------------------------------------- | ------ | -------------------------------------------------------------------- | -| 服务端安装程序(内置 Node + Web 端) | ~71MB | ✅ 静默安装(升级保留 `.env` 与数据库)→ 自动建库建号 → 服务就绪 | +| 服务端安装程序(内置 Node + Web 端) | ~62MB | ✅ 静默安装(升级保留 `.env` 与数据库)→ 自动建库建号 → 服务就绪 | | `release/win-unpacked/班级小助手.exe` | ~235MB | ✅ 打包版冒烟全绿,`packaged: true`,退出码 0 | -| `release/…-x64-portable.exe`(单文件) | ~107MB | ✅ 打包后冒烟全绿,`packaged: true`,退出码 0 | -| `release/…-x64-setup.exe`(客户端) | ~107MB | ✅ 构建成功,已嵌入自定义图标 | +| `release/…-x64-portable.exe`(单文件) | ~98MB | ✅ 打包后冒烟全绿,`packaged: true`,退出码 0 | +| `release/…-x64-setup.exe`(客户端) | ~98MB | ✅ 构建成功,已嵌入自定义图标 | | `releases/classisland-plugin/….cipx` | ~88KB | ✅ 全量编译通过(1 警告 0 错误),包内 DLL 哈希与 `bin/Release` 一致 | ### ClassIsland 插件 diff --git a/deploy/.env.example b/deploy/.env.example index 817d531..76011d5 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -7,7 +7,7 @@ SERVER_PORT=4000 # 应用版本(镜像标签) -APP_VERSION=1.0.0 +APP_VERSION=1.1.0 # MySQL 配置 MYSQL_ROOT_PASSWORD=请改成强密码_root @@ -31,7 +31,7 @@ INITIAL_ADMIN_USERNAME=admin INITIAL_ADMIN_PASSWORD=admin123 # 各类账号的默认初始密码(新增/重置时使用) -DEFAULT_STUDENT_PASSWORD=123456 + DEFAULT_CLASS_PASSWORD=123456 DEFAULT_TEACHER_PASSWORD=123456 diff --git a/deploy/.env.sqlite.example b/deploy/.env.sqlite.example index 41d9590..387aca5 100644 --- a/deploy/.env.sqlite.example +++ b/deploy/.env.sqlite.example @@ -8,7 +8,7 @@ SERVER_PORT=4000 # 应用版本(镜像标签) -APP_VERSION=1.0.0 +APP_VERSION=1.1.0 # JWT 签名密钥(必填,必须 ≥32 位;不能包含 secret / change-me 等示例词) # 生成:node -e "console.log(require('crypto').randomBytes(48).toString('hex'))" @@ -25,7 +25,7 @@ INITIAL_ADMIN_USERNAME=admin INITIAL_ADMIN_PASSWORD=admin123 # 各类账号的默认初始密码(新增/重置时使用) -DEFAULT_STUDENT_PASSWORD=123456 + DEFAULT_CLASS_PASSWORD=123456 DEFAULT_TEACHER_PASSWORD=123456 diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 35bb91f..400cce8 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -5,13 +5,13 @@ # runtime —— 仅保留运行时所需内容(生产依赖 + 构建产物),镜像更小 # # 构建(在仓库根目录执行): -# docker build -f deploy/Dockerfile -t classhelper-server:1.0.0 . +# docker build -f deploy/Dockerfile -t classhelper-server:1.1.0 . # # 运行(SQLite + 数据卷): # docker run -d --name classhelper -p 4000:4000 \ # -e JWT_SECRET=<48字节随机串> \ # -v classhelper-data:/app/data \ -# classhelper-server:1.0.0 +# classhelper-server:1.1.0 # # 生产推荐用 docker compose(见 deploy/docker-compose.yml,含 MySQL 与 HTTPS 反代) diff --git a/deploy/Dockerfile.linux-package b/deploy/Dockerfile.linux-package new file mode 100644 index 0000000..b3a17af --- /dev/null +++ b/deploy/Dockerfile.linux-package @@ -0,0 +1,45 @@ +# --------------------------------------------------------------------------- +# 班级小助手服务端 · Docker 形态镜像(由 install.sh --mode docker 使用) +# +# 与仓库根 deploy/Dockerfile 的区别: +# - 那份是"从源码构建"(两阶段、需要 pnpm 与全量源码),适合开发与自己打包镜像; +# - 这份是"从安装包构建":安装包里的 server/dist、web、node_modules 已经是**linux-x64 的真实目录**, +# 所以这里只做 COPY,不需要联网装依赖、不需要构建工具,几十秒就能出镜像。 +# +# 构建(install.sh 自动执行,也可手工): +# docker build -t classhelper-server:1.1.0 -f Dockerfile . +# 运行: +# docker run -d --name classhelper-server -p 4000:4000 \ +# --env-file /etc/classhelper/config.env -v classhelper-data:/app/data \ +# classhelper-server:1.1.0 +# --------------------------------------------------------------------------- +FROM node:22-slim + +ENV NODE_ENV=production \ + HOST=0.0.0.0 \ + PORT=4000 + +WORKDIR /app + +# 运行时会用到的全部内容(安装包里就是这套布局) +COPY package.json ./ +COPY node_modules ./node_modules +COPY server ./server +COPY web ./web +COPY tools ./tools + +# 数据目录(compose 里挂成卷) +RUN mkdir -p /app/data /app/logs + +# 说明:不用 `USER node` +# - 镜像只在本机跑,容器本身就是隔离边界; +# - 数据卷里的文件需要与宿主机上的 classhelper backup / restore 互通, +# 用 root 可以避免 uid 不一致导致的权限问题。 + +EXPOSE 4000 + +# 健康检查用 Node 自己发请求:slim 镜像里没有 curl/wget +HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||4000)+'/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +CMD ["node", "server/dist/index.js"] diff --git a/deploy/classhelper b/deploy/classhelper new file mode 100644 index 0000000..12f5c8e --- /dev/null +++ b/deploy/classhelper @@ -0,0 +1,1580 @@ +#!/usr/bin/env bash +# --------------------------------------------------------------------------- +# 班级小助手 · 运维命令(Linux) +# +# 安装位置:<安装目录>/bin/classhelper,并在 /usr/local/bin/classhelper 建软链, +# 因此任何用户在任何目录都能直接敲 `classhelper`。 +# +# 设计原则: +# 1. **敏感操作一律 root**:除 help / version / completion 外,全部先过 need_root。 +# 2. **密码永不经过 argv / 环境变量 / 日志**:`password` 子命令从 /dev/tty(无 tty 时 stdin) +# 读入,经管道交给 tools/admin-cli.mjs,只写进数据库;改配置时值也不进 argv(走临时文件)。 +# 3. **配置只有一个真身**:/etc/classhelper/config.env(默认),安装目录里的 .env 是指向它的软链。 +# —— Web 端「一键切换数据库」改写的是 <安装目录>/.env,靠软链落到同一份文件上; +# 否则 systemd 注入的旧值会压住新值,切换看起来成功、重启后又连回旧库。详见 docs/linux-deploy.md。 +# 4. **危险动作先备份**:改配置 / 改密 / 升级 / 回滚都先落一份到 /var/backups/classhelper/。 +# 5. 关键操作写审计日志 /var/log/classhelper/audit.log(只记"谁对谁做了什么、结果如何")。 +# 6. **替换程序文件用 mv 而不是 rm+cp**:升级时正在运行的正是本脚本(bin/classhelper), +# rm 掉再写同名文件会让 bash 读到半个脚本;改名后新文件就位,运行中的进程继续读旧 inode。 +# +# 用法:classhelper help +# --------------------------------------------------------------------------- +set -euo pipefail + +# ---------------------------------------------------------------- 0. 常量 + +SERVICE_NAME="classhelper" +DEFAULT_INSTALL_DIR="/opt/classhelper" +DEFAULT_CONFIG_FILE="/etc/classhelper/config.env" +DEFAULT_LOG_DIR="/var/log/classhelper" +DEFAULT_BACKUP_DIR="/var/backups/classhelper" +REPO_SLUG="LoveChengke/ClassHelper" +GITHUB_API="https://api.github.com/repos/${REPO_SLUG}" +GITHUB_RELEASES_PAGE="https://github.com/${REPO_SLUG}/releases" +# GitHub 对没有 User-Agent 的请求直接 403 +HTTP_UA="ClassHelper-Ops" +CURL_OPTS=(-fsSL --max-time 20 -H "User-Agent: ${HTTP_UA}") +CURL_OPTS_LONG=(-fsSL --max-time 600 -H "User-Agent: ${HTTP_UA}") + +# ---------------------------------------------------------------- 1. 输出 + +if [ -t 1 ] && [ "${TERM:-dumb}" != "dumb" ] && [ -z "${NO_COLOR:-}" ]; then + C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'; C_DIM=$'\033[2m' + C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'; C_RED=$'\033[31m'; C_CYAN=$'\033[36m' +else + C_RESET=''; C_BOLD=''; C_DIM=''; C_GREEN=''; C_YELLOW=''; C_RED=''; C_CYAN='' +fi + +log() { printf '%s[classhelper]%s %s\n' "$C_GREEN" "$C_RESET" "$*"; } +info() { printf '%s[classhelper]%s %s\n' "$C_CYAN" "$C_RESET" "$*"; } +warn() { printf '%s[classhelper]%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2; } +die() { printf '%s[classhelper]%s %s\n' "$C_RED" "$C_RESET" "$*" >&2; exit 1; } + +# ---------------------------------------------------------------- 2. 定位自身与安装元数据 + +# 解析真实路径(软链 /usr/local/bin/classhelper -> <安装目录>/bin/classhelper) +resolve_self() { + local target="${BASH_SOURCE[0]}" resolved + if command -v readlink >/dev/null 2>&1; then + resolved="$(readlink -f "$target" 2>/dev/null || true)" + [ -n "$resolved" ] && target="$resolved" + fi + ( cd "$(dirname "$target")" && printf '%s/%s' "$PWD" "$(basename "$target")" ) +} + +SELF_PATH="$(resolve_self)" +INSTALL_DIR="$(cd "$(dirname "$SELF_PATH")/.." && pwd)" +META_FILE="${INSTALL_DIR}/.installed.json" + +# 元数据缺失(例如手工拷贝的 bin 目录):回落到默认值,尽量仍能给出状态 +if [ ! -f "$META_FILE" ] && [ -n "${CLASSHELPER_HOME:-}" ]; then + INSTALL_DIR="${CLASSHELPER_HOME}" + META_FILE="${INSTALL_DIR}/.installed.json" +fi + +# 只解析扁平、单层、值全为字符串的 JSON —— 格式由安装器与 upgrade 写出、受本脚本控制, +# 因此用 sed 比引入 json 工具更可靠(运维工具要在 Node 坏掉时照样能用) +meta_get() { + local key="$1" + [ -f "$META_FILE" ] || return 0 + sed -n "s/^[[:space:]]*\"${key}\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*\$/\1/p" "$META_FILE" | head -n 1 +} + +MODE="$(meta_get mode)"; MODE="${MODE:-native}" +DB_KIND="$(meta_get database)"; DB_KIND="${DB_KIND:-sqlite}" +SERVICE_USER="$(meta_get serviceUser)"; SERVICE_USER="${SERVICE_USER:-classhelper}" +CONFIG_FILE="$(meta_get configFile)"; CONFIG_FILE="${CONFIG_FILE:-$DEFAULT_CONFIG_FILE}" +LOG_DIR="$(meta_get logDir)"; LOG_DIR="${LOG_DIR:-$DEFAULT_LOG_DIR}" +BACKUP_DIR="$(meta_get backupDir)"; BACKUP_DIR="${BACKUP_DIR:-$DEFAULT_BACKUP_DIR}" +COMPOSE_FILE="$(meta_get composeFile)" +CONFIG_DIR="$(dirname "$CONFIG_FILE")" +DATA_DIR="${INSTALL_DIR}/data" +TOOLS_DIR="${INSTALL_DIR}/tools" +NODE_BIN="${INSTALL_DIR}/runtime/node/bin/node" +[ -x "$NODE_BIN" ] || NODE_BIN="$(command -v node 2>/dev/null || true)" +SHARED_LIB="${INSTALL_DIR}/node_modules/@classhelper/shared/dist/index.js" + +AUDIT_LOG="${LOG_DIR}/audit.log" + +# 审计:只记动作与目标,**绝不记密码 / 令牌 / 密钥**。 +# 目录在这里按需创建,而不是脚本一启动就建 —— help / version / completion 不需要 root, +# 也不该在别人的机器上顺手创建 /var/log 下的目录。 +audit() { + local action="$1" target="${2:--}" result="${3:-ok}" + mkdir -p "$LOG_DIR" 2>/dev/null || true + chmod 700 "$LOG_DIR" 2>/dev/null || true + printf '%s operator=%s action=%s target=%s result=%s\n' \ + "$(date '+%F %T%z')" "${SUDO_USER:-$(id -un)}" "$action" "$target" "$result" >>"$AUDIT_LOG" 2>/dev/null || true + chmod 600 "$AUDIT_LOG" 2>/dev/null || true +} + +need_root() { + [ "$(id -u)" = "0" ] || die "该操作需要 root 权限:sudo classhelper $*" +} + +require_install() { + [ -d "$INSTALL_DIR" ] || die "找不到安装目录 ${INSTALL_DIR}(用 install.sh 安装,或设置 CLASSHELPER_HOME)" +} + +# ---------------------------------------------------------------- 3. 配置读写 + +# 读配置项(去掉两侧引号,与 shell source / dotenv / systemd 三种读法口径一致) +cfg_get() { + local key="$1" + [ -f "$CONFIG_FILE" ] || return 0 + sed -n "s/^[[:space:]]*${key}=//p" "$CONFIG_FILE" | tail -n 1 | sed -e 's/^"//' -e 's/"$//' -e "s/^'//" -e "s/'$//" +} + +is_secret_key() { + case "$1" in + *SECRET*|*PASSWORD*|*TOKEN*|DATABASE_URL) return 0 ;; + *) return 1 ;; + esac +} + +# 打码显示:只露出头 2 位与末 1 位,短值全星 +mask_value() { + printf '%s' "${1:-}" | awk '{ if (length($0) <= 4) print "****"; else print substr($0,1,2) "****" substr($0,length($0)-1) }' +} + +# 写 .env 时的取值引号规则:含空白或 # 的值必须加引号,否则三种读法都会解析错 +quote_env_value() { + local value="$1" + case "$value" in + *[[:space:]]*|*'#'*|*'"'*|*"'"*|'') printf '"%s"' "$(printf '%s' "$value" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')" ;; + *) printf '%s' "$value" ;; + esac +} + +current_port() { + local port + port="$(cfg_get PORT)" + [ -n "$port" ] || port="$(meta_get port)" + printf '%s' "${port:-4000}" +} + +current_version() { + local version="" + if [ -f "${INSTALL_DIR}/VERSION" ]; then version="$(tr -d '[:space:]' <"${INSTALL_DIR}/VERSION")"; fi + [ -n "$version" ] || version="$(meta_get version)" + printf '%s' "${version:-未知}" +} + +# ---------------------------------------------------------------- 4. 版本比较 + +# 与 @classhelper/shared 的 isNewerVersion 同口径 —— 优先用那份实现,不制造第二套规则。 +# 只有在 Node / 共享包缺失(运维救急场景)时才退回纯 bash 比较(只比数字段,忽略预发布后缀)。 +version_gt() { + local current="$1" candidate="$2" + if [ -n "$NODE_BIN" ] && [ -f "$SHARED_LIB" ]; then + "$NODE_BIN" --input-type=module -e ' + const [, lib, a, b] = process.argv; + const shared = await import(lib); + process.exit(shared.isNewerVersion(a, b) ? 0 : 1); + ' "$SHARED_LIB" "$current" "$candidate" 2>/dev/null && return 0 + return 1 + fi + local a="${current#v}" b="${candidate#v}" + a="${a%%-*}"; b="${b%%-*}" + local IFS='.' i x y + local -a xs ys + read -r -a xs <<<"$a" + read -r -a ys <<<"$b" + for i in 0 1 2; do + x="${xs[$i]:-0}"; y="${ys[$i]:-0}" + x="${x//[!0-9]/}"; y="${y//[!0-9]/}" + [ -n "$x" ] || x=0 + [ -n "$y" ] || y=0 + if [ "$((10#$x))" -gt "$((10#$y))" ]; then return 0; fi + if [ "$((10#$x))" -lt "$((10#$y))" ]; then return 1; fi + done + return 1 +} + +# ---------------------------------------------------------------- 5. 服务控制 + +compose_cmd() { + [ -f "$COMPOSE_FILE" ] || die "找不到 compose 文件:${COMPOSE_FILE}" + if docker compose version >/dev/null 2>&1; then + docker compose -f "$COMPOSE_FILE" --env-file "$CONFIG_FILE" "$@" + elif command -v docker-compose >/dev/null 2>&1; then + docker-compose -f "$COMPOSE_FILE" --env-file "$CONFIG_FILE" "$@" + else + die "未找到 docker compose(需要 docker compose 插件或 docker-compose)" + fi +} + +svc_active() { + if [ "$MODE" = "docker" ]; then + [ -n "$(compose_cmd ps -q 2>/dev/null || true)" ] + return + fi + systemctl is-active --quiet "$SERVICE_NAME" +} + +svc_enabled() { + if [ "$MODE" = "docker" ]; then + svc_active + return + fi + systemctl is-enabled --quiet "$SERVICE_NAME" 2>/dev/null +} + +# Docker 形态的镜像标签用**稳定名**(compose 文件里写死它),版本另打一个 tag 便于追溯。 +# 为什么不用 classhelper-server:<版本> 直接写进 compose:升级替换完程序文件后, +# compose 里那个版本号就过期了,`compose up -d` 会一直沿用旧镜像 —— 表现为"升级成功但行为没变"。 +DOCKER_IMAGE="classhelper-server:local" +DOCKER_IMAGE_MARKER="${INSTALL_DIR}/.docker-image-version" + +docker_build_image() { + local ver; ver="$(current_version)" + if [ -f "$DOCKER_IMAGE_MARKER" ] && [ "$(cat "$DOCKER_IMAGE_MARKER" 2>/dev/null)" = "$ver" ]; then + return 0 + fi + if [ ! -f "${INSTALL_DIR}/Dockerfile" ]; then + warn "安装目录里没有 Dockerfile,跳过镜像重建" + return 1 + fi + info "构建镜像(版本 ${ver})..." + if docker build -t "$DOCKER_IMAGE" -t "classhelper-server:${ver}" \ + -f "${INSTALL_DIR}/Dockerfile" "$INSTALL_DIR" >>"${LOG_DIR}/docker-build.log" 2>&1; then + printf '%s' "$ver" >"$DOCKER_IMAGE_MARKER" + log "镜像已就绪:${DOCKER_IMAGE}(版本 ${ver})" + return 0 + fi + warn "镜像构建失败,详见 ${LOG_DIR}/docker-build.log" + return 1 +} + +svc_start() { + if [ "$MODE" = "docker" ]; then + docker_build_image || true + compose_cmd up -d + else + systemctl start "$SERVICE_NAME" + fi +} + +svc_stop() { if [ "$MODE" = "docker" ]; then compose_cmd down; else systemctl stop "$SERVICE_NAME"; fi; } + +svc_restart() { + if [ "$MODE" = "docker" ]; then + docker_build_image || warn "沿用现有镜像重启容器" + compose_cmd up -d --force-recreate + else + systemctl restart "$SERVICE_NAME" + fi +} + +# 就绪探针:本地请求**必须**绕开系统代理(否则拿到的是代理返回的 404,见 AGENTS.md §7 第 1 条) +probe_ready() { + command -v curl >/dev/null 2>&1 || return 1 + curl -fsS --noproxy '*' --max-time 3 -o /dev/null "http://127.0.0.1:$(current_port)/healthz" >/dev/null 2>&1 +} + +wait_ready() { + local timeout="${1:-60}" i=0 + while [ "$i" -lt "$timeout" ]; do + probe_ready && return 0 + i=$((i + 1)) + sleep 1 + done + return 1 +} + +print_recent_logs() { + printf '%s--- 最近日志 ---%s\n' "$C_DIM" "$C_RESET" >&2 + if [ "$MODE" = "docker" ]; then + compose_cmd logs --tail 50 2>&1 || true + else + journalctl -u "$SERVICE_NAME" -n 50 --no-pager 2>&1 || true + fi +} + +# ---------------------------------------------------------------- 6. 备份 + +ensure_backup_dir() { + mkdir -p "$BACKUP_DIR" + chmod 700 "$BACKUP_DIR" +} + +# 程序文件快照(回滚用):data / logs / runtime / 配置软链都不进快照 +snapshot_program_files() { + local dest="$1" item + ensure_backup_dir + mkdir -p "$(dirname "$dest")" + local items=() + for item in server web node_modules tools bin systemd package.json VERSION README.txt Dockerfile .cross-built; do + [ -e "${INSTALL_DIR}/${item}" ] && items+=("$item") + done + [ "${#items[@]}" -gt 0 ] || die "安装目录里没有任何可打包的程序文件" + tar -czf "$dest" -C "$INSTALL_DIR" "${items[@]}" + chmod 600 "$dest" +} + +# 数据快照:SQLite 用 VACUUM INTO(服务运行中也一致);MySQL 用 mysqldump +snapshot_database() { + local dest_dir="$1" + if [ "$DB_KIND" = "mysql" ]; then + local dump_bin; dump_bin="$(command -v mysqldump 2>/dev/null || true)" + if [ -z "$dump_bin" ]; then + warn "未找到 mysqldump:本次备份只含配置,数据库请自行 mysqldump 备份" + return 0 + fi + local url host port dbname user pass + url="$(cfg_get DATABASE_URL)" + user="$(printf '%s' "$url" | sed -n 's#^mysql://\([^:@]*\).*#\1#p')" + pass="$(printf '%s' "$url" | sed -n 's#^mysql://[^:]*:\([^@]*\)@.*#\1#p')" + host="$(printf '%s' "$url" | sed -n 's#^mysql://[^@]*@\([^:/]*\).*#\1#p')" + port="$(printf '%s' "$url" | sed -n 's#^mysql://[^@]*@[^:]*:\([0-9]*\)/.*#\1#p')" + dbname="$(printf '%s' "$url" | sed -n 's#^mysql://[^/]*/\([^?]*\).*#\1#p')" + # 密码经环境变量交给子进程(不进 argv);MYSQL_PWD 是 mysqldump 官方支持的传参方式 + MYSQL_PWD="$pass" "$dump_bin" -h "${host:-127.0.0.1}" -P "${port:-3306}" -u "${user:-root}" \ + --single-transaction --routines "$dbname" >"${dest_dir}/database.sql" + chmod 600 "${dest_dir}/database.sql" + return 0 + fi + + local db_file; db_file="$(cfg_get DATABASE_URL)" + db_file="${db_file#file:}" + + if [ "$MODE" = "docker" ]; then + # Docker 形态:数据在容器卷里,宿主机看不到那个文件 —— 在容器内做一致性快照再取出来 + if compose_cmd exec -T -e LOG_LEVEL=error -e DOTENV_CONFIG_QUIET=true server \ + node tools/admin-cli.mjs snapshot /tmp/classhelper-snapshot.db >/dev/null 2>&1; then + local cid + cid="$(compose_cmd ps -q server 2>/dev/null | head -n 1)" + if [ -n "$cid" ] && docker cp "${cid}:/tmp/classhelper-snapshot.db" "${dest_dir}/classhelper.db" >/dev/null 2>&1; then + compose_cmd exec -T server rm -f /tmp/classhelper-snapshot.db >/dev/null 2>&1 || true + chmod 600 "${dest_dir}/classhelper.db" + return 0 + fi + fi + warn "容器内快照失败:本次备份只含配置(可进容器手工导出数据)" + return 0 + fi + + case "$db_file" in + /*) ;; + *) db_file="${INSTALL_DIR}/server/${db_file}" ;; + esac + if [ ! -f "$db_file" ]; then + warn "找不到 SQLite 数据文件:${db_file}(本次备份只含配置)" + return 0 + fi + if [ -x "$NODE_BIN" ] && [ -f "${TOOLS_DIR}/admin-cli.mjs" ]; then + if CLASSHELPER_SNAPSHOT_DB="$db_file" LOG_LEVEL=error DOTENV_CONFIG_QUIET=true \ + "$NODE_BIN" "${TOOLS_DIR}/admin-cli.mjs" snapshot "${dest_dir}/classhelper.db" >/dev/null 2>&1; then + chmod 600 "${dest_dir}/classhelper.db" + return 0 + fi + warn "VACUUM INTO 快照失败,退回整文件复制(服务运行中复制可能不一致,建议停机备份)" + fi + cp -p "$db_file" "${dest_dir}/classhelper.db" + chmod 600 "${dest_dir}/classhelper.db" +} + +cmd_backup() { + local quiet=0 keep=0 note="" + while [ $# -gt 0 ]; do + case "$1" in + --quiet) quiet=1; shift ;; + --keep) keep="${2:-0}"; shift 2 ;; + --note) note="${2:-}"; shift 2 ;; + list) shift; cmd_backup_list; return 0 ;; + restore) shift; cmd_backup_restore "$@"; return 0 ;; + schedule) shift; cmd_backup_schedule "$@"; return 0 ;; + *) die "backup: 未知参数 $1(classhelper help)" ;; + esac + done + + need_root "backup" + require_install + ensure_backup_dir + local stamp; stamp="$(date '+%Y%m%d-%H%M%S')" + local slug="" + if [ -n "$note" ]; then slug="-$(printf '%s' "$note" | tr -c 'A-Za-z0-9_.-' '-')"; fi + local dest="${BACKUP_DIR}/${stamp}${slug}" + mkdir -p "$dest" + chmod 700 "$dest" + + [ "$quiet" = "0" ] && info "备份到 ${dest} ..." + if [ -f "$CONFIG_FILE" ]; then cp -p "$CONFIG_FILE" "${dest}/config.env"; chmod 600 "${dest}/config.env"; fi + snapshot_database "$dest" + + # 元信息:只记版本与形态,**不记任何密钥** + cat >"${dest}/meta.json" </dev/null; then + local index=0 dir + while read -r dir; do + [ -n "$dir" ] || continue + index=$((index + 1)) + if [ "$index" -gt "$keep" ]; then + rm -rf "${BACKUP_DIR:?}/${dir}" + [ "$quiet" = "0" ] && info "已清理过期备份 ${dir}(保留最近 ${keep} 份)" + fi + done < <(ls -1 "$BACKUP_DIR" 2>/dev/null | grep -E '^[0-9]{8}-[0-9]{6}' | sort -r) + fi + + [ "$quiet" = "0" ] && log "备份完成:${dest}" + return 0 +} + +cmd_backup_list() { + ensure_backup_dir + printf '%s备份目录:%s%s\n' "$C_BOLD" "$BACKUP_DIR" "$C_RESET" + local found=0 dir size + while read -r dir; do + [ -n "$dir" ] || continue + found=1 + size="$(du -sh "${BACKUP_DIR}/${dir}" 2>/dev/null | awk '{print $1}')" + printf ' %s %6s %s\n' "$dir" "${size:-?}" "$([ -f "${BACKUP_DIR}/${dir}/classhelper.db" ] && echo '含数据库' || echo '仅配置')" + done < <(ls -1 "$BACKUP_DIR" 2>/dev/null | grep -E '^[0-9]{8}-[0-9]{6}' | sort -r) + [ "$found" = "1" ] || printf ' %s(还没有备份,运行 classhelper backup)%s\n' "$C_DIM" "$C_RESET" + + printf '\n%s程序快照(回滚用,%s/rollback):%s\n' "$C_BOLD" "$BACKUP_DIR" "$C_RESET" + found=0 + while read -r dir; do + [ -n "$dir" ] || continue + found=1 + size="$(du -h "${BACKUP_DIR}/rollback/${dir}" 2>/dev/null | awk '{print $1}')" + printf ' %s %s\n' "$dir" "${size:-?}" + done < <(ls -1t "${BACKUP_DIR}/rollback" 2>/dev/null) + [ "$found" = "1" ] || printf ' %s(无)%s\n' "$C_DIM" "$C_RESET" +} + +# 数据文件路径(SQLite) +sqlite_file_path() { + local db_file; db_file="$(cfg_get DATABASE_URL)" + db_file="${db_file#file:}" + case "$db_file" in + /*) printf '%s' "$db_file" ;; + *) printf '%s' "${INSTALL_DIR}/server/${db_file}" ;; + esac +} + +cmd_backup_restore() { + need_root "backup restore" + require_install + local src="${1:-}" + [ -n "$src" ] || die "用法:classhelper backup restore <备份目录名|路径>" + [ -d "$src" ] || src="${BACKUP_DIR}/${src}" + [ -d "$src" ] || die "找不到备份:${1}" + + printf '将用 %s 覆盖当前配置' "$src" + [ -f "${src}/classhelper.db" ] && printf '与数据库' + printf ',服务会先停止。\n' + read -r -p "确认恢复?输入 yes 继续:" reply + [ "$reply" = "yes" ] || { info "已取消"; return 0; } + + # 恢复前先备份现状,免得恢复错了无从退回 + cmd_backup --quiet --note "restore-前" || true + svc_stop || true + if [ -f "${src}/config.env" ]; then + cp -p "${src}/config.env" "$CONFIG_FILE" + chmod 600 "$CONFIG_FILE" + fi + if [ -f "${src}/classhelper.db" ]; then + if [ "$MODE" = "docker" ]; then + # 数据在容器卷里,宿主机上的路径是容器内路径,直接 cp 会在宿主机建出一个假目录 + warn "Docker 形态的数据库恢复要手工做(数据在容器卷里):" + printf ' %s stop\n docker cp %s <容器>: /app/data/classhelper.db\n %s start\n' \ + "$SERVICE_NAME" "${src}/classhelper.db" "$SERVICE_NAME" + else + local db_file; db_file="$(sqlite_file_path)" + rm -f "${db_file}-wal" "${db_file}-shm" + cp -p "${src}/classhelper.db" "$db_file" + chown "${SERVICE_USER}:${SERVICE_USER}" "$db_file" 2>/dev/null || true + fi + fi + svc_start + if wait_ready 90; then + audit restore "$src" ok + log "恢复完成,服务已就绪 ✅" + else + audit restore "$src" failed + print_recent_logs + die "恢复后 90 秒内没通过探针,请检查上面的日志" + fi +} + +cmd_backup_schedule() { + need_root "backup schedule" + require_install + local mode="${1:-}" + case "$mode" in + daily|weekly|off) ;; + *) die "用法:classhelper backup schedule daily|weekly|off" ;; + esac + if [ "$MODE" = "docker" ]; then + die "Docker 形态请用宿主机的 cron 调 'classhelper backup --quiet'(systemd timer 只装在直接安装形态)" + fi + + if [ "$mode" = "off" ]; then + systemctl disable --now classhelper-backup.timer >/dev/null 2>&1 || true + rm -f /etc/systemd/system/classhelper-backup.timer + systemctl daemon-reload + audit backup-schedule off ok + log "已关闭定时备份" + return 0 + fi + + local keep=7 oncalendar='*-*-* 03:30:00' + if [ "$mode" = "weekly" ]; then + keep=4 + oncalendar='Mon *-*-* 03:30:00' + fi + local template="${INSTALL_DIR}/systemd/classhelper-backup" + if [ -f "${template}.service" ] && [ -f "${template}.timer" ]; then + sed -e "s#__BIN__#/usr/local/bin/classhelper#g" -e "s#__KEEP__#${keep}#g" \ + "${template}.service" >/etc/systemd/system/classhelper-backup.service + sed -e "s#__ONCALENDAR__#${oncalendar}#g" "${template}.timer" >/etc/systemd/system/classhelper-backup.timer + else + # 安装包里没带模板(老版本安装)时现场生成,保证功能可用 + cat >/etc/systemd/system/classhelper-backup.service </etc/systemd/system/classhelper-backup.timer </dev/null + audit backup-schedule "$mode" ok + log "已开启定时备份(${mode},保留最近 ${keep} 份)" + systemctl list-timers classhelper-backup.timer --no-pager 2>/dev/null | head -2 || true +} + +# ---------------------------------------------------------------- 7. status / doctor + +human_size() { + local bytes="${1:-0}" + case "$bytes" in ''|*[!0-9]*) printf '0 B'; return 0 ;; esac + if [ "$bytes" -ge 1073741824 ]; then printf '%s GB' "$((bytes / 1073741824))" + elif [ "$bytes" -ge 1048576 ]; then printf '%s MB' "$((bytes / 1048576))" + elif [ "$bytes" -ge 1024 ]; then printf '%s KB' "$((bytes / 1024))" + else printf '%s B' "$bytes"; fi +} + +first_ip() { + local ip + ip="$(hostname -I 2>/dev/null | awk '{print $1}')" + printf '%s' "${ip:-127.0.0.1}" +} + +listen_on_port() { + local port="$1" + if command -v ss >/dev/null 2>&1; then + ss -ltn 2>/dev/null | awk -v p=":${port}\$" '$4 ~ p {print $4; exit}' + elif command -v netstat >/dev/null 2>&1; then + netstat -ltn 2>/dev/null | awk -v p=":${port}\$" '$4 ~ p {print $4; exit}' + fi +} + +cmd_status() { + require_install + local port; port="$(current_port)" + local active="inactive" enabled="disabled" healthy="no" + svc_active && active="active" || true + svc_enabled && enabled="enabled" || true + probe_ready && healthy="yes" || true + + local db_desc="${DB_KIND}" + if [ "$DB_KIND" = "sqlite" ]; then + if [ "$MODE" = "docker" ]; then + # 数据在容器卷里,宿主机上看不到那个文件,别报成"缺失" + db_desc="sqlite(Docker 卷 classhelper-data 内)" + else + local db_file; db_file="$(sqlite_file_path)" + if [ -f "$db_file" ]; then + db_desc="sqlite($(human_size "$(stat -c '%s' "$db_file" 2>/dev/null || echo 0)"))" + else + db_desc="sqlite(数据文件缺失:${db_file})" + fi + fi + fi + local listen; listen="$(listen_on_port "$port")" + [ -n "$listen" ] || listen="未监听" + + if [ "${1:-}" = "--json" ]; then + cat </dev/null || echo 未知)" + doctor_check "配置文件权限为 600(实际 ${bits})" "$([ "$bits" = "600" ] && echo 0 || echo 1)" "chmod 600 ${CONFIG_FILE}" + bits="$(stat -c '%a' "$CONFIG_DIR" 2>/dev/null || echo 未知)" + doctor_check "配置目录权限为 700(实际 ${bits})" "$([ "$bits" = "700" ] && echo 0 || echo 1)" "chmod 700 ${CONFIG_DIR}" + + if [ "$MODE" = "native" ]; then + local link_ok=1 link="${INSTALL_DIR}/.env" + if [ -L "$link" ] && [ "$(readlink "$link")" = "$CONFIG_FILE" ]; then link_ok=0; fi + doctor_check "安装目录 .env 是指向配置真身的软链" "$link_ok" "ln -sfn ${CONFIG_FILE} ${link}(Web 端「一键切换数据库」依赖它)" + fi + + local node_ok=1 node_ver="未找到" + if [ -x "$NODE_BIN" ]; then + node_ver="$("$NODE_BIN" -v 2>/dev/null || echo '?')" + local major minor + major="$(printf '%s' "$node_ver" | sed 's/^v\([0-9]*\).*/\1/')" + minor="$(printf '%s' "$node_ver" | sed 's/^v[0-9]*\.\([0-9]*\).*/\1/')" + if [ "${major:-0}" -gt 20 ] || { [ "${major:-0}" -eq 20 ] && [ "${minor:-0}" -ge 19 ]; }; then node_ok=0; fi + fi + doctor_check "Node 运行时可用且 ≥20.19(${node_ver})" "$node_ok" "重装以重新下载内置 Node:bash install.sh" + + local data_ok=1 + if [ -d "$DATA_DIR" ] && touch "${DATA_DIR}/.doctor-probe" 2>/dev/null; then + rm -f "${DATA_DIR}/.doctor-probe"; data_ok=0 + fi + doctor_check "数据目录可写(${DATA_DIR})" "$data_ok" "chown -R ${SERVICE_USER}:${SERVICE_USER} ${DATA_DIR}" + + if [ "$MODE" = "native" ]; then + doctor_check "systemd 单元已安装" "$([ -f "/etc/systemd/system/${SERVICE_NAME}.service" ] && echo 0 || echo 1)" "在安装目录执行 bash install.sh" + doctor_check "服务正在运行" "$(svc_active && echo 0 || echo 1)" "classhelper start,然后 classhelper logs -n 80" + doctor_check "服务开机自启" "$(svc_enabled && echo 0 || echo 1)" "systemctl enable ${SERVICE_NAME}" + else + doctor_check "compose 文件存在(${COMPOSE_FILE})" "$([ -f "$COMPOSE_FILE" ] && echo 0 || echo 1)" "重装 Docker 形态:bash install.sh" + doctor_check "容器正在运行" "$(svc_active && echo 0 || echo 1)" "classhelper start" + fi + + doctor_check "健康探针 /healthz" "$(probe_ready && echo 0 || echo 1)" "classhelper logs -n 80" + + local db_ok=1 + if [ -f "${TOOLS_DIR}/admin-cli.mjs" ] && [ -x "$NODE_BIN" ]; then + if "$NODE_BIN" "${TOOLS_DIR}/admin-cli.mjs" info >/dev/null 2>&1; then db_ok=0; fi + fi + doctor_check "数据库可连通" "$db_ok" "检查 config.env 的 DATABASE_URL 与数据库服务状态" + + local secret; secret="$(cfg_get JWT_SECRET)" + doctor_check "JWT_SECRET 长度 ≥32" "$([ "${#secret}" -ge 32 ] && echo 0 || echo 1)" "classhelper key rotate" + + # 交叉构建的包(在 Windows 上打的):能装能跑,但 prisma 的原生 schema engine 是宿主平台的, + # Web 端「数据库一键切换」那一步可能失败 —— 不做成静默,这里明确报出来。 + if [ -f "${INSTALL_DIR}/.cross-built" ]; then + doctor_check "安装包来自 Linux 上构建的正式发布包" 1 \ + "这是交叉构建的包($(tr -d '\n' <"${INSTALL_DIR}/.cross-built" 2>/dev/null)):Web 端「数据库一键切换」可能失败,其余功能正常;正式部署请用 GitHub Actions 出的包" + fi + + local avail; avail="$(df -Pk "$INSTALL_DIR" 2>/dev/null | awk 'NR==2 {print $4}')" + doctor_check "磁盘剩余 ≥1GB($(human_size "${avail:-0}"))" "$([ "${avail:-0}" -ge 1048576 ] && echo 0 || echo 1)" "清理旧备份:classhelper backup list" + + local newest="" backup_ok=1 + newest="$(ls -1t "$BACKUP_DIR" 2>/dev/null | grep -E '^[0-9]{8}-[0-9]{6}' | head -1)" + if [ -n "$newest" ]; then + local age_days + age_days=$(( ( $(date +%s) - $(stat -c '%Y' "${BACKUP_DIR}/${newest}" 2>/dev/null || echo 0) ) / 86400 )) + [ "$age_days" -le 7 ] && backup_ok=0 + fi + doctor_check "最近 7 天内有备份(${newest:-无})" "$backup_ok" "classhelper backup;长期可用 classhelper backup schedule daily" + + printf '\n' + if [ "$DOCTOR_FAIL" = "0" ]; then + log "体检通过:${DOCTOR_PASS}/${DOCTOR_PASS} 项 ✅" + else + warn "体检结果:${DOCTOR_PASS} 项通过,${DOCTOR_FAIL} 项待处理" + printf '\n%s建议依次处理:%s\n' "$C_BOLD" "$C_RESET" + local tip + for tip in "${DOCTOR_TIP[@]}"; do printf ' · %s\n' "$tip"; done + fi + return 0 +} + +# ---------------------------------------------------------------- 8. password + +# 交互优先 /dev/tty(不受管道影响),没有 tty(自动化 / ssh 无 -t)时回退 stdin。 +# 回退的意义:验证脚本可以 `printf '%s\n' "$PW" | classhelper password admin` —— 密码既不在 argv, +# 也不进 shell history。 +open_tty() { + [ "${CLASSHELPER_NO_TTY:-0}" = "1" ] && return 1 + exec 3/dev/null +} + +read_secret() { # $1=提示语 $2=目标变量名;读到 EOF 时把 READ_SECRET_EOF 置 1 + local prompt="$1" var="$2" value='' + READ_SECRET_EOF=0 + if open_tty; then + printf '%s' "$prompt" >&3 + if ! IFS= read -r -s value <&3; then READ_SECRET_EOF=1; fi + printf '\n' >&3 + exec 3<&- || true + else + printf '%s' "$prompt" >&2 + if ! IFS= read -r -s value; then READ_SECRET_EOF=1; fi + printf '\n' >&2 + fi + printf -v "$var" '%s' "$value" +} + +# 与服务端 tools/admin-cli.mjs 同一套强度规则(两处口径必须一致) +password_problems() { # $1=密码 $2=用户名 + local pw="$1" user="$2" kinds=0 + local problems=() + [ "${#pw}" -ge 8 ] || problems+=("长度至少 8 位") + [ "${#pw}" -le 128 ] || problems+=("长度不要超过 128 位") + [[ "$pw" =~ [a-z] ]] && kinds=$((kinds + 1)) + [[ "$pw" =~ [A-Z] ]] && kinds=$((kinds + 1)) + [[ "$pw" =~ [0-9] ]] && kinds=$((kinds + 1)) + [[ "$pw" =~ [^A-Za-z0-9] ]] && kinds=$((kinds + 1)) + [ "$kinds" -ge 3 ] || problems+=("需包含小写字母 / 大写字母 / 数字 / 符号 中至少 3 类") + if [ -n "$user" ] && [ "$(printf '%s' "$pw" | tr 'A-Z' 'a-z')" = "$(printf '%s' "$user" | tr 'A-Z' 'a-z')" ]; then + problems+=("不能与用户名相同") + fi + case "$pw" in + 123456*|password*|admin*|classhelper*|qwerty*|111111*|abc123*) + [ "${#pw}" -lt 12 ] && problems+=("过于常见,请换一个") ;; + esac + printf '%s' "$(IFS=';'; printf '%s' "${problems[*]:-}")" +} + +admin_cli() { + [ -f "${TOOLS_DIR}/admin-cli.mjs" ] || die "缺少 ${TOOLS_DIR}/admin-cli.mjs(重装可补齐)" + # LOG_LEVEL=error + DOTENV_CONFIG_QUIET=true 是必须的:admin-cli 复用了服务端的 env/logger + # 模块,那两个模块会往 **stdout** 打日志(dotenv 的 "◇ injected env..." 提示、Prisma 初始化一行), + # 而本命令的 stdout 是要被当数据解析的 —— dump-hash 的 JSON 直接写进回滚用的备份文件, + # 混进一行日志就不是合法 JSON 了(曾经会因此让"改密失败自动回滚"整条链路失效)。 + # env.ts 用 dotenv 加载配置,而 dotenv 既不覆盖已存在的环境变量、也会读 DOTENV_CONFIG_QUIET。 + if [ "$MODE" = "docker" ]; then + # 镜像里也带了同一份工具(deploy/Dockerfile.linux-package 已 COPY deploy/tools) + compose_cmd exec -T -e LOG_LEVEL=error -e DOTENV_CONFIG_QUIET=true server node tools/admin-cli.mjs "$@" + else + [ -x "$NODE_BIN" ] || die "缺少 Node 运行时(${NODE_BIN})" + LOG_LEVEL=error DOTENV_CONFIG_QUIET=true "$NODE_BIN" "${TOOLS_DIR}/admin-cli.mjs" "$@" + fi +} + +cmd_password() { + local target="" class_code="" do_list=0 + while [ $# -gt 0 ]; do + case "$1" in + --list) do_list=1; shift ;; + --class) class_code="${2:-}"; shift 2 ;; + -h|--help) info "用法:classhelper password [用户名] [--list] [--class <班级码>]"; return 0 ;; + -*) die "password: 未知参数 $1" ;; + *) target="$1"; shift ;; + esac + done + need_root "password" + require_install + + if [ "$do_list" = "1" ]; then + admin_cli list + return 0 + fi + + local key="${target:-admin}" label="${target:-admin}" + if [ -n "$class_code" ]; then + key="class:${class_code}" + label="班级 ${class_code}" + fi + + printf '%s现在修改 %s 的密码%s\n' "$C_BOLD" "$label" "$C_RESET" + printf '%s提示:输入不回显;密码不会出现在命令行、进程列表或日志里。%s\n\n' "$C_DIM" "$C_RESET" + + # 有界循环:每次尝试都计数,且 stdin 读完(EOF)立刻退出 —— + # 否则管道喂入不合规的密码时会一直重读空值,表现为"卡住不动"。 + local pw1='' pw2='' problems='' tries=0 accepted=0 + while [ "$tries" -lt 3 ] && [ "$accepted" = "0" ]; do + tries=$((tries + 1)) + read_secret "新密码:" pw1 + if [ -z "$pw1" ]; then + if [ "${READ_SECRET_EOF:-0}" = "1" ]; then + die "读不到输入(标准输入已结束)。自动化请用管道:printf '%s\\n%s\\n' \"\$PW\" \"\$PW\" | classhelper password ${key}" + fi + warn "密码不能为空,请重试" + continue + fi + problems="$(password_problems "$pw1" "${target:-admin}")" + if [ -n "$problems" ]; then + warn "密码不合规:${problems}" + continue + fi + read_secret "再输入一次确认:" pw2 + if [ "$pw1" != "$pw2" ]; then + warn "两次输入不一致,请重来" + continue + fi + accepted=1 + done + if [ "$accepted" != "1" ]; then + audit password "$key" rejected + die "连续 3 次输入不合规,已退出(密码未改动)" + fi + + # 改前备份原哈希:失败或复核不过时按这份文件回滚(只含用户名 + bcrypt 哈希,权限 600)。 + # 只取"以 { 开头的那一行" —— 即使将来日志噪声绕过 LOG_LEVEL,落盘的也仍是合法 JSON。 + ensure_backup_dir + local stamp; stamp="$(date '+%Y%m%d-%H%M%S')" + local hash_backup="${BACKUP_DIR}/password-${stamp}.json" + local had_hash=1 + if admin_cli dump-hash "$key" 2>/dev/null | grep -E '^\{' | tail -n 1 >"$hash_backup"; then + chmod 600 "$hash_backup" + had_hash=0 + info "原密码哈希已备份到 ${hash_backup}(权限 600)" + else + rm -f "$hash_backup" + warn "未能读取原哈希(账号可能尚未设置密码),改后将无法自动回滚" + fi + + local result=1 + if printf '%s\n' "$pw1" | admin_cli set-password "$key"; then result=0; fi + unset pw1 pw2 + + if [ "$result" != "0" ]; then + warn "写入失败" + if [ "$had_hash" = "0" ]; then + info "正在用备份回滚原密码哈希 ..." + if admin_cli restore-hash "$hash_backup" >/dev/null 2>&1; then + log "已回滚到原密码" + else + audit password "$key" rollback-failed + die "回滚失败!请用 classhelper backup restore 恢复数据(备份文件:${hash_backup})" + fi + fi + audit password "$key" failed + exit 1 + fi + + audit password "$key" ok + log "密码已更新:${label}" + printf '%s已登录的会话不受影响(JWT 仍有效);需要让所有会话立即失效请执行 classhelper key rotate。%s\n' "$C_DIM" "$C_RESET" +} + +# ---------------------------------------------------------------- 9. config / port / key + +# 把值写进配置项:值经临时文件传给 awk,**绝不进 argv**(argv 任何用户都能从 ps 看到) +config_write_value() { + local key="$1" value="$2" tmp_value tmp_file + tmp_value="$(mktemp)"; tmp_file="$(mktemp)" + chmod 600 "$tmp_value" "$tmp_file" + printf '%s' "$value" >"$tmp_value" + awk -v k="$key" -v vf="$tmp_value" ' + BEGIN { while ((getline line < vf) > 0) v = (v == "" ? line : v "\n" line); close(vf) } + $0 ~ "^[[:space:]]*" k "=" && !done { print k "=" v; done = 1; next } + { print } + END { if (!done) printf "\n%s=%s\n", k, v } + ' "$CONFIG_FILE" >"$tmp_file" + mv "$tmp_file" "$CONFIG_FILE" + rm -f "$tmp_value" + chmod 600 "$CONFIG_FILE" +} + +restart_after_change() { + if [ "${CLASSHELPER_ASSUME_YES:-0}" = "1" ]; then + svc_restart || true + if wait_ready 90; then log "服务已重启并就绪 ✅"; else warn "服务重启后未通过探针,请 classhelper logs -n 80"; fi + return 0 + fi + local reply="" + read -r -p "现在重启服务让配置生效?[y/N] " reply || true + case "$reply" in + y|Y|yes|YES) + svc_restart || true + if wait_ready 90; then log "服务已重启并就绪 ✅"; else warn "服务重启后未通过探针,请 classhelper logs -n 80"; fi + ;; + *) info "已跳过重启:配置在下次重启后生效(classhelper restart)" ;; + esac +} + +cmd_config() { + local sub="${1:-show}"; shift || true + case "$sub" in + get) + local key="${1:-}"; [ -n "$key" ] || die "用法:classhelper config get " + local value; value="$(cfg_get "$key")" + if is_secret_key "$key"; then mask_value "$value"; else printf '%s\n' "$value"; fi + ;; + show) + require_install + [ -f "$CONFIG_FILE" ] || die "配置文件不存在:${CONFIG_FILE}" + local reveal=0 + [ "${1:-}" = "--reveal" ] && reveal=1 + printf '%s%s%s\n' "$C_BOLD" "$CONFIG_FILE" "$C_RESET" + local line key value + while IFS= read -r line; do + case "$line" in + ''|'#'*) printf '%s\n' "$line"; continue ;; + esac + key="${line%%=*}" + value="${line#*=}" + if [ "$reveal" = "0" ] && is_secret_key "$key"; then + printf '%s=%s\n' "$key" "$(mask_value "$(printf '%s' "$value" | sed -e 's/^"//' -e 's/"$//')")" + else + printf '%s\n' "$line" + fi + done <"$CONFIG_FILE" + [ "$reveal" = "0" ] && printf '\n%s敏感值已打码;要看原文用 config show --reveal(会在终端留痕)%s\n' "$C_DIM" "$C_RESET" + ;; + edit) + need_root "config edit" + require_install + cmd_backup --quiet --note "config-edit-前" || true + "${EDITOR:-vi}" "$CONFIG_FILE" + chmod 600 "$CONFIG_FILE" + audit config-edit "$CONFIG_FILE" ok + log "已保存(权限已校正为 600)" + restart_after_change + ;; + set) + need_root "config set" + require_install + local key="${1:-}" value="${2:-}" + [ -n "$key" ] || die "用法:classhelper config set (敏感项会自动改为交互输入)" + [[ "$key" =~ ^[A-Z][A-Z0-9_]*$ ]] || die "KEY 只能是大写字母、数字与下划线" + if is_secret_key "$key"; then + # 敏感值不接受命令行传参:argv 会进 shell history,也会出现在 ps 里 + local secret='' + read_secret "输入 ${key} 的新值(不回显):" secret + [ -n "$secret" ] || die "值不能为空" + value="$secret" + fi + cmd_backup --quiet --note "config-set-前" || true + config_write_value "$key" "$(quote_env_value "$value")" + unset value secret + audit config-set "$key" ok + log "已更新 ${key}" + restart_after_change + ;; + *) die "用法:classhelper config get|set|show|edit" ;; + esac +} + +cmd_port() { + need_root "port" + require_install + local new="${1:-}" + if [ -z "$new" ]; then + printf '当前端口:%s\n' "$(current_port)" + return 0 + fi + [[ "$new" =~ ^[0-9]+$ ]] || die "端口必须是数字" + [ "$new" -ge 1 ] && [ "$new" -le 65535 ] || die "端口范围 1-65535" + local old; old="$(current_port)" + if [ "$old" = "$new" ]; then info "端口已经是 ${new},无需修改"; return 0; fi + if [ -n "$(listen_on_port "$new")" ]; then + die "端口 ${new} 已被占用(ss -ltnp 查看)" + fi + CLASSHELPER_ASSUME_YES=1 cmd_config set PORT "$new" + audit port "${old}->${new}" ok + if command -v firewall-cmd >/dev/null 2>&1; then + warn "记得放行防火墙:firewall-cmd --add-port=${new}/tcp --permanent && firewall-cmd --reload" + elif command -v ufw >/dev/null 2>&1; then + warn "记得放行防火墙:ufw allow ${new}/tcp" + fi +} + +cmd_key() { + local sub="${1:-}" + [ "$sub" = "rotate" ] || die "用法:classhelper key rotate" + need_root "key rotate" + require_install + printf '%s轮换 JWT_SECRET 会让所有已登录用户立即失效(需要重新登录)。%s\n' "$C_YELLOW" "$C_RESET" + if [ "${CLASSHELPER_ASSUME_YES:-0}" != "1" ]; then + local reply="" + read -r -p "确认轮换?输入 yes 继续:" reply || true + [ "$reply" = "yes" ] || { info "已取消"; return 0; } + fi + cmd_backup --quiet --note "key-rotate-前" || true + local new_secret + if [ -x "$NODE_BIN" ]; then + new_secret="$("$NODE_BIN" -e 'process.stdout.write(require("node:crypto").randomBytes(48).toString("hex"))')" + else + new_secret="$(head -c 48 /dev/urandom | od -An -tx1 | tr -d ' \n')" + fi + # 直接写配置而不是走 `config set`:后者对敏感键强制交互输入(防 argv 泄漏), + # 而这里的值是本命令自己生成的随机串,走 config_write_value 同样不进 argv。 + config_write_value JWT_SECRET "$(quote_env_value "$new_secret")" + unset new_secret + audit key-rotate - ok + log "已轮换 JWT_SECRET" + restart_after_change +} + +# ---------------------------------------------------------------- 10. upgrade / rollback + +asset_arch() { + case "$(uname -m)" in + x86_64|amd64) printf 'x64' ;; + aarch64|arm64) printf 'arm64' ;; + *) uname -m ;; + esac +} + +github_latest_tag() { + command -v curl >/dev/null 2>&1 || return 1 + local json + json="$(curl "${CURL_OPTS[@]}" "${GITHUB_API}/releases/latest" 2>/dev/null)" || return 1 + printf '%s' "$json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1 +} + +resolve_download() { # $1=版本(可空) $2=url(可空);输出下载地址 + local want="$1" url="$2" tag ver + if [ -n "$url" ]; then printf '%s' "$url"; return 0; fi + if [ -n "$want" ]; then tag="v${want#v}"; else tag="$(github_latest_tag)" || true; fi + [ -n "$tag" ] || return 1 + ver="${tag#v}" + printf '%s/releases/download/%s/classhelper-server-linux-%s-%s.tar.gz' \ + "$GITHUB_RELEASES_PAGE" "$tag" "$(asset_arch)" "$ver" +} + +download_to() { # $1=url $2=目标文件 + case "$1" in + file://*|/*) cp -f "${1#file://}" "$2" ;; + *) curl "${CURL_OPTS_LONG[@]}" -o "$2" "$1" ;; + esac +} + +verify_sha256() { # $1=文件 $2=期望值(可空) + local file="$1" want="${2:-}" got + if [ -z "$want" ]; then + warn "未提供 sha256,跳过哈希校验(内网分发也建议带上 --sha256)" + return 0 + fi + got="$(sha256sum "$file" | awk '{print $1}')" + [ "$got" = "$want" ] || { warn "哈希不匹配:期望 ${want},实际 ${got}"; return 1; } + return 0 +} + +# 用解出来的新文件替换程序目录;data / logs / runtime / 配置一律不动。 +# 用「改名 + 就位」而不是 rm + cp:升级中正在运行的就是本脚本(bin/classhelper), +# rm 掉再创建同名文件会让 bash 读到半个脚本;改名后运行中的进程继续读旧 inode,安全。 +replace_program_files() { # $1=解压出来的目录 + local src="$1" item + # 清理上次中途失败留下的残留 + for item in "${INSTALL_DIR}"/.old-*; do + [ -e "$item" ] && rm -rf "$item" + done + # .cross-built 是"这个包不在 Linux 上打的"标记:先删掉,新版包里没有它就不再提示 + rm -f "${INSTALL_DIR}/.cross-built" + for item in server web node_modules tools bin package.json VERSION README.txt Dockerfile .cross-built; do + [ -e "${src}/${item}" ] || continue + [ -e "${INSTALL_DIR}/${item}" ] && mv "${INSTALL_DIR}/${item}" "${INSTALL_DIR}/.old-${item}" + mv "${src}/${item}" "${INSTALL_DIR}/${item}" + rm -rf "${INSTALL_DIR:?}/.old-${item}" + done + chmod +x "${INSTALL_DIR}/bin/classhelper" 2>/dev/null || true + # 安装目录里的 .env 必须是指向配置真身的软链(Web 端「一键切换数据库」依赖它) + if [ -e "${INSTALL_DIR}/.env" ] && [ ! -L "${INSTALL_DIR}/.env" ]; then rm -f "${INSTALL_DIR}/.env"; fi + [ -L "${INSTALL_DIR}/.env" ] || ln -sfn "$CONFIG_FILE" "${INSTALL_DIR}/.env" +} + +extract_package() { # $1=tar.gz $2=目标目录 + mkdir -p "$2" + tar -xzf "$1" -C "$2" --strip-components=1 +} + +# 与安装器写入的 .installed.json 保持同一份扁平格式(classhelper 自己用 sed 解析) +write_meta() { # $1=版本 $2=来源 $3=sha256 + cat >"$META_FILE" <] [--sha256 H] [--check] [--force]"; return 0 ;; + *) die "upgrade: 未知参数 $1" ;; + esac + done + need_root "upgrade" + require_install + + local current; current="$(current_version)" + local target="$want" + + if [ -z "$url" ] && [ -z "$target" ]; then + target="$(github_latest_tag)" || true + if [ -z "$target" ]; then + warn "查不到 GitHub Release(本机可能没有外网,或匿名查询被限流)" + info "可手工指定:classhelper upgrade --url <安装包地址> [--sha256 <哈希>]" + return 0 + fi + target="${target#v}" + fi + + printf '当前版本:%s\n' "${current:-未知}" + [ -n "$target" ] && printf '目标版本:%s\n' "$target" + + if [ "$check_only" = "1" ]; then + if [ -z "$url" ] && [ -n "$target" ] && [ -n "$current" ]; then + if version_gt "$current" "$target"; then + log "有新版本可用:${current} → ${target}" + elif [ "$current" = "$target" ]; then + log "已经是最新版本(${current})" + else + info "远端版本 ${target} 不高于本机 ${current}(本机可能是自建版本)" + fi + printf '发布页:%s\n' "$GITHUB_RELEASES_PAGE" + fi + [ -n "$url" ] && printf '安装包:%s\n' "$url" + return 0 + fi + + if [ -z "$url" ] && [ -n "$current" ] && [ -n "$target" ] && [ "$current" = "$target" ] && [ "$force" != "1" ]; then + log "已经是最新版本(${current})。要强制重装同一版本:--force" + return 0 + fi + + local resolved; resolved="$(resolve_download "$target" "$url")" || die "无法确定下载地址" + info "下载:${resolved}" + local tmp; tmp="$(mktemp -d)" + local pkg="${tmp}/classhelper-server.tar.gz" + download_to "$resolved" "$pkg" || { rm -rf "$tmp"; die "下载失败:${resolved}"; } + verify_sha256 "$pkg" "$sha" || { rm -rf "$tmp"; die "安装包校验失败,已中止(未改动任何文件)"; } + info "sha256 校验通过" + + local stage="${tmp}/pkg" + extract_package "$pkg" "$stage" || { rm -rf "$tmp"; die "解压失败:安装包格式不对"; } + [ -f "${stage}/server/dist/index.js" ] || { rm -rf "$tmp"; die "安装包内容不对:缺少 server/dist/index.js"; } + local new_ver; new_ver="$(tr -d '[:space:]' <"${stage}/VERSION" 2>/dev/null || echo "$target")" + local pkg_sha; pkg_sha="$(sha256sum "$pkg" | awk '{print $1}')" + + info "备份数据与配置 ..." + cmd_backup --quiet --note "upgrade-${new_ver}-前" || warn "备份未完成,继续升级" + ensure_backup_dir + local rollback_file="${BACKUP_DIR}/rollback/$(date '+%Y%m%d-%H%M%S')-${current:-unknown}.tar.gz" + info "快照当前程序文件:${rollback_file}" + snapshot_program_files "$rollback_file" + + info "停止服务 ..." + svc_stop || warn "停止服务返回非 0(可能本来就没在运行)" + replace_program_files "$stage" + + svc_start || true + if ! wait_ready 90; then + warn "新版本启动失败,正在自动回滚到 ${current:-上一版本} ..." + print_recent_logs + svc_stop || true + if extract_package "$rollback_file" "${tmp}/rollback" && replace_program_files "${tmp}/rollback"; then + svc_start || true + if wait_ready 90; then + audit upgrade "${current}->${new_ver}" rolled-back + rm -rf "$tmp" + die "升级失败,已回滚到 ${current}(服务已恢复)。失败原因见上面的日志" + fi + fi + audit upgrade "${current}->${new_ver}" rollback-failed + rm -rf "$tmp" + die "升级失败且回滚后仍未就绪,请人工介入:classhelper logs -n 100" + fi + + write_meta "$new_ver" "$resolved" "$pkg_sha" + audit upgrade "${current}->${new_ver}" ok + rm -rf "$tmp" + log "升级完成:${current} → ${new_ver} ✅" + printf '%s注意:升级不会回滚数据库结构。若新版执行过迁移,回滚程序前请确认兼容性(必要时 classhelper backup restore)。%s\n' "$C_DIM" "$C_RESET" +} + +cmd_rollback() { + need_root "rollback" + require_install + local to="" list=0 + while [ $# -gt 0 ]; do + case "$1" in + --list) list=1; shift ;; + --to) to="${2:-}"; shift 2 ;; + *) die "rollback: 未知参数 $1" ;; + esac + done + + local dir="${BACKUP_DIR}/rollback" + if [ "$list" = "1" ] || [ -z "$to" ]; then + printf '%s可回滚的程序快照(%s):%s\n' "$C_BOLD" "$dir" "$C_RESET" + ls -1t "$dir" 2>/dev/null | head -20 || true + [ "$list" = "1" ] && return 0 + to="$(ls -1t "$dir" 2>/dev/null | head -1)" + [ -n "$to" ] || die "没有可用的程序快照(执行过一次 classhelper upgrade 才会产生)" + info "使用最近一份:${to}" + fi + + local file="$to" + [ -f "$file" ] || file="${dir}/${to}" + [ -f "$file" ] || die "找不到快照:${to}" + + printf '%s回滚只替换程序文件,不会回滚数据库结构。%s\n' "$C_YELLOW" "$C_RESET" + local reply="" + read -r -p "确认回滚到 $(basename "$file")?输入 yes 继续:" reply || true + [ "$reply" = "yes" ] || { info "已取消"; return 0; } + + local tmp; tmp="$(mktemp -d)" + extract_package "$file" "${tmp}/pkg" || { rm -rf "$tmp"; die "快照解压失败"; } + svc_stop || true + replace_program_files "${tmp}/pkg" + svc_start || true + if wait_ready 90; then + local now; now="$(current_version)" + write_meta "$now" "rollback:$(basename "$file")" "" + audit rollback "$(basename "$file")" ok + rm -rf "$tmp" + log "已回滚到 ${now} ✅" + else + audit rollback "$(basename "$file")" failed + print_recent_logs + rm -rf "$tmp" + die "回滚后服务未就绪,请人工排查" + fi +} + +# ---------------------------------------------------------------- 11. 其它 + +cmd_logs() { + local follow=0 lines=100 since="" unit="$SERVICE_NAME" + while [ $# -gt 0 ]; do + case "$1" in + -f|--follow) follow=1; shift ;; + -n) lines="${2:-100}"; shift 2 ;; + --since) since="${2:-}"; shift 2 ;; + --unit) unit="${2:-}"; shift 2 ;; + *) die "logs: 未知参数 $1" ;; + esac + done + if [ "$MODE" = "docker" ]; then + if [ "$follow" = "1" ]; then compose_cmd logs -f --tail "$lines" + else compose_cmd logs --tail "$lines"; fi + return 0 + fi + command -v journalctl >/dev/null 2>&1 || die "本机没有 journalctl(日志在 journald 里)" + local args=(-u "$unit" -n "$lines" --no-pager) + [ -n "$since" ] && args+=(--since "$since") + [ "$follow" = "1" ] && args+=(-f) + journalctl "${args[@]}" +} + +# 删除目录前的护栏:安装元数据是文件里读来的,绝不能因为里面的值被改坏就把系统目录删掉 +safe_remove_dir() { + local dir="${1:-}" + case "$dir" in + ''|/|/etc|/usr|/var|/opt|/bin|/sbin|/lib|/lib64|/boot|/home|/root|/tmp|/srv|/data|/dev|/proc) + warn "拒绝删除系统目录:${dir}(请手工处理)" + return 1 + ;; + esac + case "$dir" in + /*/*) ;; + *) warn "路径太浅,拒绝删除:${dir}"; return 1 ;; + esac + rm -rf "$dir" +} + +cmd_uninstall() { + need_root "uninstall" + local purge=0 keep_data=1 keep_config=1 keep_backup=1 assume_yes="${CLASSHELPER_ASSUME_YES:-0}" + while [ $# -gt 0 ]; do + case "$1" in + --purge) purge=1; shift ;; + --yes|-y) assume_yes=1; shift ;; + *) die "uninstall: 未知参数 $1" ;; + esac + done + if [ "$purge" = "1" ]; then keep_data=0; keep_config=0; keep_backup=0; fi + + if [ "$assume_yes" != "1" ] && [ "$purge" != "1" ]; then + printf '%s卸载会停止服务、删除程序文件;数据与配置默认会被搬到备份目录,不会丢。%s\n\n' "$C_BOLD" "$C_RESET" + local a="" + read -r -p "保留配置 config.env?[Y/n] " a || true; case "$a" in n|N) keep_config=0 ;; esac + read -r -p "保留数据库与数据目录?[Y/n] " a || true; case "$a" in n|N) keep_data=0 ;; esac + read -r -p "保留已有备份(${BACKUP_DIR})?[Y/n] " a || true; case "$a" in n|N) keep_backup=0 ;; esac + printf '\n' + read -r -p "确认卸载?输入 yes 继续:" a || true + [ "$a" = "yes" ] || { info "已取消"; return 0; } + fi + + local rescue="" + if [ "$keep_config" = "1" ] || [ "$keep_data" = "1" ]; then + rescue="${BACKUP_DIR}/uninstall-$(date '+%Y%m%d-%H%M%S')" + mkdir -p "$rescue"; chmod 700 "$rescue" + if [ "$keep_config" = "1" ] && [ -f "$CONFIG_FILE" ]; then cp -p "$CONFIG_FILE" "${rescue}/config.env"; fi + if [ "$keep_data" = "1" ] && [ -d "$DATA_DIR" ]; then cp -a "$DATA_DIR" "${rescue}/data"; fi + info "保留内容已搬到 ${rescue}" + fi + + info "停止并移除服务 ..." + if [ "$MODE" = "docker" ]; then + compose_cmd down --remove-orphans >/dev/null 2>&1 || true + else + systemctl disable --now "$SERVICE_NAME" >/dev/null 2>&1 || true + systemctl disable --now classhelper-backup.timer >/dev/null 2>&1 || true + rm -f "/etc/systemd/system/${SERVICE_NAME}.service" \ + /etc/systemd/system/classhelper-backup.service \ + /etc/systemd/system/classhelper-backup.timer \ + /etc/logrotate.d/classhelper + systemctl daemon-reload + fi + rm -f /etc/profile.d/classhelper.sh /usr/local/bin/classhelper /usr/share/bash-completion/completions/classhelper + + if [ "$keep_backup" != "1" ] && [ -d "$BACKUP_DIR" ]; then + safe_remove_dir "$BACKUP_DIR" && log "备份目录已删除:${BACKUP_DIR}" + fi + + if [ -d "$INSTALL_DIR" ]; then + if [ "$keep_data" = "1" ]; then + rm -rf "${INSTALL_DIR:?}/server" "${INSTALL_DIR:?}/web" "${INSTALL_DIR:?}/node_modules" \ + "${INSTALL_DIR:?}/tools" "${INSTALL_DIR:?}/bin" "${INSTALL_DIR:?}/runtime" \ + "${INSTALL_DIR:?}/systemd" "${INSTALL_DIR:?}/package.json" \ + "${INSTALL_DIR:?}/VERSION" "${INSTALL_DIR:?}/.installed.json" "${INSTALL_DIR:?}/.env" + log "程序文件已删除,数据保留在 ${INSTALL_DIR}/data" + else + safe_remove_dir "$INSTALL_DIR" && log "安装目录已删除:${INSTALL_DIR}" + fi + fi + if [ -d "$INSTALL_DIR" ] && [ -z "$(ls -A "$INSTALL_DIR" 2>/dev/null || true)" ]; then rmdir "$INSTALL_DIR" 2>/dev/null || true; fi + + # 配置目录只在明确不保留时删(--purge 或交互里回答了 n) + if [ "$keep_config" != "1" ] && [ -d "$CONFIG_DIR" ]; then + safe_remove_dir "$CONFIG_DIR" && log "配置目录已删除:${CONFIG_DIR}" + fi + + audit uninstall "$INSTALL_DIR" ok + log "卸载完成${rescue:+(保留内容:${rescue})}" + [ "$keep_backup" = "1" ] && info "备份仍保留在:${BACKUP_DIR}" +} + +cmd_completion() { + case "${1:-bash}" in + bash) + cat <<'EOF' +_classhelper_complete() { + local cur prev commands sub + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + commands="status doctor password backup upgrade rollback start stop restart reload logs config port key uninstall version help completion" + if [ "$COMP_CWORD" -eq 1 ]; then + COMPREPLY=( $(compgen -W "$commands" -- "$cur") ) + return 0 + fi + case "${COMP_WORDS[1]}" in + backup) COMPREPLY=( $(compgen -W "list restore schedule" -- "$cur") ) ;; + config) COMPREPLY=( $(compgen -W "get set show edit" -- "$cur") ) ;; + key) COMPREPLY=( $(compgen -W "rotate" -- "$cur") ) ;; + completion) COMPREPLY=( $(compgen -W "bash zsh" -- "$cur") ) ;; + logs) COMPREPLY=( $(compgen -W "-f -n --since --unit" -- "$cur") ) ;; + upgrade) COMPREPLY=( $(compgen -W "--check --version --url --sha256 --force" -- "$cur") ) ;; + esac +} +complete -F _classhelper_complete classhelper +EOF + ;; + zsh) + cat <<'EOF' +#compdef classhelper +_classhelper() { + local -a commands + commands=(status doctor password backup upgrade rollback start stop restart reload logs config port key uninstall version help completion) + _arguments '1:command:($commands)' '*::arg:->args' +} +_classhelper "$@" +EOF + ;; + *) die "用法:classhelper completion bash|zsh" ;; + esac +} + +cmd_help() { + cat < [参数] + +${C_BOLD}查看与诊断${C_RESET} + status [--json] 服务状态、版本、端口、数据库、各类路径(--json 便于接监控) + doctor 完整体检(权限 / Node / 服务单元 / 探针 / 数据库 / 磁盘 / 备份) + logs [-f] [-n N] [--since T] 查看日志(-f 实时跟随;--unit classhelper-backup 看定时备份) + +${C_BOLD}账号${C_RESET} + password [用户名] 修改密码(默认 admin);交互输入、不回显、不进历史 + password --list 列出可登录账号与班级账号(含是否已设密码) + password --class <班级码> 修改班级密码(学生端用「班级码 + 班级密码」登录) + +${C_BOLD}备份与恢复${C_RESET} + backup [--note 说明] [--keep N] 备份配置 + 数据库到 ${BACKUP_DIR} + backup list 列出备份与程序快照 + backup restore <备份目录名> 用某个备份覆盖当前配置与数据(会先停服务) + backup schedule daily|weekly|off 启用 / 关闭定时备份(systemd timer) + +${C_BOLD}升级与回滚${C_RESET} + upgrade --check 检查 GitHub Release 上有没有新版本 + upgrade 升级到最新版(下载 → 校验 sha256 → 备份 → 替换 → 探针,失败自动回滚) + upgrade --version 1.2.0 升级到指定版本 + upgrade --url [--sha256 H] [--force] 内网离线分发 / 强制重装同版本 + rollback [--list] [--to X] 回滚程序文件到某个快照 + +${C_BOLD}服务与配置${C_RESET} + start | stop | restart | reload 封装 systemctl / docker compose + config show [--reveal] 查看配置(敏感值默认打码) + config get 读取某项(敏感项自动打码) + config set 修改某项(敏感项改为交互输入),改完询问是否重启 + config edit 用 \$EDITOR 编辑配置(改前自动备份) + port [新端口] 查看或修改服务端口 + key rotate 轮换 JWT_SECRET(所有用户需重新登录) + +${C_BOLD}其它${C_RESET} + version 显示版本 + help 显示本帮助 + completion bash|zsh 生成补全脚本(chmod +x 后 source,或装到 completions 目录) + +${C_DIM}敏感操作需要 root:sudo classhelper <子命令> +审计日志:${AUDIT_LOG}${C_RESET} +EOF +} + +# ---------------------------------------------------------------- 12. 入口 + +main() { + local cmd="${1:-help}" + shift || true + case "$cmd" in + # 不需要 root 的三个 + help|-h|--help) cmd_help ;; + version|-v|--version) printf 'classhelper %s(%s 形态)\n' "$(current_version)" "$MODE" ;; + completion) cmd_completion "$@" ;; + + status) cmd_status "$@" ;; + doctor) cmd_doctor "$@" ;; + password) cmd_password "$@" ;; + backup) cmd_backup "$@" ;; + upgrade) cmd_upgrade "$@" ;; + rollback) cmd_rollback "$@" ;; + + start|stop|restart|reload) + need_root "$cmd" + require_install + case "$cmd" in + start) svc_start ;; + stop) svc_stop ;; + restart) svc_restart ;; + # 服务端没有 SIGHUP 处理,reload 与 restart 同义(保持命令面一致,并说明清楚) + reload) info "服务端不支持热重载,按重启处理"; svc_restart ;; + esac + if [ "$cmd" = "stop" ]; then + log "已停止" + else + if wait_ready 90; then log "服务已就绪 ✅(http://$(first_ip):$(current_port)/)" + else print_recent_logs; die "服务在 90 秒内没有通过探针"; fi + fi + audit "$cmd" "$SERVICE_NAME" ok + ;; + + logs) cmd_logs "$@" ;; + config) cmd_config "$@" ;; + port) cmd_port "$@" ;; + key) cmd_key "$@" ;; + uninstall) cmd_uninstall "$@" ;; + + *) warn "未知子命令:${cmd}"; printf '\n'; cmd_help; exit 2 ;; + esac +} + +main "$@" diff --git a/deploy/classhelper.service b/deploy/classhelper.service index 121ed9e..dbb2b0f 100644 --- a/deploy/classhelper.service +++ b/deploy/classhelper.service @@ -1,24 +1,33 @@ # --------------------------------------------------------------------------- # 班级小助手 · 服务端 systemd 单元(Linux) # -# 这是**模板**:deploy/install-linux.sh 会把 __NODE__ / __DIR__ / __USER__ 替换成真实值后 -# 安装到 /etc/systemd/system/classhelper.service。手动部署时直接改这三处即可: +# 这是**模板**:`deploy/install.sh` 会把占位符替换成真实值后安装到 +# /etc/systemd/system/classhelper.service。手动部署时照着替换这四处即可: # -# sudo sed -e 's#__NODE__#/usr/bin/node#' \ +# __NODE__ Node 可执行文件的绝对路径(内置运行时:<安装目录>/runtime/node/bin/node) +# __DIR__ 安装目录(默认 /opt/classhelper) +# __USER__ 运行服务的系统用户(默认 classhelper) +# __CONFIG__ 环境变量文件(默认 /etc/classhelper/config.env) +# __CONFIGDIR__ __CONFIG__ 所在目录(需要写权限,见下面的 ReadWritePaths) +# +# sudo sed -e 's#__NODE__#/opt/classhelper/runtime/node/bin/node#' \ # -e 's#__DIR__#/opt/classhelper#' \ # -e 's#__USER__#classhelper#' \ -# deploy/classhelper.service | sudo tee /etc/systemd/system/classhelper.service +# -e 's#__CONFIGDIR__#/etc/classhelper#' \ +# -e 's#__CONFIG__#/etc/classhelper/config.env#' \ +# deploy/classhelper.service > /tmp/classhelper.service +# sudo install -m 644 /tmp/classhelper.service /etc/systemd/system/classhelper.service # sudo systemctl daemon-reload && sudo systemctl enable --now classhelper # -# 常用命令: +# 常用命令(也有对应的 classhelper 封装): +# classhelper status / start / stop / restart / logs -f # systemctl status classhelper -# journalctl -u classhelper -f # 实时日志 -# systemctl restart classhelper # 重启(改完 .env 需要) +# journalctl -u classhelper -f # --------------------------------------------------------------------------- [Unit] Description=ClassHelper Server (班级小助手服务端) -Documentation=file://__DIR__/README.txt +Documentation=https://github.com/LoveChengke/ClassHelper After=network-online.target Wants=network-online.target @@ -27,15 +36,18 @@ Type=simple User=__USER__ Group=__USER__ WorkingDirectory=__DIR__ -# .env 由服务端自己从 WorkingDirectory 加载(工作目录即安装根目录) + +# 配置从这里注入(唯一的配置真身;安装目录里的 .env 是指向它的软链,见 docs/linux-deploy.md)。 +# systemd 会剥掉值两侧的引号,所以同一份文件给 dotenv / shell source / systemd 三种读法都能用。 +# 前面不加 "-":文件被删掉时直接启动失败,比拿着一份假配置跑起来更容易发现。 +EnvironmentFile=__CONFIG__ ExecStart=__NODE__ server/dist/index.js -Environment=NODE_ENV=production Restart=always RestartSec=3 # Socket.IO 长连接较多:提高文件描述符上限 LimitNOFILE=65535 -# 优雅退出:给在途请求与 websocket 收尾时间 +# 优雅退出:给在途请求与 websocket 收尾时间(index.ts 处理 SIGTERM/SIGINT) KillSignal=SIGTERM TimeoutStopSec=20 @@ -44,12 +56,26 @@ StandardOutput=journal StandardError=journal SyslogIdentifier=classhelper -# 基础加固(服务只需要读程序、写 data/logs) -NoNewPrivileges=true -PrivateTmp=true +# ---------------------------------------------------------------- 加固 +# 为什么 ReadWritePaths 要给整个 __DIR__ 与配置目录,而不是只给 data/logs: +# 1) Web 端「数据库管理 → 一键切换」会在运行中改写 server/prisma/schema.prisma, +# 并把重新生成的 Prisma Client 编译进 server/dist/generated —— 那两处都在 __DIR__ 里; +# 2) 同一功能切换成功后要改写 __CONFIG__(DATABASE_PROVIDER / DATABASE_URL 两行)。 +# ProtectSystem=full 默认把 /etc 与 /usr 设成只读,ReadWritePaths 是官方的"打洞"方式。 +# 服务本来就以 __USER__ 身份运行,这里给的写权限没有超出它的既有边界。 ProtectSystem=full ProtectHome=true -ReadWritePaths=__DIR__/data __DIR__/logs +PrivateTmp=true +NoNewPrivileges=true +ReadWritePaths=__DIR__ __CONFIGDIR__ + +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictSUIDSGID=true +LockPersonality=true +# 需要 AF_NETLINK:glibc 解析主机名(MySQL 主机 / GitHub API)时会用到 +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK [Install] WantedBy=multi-user.target diff --git a/deploy/docker-compose.sqlite.yml b/deploy/docker-compose.sqlite.yml index f155619..0bafaed 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:-1.0.0} + image: classhelper-server:${APP_VERSION:-1.1.0} restart: unless-stopped environment: NODE_ENV: production @@ -44,7 +44,7 @@ services: TERM_START_DATE: ${TERM_START_DATE:-} INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-admin} INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-admin123} - DEFAULT_STUDENT_PASSWORD: ${DEFAULT_STUDENT_PASSWORD:-123456} + DEFAULT_CLASS_PASSWORD: ${DEFAULT_CLASS_PASSWORD:-123456} DEFAULT_TEACHER_PASSWORD: ${DEFAULT_TEACHER_PASSWORD:-123456} ports: diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index e8b7911..ebe2463 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:-1.0.0} + image: classhelper-server:${APP_VERSION:-1.1.0} restart: unless-stopped environment: NODE_ENV: production @@ -78,7 +78,7 @@ services: TERM_START_DATE: ${TERM_START_DATE:-} INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-admin} INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-admin123} - DEFAULT_STUDENT_PASSWORD: ${DEFAULT_STUDENT_PASSWORD:-123456} + DEFAULT_CLASS_PASSWORD: ${DEFAULT_CLASS_PASSWORD:-123456} DEFAULT_TEACHER_PASSWORD: ${DEFAULT_TEACHER_PASSWORD:-123456} ports: diff --git a/deploy/install-linux.sh b/deploy/install-linux.sh index 1d94f2f..668c1c6 100644 --- a/deploy/install-linux.sh +++ b/deploy/install-linux.sh @@ -1,279 +1,14 @@ #!/usr/bin/env bash # --------------------------------------------------------------------------- -# 班级小助手 · 服务端 Linux 一键部署(systemd + SQLite,单机单实例) +# 兼容壳:早期文档、脚本与 README 里写的是 deploy/install-linux.sh, +# 现在安装器统一在 deploy/install.sh(避免两份实现漂移)。 # -# 干什么: -# 在 Linux 服务器上把服务端装成 systemd 服务,布局与已验证的 Windows 免安装目录完全一致: -# /opt/classhelper/ -# ├── .env 全部配置(首次安装随机生成 JWT_SECRET) -# ├── server/dist 后端程序(入口 index.js) -# ├── server/prisma 迁移 SQL(AUTO_MIGRATE=true 时首启动自动执行) -# ├── web/ Web 管理端(由后端托管,单端口 4000) -# ├── node_modules/ 仅生产依赖(npm install --omit=dev) -# ├── data/ classhelper.db + server.pid -# └── logs/ 预留(日志默认进 journald) -# -# 前置条件: -# - 在**仓库根目录**已执行过构建:pnpm build:shared && pnpm --filter @classhelper/server build \ -# && pnpm --filter @classhelper/web-admin build -# - 目标机装好 Node.js ≥ 20.19(推荐 22 LTS);本脚本不代装 Node -# -# 用法(在仓库根目录执行,需要 root): -# sudo bash deploy/install-linux.sh # 安装/升级,默认端口 4000、目录 /opt/classhelper -# sudo bash deploy/install-linux.sh --port 8080 # 换端口 -# sudo bash deploy/install-linux.sh --dir /srv/classhelper -# sudo bash deploy/install-linux.sh --no-service # 只铺文件,不装 systemd(自己接管进程) -# bash deploy/install-linux.sh --check # 只体检(不需要 root):校验构建产物/Node 版本/端口探针 -# sudo bash deploy/install-linux.sh --uninstall # 停止并禁用服务(保留 data 与 .env) -# -# 升级:把新代码构建好后**再跑一次同样的命令**即可。脚本不会覆盖已有 .env(JWT 密钥与端口保持 -# 不变,用户不用重新登录),只会补齐缺失项并覆盖程序文件。 -# -# 注意:本脚本未在 Linux 实机验证过(开发机是 Windows,无 Docker/WSL 发行版)。 -# 它复用的是**已验证**的两套布局:Windows 免安装目录(install/ 结构)与 deploy/Dockerfile -# 的 runtime 阶段(package.runtime.json + prisma/migrations + web/)。 +# 两者参数完全一致: +# sudo bash deploy/install-linux.sh --port 8080 +# 等价于 +# sudo bash deploy/install.sh --port 8080 # --------------------------------------------------------------------------- set -euo pipefail - -TARGET_DIR="/opt/classhelper" -SERVICE_USER="classhelper" -PORT="4000" -WITH_SERVICE=1 -UNINSTALL=0 -CHECK_ONLY=0 -SERVICE_NAME="classhelper" - -while [ $# -gt 0 ]; do - case "$1" in - --dir) TARGET_DIR="$2"; shift 2 ;; - --port) PORT="$2"; shift 2 ;; - --user) SERVICE_USER="$2"; shift 2 ;; - --no-service) WITH_SERVICE=0; shift ;; - --check) CHECK_ONLY=1; shift ;; - --uninstall) UNINSTALL=1; shift ;; - -h|--help) sed -n '2,40p' "$0"; exit 0 ;; - *) echo "未知参数:$1(用 --help 看用法)" >&2; exit 2 ;; - esac -done - -log() { printf '\033[32m[classhelper]\033[0m %s\n' "$*"; } -warn() { printf '\033[33m[classhelper]\033[0m %s\n' "$*" >&2; } -die() { printf '\033[31m[classhelper]\033[0m %s\n' "$*" >&2; exit 1; } - -# 就绪探针:优先 curl(几乎每台 Linux 都有),否则退回 node。 -# 注意不要用 `node -e "...process.exit()"` —— 在部分 Node 版本上于 promise 回调里硬退会触发 -# libuv 断言崩溃(本机 Windows 实测退出码 -1073740791),改成设置 process.exitCode 让事件循环自然结束。 -probe_ready() { - if command -v curl >/dev/null 2>&1; then - curl -fsS -o /dev/null --max-time 3 "http://127.0.0.1:${PORT}/healthz" >/dev/null 2>&1 - return $? - fi - "$NODE_BIN" -e "fetch('http://127.0.0.1:${PORT}/healthz').then(r=>{process.exitCode=r.ok?0:1}).catch(()=>{process.exitCode=1})" >/dev/null 2>&1 -} - -[ "$(id -u)" = "0" ] || { [ "$CHECK_ONLY" = "1" ] || die "请用 root 执行:sudo bash deploy/install-linux.sh"; } - -# ---------------------------------------------------------------- 卸载分支 -if [ "$UNINSTALL" = "1" ]; then - if systemctl list-unit-files | grep -q "^${SERVICE_NAME}.service"; then - systemctl disable --now "$SERVICE_NAME" || true - rm -f "/etc/systemd/system/${SERVICE_NAME}.service" - systemctl daemon-reload - log "已停止并移除 systemd 服务 ${SERVICE_NAME}" - else - warn "没有找到 ${SERVICE_NAME}.service(可能没装成服务)" - fi - log "程序文件与数据保留在 ${TARGET_DIR};确认不需要后再手动删除" - exit 0 -fi - -# ---------------------------------------------------------------- 定位仓库与产物 -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" -cd "$REPO_ROOT" - -SERVER_DIST="packages/server/dist/index.js" -SERVER_PRISMA="packages/server/prisma" -WEB_DIST="packages/web-admin/dist/index.html" -SHARED_DIST="packages/shared/dist/index.js" -RUNTIME_PKG="deploy/package.runtime.json" - -for f in "$SERVER_DIST" "$WEB_DIST" "$SHARED_DIST" "$RUNTIME_PKG"; do - [ -e "$f" ] || die "缺少构建产物:$f -请先在仓库根目录执行: - pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build" -done -[ -d "$SERVER_PRISMA/migrations" ] || die "缺少迁移文件目录:$SERVER_PRISMA/migrations" - -# ---------------------------------------------------------------- Node 检查 -NODE_BIN="$(command -v node || true)" -[ -n "$NODE_BIN" ] || die "未找到 node,请先安装 Node.js ≥ 20.19(推荐 22 LTS)" -NODE_MAJOR="$("$NODE_BIN" -p 'process.versions.node.split(".")[0]')" -NODE_MINOR="$("$NODE_BIN" -p 'process.versions.node.split(".")[1]')" -if [ "$NODE_MAJOR" -lt 20 ] || { [ "$NODE_MAJOR" -eq 20 ] && [ "$NODE_MINOR" -lt 19 ]; }; then - die "Node 版本过低:$("$NODE_BIN" -v)(要求 ≥ 20.19)" -fi -NPM_BIN="$(command -v npm || true)" -[ -n "$NPM_BIN" ] || die "未找到 npm(随 Node 一起安装)" -log "Node $("$NODE_BIN" -v) → $NODE_BIN" - -# ---------------------------------------------------------------- 只体检 -if [ "$CHECK_ONLY" = "1" ]; then - echo - echo "构建产物:" - for f in "$SERVER_DIST" "$WEB_DIST" "$SHARED_DIST" "$RUNTIME_PKG" "$SERVER_PRISMA/migrations"; do - printf ' %-42s %s\n' "$f" "OK" - done - printf ' %-42s %s\n' "迁移文件数量" "$(find "$SERVER_PRISMA/migrations" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" - echo - echo "运行环境:" - printf ' %-42s %s\n' "node" "$("$NODE_BIN" -v)(要求 ≥ 20.19)" - printf ' %-42s %s\n' "npm" "$("$NPM_BIN" -v)" - printf ' %-42s %s\n' "systemd" "$(command -v systemctl >/dev/null 2>&1 && echo 可用 || echo '不可用(用 --no-service)')" - printf ' %-42s %s\n' "目标目录 ${TARGET_DIR}" "$([ -e "$TARGET_DIR" ] && echo '已存在(会就地升级,保留 .env)' || echo 尚未创建)" - printf ' %-42s %s\n' "端口 ${PORT} 上的既有服务" "$(probe_ready && echo '健康(就绪)' || echo '无响应')" - echo - log "--check 通过:构建产物与运行环境都满足,可以执行安装(sudo bash deploy/install-linux.sh)" - exit 0 -fi - -# ---------------------------------------------------------------- 用户与目录 -if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then - useradd --system --create-home --shell /usr/sbin/nologin "$SERVICE_USER" 2>/dev/null \ - || useradd --system --shell /sbin/nologin "$SERVICE_USER" - log "已创建系统用户 ${SERVICE_USER}" -fi - -mkdir -p "${TARGET_DIR}/server" "${TARGET_DIR}/web" "${TARGET_DIR}/data" "${TARGET_DIR}/logs" -log "安装目录:${TARGET_DIR}" - -# ---------------------------------------------------------------- 拷贝产物 -rm -rf "${TARGET_DIR}/server/dist" -cp -R "packages/server/dist" "${TARGET_DIR}/server/dist" -rm -rf "${TARGET_DIR}/server/prisma" -cp -R "$SERVER_PRISMA" "${TARGET_DIR}/server/prisma" -# 运行时不读 schema,但保留一份便于排查 -[ -f packages/server/prisma/schema.prisma ] && cp packages/server/prisma/schema.prisma "${TARGET_DIR}/server/prisma/schema.prisma" - -rm -rf "${TARGET_DIR}/web" -cp -R "packages/web-admin/dist" "${TARGET_DIR}/web" - -cp "$RUNTIME_PKG" "${TARGET_DIR}/package.json" -cp -R deploy/classhelper.service "${TARGET_DIR}/classhelper.service.template" - -# 生产依赖(与 Dockerfile 的 runtime 阶段同一份清单) -if [ ! -d "${TARGET_DIR}/node_modules" ] || [ "${FORCE_NPM_INSTALL:-0}" = "1" ]; then - log "安装生产依赖(npm install --omit=dev,约 1~3 分钟)..." - ( cd "$TARGET_DIR" && "$NPM_BIN" install --omit=dev --no-audit --no-fund ) -else - log "复用已有 node_modules(如需强制重装:FORCE_NPM_INSTALL=1 再跑一次)" -fi - -# workspace 共享包(后端 import '@classhelper/shared'):拷进 node_modules 里 -mkdir -p "${TARGET_DIR}/node_modules/@classhelper/shared" -rm -rf "${TARGET_DIR}/node_modules/@classhelper/shared/dist" -cp -R "packages/shared/dist" "${TARGET_DIR}/node_modules/@classhelper/shared/dist" -cp packages/shared/package.json "${TARGET_DIR}/node_modules/@classhelper/shared/package.json" - -# ---------------------------------------------------------------- .env(已存在则不覆盖) -ENV_FILE="${TARGET_DIR}/.env" -if [ -f "$ENV_FILE" ]; then - log "检测到已有 .env:保留现有配置(含 JWT_SECRET),只补齐缺失项" -else - JWT_SECRET="$("$NODE_BIN" -e 'console.log(require("crypto").randomBytes(48).toString("hex"))')" - cat > "$ENV_FILE" </dev/null 2>&1 || die "本机没有 systemd;请加 --no-service 自行托管进程" - - sed -e "s#__NODE__#${NODE_BIN}#g" \ - -e "s#__DIR__#${TARGET_DIR}#g" \ - -e "s#__USER__#${SERVICE_USER}#g" \ - deploy/classhelper.service > "/etc/systemd/system/${SERVICE_NAME}.service" - systemctl daemon-reload - systemctl enable "$SERVICE_NAME" >/dev/null - systemctl restart "$SERVICE_NAME" - - log "等待服务就绪..." - ready=0 - for _ in $(seq 1 60); do - if probe_ready; then - ready=1 - break - fi - sleep 1 - done - - if [ "$ready" != "1" ]; then - warn "60 秒内没通过 /healthz,最近日志:" - journalctl -u "$SERVICE_NAME" -n 50 --no-pager || true - die "启动失败,请按上面的日志排查(常见:端口被占用、数据目录权限、JWT_SECRET 未生成)" - fi - - log "服务已就绪 ✅" -else - log "已跳过 systemd(--no-service)。手动启动:" - echo " sudo -u ${SERVICE_USER} sh -c 'cd ${TARGET_DIR} && ${NODE_BIN} server/dist/index.js'" -fi - -# ---------------------------------------------------------------- 结果 -cat <:${PORT}/ - 健康探针: http://<服务器IP>:${PORT}/healthz · /readyz - 配置文件: ${ENV_FILE} - 数据目录: ${TARGET_DIR}/data(备份只需复制这里) - 查看日志: journalctl -u ${SERVICE_NAME} -f - 重启服务: systemctl restart ${SERVICE_NAME} - -首次启动会自动建表并创建管理员:admin / admin123(登录后请立即在右上角「修改密码」改掉)。 -公网部署请在前面加 Nginx + HTTPS(deploy/nginx.conf 可直接用),并把 .env 里的 -TRUST_PROXY 设为 1、CORS_ORIGIN 收敛到你的域名。 -学生端客户端登录时填的「服务器地址」就是 https://你的域名(或 http://IP:${PORT})。 ------------------------------------------------------------------- -EOF +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +printf '\033[33m[classhelper]\033[0m install-linux.sh 已更名为 install.sh,本次自动转发(后续请直接用 install.sh)\n' >&2 +exec bash "${DIR}/install.sh" "$@" diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100644 index 0000000..8f62284 --- /dev/null +++ b/deploy/install.sh @@ -0,0 +1,1119 @@ +#!/usr/bin/env bash +# --------------------------------------------------------------------------- +# 班级小助手 · 服务端 Linux 一键安装器 +# +# 支持:Ubuntu 20.04+ / Debian 11+ / CentOS 7+ / Rocky Linux / AlmaLinux 8+ +# (自动识别 apt / yum / dnf;直接安装形态要求 systemd) +# +# 两种形态: +# native(默认)—— 装成 systemd 服务,自带 Node 运行时,SQLite 或 MySQL +# docker —— 用随包的 Dockerfile 构建镜像 + compose 起容器(需要 docker) +# +# 用法: +# sudo bash install.sh # 交互式(TUI 菜单 + 问答) +# sudo bash install.sh --check # 只体检(不需要 root,不改动任何东西) +# sudo bash install.sh --uninstall # 卸载(会问保留什么) +# +# 自动化 / 验收常用参数(给了参数就不问对应的问题): +# --mode native|docker 安装形态 +# --dir /opt/classhelper 安装目录 +# --port 4000 服务端口 +# --database sqlite|mysql 数据库 +# --mysql-host/-port/-db/-user/-password-stdin +# --admin-password-stdin 从 stdin 读一行作为初始管理员密码(不进 argv / history) +# --package 服务端安装包(默认从 GitHub Release 取最新版) +# --sha256 <哈希> 安装包校验值(给了就强制校验) +# --node-source bundled|system|tarball Node 运行时来源(默认 bundled) +# --node-mirror Node 下载镜像(默认 https://npmmirror.com/mirrors/node) +# --node-tarball 直接用指定的 Node 压缩包(内网离线场景) +# --with-nginx 顺带放一份 Nginx 反代配置(不自动申请证书) +# --open-firewall 按检测到的防火墙放行端口 +# --yes 全部用默认值,不再交互(CI / 验收用) +# +# 幂等:重复执行 = 覆盖安装。已有 config.env(含 JWT_SECRET)与 data/ 一律保留,不重置密钥。 +# 日志:/var/log/classhelper/install.log +# --------------------------------------------------------------------------- +set -euo pipefail + +# ---------------------------------------------------------------- 集中变量 + +PRODUCT="班级小助手" +SERVICE_NAME="classhelper" + +INSTALL_DIR="/opt/classhelper" +CONFIG_DIR="/etc/classhelper" +CONFIG_FILE="${CONFIG_DIR}/config.env" +LOG_DIR="/var/log/classhelper" +BACKUP_DIR="/var/backups/classhelper" +BIN_DIR="/usr/local/bin" +SERVICE_USER="classhelper" +SERVICE_GROUP="classhelper" +PORT="4000" +MODE="native" +DB_KIND="sqlite" +MYSQL_HOST="127.0.0.1" +MYSQL_PORT="3306" +MYSQL_DB="classhelper" +MYSQL_USER="classhelper" +MYSQL_PASSWORD="" + +# 服务端安装包(默认取 GitHub Release 上的 Linux 资产) +REPO_SLUG="LoveChengke/ClassHelper" +GITHUB_RELEASES_PAGE="https://github.com/${REPO_SLUG}/releases" +PACKAGE_URL="" +PACKAGE_SHA256="" +VERSION="" +STAGE_DIR="" + +# Node 运行时 +NODE_MAJOR="22" +NODE_SOURCE="bundled" +NODE_MIRROR="https://npmmirror.com/mirrors/node" +NODE_TARBALL="" +NODE_DIR_NAME="runtime/node" + +WITH_NGINX=0 +OPEN_FIREWALL=0 +ASSUME_YES=0 +CHECK_ONLY=0 +UNINSTALL=0 +ADMIN_PASSWORD="" +ADMIN_PASSWORD_FROM_STDIN=0 +INSTALL_DOCKER=0 +FORCE=0 + +LOG_FILE="${LOG_DIR}/install.log" +SCRIPT_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" +SCRIPT_DIR="$(dirname "$SCRIPT_PATH")" + +# ---------------------------------------------------------------- 输出 + +if [ -t 1 ] && [ "${TERM:-dumb}" != "dumb" ] && [ -z "${NO_COLOR:-}" ]; then + C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'; C_DIM=$'\033[2m' + C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'; C_RED=$'\033[31m'; C_CYAN=$'\033[36m'; C_MAGENTA=$'\033[35m' +else + C_RESET=''; C_BOLD=''; C_DIM=''; C_GREEN=''; C_YELLOW=''; C_RED=''; C_CYAN=''; C_MAGENTA='' +fi + +log() { printf '%s[classhelper]%s %s\n' "$C_GREEN" "$C_RESET" "$*"; } +info() { printf '%s[classhelper]%s %s\n' "$C_CYAN" "$C_RESET" "$*"; } +step() { printf '\n%s▶ %s%s\n' "$C_MAGENTA" "$*" "$C_RESET"; } +warn() { printf '%s[classhelper]%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2; } +die() { trap - ERR; printf '%s[classhelper]%s %s\n' "$C_RED" "$C_RESET" "$*" >&2; exit 1; } + +on_error() { + local code=$? + printf '\n%s[classhelper]%s 安装中断(第 %s 行,退出码 %s)\n' "$C_RED" "$C_RESET" "$1" "$code" >&2 + if [ -f "$LOG_FILE" ]; then printf '完整日志:%s\n' "$LOG_FILE" >&2; fi + exit "$code" +} +trap 'on_error "$LINENO"' ERR + +banner() { + printf '\n%s' "$C_CYAN" + cat <<'ASCII' + ██████╗██╗ █████╗ ███████╗███████╗██╗ ██╗███████╗██╗ ██████╗ ███████╗██████╗ + ██╔════╝██║ ██╔══██╗██╔════╝██╔════╝██║ ██║██╔════╝██║ ██╔══██╗██╔════╝██╔══██╗ + ██║ ██║ ███████║███████╗███████╗███████║█████╗ ██║ ██████╔╝█████╗ ██████╔╝ + ██║ ██║ ██╔══██║╚════██║╚════██║██╔══██║██╔══╝ ██║ ██╔═══╝ ██╔══╝ ██╔══██╗ + ╚██████╗███████╗██║ ██║███████║███████║██║ ██║███████╗███████╗██║ ███████╗██║ ██║ + ╚═════╝╚══════╝╚═╝ ╚═╝╚══════╝╚══════╝╚═╝ ╚═╝╚══════╝╚══════╝╚═╝ ╚══════╝╚═╝ ╚═╝ +ASCII + printf '%s %s · 服务端 Linux 安装器%s\n' "$C_BOLD" "$PRODUCT" "$C_RESET" + printf '%s 一键安装 + classhelper 运维命令(status / password / upgrade / backup …)%s\n\n' "$C_DIM" "$C_RESET" +} + +# 打印文件头部注释里的用法(第一个分隔线之后、第二个分隔线之前) +usage() { + awk 'NR == 1 { next } + /^# -{10,}/ { seen++; if (seen == 2) exit; next } + seen == 1 { sub(/^# ?/, ""); print }' "$SCRIPT_PATH" +} + +# ---------------------------------------------------------------- 参数解析 + +read_stdin_line() { IFS= read -r "$1" || true; } + +parse_args() { + while [ $# -gt 0 ]; do + case "$1" in + --mode) MODE="${2:-}"; shift 2 ;; + --dir) INSTALL_DIR="${2:-}"; shift 2 ;; + --port) PORT="${2:-}"; shift 2 ;; + --user) SERVICE_USER="${2:-}"; SERVICE_GROUP="${2:-}"; shift 2 ;; + --database) DB_KIND="${2:-}"; shift 2 ;; + --mysql-host) MYSQL_HOST="${2:-}"; shift 2 ;; + --mysql-port) MYSQL_PORT="${2:-}"; shift 2 ;; + --mysql-db) MYSQL_DB="${2:-}"; shift 2 ;; + --mysql-user) MYSQL_USER="${2:-}"; shift 2 ;; + --mysql-password) MYSQL_PASSWORD="${2:-}"; shift 2 ;; + --mysql-password-stdin) read_stdin_line MYSQL_PASSWORD; shift ;; + --admin-password-stdin) ADMIN_PASSWORD_FROM_STDIN=1; shift ;; + --package) PACKAGE_URL="${2:-}"; shift 2 ;; + --sha256) PACKAGE_SHA256="${2:-}"; shift 2 ;; + --version) VERSION="${2:-}"; shift 2 ;; + --node-source) NODE_SOURCE="${2:-}"; shift 2 ;; + --node-mirror) NODE_MIRROR="${2:-}"; shift 2 ;; + --node-tarball) NODE_TARBALL="${2:-}"; NODE_SOURCE="tarball"; shift 2 ;; + --with-nginx) WITH_NGINX=1; shift ;; + --open-firewall) OPEN_FIREWALL=1; shift ;; + --install-docker) INSTALL_DOCKER=1; shift ;; + --yes|-y) ASSUME_YES=1; shift ;; + --force) FORCE=1; shift ;; + --check) CHECK_ONLY=1; shift ;; + --uninstall|--remove) UNINSTALL=1; shift ;; + -h|--help) usage; exit 0 ;; + *) die "未知参数:$1(--help 看用法)" ;; + esac + done + case "$MODE" in native|docker) ;; *) die "--mode 只能是 native 或 docker" ;; esac + case "$DB_KIND" in sqlite|mysql) ;; *) die "--database 只能是 sqlite 或 mysql" ;; esac + case "$NODE_SOURCE" in bundled|system|tarball) ;; *) die "--node-source 只能是 bundled / system / tarball" ;; esac + case "$INSTALL_DIR" in + /) die "安装目录不能是 /" ;; + /usr|/usr/*|/etc|/etc/*|/bin|/bin/*|/sbin|/sbin/*|/lib|/lib/*|/boot|/boot/*) die "安装目录不能是系统目录:${INSTALL_DIR}" ;; + /*) ;; + *) die "安装目录必须是绝对路径:${INSTALL_DIR}" ;; + esac +} + +# ---------------------------------------------------------------- 环境探测 + +OS_ID=""; OS_VER=""; OS_NAME=""; PKG="" +detect_os() { + if [ -r /etc/os-release ]; then + # shellcheck disable=SC1091 + . /etc/os-release + OS_ID="${ID:-unknown}" + OS_VER="${VERSION_ID:-}" + OS_NAME="${PRETTY_NAME:-${OS_ID} ${OS_VER}}" + else + OS_NAME="$(uname -s) $(uname -r)" + fi + if command -v apt-get >/dev/null 2>&1; then PKG="apt" + elif command -v dnf >/dev/null 2>&1; then PKG="dnf" + elif command -v yum >/dev/null 2>&1; then PKG="yum" + else PKG="" + fi +} + +has_systemd() { [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; } + +pkg_install() { + [ "$#" -gt 0 ] || return 0 + case "$PKG" in + apt) DEBIAN_FRONTEND=noninteractive apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends "$@" ;; + dnf) dnf install -y -q "$@" ;; + yum) yum install -y -q "$@" ;; + *) warn "未识别包管理器,请手工安装:$*"; return 1 ;; + esac +} + +ensure_dependencies() { + step "检查并补齐依赖" + local missing=() tool + for tool in curl tar gzip; do + command -v "$tool" >/dev/null 2>&1 || missing+=("$tool") + done + command -v sha256sum >/dev/null 2>&1 || missing+=("coreutils") + if [ "${#missing[@]}" -gt 0 ]; then + info "缺少:${missing[*]}(用 ${PKG:-未识别的包管理器} 安装)" + local pkgs=() + for tool in "${missing[@]}"; do + case "$tool" in + curl) pkgs+=("curl") ;; + tar) pkgs+=("tar") ;; + gzip) pkgs+=("gzip") ;; + coreutils) pkgs+=("coreutils") ;; + esac + done + pkg_install "${pkgs[@]}" || die "依赖安装失败,请手工安装:${missing[*]}" + fi + command -v curl >/dev/null 2>&1 || die "缺少 curl,无法继续" + log "依赖就绪(${PKG:-未知包管理器})" +} + +port_in_use() { + local port="$1" + if command -v ss >/dev/null 2>&1; then + ss -ltn 2>/dev/null | awk -v p=":${port}\$" '$4 ~ p {found=1} END {exit !found}' + elif command -v netstat >/dev/null 2>&1; then + netstat -ltn 2>/dev/null | awk -v p=":${port}\$" '$4 ~ p {found=1} END {exit !found}' + else + return 1 + fi +} + +is_installed() { [ -f "${INSTALL_DIR}/.installed.json" ]; } + +existing_version() { + if [ -f "${INSTALL_DIR}/VERSION" ]; then tr -d '[:space:]' <"${INSTALL_DIR}/VERSION"; else printf '未知'; fi +} + +config_value() { # $1=KEY:从已有配置里读一项(覆盖安装时沿用旧值) + local key="$1" + [ -f "$CONFIG_FILE" ] || return 0 + sed -n "s/^[[:space:]]*${key}=//p" "$CONFIG_FILE" | tail -n 1 | tr -d '"'"'" +} + +# ---------------------------------------------------------------- 交互问答 + +ask() { # $1=提示 $2=默认值 $3=目标变量 + local prompt="$1" default="$2" var="$3" answer="" + if [ "$ASSUME_YES" = "1" ] || [ ! -t 0 ]; then + printf -v "$var" '%s' "$default" + return 0 + fi + if [ -n "$default" ]; then + read -r -p "$(printf '%s[?]%s %s [%s]: ' "$C_CYAN" "$C_RESET" "$prompt" "$default")" answer || true + else + read -r -p "$(printf '%s[?]%s %s: ' "$C_CYAN" "$C_RESET" "$prompt")" answer || true + fi + printf -v "$var" '%s' "${answer:-$default}" +} + +ask_secret() { # $1=提示 $2=目标变量(不回显) + local prompt="$1" var="$2" value="" + printf -v "$var" '' + if [ "$ASSUME_YES" = "1" ] || [ ! -t 0 ]; then return 0; fi + read -r -s -p "$(printf '%s[?]%s %s' "$C_CYAN" "$C_RESET" "$prompt")" value || true + printf '\n' + printf -v "$var" '%s' "$value" +} + +ask_yes_no() { # $1=提示 $2=默认 y/n;返回 0=是 + local prompt="$1" default="$2" answer="" + if [ "$ASSUME_YES" = "1" ] || [ ! -t 0 ]; then + [ "$default" = "y" ] + return + fi + read -r -p "$(printf '%s[?]%s %s [%s]: ' "$C_CYAN" "$C_RESET" "$prompt" "$default")" answer || true + case "${answer:-$default}" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac +} + +choose_mode_interactive() { + if [ "$ASSUME_YES" = "1" ] || [ ! -t 0 ]; then return 0; fi + printf '%s请选择:%s\n' "$C_BOLD" "$C_RESET" + printf ' 1) 直接安装(systemd 服务 + 内置 Node 运行时,默认 SQLite)%s ← 推荐%s\n' "$C_GREEN" "$C_RESET" + printf ' 2) 直接安装 + MySQL\n' + printf ' 3) Docker 部署(构建镜像 + compose 起容器)\n' + printf ' 4) 只体检(--check,不改动系统)\n' + printf ' 5) 卸载\n' + local choice="" + read -r -p "$(printf '%s[?]%s 输入序号 [1]: ' "$C_CYAN" "$C_RESET")" choice || true + case "${choice:-1}" in + 1) MODE="native"; DB_KIND="sqlite" ;; + 2) MODE="native"; DB_KIND="mysql" ;; + 3) MODE="docker" ;; + 4) CHECK_ONLY=1 ;; + 5) UNINSTALL=1 ;; + *) die "无效的选择:${choice}" ;; + esac + printf '\n' +} + +collect_answers() { + if [ "$CHECK_ONLY" = "1" ] || [ "$UNINSTALL" = "1" ] || [ "$ASSUME_YES" = "1" ]; then return 0; fi + + ask "安装目录" "$INSTALL_DIR" INSTALL_DIR + ask "服务端口" "$PORT" PORT + if [ "$MODE" = "native" ] && [ "$DB_KIND" = "sqlite" ]; then + if ask_yes_no "改用 MySQL(默认 SQLite:单机够用,备份就是拷文件)?" "n"; then + DB_KIND="mysql" + fi + fi + if [ "$DB_KIND" = "mysql" ]; then + ask "MySQL 主机" "$MYSQL_HOST" MYSQL_HOST + ask "MySQL 端口" "$MYSQL_PORT" MYSQL_PORT + ask "MySQL 库名" "$MYSQL_DB" MYSQL_DB + ask "MySQL 用户名" "$MYSQL_USER" MYSQL_USER + ask_secret "MySQL 密码(不回显):" MYSQL_PASSWORD + fi + if [ "$NODE_SOURCE" = "bundled" ]; then + if ask_yes_no "本机已装 Node ≥20.19 时改用系统 Node(不额外下载内置运行时)?" "n"; then + NODE_SOURCE="system" + fi + fi + if [ "$ADMIN_PASSWORD_FROM_STDIN" = "0" ] && [ -z "$ADMIN_PASSWORD" ]; then + ask_secret "初始管理员(admin)密码(留空则用服务端默认 admin123;公网部署请务必设置):" ADMIN_PASSWORD + fi + if ask_yes_no "顺带放一份 Nginx 反向代理配置到 ${CONFIG_DIR}/nginx.conf?" "n"; then WITH_NGINX=1; fi + if ask_yes_no "按检测到的防火墙放行端口 ${PORT}?" "n"; then OPEN_FIREWALL=1; fi +} + +# ---------------------------------------------------------------- 用户与目录 + +ensure_user() { + if id -u "$SERVICE_USER" >/dev/null 2>&1; then + log "系统用户 ${SERVICE_USER} 已存在,跳过创建" + return 0 + fi + if ! getent group "$SERVICE_GROUP" >/dev/null 2>&1; then + groupadd --system "$SERVICE_GROUP" + fi + useradd --system -g "$SERVICE_GROUP" -d "$INSTALL_DIR" -s /sbin/nologin -c "ClassHelper Server" "$SERVICE_USER" 2>/dev/null \ + || useradd --system -g "$SERVICE_GROUP" -d "$INSTALL_DIR" -s /usr/sbin/nologin -c "ClassHelper Server" "$SERVICE_USER" + log "已创建系统用户 ${SERVICE_USER}" +} + +ensure_dirs() { + step "准备目录" + mkdir -p "$INSTALL_DIR" "${INSTALL_DIR}/data" "${INSTALL_DIR}/logs" \ + "${INSTALL_DIR}/${NODE_DIR_NAME%/*}" "$CONFIG_DIR" "$LOG_DIR" "$BACKUP_DIR" + chmod 700 "$CONFIG_DIR" "$LOG_DIR" "$BACKUP_DIR" + # 配置目录归服务用户所有(Web 端「一键切换数据库」要改写 config.env) + chown "$SERVICE_USER:$SERVICE_GROUP" "$CONFIG_DIR" + chmod 755 "$INSTALL_DIR" + chown -R "$SERVICE_USER:$SERVICE_GROUP" "${INSTALL_DIR}/data" "${INSTALL_DIR}/logs" + log "安装目录 ${INSTALL_DIR}|配置 ${CONFIG_DIR}|日志 ${LOG_DIR}|备份 ${BACKUP_DIR}" +} + +# ---------------------------------------------------------------- 安装包 + +asset_arch() { + case "$(uname -m)" in + x86_64|amd64) printf 'x64' ;; + aarch64|arm64) printf 'arm64' ;; + *) uname -m ;; + esac +} + +github_latest_tag() { + local json + json="$(curl -fsSL --max-time 20 -H 'User-Agent: ClassHelper-Installer' \ + "https://api.github.com/repos/${REPO_SLUG}/releases/latest" 2>/dev/null)" || return 1 + printf '%s' "$json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1 +} + +resolve_package_url() { + local tag="$VERSION" ver + if [ -z "$tag" ]; then + tag="$(github_latest_tag)" || true + if [ -z "$tag" ]; then + die "取不到 GitHub 最新版本(本机可能没有外网)。请用 --package 指定安装包,例如: + sudo bash install.sh --package /tmp/classhelper-server-linux-x64-1.1.0.tar.gz" + fi + fi + ver="${tag#v}" + printf '%s/releases/download/%s/classhelper-server-linux-%s-%s.tar.gz' \ + "$GITHUB_RELEASES_PAGE" "$tag" "$(asset_arch)" "$ver" +} + +# 下载 + 校验 + 解压;结果放进全局 STAGE_DIR(不用命令替换取,否则会把日志一起捕获) +fetch_package() { + step "获取服务端安装包" + local url="${PACKAGE_URL}" + if [ -z "$url" ]; then url="$(resolve_package_url)"; fi + + local tmp; tmp="$(mktemp -d)" + local pkg="${tmp}/classhelper-server.tar.gz" + info "下载:${url}" + case "$url" in + file://*|/*|./*|../*) cp -f "${url#file://}" "$pkg" ;; + *) curl -fSL --max-time 600 -H 'User-Agent: ClassHelper-Installer' -o "$pkg" "$url" ;; + esac + [ -s "$pkg" ] || die "安装包为空:${url}" + + local got; got="$(sha256sum "$pkg" | awk '{print $1}')" + if [ -n "$PACKAGE_SHA256" ]; then + [ "$got" = "$PACKAGE_SHA256" ] || die "sha256 校验失败:期望 ${PACKAGE_SHA256},实际 ${got}" + log "sha256 校验通过" + else + PACKAGE_SHA256="$got" + info "sha256(未提供校验值,仅记录):${got}" + fi + + STAGE_DIR="${tmp}/pkg" + mkdir -p "$STAGE_DIR" + tar -xzf "$pkg" -C "$STAGE_DIR" --strip-components=1 || die "解压失败:安装包格式不对" + [ -f "${STAGE_DIR}/server/dist/index.js" ] || die "安装包内容不对:缺少 server/dist/index.js" + if [ -z "$VERSION" ] && [ -f "${STAGE_DIR}/VERSION" ]; then + VERSION="$(tr -d '[:space:]' <"${STAGE_DIR}/VERSION")" + fi + [ -n "$VERSION" ] || VERSION="0.0.0" + log "安装包就绪:v${VERSION}" +} + +place_program_files() { + local item + step "铺开程序文件" + for item in server web node_modules tools bin systemd logrotate.d profile.d \ + package.json VERSION README.txt Dockerfile nginx.conf classhelper.service.template .cross-built; do + [ -e "${STAGE_DIR}/${item}" ] || continue + rm -rf "${INSTALL_DIR:?}/${item}" + cp -a "${STAGE_DIR}/${item}" "${INSTALL_DIR}/${item}" + done + chmod +x "${INSTALL_DIR}/bin/classhelper" + chown -R root:root "${INSTALL_DIR}/server" "${INSTALL_DIR}/web" "${INSTALL_DIR}/node_modules" \ + "${INSTALL_DIR}/tools" "${INSTALL_DIR}/bin" "${INSTALL_DIR}/systemd" 2>/dev/null || true + log "程序文件已就位(server / web / node_modules / tools / bin)" +} + +# ---------------------------------------------------------------- Node 运行时 + +node_runtime_version() { # $1=可执行文件;可用且 ≥20.19 时输出版本号 + [ -x "$1" ] || return 1 + local ver major minor + ver="$("$1" -v 2>/dev/null)" || return 1 + major="$(printf '%s' "$ver" | sed 's/^v\([0-9]*\).*/\1/')" + minor="$(printf '%s' "$ver" | sed 's/^v[0-9]*\.\([0-9]*\).*/\1/')" + if [ "${major:-0}" -gt 20 ] || { [ "${major:-0}" -eq 20 ] && [ "${minor:-0}" -ge 19 ]; }; then + printf '%s' "$ver" + return 0 + fi + return 1 +} + +# glibc 版本(形如 2.28);判定不了就输出空串 +glibc_version() { + local out + out="$(getconf GNU_LIBC_VERSION 2>/dev/null || true)" + if [ -n "$out" ]; then printf '%s' "${out##* }"; return 0; fi + out="$(ldd --version 2>/dev/null | head -n 1 || true)" + printf '%s' "$(printf '%s' "$out" | sed -n 's/.* \([0-9][0-9]*\.[0-9][0-9]*\)$/\1/p')" +} + +# Node 官方 linux-x64 二进制要求 glibc ≥ 2.28(CentOS 7 是 2.17,直接跑不起来) +glibc_too_old() { + local glibc="${1:-}" + [ -n "$glibc" ] || return 1 + local major minor + major="${glibc%%.*}" + minor="$(printf '%s' "$glibc" | sed 's/^[0-9]*\.\([0-9]*\).*/\1/')" + [ "${major:-0}" -lt 2 ] || { [ "${major:-0}" -eq 2 ] && [ "${minor:-0}" -lt 28 ]; } +} + +setup_node() { + step "准备 Node 运行时" + local target="${INSTALL_DIR}/${NODE_DIR_NAME}" + local existing="" + if [ -x "${target}/bin/node" ]; then existing="$(node_runtime_version "${target}/bin/node" || true)"; fi + if [ -n "$existing" ]; then + log "已有内置 Node ${existing},跳过下载" + return 0 + fi + + if [ "$NODE_SOURCE" = "system" ]; then + local sys_node ver="" + sys_node="$(command -v node || true)" + if [ -n "$sys_node" ]; then ver="$(node_runtime_version "$sys_node" || true)"; fi + if [ -z "$ver" ]; then + die "系统 Node 不可用或版本过低(要求 ≥20.19)。去掉 --node-source system 即可用内置运行时" + fi + rm -rf "$target"; mkdir -p "${target}/bin" + ln -sfn "$sys_node" "${target}/bin/node" + log "使用系统 Node ${ver}(${sys_node})" + return 0 + fi + + local work; work="$(mktemp -d)" + local tarball="" sha="" filename="" + if [ -n "$NODE_TARBALL" ]; then + filename="$(basename "${NODE_TARBALL%%\?*}")" + case "$NODE_TARBALL" in + http*|file://*|/*) tarball="${work}/${filename}" ;; + *) tarball="$NODE_TARBALL" ;; + esac + if [ "$tarball" != "$NODE_TARBALL" ]; then + info "下载 Node:${NODE_TARBALL}" + case "$NODE_TARBALL" in + file://*|/*) cp -f "${NODE_TARBALL#file://}" "$tarball" ;; + *) curl -fSL --max-time 600 -o "$tarball" "$NODE_TARBALL" ;; + esac + fi + else + local dist="${NODE_MIRROR%/}/latest-v${NODE_MAJOR}.x" + local glibc; glibc="$(glibc_version)" + if glibc_too_old "$glibc"; then + # CentOS 7 这类老系统 glibc 2.17:Node 官方 x64 二进制(要求 ≥2.28)装了也起不来, + # 历史上会表现成"装完服务立刻退出、日志里只有一行 Illegal instruction / GLIBC not found"。 + warn "本机 glibc ${glibc} < 2.28:Node 官方 linux-$(asset_arch) 二进制无法运行" + warn "改用 unofficial-builds 的 glibc-217 构建(功能一致,只是编译基线更低)" + dist="https://unofficial-builds.nodejs.org/download/release/latest-v${NODE_MAJOR}.x" + fi + info "读取 Node 版本清单:${dist}/SHASUMS256.txt" + local sums="${work}/SHASUMS256.txt" + curl -fsSL --max-time 60 -o "$sums" "${dist}/SHASUMS256.txt" \ + || die "下载 Node 校验清单失败(可用 --node-mirror 换镜像,或 --node-source system)" + # 优先 .tar.gz:不需要 xz 解压工具 + filename="$(grep -oE "node-v[0-9.]+-linux-$(asset_arch)(-glibc-217)?\.tar\.gz" "$sums" | head -n 1)" + if [ -z "$filename" ]; then + filename="$(grep -oE "node-v[0-9.]+-linux-$(asset_arch)(-glibc-217)?\.tar\.xz" "$sums" | head -n 1)" + fi + [ -n "$filename" ] || die "校验清单里没找到 linux-$(asset_arch) 的 Node 包" + sha="$(grep -E "[ *]${filename}\$" "$sums" | awk '{print $1}' | head -n 1)" + tarball="${work}/${filename}" + info "下载 Node:${dist}/${filename}" + curl -fSL --max-time 600 -o "$tarball" "${dist}/${filename}" || die "下载 Node 失败" + fi + + if [ -n "$sha" ]; then + local got; got="$(sha256sum "$tarball" | awk '{print $1}')" + [ "$got" = "$sha" ] || die "Node 压缩包 sha256 校验失败:期望 ${sha},实际 ${got}" + log "Node 压缩包校验通过(${filename})" + else + warn "未校验 Node 压缩包哈希(自定义 --node-tarball 时拿不到校验值)" + fi + + local extract="${work}/extract" + mkdir -p "$extract" + case "$tarball" in + *.tar.xz) tar -xJf "$tarball" -C "$extract" || die "解压 Node 失败(需要 xz:apt/yum install xz)" ;; + *) tar -xzf "$tarball" -C "$extract" || die "解压 Node 失败" ;; + esac + local inner; inner="$(find "$extract" -maxdepth 1 -mindepth 1 -type d | head -n 1)" + [ -n "$inner" ] || die "Node 压缩包结构异常" + + rm -rf "$target"; mkdir -p "$target" + cp -a "${inner}/." "$target/" + if [ -z "$(node_runtime_version "${target}/bin/node" || true)" ]; then + die "内置 Node 不可用或版本过低" + fi + log "内置 Node $("${target}/bin/node" -v) → ${target}" + rm -rf "$work" +} + +# ---------------------------------------------------------------- 配置文件 + +gen_secret() { + local len="${1:-48}" + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex "$len" + elif [ -x "${INSTALL_DIR}/${NODE_DIR_NAME}/bin/node" ]; then + "${INSTALL_DIR}/${NODE_DIR_NAME}/bin/node" -e "process.stdout.write(require('node:crypto').randomBytes(${len}).toString('hex'))" + else + head -c "$len" /dev/urandom | od -An -tx1 | tr -d ' \n' + fi +} + +# URL 里这些字符会破坏连接串解析,做个最小百分号编码 +url_encode() { + printf '%s' "$1" | sed -e 's/%/%25/g' -e 's/@/%40/g' -e 's/:/%3A/g' -e 's|/|%2F|g' \ + -e 's/?/%3F/g' -e 's/#/%23/g' -e 's/ /%20/g' +} + +database_url() { + if [ "$MODE" = "docker" ] && [ "$DB_KIND" = "sqlite" ]; then + # 容器里 serverRoot 是 /app/server,绝对路径最稳 + printf 'file:/app/data/classhelper.db' + elif [ "$DB_KIND" = "mysql" ]; then + printf 'mysql://%s:%s@%s:%s/%s' "$MYSQL_USER" "$(url_encode "$MYSQL_PASSWORD")" "$MYSQL_HOST" "$MYSQL_PORT" "$MYSQL_DB" + else + printf 'file:../data/classhelper.db' + fi +} + +write_config() { + step "写入配置 ${CONFIG_FILE}" + if [ -f "$CONFIG_FILE" ] && [ "$FORCE" != "1" ]; then + log "检测到已有配置:保留(含 JWT_SECRET 与数据库设置),只补齐缺失项" + local key value + for key in NODE_ENV HOST AUTO_MIGRATE TRUST_PROXY; do + case "$key" in + NODE_ENV) value="production" ;; + HOST) value="0.0.0.0" ;; + AUTO_MIGRATE) value="true" ;; + TRUST_PROXY) value="1" ;; + esac + if ! grep -qE "^[[:space:]]*${key}=" "$CONFIG_FILE"; then + printf '\n%s=%s\n' "$key" "$value" >>"$CONFIG_FILE" + fi + done + if ! grep -qE '^[[:space:]]*JWT_SECRET=.' "$CONFIG_FILE"; then + printf '\nJWT_SECRET=%s\n' "$(gen_secret)" >>"$CONFIG_FILE" + fi + chmod 600 "$CONFIG_FILE" + chown "$SERVICE_USER:$SERVICE_GROUP" "$CONFIG_FILE" + return 0 + fi + + local jwt; jwt="$(gen_secret)" + local admin_line="" + if [ -n "$ADMIN_PASSWORD" ]; then + # 明文落盘,与 .env 里其它密钥同级;文件权限 600,属主是服务用户 + admin_line="INITIAL_ADMIN_PASSWORD=${ADMIN_PASSWORD}" + fi + + cat >"$CONFIG_FILE" </.env,而 systemd 注入的是 config.env; + # 用软链把两者收成一份真身,否则切换看起来成功、重启后又连回旧库(详见 docs/linux-deploy.md) + local link="${INSTALL_DIR}/.env" + if [ -e "$link" ] && [ ! -L "$link" ]; then + warn "安装目录里存在真实文件 .env,已改名保留为 .env.bak.$(date +%s) 再建立软链" + mv "$link" "${link}.bak.$(date +%s)" + fi + ln -sfn "$CONFIG_FILE" "$link" + log "已建立 ${link} → ${CONFIG_FILE}" +} + +# ---------------------------------------------------------------- systemd + +install_systemd_unit() { + step "安装 systemd 服务单元" + local node_bin="${INSTALL_DIR}/${NODE_DIR_NAME}/bin/node" + local template="${INSTALL_DIR}/classhelper.service.template" + if [ -f "$template" ]; then + sed -e "s#__NODE__#${node_bin}#g" \ + -e "s#__DIR__#${INSTALL_DIR}#g" \ + -e "s#__USER__#${SERVICE_USER}#g" \ + -e "s#__CONFIG__#${CONFIG_FILE}#g" \ + -e "s#__CONFIGDIR__#${CONFIG_DIR}#g" \ + "$template" >"/etc/systemd/system/${SERVICE_NAME}.service" + else + cat >"/etc/systemd/system/${SERVICE_NAME}.service" </dev/null + log "已安装 /etc/systemd/system/${SERVICE_NAME}.service 并设为开机自启" +} + +install_aux_files() { + step "安装运维命令与辅助文件" + ln -sfn "${INSTALL_DIR}/bin/classhelper" "${BIN_DIR}/classhelper" + + if [ -d /usr/share/bash-completion/completions ]; then + "${INSTALL_DIR}/bin/classhelper" completion bash >/usr/share/bash-completion/completions/classhelper 2>/dev/null || true + chmod 644 /usr/share/bash-completion/completions/classhelper 2>/dev/null || true + fi + + if [ -f "${INSTALL_DIR}/logrotate.d/classhelper" ]; then + install -m 644 "${INSTALL_DIR}/logrotate.d/classhelper" /etc/logrotate.d/classhelper + fi + + if [ -f "${INSTALL_DIR}/profile.d/classhelper.sh" ]; then + sed -e "s#__DIR__#${INSTALL_DIR}#g" \ + -e "s#__CONFIG__#${CONFIG_FILE}#g" \ + -e "s#__LOGDIR__#${LOG_DIR}#g" \ + -e "s#__BACKUPDIR__#${BACKUP_DIR}#g" \ + "${INSTALL_DIR}/profile.d/classhelper.sh" >/etc/profile.d/classhelper.sh + chmod 644 /etc/profile.d/classhelper.sh + log "已写入 /etc/profile.d/classhelper.sh" + fi + log "已安装 ${BIN_DIR}/classhelper(软链 → ${INSTALL_DIR}/bin/classhelper)" +} + +write_meta() { # $1=来源 + cat >"${INSTALL_DIR}/.installed.json" </dev/null 2>&1 && docker info >/dev/null 2>&1; } + +install_docker_engine() { + if docker_available; then return 0; fi + if [ "$INSTALL_DOCKER" != "1" ] && [ "$ASSUME_YES" != "1" ]; then + if ! ask_yes_no "未检测到可用的 Docker,是否用官方脚本安装 docker-ce?" "n"; then + die "Docker 形态需要 docker;也可以改用 --mode native" + fi + fi + info "通过 get.docker.com 安装 docker(官方脚本)" + curl -fsSL https://get.docker.com -o /tmp/get-docker.sh || die "下载 Docker 安装脚本失败" + sh /tmp/get-docker.sh >>"$LOG_FILE" 2>&1 || die "Docker 安装失败,详见 ${LOG_FILE}" + systemctl enable --now docker >/dev/null 2>&1 || true + docker_available || die "Docker 装好了但连不上(docker info 失败)" + log "Docker 已就绪:$(docker --version)" +} + +write_compose() { + local compose="${CONFIG_DIR}/compose.yml" + cat >"$compose" <>"$LOG_FILE" 2>&1; then + tail -30 "$LOG_FILE" >&2 || true + die "镜像构建失败,详见 ${LOG_FILE}" + fi + # 记下这个镜像对应的版本,classhelper 靠它判断要不要重建(避免每次 restart 都重新构建) + printf '%s' "$VERSION" >"${INSTALL_DIR}/.docker-image-version" + log "镜像已构建:classhelper-server:local(另打 tag classhelper-server:${VERSION})" +} + +# ---------------------------------------------------------------- 附加功能 + +setup_nginx() { + step "准备 Nginx 反向代理配置" + local target="${CONFIG_DIR}/nginx.conf" + if [ -f "${INSTALL_DIR}/nginx.conf" ]; then + install -m 600 "${INSTALL_DIR}/nginx.conf" "$target" + else + cat >"$target" </dev/null 2>&1 && systemctl is-active --quiet firewalld 2>/dev/null; then + firewall-cmd --permanent --add-port="${PORT}/tcp" >/dev/null && firewall-cmd --reload >/dev/null + log "firewalld 已放行 ${PORT}/tcp" + elif command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then + ufw allow "${PORT}/tcp" >/dev/null + log "ufw 已放行 ${PORT}/tcp" + else + warn "未检测到启用中的 firewalld / ufw,跳过(云服务器还要在安全组放行 ${PORT})" + fi +} + +# ---------------------------------------------------------------- 体检 + +check_only() { + detect_os + printf '%s体检报告%s(不改动任何东西)\n\n' "$C_BOLD" "$C_RESET" + local tools="" + for tool in curl tar gzip sha256sum; do + if command -v "$tool" >/dev/null 2>&1; then tools="${tools}${tool} "; else tools="${tools}${tool}(缺) "; fi + done + local sys_node="-"; local node_bin; node_bin="$(command -v node 2>/dev/null || true)" + if [ -n "$node_bin" ]; then sys_node="$("$node_bin" -v)"; fi + + printf ' %-24s %s\n' "系统" "${OS_NAME:-未知}" + printf ' %-24s %s\n' "包管理器" "${PKG:-未识别(apt/yum/dnf 都没有?)}" + printf ' %-24s %s\n' "架构" "$(uname -m) → $(asset_arch)" + printf ' %-24s %s\n' "glibc" "$(glibc_version)(<2.28 时自动改用 glibc-217 版 Node)" + printf ' %-24s %s\n' "systemd" "$(has_systemd && echo 可用 || echo 不可用)" + printf ' %-24s %s\n' "docker" "$(docker_available && docker --version || echo 不可用)" + printf ' %-24s %s\n' "基础工具" "${tools}" + printf ' %-24s %s\n' "系统 Node" "${sys_node}(安装器默认用内置运行时,不依赖它)" + printf ' %-24s %s\n' "内存" "$(free -h 2>/dev/null | awk 'NR==2 {print $2}' || echo 未知)" + printf ' %-24s %s\n' "磁盘 $(dirname "$INSTALL_DIR")" "$(df -h "$(dirname "$INSTALL_DIR")" 2>/dev/null | awk 'NR==2 {print $4" 可用"}' || echo 未知)" + printf ' %-24s %s\n' "端口 ${PORT}" "$(port_in_use "$PORT" && echo '已被占用 ⚠' || echo 空闲)" + printf ' %-24s %s\n' "安装目录 ${INSTALL_DIR}" "$(is_installed && echo "已安装 v$(existing_version)(重跑=覆盖安装)" || echo 尚未安装)" + printf ' %-24s %s\n' "配置 ${CONFIG_FILE}" "$([ -f "$CONFIG_FILE" ] && echo 已存在(会被保留) || echo 尚未创建)" + printf '\n' + + local problems=0 + command -v curl >/dev/null 2>&1 || { warn "缺少 curl"; problems=1; } + has_systemd || { warn "没有 systemd:直接安装形态不可用(可用 --mode docker)"; problems=1; } + [ "$PKG" != "" ] || { warn "未识别包管理器:自动装依赖会失败"; problems=1; } + if [ "$problems" = "1" ]; then return 1; fi + log "体检通过:可以执行 sudo bash install.sh" +} + +# ---------------------------------------------------------------- 卸载 + +do_uninstall() { + if [ -x "${INSTALL_DIR}/bin/classhelper" ]; then + exec "${INSTALL_DIR}/bin/classhelper" uninstall "$@" + fi + step "卸载(找不到 classhelper,执行内置清理)" + if [ "$MODE" = "docker" ]; then + if [ -f "${CONFIG_DIR}/compose.yml" ]; then + docker compose -f "${CONFIG_DIR}/compose.yml" down --remove-orphans 2>/dev/null || true + fi + else + systemctl disable --now "$SERVICE_NAME" >/dev/null 2>&1 || true + rm -f "/etc/systemd/system/${SERVICE_NAME}.service" /etc/systemd/system/classhelper-backup.service /etc/systemd/system/classhelper-backup.timer + fi + rm -f /etc/profile.d/classhelper.sh "${BIN_DIR}/classhelper" /etc/logrotate.d/classhelper + systemctl daemon-reload 2>/dev/null || true + log "服务已移除;程序与数据仍在 ${INSTALL_DIR}(确认不需要后手工删除)" +} + +# ---------------------------------------------------------------- 结果输出 + +print_result() { + local ip; ip="$(hostname -I 2>/dev/null | awk '{print $1}')" + ip="${ip:-127.0.0.1}" + printf '\n%s────────────────────────────────────────────────────────────%s\n' "$C_GREEN" "$C_RESET" + printf '%s安装完成%s %s服务端 v%s(%s / %s)\n\n' "$C_BOLD" "$C_RESET" "$PRODUCT" "$VERSION" "$MODE" "$DB_KIND" + printf ' 管理端地址 http://%s:%s/\n' "$ip" "$PORT" + printf ' 健康探针 http://%s:%s/healthz\n' "$ip" "$PORT" + printf ' 配置文件 %s(权限 600)\n' "$CONFIG_FILE" + printf ' 数据目录 %s/data\n' "$INSTALL_DIR" + printf ' 安装日志 %s\n\n' "$LOG_FILE" + if [ -n "$ADMIN_PASSWORD" ]; then + printf ' 管理员账号 admin(密码为你刚才设置的那个)\n' + else + printf ' 管理员账号 admin / admin123 %s← 默认密码,公网部署请立刻修改%s\n' "$C_YELLOW" "$C_RESET" + fi + printf '\n%s常用命令%s\n' "$C_BOLD" "$C_RESET" + printf ' classhelper status 查看状态 / 版本 / 端口\n' + printf ' classhelper doctor 完整体检\n' + printf ' classhelper password admin 改管理员密码(交互输入,不回显)\n' + printf ' classhelper backup 备份配置 + 数据库\n' + printf ' classhelper backup schedule daily 开启每日自动备份\n' + printf ' classhelper upgrade --check 检查新版本\n' + printf ' classhelper logs -f 实时日志\n' + printf ' classhelper help 全部命令\n' + printf '\n%s接下来建议%s\n' "$C_BOLD" "$C_RESET" + printf ' 1. 改掉默认密码:%sclasshelper password admin%s\n' "$C_CYAN" "$C_RESET" + printf ' 2. 公网访问请上 HTTPS:改好 %s/nginx.conf 的域名与证书,并把 CORS_ORIGIN\n' "$CONFIG_DIR" + printf ' 收敛到你的域名(classhelper config set CORS_ORIGIN https://your.domain)\n' + printf ' 3. 学生端「服务器地址」填 http://%s:%s 或你的域名\n' "$ip" "$PORT" + printf '%s────────────────────────────────────────────────────────────%s\n' "$C_GREEN" "$C_RESET" +} + +# ---------------------------------------------------------------- 主流程 + +main() { + parse_args "$@" + + if [ "$CHECK_ONLY" = "1" ]; then + banner + check_only + exit $? + fi + + [ "$(id -u)" = "0" ] || die "请用 root 执行:sudo bash install.sh(只体检不需要 root:bash install.sh --check)" + + banner + mkdir -p "$LOG_DIR" && chmod 700 "$LOG_DIR" + { + printf '\n===== %s 安装开始 =====\n' "$(date '+%F %T')" + printf '参数:%s\n' "$*" + } >>"$LOG_FILE" + # 后续输出同时进日志(tee 不占 stdin,交互问答照常) + exec > >(tee -a "$LOG_FILE") 2>&1 + + detect_os + info "系统:${OS_NAME:-未知}|包管理器:${PKG:-未知}|架构:$(uname -m)" + + choose_mode_interactive + collect_answers + + if [ "$UNINSTALL" = "1" ]; then + do_uninstall --yes + exit 0 + fi + + if [ "$ADMIN_PASSWORD_FROM_STDIN" = "1" ]; then read_stdin_line ADMIN_PASSWORD; fi + + if [ "$MODE" = "native" ] && ! has_systemd && [ "$FORCE" != "1" ]; then + die "本机没有 systemd:改用 --mode docker,或加 --force 只铺文件(不装服务)" + fi + + # 覆盖安装时沿用已有端口与数据库形态,避免"重跑一次配置被改回默认" + if is_installed && [ -f "$CONFIG_FILE" ] && [ "$FORCE" != "1" ]; then + local old_port old_db + old_port="$(config_value PORT)" + old_db="$(config_value DATABASE_PROVIDER)" + [ -n "$old_port" ] && PORT="$old_port" + [ -n "$old_db" ] && DB_KIND="$old_db" + info "检测到已安装(v$(existing_version)):按覆盖安装处理,配置与数据保留(端口 ${PORT} / ${DB_KIND})" + fi + + if port_in_use "$PORT"; then + if [ "$MODE" = "native" ] && is_installed; then + info "端口 ${PORT} 已被占用(可能是本服务在跑,稍后会被重启接管)" + else + warn "端口 ${PORT} 已被占用,安装后可能起不来" + ask "换一个端口" "$PORT" PORT + fi + fi + + ensure_dependencies + if [ "$MODE" = "docker" ]; then install_docker_engine; fi + ensure_user + ensure_dirs + + fetch_package + place_program_files + # 临时解压目录(含安装包副本,几百 MB)用完即删,避免把小盘服务器塞满 + rm -rf "$(dirname "$STAGE_DIR")" + + if [ "$MODE" = "native" ]; then setup_node; fi + + write_config + link_env_file + write_meta "${PACKAGE_URL:-github-release}" + + if [ "$MODE" = "docker" ]; then + write_compose + docker_build_image + step "启动容器" + if ! docker compose -f "${CONFIG_DIR}/compose.yml" --env-file "$CONFIG_FILE" up -d >>"$LOG_FILE" 2>&1; then + tail -30 "$LOG_FILE" >&2 || true + die "容器启动失败,详见 ${LOG_FILE}" + fi + elif [ "$FORCE" = "1" ] && ! has_systemd; then + warn "没有 systemd(--force):请手工启动" + printf ' sudo -u %s %s/%s/bin/node %s/server/dist/index.js\n' "$SERVICE_USER" "$INSTALL_DIR" "$NODE_DIR_NAME" "$INSTALL_DIR" + else + install_systemd_unit + step "启动服务" + systemctl restart "$SERVICE_NAME" + fi + + install_aux_files + if [ "$WITH_NGINX" = "1" ]; then setup_nginx; fi + if [ "$OPEN_FIREWALL" = "1" ]; then setup_firewall; fi + + step "等待服务就绪" + local ready=0 i=0 + while [ "$i" -lt 90 ]; do + if curl -fsS --noproxy '*' --max-time 3 -o /dev/null "http://127.0.0.1:${PORT}/healthz" 2>/dev/null; then + ready=1 + break + fi + i=$((i + 1)) + sleep 1 + done + if [ "$ready" != "1" ]; then + warn "90 秒内没通过 /healthz,最近日志:" + if [ "$MODE" = "docker" ]; then + docker compose -f "${CONFIG_DIR}/compose.yml" logs --tail 50 2>&1 || true + else + journalctl -u "$SERVICE_NAME" -n 50 --no-pager 2>&1 || true + fi + die "安装未完成:服务没起来。常见原因:端口被占用、MySQL 连接串错误、数据目录权限。日志:${LOG_FILE}" + fi + log "服务已就绪 ✅" + + print_result + printf '\n%s提示:新开一个终端(或 source /etc/profile)后,classhelper 的环境变量也会生效。%s\n' "$C_DIM" "$C_RESET" + sleep 0.3 # 给 tee 一点时间把最后几行刷进日志 +} + +main "$@" diff --git a/deploy/logrotate.d/classhelper b/deploy/logrotate.d/classhelper new file mode 100644 index 0000000..153dab7 --- /dev/null +++ b/deploy/logrotate.d/classhelper @@ -0,0 +1,19 @@ +# --------------------------------------------------------------------------- +# 班级小助手 · 日志轮转 +# 由 deploy/install.sh 安装到 /etc/logrotate.d/classhelper +# +# copytruncate 而不是 create+信号:安装器与 classhelper 都是"追加写完就关" +# (`>>` 打开、写完立即关闭),没有常驻 fd 可通知,copytruncate 是这里唯一正确的做法。 +# --------------------------------------------------------------------------- + +/var/log/classhelper/*.log { + weekly + rotate 8 + missingok + notifempty + compress + delaycompress + copytruncate + create 0600 root root + su root root +} diff --git a/deploy/package.runtime.json b/deploy/package.runtime.json index c828155..a8c6e46 100644 --- a/deploy/package.runtime.json +++ b/deploy/package.runtime.json @@ -1,6 +1,6 @@ { "name": "classhelper-server-runtime", - "version": "1.0.0", + "version": "1.1.0", "private": true, "type": "module", "description": "班级小助手服务端运行时(安装包 / 容器内置)", diff --git a/deploy/profile.d/classhelper.sh b/deploy/profile.d/classhelper.sh new file mode 100644 index 0000000..f651131 --- /dev/null +++ b/deploy/profile.d/classhelper.sh @@ -0,0 +1,28 @@ +# --------------------------------------------------------------------------- +# 班级小助手 · 登录 shell 环境变量 +# +# 由 deploy/install.sh 安装到 /etc/profile.d/classhelper.sh(占位符替换成真实路径)。 +# 生效方式:重新登录,或 `source /etc/profile.d/classhelper.sh`。 +# +# 说明:`classhelper` 命令本身装在 /usr/local/bin(软链到 $CLASSHELPER_HOME/bin/classhelper), +# 不依赖这个文件也能用;这里提供的是路径类变量,方便写脚本时引用。 +# --------------------------------------------------------------------------- + +# 只在目录真实存在时导出:卸载后残留的 profile.d 不应该污染别人的环境 +if [ -d '__DIR__' ]; then + export CLASSHELPER_HOME='__DIR__' + export CLASSHELPER_CONFIG='__CONFIG__' + export CLASSHELPER_LOG_DIR='__LOGDIR__' + export CLASSHELPER_BACKUP_DIR='__BACKUPDIR__' + export CLASSHELPER_SERVICE='classhelper' + + case ":${PATH}:" in + *":${CLASSHELPER_HOME}/bin:"*) ;; + *) export PATH="${PATH}:${CLASSHELPER_HOME}/bin" ;; + esac +fi + +# 让 `classhelper` 在非 root 登录时也能补全(bash 补全由 `classhelper completion bash` 输出) +if [ -n "${BASH_VERSION:-}" ] && [ -r /usr/share/bash-completion/completions/classhelper ]; then + . /usr/share/bash-completion/completions/classhelper +fi diff --git a/deploy/systemd/classhelper-backup.service b/deploy/systemd/classhelper-backup.service new file mode 100644 index 0000000..e22fe42 --- /dev/null +++ b/deploy/systemd/classhelper-backup.service @@ -0,0 +1,22 @@ +# --------------------------------------------------------------------------- +# 班级小助手 · 定时备份(systemd timer) +# 由 `classhelper backup schedule daily|weekly` 安装(占位符 __BIN__ / __KEEP__ 由命令替换) +# +# 手工安装: +# sudo sed -e 's#__BIN__#/usr/local/bin/classhelper#' -e 's#__KEEP__#7#' \ +# deploy/systemd/classhelper-backup.service > /tmp/x.service ... +# 然后 systemctl daemon-reload && systemctl enable --now classhelper-backup.timer +# --------------------------------------------------------------------------- + +[Unit] +Description=ClassHelper 定时备份 +Documentation=https://github.com/LoveChengke/ClassHelper +After=classhelper.service + +[Service] +Type=oneshot +ExecStart=__BIN__ backup --quiet --keep __KEEP__ +# 备份失败时不要静默:journalctl -u classhelper-backup 里能看到退出码与原因 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=classhelper-backup diff --git a/deploy/systemd/classhelper-backup.timer b/deploy/systemd/classhelper-backup.timer new file mode 100644 index 0000000..82ae4ca --- /dev/null +++ b/deploy/systemd/classhelper-backup.timer @@ -0,0 +1,19 @@ +# --------------------------------------------------------------------------- +# 班级小助手 · 定时备份(触发器) +# 由 `classhelper backup schedule daily|weekly` 安装;OnCalendar 行由命令按选择替换 +# --------------------------------------------------------------------------- + +[Unit] +Description=ClassHelper 定时备份触发器 +Documentation=https://github.com/LoveChengke/ClassHelper + +[Timer] +OnCalendar=__ONCALENDAR__ +# 错开整点:避免所有装了本服务的机器在同一秒打盘 +RandomizedDelaySec=30m +# 机器关机期间错过的备份在开机后补一次 +Persistent=true +Unit=classhelper-backup.service + +[Install] +WantedBy=timers.target diff --git a/deploy/tools/admin-cli.mjs b/deploy/tools/admin-cli.mjs new file mode 100644 index 0000000..42bdd63 --- /dev/null +++ b/deploy/tools/admin-cli.mjs @@ -0,0 +1,335 @@ +#!/usr/bin/env node +/** + * 班级小助手 · 离线账号工具(随 Linux 包发布,安装在 ${INSTALL_DIR}/tools/admin-cli.mjs) + * + * 为什么不用 HTTP API 改密码:`classhelper password` 要能在**服务停机**时救急, + * 而调 API 必须先有一个能登录的账号 —— 管理员忘了密码时就是死锁。 + * 因此这里直连数据库,复用服务端自己的 Prisma Client 与 bcrypt 参数(`server/dist/lib/*`), + * 保证写入的哈希与登录校验用的是同一套实现。 + * + * 安全约定(务必保持): + * - **密码只从 stdin 读**,绝不接受 argv / 环境变量形式的密码(否则会进 shell history 与 ps 输出); + * - 本工具自己**不打印任何密码**,也不写任何日志; + * - `dump-hash` 会输出 bcrypt 哈希 —— 仅由 root 调用的 `classhelper password` 用于改前备份, + * 调用方负责把结果落到 600 权限的文件里。 + * + * 用法(全部由 classhelper 封装,一般不需要手工调): + * node tools/admin-cli.mjs info + * node tools/admin-cli.mjs list [--json] + * node tools/admin-cli.mjs dump-hash <用户名|class:班级码> + * node tools/admin-cli.mjs set-password <用户名> # 新密码从 stdin 读 + * node tools/admin-cli.mjs set-class-password <班级码> # 新密码从 stdin 读 + * node tools/admin-cli.mjs verify <用户名> # 待校验密码从 stdin 读 + * node tools/admin-cli.mjs restore-hash <备份json路径> + * node tools/admin-cli.mjs snapshot <目标db文件> # SQLite 一致性快照(VACUUM INTO) + * + * 退出码:0 成功 / 1 通用失败 / 2 用法错误 / 3 目标不存在 / 4 密码不合规 / 5 数据库不可用 + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +/** 安装根目录(tools/ 的上一级) */ +const installDir = path.resolve(scriptDir, '..'); +const serverDist = path.join(installDir, 'server', 'dist'); + +const EXIT = { OK: 0, FAIL: 1, USAGE: 2, NOT_FOUND: 3, WEAK: 4, NO_DB: 5 }; + +function fail(message, code = EXIT.FAIL) { + process.stderr.write(`admin-cli: ${message}\n`); + process.exitCode = code; + return null; +} + +/** 一次性读完 stdin(去掉行尾换行;保留内部空白,密码里可以有空格) */ +async function readSecretFromStdin() { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + return Buffer.concat(chunks).toString('utf8').replace(/\r?\n$/, ''); +} + +/** 与服务端一致的密码强度规则(classhelper 里还有一份交互式版本,两边保持同样口径) */ +function checkStrength(password, username) { + const problems = []; + if (password.length < 8) problems.push('长度至少 8 位'); + if (password.length > 128) problems.push('长度不要超过 128 位'); + const kinds = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((re) => re.test(password)).length; + if (kinds < 3) problems.push('需包含小写字母 / 大写字母 / 数字 / 符号 中至少 3 类'); + if (username && password.toLowerCase() === username.toLowerCase()) problems.push('不能与用户名相同'); + if (/^(123456|password|admin|classhelper|qwerty|111111|abc123)/i.test(password) && password.length < 12) { + problems.push('过于常见,请换一个'); + } + return problems; +} + +/** 懒加载服务端模块:先做"库在不在"的检查,避免 libSQL 在连接时凭空建出一个空库 */ +async function loadServerModules() { + const envFile = path.join(serverDist, 'config', 'env.js'); + if (!fs.existsSync(envFile)) { + fail(`找不到服务端产物:${envFile}`, EXIT.NO_DB); + return null; + } + const env = await import(pathToFileURL(envFile).href); + if (env.env.databaseProvider === 'sqlite') { + // classhelper 做备份时会用 CLASSHELPER_SNAPSHOT_DB 显式指定数据文件(跳过硬编码路径假设) + const dbFile = process.env.CLASSHELPER_SNAPSHOT_DB || env.env.sqliteFilePath; + if (!dbFile || !fs.existsSync(dbFile)) { + fail( + `数据库文件不存在:${dbFile || '(未配置)'}\n` + + ' 提示:全新安装请先 systemctl start classhelper 完成建表(AUTO_MIGRATE);', + EXIT.NO_DB, + ); + return null; + } + } + const db = await import(pathToFileURL(path.join(serverDist, 'lib', 'db.js')).href); + const password = await import(pathToFileURL(path.join(serverDist, 'lib', 'password.js')).href); + return { env: env.env, prisma: db.prisma, disconnectPrisma: db.disconnectPrisma, ...password }; +} + +/** 目标账号:普通用户按 username,班级账号按 Class.code(学生端登录用的是班级码) */ +async function resolveTarget(prisma, key) { + if (key.startsWith('class:')) { + const code = key.slice('class:'.length).toUpperCase(); + const klass = await prisma.class.findUnique({ where: { code } }); + if (!klass) return { error: `班级码不存在:${code}`, code: EXIT.NOT_FOUND }; + return { kind: 'class', id: klass.id, label: `班级 ${klass.name}(码 ${klass.code})`, hash: klass.passwordHash }; + } + const user = await prisma.user.findUnique({ where: { username: key } }); + if (!user) return { error: `用户名不存在:${key}`, code: EXIT.NOT_FOUND }; + if (user.role === 'STUDENT') { + return { + error: `学生没有个人账号(${key} 只是名单记录),学生端用「班级码 + 班级密码」登录,请用 --class 改班级密码`, + code: EXIT.NOT_FOUND, + }; + } + return { kind: 'user', id: user.id, label: `${user.name}(${user.username} / ${user.role})`, hash: user.passwordHash }; +} + +/* ------------------------------------------------------------------ 子命令 */ + +/** + * SQLite 一致性快照:`VACUUM INTO` 在服务运行中也能拿到自洽的副本 + * (直接 cp 数据文件可能抓到写入中的中间状态)。 + * 目标文件必须不存在 —— 这是 SQLite 的硬要求,先删掉。 + */ +async function cmdSnapshot(modules, dest) { + const { env, prisma } = modules; + if (env.databaseProvider !== 'sqlite') { + return fail('snapshot 只支持 SQLite;MySQL 请用 mysqldump', EXIT.FAIL); + } + const target = path.resolve(dest); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.rmSync(target, { force: true }); + // VACUUM INTO 不接受参数占位符,只能拼字面量 —— 单引号按 SQL 规则翻倍转义 + await prisma.$executeRawUnsafe(`VACUUM INTO '${target.replace(/'/g, "''")}'`); + if (!fs.existsSync(target)) return fail('快照未生成(VACUUM INTO 未报错但没有产出文件)', EXIT.FAIL); + process.stdout.write(`${target}\n`); +} + +async function cmdInfo(modules) { + const { env, prisma } = modules; + const counts = {}; + for (const model of ['user', 'class', 'course', 'schedule', 'homework', 'notification', 'grade']) { + counts[model] = await prisma[model].count(); + } + const payload = { + provider: env.databaseProvider, + sqliteFile: env.sqliteFilePath || null, + serverRoot: env.serverRoot, + termStartDate: env.termStartDate || null, + counts, + }; + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); +} + +async function cmdList(modules, asJson) { + const { prisma } = modules; + const users = await prisma.user.findMany({ + where: { role: { in: ['ADMIN', 'TEACHER'] } }, + select: { username: true, name: true, role: true, classId: true, updatedAt: true }, + orderBy: [{ role: 'asc' }, { username: 'asc' }], + }); + const classes = await prisma.class.findMany({ + select: { code: true, name: true, passwordHash: true }, + orderBy: { code: 'asc' }, + }); + + if (asJson) { + process.stdout.write( + `${JSON.stringify( + { + users, + classes: classes.map((item) => ({ code: item.code, name: item.name, hasPassword: !!item.passwordHash })), + }, + null, + 2, + )}\n`, + ); + return; + } + + process.stdout.write('可登录账号(教师 / 管理员):\n'); + for (const user of users) { + process.stdout.write(` ${user.role.padEnd(8)} ${user.username.padEnd(16)} ${user.name}\n`); + } + process.stdout.write('\n班级账号(学生端用「班级码 + 班级密码」登录):\n'); + for (const item of classes) { + const state = item.passwordHash ? '已设置' : '⚠ 未设置密码(该班当前无法登录)'; + process.stdout.write(` ${item.code.padEnd(8)} ${item.name.padEnd(16)} ${state}\n`); + } +} + +async function cmdDumpHash(modules, key) { + const { prisma } = modules; + const target = await resolveTarget(prisma, key); + if (target.error) return fail(target.error, target.code); + if (!target.hash) return fail(`${target.label} 当前没有密码(passwordHash 为空)`, EXIT.NOT_FOUND); + // 只给调用方(root)看的原始数据,不落到本工具的日志里 + process.stdout.write(`${JSON.stringify({ target: key, kind: target.kind, id: target.id, hash: target.hash })}\n`); +} + +async function cmdSetPassword(modules, key, readSecret) { + const { prisma, hashPassword } = modules; + const target = await resolveTarget(prisma, key); + if (target.error) return fail(target.error, target.code); + + const secret = await readSecret(); + const problems = checkStrength(secret, key); + if (problems.length > 0) return fail(`密码不合规:${problems.join(';')}`, EXIT.WEAK); + + const hash = await hashPassword(secret); + if (target.kind === 'class') { + await prisma.class.update({ where: { id: target.id }, data: { passwordHash: hash } }); + } else { + await prisma.user.update({ where: { id: target.id }, data: { passwordHash: hash } }); + } + + // 写完立刻复核一次:曾经出现过"bcrypt 参数不一致导致写进去的哈希验证不过"的排查成本 + const ok = await modules.verifyPassword(secret, hash); + if (!ok) return fail('写入后复核失败:哈希无法通过校验,请用 restore-hash 回滚', EXIT.FAIL); + + process.stdout.write(`已更新密码:${target.label}\n`); +} + +async function cmdVerify(modules, key, readSecret) { + const { prisma, verifyPassword } = modules; + const target = await resolveTarget(prisma, key); + if (target.error) return fail(target.error, target.code); + const secret = await readSecret(); + const ok = !!target.hash && (await verifyPassword(secret, target.hash)); + process.stdout.write(`${ok ? 'match' : 'mismatch'}\n`); + if (!ok) process.exitCode = EXIT.FAIL; +} + +async function cmdRestoreHash(modules, backupFile) { + const { prisma } = modules; + if (!fs.existsSync(backupFile)) return fail(`备份文件不存在:${backupFile}`, EXIT.NOT_FOUND); + let payload; + try { + payload = JSON.parse(fs.readFileSync(backupFile, 'utf8')); + } catch (error) { + return fail(`备份文件不是合法 JSON:${error.message}`); + } + const { target, hash } = payload; + if (typeof target !== 'string' || typeof hash !== 'string') { + return fail('备份文件缺少 target / hash 字段,拒绝回滚'); + } + if (target.startsWith('class:')) { + await prisma.class.update({ where: { code: target.slice('class:'.length).toUpperCase() }, data: { passwordHash: hash } }); + } else { + await prisma.user.update({ where: { username: target }, data: { passwordHash: hash } }); + } + process.stdout.write(`已回滚密码哈希:${target}\n`); +} + +/* ------------------------------------------------------------------ 入口 */ + +const argv = process.argv.slice(2); +const command = argv[0] ?? 'help'; +const args = argv.slice(1); +const wantsJson = args.includes('--json'); +const positional = args.filter((item) => !item.startsWith('--')); + +function usage() { + process.stdout.write( + [ + 'classhelper 离线账号工具(一般由 classhelper 命令调用)', + '', + ' info 输出数据库与表行数(JSON)', + ' list [--json] 列出可登录账号与班级账号', + ' dump-hash <用户名|class:班级码> 输出当前密码哈希(供改密前备份)', + ' set-password <用户名> 从 stdin 读新密码并写入', + ' set-class-password <班级码> 从 stdin 读新密码并写入班级账号', + ' verify <用户名> 从 stdin 读密码,输出 match / mismatch', + ' restore-hash <备份json路径> 用备份文件里的哈希回滚', + ' snapshot <目标db文件> 把 SQLite 库一致性快照到指定路径(VACUUM INTO)', + '', + ].join('\n'), + ); +} + +if (command === 'help' || command === '--help' || command === '-h') { + usage(); +} else if ( + ![ + 'info', + 'list', + 'dump-hash', + 'set-password', + 'set-class-password', + 'verify', + 'restore-hash', + 'snapshot', + ].includes(command) +) { + fail(`未知子命令:${command}\n`, EXIT.USAGE); + usage(); +} else { + const modules = await loadServerModules(); + if (!modules) { + // 已经在 loadServerModules 里设了 exitCode + } else { + try { + switch (command) { + case 'info': + await cmdInfo(modules); + break; + case 'list': + await cmdList(modules, wantsJson); + break; + case 'dump-hash': + if (!positional[0]) fail('缺少目标账号', EXIT.USAGE); + else await cmdDumpHash(modules, positional[0]); + break; + case 'set-password': + case 'set-class-password': { + const key = command === 'set-class-password' ? `class:${positional[0] ?? ''}` : (positional[0] ?? ''); + if (!positional[0]) fail('缺少目标账号', EXIT.USAGE); + else await cmdSetPassword(modules, key, readSecretFromStdin); + break; + } + case 'verify': + if (!positional[0]) fail('缺少目标账号', EXIT.USAGE); + else await cmdVerify(modules, positional[0], readSecretFromStdin); + break; + case 'restore-hash': + if (!positional[0]) fail('缺少备份文件路径', EXIT.USAGE); + else await cmdRestoreHash(modules, positional[0]); + break; + case 'snapshot': + if (!positional[0]) fail('缺少目标文件路径', EXIT.USAGE); + else await cmdSnapshot(modules, positional[0]); + break; + default: + usage(); + } + } catch (error) { + fail(error instanceof Error ? error.message : String(error)); + } finally { + // 不用 process.exit():在顶层 await 里硬退会踩 libuv 断言(见 AGENTS.md §7) + await modules.disconnectPrisma(); + } + } +} diff --git a/deploy/verify-linux.sh b/deploy/verify-linux.sh new file mode 100644 index 0000000..0dc9d76 --- /dev/null +++ b/deploy/verify-linux.sh @@ -0,0 +1,302 @@ +#!/usr/bin/env bash +# --------------------------------------------------------------------------- +# 班级小助手 · Linux 安装与运维链路验收(在**已安装的服务器上**运行) +# +# 覆盖:安装结果 → 服务可用性 → API 登录 → 改密(含回滚与审计)→ 备份 → +# 配置读写 → key rotate →(可选)升级 / 回滚 / 卸载 +# +# 用法(在服务器上,需要 root): +# sudo bash verify-linux.sh # 基础链路 +# sudo bash verify-linux.sh --full --package /tmp/xxx.tar.gz # 追加升级/回滚 +# sudo bash verify-linux.sh --admin-password-stdin # 密码从 stdin 读一行(不进 argv/history) +# +# 说明: +# - 本脚本会**真的**改一次管理员密码(换成随机强密码,再改回去),并真的发一条备份; +# - 除了 --purge 的卸载测试,不会删除任何数据; +# - 全程不需要外网(升级测试用 --package 指向本地包)。 +# --------------------------------------------------------------------------- +set -uo pipefail + +INSTALL_DIR="/opt/classhelper" +CONFIG_FILE="/etc/classhelper/config.env" +LOG_DIR="/var/log/classhelper" +BACKUP_DIR="/var/backups/classhelper" +PORT="" +ADMIN_USER="admin" +ADMIN_PASSWORD="" +ADMIN_PASSWORD_FROM_STDIN=0 +DO_FULL=0 +PACKAGE="" + +TOTAL=0; PASS=0; FAIL=0 +FAILED=() + +while [ $# -gt 0 ]; do + case "$1" in + --dir) INSTALL_DIR="$2"; shift 2 ;; + --config) CONFIG_FILE="$2"; shift 2 ;; + --port) PORT="$2"; shift 2 ;; + --user) ADMIN_USER="$2"; shift 2 ;; + --admin-password) ADMIN_PASSWORD="$2"; shift 2 ;; + --admin-password-stdin) ADMIN_PASSWORD_FROM_STDIN=1; shift ;; + --package) PACKAGE="$2"; shift 2 ;; + --full) DO_FULL=1; shift ;; + -h|--help) sed -n '2,18p' "$0"; exit 0 ;; + *) echo "未知参数:$1" >&2; exit 2 ;; + esac +done + +if [ -t 1 ]; then G=$'\033[32m'; R=$'\033[31m'; Y=$'\033[33m'; D=$'\033[2m'; B=$'\033[1m'; N=$'\033[0m'; else G=; R=; Y=; D=; B=; N=; fi + +section() { printf '\n%s▶ %s%s\n' "$B" "$1" "$N"; } +check() { # $1=描述 $2=0 通过 + TOTAL=$((TOTAL + 1)) + if [ "$2" = "0" ]; then + PASS=$((PASS + 1)); printf ' %s✔%s %s\n' "$G" "$N" "$1" + else + FAIL=$((FAIL + 1)); FAILED+=("$1"); printf ' %s✘%s %s\n' "$R" "$N" "$1" + fi +} +note() { printf ' %s· %s%s\n' "$D" "$1" "$N"; } + +[ "$(id -u)" = "0" ] || { echo "请用 root 运行:sudo bash verify-linux.sh" >&2; exit 1; } + +if [ ! -f "$CONFIG_FILE" ]; then + echo "找不到安装:${CONFIG_FILE} 不存在(用 --config 指定,或先跑 install.sh)" >&2 + exit 1 +fi +read_cfg() { sed -n "s/^[[:space:]]*$1=//p" "$CONFIG_FILE" | tail -n 1 | tr -d '"'; } +[ -n "$PORT" ] || PORT="$(read_cfg PORT)" +[ -n "$PORT" ] || PORT=4000 +CLASSHELPER="${INSTALL_DIR}/bin/classhelper" +API="http://127.0.0.1:${PORT}" +CURL=(curl -fsS --noproxy '*') + +if [ "$ADMIN_PASSWORD_FROM_STDIN" = "1" ]; then IFS= read -r ADMIN_PASSWORD || true; fi + +printf '%s班级小助手 · Linux 安装验收%s\n' "$B" "$N" +note "安装目录 ${INSTALL_DIR}|配置 ${CONFIG_FILE}|端口 ${PORT}" + +# ---------------------------------------------------------------- 1. 安装结果 + +section "安装结果" +check "安装目录存在且含 server/dist/index.js" "$([ -f "${INSTALL_DIR}/server/dist/index.js" ] && echo 0 || echo 1)" +check "版本文件 VERSION 可读" "$([ -s "${INSTALL_DIR}/VERSION" ] && echo 0 || echo 1)" +check "安装元数据 .installed.json 存在" "$([ -f "${INSTALL_DIR}/.installed.json" ] && echo 0 || echo 1)" + +cfg_mode="$(stat -c '%a' "$CONFIG_FILE" 2>/dev/null || echo '?')" +check "配置文件权限 600(实际 ${cfg_mode})" "$([ "$cfg_mode" = "600" ] && echo 0 || echo 1)" +dir_mode="$(stat -c '%a' "$(dirname "$CONFIG_FILE")" 2>/dev/null || echo '?')" +check "配置目录权限 700(实际 ${dir_mode})" "$([ "$dir_mode" = "700" ] && echo 0 || echo 1)" + +env_link="${INSTALL_DIR}/.env" +check "安装目录 .env 是指向配置真身的软链(Web 端切库依赖它)" \ + "$([ -L "$env_link" ] && [ "$(readlink "$env_link")" = "$CONFIG_FILE" ] && echo 0 || echo 1)" + +node_bin="${INSTALL_DIR}/runtime/node/bin/node" +node_ver="$([ -x "$node_bin" ] && "$node_bin" -v 2>/dev/null || echo '')" +check "内置 Node 可用(${node_ver:-未找到})" "$([ -n "$node_ver" ] && echo 0 || echo 1)" + +check "classhelper 命令已装到 /usr/local/bin" "$([ -x /usr/local/bin/classhelper ] && echo 0 || echo 1)" +check "安装日志 ${LOG_DIR}/install.log 存在" "$([ -s "${LOG_DIR}/install.log" ] && echo 0 || echo 1)" +check "审计日志 ${LOG_DIR}/audit.log 存在" "$([ -f "${LOG_DIR}/audit.log" ] && echo 0 || echo 1)" +check "审计/安装日志权限不放行他人(≤640)" \ + "$([ "$(stat -c '%a' "${LOG_DIR}/audit.log" 2>/dev/null || echo 644)" -le 640 ] && echo 0 || echo 1)" +check "/etc/profile.d/classhelper.sh 已写入" "$([ -f /etc/profile.d/classhelper.sh ] && echo 0 || echo 1)" +profile_home="$(bash -lc 'echo "${CLASSHELPER_HOME:-}"' 2>/dev/null)" +check "新登录 shell 里 CLASSHELPER_HOME 生效(${profile_home:-空})" "$([ -n "$profile_home" ] && echo 0 || echo 1)" + +# ---------------------------------------------------------------- 2. 服务 + +section "服务与探针" +if command -v systemctl >/dev/null 2>&1 && [ -f /etc/systemd/system/classhelper.service ]; then + check "systemd 单元已安装" 0 + check "服务处于 active" "$(systemctl is-active --quiet classhelper && echo 0 || echo 1)" + check "服务开机自启(enabled)" "$(systemctl is-enabled --quiet classhelper 2>/dev/null && echo 0 || echo 1)" +else + check "systemd 单元已安装" 1 +fi +check "/healthz 返回 200" "$("${CURL[@]}" -o /dev/null "${API}/healthz" 2>/dev/null && echo 0 || echo 1)" + +status_json="$("$CLASSHELPER" status --json 2>/dev/null || true)" +check "classhelper status --json 是合法 JSON" "$(printf '%s' "$status_json" | grep -q '"healthy"' && echo 0 || echo 1)" +check "status 报告 healthy=yes" "$(printf '%s' "$status_json" | grep -q '"healthy": "yes"' && echo 0 || echo 1)" +check "status 报告的端口与实际一致" "$(printf '%s' "$status_json" | grep -q "\"port\": ${PORT}" && echo 0 || echo 1)" +check "classhelper help 可执行(非 root 也能看)" "$("$CLASSHELPER" help >/dev/null 2>&1 && echo 0 || echo 1)" +check "classhelper completion bash 有输出" "$("$CLASSHELPER" completion bash 2>/dev/null | grep -q 'classhelper' && echo 0 || echo 1)" + +doctor_out="$("$CLASSHELPER" doctor 2>&1 || true)" +check "classhelper doctor 无待处理项" "$(printf '%s' "$doctor_out" | grep -q '体检通过' && echo 0 || echo 1)" +if ! printf '%s' "$doctor_out" | grep -q '体检通过'; then + printf '%s' "$doctor_out" | sed 's/^/ /' +fi + +# ---------------------------------------------------------------- 3. API + +section "API 与登录" +TOKEN="" +login() { # $1=密码 → 输出 token(失败则空) + "${CURL[@]}" -X POST "${API}/api/auth/login" -H 'content-type: application/json' \ + -d "{\"username\":\"${ADMIN_USER}\",\"password\":\"$1\"}" 2>/dev/null \ + | sed -n 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' +} + +if [ -z "$ADMIN_PASSWORD" ]; then + note "未提供 --admin-password(-stdin),跳过登录与改密用例" +else + TOKEN="$(login "$ADMIN_PASSWORD")" + check "管理员用提供的密码能登录并拿到 token" "$([ -n "$TOKEN" ] && echo 0 || echo 1)" + if [ -n "$TOKEN" ]; then + classes="$("${CURL[@]}" -H "Authorization: Bearer ${TOKEN}" "${API}/api/classes" 2>/dev/null || true)" + check "带 token 能读业务接口 /api/classes" "$(printf '%s' "$classes" | grep -q '"data"' && echo 0 || echo 1)" + me="$("${CURL[@]}" -H "Authorization: Bearer ${TOKEN}" "${API}/api/auth/me" 2>/dev/null || true)" + check "/api/auth/me 返回的是 ADMIN" "$(printf '%s' "$me" | grep -q '"ADMIN"' && echo 0 || echo 1)" + fi +fi + +# ---------------------------------------------------------------- 4. 改密 + +if [ -n "$TOKEN" ]; then + section "password 子命令(改密 → 回滚 → 审计)" + # 生成一个一定满足强度要求的密码(大小写+数字+符号,且不落在常见口令里) + GENERATED="Ch$(head -c 12 /dev/urandom | od -An -tx1 | tr -d ' \n')!a9Z" + before_count="$(grep -c 'action=password' "${LOG_DIR}/audit.log" 2>/dev/null || echo 0)" + + # 密码经管道进 stdin,不出现在 argv / 环境变量 / shell history + out="$(printf '%s\n%s\n' "$GENERATED" "$GENERATED" | "$CLASSHELPER" password "$ADMIN_USER" 2>&1)" + check "classhelper password 执行成功" "$(printf '%s' "$out" | grep -q '密码已更新' && echo 0 || echo 1)" + + new_token="$(login "$GENERATED")" + check "新密码能登录" "$([ -n "$new_token" ] && echo 0 || echo 1)" + old_token="$(login "$ADMIN_PASSWORD")" + check "旧密码已失效(登录被拒)" "$([ -z "$old_token" ] && echo 0 || echo 1)" + + hash_backup="$(ls -1t "${BACKUP_DIR}"/password-*.json 2>/dev/null | head -n 1 || true)" + check "改密前备份了原哈希(${hash_backup:-无})" "$([ -n "$hash_backup" ] && echo 0 || echo 1)" + check "哈希备份权限 600" \ + "$([ -n "$hash_backup" ] && [ "$(stat -c '%a' "$hash_backup")" = "600" ] && echo 0 || echo 1)" + + after_count="$(grep -c 'action=password' "${LOG_DIR}/audit.log" 2>/dev/null || echo 0)" + check "审计日志新增了 password 记录" "$([ "${after_count:-0}" -gt "${before_count:-0}" ] && echo 0 || echo 1)" + check "审计日志里没有新密码明文" "$(grep -q -- "$GENERATED" "${LOG_DIR}/audit.log" 2>/dev/null && echo 1 || echo 0)" + check "审计日志里没有原密码明文" "$(grep -q -- "$ADMIN_PASSWORD" "${LOG_DIR}/audit.log" 2>/dev/null && echo 1 || echo 0)" + + check "弱密码被拒绝(长度/种数不足)" \ + "$(printf 'abc\nabc\n' | "$CLASSHELPER" password "$ADMIN_USER" 2>&1 | grep -q '不合规' && echo 0 || echo 1)" + check "密码不合规时不会卡死(stdin 结束即退出)" \ + "$(printf 'abc\n' | timeout 20 "$CLASSHELPER" password "$ADMIN_USER" >/dev/null 2>&1; [ $? -ne 124 ] && echo 0 || echo 1)" + check "password --list 能列出账号" \ + "$("$CLASSHELPER" password --list 2>&1 | grep -q "$ADMIN_USER" && echo 0 || echo 1)" + + # 用改密前保存的哈希把账号恢复原样:比再改一次密码更可靠 + # (原密码若是 admin123 这类弱口令,改回去会被强度校验挡下 —— 那是产品的正确行为) + if [ -n "$hash_backup" ]; then + restore_hash_out="$(LOG_LEVEL=error DOTENV_CONFIG_QUIET=true "$node_bin" "${INSTALL_DIR}/tools/admin-cli.mjs" restore-hash "$hash_backup" 2>&1 || true)" + check "restore-hash 能把原哈希回滚回来" "$(printf '%s' "$restore_hash_out" | grep -q '已回滚密码哈希' && echo 0 || echo 1)" + check "原密码重新可用" "$([ -n "$(login "$ADMIN_PASSWORD")" ] && echo 0 || echo 1)" + fi +else + note "跳过改密用例(没有可用的登录会话)" +fi + +# ---------------------------------------------------------------- 5. 备份 + +section "backup 子命令" +backup_out="$("$CLASSHELPER" backup --keep 5 2>&1)" +check "classhelper backup 执行成功" "$(printf '%s' "$backup_out" | grep -q '备份完成' && echo 0 || echo 1)" + +latest="$(ls -1dt "${BACKUP_DIR}"/*/ 2>/dev/null | head -n 1)" +check "生成了新的备份目录" "$([ -n "$latest" ] && echo 0 || echo 1)" +if [ -n "$latest" ]; then + check "备份含 config.env" "$([ -f "${latest}config.env" ] && echo 0 || echo 1)" + check "备份 meta.json 带版本号" "$(grep -q '"version"' "${latest}meta.json" 2>/dev/null && echo 0 || echo 1)" + db_kind="$(read_cfg DATABASE_PROVIDER)" + if [ "$db_kind" = "sqlite" ]; then + snap="${latest}classhelper.db" + check "备份含 SQLite 快照" "$([ -s "$snap" ] && echo 0 || echo 1)" + # 一致性快照必须是完整的 SQLite 文件(VACUUM INTO 产物) + check "快照是合法 SQLite 文件(文件头正确)" \ + "$([ -n "$snap" ] && [ "$(head -c 15 "$snap" 2>/dev/null)" = "SQLite format 3" ] && echo 0 || echo 1)" + check "快照权限 600" "$([ -n "$snap" ] && [ "$(stat -c '%a' "$snap")" = "600" ] && echo 0 || echo 1)" + else + note "数据库是 MySQL,跳过 SQLite 快照用例" + fi + check "备份目录权限 700" "$([ "$(stat -c '%a' "${latest%/}" 2>/dev/null)" = "700" ] && echo 0 || echo 1)" +fi +check "backup list 能列出备份" "$("$CLASSHELPER" backup list 2>&1 | grep -qE '^ [0-9]{8}-' && echo 0 || echo 1)" + +# ---------------------------------------------------------------- 6. 配置 + +section "config 子命令" +check "config get PORT 返回当前端口" "$([ "$("$CLASSHELPER" config get PORT)" = "$PORT" ] && echo 0 || echo 1)" +masked="$("$CLASSHELPER" config show 2>/dev/null | grep '^JWT_SECRET=')" +check "config show 对 JWT_SECRET 打码" "$(printf '%s' "$masked" | grep -q '\*\*\*\*' && echo 0 || echo 1)" +check "config show 没打印出真实密钥" \ + "$(secret="$(read_cfg JWT_SECRET)"; [ -n "$secret" ] && printf '%s' "$masked" | grep -q -- "$secret" && echo 1 || echo 0)" + +# 用一个无害的键做读写往返(不重启服务) +printf 'CH_VERIFY_PROBE=1\n' >>"$CONFIG_FILE" +probe="$("$CLASSHELPER" config get CH_VERIFY_PROBE 2>/dev/null)" +check "config get 能读到新增键" "$([ "$probe" = "1" ] && echo 0 || echo 1)" +sed -i '/^CH_VERIFY_PROBE=/d' "$CONFIG_FILE" + +# ---------------------------------------------------------------- 7. key rotate + +if [ -n "$TOKEN" ]; then + section "key rotate" + if [ "${CLASSHELPER_VERIFY_SKIP_KEY_ROTATE:-0}" = "1" ]; then + note "按环境变量跳过" + else + old_secret="$(read_cfg JWT_SECRET)" + # key rotate 会问一次确认;无 tty 时从 stdin 读,因此用管道喂 yes + printf 'yes\n' | CLASSHELPER_NO_TTY=1 "$CLASSHELPER" key rotate >/dev/null 2>&1 || true + new_secret="$(read_cfg JWT_SECRET)" + check "key rotate 改变了 JWT_SECRET" "$([ -n "$new_secret" ] && [ "$new_secret" != "$old_secret" ] && echo 0 || echo 1)" + check "新密钥长度 ≥32" "$([ "${#new_secret}" -ge 32 ] && echo 0 || echo 1)" + check "轮换后服务仍可用" "$("${CURL[@]}" -o /dev/null "${API}/healthz" && echo 0 || echo 1)" + check "轮换前的 token 已失效(/api/auth/me 返回 401)" \ + "$(code="$(curl -s --noproxy '*' -o /dev/null -w '%{http_code}' -H "Authorization: Bearer ${TOKEN}" "${API}/api/auth/me")"; [ "$code" = "401" ] && echo 0 || echo 1)" + # 轮换后要重新登录,否则后面的用例会带着失效 token 跑 + TOKEN="$(login "$ADMIN_PASSWORD")" + check "轮换后重新登录成功" "$([ -n "$TOKEN" ] && echo 0 || echo 1)" + fi +fi + +# ---------------------------------------------------------------- 8. 升级 / 回滚 + +if [ "$DO_FULL" = "1" ]; then + section "upgrade / rollback(--full)" + if [ -z "$PACKAGE" ]; then + note "未提供 --package,跳过升级用例" + else + before_version="$("$CLASSHELPER" version 2>/dev/null | awk '{print $2}')" + sha="$(sha256sum "$PACKAGE" | awk '{print $1}')" + up_out="$("$CLASSHELPER" upgrade --url "$PACKAGE" --sha256 "$sha" --force 2>&1)" + check "upgrade 执行成功" "$(printf '%s' "$up_out" | grep -q '升级完成' && echo 0 || echo 1)" + check "升级后服务仍就绪" "$("${CURL[@]}" -o /dev/null "${API}/healthz" && echo 0 || echo 1)" + check "升级后配置未被改动(JWT_SECRET 保持)" \ + "$([ "$(read_cfg JWT_SECRET)" = "${new_secret:-$(read_cfg JWT_SECRET)}" ] && echo 0 || echo 1)" + check "产生了程序快照(可回滚)" "$(ls -1 "${BACKUP_DIR}/rollback" 2>/dev/null | grep -q . && echo 0 || echo 1)" + check "坏包被拒绝(sha256 不匹配时不改动任何文件)" \ + "$("$CLASSHELPER" upgrade --url "$PACKAGE" --sha256 0000000000000000000000000000000000000000000000000000000000000000 2>&1 | grep -q '校验失败' && echo 0 || echo 1)" + check "校验失败后服务仍在运行" "$("${CURL[@]}" -o /dev/null "${API}/healthz" && echo 0 || echo 1)" + + note "(回滚需要交互确认,这里用 yes 管道)" + rb_out="$(printf 'yes\n' | "$CLASSHELPER" rollback 2>&1)" + check "rollback 执行成功" "$(printf '%s' "$rb_out" | grep -qE '已回滚到|回滚' && echo 0 || echo 1)" + check "回滚后服务仍就绪" "$("${CURL[@]}" -o /dev/null "${API}/healthz" && echo 0 || echo 1)" + note "升级前版本 ${before_version}|升级后 $("$CLASSHELPER" version 2>/dev/null | awk '{print $2}')" + fi +fi + +# ---------------------------------------------------------------- 结果 + +printf '\n' +if [ "$FAIL" = "0" ]; then + printf '%s=== 结果:%s/%s 项通过 ===%s\n' "$G" "$PASS" "$TOTAL" "$N" +else + printf '%s=== 结果:%s/%s 项通过,%s 项失败 ===%s\n' "$R" "$PASS" "$TOTAL" "$FAIL" "$N" + printf '%s失败项:%s\n' "$Y" "$N" + for item in "${FAILED[@]}"; do printf ' · %s\n' "$item"; done +fi +[ "$FAIL" = "0" ] || exit 1 diff --git a/docs/linux-deploy.md b/docs/linux-deploy.md new file mode 100644 index 0000000..befa935 --- /dev/null +++ b/docs/linux-deploy.md @@ -0,0 +1,536 @@ +# Linux 部署与运维指南 + +本文覆盖服务端在 Linux 上的**一键安装**与日常运维(`classhelper` 命令)。 +适用:Ubuntu 20.04+ / Debian 11+ / CentOS 7+ / Rocky Linux / AlmaLinux 8+(自动识别 apt / yum / dnf)。 + +不想看长文的话,最短路径是这两条命令: + +```bash +sudo bash install.sh --package /path/to/classhelper-server-linux-x64-1.1.0.tar.gz +sudo classhelper status +``` + +--- + +## 1. 三种形态怎么选 + +| 形态 | 适合 | 命令 | 说明 | +| --- | --- | --- | --- | +| **直接安装(native)** | 有一台 Linux 服务器/Mini 主机,单机跑 | `install.sh` 选 1 | systemd 管理,自带 Node 运行时,SQLite 或 MySQL | +| **直接安装 + MySQL** | 多终端共享数据、已有 MySQL | `install.sh` 选 2 | 连接串写在配置里;库要先建好 | +| **Docker** | 机器上已有 Docker、想与其他服务隔离 | `install.sh` 选 3 | 用安装包里随带的 Dockerfile 构建镜像 + compose 起容器 | + +> 只想先看看这台机器能不能装:`bash install.sh --check`(**不需要 root**,不改动任何东西)。 +> Windows 机器用服务端安装包(`pnpm dist:server` 的产物),不需要本文。 + +--- + +## 2. 安装包从哪来 + +Linux 安装包命名固定为: + +``` +classhelper-server-linux-x64-<版本>.tar.gz ← install.sh / classhelper upgrade 都认这个名字 +classhelper-server-linux-x64-<版本>.tar.gz.sha256 ← 旁边那份校验值 +``` + +它发布在 [GitHub Releases](https://github.com/LoveChengke/ClassHelper/releases) 上。 + +**为什么 Linux 包要单独出一个**:`@libsql/linux-x64-gnu`、`@prisma/adapter-libsql` 这些依赖是平台相关的, +Windows 开发机上 `npm install` 出来的 `node_modules` 拷到 Linux 跑不起来,因此 Linux 包必须在 Linux 上构建。 +仓库里的 [`.github/workflows/release-linux-server.yml`](../.github/workflows/release-linux-server.yml) +就是干这个的(拿 ubuntu runner 跑 `pnpm dist:server:linux`,再把产物挂到对应 Release 上): + +1. 本地照惯例发 Windows 包的 Release(tag 形如 `v1.1.0`); +2. GitHub → Actions → 「发布 Linux 服务端安装包」→ Run workflow → 填 tag → 运行; +3. 跑完 Release 里会多出 Linux 包与更新后的 `SHA256SUMS-<版本>.txt`。 + +内网离线分发也可以:在能上网的机器上 `pnpm dist:server:linux`,把 tar.gz 拷进去, +用 `install.sh --package /path/to/xxx.tar.gz --sha256 <哈希>` 安装。 + +### 没有 Linux 机器时(交叉构建) + +`pnpm dist:server:linux` 在非 Linux 上也**能跑**:脚本会用 npm 的 `--os=linux --cpu=x64` +按目标平台解析可选依赖,所以 `@libsql/linux-x64-gnu` 这些"按平台分发"的包会被正确装进来。 +适合本地快速打一个包去验证安装与运维流程。已核对过的结论: + +| 项 | 结论 | +| --- | --- | +| SQLite 驱动 | ✅ 包内是 `@libsql/linux-x64-gnu` + `linux-x64-musl`,**没有任何 win32 残留** | +| 服务端本体 | ✅ Node + libsql + express + socket.io 都是纯 JS 或已就位的 Linux 原生包 | +| 运维命令 | ✅ `status` / `doctor` / `password` / `backup` / `upgrade` / `rollback` 都不依赖平台差异 | +| Web 端「一键切换数据库」 | ⚠ **可能失败**:这条链路要调 prisma CLI 的 schema engine,CLI 在运行期按平台扫 `@prisma/engines/schema-engine-debian-openssl-3.0.x` 这类路径,而交叉包里只有宿主平台的 `schema-engine-windows.exe` | + +所以交叉包**能装、能跑、能验收**,只是那一个 Web 端功能存疑 —— 它会在包内写入 `.cross-built` 标记, +`classhelper doctor` 会据此提示你(不会静默)。**正式发布一律用 GitHub Actions 那条路。** + +```bash +# 开发机(Windows 也可以) +pnpm dist:server:linux +# → release-server/classhelper-server-linux-x64-<版本>.tar.gz(+ .sha256) +# 重跑很快:node scripts/dist-server.mjs --platform linux --reuse-deps(复用已装的 node_modules) +``` + +--- + +## 3. 一键安装 + +### 3.1 交互式(推荐第一次用) + +```bash +sudo bash install.sh +``` + +TUI 会依次问:形态 → 安装目录 → 端口 → 数据库 → 初始管理员密码 → 是否要 Nginx 配置 / 放行防火墙。 +每一问都有默认值,直接回车即可。 + +### 3.2 非交互(自动化 / 批量部署) + +```bash +# 全部默认:/opt/classhelper、4000 端口、SQLite、内置 Node +sudo bash install.sh --yes --package ./classhelper-server-linux-x64-1.1.0.tar.gz + +# 指定目录与端口,从 stdin 传初始管理员密码(不进 argv / shell history) +printf '%s\n' 'MyStrongPass!2026' | sudo bash install.sh --yes \ + --dir /srv/classhelper --port 8080 --admin-password-stdin \ + --package ./classhelper-server-linux-x64-1.1.0.tar.gz + +# MySQL 形态 +sudo bash install.sh --yes --database mysql --mysql-host 10.0.0.5 --mysql-db classhelper \ + --mysql-user classhelper --mysql-password-stdin \ + --package ./classhelper-server-linux-x64-1.1.0.tar.gz <<< 'MySQL密码' +``` + +完整参数见 `bash install.sh --help`。要点: + +| 参数 | 作用 | +| --- | --- | +| `--package ` | 安装包地址(默认从 GitHub Release 取最新版) | +| `--sha256 <哈希>` | 强制校验安装包哈希 | +| `--admin-password-stdin` | 从 stdin 读一行作为初始管理员密码 | +| `--node-source bundled|system|tarball` | 内置 Node / 系统 Node / 指定压缩包 | +| `--node-mirror ` | Node 下载镜像(默认 `https://npmmirror.com/mirrors/node`) | +| `--node-tarball ` | 内网离线时直接用本地 Node 包 | +| `--with-nginx` / `--open-firewall` | 顺带生成反代配置 / 放行端口 | +| `--yes` | 全默认、不再交互 | +| `--check` | 只体检(无需 root) | +| `--force` | 覆盖已有配置;在无 systemd 的机器上只铺文件不装服务 | + +**幂等性**:重复执行就是覆盖安装。已有的 `config.env`(含 `JWT_SECRET`)与 `data/` 一律保留, +用户不会被踢下线;端口与数据库形态也沿用旧配置,不会被改回默认值。 + +### 3.3 安装器做了什么 + +1. 识别发行版与包管理器,补齐 `curl / tar / gzip / ca-certificates`(只装缺的); +2. 创建系统用户与组 `classhelper`(`--system`,nologin); +3. 建目录、收权限(见下一节); +4. 下载安装包 → 校验 sha256 → 解压铺文件; +5. 下载 Node(默认走 npmmirror 镜像,从 `SHASUMS256.txt` 同时取版本号与哈希); +6. 生成 `/etc/classhelper/config.env`(随机 `JWT_SECRET`,权限 600); +7. 装 systemd 单元、`/usr/local/bin/classhelper`、`/etc/profile.d/classhelper.sh`、logrotate; +8. 启动服务并轮询 `/healthz`,通过后才算安装成功; +9. 全过程写 `/var/log/classhelper/install.log`。 + +### 3.4 Docker 形态 + +`install.sh` 选 3(或 `--mode docker`):用安装包里随带的 `Dockerfile` 直接构建镜像 +(包的 `node_modules` 已经是 linux 的真实目录,**构建时不需要联网装依赖**,几十秒出镜像), +然后写一份 `/etc/classhelper/compose.yml` 拉起容器。 + +```bash +classhelper status # 走 docker compose ps / logs +classhelper restart # 重建容器 +classhelper password admin # 通过 docker exec 在容器里执行同一份 admin-cli +classhelper backup # 容器内做一致性快照再取出来 +``` + +Docker 形态与直接安装的差异: + +| | 直接安装 | Docker | +| --- | --- | --- | +| 数据位置 | `<安装目录>/data` | Docker 卷 `classhelper-data` | +| 配置 | `/etc/classhelper/config.env`(systemd 注入) | 同一份文件(compose `env_file`) | +| 升级 / 回滚 | 替换程序文件后重启服务 | 同一套流程:替换构建上下文 → 重建镜像(`classhelper-server:local`)→ `compose up -d --force-recreate` | +| 恢复数据 | `classhelper backup restore <目录>` | 需手工 `docker cp` 回容器(命令里有提示) | +| `backup schedule` | 支持(systemd timer) | 用宿主机 cron 调 `classhelper backup --quiet` | + +> Docker 形态的镜像用**稳定标签** `classhelper-server:local`(另打一个 `classhelper-server:<版本>` 便于追溯)。 +> 版本号写在 compose 里的话,升级替换完程序文件后那个标签就过期了,`compose up` 会一直沿用旧镜像 —— +> 表现为"升级成功但行为没变"。`classhelper` 自己按 `VERSION` 决定要不要重建镜像。 + +--- + +## 4. 目录、权限与配置 + +``` +/opt/classhelper/ 安装目录(--dir 可改) +├── server/ 后端程序(入口 server/dist/index.js) +│ ├── dist/ prisma/ 产物 + 迁移 SQL(首启动 AUTO_MIGRATE 自动执行) +│ ├── prisma.config.ts tsconfig.generate.json 数据库「一键切换」用 +├── web/ Web 管理端(由后端托管) +├── node_modules/ 生产依赖(真实目录,非软链) +├── tools/admin-cli.mjs 离线改密 / 数据库快照工具 +├── bin/classhelper 运维命令本体(/usr/local/bin/classhelper 软链到它) +├── runtime/node/ 内置 Node 运行时 +├── systemd/ logrotate.d/ profile.d/ 模板(安装时拷到 /etc 下) +├── Dockerfile Docker 形态构建用 +├── data/ 数据库 + Web 端自建快照(备份就是拷这里) +├── logs/ 预留 +├── VERSION 当前版本 +├── .installed.json 安装元数据(形态/版本/端口,classhelper 靠它决策) +└── .env → /etc/classhelper/config.env (软链,见下) + +/etc/classhelper/ 目录 700,属主 classhelper +└── config.env 配置文件,**权限 600**,唯一真身 +/var/log/classhelper/ 安装日志 install.log + 审计日志 audit.log +/var/backups/classhelper/ 备份 + 程序快照(回滚用) +``` + +### 4.1 为什么安装目录里的 `.env` 是软链 + +Web 端「数据库管理 → 一键切换数据库」在运行中改写的是 **`<安装目录>/.env`** +(`packages/server/src/modules/database/database.service.ts`),而 systemd 是通过 +`EnvironmentFile=/etc/classhelper/config.env` 注入环境变量的。dotenv 不覆盖已存在的环境变量 → +如果这两处不是同一份文件,**切换看起来成功、重启后又连回旧库**。 + +所以安装器把 `<安装目录>/.env` 做成指向 `config.env` 的软链:两种读法落到同一个真身。 +`classhelper doctor` 会专门检查这条软链,别手工删掉。 + +### 4.2 配置项 + +配置就是一份 `KEY=VALUE` 的 env 文件,systemd / dotenv / `source` 三种读法都兼容 +(含空白或 `#` 的值请加双引号)。最常见的几项: + +| KEY | 默认 | 说明 | +| --- | --- | --- | +| `PORT` / `HOST` | `4000` / `0.0.0.0` | 监听端口与地址 | +| `DATABASE_PROVIDER` | `sqlite` | `sqlite` 或 `mysql` | +| `DATABASE_URL` | `file:../data/classhelper.db` | SQLite 相对 `<安装目录>/server` 解析;MySQL 是连接串 | +| `JWT_SECRET` | 随机 | 登录令牌签名密钥。**换掉=所有用户重新登录**(`classhelper key rotate`) | +| `JWT_EXPIRES_IN` | `7d` | 令牌有效期 | +| `CORS_ORIGIN` | `*` | 走域名/反代时建议收敛成 `https://你的域名` | +| `TRUST_PROXY` | `1` | 前面有 Nginx 时保持 1,限流才按真实 IP 统计 | +| `AUTO_MIGRATE` | `true` | 空库首启动自动建表 + 建管理员 | +| `INITIAL_ADMIN_USERNAME` / `INITIAL_ADMIN_PASSWORD` | `admin` / `admin123` | **只在空库首次初始化时生效**,之后改密码用 `classhelper password` | +| `DEFAULT_CLASS_PASSWORD` / `DEFAULT_TEACHER_PASSWORD` | `123456` | 新建班级 / 重置教师时的初始密码 | +| `TERM_START_DATE` | 空 | 第 1 教学周的周一(`YYYY-MM-DD`),影响"当前周次" | + +改配置的三种方式(都会先自动备份、改完问你是否重启): + +```bash +sudo classhelper config show # 敏感值打码查看 +sudo classhelper config get PORT +sudo classhelper config set CORS_ORIGIN https://class.example.com # 敏感项会改成交互输入 +sudo classhelper config edit # 用 $EDITOR 直接编辑 +sudo classhelper port 8080 # 改端口的快捷方式 +``` + +> 手工改也可以(`vim /etc/classhelper/config.env`),但记得改完 `classhelper restart`, +> 并确认权限仍是 600。 + +--- + +## 5. `classhelper` 运维命令 + +安装后可直接使用(`/usr/local/bin/classhelper`)。除 `help` / `version` / `completion` 外都需要 root。 + +### 5.1 查看与诊断 + +```bash +classhelper status # 服务状态、版本、端口、数据库、各类路径 +classhelper status --json # 同上,机器可读(接监控用) +classhelper doctor # 完整体检:权限 / Node / 服务单元 / 探针 / 数据库 / 磁盘 / 备份 +classhelper logs -f # 实时日志(= journalctl -u classhelper -f) +classhelper logs -n 200 --since "1 hour ago" +classhelper logs --unit classhelper-backup # 看定时备份的日志 +``` + +`doctor` 会把每一项标成 `✔` / `✘`,失败项后面直接跟修复建议,例如: + +``` + ✘ 安装目录 .env 是指向配置真身的软链 + ✔ Node 运行时可用且 ≥20.19(v22.20.0) + ✘ 最近 7 天内有备份(无) +建议依次处理: + · ln -sfn /etc/classhelper/config.env /opt/classhelper/.env(Web 端「一键切换数据库」依赖它) + · classhelper backup;长期可用 classhelper backup schedule daily +``` + +### 5.2 改密码 + +```bash +sudo classhelper password # 改 admin +sudo classhelper password teacher1 # 改指定教师 +sudo classhelper password --list # 列出可登录账号与班级账号(含是否已设密码) +sudo classhelper password --class G101 # 改某个班的班级密码(学生端登录用) +``` + +安全约定(照要求逐条落实): + +- 密码**交互输入、不回显**;不接受命令行传参,因此不会进 shell history、也不出现在 `ps` 里; +- 强度校验:≥8 位,小写/大写/数字/符号至少 3 类,不得与用户名相同,常见弱口令直接拒绝; + 需二次确认,连续 3 次不合规即退出(密码不变); +- 改前把**原密码哈希**备份到 `/var/backups/classhelper/password-<时间戳>.json`(权限 600), + 写入失败或写入后复核不通过会**自动回滚**原哈希; +- 全过程写审计日志,**审计里只有"谁对谁做了什么、结果如何",没有密码**; +- 自动化可以用管道传入(仍不进 history): + +```bash +printf '%s\n%s\n' "$NEW_PW" "$NEW_PW" | sudo classhelper password admin +``` + +> 顺带说明:改密码不需要重启服务(登录时读库校验);已登录的会话仍有效, +> 要让所有会话立刻失效用 `classhelper key rotate`。 + +### 5.3 备份与恢复 + +```bash +sudo classhelper backup # 备份配置 + 数据库 +sudo classhelper backup --note "升级前" --keep 7 +sudo classhelper backup list +sudo classhelper backup restore 20261005-033000 # 用某个备份覆盖当前配置与数据(会先停服务) +sudo classhelper backup schedule daily # 每天 03:30 自动备份(systemd timer,保留 7 份) +sudo classhelper backup schedule off +``` + +- 备份落在 `/var/backups/classhelper/<时间戳>[-说明]/`:`config.env` + `classhelper.db` + `meta.json`, + 目录 700、文件 600; +- SQLite 用 `VACUUM INTO` 做**一致性快照**(服务在跑也安全,比直接 `cp` 可靠); +- MySQL 用 `mysqldump --single-transaction`(密码经 `MYSQL_PWD` 传给子进程,不进 argv); +- `restore` 前会先自动备份现状,恢复对象是配置与数据(不是程序); +- 程序文件快照(回滚用)在 `/var/backups/classhelper/rollback/`,是另一类东西,`backup list` 会分开列。 + +### 5.4 升级与回滚 + +```bash +sudo classhelper upgrade --check # 只检查有没有新版本(查 GitHub Release) +sudo classhelper upgrade # 升到最新版 +sudo classhelper upgrade --version 1.2.0 # 升到指定版本 +sudo classhelper upgrade --url ./classhelper-server-linux-x64-1.0.1.tar.gz --sha256 <哈希> # 内网离线 +sudo classhelper upgrade --url <包> --force # 强制重装同一版本 + +sudo classhelper rollback --list # 看有哪些程序快照 +sudo classhelper rollback # 回滚到最近一份(会问一次确认) +``` + +`upgrade` 的完整链路(任一步失败都不改动程序文件): + +``` +解析目标版本 → 下载 → 校验 sha256 → 备份数据与配置 → 快照当前程序文件 → +停服 → 替换程序文件(mv 原子替换,data/ 配置/ 运行时原地不动)→ 起服 → 轮询 /healthz + ↘ 失败:自动用刚才的快照回滚并重新起服,再告诉你失败原因 +``` + +两点必须知道: + +1. **升级不会回滚数据库结构**。如果新版执行过迁移,回滚程序前要确认旧版还能读新库, + 必要时用 `classhelper backup restore` 连数据一起回退; +2. 替换程序文件用的是"改名 + 就位"而不是删除重写 —— 升级时正在运行的就是 `bin/classhelper` 自己, + 必须先改名再放新文件,运行中的进程继续读旧 inode,否则会读到半个脚本。 + +### 5.5 服务与配置 + +```bash +sudo classhelper start | stop | restart | reload # reload 与 restart 同义(服务端不支持热重载) +sudo classhelper port # 看当前端口 +sudo classhelper port 8080 # 改端口(顺带提示放行防火墙) +sudo classhelper config show [--reveal] +sudo classhelper config set KEY VALUE +sudo classhelper key rotate # 轮换 JWT_SECRET(所有用户需重新登录) +``` + +### 5.6 其它 + +```bash +classhelper version +classhelper help +source <(classhelper completion bash) # 补全(安装时已装到 /usr/share/bash-completion) +``` + +### 5.7 卸载 + +```bash +sudo classhelper uninstall +``` + +会依次问:保留配置?保留数据?保留备份?默认全部保留 —— 数据与配置会被**搬到** +`/var/backups/classhelper/uninstall-<时间戳>/`,然后才删除程序目录,不会悄悄丢数据。 + +```bash +sudo classhelper uninstall --yes # 不问,用默认(保留配置/数据/备份) +sudo classhelper uninstall --purge # 全删(含备份与数据),不可恢复 +``` + +--- + +## 6. 环境变量文件 + +安装器会写 `/etc/profile.d/classhelper.sh`(内容见 +[`deploy/profile.d/classhelper.sh`](../deploy/profile.d/classhelper.sh)): + +```sh +export CLASSHELPER_HOME=/opt/classhelper +export CLASSHELPER_CONFIG=/etc/classhelper/config.env +export CLASSHELPER_LOG_DIR=/var/log/classhelper +export CLASSHELPER_BACKUP_DIR=/var/backups/classhelper +export PATH=$PATH:$CLASSHELPER_HOME/bin +``` + +新登录的 shell 自动生效;当前会话要手工 `source /etc/profile.d/classhelper.sh`。 +`classhelper` 命令本身装在 `/usr/local/bin`(软链),所以**不登录也照样能用**。 + +--- + +## 7. 反向代理与 HTTPS(公网部署必做) + +```bash +sudo bash install.sh --with-nginx # 或者安装后手工: +sudo cp deploy/nginx.conf /etc/nginx/conf.d/classhelper.conf +``` + +要点: + +- `client_max_body_size 12m`(表格导入最大 8MB,base64 后约 11MB); +- Socket.IO 需要 `Upgrade` / `Connection` 头与较长的 `proxy_read_timeout`(模板里已配); +- 配置里保持 `TRUST_PROXY=1`,并把 `CORS_ORIGIN` 收敛成你的域名; +- 证书用 certbot 或你自己的证书,装好后 `nginx -t && nginx -s reload`。 + +--- + +## 8. 故障排查 + +| 现象 | 先查什么 | +| --- | --- | +| 服务起不来 | `classhelper doctor` → `classhelper logs -n 100`。常见:端口被占用、MySQL 连接串错、`data/` 权限 | +| 装完打不开页面 | `classhelper status` 看探针与监听地址;云服务器还要在**安全组**放行端口 | +| 改了配置没生效 | 是否重启过(`classhelper config set` 会问);`EnvironmentFile` 的值优先于文件里的同项 | +| Web 端切库后还是连旧库 | `classhelper doctor` 看 `.env` 软链是否被破坏(`ln -sfn /etc/classhelper/config.env <安装目录>/.env`) | +| 升级卡住/失败 | 失败会自动回滚;看 `classhelper logs -n 100`,确认磁盘余量 ≥1GB | +| `classhelper` 命令找不到 | `ls -l /usr/local/bin/classhelper`;被删了就重装或 `ln -sfn <安装目录>/bin/classhelper /usr/local/bin/classhelper` | +| 忘记管理员密码 | `sudo classhelper password admin`(直连数据库,不需要登录) | +| 学生端连不上 | 客户端「服务器地址」填 `http://IP:端口` 或域名;确认没有多余路径与斜杠 | +| CentOS 7 装完立刻退出 | glibc < 2.28,见下面的"已知限制" | + +--- + +## 9. 已知限制 + +### CentOS 7 / glibc < 2.28 + +Node 官方的 linux-x64 二进制要求 **glibc ≥ 2.28**,而 CentOS 7 是 2.17 —— 直接装官方包会表现成 +"装完服务立刻退出、日志里什么也没有"。安装器会自动检测 glibc,低于 2.28 时改用 +[unofficial-builds](https://unofficial-builds.nodejs.org/) 的 **glibc-217** 构建(功能一致,只是编译基线更低)。 + +但即便这样,个别原生依赖仍可能不兼容,CentOS 7 支持属于**尽力而为**: + +- CentOS 7 已于 2024-06 EOL,**新部署请用 Rocky Linux 9 / AlmaLinux 9 / Ubuntu 22.04+**; +- 若坚持用 CentOS 7,建议装完立刻跑一遍 `classhelper doctor` 与本文第 10 节的验收清单。 + +### 其它 + +- 直接安装形态要求 **systemd**(容器里没有 `/run/systemd/system` 时会明确拒绝; + 容器场景请用 Docker 形态)。 +- Docker 形态的数据恢复需要手工 `docker cp`(数据在容器卷里,宿主机上看不到那个文件); + `classhelper backup restore` 会给出具体命令。 +- `classhelper reload` 等价于 restart(服务端没有 SIGHUP 处理)。 +- 升级包只发 x64;arm64 需要自行构建(`node scripts/dist-server.mjs --platform linux` 跑在 arm64 机器上)。 + +--- + +## 10. 验收清单 + +下面这套在干净的 **Ubuntu 22.04** 与 **Rocky Linux 9** 上各跑一遍。 +`deploy/verify-linux.sh` 会把第 3~8 步自动跑完并打印 `=== 结果:N/N 项通过 ===`。 + +### 10.0 准备 + +```bash +# 在开发机:做出 Linux 包(或在 Actions 里下载) +pnpm dist:server:linux +scp release-server/classhelper-server-linux-x64-*.tar.gz root@<服务器>:/tmp/ +scp deploy/install.sh deploy/verify-linux.sh root@<服务器>:/tmp/ +``` + +### 10.1 体检(不需要 root) + +```bash +bash /tmp/install.sh --check +``` + +期望:系统/包管理器/glibc/docker/端口/磁盘逐项列出,末尾「体检通过」。 +端口被占用、缺 curl、没有 systemd 都会在这里被点出来。 + +### 10.2 安装 + +```bash +sudo bash /tmp/install.sh --yes \ + --package /tmp/classhelper-server-linux-x64-1.1.0.tar.gz \ + --admin-password-stdin <<< 'Verify-Pass-2026!' +``` + +期望:末尾出现「安装完成」与「服务已就绪 ✅」,并打印管理端地址。 + +### 10.3 自动验收 + +```bash +sudo bash /tmp/verify-linux.sh --admin-password-stdin <<< 'Verify-Pass-2026!' +``` + +覆盖项: + +| 分组 | 检查内容 | +| --- | --- | +| 安装结果 | 程序文件、`VERSION`、`.installed.json`、`config.env` 600、配置目录 700、`.env` 软链、内置 Node、`/usr/local/bin/classhelper`、安装/审计日志、profile.d 生效 | +| 服务 | systemd 单元、active、enabled、`/healthz` 200、`status --json` 合法且 healthy | +| 体检 | `classhelper doctor` 无待处理项、`help` / `completion` 可用 | +| API | 用初始密码登录拿 token、带 token 读 `/api/classes` 与 `/api/auth/me` | +| 改密 | 改密成功 → 新密码可登录 → **旧密码被拒** → 原哈希已备份且 600 → 审计有记录且**不含密码明文** → 弱密码被拒 → stdin 结束不卡死 → `restore-hash` 把原密码回滚回来 | +| 备份 | 备份目录/文件/权限、SQLite 快照是合法库文件、`backup list` | +| 配置 | `config get/show`、敏感值打码、读写往返 | +| key rotate | 密钥确实变了且 ≥32 位、服务仍可用、**旧 token 变 401**、重新登录成功 | + +追加升级/回滚链路(会真的替换一次程序文件): + +```bash +sudo bash /tmp/verify-linux.sh --full --package /tmp/classhelper-server-linux-x64-1.0.1.tar.gz \ + --admin-password-stdin <<< 'Verify-Pass-2026!' +``` + +覆盖:升级成功 → 服务恢复 → 配置未被改动 → 产生程序快照 → **坏哈希被拒绝且服务不受影响** → +`rollback` 回到旧版且服务可用。 + +### 10.4 手工补验(自动化覆盖不到的) + +```bash +# ① 浏览器打开 http://:4000/ ,用 admin 登录,改一次密码再登录 +# ② 学生端客户端填 http://:4000 用班级码 G101/123456 登录,收到一条老师发的通知 +# ③ 重跑安装器(幂等性):sudo bash /tmp/install.sh --yes --package <同一个包> +# 期望:配置与数据保留、JWT_SECRET 不变(已登录用户不被踢下线) +# ④ 卸载:sudo classhelper uninstall --yes +# 期望:服务停止、单元删除、/usr/local/bin/classhelper 消失、 +# 数据与配置被搬到 /var/backups/classhelper/uninstall-*/ +# ⑤ 重启机器后服务自动起来(systemctl is-enabled classhelper) +``` + +### 10.5 Rocky 9 的差异点 + +- 包管理器走 `dnf`,依赖包名与 apt 不同(`install.sh` 已按 `PKG` 分支处理); +- 默认没有 `curl` 之外的工具时会被补装,注意首次安装需要能访问 dnf 源; +- 防火墙默认是 `firewalld`:用 `--open-firewall` 或手工 + `firewall-cmd --add-port=4000/tcp --permanent && firewall-cmd --reload`; +- SELinux(Enforcing):服务读写 `/opt/classhelper` 与 `/etc/classhelper` 一般不受影响, + 若日志里出现 `Permission denied` 且路径正常,先 `ausearch -m avc -ts recent` 看是不是 SELinux, + 可以用 `semanage fcontext` 加规则,或临时 `setenforce 0` 验证判断。 + +--- + +## 11. 安全清单 + +- [ ] 改掉默认管理员密码:`classhelper password admin`(**公网部署第一件事**) +- [ ] 公网服务前面加 Nginx + HTTPS,`CORS_ORIGIN` 收敛到具体域名 +- [ ] 定期备份:`classhelper backup schedule daily`,并确认备份能恢复(`backup restore` 演练一次) +- [ ] 配置与备份权限:`config.env` 600、`/etc/classhelper` 700、备份目录 700 +- [ ] `classhelper doctor` 无待处理项;关注"最近 7 天内有备份" +- [ ] 审计日志(`/var/log/classhelper/audit.log`)随日志轮转保留,必要时接集中日志 +- [ ] 只在需要时使用 `--purge`;卸载默认保留数据与备份 +- [ ] 升级包一律校验 sha256(`upgrade` 内网分发时也要带 `--sha256`) diff --git a/docs/production.md b/docs/production.md index c5683b1..df716aa 100644 --- a/docs/production.md +++ b/docs/production.md @@ -6,7 +6,7 @@ | --------------------- | -------------------------------------------- | -------------------------------------------------------- | -------------------------------------- | | **A. Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | `release-server/班级小助手服务端-<版本>-x64-setup.exe` | ✅ 已完整实测(静默安装/启停/卸载/UI) | | **B. Docker + MySQL** | 云服务器、多终端共享一套数据(推荐长期方案) | `deploy/Dockerfile` + `deploy/docker-compose.yml` | ⚠️ 文件已提供,本机无 Docker 未实测 | -| **C. Linux 原生部署** | 已有 Linux 服务器(systemd + SQLite) | `deploy/install-linux.sh` + `deploy/classhelper.service` | ⚠️ 脚本已提供,本机无 Linux 未实测 | +| **C. Linux 原生部署** | 已有 Linux 服务器(systemd,SQLite 或 MySQL) | [`deploy/install.sh`](../deploy/install.sh) + `classhelper` 运维命令 | ⚠️ 本机无 Linux,实机验收见 [linux-deploy.md](linux-deploy.md) §10 | | **D. 手动部署** | 已有 Node 环境的服务器、需要自定义 | `pnpm dist:server` 的免安装目录 | ✅ 免安装目录已实测(163 项端到端) | --- @@ -25,7 +25,7 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已安装依赖,跳 | 路径 | 说明 | | ----------------------------------------------------- | ----------------------------------------------- | | `release-server/classhelper-server/` | 免安装目录,可直接拷到任意 Windows x64 机器运行 | -| `release-server/班级小助手服务端-1.0.0-x64-setup.exe` | 安装程序(约 34 MB,NSIS) | +| `release-server/班级小助手服务端-1.1.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` -- 静默安装:`"班级小助手服务端-1.0.0-x64-setup.exe" /S` +- 静默安装:`"班级小助手服务端-1.1.0-x64-setup.exe" /S` - 开始菜单:启动服务 / 停止服务 / 重启服务 / 打开管理端 / 使用说明 / 卸载 - 桌面:`班级小助手服务端.lnk`(打开管理端) - 自动加入当前用户开机自启(`HKCU\...\CurrentVersion\Run`) @@ -149,108 +149,97 @@ docker compose -f deploy/docker-compose.sqlite.yml --env-file deploy/.env.sqlite --- -## 三、Linux 原生部署(形态 C,systemd + SQLite) +## 三、Linux 部署(形态 C,推荐一键安装器) -适用:Ubuntu / Debian / CentOS / Rocky 等有 systemd 的服务器,单机跑一套(SQLite), -用 Nginx 挂 HTTPS 给校内网/公网访问。 +适用:Ubuntu 20.04+ / Debian 11+ / CentOS 7+ / Rocky / AlmaLinux,任何有 systemd 的服务器 +(容器环境用下面的 Docker 形态)。**完整指南见 [linux-deploy.md](linux-deploy.md)**,这里是速查。 -### 3.1 一键脚本(推荐) +### 3.1 一键安装(推荐) -在**仓库根目录**先构建,然后执行脚本(需要 root): +Linux 包由 GitHub Actions 在 ubuntu runner 上构建(平台相关的依赖只能在 Linux 上装), +产物挂在 Release 上;也可以本地 `pnpm dist:server:linux` 后把 tar.gz 拷到服务器。 +安装器脚本两个文件都在仓库的 `deploy/` 下: ```bash -# 1) 构建(Node ≥ 20.19,推荐 22 LTS;目标机没有 pnpm 时可在别的机器上构建后拷仓库过来) -corepack enable && corepack prepare pnpm@11.8.0 --activate -pnpm install -pnpm build:shared -pnpm --filter @classhelper/server build -pnpm --filter @classhelper/web-admin build +# 开发机:做出 Linux 包并连同脚本一起拷到服务器 +pnpm dist:server:linux +scp release-server/classhelper-server-linux-x64-*.tar.gz deploy/install.sh deploy/verify-linux.sh \ + root@<服务器>:/tmp/ -# 2) 安装成 systemd 服务 -sudo bash deploy/install-linux.sh # 默认端口 4000、目录 /opt/classhelper -# 常用变体: -# bash deploy/install-linux.sh --check # 只体检(不需要 root):校验构建产物/Node/端口探针 -# sudo bash deploy/install-linux.sh --port 8080 -# sudo bash deploy/install-linux.sh --dir /srv/classhelper -# sudo bash deploy/install-linux.sh --no-service # 只铺文件,自己托管进程 -# sudo bash deploy/install-linux.sh --uninstall # 停服务并移除 unit(保留 data 与 .env) +# 服务器上 +bash /tmp/install.sh --check # 只体检(不需要 root,不改动系统) +sudo bash /tmp/install.sh # 交互式:选形态 → 选目录 → 选库 → 设初始管理员密码 ``` -> 建议先跑一次 `--check`:它只读地校验"构建产物齐不齐、Node 版本够不够、目标端口上有没有旧实例", -> 不需要 root,也不会改任何东西。输出示例见 `deploy/install-linux.sh` 头部注释。 - -脚本做的事:建系统用户 `classhelper` → 把后端产物、`prisma/migrations`、Web 管理端拷到 -`/opt/classhelper` → `npm install --omit=dev`(清单就是 `deploy/package.runtime.json`,与 -容器运行时同一份)→ 生成 `.env`(随机 `JWT_SECRET`、`AUTO_MIGRATE=true`)→ 装 systemd unit → -`enable --now` → 轮询 `/healthz` 就绪后打印地址与初始管理员。 - -安装后的目录(与已验证的 Windows 免安装目录同构,因此 `.env` 里的相对路径语义一致): - -``` -/opt/classhelper/ -├── .env 全部配置(权限 600) -├── server/dist 后端程序(入口 index.js) -├── server/prisma 迁移 SQL(首启动自动执行) -├── web/ Web 管理端(后端托管,单端口) -├── node_modules/ 仅生产依赖 -├── data/ classhelper.db + server.pid(备份只复制这里) -└── logs/ -``` - -首次启动会自动建表并创建管理员 `admin / admin123`(**登录后立刻改密码**)。 - -### 3.2 手动部署(不想用脚本 / 没有 systemd) - -等价的手工步骤,便于排查: +非交互(批量部署 / 验收用): ```bash -sudo useradd --system --shell /usr/sbin/nologin classhelper -sudo mkdir -p /opt/classhelper/{server,web,data,logs} +printf '%s\n' 'StrongPass!2026' | sudo bash /tmp/install.sh --yes \ + --dir /srv/classhelper --port 8080 --admin-password-stdin \ + --package /tmp/classhelper-server-linux-x64-1.1.0.tar.gz +``` -# 程序产物 -sudo cp -R packages/server/dist /opt/classhelper/server/dist -sudo cp -R packages/server/prisma /opt/classhelper/server/prisma -sudo cp -R packages/web-admin/dist /opt/classhelper/web -sudo cp deploy/package.runtime.json /opt/classhelper/package.json +安装器做的事:识别发行版与包管理器补依赖 → 建系统用户 `classhelper` → 下载包并校验 sha256 → +铺文件(`server/` `web/` `node_modules/` `tools/` `bin/`)→ 下载内置 Node 运行时 → +写 `/etc/classhelper/config.env`(随机 `JWT_SECRET`,权限 600)→ 装 systemd 单元与 +`/usr/local/bin/classhelper` → 启动并轮询 `/healthz` 通过为止。全过程写 +`/var/log/classhelper/install.log`。 -# 生产依赖 + 工作区共享包 -cd /opt/classhelper && sudo npm install --omit=dev --no-audit --no-fund -sudo mkdir -p /opt/classhelper/node_modules/@classhelper/shared -sudo cp -R packages/shared/dist /opt/classhelper/node_modules/@classhelper/shared/dist -sudo cp packages/shared/package.json /opt/classhelper/node_modules/@classhelper/shared/package.json +重跑就是**覆盖安装**:`config.env`(含密钥)与 `data/` 保留,已登录用户不会被踢下线。 -# 配置(参考脚本生成的 .env,务必自己生成 JWT_SECRET) -sudo tee /opt/classhelper/.env >/dev/null <<'EOF' -NODE_ENV=production -HOST=0.0.0.0 -PORT=4000 -DATABASE_PROVIDER=sqlite -DATABASE_URL="file:../data/classhelper.db" -AUTO_MIGRATE=true -JWT_SECRET=换成 openssl rand -hex 48 的输出 -JWT_EXPIRES_IN=7d -CORS_ORIGIN=https://class.example.com -TRUST_PROXY=1 -RATE_LIMIT_ENABLED=true -STARTUP_DB_CHECK=true -PID_FILE=../data/server.pid -LOG_LEVEL=info -EOF +### 3.2 安装后的布局与运维 -sudo chown -R classhelper:classhelper /opt/classhelper/{data,logs,node_modules} /opt/classhelper/.env -sudo chmod 600 /opt/classhelper/.env +``` +/opt/classhelper/ 程序、node_modules、内置 Node、data/、tools/、bin/ +/etc/classhelper/config.env 唯一配置真身(600);安装目录里的 .env 是指向它的软链 +/var/log/classhelper/ install.log + audit.log +/var/backups/classhelper/ 备份与程序快照(回滚用) +``` -# systemd -sudo sed -e 's#__NODE__#/usr/bin/node#' -e 's#__DIR__#/opt/classhelper#' -e 's#__USER__#classhelper#' \ - deploy/classhelper.service | sudo tee /etc/systemd/system/classhelper.service +> 为什么安装目录的 `.env` 是软链:Web 端「数据库管理 → 一键切换」改写的是 `<安装目录>/.env`, +> 而 systemd 用 `EnvironmentFile=` 注入同一份配置;两处不是同一个文件时,切换会"看起来成功、 +> 重启后又连回旧库"。软链把两者收成一份真身(`classhelper doctor` 会检查它)。 + +日常运维全部走 `classhelper`(`classhelper help` 看全部): + +| 事项 | 命令 | +| ------ | --------------------------------------------------------------------------------------- | +| 状态 | `classhelper status [--json]` | +| 体检 | `classhelper doctor`(权限 / Node / 服务单元 / 探针 / 数据库 / 磁盘 / 备份) | +| 改密码 | `classhelper password [用户名]`(交互输入、不回显、不进 shell history) | +| 备份 | `classhelper backup`;每日自动:`classhelper backup schedule daily` | +| 升级 | `classhelper upgrade [--check]`(下载 + 校验 sha256 + 备份 + 替换 + 探针,失败自动回滚) | +| 回滚 | `classhelper rollback [--list]` | +| 日志 | `classhelper logs -f` | +| 改配置 | `classhelper config set KEY VALUE` / `config show` / `port 8080` | +| 换密钥 | `classhelper key rotate`(所有用户需重新登录) | +| 卸载 | `classhelper uninstall`(默认保留配置、数据与备份) | +| 服务 | `classhelper start|stop|restart|reload` | + +### 3.3 手动部署(不想用安装器 / 没有 systemd) + +没有 systemd 的容器里可以用安装器的"只铺文件"模式,再自己托管进程: + +```bash +sudo bash /tmp/install.sh --yes --force --package /tmp/classhelper-server-linux-x64-1.1.0.tar.gz +sudo -u classhelper /opt/classhelper/runtime/node/bin/node /opt/classhelper/server/dist/index.js +``` + +完全手工也可以(等价步骤,便于排查):解包后把 `server/` `web/` `node_modules/` `tools/` 铺到安装目录, +配置写到 `/etc/classhelper/config.env`(`JWT_SECRET` 用 `openssl rand -hex 48`),再用 +`deploy/classhelper.service` 模板生成单元 —— 注意模板有 5 个占位符 +(`__NODE__` `__DIR__` `__USER__` `__CONFIG__` `__CONFIGDIR__`): + +```bash +sudo sed -e "s#__NODE__#/opt/classhelper/runtime/node/bin/node#" \ + -e "s#__DIR__#/opt/classhelper#" -e "s#__USER__#classhelper#" \ + -e "s#__CONFIG__#/etc/classhelper/config.env#" -e "s#__CONFIGDIR__#/etc/classhelper#" \ + deploy/classhelper.service > /tmp/classhelper.service +sudo install -m 644 /tmp/classhelper.service /etc/systemd/system/classhelper.service sudo systemctl daemon-reload && sudo systemctl enable --now classhelper -systemctl status classhelper --no-pager ``` -也可以用 PM2 之类托管(`pm2 start /opt/classhelper/server/dist/index.js --name classhelper`), -但 systemd 更省心:`Restart=always`、日志进 journald、开机自启一次配好。 - -### 3.3 Nginx + HTTPS +### 3.4 Nginx + HTTPS ```bash sudo cp deploy/nginx.conf /etc/nginx/conf.d/classhelper.conf @@ -259,7 +248,7 @@ sudo nginx -t && sudo systemctl reload nginx ``` `deploy/nginx.conf` 已经处理了最容易踩的两点:`/socket.io/` 的 WebSocket 升级头, -以及 `X-Forwarded-For/X-Forwarded-Proto` 透传(配合 `.env` 里 `TRUST_PROXY=1`,限流才会按真实 IP 统计)。 +以及 `X-Forwarded-For/X-Forwarded-Proto` 透传(配合配置里的 `TRUST_PROXY=1`,限流才会按真实 IP 统计)。 用 certbot 签证书: ```bash @@ -267,31 +256,19 @@ sudo apt install -y certbot python3-certbot-nginx sudo certbot --nginx -d class.example.com ``` -### 3.4 升级 / 备份 / 排障 - -升级(不会动 `.env`,JWT 密钥不变,学生端不用重新登录): +### 3.5 验收 ```bash -git pull && pnpm install -pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build -sudo bash deploy/install-linux.sh # 覆盖程序文件 + 自动补迁移(AUTO_MIGRATE) -journalctl -u classhelper -n 30 --no-pager +sudo bash /tmp/verify-linux.sh --admin-password-stdin <<< 'StrongPass!2026' +# 追加升级 / 回滚链路(会真的替换一次程序文件): +sudo bash /tmp/verify-linux.sh --full --package /tmp/classhelper-server-linux-x64-1.0.1.tar.gz \ + --admin-password-stdin <<< 'StrongPass!2026' ``` -| 事项 | 命令 | -| -------- | ---------------------------------------------------------------------------------------------------------- | -| 备份 | `systemctl stop classhelper && cp -a /opt/classhelper/data /root/classhelper-data-$(date +%F)`(然后启动) | -| 恢复 | 停服务 → 用备份覆盖 `data/` → 启动 | -| 看日志 | `journalctl -u classhelper -f` | -| 重启 | `systemctl restart classhelper`(改 `.env` 后必须重启) | -| 探针 | `curl -s localhost:4000/healthz` / `/readyz`(就绪 200,数据库不可用 503) | -| 换端口 | 改 `.env` 的 `PORT` → 重启;同时改 Nginx 的 `upstream` | -| 换数据库 | 见 `docs/mysql.md`:切 provider + 装 `@prisma/adapter-mariadb` + `npx prisma migrate deploy` | +覆盖安装结果、服务探针、API 登录、改密(含审计与回滚)、备份、配置读写、key rotate、 +升级与回滚整条链路,末尾打印 `=== 结果:N/N 项通过 ===`。逐条清单见 +[linux-deploy.md](linux-deploy.md) 第 10 节。 -> 注意:本机(开发环境)是 Windows 且无 Docker/WSL 发行版,因此 **3.1 的脚本没有在 Linux 实机跑过**。 -> 它复用的两套东西都已验证:Windows 免安装目录(同样的目录结构、`.env` 相对路径与 -> `AUTO_MIGRATE` 首启动行为)和 `deploy/Dockerfile` 的 runtime 阶段(同样的依赖清单与产物布局)。 -> 首次在服务器上执行后请按 3.4 的探针 + Web 登录 + `pnpm verify:e2e`(把 `VERIFY_BASE_URL` 指向服务器)确认。 --- @@ -337,7 +314,6 @@ start.cmd :: Windows | `PID_FILE` | — | PID 文件路径(安装包启停脚本依赖),相对 `server` 根 | | `WEB_DIST_DIR` | 自动探测 | Web 管理端产物目录(`../web-admin/dist` 或 `./web`) | | `LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error` | -| `DEFAULT_STUDENT_PASSWORD` | `123456` | 新增/重置学生账号的默认密码 | | `DEFAULT_CLASS_PASSWORD` | `123456` | 新建班级时班级账号(学生端登录)的默认密码 | | `DEFAULT_TEACHER_PASSWORD` | `123456` | 新增/重置教师账号的默认密码(管理员录入教师用) | | `TERM_START_DATE` | — | 第 1 教学周的周一,用于"当前周次"与课表默认视图 | diff --git a/package.json b/package.json index 75a6f7b..832c8d5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "class-helper", - "version": "1.0.0", + "version": "1.1.0", "private": true, "description": "班级小助手 - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)", "packageManager": "pnpm@11.8.0", @@ -16,6 +16,7 @@ "build": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && pnpm --filter @classhelper/desktop-client build", "build:desktop": "pnpm build:shared && pnpm --filter @classhelper/desktop-client build", "build:classisland-plugin": "node packages/classisland-plugin/scripts/build.mjs", + "check:icons": "node scripts/check-icons.mjs", "typecheck": "pnpm build:shared && pnpm -r --no-bail typecheck", "lint": "eslint .", "lint:fix": "eslint . --fix", @@ -38,6 +39,7 @@ "dist:win": "pnpm --filter @classhelper/desktop-client dist:win", "dist:dir": "pnpm --filter @classhelper/desktop-client dist:dir", "dist:server": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && node scripts/dist-server.mjs", + "dist:server:linux": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && node scripts/dist-server.mjs --platform linux", "dist:classisland-plugin": "node packages/classisland-plugin/scripts/build.mjs --cipx", "dist:all": "pnpm dist:server && pnpm dist:win && pnpm dist:classisland-plugin" }, diff --git a/packages/classisland-plugin/manifest.yml b/packages/classisland-plugin/manifest.yml index bf80008..a730f05 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: 1.0.0.0 +version: 1.1.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 daa5f16..7962b30 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 = "1.0.0.0"; + private const string PluginVersion = "1.1.0.0"; /// ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。 private static string SafeAppVersion() diff --git a/packages/desktop-client/electron-builder.yml b/packages/desktop-client/electron-builder.yml index 203ea7e..9fbf419 100644 --- a/packages/desktop-client/electron-builder.yml +++ b/packages/desktop-client/electron-builder.yml @@ -2,6 +2,18 @@ appId: com.classhelper.desktop productName: 班级小助手 copyright: Copyright © 2026 Class Helper +# 语言包:Electron 发行版自带 55 个 `locales/*.pak`(实测 48.3MB 未压缩 / 约 10MB 压缩后), +# 而本应用界面文案只有中文,其余 54 个语言包纯属白占体积(`win-unpacked` 372MB → 约 324MB)。 +# 这是 electron-builder 的官方选项:`app-builder-lib` 的 removeUnusedLanguagesIfNeeded() +# 会对 `locales/*.pak` 做精确匹配删除,只留 `zh-CN.pak`(0.6MB)。 +# +# 注意:**不要**为了继续压体积去删 dxcompiler/vk_swiftshader/d3dcompiler_47 这些 GPU 回退组件, +# 或 LICENSES.chromium.html —— 前者是教室老显卡/虚拟机上的 D3D12/D3D11/Vulkan 渲染回退, +# 后者是 Chromium 的许可证声明,合计只能再省约 12MB,风险不成比例。 +# 另外安装包有硬底:`班级小助手.exe`(就是 electron.exe)压缩后本身就有 85~90MB,别再指望压到 60MB。 +electronLanguages: + - zh-CN + directories: output: release buildResources: build diff --git a/packages/desktop-client/package.json b/packages/desktop-client/package.json index ae6620d..d9b11d2 100644 --- a/packages/desktop-client/package.json +++ b/packages/desktop-client/package.json @@ -1,13 +1,13 @@ { "name": "@classhelper/desktop-client", - "version": "1.0.0", + "version": "1.1.0", "private": true, "description": "班级小助手 EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)", "author": "Class Helper", "main": "dist/main/index.js", "scripts": { "dev": "node scripts/dev.mjs", - "build": "npm run build:main && npm run build:renderer && npm run check:secrets", + "build": "node ../../scripts/check-icons.mjs && npm run build:main && npm run build:renderer && npm run check:secrets", "build:main": "node scripts/build-main.mjs", "build:renderer": "vite build", "check:secrets": "node scripts/check-bundle-secrets.mjs", @@ -21,7 +21,6 @@ "@classhelper/shared": "workspace:*", "@element-plus/icons-vue": "^2.3.2", "axios": "^1.20.0", - "echarts": "^6.1.0", "element-plus": "^2.14.5", "pinia": "^4.0.3", "socket.io-client": "^4.8.3", diff --git a/packages/desktop-client/src/main/config.ts b/packages/desktop-client/src/main/config.ts index 9c8185e..b5d3770 100644 --- a/packages/desktop-client/src/main/config.ts +++ b/packages/desktop-client/src/main/config.ts @@ -42,6 +42,8 @@ interface PersistedConfig { theme?: 'light' | 'dark'; /** 主侧边栏是否折叠(向后兼容:旧配置没有该字段视为展开) */ sidebarCollapsed?: boolean; + /** 用户选择「忽略此版本」的版本号(向后兼容:旧配置没有该字段视为没忽略过任何版本) */ + ignoredUpdateVersion?: string; } const DEFAULT_CONFIG: PersistedConfig = { @@ -56,6 +58,7 @@ const DEFAULT_CONFIG: PersistedConfig = { onboardingDone: false, theme: 'light', sidebarCollapsed: false, + ignoredUpdateVersion: '', }; /** 主题白名单:非法值一律回落浅色 */ @@ -179,7 +182,7 @@ function decryptToken(raw: PersistedConfig): string | null { } } -function readPersisted(): PersistedConfig { +function readPersistedFromDisk(): PersistedConfig { try { const content = fs.readFileSync(configFilePath(), 'utf8'); const parsed = JSON.parse(content) as Partial; @@ -198,16 +201,125 @@ function readPersisted(): PersistedConfig { onboardingDone: parsed.onboardingDone === true, theme: normalizeTheme(parsed.theme), sidebarCollapsed: parsed.sidebarCollapsed === true, + ignoredUpdateVersion: + typeof parsed.ignoredUpdateVersion === 'string' ? parsed.ignoredUpdateVersion : '', }; } catch { return { ...DEFAULT_CONFIG }; } } -function writePersisted(config: PersistedConfig): void { - const target = configFilePath(); - fs.mkdirSync(path.dirname(target), { recursive: true }); - fs.writeFileSync(target, JSON.stringify(config, null, 2), 'utf8'); +/* ------------------------------------------------------------ 内存缓存 + 异步合并写 + * + * 为什么不能每次 saveConfig 都同步读写盘(原来的写法): + * 侧栏折叠(`stores/ui.ts` 的 toggleSidebar)、主题切换、通知渠道、作业看板偏好、登录/登出 + * 全都走 `saveConfig`,而它一次调用要做「readFileSync + JSON.parse + 8 个 normalize」**两遍** + * (开头读一遍、结尾 `getConfig()` 又读一遍)+ `writeFileSync` 一遍 + DPAPI 解密一遍, + * **全部同步跑在主进程主线程上**。主进程是 Electron 的消息泵进程,被它摁住的那一拍 + * 渲染进程也跟着掉帧 —— 用户反馈的"点汉堡展开/收回侧栏时卡一下"就是这一拍。 + * + * 现在:配置常驻内存(连已解密的 token 一起缓存),写盘改成**异步 + 合并** —— + * 同一拍内的多次改动只落最后一次,写入串行不会互相覆盖;退出前由 `flushConfigSync()` 兜底。 + * + * **语义变化(有意为之)**:`saveConfig()` 返回时数据可能还没落盘(延迟 CONFIG_WRITE_DEBOUNCE_MS)。 + * 正常退出(托盘 → 退出)会走 `flushConfigSync()`,不会丢最后一次改动;只有进程被强杀 + * 才可能丢这最后 120ms 内的改动。 + */ + +/** 写入合并窗口:拖动同类控件(滑块/连续点击)时的连续改动只落最后一次 */ +const CONFIG_WRITE_DEBOUNCE_MS = 120; + +interface ConfigCache { + config: PersistedConfig; + /** 已解密的登录令牌(null = 未登录 / 解密失败)。缓存它是为了不再每次都走一遍 DPAPI */ + token: string | null; +} + +/** + * 内存配置。**必须懒初始化**:`main/index.ts` 会先 `app.setPath('userData', smokeProfile)` + * 把配置目录指到冒烟专用 profile,之后才第一次读配置;在模块顶层立即读盘会读错文件。 + */ +let cache: ConfigCache | null = null; + +function loadCache(): ConfigCache { + if (cache) return cache; + const config = readPersistedFromDisk(); + cache = { config, token: decryptToken(config) }; + return cache; +} + +/** 待写入的配置快照(合并窗口内只保留最后一次) */ +let pendingWrite: PersistedConfig | null = null; +let writeTimer: ReturnType | null = null; +/** 是否有一次异步写入正在进行(用它把写入串行化,避免两次写互相覆盖) */ +let writing = false; + +/** 排一次落盘。合并窗口内的多次调用只会写最后一次;`immediate` 用于不该被延迟的场景 */ +function scheduleWrite(config: PersistedConfig, immediate = false): void { + pendingWrite = config; + if (immediate) { + if (writeTimer !== null) { + clearTimeout(writeTimer); + writeTimer = null; + } + void flushConfigAsync(); + return; + } + // 已有待写任务(定时器在跑或正在写):内容已经记在 pendingWrite 里,等它自己收尾即可。 + // 正在写的那次结束后会再检查一次 pendingWrite(见 flushConfigAsync 的 finally)。 + if (writeTimer !== null || writing) return; + writeTimer = setTimeout(() => { + writeTimer = null; + void flushConfigAsync(); + }, CONFIG_WRITE_DEBOUNCE_MS); +} + +/** 异步落盘(不阻塞主进程消息泵)。失败只记日志 —— 与 readPersistedFromDisk 的容错口径一致 */ +async function flushConfigAsync(): Promise { + if (writing) return; + const target = pendingWrite; + if (!target) return; + pendingWrite = null; + writing = true; + // 先把内容序列化下来:写入期间的后续改动由 pendingWrite 负责补一次, + // 不会出现"这一次写的是半新半旧的对象" + const payload = JSON.stringify(target, null, 2); + try { + const file = configFilePath(); + await fs.promises.mkdir(path.dirname(file), { recursive: true }); + await fs.promises.writeFile(file, payload, 'utf8'); + } catch (error) { + logger.warn(`写入配置失败(${configFilePath()}):${error instanceof Error ? error.message : error}`); + } finally { + writing = false; + // 写入期间又有改动:再写一次(内容已被 pendingWrite 合并成最后一次) + if (pendingWrite) void flushConfigAsync(); + } +} + +/** + * 退出前同步落地一次(`main/index.ts` 的 shutdownResources 调用)。 + * + * 即使没有待写内容也把内存里的配置重写一遍:那一时刻可能正有一次异步写在进行中, + * 它未必来得及完成;退出时多写一次的代价可以忽略。 + */ +export function flushConfigSync(): void { + if (writeTimer !== null) { + clearTimeout(writeTimer); + writeTimer = null; + } + const target = pendingWrite ?? cache?.config ?? null; + if (!target) return; + pendingWrite = null; + try { + const file = configFilePath(); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(target, null, 2), 'utf8'); + } catch (error) { + logger.warn( + `退出前写入配置失败(${configFilePath()}):${error instanceof Error ? error.message : error}`, + ); + } } /** @@ -229,13 +341,13 @@ function normalizeServerUrl(input: string): string | null { } } -/** 读取配置(token 已解密),供渲染进程使用 */ -export function getConfig(): DesktopStoredConfig { - const persisted = readPersisted(); +/** 由内存缓存组装对渲染进程暴露的配置(不再读盘、不再解密) */ +function toDesktopConfig(state: ConfigCache): DesktopStoredConfig { + const persisted = state.config; return { serverUrl: persisted.serverUrl, username: persisted.username, - token: decryptToken(persisted), + token: state.token, island: normalizeIslandAppearance(persisted.island), homeworkBoard: normalizeHomeworkBoard(persisted.homeworkBoard), notificationChannel: normalizeNotificationChannel(persisted.notificationChannel), @@ -243,12 +355,19 @@ export function getConfig(): DesktopStoredConfig { onboardingDone: persisted.onboardingDone === true, theme: normalizeTheme(persisted.theme), sidebarCollapsed: persisted.sidebarCollapsed === true, + ignoredUpdateVersion: persisted.ignoredUpdateVersion ?? '', }; } +/** 读取配置(token 已解密),供渲染进程使用 */ +export function getConfig(): DesktopStoredConfig { + return toDesktopConfig(loadCache()); +} + /** 局部更新配置:只覆盖传入的字段 */ export function saveConfig(patch: Partial): DesktopStoredConfig { - const persisted = readPersisted(); + const state = loadCache(); + const persisted = state.config; if (typeof patch.serverUrl === 'string' && patch.serverUrl.trim()) { const normalized = normalizeServerUrl(patch.serverUrl); @@ -284,20 +403,28 @@ export function saveConfig(patch: Partial): DesktopStoredCo if (patch.sidebarCollapsed !== undefined) { persisted.sidebarCollapsed = patch.sidebarCollapsed === true; } + // 空串是合法值(= 没有忽略任何版本),因此这里只判类型不判空 + if (typeof patch.ignoredUpdateVersion === 'string') { + persisted.ignoredUpdateVersion = patch.ignoredUpdateVersion; + } if (patch.token !== undefined) { const encrypted = encryptToken(patch.token); persisted.token = encrypted.token; persisted.tokenEncrypted = encrypted.tokenEncrypted; + // 明文一并进缓存:既省掉一次 DPAPI 解密,也保证紧接着的 getConfig() 读到的就是刚存的令牌 + state.token = patch.token; } - writePersisted(persisted); - return getConfig(); + scheduleWrite(persisted); + return toDesktopConfig(state); } -/** 清空为默认配置(退出登录 / 重置) */ +/** 清空为默认配置(退出登录 / 重置)。立即落盘:它对应退出登录,不该被合并窗口延迟 */ export function clearConfig(): DesktopStoredConfig { - writePersisted({ ...DEFAULT_CONFIG }); - return getConfig(); + const state: ConfigCache = { config: { ...DEFAULT_CONFIG }, token: null }; + cache = state; + scheduleWrite(state.config, true); + return toDesktopConfig(state); } export function getConfigPath(): string { diff --git a/packages/desktop-client/src/main/index.ts b/packages/desktop-client/src/main/index.ts index d4843f5..a521df8 100644 --- a/packages/desktop-client/src/main/index.ts +++ b/packages/desktop-client/src/main/index.ts @@ -1,12 +1,13 @@ import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { BrowserWindow, app, shell } from 'electron'; -import { getConfig } from './config.js'; +import { flushConfigSync, getConfig } from './config.js'; import { registerIpcHandlers } from './ipc.js'; import { island, registerIslandIpc } from './island.js'; import { logger } from './logger.js'; import { runSmokeTest } from './smoke.js'; import { createTray, destroyTray, isTrayReady } from './tray.js'; +import { scheduleStartupUpdateCheck } from './update.js'; // 主进程由 esbuild 打包为 CommonJS,因此这里可以直接使用 __dirname // (dist/main/index.js -> dist/preload/index.js 与 dist/renderer/index.html) @@ -163,6 +164,9 @@ if (!gotLock) { logger.warn(`系统托盘初始化失败(不影响主功能):${error instanceof Error ? error.message : error}`); } + // 启动后自动查一次更新(延迟几秒,不跟首屏抢带宽);冒烟模式下自动跳过,见 main/update.ts + scheduleStartupUpdateCheck(() => mainWindow); + if (isSmokeTest) { try { await runSmokeTest(mainWindow); @@ -190,6 +194,11 @@ if (!gotLock) { */ function shutdownResources(): string { const done: string[] = []; + // 配置是**异步合并写**的(见 main/config.ts):退出前必须同步落地一次, + // 否则"刚改完设置就退出"这一类操作会丢掉最后一次改动。 + // 放在最前面:后面会销毁窗口与灵动岛,先落盘不依赖任何窗口是否还在。 + flushConfigSync(); + done.push('配置已落盘'); if (isTrayReady()) { destroyTray(); done.push('托盘已销毁'); diff --git a/packages/desktop-client/src/main/ipc.ts b/packages/desktop-client/src/main/ipc.ts index a3db956..bc6d9aa 100644 --- a/packages/desktop-client/src/main/ipc.ts +++ b/packages/desktop-client/src/main/ipc.ts @@ -1,5 +1,7 @@ import { app, ipcMain, shell } from 'electron'; +import type { UpdateInfo } from '@classhelper/shared'; import { clearConfig, getConfig, getConfigPath, saveConfig } from './config.js'; +import { checkForUpdates, ignoreUpdateVersion } from './update.js'; import type { DesktopAppInfo, DesktopStoredConfig } from '../types/desktop.js'; /** IPC 通道名集中管理,preload 与主进程共用同一份字符串 */ @@ -9,6 +11,8 @@ export const IPC_CHANNELS = { clearConfig: 'classhelper:config:clear', appInfo: 'classhelper:app:info', openExternal: 'classhelper:shell:open-external', + updateCheck: 'classhelper:update:check', + updateIgnore: 'classhelper:update:ignore', } as const; export function registerIpcHandlers(): void { @@ -39,6 +43,21 @@ export function registerIpcHandlers(): void { await shell.openExternal(url); return true; }); + + /** + * 检查更新(走主进程直连 GitHub,见 main/update.ts 的说明)。 + * + * `force` 为 true 时绕过主进程缓存 —— 对应「关于」页里用户主动点的那次检查; + * 启动自动检查一律用缓存(避免同一分钟内重复请求打满匿名限流)。 + */ + ipcMain.handle(IPC_CHANNELS.updateCheck, (_event, force?: boolean): Promise => { + return checkForUpdates(force === true); + }); + + /** 忽略某个版本的更新提示(记入 config.json,启动自动检查不再提示它) */ + ipcMain.handle(IPC_CHANNELS.updateIgnore, (_event, version: string): void => { + if (typeof version === 'string') ignoreUpdateVersion(version); + }); } export function getDiagnostics(): { configPath: string; config: DesktopStoredConfig } { diff --git a/packages/desktop-client/src/main/island.ts b/packages/desktop-client/src/main/island.ts index 54aa402..124f4ab 100644 --- a/packages/desktop-client/src/main/island.ts +++ b/packages/desktop-client/src/main/island.ts @@ -958,9 +958,9 @@ class IslandController { } /** 当前应该把岛放在哪块屏上:跟随鼠标时用光标所在屏,否则用主屏 */ - private targetDisplay(): Electron.Display { + private targetDisplay(cursor?: { x: number; y: number } | null): Electron.Display { return this.appearance.followCursorDisplay - ? screen.getDisplayNearestPoint(this.cursorOverride ?? screen.getCursorScreenPoint()) + ? screen.getDisplayNearestPoint(cursor ?? this.cursorOverride ?? screen.getCursorScreenPoint()) : screen.getPrimaryDisplay(); } @@ -970,10 +970,13 @@ class IslandController { * 原先锚点只在 `setAppearance()` 里算一次,于是"跟随鼠标屏幕"实际上**只在改设置的那一瞬间** * 生效:讲台从投影切回来之后,岛还留在原来那块屏上,直到用户再动一次外观设置。 * 这里挂在已有的 60ms 命中轮询上(不新增定时器),并且只在目标屏幕真的变了、且窗口可见时才动窗口。 + * + * 不需要额外监听 `screen` 的 display-* 事件:目标屏是由**当前光标位置**或主屏推出来的, + * 插拔/切主屏之后下一轮 tick 自然会算出不同的 id,一样能触发重排。 */ - private syncAnchorDisplay(): void { + private syncAnchorDisplay(cursor?: { x: number; y: number } | null): void { if (!this.win || this.win.isDestroyed()) return; - const displayId = this.targetDisplay().id; + const displayId = this.targetDisplay(cursor).id; if (this.anchorDisplayId === displayId) return; this.anchorDisplayId = displayId; // 换屏了:可用高度(工作区)也跟着换,列表"能放几行"要重算 @@ -1409,11 +1412,14 @@ class IslandController { /** * 光标相对窗口左上角的本地坐标(CSS px / DIP,与渲染进程上报的岛体矩形同一坐标系)。 * 冒烟里用 `setHitTestCursor` 注入,真实场景读系统光标。 + * + * `cursor`:调用方在本轮 tick 里已经读过的光标位置(见 `startHitPoll`)。 + * 传进来就复用它,避免同一个 tick 里重复调 `screen.getCursorScreenPoint()`。 */ - private cursorLocal(): { x: number; y: number } | null { + private cursorLocal(cursor?: { x: number; y: number } | null): { x: number; y: number } | null { if (!this.win || this.win.isDestroyed()) return null; const bounds = this.win.getBounds(); - const point = this.cursorOverride ?? screen.getCursorScreenPoint(); + const point = cursor ?? this.cursorOverride ?? screen.getCursorScreenPoint(); return { x: point.x - bounds.x, y: point.y - bounds.y }; } @@ -1494,7 +1500,7 @@ class IslandController { } /** 按光标位置校正命中(不依赖渲染进程是否收到 mousemove) */ - private syncHitFromCursor(): void { + private syncHitFromCursor(cursor?: { x: number; y: number } | null): void { if (!this.win || this.win.isDestroyed() || !this.win.isVisible()) { this.setInteractive(false, '窗口不可见'); return; @@ -1505,18 +1511,34 @@ class IslandController { this.setInteractive(false, '隐藏态'); return; } - this.setInteractive(this.isInsideHitRect(this.cursorLocal()), '光标轮询'); + this.setInteractive(this.isInsideHitRect(this.cursorLocal(cursor)), '光标轮询'); } private startHitPoll(): void { if (this.hitTimer) return; // 60ms:渲染进程的 mousemove 转发在 Windows 上并不可靠,这里是命中的**权威来源**, // 间隔必须足够小,否则"指针移上胶囊后立刻点击"会因窗口还没接收鼠标而点空(用户反馈过)。 + // + // 每一跳的成本要压到最低(这个定时器从岛初始化一直跑到进程退出): + // 1) 光标位置**每跳只读一次**,命中判定与"跟随鼠标屏幕"共用 —— 原先 cursorLocal() 与 + // targetDisplay() 各读一次,等于每跳两次 `screen.getCursorScreenPoint()`; + // 2) 窗口不可见时直接返回:此时命中判定与心跳检查本来就各自早退(窗口不可见 ⇒ 无命中可言), + // 留着那两次系统调用纯属白跑。 this.hitTimer = setInterval(() => { + if (!this.win || this.win.isDestroyed()) return; + const visible = this.win.isVisible(); + // 不可见、且不跟随鼠标屏幕时,连读光标都可省掉(targetDisplay 会走主屏分支) + const cursor = + visible || this.appearance.followCursorDisplay + ? (this.cursorOverride ?? screen.getCursorScreenPoint()) + : null; + // 先按目标屏幕校正窗口位置(换屏/插拔显示器),再算命中与心跳: // 顺序不能反,否则命中判定会用上一块屏的窗口坐标 - this.syncAnchorDisplay(); - this.syncHitFromCursor(); + this.syncAnchorDisplay(cursor); + if (!visible) return; + + this.syncHitFromCursor(cursor); this.checkRendererAlive(); }, 60); // 定时器不应拖住进程退出 @@ -1748,12 +1770,12 @@ const MIN_CARD_HEIGHT = 120; /** - * 渲染进程心跳(渲染进程每 5s 上报一次):超过这个时间没上报就认为它已经卡死/不响应。 + * 渲染进程心跳(渲染进程每 5s 上报一次,间隔写死在 `src/island/IslandApp.vue`): + * 超过这个时间没上报就认为它已经卡死/不响应。 * 岛是"无边框透明置顶窗口",渲染进程一旦挂掉,Windows 会把**最后一帧留在屏幕上** * 形成"幽灵胶囊":窗口还在、看着正常,但怎么点都没反应(实测复现:崩掉渲染进程后 * 点击完全不产生任何动作)。所以必须有心跳 + 重建来兜底。 */ -const RENDERER_ALIVE_INTERVAL_MS = 5_000; const RENDERER_ALIVE_TIMEOUT_MS = 20_000; /** 窗口重建限流:一段时间内最多重建几次,避免持续崩坏时无限重建 */ const RECOVER_MAX_TIMES = 3; diff --git a/packages/desktop-client/src/main/smoke.ts b/packages/desktop-client/src/main/smoke.ts index 5129241..aeb4036 100644 --- a/packages/desktop-client/src/main/smoke.ts +++ b/packages/desktop-client/src/main/smoke.ts @@ -15,6 +15,7 @@ import { ISLAND_URGENT_SIZE, } from './island.js'; import { destroyTray, getTrayState, isTrayReady } from './tray.js'; +import { checkForUpdates, wasStartupCheckSkipped } from './update.js'; interface SmokeResult { name: string; @@ -555,11 +556,26 @@ async function diagnoseClosingFlash(label: string, withTouch: boolean): Promise< ); if (buttonPoint) { const bounds = win.getBounds(); - island.setHitTestCursor({ x: Math.round(bounds.x + buttonPoint.x), y: Math.round(bounds.y + buttonPoint.y) }); + island.setHitTestCursor({ + x: Math.round(bounds.x + buttonPoint.x), + y: Math.round(bounds.y + buttonPoint.y), + }); await sleep(160); - win.webContents.sendInputEvent({ type: 'mouseDown', x: buttonPoint.x, y: buttonPoint.y, button: 'left', clickCount: 1 }); + win.webContents.sendInputEvent({ + type: 'mouseDown', + x: buttonPoint.x, + y: buttonPoint.y, + button: 'left', + clickCount: 1, + }); await sleep(40); - win.webContents.sendInputEvent({ type: 'mouseUp', x: buttonPoint.x, y: buttonPoint.y, button: 'left', clickCount: 1 }); + win.webContents.sendInputEvent({ + type: 'mouseUp', + x: buttonPoint.x, + y: buttonPoint.y, + button: 'left', + clickCount: 1, + }); } const deadline = Date.now() + 1600; let index = 0; @@ -581,14 +597,8 @@ async function diagnoseClosingFlash(label: string, withTouch: boolean): Promise< const crop = image.crop({ x: Math.max(0, Math.round((geometry.window.x - pad) * shotScale)), y: Math.max(0, Math.round((geometry.window.y - pad) * shotScale)), - width: Math.min( - image.getSize().width, - Math.round((geometry.window.width + pad * 2) * shotScale), - ), - height: Math.min( - image.getSize().height, - Math.round((geometry.window.height + pad * 2) * shotScale), - ), + width: Math.min(image.getSize().width, Math.round((geometry.window.width + pad * 2) * shotScale)), + height: Math.min(image.getSize().height, Math.round((geometry.window.height + pad * 2) * shotScale)), }); fs.writeFileSync(path.join(dir, `${label}-${String(index).padStart(2, '0')}.png`), crop.toPNG()); lines.push( @@ -2087,7 +2097,8 @@ async function runIslandChecks( let count = 0; for (let y = Math.max(0, fromY); y < Math.min(collapseShotSize.height, toY); y += 2) { for (let x = Math.max(0, fromX); x < Math.min(collapseShotSize.width, toX); x += 2) { - if (x >= cardBox.x && x <= cardBox.x + cardBox.w && y >= cardBox.y && y <= cardBox.y + cardBox.h) continue; + if (x >= cardBox.x && x <= cardBox.x + cardBox.w && y >= cardBox.y && y <= cardBox.y + cardBox.h) + continue; const index = (y * collapseShotSize.width + x) * 4; if (index + 3 >= bitmap.length) continue; const b = bitmap[index]; @@ -4349,6 +4360,46 @@ export async function runSmokeTest(win: BrowserWindow): Promise { ); record('关于页渲染(应用信息/诊断信息/鸣谢)', Boolean(aboutPage?.ok), String(aboutPage?.detail ?? '')); + // 更新检查(新功能回归):主进程直连 GitHub 取最新 Release。 + // + // **刻意不断言"一定有新版本"**:那会把冒烟绑死在外网可达上,而教室机器常常没有外网 + // (`ok=false` 是设计内的降级结果,不是失败)。因此两种结果都算通过,验的是: + // 结构完整、失败时有可读原因、以及缓存真的生效。 + const updateFirst = await checkForUpdates(true); + const isVersionLike = (value: unknown): boolean => typeof value === 'string' && /^\d/.test(value); + const updateShapeOk = + isVersionLike(updateFirst.currentVersion) && + typeof updateFirst.ok === 'boolean' && + typeof updateFirst.hasUpdate === 'boolean' && + (updateFirst.ok + ? isVersionLike(updateFirst.latestVersion) && + Array.isArray(updateFirst.assets) && + /^https:\/\//.test(updateFirst.releaseUrl) && + updateFirst.error === null + : typeof updateFirst.error === 'string' && updateFirst.error.length > 0); + record( + '更新检查返回结构正确(无外网时降级为 ok=false 且带原因)', + updateShapeOk, + `ok=${updateFirst.ok} 本机=${updateFirst.currentVersion} 最新=${updateFirst.latestVersion ?? '-'} ` + + `有新版本=${updateFirst.hasUpdate} 附件=${updateFirst.assets.length}` + + `${updateFirst.error ? ` 原因=${updateFirst.error}` : ''}`, + ); + + // 缓存:紧接着再查一次(不传 force)应命中缓存 —— checkedAt 不变即说明没有二次请求 + const updateSecond = await checkForUpdates(); + record( + '更新检查结果带缓存(不重复请求 GitHub)', + updateSecond.checkedAt === updateFirst.checkedAt, + `两次 checkedAt 相同=${updateSecond.checkedAt === updateFirst.checkedAt}`, + ); + + // 启动自动检查在冒烟模式下必须被跳过,否则每次验证都会产生一次真实网络请求与提示条 + record( + '冒烟模式下跳过启动自动检查(验证不依赖外网)', + wasStartupCheckSkipped(), + `skipped=${wasStartupCheckSkipped()}`, + ); + // 未读红点必须挂在"通知"图标右上方(用户反馈:原来挂在文字后面,位置不对) const readBadgeGeometry = async (): Promise<{ ok: boolean; diff --git a/packages/desktop-client/src/main/update.ts b/packages/desktop-client/src/main/update.ts new file mode 100644 index 0000000..041141c --- /dev/null +++ b/packages/desktop-client/src/main/update.ts @@ -0,0 +1,215 @@ +import { app, type BrowserWindow } from 'electron'; +import { + GITHUB_RELEASES_LATEST_API, + GITHUB_RELEASES_PAGE, + UPDATE_CHECK_CACHE_TTL_MS, + UPDATE_CHECK_TIMEOUT_MS, + UPDATE_CHECK_USER_AGENT, + isNewerVersion, + normalizeVersion, + type UpdateAssetDto, + type UpdateInfo, +} from '@classhelper/shared'; +import { getConfig, saveConfig } from './config.js'; +import { logger } from './logger.js'; + +/** + * 更新检查(桌面客户端)。 + * + * **为什么在主进程做**:渲染进程通过 preload 能拿到 `getConfig()` 的明文 token(代表全班身份), + * 它的 CSP 与导航防线是整个安全模型里最要紧的一层;让它额外去连一个外部域名、 + * 解析外部 JSON,等于把"外部数据"请进最有价值的那一侧。主进程本来就在做网络与文件, + * 放这里也顺带复用 Node 的 fetch(无需额外依赖)。 + * + * 与 Web 管理端的差别:Web 端受服务端 CSP 的 `connect-src` 限制,只能走 `/api/update/check` 转发; + * 客户端没有这个限制,按用户选择**直连 GitHub**。两边归一化出的 `UpdateInfo` 结构一致。 + */ + +/** 主进程 → 渲染进程:发现新版本(渲染进程据此弹一次性提示) */ +export const UPDATE_AVAILABLE_CHANNEL = 'classhelper:update:available'; + +/** 启动后多久自动查一次:等窗口与登录流程就绪,也不跟首屏请求抢带宽 */ +const STARTUP_DELAY_MS = 5_000; + +interface GithubReleasePayload { + tag_name?: unknown; + name?: unknown; + published_at?: unknown; + body?: unknown; + html_url?: unknown; + assets?: unknown; +} + +let cache: { at: number; info: UpdateInfo } | null = null; +let startupTimer: ReturnType | null = null; +/** 冒烟断言用:启动自动检查是否被跳过(冒烟模式下必须是 true) */ +let startupCheckSkipped = false; + +/** 失败的统一出口:结构完整,界面不必做 null 判断 */ +function failure(error: string): UpdateInfo { + return { + ok: false, + error, + currentVersion: app.getVersion(), + latestVersion: null, + hasUpdate: false, + releaseUrl: GITHUB_RELEASES_PAGE, + releaseName: null, + publishedAt: null, + notes: null, + assets: [], + checkedAt: new Date().toISOString(), + }; +} + +function toAssets(raw: unknown): UpdateAssetDto[] { + if (!Array.isArray(raw)) return []; + return raw + .map((item) => item as { name?: unknown; browser_download_url?: unknown; size?: unknown }) + .filter( + (item): item is { name: string; browser_download_url: string; size?: unknown } => + typeof item.name === 'string' && typeof item.browser_download_url === 'string', + ) + .map((item) => ({ + name: item.name, + downloadUrl: item.browser_download_url, + size: typeof item.size === 'number' && Number.isFinite(item.size) ? item.size : 0, + })); +} + +function toInfo(payload: GithubReleasePayload, currentVersion: string): UpdateInfo { + const latestVersion = normalizeVersion(typeof payload.tag_name === 'string' ? payload.tag_name : ''); + if (!latestVersion) { + logger.warn('更新检查:GitHub 返回的 Release 里没有可识别的 tag_name'); + return failure('GitHub 返回的版本号无法识别'); + } + + return { + ok: true, + error: null, + currentVersion, + latestVersion, + hasUpdate: isNewerVersion(currentVersion, latestVersion), + releaseUrl: + typeof payload.html_url === 'string' && payload.html_url ? payload.html_url : GITHUB_RELEASES_PAGE, + releaseName: typeof payload.name === 'string' ? payload.name : null, + publishedAt: typeof payload.published_at === 'string' ? payload.published_at : null, + notes: typeof payload.body === 'string' ? payload.body : null, + assets: toAssets(payload.assets), + checkedAt: new Date().toISOString(), + }; +} + +/** 把各种失败翻译成一句用户能看懂的话(教室里最常见的就是"这台机器没有外网") */ +function describeFailure(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + if (error instanceof Error && error.name === 'TimeoutError') { + return `连接 GitHub 超时(${UPDATE_CHECK_TIMEOUT_MS / 1000} 秒),本机可能没有外网`; + } + if (/fetch failed|ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT|certificate|net::/i.test(message)) { + return '无法连接 GitHub,本机可能没有外网'; + } + return `检查更新失败:${message}`; +} + +/** + * 检查 GitHub 上有没有比本机新的版本(结果带缓存)。 + * + * 缓存的意义:匿名调用 GitHub API 的限流是 60 次/小时/IP,而「关于」页的按钮可以随便点。 + * 失败结果同样进缓存 —— 否则离线时每点一次都要白等一个超时。 + */ +export async function checkForUpdates(force = false): Promise { + const currentVersion = app.getVersion(); + const now = Date.now(); + if (!force && cache && now - cache.at < UPDATE_CHECK_CACHE_TTL_MS) { + return cache.info; + } + + let info: UpdateInfo; + try { + const response = await fetch(GITHUB_RELEASES_LATEST_API, { + headers: { + // GitHub API 强制要求 User-Agent,不带会直接 403 + 'User-Agent': UPDATE_CHECK_USER_AGENT, + Accept: 'application/vnd.github+json', + }, + signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS), + }); + + if (!response.ok) { + info = + response.status === 403 || response.status === 429 + ? failure('GitHub 暂时拒绝了请求(匿名查询次数过多),请过一会儿再试') + : failure(`GitHub 返回 HTTP ${response.status}`); + logger.warn(`更新检查失败:HTTP ${response.status}(本机版本 ${currentVersion})`); + } else { + info = toInfo((await response.json()) as GithubReleasePayload, currentVersion); + logger.info( + `更新检查:本机 ${currentVersion} / 最新 ${info.latestVersion ?? '-'}` + + `${info.hasUpdate ? '(有新版本)' : '(已是最新)'}`, + ); + } + } catch (error) { + info = failure(describeFailure(error)); + logger.warn(`更新检查失败:${info.error}(本机版本 ${currentVersion})`); + } + + cache = { at: now, info }; + return info; +} + +/** 「知道了 / 忽略此版本」:记到配置里,同一个版本不再重复弹提示 */ +export function ignoreUpdateVersion(version: string): void { + const normalized = normalizeVersion(version); + if (!normalized) return; + saveConfig({ ignoredUpdateVersion: normalized }); + logger.info(`已忽略版本 ${normalized} 的更新提示`); +} + +/** 该版本是否已被用户忽略 */ +export function isUpdateIgnored(version: string): boolean { + return normalizeVersion(getConfig().ignoredUpdateVersion) === normalizeVersion(version); +} + +/** + * 启动后的自动检查:延迟 `STARTUP_DELAY_MS` 查一次,有新版本(且没被忽略)就通知渲染进程弹提示。 + * + * **冒烟模式直接跳过**:自动化验证不该每次都发真实网络请求(既抖动又依赖外网), + * 而且凭空多出来的提示条会撞坏既有 UI 断言。冒烟用例改为直接调 IPC 验证返回结构。 + */ +export function scheduleStartupUpdateCheck(getWindow: () => BrowserWindow | null): void { + if (process.env.ELECTRON_SMOKE_TEST === '1') { + startupCheckSkipped = true; + logger.info('更新检查:冒烟模式下跳过启动自动检查'); + return; + } + if (startupTimer) clearTimeout(startupTimer); + + startupTimer = setTimeout(() => { + startupTimer = null; + void (async () => { + try { + const info = await checkForUpdates(); + if (!info.ok || !info.hasUpdate || !info.latestVersion) return; + if (isUpdateIgnored(info.latestVersion)) { + logger.info(`更新检查:${info.latestVersion} 已被用户忽略,本次不提示`); + return; + } + const win = getWindow(); + if (!win || win.isDestroyed()) return; + win.webContents.send(UPDATE_AVAILABLE_CHANNEL, info); + logger.info(`更新检查:已提示新版本 ${info.latestVersion}`); + } catch (error) { + // 自动检查失败不该打扰用户(手动检查才需要看到原因) + logger.warn(`启动自动检查更新失败:${error instanceof Error ? error.message : error}`); + } + })(); + }, STARTUP_DELAY_MS); + // 不阻止进程退出:这个定时器只是"顺便查一下" + startupTimer.unref?.(); +} + +/** 冒烟断言用:启动自动检查是否被跳过(冒烟模式下应为 true —— 否则每次验证都会发真实网络请求) */ +export function wasStartupCheckSkipped(): boolean { + return startupCheckSkipped; +} diff --git a/packages/desktop-client/src/preload/index.ts b/packages/desktop-client/src/preload/index.ts index e4e6a8d..5ff8957 100644 --- a/packages/desktop-client/src/preload/index.ts +++ b/packages/desktop-client/src/preload/index.ts @@ -1,5 +1,6 @@ import { contextBridge, ipcRenderer } from 'electron'; import { IPC_CHANNELS } from '../main/ipc.js'; +import { UPDATE_AVAILABLE_CHANNEL } from '../main/update.js'; import type { DesktopAppInfo, DesktopBridge, @@ -7,7 +8,7 @@ import type { IslandClassStatePayload, IslandPushContext, } from '../types/desktop.js'; -import type { IslandNotification } from '@classhelper/shared'; +import type { IslandNotification, UpdateInfo } from '@classhelper/shared'; /** * 预加载脚本:在开启了 contextIsolation + sandbox 的前提下, @@ -37,6 +38,15 @@ const bridge: DesktopBridge = { getAppInfo: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.appInfo), openExternal: (url: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.openExternal, url), + /* 更新检查(主进程直连 GitHub,见 main/update.ts) */ + checkForUpdates: (force = false): Promise => + ipcRenderer.invoke(IPC_CHANNELS.updateCheck, force), + ignoreUpdateVersion: (version: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.updateIgnore, version), + onUpdateAvailable: (handler: (info: UpdateInfo) => void): void => { + ipcRenderer.on(UPDATE_AVAILABLE_CHANNEL, (_event, info: UpdateInfo) => handler(info)); + }, + islandPush: (payload: { notification: IslandNotification; context?: IslandPushContext }): void => { ipcRenderer.send('island:push', payload); }, diff --git a/packages/desktop-client/src/renderer/App.vue b/packages/desktop-client/src/renderer/App.vue index ad5bb0c..464f291 100644 --- a/packages/desktop-client/src/renderer/App.vue +++ b/packages/desktop-client/src/renderer/App.vue @@ -1,6 +1,8 @@ + + + + diff --git a/packages/desktop-client/src/renderer/icons.ts b/packages/desktop-client/src/renderer/icons.ts new file mode 100644 index 0000000..6ba99d8 --- /dev/null +++ b/packages/desktop-client/src/renderer/icons.ts @@ -0,0 +1,94 @@ +import type { App } from 'vue'; +import { + AlarmClock, + ArrowDown, + ArrowLeft, + ArrowRight, + Bell, + Brush, + Calendar, + CircleCheckFilled, + Connection, + Expand, + Fold, + FullScreen, + Guide, + InfoFilled, + Key, + Link, + Loading, + Lock, + MagicStick, + Moon, + Notebook, + Plus, + Pointer, + Refresh, + School, + Setting, + Sunny, + SwitchButton, + Tools, + Trophy, + UserFilled, + Warning, +} from '@element-plus/icons-vue'; + +/** + * Element Plus 图标注册表(桌面客户端渲染进程)。 + * + * **为什么不 `import * as ElementPlusIconsVue` 全量注册**: + * 图标库有 293 个组件(实测全量打包 minified 203KB,实际用到的只有 33 个), + * 全量注册既把死代码打进主 chunk,又要在启动时执行 293 次 `app.component()`。 + * + * **为什么不改成模板里逐个 import**:本项目的图标是按**字符串名**引用的 + * (`ClientLayout.vue` 的菜单配置 `icon: 'Setting'`、`OnboardingWelcome.vue` 的引导步骤、 + * 按钮上的 `:icon="'FullScreen'"`、``),Element Plus 会用 + * `` 去解析**全局组件名**,因此图标必须以全局组件存在。 + * + * 新增视图时若用了新图标,`scripts/check-icons.mjs` 会在构建前报错, + * 不会静默渲染成空白。 + */ +const APP_ICONS = { + AlarmClock, + ArrowDown, + ArrowLeft, + ArrowRight, + Bell, + Brush, + Calendar, + CircleCheckFilled, + Connection, + Expand, + Fold, + FullScreen, + Guide, + InfoFilled, + Key, + Link, + Loading, + Lock, + MagicStick, + Moon, + Notebook, + Plus, + Pointer, + Refresh, + School, + Setting, + Sunny, + SwitchButton, + Tools, + Trophy, + UserFilled, + Warning, +} as const; + +/** 供 `scripts/check-icons.mjs` 读取的名单(构建期静态校验用) */ +export const APP_ICON_NAMES: readonly string[] = Object.keys(APP_ICONS).sort(); + +export function registerAppIcons(app: App): void { + for (const [name, component] of Object.entries(APP_ICONS)) { + app.component(name, component); + } +} diff --git a/packages/desktop-client/src/renderer/layouts/ClientLayout.vue b/packages/desktop-client/src/renderer/layouts/ClientLayout.vue index 3cef2d1..89166aa 100644 --- a/packages/desktop-client/src/renderer/layouts/ClientLayout.vue +++ b/packages/desktop-client/src/renderer/layouts/ClientLayout.vue @@ -19,22 +19,24 @@ const notifications = useNotificationStore(); const ui = useUiStore(); /** - * 主侧边栏:ClassIsland 式「分组 + 子菜单」。 - * - 学习:课表 / 作业 / 通知 / 成绩 - * - 设置:通用 / 外观 / 灵动岛 / 提醒 / 账号 / 关于(每个子项都是独立路由 /settings/*) - * 顶栏左侧汉堡按钮把整栏折叠成图标栏(状态持久化在主进程配置里)。 + * 主侧边栏。 + * + * - **一级项**:课表 / 作业 / 通知 / 成绩 —— 直接铺在侧栏上,**不再套「学习」分组**。 + * 分组标题白占一行、还得多点一次展开,折叠成图标导轨时尤其明显(用户要求拆掉)。 + * - **分组**:只剩「设置」(通用 / 外观 / 灵动岛 / 提醒 / 账号 / 关于,各自是独立路由)。 + * 6 个子项平铺太长,保留分组与"默认只展开当前路由所在分组"。折叠后是图标导轨, + * 分组子项与一级项一样以图标列出(见 styles/index.css 的折叠态规则)。 + * + * 顶栏左侧汉堡按钮把整栏收成图标导轨(状态持久化在主进程配置里)。 */ +const primaryItems = [ + { path: '/schedule', title: '课表', icon: 'Calendar' }, + { path: '/homeworks', title: '作业', icon: 'Notebook' }, + { path: '/notifications', title: '通知', icon: 'Bell' }, + { path: '/grades', title: '成绩', icon: 'Trophy' }, +]; + const menuGroups = [ - { - title: '学习', - icon: 'Reading', - items: [ - { path: '/schedule', title: '课表', icon: 'Calendar' }, - { path: '/homeworks', title: '作业', icon: 'Notebook' }, - { path: '/notifications', title: '通知', icon: 'Bell' }, - { path: '/grades', title: '成绩', icon: 'Trophy' }, - ], - }, { title: '设置', icon: 'Setting', @@ -60,19 +62,19 @@ const activeMenu = computed(() => route.path); /** 当前路由所属的分组标题(用于"只默认展开所在分组",与 ClassIsland 一致) */ const activeGroupTitle = computed(() => { const group = menuGroups.find((item) => item.items.some((entry) => route.path === entry.path)); - return group?.title ?? '学习'; + // 落在**一级项**(课表/作业/通知/成绩)上没有所属分组 —— 返回 null,别拿某个组的名字顶上去 + return group?.title ?? null; }); /** - * 分组展开控制:默认只展开**当前路由所在的分组**。 + * 分组展开控制:默认只展开**当前路由所在的分组**(一级项不属于任何分组,此时一个都不展开)。 * - * 窗口默认高度与 ClassIsland 对齐(582px),两组全展开(10 个子项 + 2 个分组标题) - * 会超出可视高度、把底部的「账号 / 关于」挤出屏幕;只展开当前组既符合 ClassIsland 的 - * 观感(它也只展开当前组),又让小窗口下的侧栏始终完整可见。 - * 用户手动展开其它分组不受限制(可多个同时展开)。 + * 窗口默认高度与 ClassIsland 对齐(582px),设置组 6 个子项展开后会超出可视高度、 + * 把底部的「账号 / 关于」挤出屏幕;只展开当前组既符合 ClassIsland 的观感(它也只展开当前组), + * 又让小窗口下的侧栏始终完整可见。用户手动展开其它分组不受限制(可多个同时展开)。 */ const menuRef = ref(); -const openedGroups = ref([activeGroupTitle.value]); +const openedGroups = ref(activeGroupTitle.value ? [activeGroupTitle.value] : []); watch( () => route.path, @@ -166,7 +168,11 @@ onUnmounted(() => {
    -
  • ClassIsland —— 界面风格、设置页与「今天」时间轴的交互参考
  • -
  • WinIsland —— 灵动岛的窗口形态与弹簧动画参考
  • -
  • Vue / Element Plus / Prisma / Express / Socket.IO —— 本项目赖以构建的开源基石
  • +
  • + ClassIsland + —— 界面风格、设置页与「今天」时间轴的交互参考 +
  • +
  • + WinIsland + —— 灵动岛的窗口形态与弹簧动画参考 +
  • +
  • + Vue / Element Plus / Prisma / Express / Socket.IO + —— 本项目赖以构建的开源基石 +
@@ -165,6 +248,42 @@ onMounted(async () => { margin-right: 4px; } +/* 检查更新:颜色一律走 --ch-* 变量(深色主题下不能写死浅色底/深色字) */ +.about-update { + margin-top: 12px; + padding: 10px 12px; + border: 1px solid var(--ch-border); + border-radius: var(--ch-radius-card); + background: var(--ch-layer-alt); +} + +.about-update-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.about-update-label { + font-size: 12.5px; + font-weight: 600; + color: var(--ch-text-secondary); +} + +.about-update-body { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; + font-size: 12.5px; + color: var(--ch-text); +} + +.about-update-muted { + color: var(--ch-text-tertiary); +} + .about-thanks { margin: 0; padding-left: 18px; diff --git a/packages/desktop-client/src/types/desktop.d.ts b/packages/desktop-client/src/types/desktop.d.ts index a6dbc4f..468d144 100644 --- a/packages/desktop-client/src/types/desktop.d.ts +++ b/packages/desktop-client/src/types/desktop.d.ts @@ -7,6 +7,7 @@ import type { IslandAppearance, IslandNotification, IslandState, + UpdateInfo, } from '@classhelper/shared'; /** 作业页展示偏好(看板 / 列表,看板外观) */ @@ -58,6 +59,13 @@ export interface DesktopStoredConfig { theme: 'light' | 'dark'; /** 主侧边栏是否折叠成图标栏(可由顶栏汉堡按钮切换) */ sidebarCollapsed: boolean; + /** + * 用户点过「忽略此版本」的版本号(空串 = 没忽略过)。 + * + * 启动时的自动检查会跳过这个版本,避免每次开客户端都弹一次同样的提示; + * 在「关于」页手动检查时仍会正常显示结果。 + */ + ignoredUpdateVersion: string; } export interface DesktopAppInfo { @@ -121,6 +129,21 @@ export interface DesktopBridge { getAppInfo(): Promise; openExternal(url: string): Promise; + /* 更新检查 */ + /** + * 检查 GitHub 上有没有新版本(主进程直连,不经后端)。 + * + * @param force true = 绕过主进程缓存(对应「关于」页里用户主动点的那次检查) + * + * 返回的 `ok:false` 是**正常结果**(教室机器没有外网 / GitHub 限流 / 超时),不是异常, + * 界面按"暂时查不到更新"呈现即可。 + */ + checkForUpdates(force?: boolean): Promise; + /** 忽略某个版本的更新提示(写入 config.json,启动自动检查不再提示它) */ + ignoreUpdateVersion(version: string): Promise; + /** 订阅"启动自动检查发现新版本"(仅新版本且未被忽略时触发一次) */ + onUpdateAvailable(handler: (info: UpdateInfo) => void): void; + /* 灵动岛 */ /** 把一条通知投递到灵动岛(payload 必须是纯数据,说明见 saveConfig) */ islandPush(payload: { notification: IslandNotification; context?: IslandPushContext }): void; diff --git a/packages/server/.env.example b/packages/server/.env.example index 8025c3a..3845a5c 100644 --- a/packages/server/.env.example +++ b/packages/server/.env.example @@ -29,8 +29,10 @@ CORS_ORIGIN=* # bcrypt 加密强度 BCRYPT_ROUNDS=10 -# 教师新增学生账号时未指定密码所使用的初始密码 - +# 新建班级 / 新增教师未指定密码时使用的初始密码 +# (学生没有个人账号,因此没有 DEFAULT_STUDENT_PASSWORD —— 见 AGENTS.md §5 第 31 条) +DEFAULT_CLASS_PASSWORD=123456 +DEFAULT_TEACHER_PASSWORD=123456 # 学期配置:第 1 教学周的周一日期(YYYY-MM-DD),用于“当前周次”换算 TERM_START_DATE=2026-02-23 diff --git a/packages/server/package.json b/packages/server/package.json index 5e5f368..9f76ad5 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@classhelper/server", - "version": "1.0.0", + "version": "1.1.0", "private": true, "description": "班级小助手后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问", "type": "module", diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma index cca74ad..3dd7823 100644 --- a/packages/server/prisma/schema.prisma +++ b/packages/server/prisma/schema.prisma @@ -4,7 +4,8 @@ // 1) 本 schema 使用 sqlite provider(本地 MVP / 离线开发)。 // 字段全部使用 String / Int / Float / Boolean / DateTime 等跨库通用类型, // 不使用 Prisma enum 与 @db.* 原生类型,因此可平移到 MySQL。 -// MySQL 版本见同目录 schema.mysql.prisma,切换步骤见 docs/mysql.md。 +// MySQL 复用这一份 schema(就地改写下面的 provider,见 `pnpm db:switch:mysql`), +// 没有第二份 schema.mysql.prisma;切换步骤见 docs/mysql.md。 // 2) 角色与优先级用 String 存储('ADMIN' | 'TEACHER' | 'STUDENT'、 // 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT'),在 API 层用 zod 校验, // 这样 SQLite / MySQL 行为完全一致。 diff --git a/packages/server/scripts/verify-classisland.mjs b/packages/server/scripts/verify-classisland.mjs index 16a6d91..448110c 100644 --- a/packages/server/scripts/verify-classisland.mjs +++ b/packages/server/scripts/verify-classisland.mjs @@ -105,6 +105,34 @@ async function loginWithCandidates(username, passwords) { return null; } +/** + * 反复「拉取待提醒 → 逐条确认」,直到该设备视角下没有积压。 + * + * 为什么验证脚本要做这件事:`GET /integrations/classisland/pending` 是 + * 「本班全部未确认且未过期的提醒,按时间升序取前 20 条」。而 `verify:desktop` / + * `verify:web` 等冒烟会**真的往演示班发通知**(AGENTS.md §5 第 38 条), + * 那些条如果没被确认就会一直堆着 —— 攒够 20 条之后,本脚本刚发的提醒会被挤到窗口之外, + * 表现为「待提醒里找不到刚发的那条」,与代码对不对完全无关(2026-10-05 实测踩过: + * 库里积了 45 条历次冒烟残条,本脚本从 42/42 掉到 35/40)。 + * + * 这里做的事情与真实插件重连后的行为完全一致(补弹积压并逐条确认), + * 因此不会掩盖任何产品侧问题。可见范围也一致:只处理 `deviceId` 为空的班级广播 + * 与本设备自己的推送,别的设备的目标推送取不到、也确认不了。 + */ +async function drainPending(deviceToken) { + let total = 0; + for (let round = 0; round < 20; round += 1) { + const pending = await api('/integrations/classisland/pending', { deviceToken }); + const items = pending.payload?.data?.notifications ?? []; + if (items.length === 0) break; + for (const item of items) { + await api('/integrations/classisland/ack', { method: 'POST', deviceToken, body: { id: item.id } }); + total += 1; + } + } + return total; +} + async function main() { console.log(`\n=== ClassIsland 联动验证(${BASE_URL})===\n`); @@ -182,7 +210,7 @@ async function main() { // ---------------------------------------------------------------- 3. 上报(状态 + 课表 + 节次) const reportBody = { - pluginVersion: '1.0.0.0', + pluginVersion: '1.1.0.0', classIslandVersion: '2.1.0.0', state: { inClass: true, @@ -237,6 +265,13 @@ async function main() { ); // ---------------------------------------------------------------- 4. 下发提醒 + // 先确认掉本班的历史积压(历次冒烟留下的未确认提醒),否则 /pending 的前 20 条窗口 + // 会被它们占满,下面「插件拉取到待提醒」等用例会拿不到刚发的这条(理由见 drainPending) + const drained = await drainPending(deviceToken); + if (drained > 0) { + console.log(`(已确认本班 ${drained} 条历史积压提醒,避免挤占待提醒窗口)\n`); + } + const notify = await api('/integrations/classisland/notify', { method: 'POST', token: teacherToken, diff --git a/packages/server/scripts/verify-e2e.mjs b/packages/server/scripts/verify-e2e.mjs index 38f928b..e038571 100644 --- a/packages/server/scripts/verify-e2e.mjs +++ b/packages/server/scripts/verify-e2e.mjs @@ -67,6 +67,17 @@ async function main() { } console.log(` 已挂载模块:${(health.payload?.data?.modules ?? []).join(', ')}\n`); + // 版本号不再硬编码(见 lib/version.ts):健康检查必须暴露一个像样的版本, + // 更新检查要靠它判断"本机是不是旧版";同时确认 update 模块已进注册表。 + const healthData = health.payload?.data ?? {}; + record( + '健康检查暴露版本号与模块清单(含 update 模块)', + typeof healthData.version === 'string' && + /^\d+\.\d+\.\d+/.test(healthData.version) && + (healthData.modules ?? []).includes('update'), + `版本=${healthData.version} 模块数=${(healthData.modules ?? []).length}`, + ); + // ---------------------------------------------------------------- 1. 登录 const teacherLogin = await api('/auth/login', { method: 'POST', @@ -2503,7 +2514,10 @@ async function main() { 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 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, @@ -2521,7 +2535,10 @@ async function main() { } const dbBackupList = await api('/database/backups', { token: adminToken }); - const dbBackupDelete = await api(`/database/backups/${backupName}`, { method: 'DELETE', token: adminToken }); + const dbBackupDelete = await api(`/database/backups/${backupName}`, { + method: 'DELETE', + token: adminToken, + }); const dbBackupListAfter = await api('/database/backups', { token: adminToken }); record( '备份列表与删除(创建后可见,删除后消失)', @@ -2612,6 +2629,42 @@ async function main() { } 残留班级=${leftoverClasses.map((item) => item.name).join(',') || '无'}`, ); + // ---------------------------------------------------------------- 更新检查 + // 验的是"结构完整 + 权限 + 可降级",**刻意不断言"一定有新版本"** —— + // 那会把用例绑死在外网可达上,而学校机房/内网服务器查不到 GitHub 是常态 + // (`ok:false` 是设计内的结果,不是失败)。真实联网取值在交付前人工验一次。 + const updateAnon = await api('/update/check'); + record('更新检查未登录 401', updateAnon.status === 401, `status=${updateAnon.status}`); + + const updateRes = await api('/update/check', { token: adminToken }); + const updateData = updateRes.payload?.data ?? {}; + const isVersionLike = (value) => typeof value === 'string' && /^\d/.test(value); + record( + '更新检查(管理员):结构完整,本机版本取自 package.json', + updateRes.status === 200 && + isVersionLike(updateData.currentVersion) && + typeof updateData.ok === 'boolean' && + typeof updateData.hasUpdate === 'boolean' && + (updateData.ok + ? isVersionLike(updateData.latestVersion) && + Array.isArray(updateData.assets) && + /^https:\/\//.test(updateData.releaseUrl ?? '') && + updateData.error === null + : typeof updateData.error === 'string' && updateData.error.length > 0), + `ok=${updateData.ok} 本机=${updateData.currentVersion} 最新=${updateData.latestVersion ?? '-'} ` + + `有新版本=${updateData.hasUpdate} 附件=${(updateData.assets ?? []).length}` + + `${updateData.error ? ` 原因=${updateData.error}` : ''}`, + ); + + // force 只对管理员生效:教师带 force=1 也必须命中缓存(否则任意登录用户都能刷爆匿名限流) + const updateTeacherForced = await api('/update/check?force=1', { token: teacherToken }); + record( + '更新检查:教师可查(公开信息),但 force 仅管理员生效', + updateTeacherForced.status === 200 && + updateTeacherForced.payload?.data?.checkedAt === updateData.checkedAt, + `status=${updateTeacherForced.status} 命中缓存=${updateTeacherForced.payload?.data?.checkedAt === updateData.checkedAt}`, + ); + socket.close(); // ---------------------------------------------------------------- 汇总 diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 210a911..0a085fa 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -6,6 +6,7 @@ import { env } from './config/env.js'; import { pingDatabase } from './lib/db.js'; import { sendOk } from './lib/http.js'; import { logger } from './lib/logger.js'; +import { APP_VERSION } from './lib/version.js'; import { mountWebAdmin, resolveWebDistDir } from './lib/web-static.js'; import { errorHandler, notFoundHandler } from './middleware/error.js'; import { apiRateLimiter, loginRateLimiter, requestLogger, securityHeaders } from './middleware/security.js'; @@ -37,7 +38,8 @@ async function healthHandler(_req: Request, res: Response): Promise { res, { status: 'ok', - version: '1.0.0', + // 版本号取自 package.json(见 lib/version.ts):更新检查要拿它跟 GitHub 上的 Release 比 + version: APP_VERSION, env: env.nodeEnv, uptimeSeconds: Math.round(process.uptime()), database: { @@ -68,7 +70,7 @@ function createRootHandler(webDistDir: string | null) { res, { service: 'class-helper-server', - version: '1.0.0', + version: APP_VERSION, environment: env.nodeEnv, apiPrefix: API_PREFIX, socket: '/socket.io', @@ -115,7 +117,7 @@ function renderLandingPage(webAdminServed: boolean): string {

班级小助手 · 后端服务 running

-

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

+

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

健康检查 ${API_PREFIX}/health · 存活 /healthz · 就绪 /readyz diff --git a/packages/server/src/config/env.ts b/packages/server/src/config/env.ts index c9cf93c..a4916e2 100644 --- a/packages/server/src/config/env.ts +++ b/packages/server/src/config/env.ts @@ -156,7 +156,6 @@ export const env = { .map((item) => item.trim()) .filter(Boolean), bcryptRounds: raw.BCRYPT_ROUNDS, - /** 新增学生时未指定密码时的初始密码 */ /** 新建班级时的默认班级密码(班级账号登录) */ defaultClassPassword: raw.DEFAULT_CLASS_PASSWORD, /** 新建/重置教师账号时的初始密码 */ diff --git a/packages/server/src/lib/version.ts b/packages/server/src/lib/version.ts new file mode 100644 index 0000000..f4bf75f --- /dev/null +++ b/packages/server/src/lib/version.ts @@ -0,0 +1,41 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { env } from '../config/env.js'; +import { logger } from './logger.js'; + +/** + * 服务端版本号。 + * + * 为什么不再硬编码:`app.ts` 里原本写了两个字面量 `'1.0.0'`(健康检查 + 首页), + * 而版本号在仓库里已经有 5 份 package.json —— 更新检查要靠它判断"本机是不是旧版", + * 再留一份手写副本就一定会漂移。 + * + * 取哪一份(按顺序): + * 1. `serverRoot/..` —— 打包形态下是运行时清单(`scripts/dist-server.mjs` 把**根** + * package.json 的 version 写进 `staging/package.json`),也就是产物命名用的那个版本; + * 2. `serverRoot` —— 开发形态下是 `packages/server/package.json`。 + * + * 与 `config/env.ts` 加载 `.env` 的双候选写法同一思路:不依赖启动时的 cwd。 + */ +function readVersion(): string { + const candidates = [ + path.resolve(env.serverRoot, '..', 'package.json'), + path.resolve(env.serverRoot, 'package.json'), + ]; + + for (const file of candidates) { + try { + const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as { version?: unknown }; + if (typeof parsed.version === 'string' && parsed.version.trim()) { + return parsed.version.trim(); + } + } catch { + // 换下一个候选 + } + } + + logger.warn(`未能从 package.json 读到版本号(候选:${candidates.join('、')}),暂时按 0.0.0 处理`); + return '0.0.0'; +} + +export const APP_VERSION = readVersion(); diff --git a/packages/server/src/lib/web-static.ts b/packages/server/src/lib/web-static.ts index 56516a6..5ad92dd 100644 --- a/packages/server/src/lib/web-static.ts +++ b/packages/server/src/lib/web-static.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; -import express, { type Express, type RequestHandler } from 'express'; +import express, { type Express } from 'express'; import { env } from '../config/env.js'; import { logger } from './logger.js'; @@ -100,9 +100,3 @@ export function mountWebAdmin(app: Express): string | null { logger.info(`Web 管理端已挂载:${distDir}`); return distDir; } - -/** 静态资源请求判断(用于日志降噪等场景) */ -export const staticAssetMatcher: RequestHandler = (req, _res, next) => { - req.headers['x-classhelper-static'] = req.path.startsWith('/assets/') ? '1' : '0'; - next(); -}; diff --git a/packages/server/src/middleware/security.ts b/packages/server/src/middleware/security.ts index f7cc4e4..890bc9a 100644 --- a/packages/server/src/middleware/security.ts +++ b/packages/server/src/middleware/security.ts @@ -85,11 +85,20 @@ export function loginRateLimiter(): RequestHandler { }); } -/** 请求耗时日志(生产 info,开发 debug) */ +/** + * 请求耗时日志(生产 info,开发 debug)。 + * + * 静态构建产物(`/assets/*`,带内容哈希、长缓存)**成功的请求不打点**: + * Web 管理端首屏一次就有十几个资源请求,逐条刷 info 会把真正有用的接口日志淹掉, + * 而且每条都要走一次 stdout 写入。失败的静态请求(404 / 500)照常记录 —— + * 那种情况恰恰是最需要看见的(产物缺失、路径不对)。 + */ export function requestLogger(): RequestHandler { return (req, res, next) => { + const isStaticAsset = req.path.startsWith('/assets/'); const startedAt = process.hrtime.bigint(); res.on('finish', () => { + if (isStaticAsset && res.statusCode < 400) return; const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000; const message = `${req.method} ${req.originalUrl} ${res.statusCode} ${durationMs.toFixed(1)}ms`; if (res.statusCode >= 500) logger.error(message); diff --git a/packages/server/src/modules/registry.ts b/packages/server/src/modules/registry.ts index 9c2f37c..93814c8 100644 --- a/packages/server/src/modules/registry.ts +++ b/packages/server/src/modules/registry.ts @@ -12,6 +12,7 @@ import { notificationsModule } from './notifications/notifications.module.js'; import { schedulesModule } from './schedules/schedules.module.js'; import { studentsModule } from './students/students.module.js'; import { teachersModule } from './teachers/teachers.module.js'; +import { updateModule } from './update/update.module.js'; import type { ApiModule } from './module.types.js'; /** @@ -36,4 +37,5 @@ export const apiModules: ApiModule[] = [ teachersModule, dashboardModule, databaseModule, + updateModule, ].filter((module) => module.enabled !== false); diff --git a/packages/server/src/modules/update/update.module.ts b/packages/server/src/modules/update/update.module.ts new file mode 100644 index 0000000..fcf1db2 --- /dev/null +++ b/packages/server/src/modules/update/update.module.ts @@ -0,0 +1,32 @@ +import { Router } from 'express'; +import { sendOk } from '../../lib/http.js'; +import { authenticate, getAuthUser } from '../../middleware/auth.js'; +import { defineModule } from '../module.types.js'; +import * as updateService from './update.service.js'; + +const router = Router(); +router.use(authenticate()); + +/** + * GET /api/update/check?force=1 - 检查 GitHub 上有没有比本机新的版本 + * + * 为什么 Web 管理端不直连 GitHub:浏览器的请求受服务端下发的 CSP 约束, + * `connect-src` 只放行 `'self'` 与 WebSocket(见 `middleware/security.ts`), + * 直连 `api.github.com` 会被浏览器拦掉。走服务端转发还顺带让所有管理员共享同一份缓存。 + * + * 桌面客户端不受这条约束(它由主进程直连),但两边拿到的 `UpdateInfo` 结构是一致的。 + * + * 权限:任何已登录用户都能查(信息本身来自公开仓库)。`force=1` 绕过服务端缓存, + * 但**只有管理员生效** —— 否则任意登录用户都能靠刷这个参数把匿名限流打满。 + */ +router.get('/check', async (req, res) => { + const user = getAuthUser(req); + const force = req.query.force === '1' && user.role === 'ADMIN'; + sendOk(res, await updateService.checkForUpdates({ force }), '更新检查完成'); +}); + +export const updateModule = defineModule({ + name: 'update', + basePath: '/update', + router, +}); diff --git a/packages/server/src/modules/update/update.service.ts b/packages/server/src/modules/update/update.service.ts new file mode 100644 index 0000000..83cd27f --- /dev/null +++ b/packages/server/src/modules/update/update.service.ts @@ -0,0 +1,152 @@ +import { + GITHUB_RELEASES_LATEST_API, + GITHUB_RELEASES_PAGE, + UPDATE_CHECK_CACHE_TTL_MS, + UPDATE_CHECK_TIMEOUT_MS, + UPDATE_CHECK_USER_AGENT, + isNewerVersion, + normalizeVersion, + type UpdateAssetDto, + type UpdateInfo, +} from '@classhelper/shared'; +import { logger } from '../../lib/logger.js'; +import { APP_VERSION } from '../../lib/version.js'; + +/** + * 更新检查:问 GitHub 上最新的 Release 是不是比本机新。 + * + * 三个设计约束: + * 1. **失败是常态,不是异常**:学校的服务器常常在只有内网的机房网络里, + * 因此离线 / 限流 / 超时一律收敛成 `ok:false` + 一句人话,绝不抛给路由层(否则管理员看到的会是 500)。 + * 2. **必须带 User-Agent**:GitHub API 对没有 UA 的请求直接 403。 + * 3. **必须有缓存**:匿名调用限流是 60 次/小时/IP,而 Web 管理端每次打开弹窗都会问一次。 + * + * 这里用 Node 内置的 `fetch`(Node 20+ / Electron 同源),不引入任何 HTTP 依赖 —— + * 这也是本服务端**唯一**的出网请求。 + */ + +/** GitHub Releases API 里我们真正用到的字段(其余忽略,避免把整个响应建模一遍) */ +interface GithubReleasePayload { + tag_name?: unknown; + name?: unknown; + published_at?: unknown; + body?: unknown; + html_url?: unknown; + assets?: unknown; +} + +let cache: { at: number; info: UpdateInfo } | null = null; + +/** 失败的统一出口:结构完整,界面不必做 null 判断 */ +function failure(error: string): UpdateInfo { + return { + ok: false, + error, + currentVersion: APP_VERSION, + latestVersion: null, + hasUpdate: false, + releaseUrl: GITHUB_RELEASES_PAGE, + releaseName: null, + publishedAt: null, + notes: null, + assets: [], + checkedAt: new Date().toISOString(), + }; +} + +function toAssets(raw: unknown): UpdateAssetDto[] { + if (!Array.isArray(raw)) return []; + return raw + .map((item) => item as { name?: unknown; browser_download_url?: unknown; size?: unknown }) + .filter( + (item): item is { name: string; browser_download_url: string; size?: unknown } => + typeof item.name === 'string' && typeof item.browser_download_url === 'string', + ) + .map((item) => ({ + name: item.name, + downloadUrl: item.browser_download_url, + size: typeof item.size === 'number' && Number.isFinite(item.size) ? item.size : 0, + })); +} + +function toInfo(payload: GithubReleasePayload): UpdateInfo { + const latestVersion = normalizeVersion(typeof payload.tag_name === 'string' ? payload.tag_name : ''); + // 拿不到版本号就当"查不到",不要伪造一个 latestVersion 出来 + if (!latestVersion) { + logger.warn('更新检查:GitHub 返回的 Release 里没有可识别的 tag_name'); + return failure('GitHub 返回的版本号无法识别'); + } + + return { + ok: true, + error: null, + currentVersion: APP_VERSION, + latestVersion, + hasUpdate: isNewerVersion(APP_VERSION, latestVersion), + // 优先用 Release 自己的 html_url,拿不到才回落到列表页 + releaseUrl: + typeof payload.html_url === 'string' && payload.html_url ? payload.html_url : GITHUB_RELEASES_PAGE, + releaseName: typeof payload.name === 'string' ? payload.name : null, + publishedAt: typeof payload.published_at === 'string' ? payload.published_at : null, + notes: typeof payload.body === 'string' ? payload.body : null, + assets: toAssets(payload.assets), + checkedAt: new Date().toISOString(), + }; +} + +/** 把各种失败翻译成一句管理员能看懂的话 */ +function describeFailure(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + if (error instanceof Error && error.name === 'TimeoutError') { + return `连接 GitHub 超时(${UPDATE_CHECK_TIMEOUT_MS / 1000} 秒),本机可能没有外网`; + } + if (/fetch failed|ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT|certificate/i.test(message)) { + return '无法连接 GitHub,本机可能没有外网'; + } + return `检查更新失败:${message}`; +} + +/** + * 检查更新(结果带缓存)。 + * + * @param options.force 绕过缓存(由路由层限定为管理员才能生效) + */ +export async function checkForUpdates(options: { force?: boolean } = {}): Promise { + const now = Date.now(); + if (!options.force && cache && now - cache.at < UPDATE_CHECK_CACHE_TTL_MS) { + return cache.info; + } + + let info: UpdateInfo; + try { + const response = await fetch(GITHUB_RELEASES_LATEST_API, { + headers: { + 'User-Agent': UPDATE_CHECK_USER_AGENT, + Accept: 'application/vnd.github+json', + }, + signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS), + }); + + if (!response.ok) { + // 403 / 429 基本都是匿名限流(60 次/小时/IP)—— 给出可操作的提示而不是原始状态码 + info = + response.status === 403 || response.status === 429 + ? failure('GitHub 暂时拒绝了请求(匿名查询次数过多),请过一会儿再试') + : failure(`GitHub 返回 HTTP ${response.status}`); + logger.warn(`更新检查失败:HTTP ${response.status}(本机版本 ${APP_VERSION})`); + } else { + info = toInfo((await response.json()) as GithubReleasePayload); + logger.info( + `更新检查:本机 ${APP_VERSION} / 最新 ${info.latestVersion ?? '-'}` + + `${info.hasUpdate ? '(有新版本)' : '(已是最新)'}`, + ); + } + } catch (error) { + info = failure(describeFailure(error)); + logger.warn(`更新检查失败:${info.error}(本机版本 ${APP_VERSION})`); + } + + // 失败结果**也缓存**:否则机房断网时,管理员每点一次按钮都要白等一个超时。 + cache = { at: now, info }; + return info; +} diff --git a/packages/server/tsconfig.build.json b/packages/server/tsconfig.build.json index 07bfbd3..1073b54 100644 --- a/packages/server/tsconfig.build.json +++ b/packages/server/tsconfig.build.json @@ -3,7 +3,11 @@ "compilerOptions": { "rootDir": "src", "outDir": "dist", - "noEmit": false + "noEmit": false, + "removeComments": true, + "declaration": false, + "declarationMap": false, + "sourceMap": false }, "include": ["src/**/*.ts", "src/**/*.d.ts"] } diff --git a/packages/server/tsconfig.generate.json b/packages/server/tsconfig.generate.json index f219e8a..24f07f8 100644 --- a/packages/server/tsconfig.generate.json +++ b/packages/server/tsconfig.generate.json @@ -1,9 +1,26 @@ { - "extends": "./tsconfig.json", "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["node"], + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, "noEmit": false, "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + "removeComments": true, + "declaration": false, + "declarationMap": false, + "sourceMap": false }, "include": ["src/generated/**/*.ts"] } diff --git a/packages/shared/package.json b/packages/shared/package.json index 78bb416..18f4fd3 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@classhelper/shared", - "version": "1.0.0", + "version": "1.1.0", "private": true, "description": "班级小助手 - 三端共享的类型、常量与工具函数", "type": "module", diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 0b897d6..fb2a140 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -27,6 +27,8 @@ export const API_PATHS = { integrations: '/integrations', /** 数据库管理(仅管理员):状态/备份/导入导出/一键切换 */ database: '/database', + /** 更新检查:Web 端经服务端转发查 GitHub 上的最新 Release(浏览器受 CSP 限制不能直连) */ + update: '/update', dashboard: '/dashboard/summary', } as const; @@ -496,3 +498,29 @@ export const MAX_PAGE_SIZE = 200; /** 教师与管理员角色集合,便于 RBAC 判断 */ export const STAFF_ROLES: readonly UserRole[] = ['ADMIN', 'TEACHER']; + +/* ------------------------------------------------------------------ 更新检查 */ + +/** + * 更新来源:本项目用 **GitHub Releases** 分发交付物(服务端 / 客户端安装包、便携版、插件 .cipx), + * 因此"有没有新版本"就是问 GitHub 上最新的那个 Release。 + * + * 仓库是**公开**的,匿名调用即可(不需要 token);但匿名限流是 60 次/小时/IP, + * 所以服务端与客户端都把结果缓存 `UPDATE_CHECK_CACHE_TTL_MS`,不要每次开页面都打一次。 + */ +export const GITHUB_REPO = { owner: 'LoveChengke', repo: 'ClassHelper' } as const; + +/** 最新 Release 的 API 地址(GitHub 保证它不含草稿与预发布版) */ +export const GITHUB_RELEASES_LATEST_API = `https://api.github.com/repos/${GITHUB_REPO.owner}/${GITHUB_REPO.repo}/releases/latest`; + +/** 给人看的 Release 列表页(「前往下载」按钮打开它) */ +export const GITHUB_RELEASES_PAGE = `https://github.com/${GITHUB_REPO.owner}/${GITHUB_REPO.repo}/releases`; + +/** GitHub API 强制要求 User-Agent,不带会直接 403 */ +export const UPDATE_CHECK_USER_AGENT = 'ClassHelper-UpdateCheck'; + +/** 检查结果缓存时长(30 分钟):既避开匿名限流,也不至于让用户等太久才知道有新版本 */ +export const UPDATE_CHECK_CACHE_TTL_MS = 30 * 60 * 1000; + +/** 单次检查的超时:教室机 / 机房的网络常常很慢,但也不能把界面卡住 */ +export const UPDATE_CHECK_TIMEOUT_MS = 8_000; diff --git a/packages/shared/src/permissions.ts b/packages/shared/src/permissions.ts index 9cab050..9fb04ae 100644 --- a/packages/shared/src/permissions.ts +++ b/packages/shared/src/permissions.ts @@ -41,7 +41,7 @@ export function canAssignTeachers(role: UserRole): boolean { return role === 'ADMIN'; } -/** 学生名单(增删学生、重置密码、导入名单):仅管理员 */ +/** 学生名单(增删学生、导入名单):仅管理员(学生没有账号,因此没有"重置密码"这一步) */ export function canManageRoster(role: UserRole): boolean { return role === 'ADMIN'; } @@ -92,7 +92,7 @@ export const PERMISSION_MATRIX: readonly { }[] = [ { action: '班级创建 / 修改 / 删除', admin: true, head: false, subject: false }, { action: '分配班主任与科任老师', admin: true, head: false, subject: false }, - { action: '学生名单管理(增删 / 重置密码 / 导入)', admin: true, head: false, subject: false }, + { action: '学生名单管理(增删 / 导入)', admin: true, head: false, subject: false }, { action: '教师录入(新建 / 导入名单 / 重置密码)', admin: true, head: false, subject: false }, { action: '课表管理(增删改 / 时间配置导入)', admin: true, head: true, subject: false }, { action: '布置作业', admin: true, head: true, subject: true }, diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 5cc24ef..de476a6 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -1098,3 +1098,41 @@ export interface DatabaseSwitchStepDto { status: 'running' | 'done' | 'error'; detail?: string; } + +/* ------------------------------------------------------------------ 更新检查 */ + +/** Release 里的一个附件(安装包 / 便携版 / 插件包) */ +export interface UpdateAssetDto { + name: string; + downloadUrl: string; + size: number; +} + +/** + * 更新检查结果(服务端 `GET /api/update/check` 与桌面客户端 IPC 共用同一结构)。 + * + * 两点约定: + * 1. **`ok:false` 是正常结果,不是异常** —— 服务器没有外网、GitHub 限流、请求超时都会走到这里, + * 调用方按"暂时查不到"呈现即可(与 ClassIsland 推送失败只记日志是同一种处理思路)。 + * 2. **`currentVersion` 由调用方各自提供**:服务端比的是服务端版本、客户端比的是客户端版本。 + * 两边安装版本可能不同(例如教室机没跟着升级),所以不能由一端替另一端判断。 + */ +export interface UpdateInfo { + ok: boolean; + /** 失败原因(给人看的一句话);ok=true 时为 null */ + error: string | null; + /** 本机版本 */ + currentVersion: string; + /** GitHub 上最新 Release 的版本号;ok=false 时为 null */ + latestVersion: string | null; + hasUpdate: boolean; + /** 给人看的 Release 页面地址(「前往下载」用) */ + releaseUrl: string; + /** Release 标题 */ + releaseName: string | null; + publishedAt: string | null; + /** Release 说明(Markdown 原文,由界面自行截断展示) */ + notes: string | null; + assets: UpdateAssetDto[]; + checkedAt: string; +} diff --git a/packages/shared/src/utils.ts b/packages/shared/src/utils.ts index 739ce5c..a6796a6 100644 --- a/packages/shared/src/utils.ts +++ b/packages/shared/src/utils.ts @@ -28,9 +28,7 @@ export function isDayKey(value: string): boolean { if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; const [year, month, day] = value.split('-').map(Number); const date = new Date(year, month - 1, day); - return ( - date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day - ); + return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day; } /** 在 YYYY-MM-DD 上加减天数(用于日期选择器的区间与"昨天/明天") */ @@ -357,3 +355,65 @@ export function minutesUntil(time: string, now: Date = new Date()): number { if (target < 0) return 0; return target - (now.getHours() * 60 + now.getMinutes()); } + +/* ------------------------------------------------------------------ 版本比较(更新检查) */ + +/** + * 版本号归一化:去掉首尾空白与前导的 `v` / `V`。 + * + * 之所以需要:GitHub 的 tag 是 `v1.0.0`,而 package.json / `app.getVersion()` 给的是 `1.0.0`, + * 两边不归一化就没法比。三端共用这一个实现,避免"客户端说有新版、服务端说没有"。 + */ +export function normalizeVersion(value: string | null | undefined): string { + return (value ?? '').trim().replace(/^[vV]/, ''); +} + +/** + * 比较两个版本号:`a` 比 `b` 新返回正数,旧返回负数,相同返回 0。 + * + * 规则: + * 1. 按 `.` 分段比数字(每段只取开头的连续数字,因此 `1.0.0-fix` 的第三段仍是 `0`); + * 2. 数字全部相同、只有后缀不同时,**带后缀的一方视为更新**。 + * + * 第 2 条是为 `v0.1.0-fix` 这种"同一个版本号的修复发布"定的:按标准 semver, + * `0.1.0-fix` 是 `0.1.0` 的**预发布**(应当更旧),但本项目是拿它当"修完再补发一版"用的, + * 照 semver 判会让那条修复版永远提示不出来。另外 `/releases/latest` 本身会跳过预发布, + * 所以不用担心把 `-beta` 当成正式版。 + */ +export function compareVersions(a: string | null | undefined, b: string | null | undefined): number { + const left = normalizeVersion(a); + const right = normalizeVersion(b); + if (left === right) return 0; + + const parse = (value: string): { numbers: number[]; suffix: string } => { + const [core = '', ...rest] = value.split('-'); + const numbers = core.split('.').map((part) => { + const matched = /^\d+/.exec(part.trim()); + return matched ? Number(matched[0]) : 0; + }); + return { numbers, suffix: rest.join('-') }; + }; + + const l = parse(left); + const r = parse(right); + const length = Math.max(l.numbers.length, r.numbers.length); + for (let index = 0; index < length; index += 1) { + const diff = (l.numbers[index] ?? 0) - (r.numbers[index] ?? 0); + if (diff !== 0) return diff > 0 ? 1 : -1; + } + + if (l.suffix === r.suffix) return 0; + // 数字相同时:裸版本更旧(1.0.0 < 1.0.0-fix),两个都有后缀才按字典序 + if (!l.suffix) return -1; + if (!r.suffix) return 1; + return l.suffix.localeCompare(r.suffix); +} + +/** `latest` 是否比 `current` 新(更新检查的判定入口;latest 为空一律视为"没有新版") */ +export function isNewerVersion( + current: string | null | undefined, + latest: string | null | undefined, +): boolean { + if (!normalizeVersion(latest)) return false; + return compareVersions(latest, current) > 0; +} diff --git a/packages/web-admin/package.json b/packages/web-admin/package.json index 87d43c5..575f2a4 100644 --- a/packages/web-admin/package.json +++ b/packages/web-admin/package.json @@ -1,12 +1,12 @@ { "name": "@classhelper/web-admin", - "version": "1.0.0", + "version": "1.1.0", "private": true, "description": "班级小助手 Web 管理端(Vue 3 + Vite + Element Plus)", "type": "module", "scripts": { "dev": "vite", - "build": "vue-tsc --noEmit -p tsconfig.json && vite build", + "build": "node ../../scripts/check-icons.mjs && vue-tsc --noEmit -p tsconfig.json && vite build", "build:only": "vite build", "preview": "vite preview", "typecheck": "vue-tsc --noEmit -p tsconfig.json" @@ -15,7 +15,6 @@ "@classhelper/shared": "workspace:*", "@element-plus/icons-vue": "^2.3.2", "axios": "^1.20.0", - "echarts": "^6.1.0", "element-plus": "^2.14.5", "pinia": "^4.0.3", "socket.io-client": "^4.8.3", diff --git a/packages/web-admin/src/api/index.ts b/packages/web-admin/src/api/index.ts index 00553a4..d602203 100644 --- a/packages/web-admin/src/api/index.ts +++ b/packages/web-admin/src/api/index.ts @@ -42,6 +42,7 @@ import { type TimeLayoutParsePreview, type UpdateClassAccountRequest, type UpdateClassRequest, + type UpdateInfo, type UpdateIntegrationDeviceRequest, type UserDto, } from '@classhelper/shared'; @@ -126,12 +127,8 @@ export const homeworkApi = { date?: string; }): Promise => api.get(API_PATHS.homeworks, params), /** 哪些天有作业(日期选择器高亮) */ - days: (params: { - classId?: string; - from?: string; - to?: string; - days?: number; - }): Promise => api.get(`${API_PATHS.homeworks}/days`, params), + days: (params: { classId?: string; from?: string; to?: string; days?: number }): Promise => + api.get(`${API_PATHS.homeworks}/days`, params), detail: (id: string): Promise => api.get(`${API_PATHS.homeworks}/${id}`), create: (payload: CreateHomeworkRequest): Promise => api.post(API_PATHS.homeworks, payload), update: (id: string, payload: Partial): Promise => @@ -344,8 +341,10 @@ export const integrationApi = { */ 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), + 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 }> => @@ -366,3 +365,18 @@ export const databaseApi = { getSwitchJob: (jobId: string): Promise => api.get(`${API_PATHS.database}/switch/jobs/${encodeURIComponent(jobId)}`), }; + +/** + * 更新检查:问服务端 GitHub 上有没有比本机新的版本。 + * + * **Web 端不直连 GitHub** —— 服务端下发的 CSP `connect-src` 只放行 `'self'` 与 WebSocket + * (见 `middleware/security.ts`),浏览器直连 `api.github.com` 会被拦掉; + * 走服务端转发还顺带让所有管理员共享同一份缓存(匿名限流 60 次/小时/IP)。 + * + * `ok:false` 是正常结果(机房没外网 / GitHub 限流),不是异常。 + */ +export const updateApi = { + /** @param force 绕过服务端缓存;仅管理员生效 */ + check: (force = false): Promise => + api.get(`${API_PATHS.update}/check${force ? '?force=1' : ''}`), +}; diff --git a/packages/web-admin/src/components/ScoreBarChart.vue b/packages/web-admin/src/components/ScoreBarChart.vue new file mode 100644 index 0000000..b4798fd --- /dev/null +++ b/packages/web-admin/src/components/ScoreBarChart.vue @@ -0,0 +1,213 @@ + + + + + diff --git a/packages/web-admin/src/components/ScoreLineChart.vue b/packages/web-admin/src/components/ScoreLineChart.vue new file mode 100644 index 0000000..f80a1a1 --- /dev/null +++ b/packages/web-admin/src/components/ScoreLineChart.vue @@ -0,0 +1,268 @@ + + + + + diff --git a/packages/web-admin/src/components/UpdateCheckDialog.vue b/packages/web-admin/src/components/UpdateCheckDialog.vue new file mode 100644 index 0000000..ae7f9f7 --- /dev/null +++ b/packages/web-admin/src/components/UpdateCheckDialog.vue @@ -0,0 +1,179 @@ + + + + + diff --git a/packages/web-admin/src/icons.ts b/packages/web-admin/src/icons.ts new file mode 100644 index 0000000..348b197 --- /dev/null +++ b/packages/web-admin/src/icons.ts @@ -0,0 +1,74 @@ +import type { App } from 'vue'; +import { + ArrowDown, + Bell, + Calendar, + Coin, + Connection, + CopyDocument, + Document, + Guide, + Lock, + Menu, + Notebook, + Odometer, + Plus, + Refresh, + School, + Search, + SwitchButton, + Trophy, + Upload, + User, + UserFilled, + WarningFilled, +} from '@element-plus/icons-vue'; + +/** + * Element Plus 图标注册表(Web 管理端)。 + * + * **为什么不 `import * as ElementPlusIconsVue` 全量注册**: + * 图标库有 293 个组件(实测全量打包 minified 203KB,实际用到的只有 22 个 / 59KB), + * 全量注册既把 144KB 死代码打进主 chunk,又要在启动时执行 293 次 `app.component()`。 + * + * **为什么不改成模板里逐个 import**:本项目的图标是按**字符串名**引用的 + * (菜单/路由配置里的 `icon: 'Odometer'`、按钮上的 `:icon="'Search'"`、 + * ``),Element Plus 的 `el-button` / `el-menu-item` + * 会用 `` 去解析**全局组件名**,因此图标必须以全局组件存在。 + * + * 新增视图时若用了新图标,`scripts/check-icons.mjs` 会在构建前报错(见 package.json 的 build 脚本), + * 不会静默渲染成空白。 + */ +const APP_ICONS = { + ArrowDown, + Bell, + Calendar, + Coin, + Connection, + CopyDocument, + Document, + Guide, + Lock, + Menu, + Notebook, + Odometer, + Plus, + Refresh, + School, + Search, + SwitchButton, + Trophy, + Upload, + User, + UserFilled, + WarningFilled, +} as const; + +/** 供 `scripts/check-icons.mjs` 读取的名单(构建期静态校验用) */ +export const APP_ICON_NAMES: readonly string[] = Object.keys(APP_ICONS).sort(); + +export function registerAppIcons(app: App): void { + for (const [name, component] of Object.entries(APP_ICONS)) { + app.component(name, component); + } +} diff --git a/packages/web-admin/src/layouts/AdminLayout.vue b/packages/web-admin/src/layouts/AdminLayout.vue index 297bdcb..1b2b8b5 100644 --- a/packages/web-admin/src/layouts/AdminLayout.vue +++ b/packages/web-admin/src/layouts/AdminLayout.vue @@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'; import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'; import { STORAGE_KEYS } from '@classhelper/shared'; import { authApi } from '@/api'; +import UpdateCheckDialog from '@/components/UpdateCheckDialog.vue'; import { useAuthStore } from '@/stores/auth'; import { useRealtimeStore } from '@/stores/realtime'; import { useResponsive } from '@/composables/useResponsive'; @@ -107,6 +108,10 @@ async function handleLogout(): Promise { void router.push({ name: 'login' }); } +/* ------------------------------------------------------------ 检查更新 */ +/** 顶栏用户菜单「检查更新」弹窗 */ +const updateVisible = ref(false); + /* ------------------------------------------------------------ 修改密码 */ const passwordVisible = ref(false); const passwordFormRef = ref(); @@ -295,6 +300,10 @@ const tourSteps = computed(() => [ 使用引导 + + + 检查更新 + 退出登录 @@ -352,6 +361,9 @@ const tourSteps = computed(() => [ + + +