10 Commits
Author SHA1 Message Date
LoveChengke 9e52cfad4d fix(deploy): 改密前把密码规则说清楚,并让"不合规"提示与输入提示走同一个终端
实机反馈:`classhelper password admin` 连问三次"新密码:"却不说明为什么——用户得失败一两次
才知道规则(而且 read -s 不回显,屏幕上连星号都没有,更容易以为卡住了)。

- 开问之前先打印规则(≥8 位、大小写/数字/符号至少 3 类、不能与用户名相同、别用
  admin/123456 这类开头,并给一个合规示例),并说明"输入不回显是正常的";
- 提示语带上次数:`新密码(第 1/3 次):`,三次都没过时明确告知密码未改动,
  并给出非交互改密的完整命令;
- 新增 warn_user():这类"正对着交互输入"的提示直接写 /dev/tty(与提示语同一个设备),
  不再走 stdout/stderr 分流,避免提示与它对应的警告错位或看不见;没有终端时回退 stderr。
  用 `{ …; } 2>/dev/null` 这个形状而不是 `… >/dev/tty 2>/dev/null`,否则 /dev/tty 打不开时
  bash 自己那句 "No such device or address" 会漏在用户眼前(重定向从左往右处理,压不住)。
2026-10-05 21:01:10 +08:00
LoveChengke 79d73c5889 chore(release): 版本号统一到 1.1.1(服务端 CSP 白屏修复)
对齐 1.1.0 的做法把版本号统一:5 份 package.json、deploy/package.runtime.json、
两份 .env.example 的 APP_VERSION、两个 docker-compose 的镜像标签、ClassIsland 插件的
manifest.yml 与 BridgeService.PluginVersion(verify:classisland-plugin 有 manifest↔代码
一致性断言,已跑 68/68)、README 首页版本、以及文档与脚本里的安装包名示例,共 28 处。

刻意不动:docs/linux-deploy.md 排障条目里"1.1.0 的服务端在 HTTP 直连访问时会下发 CSP
upgrade-insecure-requests…"——那句说的是**出问题的那个版本**,改成 1.1.1 反而是错的。
2026-10-05 20:05:39 +08:00
LoveChengke 48e0c89394 fix(server): 纯 HTTP 部署下 CSP 的 upgrade-insecure-requests 会让管理端白屏
helmet 的**默认** CSP 自带 upgrade-insecure-requests,它让浏览器把页面**子资源**(JS/CSS)
请求全部改写成 https。而安装器的默认形态是 http://IP:端口 直连(没有 TLS),改写后的请求
必然连接失败 —— 表现是「HTML 外壳加载出来了(标签页标题正常)、脚本没加载(页面一片空白)」。
2026-10-05 用一行安装脚本在 Ubuntu 24.04 上装完后实测踩到。

改法:securityHeaders() 按请求挑策略 —— req.secure(直连 TLS,或反代下由
x-forwarded-proto 决定)走 buildHelmet(true),否则 buildHelmet(false):

- 明文请求不再下发 upgrade-insecure-requests(置 null 去掉 helmet 的默认指令);
- 明文请求也不再下发 HSTS(按规范浏览器本就忽略,没必要发);
- HTTPS 请求的行为与改动前完全一致,安全性没有被削弱。

本地实测(另起一份服务在 4100、TRUST_PROXY=1,避免扰动开发机上已有的 4000 实例):
明文请求的 CSP 里 upgrade-insecure-requests 出现 0 次、带 X-Forwarded-Proto: https 的
请求里出现 1 次;healthz 与首页均 200;tsc 与 ESLint 均无问题。
2026-10-05 20:02:30 +08:00
LoveChengke 5d2fa2d6b4 fix(deploy): 修两处安装器 bug——下载地址多了一段 /releases、菜单选 4(只体检)不生效
Ubuntu 24.04 实机跑出来的两个问题(用户贴的输出各暴露一个):

1. **下载地址变成 .../releases/releases/download/...**(必然 404):GITHUB_RELEASES_PAGE 本身
   已经以 /releases 结尾,拼接时又写了一遍。install.sh 与 classhelper 各有一份,都改用新增的
   GITHUB_REPO_URL(不含 /releases);GITHUB_RELEASES_PAGE 只留给"发布页"这类展示用途。
2. **菜单里选 4)只体检,却直接开始安装**:CHECK_ONLY 的分流写在菜单**之前**,菜单里再把它置 1
   已经没人看。现在把 root 检查与 tee 都挪到菜单之后,菜单选 4 与 --check 走同一条路
   —— 顺带让非 root 用户也能看到菜单并选 4,不必非得记住 --check。
2026-10-05 19:52:57 +08:00
LoveChengke 2be8d30376 fix(deploy): 交互提示写不进终端导致静默退出——fd 3 必须用 <> 打开,不能只读
Ubuntu 24.04 实机定位:菜单打印完就退回 shell、退出码 1、没有任何提示(用户三次复现)。
根因是 `exec 3</dev/tty` 以**只读**方式打开 fd 3,而 read_prompt / read_secret 要往它**写**提示语:

  $ exec 3<file; printf x >&3
  bash: printf: write error: Bad file descriptor      # 退出码 1

在 set -e 下这一下就把脚本打死;错误信息还因为走 tee 管道、脚本退出时进程替换的子进程来不及
flush 而被吞掉 —— 于是表现成"菜单打完就默默消失"。

- install.sh 的 open_prompt_fd、classhelper 的 open_tty 都改成 `exec 3<>/dev/tty`(读写打开);
- 提示语写失败改为显式降级(该项用默认值 + 打印一条提示),不再让整条命令消失;
- classhelper 里是同一份实现,不修的话 Linux 上 `classhelper password` 会同样毫无提示地退出。

本地复现验证过三种情形:只读 fd 写 → Bad file descriptor / 退出码 1;`<>` 写 → 成功;
`if printf … >&3; then` 里的失败不会被 set -e 打死(新加的护栏确实兜住了)。
2026-10-05 19:50:24 +08:00
LoveChengke f6edfa5ea1 fix(deploy): 修正一行安装的写法——bash -c 会吞掉参数,改用「先落盘再执行」
实测(Ubuntu 24.04):`bash -c "$(curl …)" --check` 并没有进体检分支,而是走进了交互式安装。
原因是 `bash -c '代码' 第一个参数` 里那个参数会被赋给 **$0** 而不是 $1,脚本根本收不到它 ——
我之前在 install.sh 头部、内嵌 --help 与 README 里推荐的"末尾追加 --version / --yes / --check"
全都因此无效(用户按文档加了参数,实际一个都没生效)。

改成先落盘再执行:
  curl -fsSL -o /tmp/classhelper-install.sh <url> && sudo bash /tmp/classhelper-install.sh
参数直接接在最后(`sudo bash /tmp/classhelper-install.sh --check`)。这个形式顺带避免了
"下载中断却执行到半个脚本"的风险,stdin 也仍是终端。

同步位置:install.sh 头部注释、内嵌 usage(远端模式 --help)、README、docs/linux-deploy.md;
另在脚本里写明这个 $0 坑,免得以后又改回去。
2026-10-05 19:46:31 +08:00
LoveChengke 985e602012 fix(deploy): 修一行安装"菜单打印完就静默退出"——read_prompt 的返回值被 set -e 当成致命错误,且报错被 tee 吞掉
Ubuntu 24.04 实机跑一行安装时的现象:菜单打印出来后**没有任何提示**就退回 shell,
用户敲的 1 被 shell 当成命令(1: command not found)。两个原因叠在一起:

- read_prompt 里写的是 `open_prompt_fd || return 1`,而调用处(`read_prompt "…" choice`)是独立命令,
  在 `set -e` 下返回非 0 会当场终止整个脚本;改成永远返回 0(读不到终端就把变量留空、由调用方用默认值)。
- `exec > >(tee -a 安装日志)` 之后所有输出都走进程替换,而脚本退出时那个子进程可能来不及把最后几行
  写出去 —— 于是**致命错误被彻底吞掉**,只剩"默默退出"。现在脚本开头先把真正的 stderr 存到 fd 9,
  die / on_error 绕开 tee 直接写 fd 9(终端立刻可见)并直接追加日志文件。

随之而来的两处改进:
- 交互式安装读不到终端时**明确报错**并给出三条出路(--yes / 存成文件再执行 / --check),
  不再默默按默认值装下去 —— 那会让用户以为自己在选,其实一个都没问到;
- 开始安装前打印最终生效的选择(形态|目录|端口|数据库|Node 来源):非交互或问答被跳过时,
  这一行是唯一能看出"到底按什么装的"地方。

"请用 root" 的提示也按本地/远端两种执行方式分别给出可照抄的命令。
2026-10-05 19:44:15 +08:00
LoveChengke d820926491 feat(deploy): install.sh 支持一行远程安装(从 GitHub 取脚本与安装包,并自动校验哈希)
用户侧只要一行:
  sudo bash -c "$(curl -fsSL https://raw.githubusercontent.com/LoveChengke/ClassHelper/master/deploy/install.sh)"

为让它成立改了四处(原来只支持"本地有脚本 + 本地有安装包"):

- usage():读不到脚本文件时(bash -c "$(curl …)" / curl|bash)打印内置参数表,
  不再去 awk 一个不存在的文件;
- SCRIPT_PATH / SCRIPT_DIR:容忍非文件执行;与脚本同目录的模板本来也由安装包提供;
- 交互问答统一走 /dev/tty(新增 read_prompt / can_prompt):原来判断 [ ! -t 0 ] 会让
  curl|bash 下的所有提问被**静默跳过、直接按默认值装完**;并且刻意不退回 stdin ——
  管道模式里 stdin 就是脚本本身,从 stdin 读会把脚本的下一行当成用户输入吃掉;
- fetch_package:没给 --sha256 时自动取发布页上同名的 .sha256 来校验。一行安装的用户
  不会自己去核哈希,不补这一步"校验"就只是把哈希记进日志;取不到时明确告警,而不是
  假装校验过了。

文档:README 增加一行安装入口;docs/linux-deploy.md 的开头同样补上(该文件另有一处
改动不属于本次提交,故未纳入)。
2026-10-05 19:27:20 +08:00
LoveChengke f1edb42665 ci(workflow): Linux 包一并上传 .sha256,避免发布后哈希对不上
首次跑通后发现:workflow 只上传了 tar.gz 与 SHA256SUMS,而那份独立发布的 .sha256 还是上一版的哈希 —— 拿它校验会 mismatch(classhelper upgrade 用的正是这个文件,不是 SHA256SUMS)。另补一道兜底:文件缺失时现场生成。
2026-10-05 18:19:02 +08:00
LoveChengke 67e3961e55 ci(workflow): 修 Linux 出包 —— 补 Prisma Client 生成,SHA256SUMS 不再排序打乱
首次在 tag push 上跑就挂了(run 37294579347,失败在"构建 Linux 安装包")。两处都是我的问题:

1. 漏了 `pnpm --filter @classhelper/server db:generate`。`packages/server/src/generated/prisma`
   是 gitignored 的,CI 全新检出来没有它,于是 Prisma 类型解析不到、回调参数全被推成 any,
   `tsc` 报出一大片 TS7006 / TS2339 —— 看着像代码写错了,其实只是缺生成物。
   本机因为早就生成过,永远踩不到这个坑;deploy/Dockerfile 的构建阶段本来就有这一步,
   workflow 里漏抄了(同一条注释在那边写着)。
2. 更新 SHA256SUMS 时用了 `sort -u -k2` 去重,会把那份人读的清单(表头 + 体积 + 说明行)打散。
   改成只替换/追加本包那一行,其余原样保留。
2026-10-05 18:10:29 +08:00
19 changed files with 351 additions and 94 deletions

No files matched your search

+22 -7
View File
@@ -69,6 +69,14 @@ jobs:
- name: 安装依赖 - name: 安装依赖
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: 生成 Prisma Client
# 必须显式生成:packages/server/src/generated/prisma 是 gitignored 的,CI 上全新检出没有它。
# 少了这一步,`tsc` 会因为 Prisma 类型解析不到而把回调参数全推成 any,报出一大片
# "TS7006 Parameter 'item' implicitly has an 'any' type" / "TS2339 Property 'id' does not exist
# on type '{}'" —— 看着像代码写错了,实际是缺生成物(本机因为早就生成过,永远踩不到)。
# deploy/Dockerfile 的构建阶段有同样的步骤与注释。
run: pnpm --filter @classhelper/server db:generate
- name: 构建 Linux 安装包 - name: 构建 Linux 安装包
run: pnpm dist:server:linux run: pnpm dist:server:linux
@@ -94,23 +102,30 @@ jobs:
set -euo pipefail set -euo pipefail
VERSION="${TAG#v}" VERSION="${TAG#v}"
TARBALL="release-server/classhelper-server-linux-x64-${VERSION}.tar.gz" TARBALL="release-server/classhelper-server-linux-x64-${VERSION}.tar.gz"
SHAFILE="${TARBALL}.sha256"
SUMS="release-server/SHA256SUMS-${VERSION}.txt" SUMS="release-server/SHA256SUMS-${VERSION}.txt"
if [ ! -f "$TARBALL" ]; then if [ ! -f "$TARBALL" ]; then
echo "::error::没有找到 $TARBALL(tag 与 package.json 的版本号对得上吗?)" echo "::error::没有找到 $TARBALL(tag 与 package.json 的版本号对得上吗?)"
exit 1 exit 1
fi fi
# .sha256 必须跟着一起传:它是独立发布的资产,只更新 tar.gz 会让这个文件指向旧哈希,
# 拿它校验的人会得到 mismatch(classhelper upgrade 用的是 --sha256/这个文件,不是 SHA256SUMS)。
if [ ! -f "$SHAFILE" ]; then
sha256sum "$TARBALL" | sed "s#release-server/##" > "$SHAFILE"
fi
# 取回已有的校验清单(Windows 包那几行),把本次的 Linux 包追加/替换进去 # 取回已有的校验清单(Windows 包那几行),只替换/追加本包这一行,其余内容原样保留。
# 注意**不要**用 `sort -u` 去重:那份文件是人读的(表头 + 体积 + 说明),排序会把它们打散。
gh release download "$TAG" --pattern "SHA256SUMS-*.txt" --dir release-server --clobber || true 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)" DOWNLOADED="$(ls release-server/SHA256SUMS-*.txt 2>/dev/null | head -n 1 || true)"
if [ -n "$DOWNLOADED" ] && [ "$DOWNLOADED" != "$SUMS" ]; then mv "$DOWNLOADED" "$SUMS"; fi 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 touch "$SUMS"
cat "${SUMS}.tmp" > "$SUMS" 2>/dev/null || true grep -v "^[0-9a-f]\{64\} classhelper-server-linux-" "$SUMS" > "${SUMS}.new" || true
rm -f "${SUMS}.tmp" printf '%s %s\n' "$(sha256sum "$TARBALL" | awk '{print $1}')" "$(basename "$TARBALL")" >> "${SUMS}.new"
sha256sum "$TARBALL" | awk '{print $1" "$2}' | sed "s#release-server/##" >> "$SUMS" mv "${SUMS}.new" "$SUMS"
sort -u -k2 "$SUMS" -o "$SUMS"
gh release upload "$TAG" "$TARBALL" "$SUMS" --clobber gh release upload "$TAG" "$TARBALL" "$SHAFILE" "$SUMS" --clobber
echo "已上传:" echo "已上传:"
cat "$SHAFILE"
cat "$SUMS" cat "$SUMS"
+36 -20
View File
@@ -1,7 +1,7 @@
# 班级小助手(Class Helper) # 班级小助手(Class Helper)
班级信息管理系统。一个 pnpm monorepo,一份代码产出四个交付物:**后端服务**、**Web 管理端**(教师 / 管理员)、 班级信息管理系统。一个 pnpm monorepo,一份代码产出四个交付物:**后端服务**、**Web 管理端**(教师 / 管理员)、
**桌面客户端**(学生端 / 教室机器)与 **ClassIsland 联动插件**(装在教室的 ClassIsland 上)。**当前版本 1.1.0**。 **桌面客户端**(学生端 / 教室机器)与 **ClassIsland 联动插件**(装在教室的 ClassIsland 上)。**当前版本 1.1.1**。
核心链路: 核心链路:
@@ -74,26 +74,40 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/
## 2. 交付产物与部署形态 ## 2. 交付产物与部署形态
| 产物 | 产物文件位置(打包后) | 用途 | | 产物 | 产物文件位置(打包后) | 用途 |
| ------------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | | ------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| **服务端 + Web 管理端安装程序** | `release-server/班级小助手服务端-<版本>-x64-setup.exe` | 装到教师电脑 / 校服务器即完整系统,**内置 Node 运行时**,双击安装、开机自启、自动建库建号 | | **服务端 + Web 管理端安装程序** | `releases/server/<版本>/安装包/班级小助手服务端-<版本>-x64-setup.exe` | 装到教师电脑 / 校服务器即完整系统,**内置 Node 运行时**,双击安装、开机自启、自动建库建号 |
| **服务端 Linux 安装包** | `release-server/classhelper-server-linux-x64-<版本>.tar.gz` | 给 `deploy/install.sh` 用(另见同名 `.sha256`);由 Actions 在 Linux 上构建后挂 Release | | **服务端 Linux 安装包** | `releases/server/<版本>/linux-x64/classhelper-server-linux-x64-<版本>.tar.gz` | 给 `deploy/install.sh` 用(另见同名 `.sha256`);由 Actions 在 Linux 上构建后挂 Release |
| **学生客户端安装程序** | `packages/desktop-client/release/班级小助手-<版本>-x64-setup.exe` | 学生机安装(NSIS) | | **学生客户端安装程序** | `releases/client/<版本>/安装包/班级小助手-<版本>-x64-setup.exe` | 学生机安装(NSIS) |
| **学生客户端单文件版** | `packages/desktop-client/release/班级小助手-<版本>-x64-portable.exe` | 免安装直接运行(U 盘分发) | | **学生客户端单文件版** | `releases/client/<版本>/安装包/班级小助手-<版本>-x64-portable.exe` | 免安装直接运行(U 盘分发) |
| Web 管理端(PWA) | 由服务端在 `/` 直接托管 | 浏览器打开即用,可在 Edge/Chrome 中「安装为应用」;已适配手机小屏 | | Web 管理端(PWA) | 由服务端在 `/` 直接托管 | 浏览器打开即用,可在 Edge/Chrome 中「安装为应用」;已适配手机小屏 |
| **ClassIsland 联动插件** | `releases/classisland-plugin/ClassHelper.ClassIslandPlugin.cipx` | 装在教室机器的 ClassIsland 上(也可把 `ClassHelper.ClassIslandPlugin/` 目录丢进 Plugins) | | **ClassIsland 联动插件** | `releases/classisland-plugin/<版本>/安装包/ClassHelper.ClassIslandPlugin.cipx` | 装在教室机器的 ClassIsland 上(也可把 `ClassHelper.ClassIslandPlugin/` 目录丢进 Plugins) |
> 这些产物**都不入库**(`release-server/`、`packages/*/release/`、`releases/` 均已忽略)。 > 这些产物**都不入库**(`releases/` 已 gitignore + eslint ignore)。三个打包脚本**直接输出**到
> 仓库约定是把它们归集到根目录 `releases/`(`client/`、`server/`、`classisland-plugin/`),详见 [AGENTS.md](AGENTS.md) §4。 > `releases/<组件>/<版本>/`,每个版本目录下按 `安装包/`、`免安装/`、`构建中间/` 分层,
> 历史版本原样保留 —— 详见 [AGENTS.md](AGENTS.md) §4。
四种部署形态按场景选(Linux 完整步骤见 [docs/linux-deploy.md](docs/linux-deploy.md), 四种部署形态按场景选(Linux 完整步骤见 [docs/linux-deploy.md](docs/linux-deploy.md),
其余见 [docs/production.md](docs/production.md)): 其余见 [docs/production.md](docs/production.md)):
| 场景 | 形态 | 入口 | | 场景 | 形态 | 入口 |
| ----------------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------- | | ----------------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| 学校机房 / 教师电脑(Windows 单机) | Windows 服务端安装程序(内置 Node,双击即用) | `release-server/班级小助手服务端-<版本>-x64-setup.exe` | | 学校机房 / 教师电脑(Windows 单机) | Windows 服务端安装程序(内置 Node,双击即用) | `releases/server/<版本>/安装包/班级小助手服务端-<版本>-x64-setup.exe` |
| 云服务器 / 多终端共享(推荐长期) | Docker Compose + MySQL(另有 SQLite 单容器版) | `deploy/Dockerfile`、`deploy/docker-compose{,.sqlite}.yml` | | 云服务器 / 多终端共享(推荐长期) | Docker Compose + MySQL(另有 SQLite 单容器版) | `deploy/Dockerfile`、`deploy/docker-compose{,.sqlite}.yml` |
| 已有 Linux 服务器 | **一键安装器 + `classhelper` 运维命令**(systemd / docker) | `sudo bash deploy/install.sh`(先 `--check` 体检);运维见 `classhelper help` | | 已有 Linux 服务器 | **一键安装器 + `classhelper` 运维命令**(systemd / docker) | `sudo bash deploy/install.sh`(先 `--check` 体检);运维见 `classhelper help` |
| 自定义 / 已有 Node 环境 | 手动部署免安装目录 | `pnpm dist:server` → `release-server/classhelper-server/` | | 自定义 / 已有 Node 环境 | 手动部署免安装目录 | `pnpm dist:server` → `releases/server/<版本>/免安装/` |
**Linux 服务器最快路径是一行命令**:脚本会自己从 GitHub Release 取最新版服务端包、取发布页上的
`.sha256` 校验,再装成 systemd 服务并带上 `classhelper` 运维命令。完整步骤与验收清单见
[docs/linux-deploy.md](docs/linux-deploy.md)。
```bash
curl -fsSL -o /tmp/classhelper-install.sh https://raw.githubusercontent.com/LoveChengke/ClassHelper/master/deploy/install.sh && sudo bash /tmp/classhelper-install.sh
```
> 参数接在最后即可,例如先只体检(不需要 root、不改动系统):把末尾换成
> `sudo bash /tmp/classhelper-install.sh --check`。
> **别写成 `bash -c "$(curl …)" --check`**:`bash -c '代码' 第一个参数` 里那个参数会变成 `$0`
> 而不是 `$1`,脚本收不到(实测踩到:想体检却走进了交互式安装)。
## 3. 快速开始 ## 3. 快速开始
@@ -187,7 +201,7 @@ pnpm verify:desktop
| `pnpm build:shared` | 只构建 shared(改完 `packages/shared` 必须跑) | | `pnpm build:shared` | 只构建 shared(改完 `packages/shared` 必须跑) |
| `pnpm build:desktop` | 只构建客户端(esbuild 主进程 / preload + Vite 渲染进程,末尾跑产物凭据门禁) | | `pnpm build:desktop` | 只构建客户端(esbuild 主进程 / preload + Vite 渲染进程,末尾跑产物凭据门禁) |
| `pnpm build:classisland-plugin` | 构建 ClassIsland 联动插件(.NET 8) | | `pnpm build:classisland-plugin` | 构建 ClassIsland 联动插件(.NET 8) |
| `pnpm dist:classisland-plugin` | 打包插件为 `.cipx` 并归集到 `releases/classisland-plugin/` | | `pnpm dist:classisland-plugin` | 打包插件为 `.cipx` 并归集到 `releases/classisland-plugin/<版本>/` |
| `pnpm icons` | 生成应用图标(Electron 渲染 SVG → PNG/ICO) | | `pnpm icons` | 生成应用图标(Electron 渲染 SVG → PNG/ICO) |
| `pnpm dist:server` | 打包服务端 + Web 管理端(免安装目录 + NSIS 安装程序,内置 Node) | | `pnpm dist:server` | 打包服务端 + Web 管理端(免安装目录 + NSIS 安装程序,内置 Node) |
| `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(最快) | | `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(最快) |
@@ -1225,19 +1239,21 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
### 客户端(Windows EXE) ### 客户端(Windows EXE)
```bash ```bash
pnpm dist:dir # 免安装目录:packages/desktop-client/release/win-unpacked/班级小助手.exe pnpm dist:dir # 免安装目录:releases/client/<版本>/免安装/班级小助手.exe
pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS 工具,可用镜像) pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS 工具,可用镜像)
``` ```
打包脚本 `packages/desktop-client/scripts/dist-win.mjs` 做了几件事以规避常见问题:复用本地已解压的 Electron 打包脚本 `packages/desktop-client/scripts/dist-win.mjs` 做了几件事以规避常见问题:复用本地已解压的 Electron
(`--config.electronDist`,不重复下载 150MB+);默认走国内镜像(`ELECTRON_MIRROR`、 (`--config.electronDist`,不重复下载 150MB+);默认走国内镜像(`ELECTRON_MIRROR`、
`ELECTRON_BUILDER_BINARIES_MIRROR`,可用环境变量覆盖);构建缓存固定到仓库内 `.cache/`; `ELECTRON_BUILDER_BINARIES_MIRROR`,可用环境变量覆盖);构建缓存固定到仓库内 `.cache/`;
用 `--config.directories.output` 把输出目录覆盖成 `releases/client/<版本>/`,打包成功后再把
electron-builder 的原始布局整理成 `安装包/`、`免安装/`、`构建中间/`;
收尾把交付物完整性标签改成 Medium(Low 标签会导致装不上 / 双击没反应,见 [8. 常见问题](#8-常见问题))。 收尾把交付物完整性标签改成 Medium(Low 标签会导致装不上 / 双击没反应,见 [8. 常见问题](#8-常见问题))。
产物结构:`release/win-unpacked/班级小助手.exe`(Electron 运行时)+ `resources/app.asar` 产物结构:`免安装/班级小助手.exe`(Electron 运行时)+ `resources/app.asar`
(仅含 `dist/` 与 `package.json`,运行时不需要 node_modules)。 (仅含 `dist/` 与 `package.json`,运行时不需要 node_modules)。
> **把修复交付到「已安装的客户端」**:`pnpm dist:win` 只产出 `packages/desktop-client/release/`。 > **把修复交付到「已安装的客户端」**:`pnpm dist:win` 只产出 `releases/client/<版本>/`。
> 若学生机上是**安装版**(例如装在 `D:\class\@classhelperdesktop-client`),只更新 release 而不同步安装目录, > 若学生机上是**安装版**(例如装在 `D:\class\@classhelperdesktop-client`),只更新 release 而不同步安装目录,
> 用户打开的还是旧 `resources/app.asar`,会出现「改了但界面不生效」。覆盖升级(用户配置在 `%APPDATA%`,不受影响): > 用户打开的还是旧 `resources/app.asar`,会出现「改了但界面不生效」。覆盖升级(用户配置在 `%APPDATA%`,不受影响):
> >
@@ -1254,16 +1270,16 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS
| 产物 | 大小 | 冒烟结果 | | 产物 | 大小 | 冒烟结果 |
| -------------------------------------- | ------ | -------------------------------------------------------------------- | | -------------------------------------- | ------ | -------------------------------------------------------------------- |
| 服务端安装程序(内置 Node + Web 端) | ~62MB | ✅ 静默安装(升级保留 `.env` 与数据库)→ 自动建库建号 → 服务就绪 | | 服务端安装程序(内置 Node + Web 端) | ~62MB | ✅ 静默安装(升级保留 `.env` 与数据库)→ 自动建库建号 → 服务就绪 |
| `release/win-unpacked/班级小助手.exe` | ~235MB | ✅ 打包版冒烟全绿,`packaged: true`,退出码 0 | | `releases/client/<版本>/免安装/班级小助手.exe` | ~235MB | ✅ 打包版冒烟全绿,`packaged: true`,退出码 0 |
| `release/…-x64-portable.exe`(单文件) | ~98MB | ✅ 打包后冒烟全绿,`packaged: true`,退出码 0 | | `releases/client/<版本>/安装包/…-portable.exe` | ~98MB | ✅ 打包后冒烟全绿,`packaged: true`,退出码 0 |
| `release/…-x64-setup.exe`(客户端) | ~98MB | ✅ 构建成功,已嵌入自定义图标 | | `releases/client/<版本>/安装包/…-setup.exe` | ~98MB | ✅ 构建成功,已嵌入自定义图标 |
| `releases/classisland-plugin/….cipx` | ~88KB | ✅ 全量编译通过(1 警告 0 错误),包内 DLL 哈希与 `bin/Release` 一致 | | `releases/classisland-plugin/<版本>/…cipx` | ~88KB | ✅ 全量编译通过(1 警告 0 错误),包内 DLL 哈希与 `bin/Release` 一致 |
### ClassIsland 插件 ### ClassIsland 插件
```bash ```bash
pnpm build:classisland-plugin # 编译(.NET 8,缓存与临时目录自动指到仓库内 .cache/) pnpm build:classisland-plugin # 编译(.NET 8,缓存与临时目录自动指到仓库内 .cache/)
pnpm dist:classisland-plugin # 打包 .cipx 并归集到 releases/classisland-plugin/ pnpm dist:classisland-plugin # 打包 .cipx 并归集到 releases/classisland-plugin/<版本>/
pnpm verify:classisland-plugin # 静态契约校验(不需要 .NET,不需要服务端) pnpm verify:classisland-plugin # 静态契约校验(不需要 .NET,不需要服务端)
``` ```
+1 -1
View File
@@ -7,7 +7,7 @@
SERVER_PORT=4000 SERVER_PORT=4000
# 应用版本(镜像标签) # 应用版本(镜像标签)
APP_VERSION=1.1.0 APP_VERSION=1.1.1
# MySQL 配置 # MySQL 配置
MYSQL_ROOT_PASSWORD=请改成强密码_root MYSQL_ROOT_PASSWORD=请改成强密码_root
+1 -1
View File
@@ -8,7 +8,7 @@
SERVER_PORT=4000 SERVER_PORT=4000
# 应用版本(镜像标签) # 应用版本(镜像标签)
APP_VERSION=1.1.0 APP_VERSION=1.1.1
# JWT 签名密钥(必填,必须 ≥32 位;不能包含 secret / change-me 等示例词) # JWT 签名密钥(必填,必须 ≥32 位;不能包含 secret / change-me 等示例词)
# 生成:node -e "console.log(require('crypto').randomBytes(48).toString('hex'))" # 生成:node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
+53 -15
View File
@@ -30,7 +30,8 @@ DEFAULT_LOG_DIR="/var/log/classhelper"
DEFAULT_BACKUP_DIR="/var/backups/classhelper" DEFAULT_BACKUP_DIR="/var/backups/classhelper"
REPO_SLUG="LoveChengke/ClassHelper" REPO_SLUG="LoveChengke/ClassHelper"
GITHUB_API="https://api.github.com/repos/${REPO_SLUG}" GITHUB_API="https://api.github.com/repos/${REPO_SLUG}"
GITHUB_RELEASES_PAGE="https://github.com/${REPO_SLUG}/releases" GITHUB_REPO_URL="https://github.com/${REPO_SLUG}"
GITHUB_RELEASES_PAGE="${GITHUB_REPO_URL}/releases"
# GitHub 对没有 User-Agent 的请求直接 403 # GitHub 对没有 User-Agent 的请求直接 403
HTTP_UA="ClassHelper-Ops" HTTP_UA="ClassHelper-Ops"
CURL_OPTS=(-fsSL --max-time 20 -H "User-Agent: ${HTTP_UA}") CURL_OPTS=(-fsSL --max-time 20 -H "User-Agent: ${HTTP_UA}")
@@ -48,6 +49,23 @@ fi
log() { printf '%s[classhelper]%s %s\n' "$C_GREEN" "$C_RESET" "$*"; } log() { printf '%s[classhelper]%s %s\n' "$C_GREEN" "$C_RESET" "$*"; }
info() { printf '%s[classhelper]%s %s\n' "$C_CYAN" "$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; } warn() { printf '%s[classhelper]%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2; }
# 给"正在交互输入"的那几行提示用的输出口:
# 优先直接写 /dev/tty —— 交互提示语是往 fd 3(/dev/tty)写的,警告也从同一个设备出去,
# 才能保证"警告紧挨着它对应的那次输入"、不会被 stdout/stderr 的缓冲或分流弄丢/错位
# (read_secret 结束时会把 fd 3 关掉,所以这里不用 fd 3,而是重新开 /dev/tty 写)。
# 没有终端时退回 stderr。整句包在 if 条件里,写失败也不会因 set -e 打断命令。
warn_user() {
# `{ …; } 2>/dev/null` 这个形状是必需的:/dev/tty 打不开时(没有控制终端)bash 会先自己打一句
# "bash: /dev/tty: No such device or address",而 `… >/dev/tty 2>/dev/null` 这种写法压不住它
# —— 重定向是从左往右处理的,报错发生在 `2>/dev/null` 生效之前。把整句包进一个 stderr
# 已经被接管的组里,那行噪音才会被吃掉,调用方也仍然走得到下面的 stderr 回退。
if [ "${PROMPT_FD:-}" = "3" ] &&
{ printf '%s[classhelper]%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >/dev/tty; } 2>/dev/null; then
return 0
fi
warn "$*"
}
die() { printf '%s[classhelper]%s %s\n' "$C_RED" "$C_RESET" "$*" >&2; exit 1; } die() { printf '%s[classhelper]%s %s\n' "$C_RED" "$C_RESET" "$*" >&2; exit 1; }
# ---------------------------------------------------------------- 2. 定位自身与安装元数据 # ---------------------------------------------------------------- 2. 定位自身与安装元数据
@@ -747,8 +765,10 @@ cmd_doctor() {
"这是交叉构建的包($(tr -d '\n' <"${INSTALL_DIR}/.cross-built" 2>/dev/null)):Web 端「数据库一键切换」可能失败,其余功能正常;正式部署请用 GitHub Actions 出的包" "这是交叉构建的包($(tr -d '\n' <"${INSTALL_DIR}/.cross-built" 2>/dev/null)):Web 端「数据库一键切换」可能失败,其余功能正常;正式部署请用 GitHub Actions 出的包"
fi fi
local avail; avail="$(df -Pk "$INSTALL_DIR" 2>/dev/null | awk 'NR==2 {print $4}')" local avail_kb; avail_kb="$(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" case "$avail_kb" in ''|*[!0-9]*) avail_kb=0 ;; esac
# df -Pk 给的是 1K 块,human_size 收的是字节 —— 不乘 1024 会把 25 GB 显示成 25 MB
doctor_check "磁盘剩余 ≥1GB($(human_size "$((avail_kb * 1024))"))" "$([ "$avail_kb" -ge 1048576 ] && echo 0 || echo 1)" "清理旧备份:classhelper backup list"
local newest="" backup_ok=1 local newest="" backup_ok=1
newest="$(ls -1t "$BACKUP_DIR" 2>/dev/null | grep -E '^[0-9]{8}-[0-9]{6}' | head -1)" newest="$(ls -1t "$BACKUP_DIR" 2>/dev/null | grep -E '^[0-9]{8}-[0-9]{6}' | head -1)"
@@ -778,23 +798,32 @@ cmd_doctor() {
# 也不进 shell history。 # 也不进 shell history。
open_tty() { open_tty() {
[ "${CLASSHELPER_NO_TTY:-0}" = "1" ] && return 1 [ "${CLASSHELPER_NO_TTY:-0}" = "1" ] && return 1
exec 3</dev/tty 2>/dev/null # 必须用 `<>`(读写):read_secret 既要往 fd 3 写提示语、又要从它读输入,
# 而只读打开的 fd 一写就 EBADF("write error: Bad file descriptor")——
# 在 set -e 下会让整个命令当场退出。install.sh 里踩过同一个坑(见那边的注释)。
exec 3<>/dev/tty 2>/dev/null
} }
read_secret() { # $1=提示语 $2=目标变量名;读到 EOF 时把 READ_SECRET_EOF 置 1 read_secret() { # $1=提示语 $2=目标变量名;读到 EOF 时把 READ_SECRET_EOF 置 1
local prompt="$1" var="$2" value='' local prompt="$1" var="$2" value=''
READ_SECRET_EOF=0 READ_SECRET_EOF=0
if open_tty; then if open_tty; then
printf '%s' "$prompt" >&3 # 提示语写不进去(fd 3 不可写)时按"读不到输入"处理,交给调用方报错退出;
# 千万别让这行失败把命令打死 —— set -e 下它会让 `classhelper password` 毫无提示地退出。
if printf '%s' "$prompt" >&3 2>/dev/null; then
if ! IFS= read -r -s value <&3; then READ_SECRET_EOF=1; fi if ! IFS= read -r -s value <&3; then READ_SECRET_EOF=1; fi
printf '\n' >&3 printf '\n' >&3 2>/dev/null || true
exec 3<&- || true else
READ_SECRET_EOF=1
fi
exec 3<&- 2>/dev/null || true
else else
printf '%s' "$prompt" >&2 printf '%s' "$prompt" >&2
if ! IFS= read -r -s value; then READ_SECRET_EOF=1; fi if ! IFS= read -r -s value; then READ_SECRET_EOF=1; fi
printf '\n' >&2 printf '\n' >&2
fi fi
printf -v "$var" '%s' "$value" printf -v "$var" '%s' "$value"
return 0
} }
# 与服务端 tools/admin-cli.mjs 同一套强度规则(两处口径必须一致) # 与服务端 tools/admin-cli.mjs 同一套强度规则(两处口径必须一致)
@@ -860,36 +889,43 @@ cmd_password() {
fi fi
printf '%s现在修改 %s 的密码%s\n' "$C_BOLD" "$label" "$C_RESET" printf '%s现在修改 %s 的密码%s\n' "$C_BOLD" "$label" "$C_RESET"
printf '%s提示:输入不回显;密码不会出现在命令行、进程列表或日志里。%s\n\n' "$C_DIM" "$C_RESET" # 要求提前说清楚:原来是"先让人撞墙、再看报错才知道规则",用户得失败一两次才明白
# (而且 read -s 不回显,屏幕上什么都没有,更容易以为卡住了)
printf '%s密码要求:至少 8 位;小写字母 / 大写字母 / 数字 / 符号 中至少包含 3 类;不能与用户名相同;\n' "$C_DIM"
printf ' 也不要用 admin / 123456 / password 这类开头(例如 RainYun-2026 就合规)。%s\n' "$C_RESET"
printf '%s提示:输入不回显 —— 敲键盘时屏幕上不会出现任何字符(连星号都没有),这是正常的,输完直接回车。%s\n\n' "$C_DIM" "$C_RESET"
# 有界循环:每次尝试都计数,且 stdin 读完(EOF)立刻退出 —— # 有界循环:每次尝试都计数,且 stdin 读完(EOF)立刻退出 ——
# 否则管道喂入不合规的密码时会一直重读空值,表现为"卡住不动"。 # 否则管道喂入不合规的密码时会一直重读空值,表现为"卡住不动"。
local pw1='' pw2='' problems='' tries=0 accepted=0 local pw1='' pw2='' problems='' tries=0 accepted=0
while [ "$tries" -lt 3 ] && [ "$accepted" = "0" ]; do while [ "$tries" -lt 3 ] && [ "$accepted" = "0" ]; do
tries=$((tries + 1)) tries=$((tries + 1))
read_secret "新密码:" pw1 read_secret "新密码(第 ${tries}/3 次):" pw1
if [ -z "$pw1" ]; then if [ -z "$pw1" ]; then
if [ "${READ_SECRET_EOF:-0}" = "1" ]; then if [ "${READ_SECRET_EOF:-0}" = "1" ]; then
die "读不到输入(标准输入已结束)。自动化请用管道:printf '%s\\n%s\\n' \"\$PW\" \"\$PW\" | classhelper password ${key}" die "读不到输入(标准输入已结束)。自动化请用管道:
printf '%s\\n%s\\n' \"\$PW\" \"\$PW\" | CLASSHELPER_NO_TTY=1 classhelper password ${key}"
fi fi
warn "密码不能为空,请重试" warn_user "密码不能为空,请重试"
continue continue
fi fi
problems="$(password_problems "$pw1" "${target:-admin}")" problems="$(password_problems "$pw1" "${target:-admin}")"
if [ -n "$problems" ]; then if [ -n "$problems" ]; then
warn "密码不合规:${problems}" warn_user "密码不合规:${problems}"
continue continue
fi fi
read_secret "再输入一次确认:" pw2 read_secret "再输入一次确认:" pw2
if [ "$pw1" != "$pw2" ]; then if [ "$pw1" != "$pw2" ]; then
warn "两次输入不一致,请重来" warn_user "两次输入不一致,请重来"
continue continue
fi fi
accepted=1 accepted=1
done done
if [ "$accepted" != "1" ]; then if [ "$accepted" != "1" ]; then
audit password "$key" rejected audit password "$key" rejected
die "连续 3 次输入不合规,已退出(密码未改动)" warn_user "连续 3 次都没通过校验,已退出(密码未改动)"
die "如需非交互改密,用:
read -r -s -p '新密码: ' PW; echo; printf '%s\\n%s\\n' \"\$PW\" \"\$PW\" | CLASSHELPER_NO_TTY=1 classhelper password ${key}; unset PW"
fi fi
# 改前备份原哈希:失败或复核不过时按这份文件回滚(只含用户名 + bcrypt 哈希,权限 600)。 # 改前备份原哈希:失败或复核不过时按这份文件回滚(只含用户名 + bcrypt 哈希,权限 600)。
@@ -1104,8 +1140,10 @@ resolve_download() { # $1=版本(可空) $2=url(可空);输出下载地址
if [ -n "$want" ]; then tag="v${want#v}"; else tag="$(github_latest_tag)" || true; fi if [ -n "$want" ]; then tag="v${want#v}"; else tag="$(github_latest_tag)" || true; fi
[ -n "$tag" ] || return 1 [ -n "$tag" ] || return 1
ver="${tag#v}" ver="${tag#v}"
# 用 GITHUB_REPO_URL(不含 /releases)拼接:GITHUB_RELEASES_PAGE 已以 /releases 结尾,
# 拼错就成了 .../releases/releases/download/... 必然 404(install.sh 里踩过同一个坑)。
printf '%s/releases/download/%s/classhelper-server-linux-%s-%s.tar.gz' \ printf '%s/releases/download/%s/classhelper-server-linux-%s-%s.tar.gz' \
"$GITHUB_RELEASES_PAGE" "$tag" "$(asset_arch)" "$ver" "$GITHUB_REPO_URL" "$tag" "$(asset_arch)" "$ver"
} }
download_to() { # $1=url $2=目标文件 download_to() { # $1=url $2=目标文件
+1 -1
View File
@@ -23,7 +23,7 @@ services:
context: .. context: ..
dockerfile: deploy/Dockerfile dockerfile: deploy/Dockerfile
target: runtime target: runtime
image: classhelper-server:${APP_VERSION:-1.1.0} image: classhelper-server:${APP_VERSION:-1.1.1}
restart: unless-stopped restart: unless-stopped
environment: environment:
NODE_ENV: production NODE_ENV: production
+1 -1
View File
@@ -60,7 +60,7 @@ services:
context: .. context: ..
dockerfile: deploy/Dockerfile dockerfile: deploy/Dockerfile
target: runtime target: runtime
image: classhelper-server:${APP_VERSION:-1.1.0} image: classhelper-server:${APP_VERSION:-1.1.1}
restart: unless-stopped restart: unless-stopped
environment: environment:
NODE_ENV: production NODE_ENV: production
+202 -28
View File
@@ -9,7 +9,23 @@
# native(默认)—— 装成 systemd 服务,自带 Node 运行时,SQLite 或 MySQL # native(默认)—— 装成 systemd 服务,自带 Node 运行时,SQLite 或 MySQL
# docker —— 用随包的 Dockerfile 构建镜像 + compose 起容器(需要 docker) # docker —— 用随包的 Dockerfile 构建镜像 + compose 起容器(需要 docker)
# #
# 用法: # 用法(一行安装,推荐给最终用户):
# curl -fsSL -o /tmp/classhelper-install.sh \
# https://raw.githubusercontent.com/LoveChengke/ClassHelper/master/deploy/install.sh \
# && sudo bash /tmp/classhelper-install.sh
#
# 脚本会自动从 GitHub Release 取**最新版**服务端安装包,并取发布页上同名的 .sha256 做校验,
# 所以整条链路只需要这一行。要加参数就接在最后:`sudo bash /tmp/classhelper-install.sh --check`
# --check 只体检、不安装(不需要 root) --yes 全部默认、不交互
# --version 1.1.1 装指定版本 --port 8080 自定义端口
# --admin-password-stdin 从 stdin 读初始管理员密码
#
# ⚠ 别写成 `bash -c "$(curl …)" --check`:`bash -c '代码' 第一个参数` 里那个参数会变成 **$0**、
# 不是 $1,脚本根本收不到它(实测踩到:想着体检、结果走进交互式安装)。先落盘再执行没这个坑。
# 内网/离线:把 tar.gz 与 install.sh 一起拷进去,再
# sudo bash install.sh --package /path/to/classhelper-server-linux-x64-<版本>.tar.gz
#
# 本地已有的脚本用法:
# sudo bash install.sh # 交互式(TUI 菜单 + 问答) # sudo bash install.sh # 交互式(TUI 菜单 + 问答)
# sudo bash install.sh --check # 只体检(不需要 root,不改动任何东西) # sudo bash install.sh --check # 只体检(不需要 root,不改动任何东西)
# sudo bash install.sh --uninstall # 卸载(会问保留什么) # sudo bash install.sh --uninstall # 卸载(会问保留什么)
@@ -59,7 +75,8 @@ MYSQL_PASSWORD=""
# 服务端安装包(默认取 GitHub Release 上的 Linux 资产) # 服务端安装包(默认取 GitHub Release 上的 Linux 资产)
REPO_SLUG="LoveChengke/ClassHelper" REPO_SLUG="LoveChengke/ClassHelper"
GITHUB_RELEASES_PAGE="https://github.com/${REPO_SLUG}/releases" GITHUB_REPO_URL="https://github.com/${REPO_SLUG}"
GITHUB_RELEASES_PAGE="${GITHUB_REPO_URL}/releases"
PACKAGE_URL="" PACKAGE_URL=""
PACKAGE_SHA256="" PACKAGE_SHA256=""
VERSION="" VERSION=""
@@ -83,8 +100,20 @@ INSTALL_DOCKER=0
FORCE=0 FORCE=0
LOG_FILE="${LOG_DIR}/install.log" LOG_FILE="${LOG_DIR}/install.log"
SCRIPT_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
SCRIPT_DIR="$(dirname "$SCRIPT_PATH")" # 脚本自身路径。两种"一行脚本安装"方式下这里会不一样,必须都能活:
# bash -c "$(curl -fsSL .../install.sh)" → 没有脚本文件(BASH_SOURCE 为空)
# curl -fsSL .../install.sh | bash → 脚本内容是 stdin,同样没有文件
# SCRIPT_DIR 只用来找与脚本同目录的模板(classhelper.service / nginx.conf),
# 从远端执行时找不到也不要紧 —— 安装包里带了同样的模板(见 place_program_files)。
SELF_PATH="${BASH_SOURCE[0]:-}"
if [ -n "$SELF_PATH" ] && [ -f "$SELF_PATH" ]; then
SCRIPT_PATH="$(cd "$(dirname "$SELF_PATH")" && pwd)/$(basename "$SELF_PATH")"
SCRIPT_DIR="$(dirname "$SCRIPT_PATH")"
else
SCRIPT_PATH=""
SCRIPT_DIR=""
fi
# ---------------------------------------------------------------- 输出 # ---------------------------------------------------------------- 输出
@@ -99,12 +128,29 @@ log() { printf '%s[classhelper]%s %s\n' "$C_GREEN" "$C_RESET" "$*"; }
info() { printf '%s[classhelper]%s %s\n' "$C_CYAN" "$C_RESET" "$*"; } info() { printf '%s[classhelper]%s %s\n' "$C_CYAN" "$C_RESET" "$*"; }
step() { printf '\n%s▶ %s%s\n' "$C_MAGENTA" "$*" "$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; } 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; }
# 真正的 stderr 先留一份到 fd 9。main() 里会用 `exec > >(tee -a 安装日志)` 接管 stdout/stderr,
# 而脚本退出时那个进程替换的子进程**可能来不及把最后几行写出去** —— 后果是致命错误被彻底吞掉,
# 用户只看到脚本默默退回 shell、一条提示都没有(2026-10-05 实测踩到,排查成本很高)。
# 所以 die / on_error 绕开 tee:直接写 fd 9 + 直接追加日志文件。
exec 9>&2
fail_print() { # $1=要同时显示到终端与日志的错误文本
printf '%s\n' "$1" >&9 2>/dev/null || printf '%s\n' "$1" >&2
# 日志目录还没建起来时(比如卡在 root 检查)不要去追加,否则 bash 会额外吐一句
# "install.log: No such file or directory",把真正的原因淹掉
if [ -n "${LOG_FILE:-}" ] && [ -d "$(dirname "$LOG_FILE")" ]; then
printf '[%s] %s\n' "$(date '+%F %T')" "$1" >>"$LOG_FILE" 2>/dev/null || true
fi
return 0
}
die() { trap - ERR; fail_print "$(printf '%s[classhelper]%s %s' "$C_RED" "$C_RESET" "$*")"; exit 1; }
on_error() { on_error() {
local code=$? local code=$?
printf '\n%s[classhelper]%s 安装中断(第 %s 行,退出码 %s)\n' "$C_RED" "$C_RESET" "$1" "$code" >&2 fail_print "$(printf '\n%s[classhelper]%s 安装中断(第 %s 行,退出码 %s)' "$C_RED" "$C_RESET" "$1" "$code")"
if [ -f "$LOG_FILE" ]; then printf '完整日志:%s\n' "$LOG_FILE" >&2; fi [ -f "${LOG_FILE:-}" ] && fail_print "完整日志:${LOG_FILE}"
exit "$code" exit "$code"
} }
trap 'on_error "$LINENO"' ERR trap 'on_error "$LINENO"' ERR
@@ -123,11 +169,39 @@ ASCII
printf '%s 一键安装 + classhelper 运维命令(status / password / upgrade / backup …)%s\n\n' "$C_DIM" "$C_RESET" printf '%s 一键安装 + classhelper 运维命令(status / password / upgrade / backup …)%s\n\n' "$C_DIM" "$C_RESET"
} }
# 打印文件头部注释里的用法(第一个分隔线之后、第二个分隔线之前) # 用法:能读到脚本文件时直接打印文件头部注释(唯一真身,不会漂);从远端执行时没有文件可读,
# 退化成下面这份精简参数表。
usage() { usage() {
if [ -n "$SCRIPT_PATH" ] && [ -r "$SCRIPT_PATH" ]; then
awk 'NR == 1 { next } awk 'NR == 1 { next }
/^# -{10,}/ { seen++; if (seen == 2) exit; next } /^# -{10,}/ { seen++; if (seen == 2) exit; next }
seen == 1 { sub(/^# ?/, ""); print }' "$SCRIPT_PATH" seen == 1 { sub(/^# ?/, ""); print }' "$SCRIPT_PATH"
return 0
fi
cat <<'USAGE'
班级小助手 · 服务端 Linux 安装器
(本次是从远端直接执行的,没有本地脚本文件可读,这里只列常用参数;
完整说明见 https://github.com/LoveChengke/ClassHelper/blob/master/docs/linux-deploy.md )
--mode native|docker 安装形态(默认 native:systemd + 内置 Node)
--dir /opt/classhelper 安装目录 --port 4000 服务端口
--database sqlite|mysql 数据库(默认 sqlite)
--mysql-host/-port/-db/-user/-password-stdin MySQL 连接信息
--admin-password-stdin 从 stdin 读一行作为初始管理员密码
--package <url|file://路径> 服务端安装包(默认从 GitHub Release 取最新版)
--sha256 <哈希> 安装包校验值(不给时自动取发布页上的同名 .sha256)
--version <X.Y.Z> 指定安装的版本
--node-source bundled|system|tarball Node 运行时来源(默认 bundled)
--node-mirror <url> / --node-tarball <url|路径> Node 下载镜像 / 离线包
--with-nginx 顺带放一份 Nginx 反代配置
--open-firewall 按检测到的防火墙放行端口
--install-docker Docker 形态缺少 docker 时自动安装
--yes 全部用默认值,不再交互(CI / 验收用)
--force 覆盖已有配置;无 systemd 时只铺文件不装服务
--check 只体检(不需要 root,不改动系统)
--uninstall 卸载
USAGE
} }
# ---------------------------------------------------------------- 参数解析 # ---------------------------------------------------------------- 参数解析
@@ -181,6 +255,10 @@ parse_args() {
OS_ID=""; OS_VER=""; OS_NAME=""; PKG="" OS_ID=""; OS_VER=""; OS_NAME=""; PKG=""
detect_os() { detect_os() {
# /etc/os-release 里也有 VERSION(Ubuntu 是 "24.04.1 LTS (Noble Numbat)"),直接 source 会盖掉
# 安装包版本号:.installed.json 会记成发行版名,而且不带 --package 时 resolve_package_url
# 会拿它当 Release tag 去拼下载地址(必然 404)。
local keep_version="${VERSION:-}"
if [ -r /etc/os-release ]; then if [ -r /etc/os-release ]; then
# shellcheck disable=SC1091 # shellcheck disable=SC1091
. /etc/os-release . /etc/os-release
@@ -190,6 +268,7 @@ detect_os() {
else else
OS_NAME="$(uname -s) $(uname -r)" OS_NAME="$(uname -s) $(uname -r)"
fi fi
VERSION="$keep_version"
if command -v apt-get >/dev/null 2>&1; then PKG="apt" 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 dnf >/dev/null 2>&1; then PKG="dnf"
elif command -v yum >/dev/null 2>&1; then PKG="yum" elif command -v yum >/dev/null 2>&1; then PKG="yum"
@@ -258,16 +337,66 @@ config_value() { # $1=KEY:从已有配置里读一项(覆盖安装时沿用
# ---------------------------------------------------------------- 交互问答 # ---------------------------------------------------------------- 交互问答
# 交互输入的统一出口。
#
# 为什么不能直接用 `read`:一行脚本安装的两种写法里,stdin 未必是终端 ——
# curl -fsSL .../install.sh | bash → **stdin 就是脚本内容本身**,read 会把脚本的下一行吃掉,
# 表现为"问题全被跳过 + 后续脚本乱套"
# bash -c "$(curl -fsSL .../install.sh)" → stdin 仍是终端,正常
# 所以统一优先读 /dev/tty,退回 stdin;两者都不行(CI、定时任务)才按"非交互"处理。
PROMPT_FD=""
open_prompt_fd() {
if [ -n "$PROMPT_FD" ]; then return 0; fi
if [ "${CLASSHELPER_NO_TTY:-0}" = "1" ]; then return 1; fi
# 必须用 `<>`(读写)打开,不能只用 `<`(只读):下面 read_prompt 要往 fd 3 **写**提示语,
# 而往只读 fd 写会 EBADF("printf: write error: Bad file descriptor"),在 set -e 下
# 直接把安装脚本打死 —— 现象是"菜单打印完就默默退回 shell",错误还被 tee 吞掉,极难查。
# 2026-10-05 在 Ubuntu 24.04 上实测踩到并定位。
if exec 3<>/dev/tty 2>/dev/null; then PROMPT_FD=3; return 0; fi
return 1
}
# $1=提示语 $2=目标变量名 $3=1 表示隐藏输入(密码)
#
# 注意:**这个函数永远返回 0**。它一旦返回非 0,在 `set -e` 下会把整个安装脚本当场终止,
# 而调用处(`read_prompt "…" choice`)是独立命令、不会去接这个返回值 —— 实测后果是
# "菜单打印完就默默退回 shell,一条错误信息都没有",极难排查。
# 读不到终端时就把变量留空,由调用方按默认值走。
read_prompt() {
local prompt="$1" var="$2" hidden="${3:-0}" value=""
printf -v "$var" ''
if open_prompt_fd; then
# 提示语写不进去时**不能让它把脚本打死**:退化成"这一项用默认值",并且把这件事说出来
if printf '%s' "$prompt" >&3 2>/dev/null; then
if [ "$hidden" = "1" ]; then IFS= read -r -s value <&3 || true; else IFS= read -r value <&3 || true; fi
printf '\n' >&3 2>/dev/null || true
else
fail_print "$(printf '%s[classhelper]%s 写不进终端(fd 3 不可写),这一项按默认值处理' "$C_YELLOW" "$C_RESET")"
fi
fi
# 刻意**不**退回 stdin:`curl … | bash` 时 stdin 就是脚本内容本身,
# 从 stdin 读会把脚本的下一行当成用户输入吃掉,表现为"脚本后半段莫名其妙乱套"。
printf -v "$var" '%s' "$value"
return 0
}
# 能不能问用户:--yes 不问;没有终端可读也不问(这时一律用默认值)
can_prompt() {
[ "$ASSUME_YES" = "1" ] && return 1
open_prompt_fd
}
ask() { # $1=提示 $2=默认值 $3=目标变量 ask() { # $1=提示 $2=默认值 $3=目标变量
local prompt="$1" default="$2" var="$3" answer="" local prompt="$1" default="$2" var="$3" answer=""
if [ "$ASSUME_YES" = "1" ] || [ ! -t 0 ]; then if ! can_prompt; then
printf -v "$var" '%s' "$default" printf -v "$var" '%s' "$default"
return 0 return 0
fi fi
if [ -n "$default" ]; then if [ -n "$default" ]; then
read -r -p "$(printf '%s[?]%s %s [%s]: ' "$C_CYAN" "$C_RESET" "$prompt" "$default")" answer || true read_prompt "$(printf '%s[?]%s %s [%s]: ' "$C_CYAN" "$C_RESET" "$prompt" "$default")" answer
else else
read -r -p "$(printf '%s[?]%s %s: ' "$C_CYAN" "$C_RESET" "$prompt")" answer || true read_prompt "$(printf '%s[?]%s %s: ' "$C_CYAN" "$C_RESET" "$prompt")" answer
fi fi
printf -v "$var" '%s' "${answer:-$default}" printf -v "$var" '%s' "${answer:-$default}"
} }
@@ -275,24 +404,33 @@ ask() { # $1=提示 $2=默认值 $3=目标变量
ask_secret() { # $1=提示 $2=目标变量(不回显) ask_secret() { # $1=提示 $2=目标变量(不回显)
local prompt="$1" var="$2" value="" local prompt="$1" var="$2" value=""
printf -v "$var" '' printf -v "$var" ''
if [ "$ASSUME_YES" = "1" ] || [ ! -t 0 ]; then return 0; fi if ! can_prompt; then return 0; fi
read -r -s -p "$(printf '%s[?]%s %s' "$C_CYAN" "$C_RESET" "$prompt")" value || true read_prompt "$(printf '%s[?]%s %s' "$C_CYAN" "$C_RESET" "$prompt")" value 1
printf '\n'
printf -v "$var" '%s' "$value" printf -v "$var" '%s' "$value"
} }
ask_yes_no() { # $1=提示 $2=默认 y/n;返回 0=是 ask_yes_no() { # $1=提示 $2=默认 y/n;返回 0=是
local prompt="$1" default="$2" answer="" local prompt="$1" default="$2" answer=""
if [ "$ASSUME_YES" = "1" ] || [ ! -t 0 ]; then if ! can_prompt; then
[ "$default" = "y" ] [ "$default" = "y" ]
return return
fi fi
read -r -p "$(printf '%s[?]%s %s [%s]: ' "$C_CYAN" "$C_RESET" "$prompt" "$default")" answer || true read_prompt "$(printf '%s[?]%s %s [%s]: ' "$C_CYAN" "$C_RESET" "$prompt" "$default")" answer
case "${answer:-$default}" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac case "${answer:-$default}" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac
} }
choose_mode_interactive() { choose_mode_interactive() {
if [ "$ASSUME_YES" = "1" ] || [ ! -t 0 ]; then return 0; fi if [ "$ASSUME_YES" = "1" ]; then return 0; fi
# 交互式安装却读不到终端时**明确报错**,而不是默默按默认值装下去:
# 后者会让用户以为自己在选,其实一个都没问到(实测踩到过)。
if ! can_prompt; then
die "读不到终端(/dev/tty 打不开),没法交互式选择安装形态。
· 确实想非交互安装:加 --yes(默认 native + SQLite + ${INSTALL_DIR} + 端口 ${PORT})
· 或者先把脚本存成文件,再在同一终端里执行:
curl -fsSL -o /tmp/classhelper-install.sh https://raw.githubusercontent.com/LoveChengke/ClassHelper/master/deploy/install.sh
sudo bash /tmp/classhelper-install.sh
· 只想先体检(不需要终端、也不需要 root):加 --check"
fi
printf '%s请选择:%s\n' "$C_BOLD" "$C_RESET" printf '%s请选择:%s\n' "$C_BOLD" "$C_RESET"
printf ' 1) 直接安装(systemd 服务 + 内置 Node 运行时,默认 SQLite)%s ← 推荐%s\n' "$C_GREEN" "$C_RESET" printf ' 1) 直接安装(systemd 服务 + 内置 Node 运行时,默认 SQLite)%s ← 推荐%s\n' "$C_GREEN" "$C_RESET"
printf ' 2) 直接安装 + MySQL\n' printf ' 2) 直接安装 + MySQL\n'
@@ -300,7 +438,7 @@ choose_mode_interactive() {
printf ' 4) 只体检(--check,不改动系统)\n' printf ' 4) 只体检(--check,不改动系统)\n'
printf ' 5) 卸载\n' printf ' 5) 卸载\n'
local choice="" local choice=""
read -r -p "$(printf '%s[?]%s 输入序号 [1]: ' "$C_CYAN" "$C_RESET")" choice || true read_prompt "$(printf '%s[?]%s 输入序号 [1]: ' "$C_CYAN" "$C_RESET")" choice
case "${choice:-1}" in case "${choice:-1}" in
1) MODE="native"; DB_KIND="sqlite" ;; 1) MODE="native"; DB_KIND="sqlite" ;;
2) MODE="native"; DB_KIND="mysql" ;; 2) MODE="native"; DB_KIND="mysql" ;;
@@ -391,12 +529,14 @@ resolve_package_url() {
tag="$(github_latest_tag)" || true tag="$(github_latest_tag)" || true
if [ -z "$tag" ]; then if [ -z "$tag" ]; then
die "取不到 GitHub 最新版本(本机可能没有外网)。请用 --package 指定安装包,例如: die "取不到 GitHub 最新版本(本机可能没有外网)。请用 --package 指定安装包,例如:
sudo bash install.sh --package /tmp/classhelper-server-linux-x64-1.1.0.tar.gz" sudo bash install.sh --package /tmp/classhelper-server-linux-x64-1.1.1.tar.gz"
fi fi
fi fi
ver="${tag#v}" ver="${tag#v}"
# 注意这里用 GITHUB_REPO_URL 而不是 GITHUB_RELEASES_PAGE:后者本身已经以 /releases 结尾,
# 再拼一次就变成 .../releases/releases/download/... (必然 404,实测踩到)。
printf '%s/releases/download/%s/classhelper-server-linux-%s-%s.tar.gz' \ printf '%s/releases/download/%s/classhelper-server-linux-%s-%s.tar.gz' \
"$GITHUB_RELEASES_PAGE" "$tag" "$(asset_arch)" "$ver" "$GITHUB_REPO_URL" "$tag" "$(asset_arch)" "$ver"
} }
# 下载 + 校验 + 解压;结果放进全局 STAGE_DIR(不用命令替换取,否则会把日志一起捕获) # 下载 + 校验 + 解压;结果放进全局 STAGE_DIR(不用命令替换取,否则会把日志一起捕获)
@@ -414,13 +554,30 @@ fetch_package() {
esac esac
[ -s "$pkg" ] || die "安装包为空:${url}" [ -s "$pkg" ] || die "安装包为空:${url}"
# 没给 --sha256 时,把发布页上同名的 .sha256 取回来自己校验。
# 这一步是一行脚本安装下的**唯一**校验机会:用户不会自己去核哈希,
# 而少了它,"校验"就只是把算出来的哈希记进日志而已。
if [ -z "$PACKAGE_SHA256" ]; then
local sum_file="${tmp}/expected.sha256"
case "$url" in
file://*|/*|./*|../*) [ -f "${url#file://}.sha256" ] && cp -f "${url#file://}.sha256" "$sum_file" ;;
*) curl -fsSL --max-time 60 -H 'User-Agent: ClassHelper-Installer' -o "$sum_file" "${url}.sha256" 2>/dev/null ;;
esac
if [ -s "$sum_file" ]; then
PACKAGE_SHA256="$(awk '{print $1; exit}' "$sum_file")"
[ -n "$PACKAGE_SHA256" ] && info "已从发布页取到 sha256,安装包会做校验"
fi
fi
local got; got="$(sha256sum "$pkg" | awk '{print $1}')" local got; got="$(sha256sum "$pkg" | awk '{print $1}')"
if [ -n "$PACKAGE_SHA256" ]; then if [ -n "$PACKAGE_SHA256" ]; then
[ "$got" = "$PACKAGE_SHA256" ] || die "sha256 校验失败:期望 ${PACKAGE_SHA256},实际 ${got}" [ "$got" = "$PACKAGE_SHA256" ] || die "sha256 校验失败:期望 ${PACKAGE_SHA256},实际 ${got}
安装包可能在传输中损坏或被篡改,已中止(什么也没改)。可用 --sha256 <正确的哈希> 重试。"
log "sha256 校验通过" log "sha256 校验通过"
else else
PACKAGE_SHA256="$got" PACKAGE_SHA256="$got"
info "sha256(未提供校验值,仅记录):${got}" warn "取不到发布页的 .sha256,本次只记录实际哈希、不做校验:${got}"
warn "(内网分发时请自带 --sha256;或手工核对上面这串哈希)"
fi fi
STAGE_DIR="${tmp}/pkg" STAGE_DIR="${tmp}/pkg"
@@ -1000,15 +1157,34 @@ print_result() {
main() { main() {
parse_args "$@" parse_args "$@"
# `--check` 与菜单里的"4) 只体检"是同一条路:都不需要 root、都不改动系统。
# 菜单必须出现在 root 检查**之前** —— 否则非 root 用户连菜单都看不到,只能靠 --check。
if [ "$CHECK_ONLY" = "1" ]; then if [ "$CHECK_ONLY" = "1" ]; then
banner banner
check_only check_only
exit $? exit $?
fi fi
[ "$(id -u)" = "0" ] || die "请用 root 执行:sudo bash install.sh(只体检不需要 root:bash install.sh --check)"
banner banner
detect_os
info "系统:${OS_NAME:-未知}|包管理器:${PKG:-未知}|架构:$(uname -m)"
choose_mode_interactive
# 菜单里选了 4:到这里才分流。
# (原来这段判断写在菜单**之前**,从菜单选 4 时 CHECK_ONLY 设了也没人看 —— 实测踩到:
# 用户选"只体检",脚本却直接开始安装。)
if [ "$CHECK_ONLY" = "1" ]; then
check_only
exit $?
fi
if [ "$(id -u)" != "0" ]; then
if [ -n "$SCRIPT_PATH" ]; then
die "请用 root 执行:sudo bash ${SCRIPT_PATH}(只体检不需要 root:加 --check)"
fi
die "请用 root 执行:在刚才那条命令前面加 sudo(只体检不需要 root:末尾加 --check)"
fi
mkdir -p "$LOG_DIR" && chmod 700 "$LOG_DIR" mkdir -p "$LOG_DIR" && chmod 700 "$LOG_DIR"
{ {
printf '\n===== %s 安装开始 =====\n' "$(date '+%F %T')" printf '\n===== %s 安装开始 =====\n' "$(date '+%F %T')"
@@ -1017,11 +1193,9 @@ main() {
# 后续输出同时进日志(tee 不占 stdin,交互问答照常) # 后续输出同时进日志(tee 不占 stdin,交互问答照常)
exec > >(tee -a "$LOG_FILE") 2>&1 exec > >(tee -a "$LOG_FILE") 2>&1
detect_os
info "系统:${OS_NAME:-未知}|包管理器:${PKG:-未知}|架构:$(uname -m)"
choose_mode_interactive
collect_answers collect_answers
# 把最终生效的选择打出来:非交互(--yes)或问答被跳过时,这一行是唯一能看出"到底按什么装的"地方
info "本次安装:形态 ${MODE}|目录 ${INSTALL_DIR}|端口 ${PORT}|数据库 ${DB_KIND}|Node ${NODE_SOURCE}"
if [ "$UNINSTALL" = "1" ]; then if [ "$UNINSTALL" = "1" ]; then
do_uninstall --yes do_uninstall --yes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "classhelper-server-runtime", "name": "classhelper-server-runtime",
"version": "1.1.0", "version": "1.1.1",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "班级小助手服务端运行时(安装包 / 容器内置)", "description": "班级小助手服务端运行时(安装包 / 容器内置)",
+9 -9
View File
@@ -4,7 +4,7 @@
| 形态 | 适用场景 | 产物 | 本机验证情况 | | 形态 | 适用场景 | 产物 | 本机验证情况 |
| --------------------- | -------------------------------------------- | -------------------------------------------------------- | -------------------------------------- | | --------------------- | -------------------------------------------- | -------------------------------------------------------- | -------------------------------------- |
| **A. Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | `release-server/班级小助手服务端-<版本>-x64-setup.exe` | ✅ 已完整实测(静默安装/启停/卸载/UI) | | **A. Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | `releases/server/<版本>/安装包/班级小助手服务端-<版本>-x64-setup.exe` | ✅ 已完整实测(静默安装/启停/卸载/UI) |
| **B. Docker + MySQL** | 云服务器、多终端共享一套数据(推荐长期方案) | `deploy/Dockerfile` + `deploy/docker-compose.yml` | ⚠️ 文件已提供,本机无 Docker 未实测 | | **B. Docker + MySQL** | 云服务器、多终端共享一套数据(推荐长期方案) | `deploy/Dockerfile` + `deploy/docker-compose.yml` | ⚠️ 文件已提供,本机无 Docker 未实测 |
| **C. Linux 原生部署** | 已有 Linux 服务器(systemd,SQLite 或 MySQL) | [`deploy/install.sh`](../deploy/install.sh) + `classhelper` 运维命令 | ⚠️ 本机无 Linux,实机验收见 [linux-deploy.md](linux-deploy.md) §10 | | **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 项端到端) | | **D. 手动部署** | 已有 Node 环境的服务器、需要自定义 | `pnpm dist:server` 的免安装目录 | ✅ 免安装目录已实测(163 项端到端) |
@@ -23,9 +23,9 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已安装依赖,跳
产物: 产物:
| 路径 | 说明 | | 路径 | 说明 |
| ----------------------------------------------------- | ----------------------------------------------- | | ----------------------------------------------------------------------- | ----------------------------------------------- |
| `release-server/classhelper-server/` | 免安装目录,可直接拷到任意 Windows x64 机器运行 | | `releases/server/<版本>/免安装/` | 免安装目录,可直接拷到任意 Windows x64 机器运行 |
| `release-server/班级小助手服务端-1.1.0-x64-setup.exe` | 安装程序(约 34 MB,NSIS) | | `releases/server/<版本>/安装包/班级小助手服务端-<版本>-x64-setup.exe` | 安装程序(约 62 MB,NSIS) |
打包内容:内置 **Node 运行时**(目标机无需安装 Node.js)、后端产物、Web 管理端产物、 打包内容:内置 **Node 运行时**(目标机无需安装 Node.js)、后端产物、Web 管理端产物、
生产依赖(真实目录,非软链)、迁移 SQL、`.env`(随机 JWT 密钥)、启停脚本。 生产依赖(真实目录,非软链)、迁移 SQL、`.env`(随机 JWT 密钥)、启停脚本。
@@ -35,7 +35,7 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已安装依赖,跳
双击安装程序即可,**无需管理员权限**(当前用户级安装): 双击安装程序即可,**无需管理员权限**(当前用户级安装):
- 安装目录:`%LOCALAPPDATA%\Programs\ClassHelperServer` - 安装目录:`%LOCALAPPDATA%\Programs\ClassHelperServer`
- 静默安装:`"班级小助手服务端-1.1.0-x64-setup.exe" /S` - 静默安装:`"班级小助手服务端-1.1.1-x64-setup.exe" /S`
- 开始菜单:启动服务 / 停止服务 / 重启服务 / 打开管理端 / 使用说明 / 卸载 - 开始菜单:启动服务 / 停止服务 / 重启服务 / 打开管理端 / 使用说明 / 卸载
- 桌面:`班级小助手服务端.lnk`(打开管理端) - 桌面:`班级小助手服务端.lnk`(打开管理端)
- 自动加入当前用户开机自启(`HKCU\...\CurrentVersion\Run`) - 自动加入当前用户开机自启(`HKCU\...\CurrentVersion\Run`)
@@ -163,7 +163,7 @@ Linux 包由 GitHub Actions 在 ubuntu runner 上构建(平台相关的依赖
```bash ```bash
# 开发机:做出 Linux 包并连同脚本一起拷到服务器 # 开发机:做出 Linux 包并连同脚本一起拷到服务器
pnpm dist:server:linux pnpm dist:server:linux
scp release-server/classhelper-server-linux-x64-*.tar.gz deploy/install.sh deploy/verify-linux.sh \ scp releases/server/*/linux-x64/classhelper-server-linux-x64-*.tar.gz deploy/install.sh deploy/verify-linux.sh \
root@<服务器>:/tmp/ root@<服务器>:/tmp/
# 服务器上 # 服务器上
@@ -176,7 +176,7 @@ sudo bash /tmp/install.sh # 交互式:选形态 → 选目录
```bash ```bash
printf '%s\n' 'StrongPass!2026' | sudo bash /tmp/install.sh --yes \ printf '%s\n' 'StrongPass!2026' | sudo bash /tmp/install.sh --yes \
--dir /srv/classhelper --port 8080 --admin-password-stdin \ --dir /srv/classhelper --port 8080 --admin-password-stdin \
--package /tmp/classhelper-server-linux-x64-1.1.0.tar.gz --package /tmp/classhelper-server-linux-x64-1.1.1.tar.gz
``` ```
安装器做的事:识别发行版与包管理器补依赖 → 建系统用户 `classhelper` → 下载包并校验 sha256 → 安装器做的事:识别发行版与包管理器补依赖 → 建系统用户 `classhelper` → 下载包并校验 sha256 →
@@ -221,7 +221,7 @@ printf '%s\n' 'StrongPass!2026' | sudo bash /tmp/install.sh --yes \
没有 systemd 的容器里可以用安装器的"只铺文件"模式,再自己托管进程: 没有 systemd 的容器里可以用安装器的"只铺文件"模式,再自己托管进程:
```bash ```bash
sudo bash /tmp/install.sh --yes --force --package /tmp/classhelper-server-linux-x64-1.1.0.tar.gz sudo bash /tmp/install.sh --yes --force --package /tmp/classhelper-server-linux-x64-1.1.1.tar.gz
sudo -u classhelper /opt/classhelper/runtime/node/bin/node /opt/classhelper/server/dist/index.js sudo -u classhelper /opt/classhelper/runtime/node/bin/node /opt/classhelper/server/dist/index.js
``` ```
@@ -279,7 +279,7 @@ pnpm install
pnpm build:shared pnpm build:shared
pnpm --filter @classhelper/server build pnpm --filter @classhelper/server build
pnpm --filter @classhelper/web-admin build pnpm --filter @classhelper/web-admin build
pnpm dist:server -- --reuse-deps # 得到 release-server/classhelper-server 免安装目录 pnpm dist:server -- --reuse-deps # 得到 releases/server/<版本>/免安装/ 免安装目录
``` ```
把该目录拷贝到目标机,编辑 `.env`(端口、数据库、密钥、跨域),然后: 把该目录拷贝到目标机,编辑 `.env`(端口、数据库、密钥、跨域),然后:
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "class-helper", "name": "class-helper",
"version": "1.1.0", "version": "1.1.1",
"private": true, "private": true,
"description": "班级小助手 - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)", "description": "班级小助手 - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)",
"packageManager": "pnpm@11.8.0", "packageManager": "pnpm@11.8.0",
+1 -1
View File
@@ -14,6 +14,6 @@ description: |-
4. 可选:把班级小助手上排好的课表镜像回 ClassIsland。 4. 可选:把班级小助手上排好的课表镜像回 ClassIsland。
entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll' entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll'
url: https://github.com/LoveChengke/ClassHelper url: https://github.com/LoveChengke/ClassHelper
version: 1.1.0.0 version: 1.1.1.0
apiVersion: 2.0.0.0 apiVersion: 2.0.0.0
author: 班级小助手 author: 班级小助手
@@ -495,7 +495,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
/* ---------------------------------------------------------------- 内部实现 */ /* ---------------------------------------------------------------- 内部实现 */
/// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary> /// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary>
private const string PluginVersion = "1.1.0.0"; private const string PluginVersion = "1.1.1.0";
/// <summary>ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。</summary> /// <summary>ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。</summary>
private static string SafeAppVersion() private static string SafeAppVersion()
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@classhelper/desktop-client", "name": "@classhelper/desktop-client",
"version": "1.1.0", "version": "1.1.1",
"private": true, "private": true,
"description": "班级小助手 EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)", "description": "班级小助手 EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
"author": "Class Helper", "author": "Class Helper",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@classhelper/server", "name": "@classhelper/server",
"version": "1.1.0", "version": "1.1.1",
"private": true, "private": true,
"description": "班级小助手后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问", "description": "班级小助手后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
"type": "module", "type": "module",
@@ -210,7 +210,7 @@ async function main() {
// ---------------------------------------------------------------- 3. 上报(状态 + 课表 + 节次) // ---------------------------------------------------------------- 3. 上报(状态 + 课表 + 节次)
const reportBody = { const reportBody = {
pluginVersion: '1.1.0.0', pluginVersion: '1.1.1.0',
classIslandVersion: '2.1.0.0', classIslandVersion: '2.1.0.0',
state: { state: {
inClass: true, inClass: true,
+16 -2
View File
@@ -11,8 +11,13 @@ import { logger } from '../lib/logger.js';
* - style-src 需要 'unsafe-inline':Element Plus 运行时注入行内样式 * - style-src 需要 'unsafe-inline':Element Plus 运行时注入行内样式
* - connect-src 需要放行 WebSocket(同源 ws/wss)与已配置的跨域后端 * - connect-src 需要放行 WebSocket(同源 ws/wss)与已配置的跨域后端
* - 桌面客户端通过 file:// 加载页面,因此不启用 COOP/COEP,资源策略设为 cross-origin * - 桌面客户端通过 file:// 加载页面,因此不启用 COOP/COEP,资源策略设为 cross-origin
* - helmet 默认 CSP 自带 upgrade-insecure-requests,它会把页面**子资源**请求全部改写成
* https —— 而安装器的默认形态是 http://IP:端口 直连(无 TLS),改写后的请求必然
* 连接失败,表现为「管理端一片空白、标签页标题正常」(2026-10-05 实测踩到)。
* HSTS 在明文响应里按规范本就会被浏览器忽略,但 CSP 指令没有这层豁免,
* 所以这两者都只在 HTTPS 请求下下发(见 buildHelmet 的 httpsRequest 参数)。
*/ */
export function securityHeaders(): RequestHandler { function buildHelmet(httpsRequest: boolean): RequestHandler {
const connectSrc = new Set<string>(["'self'", 'ws:', 'wss:']); const connectSrc = new Set<string>(["'self'", 'ws:', 'wss:']);
if (Array.isArray(env.corsOrigins)) { if (Array.isArray(env.corsOrigins)) {
for (const origin of env.corsOrigins) connectSrc.add(origin); for (const origin of env.corsOrigins) connectSrc.add(origin);
@@ -32,6 +37,7 @@ export function securityHeaders(): RequestHandler {
'base-uri': ["'self'"], 'base-uri': ["'self'"],
'frame-ancestors': ["'none'"], 'frame-ancestors': ["'none'"],
'form-action': ["'self'"], 'form-action': ["'self'"],
'upgrade-insecure-requests': httpsRequest ? [] : null,
}, },
}, },
crossOriginEmbedderPolicy: false, crossOriginEmbedderPolicy: false,
@@ -39,10 +45,18 @@ export function securityHeaders(): RequestHandler {
// 允许桌面客户端等跨源场景读取静态资源与接口响应 // 允许桌面客户端等跨源场景读取静态资源与接口响应
crossOriginResourcePolicy: { policy: 'cross-origin' }, crossOriginResourcePolicy: { policy: 'cross-origin' },
referrerPolicy: { policy: 'no-referrer' }, referrerPolicy: { policy: 'no-referrer' },
hsts: env.isProduction ? { maxAge: 15552000, includeSubDomains: true } : false, hsts: httpsRequest && env.isProduction ? { maxAge: 15552000, includeSubDomains: true } : false,
}); });
} }
export function securityHeaders(): RequestHandler {
const secure = buildHelmet(true);
const plain = buildHelmet(false);
// req.secure:直连 TLS 时为 true;前面有反代时由 x-forwarded-proto 决定
// (app.ts 里 app.set('trust proxy', env.trustProxy) 已生效)
return (req, res, next) => (req.secure ? secure : plain)(req, res, next);
}
/** 通用接口限流:防止单 IP 高频刷接口 */ /** 通用接口限流:防止单 IP 高频刷接口 */
export function apiRateLimiter(): RequestHandler { export function apiRateLimiter(): RequestHandler {
const limiter = rateLimit({ const limiter = rateLimit({
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@classhelper/shared", "name": "@classhelper/shared",
"version": "1.1.0", "version": "1.1.1",
"private": true, "private": true,
"description": "班级小助手 - 三端共享的类型、常量与工具函数", "description": "班级小助手 - 三端共享的类型、常量与工具函数",
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@classhelper/web-admin", "name": "@classhelper/web-admin",
"version": "1.1.0", "version": "1.1.1",
"private": true, "private": true,
"description": "班级小助手 Web 管理端(Vue 3 + Vite + Element Plus)", "description": "班级小助手 Web 管理端(Vue 3 + Vite + Element Plus)",
"type": "module", "type": "module",