feat(server,web,desktop,shared,deploy,docs): Linux 一键安装与 classhelper 运维命令、更新检查、图表去 echarts,版本统一 1.1.0
Linux 交付链(新增)
- deploy/install.sh:一键安装器(CLASSHELPER 大字 TUI、native/docker 双形态、向导选安装目录/
端口/数据库/初始管理员密码);识别 apt/yum/dnf 只补缺的依赖、建独立系统用户、下载内置 Node
运行时(glibc < 2.28 的 CentOS 7 自动改用 unofficial-builds 的 glibc-217 构建)、幂等覆盖安装
(保留 config.env 与 data/,不重置密钥)、全程写 /var/log/classhelper/install.log;
`--check` 只体检(不需要 root)、`--yes` 无人值守、`--package` 支持内网离线分发。
- deploy/classhelper:运维命令 —— status / doctor / password / backup(list,restore,schedule) /
upgrade / rollback / start|stop|restart|reload / logs / config / port / key rotate / uninstall /
completion / help。特权操作一律校验 EUID,关键动作写 /var/log/classhelper/audit.log。
- deploy/tools/admin-cli.mjs:离线账号工具(改密、列账号、VACUUM INTO 一致性快照、哈希回滚),
直连数据库 —— 服务停机也能救急,不需要先有一个能登录的账号。
- deploy/{classhelper.service,profile.d,logrotate.d,systemd}/ 模板 + Dockerfile.linux-package。
- scripts/dist-server.mjs 增加 --platform linux(根脚本 `pnpm dist:server:linux`),产物
classhelper-server-linux-x64-<版本>.tar.gz + 同名 .sha256;新增 GitHub Actions workflow 在
ubuntu runner 上构建并挂 Release(@libsql/linux-* 这类平台相关依赖只能在 Linux 上装)。
- docs/linux-deploy.md:安装 / 运维命令 / 升级回滚 / 备份恢复 / 排障 / 安全清单 /
Ubuntu 22.04 与 Rocky 9 的验收清单。deploy/install-linux.sh 改为转发壳,不留两份实现。
三处必须知道的取舍(已写进 AGENTS.md §5 第 59 条)
- 配置真身在 /etc/classhelper/config.env,安装目录里的 .env 是指向它的软链:Web 端
「数据库管理 → 一键切换」改写的是 <安装目录>/.env,而 systemd 注入的是同一份配置 ——
两处不是同一个文件时,切换会「看起来成功、重启后又连回旧库」。classhelper doctor 有专门断言。
- 升级/回滚替换程序文件用「改名 + 就位」而不是 rm+cp:升级时正在运行的就是 bin/classhelper
自己,直接删掉同名文件会让 bash 读到半个脚本。
- 交叉构建(在非 Linux 机器上打 Linux 包)会在包内写 .cross-built 标记、doctor 据此提示:
已核对 @libsql/linux-x64-gnu 与 -musl 就位且无 win32 残留,但 prisma CLI 的原生 schema engine
是宿主平台的,Web 端「数据库一键切换」那一步存疑;正式产物一律走 CI。
更新检查(此前只在工作区,本次一并提交)
- 服务端 modules/update:由服务端转发 GitHub Releases(Web 端 CSP 不允许直连),离线/限流/超时
一律收敛成 ok:false + 一句人话,失败结果同样进缓存;?force=1 只对管理员生效(避免刷爆匿名限流)。
- 版本比较放 @classhelper/shared(compareVersions / isNewerVersion),三端共用;服务端版本号
改由 lib/version.ts 读 package.json,不再在 app.ts 里写字面量。
- Web 端顶栏「检查更新」弹窗、客户端「关于」页的检查更新与诊断信息。
Web 端 / 客户端
- 成绩图表改用自包含的轻量组件(ScoreBarChart / ScoreLineChart),不再全量引入 echarts
(原先 echarts 占桌面端渲染产物 1.12MB、Web 端 1.13MB);柱状图是纯 HTML/CSS,折线图是
viewBox SVG,均无 resize 监听。
- Element Plus 图标改为按需注册(两端各一份 icons.ts),新增 scripts/check-icons.mjs 静态门禁
并挂进两端 build —— 漏注册的后果是界面静默渲染空白,只在控制台一条 warning。
版本号统一 1.1.0
- 5 份 package.json、deploy/package.runtime.json、.env.example 的 APP_VERSION、
docker-compose 与两个 Dockerfile 的镜像标签、Linux 安装包名示例、README 首页版本;
- ClassIsland 插件 manifest.yml 与 BridgeService.PluginVersion 同步(verify:classisland-plugin
有 manifest ↔ code 的一致性断言);
- 刻意保持不变的是**历史性表述与举例**:docs/mysql.md 的「自 v1.0.0 起内置 MySQL 适配器」、
shared/utils.ts 讲版本比较用的 1.0.0 / 1.0.0-fix、README 验收表里的实测记录。
This commit is contained in:
1 parent
aad22df4fd
commit
9a38f86cd3
77 files changed
+8021
-899
No files matched your search
+9
-274
@@ -1,279 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# ---------------------------------------------------------------------------
|
||||
# 班级小助手 · 服务端 Linux 一键部署(systemd + SQLite,单机单实例)
|
||||
# 兼容壳:早期文档、脚本与 README 里写的是 deploy/install-linux.sh,
|
||||
# 现在安装器统一在 deploy/install.sh(避免两份实现漂移)。
|
||||
#
|
||||
# 干什么:
|
||||
# 在 Linux 服务器上把服务端装成 systemd 服务,布局与已验证的 Windows 免安装目录完全一致:
|
||||
# /opt/classhelper/
|
||||
# ├── .env 全部配置(首次安装随机生成 JWT_SECRET)
|
||||
# ├── server/dist 后端程序(入口 index.js)
|
||||
# ├── server/prisma 迁移 SQL(AUTO_MIGRATE=true 时首启动自动执行)
|
||||
# ├── web/ Web 管理端(由后端托管,单端口 4000)
|
||||
# ├── node_modules/ 仅生产依赖(npm install --omit=dev)
|
||||
# ├── data/ classhelper.db + server.pid
|
||||
# └── logs/ 预留(日志默认进 journald)
|
||||
#
|
||||
# 前置条件:
|
||||
# - 在**仓库根目录**已执行过构建:pnpm build:shared && pnpm --filter @classhelper/server build \
|
||||
# && pnpm --filter @classhelper/web-admin build
|
||||
# - 目标机装好 Node.js ≥ 20.19(推荐 22 LTS);本脚本不代装 Node
|
||||
#
|
||||
# 用法(在仓库根目录执行,需要 root):
|
||||
# sudo bash deploy/install-linux.sh # 安装/升级,默认端口 4000、目录 /opt/classhelper
|
||||
# sudo bash deploy/install-linux.sh --port 8080 # 换端口
|
||||
# sudo bash deploy/install-linux.sh --dir /srv/classhelper
|
||||
# sudo bash deploy/install-linux.sh --no-service # 只铺文件,不装 systemd(自己接管进程)
|
||||
# bash deploy/install-linux.sh --check # 只体检(不需要 root):校验构建产物/Node 版本/端口探针
|
||||
# sudo bash deploy/install-linux.sh --uninstall # 停止并禁用服务(保留 data 与 .env)
|
||||
#
|
||||
# 升级:把新代码构建好后**再跑一次同样的命令**即可。脚本不会覆盖已有 .env(JWT 密钥与端口保持
|
||||
# 不变,用户不用重新登录),只会补齐缺失项并覆盖程序文件。
|
||||
#
|
||||
# 注意:本脚本未在 Linux 实机验证过(开发机是 Windows,无 Docker/WSL 发行版)。
|
||||
# 它复用的是**已验证**的两套布局:Windows 免安装目录(install/ 结构)与 deploy/Dockerfile
|
||||
# 的 runtime 阶段(package.runtime.json + prisma/migrations + web/)。
|
||||
# 两者参数完全一致:
|
||||
# sudo bash deploy/install-linux.sh --port 8080
|
||||
# 等价于
|
||||
# sudo bash deploy/install.sh --port 8080
|
||||
# ---------------------------------------------------------------------------
|
||||
set -euo pipefail
|
||||
|
||||
TARGET_DIR="/opt/classhelper"
|
||||
SERVICE_USER="classhelper"
|
||||
PORT="4000"
|
||||
WITH_SERVICE=1
|
||||
UNINSTALL=0
|
||||
CHECK_ONLY=0
|
||||
SERVICE_NAME="classhelper"
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--dir) TARGET_DIR="$2"; shift 2 ;;
|
||||
--port) PORT="$2"; shift 2 ;;
|
||||
--user) SERVICE_USER="$2"; shift 2 ;;
|
||||
--no-service) WITH_SERVICE=0; shift ;;
|
||||
--check) CHECK_ONLY=1; shift ;;
|
||||
--uninstall) UNINSTALL=1; shift ;;
|
||||
-h|--help) sed -n '2,40p' "$0"; exit 0 ;;
|
||||
*) echo "未知参数:$1(用 --help 看用法)" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
log() { printf '\033[32m[classhelper]\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[33m[classhelper]\033[0m %s\n' "$*" >&2; }
|
||||
die() { printf '\033[31m[classhelper]\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# 就绪探针:优先 curl(几乎每台 Linux 都有),否则退回 node。
|
||||
# 注意不要用 `node -e "...process.exit()"` —— 在部分 Node 版本上于 promise 回调里硬退会触发
|
||||
# libuv 断言崩溃(本机 Windows 实测退出码 -1073740791),改成设置 process.exitCode 让事件循环自然结束。
|
||||
probe_ready() {
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsS -o /dev/null --max-time 3 "http://127.0.0.1:${PORT}/healthz" >/dev/null 2>&1
|
||||
return $?
|
||||
fi
|
||||
"$NODE_BIN" -e "fetch('http://127.0.0.1:${PORT}/healthz').then(r=>{process.exitCode=r.ok?0:1}).catch(()=>{process.exitCode=1})" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
[ "$(id -u)" = "0" ] || { [ "$CHECK_ONLY" = "1" ] || die "请用 root 执行:sudo bash deploy/install-linux.sh"; }
|
||||
|
||||
# ---------------------------------------------------------------- 卸载分支
|
||||
if [ "$UNINSTALL" = "1" ]; then
|
||||
if systemctl list-unit-files | grep -q "^${SERVICE_NAME}.service"; then
|
||||
systemctl disable --now "$SERVICE_NAME" || true
|
||||
rm -f "/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
systemctl daemon-reload
|
||||
log "已停止并移除 systemd 服务 ${SERVICE_NAME}"
|
||||
else
|
||||
warn "没有找到 ${SERVICE_NAME}.service(可能没装成服务)"
|
||||
fi
|
||||
log "程序文件与数据保留在 ${TARGET_DIR};确认不需要后再手动删除"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- 定位仓库与产物
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
SERVER_DIST="packages/server/dist/index.js"
|
||||
SERVER_PRISMA="packages/server/prisma"
|
||||
WEB_DIST="packages/web-admin/dist/index.html"
|
||||
SHARED_DIST="packages/shared/dist/index.js"
|
||||
RUNTIME_PKG="deploy/package.runtime.json"
|
||||
|
||||
for f in "$SERVER_DIST" "$WEB_DIST" "$SHARED_DIST" "$RUNTIME_PKG"; do
|
||||
[ -e "$f" ] || die "缺少构建产物:$f
|
||||
请先在仓库根目录执行:
|
||||
pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build"
|
||||
done
|
||||
[ -d "$SERVER_PRISMA/migrations" ] || die "缺少迁移文件目录:$SERVER_PRISMA/migrations"
|
||||
|
||||
# ---------------------------------------------------------------- Node 检查
|
||||
NODE_BIN="$(command -v node || true)"
|
||||
[ -n "$NODE_BIN" ] || die "未找到 node,请先安装 Node.js ≥ 20.19(推荐 22 LTS)"
|
||||
NODE_MAJOR="$("$NODE_BIN" -p 'process.versions.node.split(".")[0]')"
|
||||
NODE_MINOR="$("$NODE_BIN" -p 'process.versions.node.split(".")[1]')"
|
||||
if [ "$NODE_MAJOR" -lt 20 ] || { [ "$NODE_MAJOR" -eq 20 ] && [ "$NODE_MINOR" -lt 19 ]; }; then
|
||||
die "Node 版本过低:$("$NODE_BIN" -v)(要求 ≥ 20.19)"
|
||||
fi
|
||||
NPM_BIN="$(command -v npm || true)"
|
||||
[ -n "$NPM_BIN" ] || die "未找到 npm(随 Node 一起安装)"
|
||||
log "Node $("$NODE_BIN" -v) → $NODE_BIN"
|
||||
|
||||
# ---------------------------------------------------------------- 只体检
|
||||
if [ "$CHECK_ONLY" = "1" ]; then
|
||||
echo
|
||||
echo "构建产物:"
|
||||
for f in "$SERVER_DIST" "$WEB_DIST" "$SHARED_DIST" "$RUNTIME_PKG" "$SERVER_PRISMA/migrations"; do
|
||||
printf ' %-42s %s\n' "$f" "OK"
|
||||
done
|
||||
printf ' %-42s %s\n' "迁移文件数量" "$(find "$SERVER_PRISMA/migrations" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')"
|
||||
echo
|
||||
echo "运行环境:"
|
||||
printf ' %-42s %s\n' "node" "$("$NODE_BIN" -v)(要求 ≥ 20.19)"
|
||||
printf ' %-42s %s\n' "npm" "$("$NPM_BIN" -v)"
|
||||
printf ' %-42s %s\n' "systemd" "$(command -v systemctl >/dev/null 2>&1 && echo 可用 || echo '不可用(用 --no-service)')"
|
||||
printf ' %-42s %s\n' "目标目录 ${TARGET_DIR}" "$([ -e "$TARGET_DIR" ] && echo '已存在(会就地升级,保留 .env)' || echo 尚未创建)"
|
||||
printf ' %-42s %s\n' "端口 ${PORT} 上的既有服务" "$(probe_ready && echo '健康(就绪)' || echo '无响应')"
|
||||
echo
|
||||
log "--check 通过:构建产物与运行环境都满足,可以执行安装(sudo bash deploy/install-linux.sh)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- 用户与目录
|
||||
if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then
|
||||
useradd --system --create-home --shell /usr/sbin/nologin "$SERVICE_USER" 2>/dev/null \
|
||||
|| useradd --system --shell /sbin/nologin "$SERVICE_USER"
|
||||
log "已创建系统用户 ${SERVICE_USER}"
|
||||
fi
|
||||
|
||||
mkdir -p "${TARGET_DIR}/server" "${TARGET_DIR}/web" "${TARGET_DIR}/data" "${TARGET_DIR}/logs"
|
||||
log "安装目录:${TARGET_DIR}"
|
||||
|
||||
# ---------------------------------------------------------------- 拷贝产物
|
||||
rm -rf "${TARGET_DIR}/server/dist"
|
||||
cp -R "packages/server/dist" "${TARGET_DIR}/server/dist"
|
||||
rm -rf "${TARGET_DIR}/server/prisma"
|
||||
cp -R "$SERVER_PRISMA" "${TARGET_DIR}/server/prisma"
|
||||
# 运行时不读 schema,但保留一份便于排查
|
||||
[ -f packages/server/prisma/schema.prisma ] && cp packages/server/prisma/schema.prisma "${TARGET_DIR}/server/prisma/schema.prisma"
|
||||
|
||||
rm -rf "${TARGET_DIR}/web"
|
||||
cp -R "packages/web-admin/dist" "${TARGET_DIR}/web"
|
||||
|
||||
cp "$RUNTIME_PKG" "${TARGET_DIR}/package.json"
|
||||
cp -R deploy/classhelper.service "${TARGET_DIR}/classhelper.service.template"
|
||||
|
||||
# 生产依赖(与 Dockerfile 的 runtime 阶段同一份清单)
|
||||
if [ ! -d "${TARGET_DIR}/node_modules" ] || [ "${FORCE_NPM_INSTALL:-0}" = "1" ]; then
|
||||
log "安装生产依赖(npm install --omit=dev,约 1~3 分钟)..."
|
||||
( cd "$TARGET_DIR" && "$NPM_BIN" install --omit=dev --no-audit --no-fund )
|
||||
else
|
||||
log "复用已有 node_modules(如需强制重装:FORCE_NPM_INSTALL=1 再跑一次)"
|
||||
fi
|
||||
|
||||
# workspace 共享包(后端 import '@classhelper/shared'):拷进 node_modules 里
|
||||
mkdir -p "${TARGET_DIR}/node_modules/@classhelper/shared"
|
||||
rm -rf "${TARGET_DIR}/node_modules/@classhelper/shared/dist"
|
||||
cp -R "packages/shared/dist" "${TARGET_DIR}/node_modules/@classhelper/shared/dist"
|
||||
cp packages/shared/package.json "${TARGET_DIR}/node_modules/@classhelper/shared/package.json"
|
||||
|
||||
# ---------------------------------------------------------------- .env(已存在则不覆盖)
|
||||
ENV_FILE="${TARGET_DIR}/.env"
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
log "检测到已有 .env:保留现有配置(含 JWT_SECRET),只补齐缺失项"
|
||||
else
|
||||
JWT_SECRET="$("$NODE_BIN" -e 'console.log(require("crypto").randomBytes(48).toString("hex"))')"
|
||||
cat > "$ENV_FILE" <<EOF
|
||||
# 班级小助手服务端(install-linux.sh 生成,请妥善保管)
|
||||
# 修改后重启服务生效:systemctl restart ${SERVICE_NAME}
|
||||
|
||||
NODE_ENV=production
|
||||
HOST=0.0.0.0
|
||||
PORT=${PORT}
|
||||
|
||||
# 数据库:SQLite 文件放在安装根目录的 data 下(备份只需复制该目录)
|
||||
DATABASE_PROVIDER=sqlite
|
||||
DATABASE_URL="file:../data/classhelper.db"
|
||||
# 空库首启动自动执行随包迁移并创建管理员
|
||||
AUTO_MIGRATE=true
|
||||
|
||||
# 令牌签名密钥(随机生成;更换后所有用户需重新登录)
|
||||
JWT_SECRET=${JWT_SECRET}
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# 允许的跨域来源:走域名/反代时建议改成具体来源,例如 https://class.example.com
|
||||
CORS_ORIGIN=*
|
||||
# 前面有 Nginx 等反向代理时设为 1,限流才会按真实客户端 IP 统计
|
||||
TRUST_PROXY=1
|
||||
|
||||
RATE_LIMIT_ENABLED=true
|
||||
STARTUP_DB_CHECK=true
|
||||
PID_FILE=../data/server.pid
|
||||
LOG_LEVEL=info
|
||||
|
||||
BCRYPT_ROUNDS=10
|
||||
DEFAULT_STUDENT_PASSWORD=123456
|
||||
DEFAULT_CLASS_PASSWORD=123456
|
||||
DEFAULT_TEACHER_PASSWORD=123456
|
||||
TERM_START_DATE=
|
||||
EOF
|
||||
log "已生成 ${ENV_FILE}(含随机 JWT_SECRET)"
|
||||
fi
|
||||
|
||||
# data/logs 必须可写;程序文件只读即可
|
||||
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${TARGET_DIR}/data" "${TARGET_DIR}/logs"
|
||||
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${TARGET_DIR}/node_modules"
|
||||
chown "${SERVICE_USER}:${SERVICE_USER}" "$ENV_FILE"
|
||||
chown -R root:root "${TARGET_DIR}/server" "${TARGET_DIR}/web"
|
||||
chmod 600 "$ENV_FILE"
|
||||
|
||||
# ---------------------------------------------------------------- systemd
|
||||
if [ "$WITH_SERVICE" = "1" ]; then
|
||||
command -v systemctl >/dev/null 2>&1 || die "本机没有 systemd;请加 --no-service 自行托管进程"
|
||||
|
||||
sed -e "s#__NODE__#${NODE_BIN}#g" \
|
||||
-e "s#__DIR__#${TARGET_DIR}#g" \
|
||||
-e "s#__USER__#${SERVICE_USER}#g" \
|
||||
deploy/classhelper.service > "/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
systemctl daemon-reload
|
||||
systemctl enable "$SERVICE_NAME" >/dev/null
|
||||
systemctl restart "$SERVICE_NAME"
|
||||
|
||||
log "等待服务就绪..."
|
||||
ready=0
|
||||
for _ in $(seq 1 60); do
|
||||
if probe_ready; then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ "$ready" != "1" ]; then
|
||||
warn "60 秒内没通过 /healthz,最近日志:"
|
||||
journalctl -u "$SERVICE_NAME" -n 50 --no-pager || true
|
||||
die "启动失败,请按上面的日志排查(常见:端口被占用、数据目录权限、JWT_SECRET 未生成)"
|
||||
fi
|
||||
|
||||
log "服务已就绪 ✅"
|
||||
else
|
||||
log "已跳过 systemd(--no-service)。手动启动:"
|
||||
echo " sudo -u ${SERVICE_USER} sh -c 'cd ${TARGET_DIR} && ${NODE_BIN} server/dist/index.js'"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- 结果
|
||||
cat <<EOF
|
||||
|
||||
------------------------------------------------------------------
|
||||
安装完成
|
||||
访问管理端:http://<服务器IP>:${PORT}/
|
||||
健康探针: http://<服务器IP>:${PORT}/healthz · /readyz
|
||||
配置文件: ${ENV_FILE}
|
||||
数据目录: ${TARGET_DIR}/data(备份只需复制这里)
|
||||
查看日志: journalctl -u ${SERVICE_NAME} -f
|
||||
重启服务: systemctl restart ${SERVICE_NAME}
|
||||
|
||||
首次启动会自动建表并创建管理员:admin / admin123(登录后请立即在右上角「修改密码」改掉)。
|
||||
公网部署请在前面加 Nginx + HTTPS(deploy/nginx.conf 可直接用),并把 .env 里的
|
||||
TRUST_PROXY 设为 1、CORS_ORIGIN 收敛到你的域名。
|
||||
学生端客户端登录时填的「服务器地址」就是 https://你的域名(或 http://IP:${PORT})。
|
||||
------------------------------------------------------------------
|
||||
EOF
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
printf '\033[33m[classhelper]\033[0m install-linux.sh 已更名为 install.sh,本次自动转发(后续请直接用 install.sh)\n' >&2
|
||||
exec bash "${DIR}/install.sh" "$@"
|
||||
Reference in new issue
Block a user