Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e52cfad4d | ||
|
|
79d73c5889 | ||
|
|
48e0c89394 | ||
|
|
5d2fa2d6b4 | ||
|
|
2be8d30376 | ||
|
|
f6edfa5ea1 | ||
|
|
985e602012 | ||
|
|
d820926491 | ||
|
|
f1edb42665 | ||
|
|
67e3961e55 |
No files matched your search
@@ -69,6 +69,14 @@ jobs:
|
||||
- name: 安装依赖
|
||||
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 安装包
|
||||
run: pnpm dist:server:linux
|
||||
|
||||
@@ -94,23 +102,30 @@ jobs:
|
||||
set -euo pipefail
|
||||
VERSION="${TAG#v}"
|
||||
TARBALL="release-server/classhelper-server-linux-x64-${VERSION}.tar.gz"
|
||||
SHAFILE="${TARBALL}.sha256"
|
||||
SUMS="release-server/SHA256SUMS-${VERSION}.txt"
|
||||
|
||||
if [ ! -f "$TARBALL" ]; then
|
||||
echo "::error::没有找到 $TARBALL(tag 与 package.json 的版本号对得上吗?)"
|
||||
exit 1
|
||||
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
|
||||
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"
|
||||
touch "$SUMS"
|
||||
grep -v "^[0-9a-f]\{64\} classhelper-server-linux-" "$SUMS" > "${SUMS}.new" || true
|
||||
printf '%s %s\n' "$(sha256sum "$TARBALL" | awk '{print $1}')" "$(basename "$TARBALL")" >> "${SUMS}.new"
|
||||
mv "${SUMS}.new" "$SUMS"
|
||||
|
||||
gh release upload "$TAG" "$TARBALL" "$SUMS" --clobber
|
||||
gh release upload "$TAG" "$TARBALL" "$SHAFILE" "$SUMS" --clobber
|
||||
echo "已上传:"
|
||||
cat "$SHAFILE"
|
||||
cat "$SUMS"
|
||||
@@ -1,7 +1,7 @@
|
||||
# 班级小助手(Class Helper)
|
||||
|
||||
班级信息管理系统。一个 pnpm monorepo,一份代码产出四个交付物:**后端服务**、**Web 管理端**(教师 / 管理员)、
|
||||
**桌面客户端**(学生端 / 教室机器)与 **ClassIsland 联动插件**(装在教室的 ClassIsland 上)。**当前版本 1.1.0**。
|
||||
**桌面客户端**(学生端 / 教室机器)与 **ClassIsland 联动插件**(装在教室的 ClassIsland 上)。**当前版本 1.1.1**。
|
||||
|
||||
核心链路:
|
||||
|
||||
@@ -74,26 +74,40 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/
|
||||
## 2. 交付产物与部署形态
|
||||
|
||||
| 产物 | 产物文件位置(打包后) | 用途 |
|
||||
| ------------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| **服务端 + 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 管理端安装程序** | `releases/server/<版本>/安装包/班级小助手服务端-<版本>-x64-setup.exe` | 装到教师电脑 / 校服务器即完整系统,**内置 Node 运行时**,双击安装、开机自启、自动建库建号 |
|
||||
| **服务端 Linux 安装包** | `releases/server/<版本>/linux-x64/classhelper-server-linux-x64-<版本>.tar.gz` | 给 `deploy/install.sh` 用(另见同名 `.sha256`);由 Actions 在 Linux 上构建后挂 Release |
|
||||
| **学生客户端安装程序** | `releases/client/<版本>/安装包/班级小助手-<版本>-x64-setup.exe` | 学生机安装(NSIS) |
|
||||
| **学生客户端单文件版** | `releases/client/<版本>/安装包/班级小助手-<版本>-x64-portable.exe` | 免安装直接运行(U 盘分发) |
|
||||
| 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/`(`client/`、`server/`、`classisland-plugin/`),详见 [AGENTS.md](AGENTS.md) §4。
|
||||
> 这些产物**都不入库**(`releases/` 已 gitignore + eslint ignore)。三个打包脚本**直接输出**到
|
||||
> `releases/<组件>/<版本>/`,每个版本目录下按 `安装包/`、`免安装/`、`构建中间/` 分层,
|
||||
> 历史版本原样保留 —— 详见 [AGENTS.md](AGENTS.md) §4。
|
||||
|
||||
四种部署形态按场景选(Linux 完整步骤见 [docs/linux-deploy.md](docs/linux-deploy.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` |
|
||||
| 已有 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. 快速开始
|
||||
|
||||
@@ -187,7 +201,7 @@ pnpm verify:desktop
|
||||
| `pnpm build:shared` | 只构建 shared(改完 `packages/shared` 必须跑) |
|
||||
| `pnpm build:desktop` | 只构建客户端(esbuild 主进程 / preload + Vite 渲染进程,末尾跑产物凭据门禁) |
|
||||
| `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 dist:server` | 打包服务端 + Web 管理端(免安装目录 + NSIS 安装程序,内置 Node) |
|
||||
| `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(最快) |
|
||||
@@ -1225,19 +1239,21 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
|
||||
### 客户端(Windows EXE)
|
||||
|
||||
```bash
|
||||
pnpm dist:dir # 免安装目录:packages/desktop-client/release/win-unpacked/班级小助手.exe
|
||||
pnpm dist:dir # 免安装目录:releases/client/<版本>/免安装/班级小助手.exe
|
||||
pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS 工具,可用镜像)
|
||||
```
|
||||
|
||||
打包脚本 `packages/desktop-client/scripts/dist-win.mjs` 做了几件事以规避常见问题:复用本地已解压的 Electron
|
||||
(`--config.electronDist`,不重复下载 150MB+);默认走国内镜像(`ELECTRON_MIRROR`、
|
||||
`ELECTRON_BUILDER_BINARIES_MIRROR`,可用环境变量覆盖);构建缓存固定到仓库内 `.cache/`;
|
||||
用 `--config.directories.output` 把输出目录覆盖成 `releases/client/<版本>/`,打包成功后再把
|
||||
electron-builder 的原始布局整理成 `安装包/`、`免安装/`、`构建中间/`;
|
||||
收尾把交付物完整性标签改成 Medium(Low 标签会导致装不上 / 双击没反应,见 [8. 常见问题](#8-常见问题))。
|
||||
|
||||
产物结构:`release/win-unpacked/班级小助手.exe`(Electron 运行时)+ `resources/app.asar`
|
||||
产物结构:`免安装/班级小助手.exe`(Electron 运行时)+ `resources/app.asar`
|
||||
(仅含 `dist/` 与 `package.json`,运行时不需要 node_modules)。
|
||||
|
||||
> **把修复交付到「已安装的客户端」**:`pnpm dist:win` 只产出 `packages/desktop-client/release/`。
|
||||
> **把修复交付到「已安装的客户端」**:`pnpm dist:win` 只产出 `releases/client/<版本>/`。
|
||||
> 若学生机上是**安装版**(例如装在 `D:\class\@classhelperdesktop-client`),只更新 release 而不同步安装目录,
|
||||
> 用户打开的还是旧 `resources/app.asar`,会出现「改了但界面不生效」。覆盖升级(用户配置在 `%APPDATA%`,不受影响):
|
||||
>
|
||||
@@ -1254,16 +1270,16 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS
|
||||
| 产物 | 大小 | 冒烟结果 |
|
||||
| -------------------------------------- | ------ | -------------------------------------------------------------------- |
|
||||
| 服务端安装程序(内置 Node + Web 端) | ~62MB | ✅ 静默安装(升级保留 `.env` 与数据库)→ 自动建库建号 → 服务就绪 |
|
||||
| `release/win-unpacked/班级小助手.exe` | ~235MB | ✅ 打包版冒烟全绿,`packaged: true`,退出码 0 |
|
||||
| `release/…-x64-portable.exe`(单文件) | ~98MB | ✅ 打包后冒烟全绿,`packaged: true`,退出码 0 |
|
||||
| `release/…-x64-setup.exe`(客户端) | ~98MB | ✅ 构建成功,已嵌入自定义图标 |
|
||||
| `releases/classisland-plugin/….cipx` | ~88KB | ✅ 全量编译通过(1 警告 0 错误),包内 DLL 哈希与 `bin/Release` 一致 |
|
||||
| `releases/client/<版本>/免安装/班级小助手.exe` | ~235MB | ✅ 打包版冒烟全绿,`packaged: true`,退出码 0 |
|
||||
| `releases/client/<版本>/安装包/…-portable.exe` | ~98MB | ✅ 打包后冒烟全绿,`packaged: true`,退出码 0 |
|
||||
| `releases/client/<版本>/安装包/…-setup.exe` | ~98MB | ✅ 构建成功,已嵌入自定义图标 |
|
||||
| `releases/classisland-plugin/<版本>/…cipx` | ~88KB | ✅ 全量编译通过(1 警告 0 错误),包内 DLL 哈希与 `bin/Release` 一致 |
|
||||
|
||||
### ClassIsland 插件
|
||||
|
||||
```bash
|
||||
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,不需要服务端)
|
||||
```
|
||||
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
SERVER_PORT=4000
|
||||
|
||||
# 应用版本(镜像标签)
|
||||
APP_VERSION=1.1.0
|
||||
APP_VERSION=1.1.1
|
||||
|
||||
# MySQL 配置
|
||||
MYSQL_ROOT_PASSWORD=请改成强密码_root
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
SERVER_PORT=4000
|
||||
|
||||
# 应用版本(镜像标签)
|
||||
APP_VERSION=1.1.0
|
||||
APP_VERSION=1.1.1
|
||||
|
||||
# JWT 签名密钥(必填,必须 ≥32 位;不能包含 secret / change-me 等示例词)
|
||||
# 生成:node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
|
||||
|
||||
+53
-15
@@ -30,7 +30,8 @@ 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_REPO_URL="https://github.com/${REPO_SLUG}"
|
||||
GITHUB_RELEASES_PAGE="${GITHUB_REPO_URL}/releases"
|
||||
# GitHub 对没有 User-Agent 的请求直接 403
|
||||
HTTP_UA="ClassHelper-Ops"
|
||||
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" "$*"; }
|
||||
info() { printf '%s[classhelper]%s %s\n' "$C_CYAN" "$C_RESET" "$*"; }
|
||||
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; }
|
||||
|
||||
# ---------------------------------------------------------------- 2. 定位自身与安装元数据
|
||||
@@ -747,8 +765,10 @@ cmd_doctor() {
|
||||
"这是交叉构建的包($(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 avail_kb; avail_kb="$(df -Pk "$INSTALL_DIR" 2>/dev/null | awk 'NR==2 {print $4}')"
|
||||
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
|
||||
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。
|
||||
open_tty() {
|
||||
[ "${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
|
||||
local prompt="$1" var="$2" value=''
|
||||
READ_SECRET_EOF=0
|
||||
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
|
||||
printf '\n' >&3
|
||||
exec 3<&- || true
|
||||
printf '\n' >&3 2>/dev/null || true
|
||||
else
|
||||
READ_SECRET_EOF=1
|
||||
fi
|
||||
exec 3<&- 2>/dev/null || 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"
|
||||
return 0
|
||||
}
|
||||
|
||||
# 与服务端 tools/admin-cli.mjs 同一套强度规则(两处口径必须一致)
|
||||
@@ -860,36 +889,43 @@ cmd_password() {
|
||||
fi
|
||||
|
||||
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)立刻退出 ——
|
||||
# 否则管道喂入不合规的密码时会一直重读空值,表现为"卡住不动"。
|
||||
local pw1='' pw2='' problems='' tries=0 accepted=0
|
||||
while [ "$tries" -lt 3 ] && [ "$accepted" = "0" ]; do
|
||||
tries=$((tries + 1))
|
||||
read_secret "新密码:" pw1
|
||||
read_secret "新密码(第 ${tries}/3 次):" pw1
|
||||
if [ -z "$pw1" ]; 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
|
||||
warn "密码不能为空,请重试"
|
||||
warn_user "密码不能为空,请重试"
|
||||
continue
|
||||
fi
|
||||
problems="$(password_problems "$pw1" "${target:-admin}")"
|
||||
if [ -n "$problems" ]; then
|
||||
warn "密码不合规:${problems}"
|
||||
warn_user "密码不合规:${problems}"
|
||||
continue
|
||||
fi
|
||||
read_secret "再输入一次确认:" pw2
|
||||
if [ "$pw1" != "$pw2" ]; then
|
||||
warn "两次输入不一致,请重来"
|
||||
warn_user "两次输入不一致,请重来"
|
||||
continue
|
||||
fi
|
||||
accepted=1
|
||||
done
|
||||
if [ "$accepted" != "1" ]; then
|
||||
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
|
||||
|
||||
# 改前备份原哈希:失败或复核不过时按这份文件回滚(只含用户名 + 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
|
||||
[ -n "$tag" ] || return 1
|
||||
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' \
|
||||
"$GITHUB_RELEASES_PAGE" "$tag" "$(asset_arch)" "$ver"
|
||||
"$GITHUB_REPO_URL" "$tag" "$(asset_arch)" "$ver"
|
||||
}
|
||||
|
||||
download_to() { # $1=url $2=目标文件
|
||||
|
||||
@@ -23,7 +23,7 @@ services:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile
|
||||
target: runtime
|
||||
image: classhelper-server:${APP_VERSION:-1.1.0}
|
||||
image: classhelper-server:${APP_VERSION:-1.1.1}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
|
||||
@@ -60,7 +60,7 @@ services:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile
|
||||
target: runtime
|
||||
image: classhelper-server:${APP_VERSION:-1.1.0}
|
||||
image: classhelper-server:${APP_VERSION:-1.1.1}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
|
||||
+201
-27
@@ -9,7 +9,23 @@
|
||||
# native(默认)—— 装成 systemd 服务,自带 Node 运行时,SQLite 或 MySQL
|
||||
# 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 --check # 只体检(不需要 root,不改动任何东西)
|
||||
# sudo bash install.sh --uninstall # 卸载(会问保留什么)
|
||||
@@ -59,7 +75,8 @@ MYSQL_PASSWORD=""
|
||||
|
||||
# 服务端安装包(默认取 GitHub Release 上的 Linux 资产)
|
||||
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_SHA256=""
|
||||
VERSION=""
|
||||
@@ -83,8 +100,20 @@ INSTALL_DOCKER=0
|
||||
FORCE=0
|
||||
|
||||
LOG_FILE="${LOG_DIR}/install.log"
|
||||
SCRIPT_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
|
||||
|
||||
# 脚本自身路径。两种"一行脚本安装"方式下这里会不一样,必须都能活:
|
||||
# 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" "$*"; }
|
||||
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; }
|
||||
|
||||
# 真正的 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() {
|
||||
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
|
||||
fail_print "$(printf '\n%s[classhelper]%s 安装中断(第 %s 行,退出码 %s)' "$C_RED" "$C_RESET" "$1" "$code")"
|
||||
[ -f "${LOG_FILE:-}" ] && fail_print "完整日志:${LOG_FILE}"
|
||||
exit "$code"
|
||||
}
|
||||
trap 'on_error "$LINENO"' ERR
|
||||
@@ -123,11 +169,39 @@ ASCII
|
||||
printf '%s 一键安装 + classhelper 运维命令(status / password / upgrade / backup …)%s\n\n' "$C_DIM" "$C_RESET"
|
||||
}
|
||||
|
||||
# 打印文件头部注释里的用法(第一个分隔线之后、第二个分隔线之前)
|
||||
# 用法:能读到脚本文件时直接打印文件头部注释(唯一真身,不会漂);从远端执行时没有文件可读,
|
||||
# 退化成下面这份精简参数表。
|
||||
usage() {
|
||||
if [ -n "$SCRIPT_PATH" ] && [ -r "$SCRIPT_PATH" ]; then
|
||||
awk 'NR == 1 { next }
|
||||
/^# -{10,}/ { seen++; if (seen == 2) exit; next }
|
||||
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=""
|
||||
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
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/os-release
|
||||
@@ -190,6 +268,7 @@ detect_os() {
|
||||
else
|
||||
OS_NAME="$(uname -s) $(uname -r)"
|
||||
fi
|
||||
VERSION="$keep_version"
|
||||
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"
|
||||
@@ -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=目标变量
|
||||
local prompt="$1" default="$2" var="$3" answer=""
|
||||
if [ "$ASSUME_YES" = "1" ] || [ ! -t 0 ]; then
|
||||
if ! can_prompt; 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
|
||||
read_prompt "$(printf '%s[?]%s %s [%s]: ' "$C_CYAN" "$C_RESET" "$prompt" "$default")" answer
|
||||
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
|
||||
printf -v "$var" '%s' "${answer:-$default}"
|
||||
}
|
||||
@@ -275,24 +404,33 @@ ask() { # $1=提示 $2=默认值 $3=目标变量
|
||||
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'
|
||||
if ! can_prompt; then return 0; fi
|
||||
read_prompt "$(printf '%s[?]%s %s' "$C_CYAN" "$C_RESET" "$prompt")" value 1
|
||||
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
|
||||
if ! can_prompt; then
|
||||
[ "$default" = "y" ]
|
||||
return
|
||||
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
|
||||
}
|
||||
|
||||
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 ' 1) 直接安装(systemd 服务 + 内置 Node 运行时,默认 SQLite)%s ← 推荐%s\n' "$C_GREEN" "$C_RESET"
|
||||
printf ' 2) 直接安装 + MySQL\n'
|
||||
@@ -300,7 +438,7 @@ choose_mode_interactive() {
|
||||
printf ' 4) 只体检(--check,不改动系统)\n'
|
||||
printf ' 5) 卸载\n'
|
||||
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
|
||||
1) MODE="native"; DB_KIND="sqlite" ;;
|
||||
2) MODE="native"; DB_KIND="mysql" ;;
|
||||
@@ -391,12 +529,14 @@ resolve_package_url() {
|
||||
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"
|
||||
sudo bash install.sh --package /tmp/classhelper-server-linux-x64-1.1.1.tar.gz"
|
||||
fi
|
||||
fi
|
||||
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' \
|
||||
"$GITHUB_RELEASES_PAGE" "$tag" "$(asset_arch)" "$ver"
|
||||
"$GITHUB_REPO_URL" "$tag" "$(asset_arch)" "$ver"
|
||||
}
|
||||
|
||||
# 下载 + 校验 + 解压;结果放进全局 STAGE_DIR(不用命令替换取,否则会把日志一起捕获)
|
||||
@@ -414,13 +554,30 @@ fetch_package() {
|
||||
esac
|
||||
[ -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}')"
|
||||
if [ -n "$PACKAGE_SHA256" ]; then
|
||||
[ "$got" = "$PACKAGE_SHA256" ] || die "sha256 校验失败:期望 ${PACKAGE_SHA256},实际 ${got}"
|
||||
[ "$got" = "$PACKAGE_SHA256" ] || die "sha256 校验失败:期望 ${PACKAGE_SHA256},实际 ${got}
|
||||
安装包可能在传输中损坏或被篡改,已中止(什么也没改)。可用 --sha256 <正确的哈希> 重试。"
|
||||
log "sha256 校验通过"
|
||||
else
|
||||
PACKAGE_SHA256="$got"
|
||||
info "sha256(未提供校验值,仅记录):${got}"
|
||||
warn "取不到发布页的 .sha256,本次只记录实际哈希、不做校验:${got}"
|
||||
warn "(内网分发时请自带 --sha256;或手工核对上面这串哈希)"
|
||||
fi
|
||||
|
||||
STAGE_DIR="${tmp}/pkg"
|
||||
@@ -1000,15 +1157,34 @@ print_result() {
|
||||
main() {
|
||||
parse_args "$@"
|
||||
|
||||
# `--check` 与菜单里的"4) 只体检"是同一条路:都不需要 root、都不改动系统。
|
||||
# 菜单必须出现在 root 检查**之前** —— 否则非 root 用户连菜单都看不到,只能靠 --check。
|
||||
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
|
||||
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"
|
||||
{
|
||||
printf '\n===== %s 安装开始 =====\n' "$(date '+%F %T')"
|
||||
@@ -1017,11 +1193,9 @@ main() {
|
||||
# 后续输出同时进日志(tee 不占 stdin,交互问答照常)
|
||||
exec > >(tee -a "$LOG_FILE") 2>&1
|
||||
|
||||
detect_os
|
||||
info "系统:${OS_NAME:-未知}|包管理器:${PKG:-未知}|架构:$(uname -m)"
|
||||
|
||||
choose_mode_interactive
|
||||
collect_answers
|
||||
# 把最终生效的选择打出来:非交互(--yes)或问答被跳过时,这一行是唯一能看出"到底按什么装的"地方
|
||||
info "本次安装:形态 ${MODE}|目录 ${INSTALL_DIR}|端口 ${PORT}|数据库 ${DB_KIND}|Node ${NODE_SOURCE}"
|
||||
|
||||
if [ "$UNINSTALL" = "1" ]; then
|
||||
do_uninstall --yes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "classhelper-server-runtime",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "班级小助手服务端运行时(安装包 / 容器内置)",
|
||||
|
||||
+9
-9
@@ -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 未实测 |
|
||||
| **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 项端到端) |
|
||||
@@ -23,9 +23,9 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已安装依赖,跳
|
||||
产物:
|
||||
|
||||
| 路径 | 说明 |
|
||||
| ----------------------------------------------------- | ----------------------------------------------- |
|
||||
| `release-server/classhelper-server/` | 免安装目录,可直接拷到任意 Windows x64 机器运行 |
|
||||
| `release-server/班级小助手服务端-1.1.0-x64-setup.exe` | 安装程序(约 34 MB,NSIS) |
|
||||
| ----------------------------------------------------------------------- | ----------------------------------------------- |
|
||||
| `releases/server/<版本>/免安装/` | 免安装目录,可直接拷到任意 Windows x64 机器运行 |
|
||||
| `releases/server/<版本>/安装包/班级小助手服务端-<版本>-x64-setup.exe` | 安装程序(约 62 MB,NSIS) |
|
||||
|
||||
打包内容:内置 **Node 运行时**(目标机无需安装 Node.js)、后端产物、Web 管理端产物、
|
||||
生产依赖(真实目录,非软链)、迁移 SQL、`.env`(随机 JWT 密钥)、启停脚本。
|
||||
@@ -35,7 +35,7 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已安装依赖,跳
|
||||
双击安装程序即可,**无需管理员权限**(当前用户级安装):
|
||||
|
||||
- 安装目录:`%LOCALAPPDATA%\Programs\ClassHelperServer`
|
||||
- 静默安装:`"班级小助手服务端-1.1.0-x64-setup.exe" /S`
|
||||
- 静默安装:`"班级小助手服务端-1.1.1-x64-setup.exe" /S`
|
||||
- 开始菜单:启动服务 / 停止服务 / 重启服务 / 打开管理端 / 使用说明 / 卸载
|
||||
- 桌面:`班级小助手服务端.lnk`(打开管理端)
|
||||
- 自动加入当前用户开机自启(`HKCU\...\CurrentVersion\Run`)
|
||||
@@ -163,7 +163,7 @@ Linux 包由 GitHub Actions 在 ubuntu runner 上构建(平台相关的依赖
|
||||
```bash
|
||||
# 开发机:做出 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/
|
||||
|
||||
# 服务器上
|
||||
@@ -176,7 +176,7 @@ sudo bash /tmp/install.sh # 交互式:选形态 → 选目录
|
||||
```bash
|
||||
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
|
||||
--package /tmp/classhelper-server-linux-x64-1.1.1.tar.gz
|
||||
```
|
||||
|
||||
安装器做的事:识别发行版与包管理器补依赖 → 建系统用户 `classhelper` → 下载包并校验 sha256 →
|
||||
@@ -221,7 +221,7 @@ printf '%s\n' 'StrongPass!2026' | sudo bash /tmp/install.sh --yes \
|
||||
没有 systemd 的容器里可以用安装器的"只铺文件"模式,再自己托管进程:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
@@ -279,7 +279,7 @@ pnpm install
|
||||
pnpm build:shared
|
||||
pnpm --filter @classhelper/server build
|
||||
pnpm --filter @classhelper/web-admin build
|
||||
pnpm dist:server -- --reuse-deps # 得到 release-server/classhelper-server 免安装目录
|
||||
pnpm dist:server -- --reuse-deps # 得到 releases/server/<版本>/免安装/ 免安装目录
|
||||
```
|
||||
|
||||
把该目录拷贝到目标机,编辑 `.env`(端口、数据库、密钥、跨域),然后:
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "class-helper",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"private": true,
|
||||
"description": "班级小助手 - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)",
|
||||
"packageManager": "pnpm@11.8.0",
|
||||
|
||||
@@ -14,6 +14,6 @@ description: |-
|
||||
4. 可选:把班级小助手上排好的课表镜像回 ClassIsland。
|
||||
entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll'
|
||||
url: https://github.com/LoveChengke/ClassHelper
|
||||
version: 1.1.0.0
|
||||
version: 1.1.1.0
|
||||
apiVersion: 2.0.0.0
|
||||
author: 班级小助手
|
||||
@@ -495,7 +495,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
/* ---------------------------------------------------------------- 内部实现 */
|
||||
|
||||
/// <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>
|
||||
private static string SafeAppVersion()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@classhelper/desktop-client",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"private": true,
|
||||
"description": "班级小助手 EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
|
||||
"author": "Class Helper",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@classhelper/server",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"private": true,
|
||||
"description": "班级小助手后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
|
||||
"type": "module",
|
||||
|
||||
@@ -210,7 +210,7 @@ async function main() {
|
||||
|
||||
// ---------------------------------------------------------------- 3. 上报(状态 + 课表 + 节次)
|
||||
const reportBody = {
|
||||
pluginVersion: '1.1.0.0',
|
||||
pluginVersion: '1.1.1.0',
|
||||
classIslandVersion: '2.1.0.0',
|
||||
state: {
|
||||
inClass: true,
|
||||
|
||||
@@ -11,8 +11,13 @@ import { logger } from '../lib/logger.js';
|
||||
* - style-src 需要 'unsafe-inline':Element Plus 运行时注入行内样式
|
||||
* - connect-src 需要放行 WebSocket(同源 ws/wss)与已配置的跨域后端
|
||||
* - 桌面客户端通过 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:']);
|
||||
if (Array.isArray(env.corsOrigins)) {
|
||||
for (const origin of env.corsOrigins) connectSrc.add(origin);
|
||||
@@ -32,6 +37,7 @@ export function securityHeaders(): RequestHandler {
|
||||
'base-uri': ["'self'"],
|
||||
'frame-ancestors': ["'none'"],
|
||||
'form-action': ["'self'"],
|
||||
'upgrade-insecure-requests': httpsRequest ? [] : null,
|
||||
},
|
||||
},
|
||||
crossOriginEmbedderPolicy: false,
|
||||
@@ -39,10 +45,18 @@ export function securityHeaders(): RequestHandler {
|
||||
// 允许桌面客户端等跨源场景读取静态资源与接口响应
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||
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 高频刷接口 */
|
||||
export function apiRateLimiter(): RequestHandler {
|
||||
const limiter = rateLimit({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@classhelper/shared",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"private": true,
|
||||
"description": "班级小助手 - 三端共享的类型、常量与工具函数",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@classhelper/web-admin",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"private": true,
|
||||
"description": "班级小助手 Web 管理端(Vue 3 + Vite + Element Plus)",
|
||||
"type": "module",
|
||||
|
||||
Reference in new issue
Block a user