Files

266 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# T-WEB-010 部署(Linux 业务服务,不使用 Docker,不部署反代)
- **状态**:done(两次需求变更后重做)
- **依赖**:T-WEB-001 ~ 009
## 目标
Web 服务端以**一个 Linux 业务服务**的形式部署运行:Node 进程由系统服务
(systemd / OpenRC / SysVinit / 后台进程)托管,**同时提供 REST API 与管理端页面**。
**不使用 Docker**,**不部署 MySQL**(数据库为部署方提供的现有实例),
**也不部署反向代理**(由另外的服务/团队负责)。
> **需求变更记录**
> 1. 初版按文档 4.4 实现为 Docker Compose;用户要求改为"运行在 Linux 的网站",
> 于是替换为 nginx + systemd 原生站点部署,并删除 `deploy/docker-compose.yml`、
> `web-server/Dockerfile`、`web-server/admin/Dockerfile`。
> 2. 随后用户明确:"web 端仅需要业务需求,反代等工作交由另外的服务处理。
> 只需要给出 iis 和 nginx 反代的说明文档,不用部署反代服务。"
> 于是 install.sh **彻底移除 nginx 的安装与站点配置**,管理端改由 Node 服务自身
> 托管(`ADMIN_DIR` + SPA 深链接回退),并新增 `deploy/proxy/` 只交付说明文档。
> 3. 再随后用户要求**管理端口与软件 API 端口分离**:新增 `ADMIN_PORT`
> (默认 `PORT + 1`)与可选的 `ADMIN_HOST`(默认跟随 `HOST`)。软件 API 端口
> 只给客户端调接口(`/api`、`/health`、`/static`、`/contracts`,`/` 等返回 404),
> 管理端端口给浏览器(管理页面 + 全部接口)。`ADMIN_PORT === PORT` 保留为
> 单端口兼容模式,只 warn 不中止。install.sh 的汇总与反代说明改为两个端口。
> 4. 本次追加两项:
> a) **首次启动的.env 引导**:新增 `deploy/init-env.sh`(交互式逐项询问数据库
> 与端口,写盘前校验必填项/端口范围,可用 Node + mysql2 做连通性自检);
> `install.sh` 在缺 `deploy/.env` 时先调用它,失败才 die(安装目录已有上次
> 安装留下的.env 则直接复用,保持原语义)。
> b) **默认监听 0.0.0.0 固化**:确认 `configuration.ts` 的
> `bindHost = process.env.HOST?? '0.0.0.0'`、`adminHost` 默认跟随 HOST,
> 两个 `.env.example` 与 `init-env.sh` 的 HOST 默认值均为 `0.0.0.0`。
## load
```
DSH.md
contracts/database.md
deploy/init-env.sh
deploy/install.sh
deploy/uninstall.sh
deploy/.env.example
deploy/systemd/opendance-api.service
deploy/proxy/README.md
```
## 部署形态
**职责边界**:本端只提供**业务服务**;反向代理由另外的服务/团队负责。
```
Windows/Godot 客户端、Android App ─▶ 软件 API 端口:3000(只提供接口,无管理页面)
├── /api/ REST API
├── /health 健康检查(数据库不可用返回 503)
├── /static/ 上传文件
└── /contracts/ 契约快照
(/、/songs 等返回 404,有意设计)
浏览器 ─▶ 反向代理(外部提供,IIS / nginx / 网关...)──▶ 管理端端口:3001
│ 整端口转发,不需要托管静态文件 ├── / 管理端页面(含 SPA 深链接回退)
│ ├── /assets/ 前端静态资源
│ └── 上面全部接口
▼
现有 MySQL / MariaDB 实例(两个端口共用同一 Node 进程)
```
本服务一个 Node 进程**同时监听两个端口**(软件 API 端口 + 管理端端口),
管理页面只在管理端口提供;反代按用途分别转发,每个端口各一条全匹配规则即可,
既不用在代理层托管静态文件,也不用配置 SPA 回退(`try_files`)。
`deploy/install.sh` **不安装、不配置任何反向代理软件**,收尾时只打印反代交接指引。
## 交付物
| 文件 | 说明 |
|--|--|
| `deploy/install.sh` | 一键部署:前置检查 -> 建用户/目录 -> 复制并校验文件 -> 装依赖 -> 迁移 -> 注册系统服务 -> 健康检查;汇总打印两个端口与防火墙放行提示;缺 `deploy/.env` 时先调用 `init-env.sh` 引导 |
| `deploy/init-env.sh` | **首次启动的 `.env` 交互引导**:逐项询问 8 个键、写盘前校验必填项与端口范围、可选数据库连通性自检;只替换/追加这 8 个键并保留模板注释,写完 `chmod 600` 并回读;`--check` / `-y` 等非交互模式下绝不阻塞 |
| `deploy/uninstall.sh` | 卸载;默认保留上传数据,`--purge` 才删除 |
| `deploy/.env.example` | 部署环境变量样例(`PORT` / `ADMIN_PORT` / `ADMIN_HOST` / `HOST` 说明与上传上限告警) |
| `deploy/systemd/opendance-api.service` | systemd 单元,含 `ProtectSystem=strict` 等安全加固 |
| `deploy/openrc/opendance-api` | OpenRC 服务脚本 |
| `deploy/initd/opendance-api` | SysVinit / LSB 启动脚本(转调 ctl.sh) |
| `deploy/ctl.sh` | 无 init 系统时的进程控制(后台进程 + PID 文件),start/stop/restart/status/logs |
| `deploy/proxy/README.md` | **反代说明文档**:转发目标、绑定地址、自检清单、故障对照 |
| `deploy/proxy/nginx.conf.example` | nginx 配置样例(上传上限、超时、keepalive、可选 HTTPS) |
| `deploy/proxy/web.config.example` | IIS 配置样例(需 ARR + URL Rewrite 模块) |
## deploy/.env 初始化引导(init-env.sh)
首次部署不再要求手工 `cp deploy/.env.example deploy/.env && vi deploy/.env`:
```
install.sh 检测到缺 deploy/.env
├── $APP_DIR/deploy/.env 存在 -> 直接复用(上次安装留下的配置,原语义保留)
└── 否则调用 deploy/init-env.sh
├── 缺.env -> 从.env.example 复制,chmod 600
├── 交互(TTY / 管道)-> 逐项询问并写回
└── 非交互(CI、</dev/null、-y)-> 只校验,绝不阻塞;失败则 install.sh die
```
逐项询问 `DB_HOST / DB_PORT / DB_USER / DB_PASS / DB_NAME / PORT / ADMIN_PORT / HOST`
共 8 个键,回车保留默认;`DB_PASS` 用 `read -rs` 不回显、按约定写盘**不加引号**;
校验必填项与端口范围;可用 Node + mysql2 做连通性自检(口令只经环境变量传给子进程,
失败仅告警);只替换/追加这 8 个键、保留模板注释,写完 `chmod 600` 并回读校验;
结尾打印摘要(口令 `***`)与本次变更列表。
| 开关 | 说明 |
|--|--|
| (无) | 交互引导;`.env` 已存在且必填项齐全时跳过询问 |
| `--force` | 已有 `.env` 也重新逐项询问 |
| `--check` | 只校验,不修改文件、不提问 |
| `-y` / `--yes` / `--non-interactive` | 非交互:只校验缺字段与端口,绝不提问 |
| `-i` / `--interactive` | 即使 stdin 不是 TTY 也强制提问(读取超时仍生效) |
| `--skip-db-check` | 跳过数据库连通性自检 |
| `--dir DIR` | 等价于 `--env-file DIR/.env --example DIR/.env.example` |
| `--env-file PATH` / `--example PATH` | 指定 `.env` / 模板路径 |
| `--timeout SECONDS` | 非 TTY 下每次读取超时秒数(默认 10) |
| `-h` / `--help` | 显示帮助 |
环境变量:`OPENDANCE_NONINTERACTIVE` / `OPENDANCE_INTERACTIVE` /
`OPENDANCE_SKIP_DB_CHECK` / `OPENDANCE_ENV_FILE` / `OPENDANCE_ENV_EXAMPLE` /
`OPENDANCE_PROMPT_TIMEOUT`。
**非交互绝不挂住**:stdin 非 TTY 且是 `</dev/null` 或已关闭,或带 `-y` 等开关 ->
只校验不提问;所有 read 带超时,EOF/超时立即 `exit 1`。
**默认监听 0.0.0.0**:`configuration.ts` 的
`bindHost = process.env.HOST?? '0.0.0.0'`,`adminHost` 默认跟随 `HOST`
(HOST 未设置时同样回落 `0.0.0.0`);`deploy/.env.example` 与
`web-server/.env.example` 均写 `HOST=0.0.0.0`;`install.sh` 的
`BIND_HOST="${HOST:-0.0.0.0}"`;`init-env.sh` 的 HOST 默认值也是 `0.0.0.0`。
注意 `install.sh` 里的 `HEALTH_HOST` / `API_HOST` 用的是**回环地址**做本机健康检查,
与对外监听的默认值无关。
## 进程管理:四层降级
"运行在 Linux 上"不应等于"必须有 systemd"。安装时按以下顺序探测并选择:
```
1. systemd /run/systemd/system 存在且 systemctl 可用
2. OpenRC rc-service + rc-update 可用
3. SysVinit /etc/init.d 存在且 update-rc.d 或 chkconfig 可用
4. 都没有 nohup 后台进程 + PID 文件(deploy/ctl.sh)
并写 crontab @reboot 实现开机自启
```
三种 init 方式最终都调用同一个 Node 进程,只是守护方式不同;第 4 种由
`deploy/ctl.sh` 提供 start/stop/restart/status/logs。
(注意:`nohup` 不能调用 shell 函数,故 `ctl.sh` 用
`spawn_service_process()` 拼装外部命令,而非包装函数。)
**不使用 sudo**:脚本已要求 root,降权到服务用户用 `runuser -> setpriv -> su`
依次回退(部分服务器根本没装 sudo)。
## 关键实现
- **双端口**:一个 Node 进程创建两个 HTTP server--软件 API 端口(`PORT`,默认 3000)
与管理端端口(`ADMIN_PORT`,默认 3001),复用同一个 express 实例;用
`req.socket.localPort` 判断请求来自哪个端口,决定是否提供管理页面。
`ADMIN_PORT === PORT` 时只建一个 server(单端口兼容模式,避免 EADDRINUSE)
- **服务自身托管管理端**:`app.useStaticAssets(adminDir,...)` + SPA 回退中间件
(仅管理端口生效);回退会跳过 `/api/`、`/health`、`/static/`、`/contracts/`
以及带扩展名的路径,保证未知 API 仍返回 404 而不是 HTML
- **软件 API 端口不提供管理页面**:`/`、`/songs` 等在该端口一律 404(有意设计)
- **`HOST` / `ADMIN_HOST` 绑定地址**:同机反代建议 `127.0.0.1`,异机反代用
`0.0.0.0` + 防火墙;`ADMIN_HOST` 可选(默认跟随 `HOST`),可单独把管理端限制在
本机或内网;`HOST` 默认取 `0.0.0.0` 以保证开箱可用
- **首次部署的.env 由引导生成**:`install.sh` 缺 `deploy/.env` 时先调用
`deploy/init-env.sh`(安装目录已有上次安装的 `.env` 则直接复用);引导脚本在
非交互环境下只校验、绝不阻塞,校验失败时 install.sh 才 die
- **端口校验**:`PORT` / `ADMIN_PORT` 必须是 1..65535 的整数;非法值在生产环境
启动时快速失败(`validateEnv` 的 errors),`ADMIN_PORT === PORT` 只 warn
- **install.sh 汇总与反代说明**:结尾分别打印两个端口的访问地址、监听配置与
防火墙放行命令(`firewall-cmd` / `ufw` 同时放行两个端口)
- **systemd 加固**:`NoNewPrivileges`、`ProtectSystem=strict`、`ProtectHome`、
`ReadWritePaths=/var/lib/opendance`,进程只能写自己的存储目录
- **上传上限两侧一致**:后端 `UPLOAD_MAX_BYTES=536870912`(512 MiB),
反代侧 `client_max_body_size` / `maxAllowedContentLength` 必须不小于此值
- **模型转换配置**(`deploy/.env.example` 已写入):
- `ASSIMP_BIN=assimp`:上传 `.zip` 模型包或 `.pmx`/`.fbx` 单文件时**必须**能调用到,
否则转换失败 **422**
- `MODEL_CONVERT_FORMAT=glb`(默认)|`vrm`:只影响需要转换的 PMX/FBX 的交付格式。
`glb` 通用,两个客户端都原生加载;`vrm` 在 GLB 之上补 `VRMC_vrm` 扩展
(humanoid 骨骼 / 表情语义),**humanoid 骨骼名映射不全时自动回退交付 GLB**
并写进 `models.conversion_note`。产物一律 `assimp info` 回读校验
- `MODEL_CONVERT_PMX` / `MODEL_CONVERT_TIMEOUT_MS` 语义见 README 四
- **迁移用编译产物**:`web-server/scripts/migrate.mjs` 检测到 `dist/` 时走
`typeorm -d dist/db/data-source.js`,无需在服务器上装 devDependencies;
本版本迁移共 3 个:`InitSchema1789382903060`、
`AddModelConversionFields1789450000000`、**`AddModelStats1789460000000`**
(新增 `models.stats TEXT`,存模型分析结果,已在真实库执行)
- **依赖安装显式带 dev**:安装脚本先 `set -a;. deploy/.env` 导出了
`NODE_ENV=production`,会让 npm 跳过 devDependencies 导致 `tsc: not found`;
故用 `env -u NODE_ENV npm ci --include=dev`
- **复制文件时锚定排除**:`storage/` 这类模式会误伤 `src/storage/` 与 `dist/storage/`
(曾导致 `MODULE_NOT_FOUND...storage.module`),排除规则一律用 `/storage/` 锚定
- **`/contracts/` 静态暴露**:另两端可直接从站点取契约快照
## 验收
```bash
bash deploy/init-env.sh # 或手工 cp deploy/.env.example deploy/.env 后填写 DB_*
bash deploy/install.sh # 需 root(不需要 sudo)
curl -fsS http://localhost:3000/health
curl -fsS http://localhost:3001/health
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/ # 期望 404(软件 API 端口无管理页面)
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/ # 期望 200(管理页面)
```
| 验收项 | 状态 |
|--|--|
| `deploy/install.sh` 脚本语法与幂等性 | ✅ `bash -n` 通过;重复执行会覆盖程序文件、保留 `.env` |
| 连接现有 MySQL 成功 | ✅ 已用真实实例验证(`/health` 200,`database.connected=true`) |
| TypeORM migration 执行成功 | ✅ 5 张业务表已创建并验证;v0.2.0 的 `AddModelStats1789460000000`(`models.stats TEXT`)已在真实库执行 |
| 模型转换配置 | ✅ `deploy/.env.example` 写入 `ASSIMP_BIN` / `MODEL_CONVERT_FORMAT=glb|vrm` / `MODEL_CONVERT_PMX` / `MODEL_CONVERT_TIMEOUT_MS`;不合规输入转换失败 422 |
| `curl http://<host>:3000/health` 通过 | ✅ 直连后端端口已验证 |
| 软件 API 端口 `/` 返回 404 | ✅ `PORT=3000` 上 `/`、`/songs` 均 404(有意设计,无管理页面) |
| 管理端端口 `/` 返回 200 | ✅ `ADMIN_PORT=3001` 上 `/`、`/songs`、`/models` 均返回同一份 HTML(200) |
| 前端深链接回退 | ✅ 未知静态路径回退到 `index.html`,未知 API 仍 404 |
| `ADMIN_PORT === PORT` 单端口兼容模式 | ✅ 只建一个 server,管理页面回到 API 端口,日志提示「未分端口」 |
| 端口非法在生产环境中止启动 | ✅ `validateEnv` 对非 1..65535 整数端口报 error |
| 不部署、不管理 MySQL | ✅ 部署脚本不含任何数据库安装/建库操作 |
| 不使用 Docker | ✅ 仓库内已无 Dockerfile / docker-compose / Docker 引用 |
| 不部署反向代理 | ✅ 仅交付 `deploy/proxy/` 文档,install.sh 中无任何反代安装动作 |
| 缺 `deploy/.env` 时由 `init-env.sh` 引导 | ✅ 交互逐项询问 8 个键;只改这 8 个键、保留模板注释、`chmod 600`、回读校验;安装目录已有 `.env` 时直接复用 |
| `init-env.sh --check` / 非交互不阻塞 | ✅ `test/init-env.spec.ts`(16 项)覆盖 `--check`、`-y`、EOF/超时立即退出、口令不加引号写盘 |
| `HOST` 默认 `0.0.0.0` | ✅ `configuration.ts`、`deploy/.env.example`、`web-server/.env.example`、`install.sh`、`init-env.sh` 五处一致 |
> **环境限制说明**:本次执行环境**无 root 权限**,且**未安装 nginx**(按需求也不应安装)。
> 因此 `deploy/install.sh` 的服务注册链路(systemd / OpenRC / SysVinit / crontab)
> 未能在本机实机跑通。已完成的等价验证:
> - 后端以 `node dist/main.js` 直连真实数据库启动,两个端口 `/health` 均 200
> - 管理端托管与 SPA 回退逐路径实测(`/`、`/songs`、`/models`、`/api/*`、未知路径),
> 并确认 3000 上返回 404、3001 上返回 200
> - `ADMIN_DIR` 环境变量生效,`index.html` 缺失时打印告警而非崩溃
> - `npm run migration:run` 走编译产物成功
> - 端到端冒烟 57 项全通过
> - `install.sh` / `uninstall.sh` / `ctl.sh` 通过 `bash -n` 语法校验
> - `init-env.sh` 通过 `bash -n`,并由 `test/init-env.spec.ts`(16 项)覆盖
> 交互写回 / `--check` / 非交互 / 超时 / 幂等 / 口令不加引号
> - 反代文档中的占位符与后端实际行为逐条对照(两个端口、路径、上传上限、健康检查)
>
> 需在一台有 root 的 Linux 服务器上补做一次 `install.sh` 实机验收。
## 发布产物
```bash
./scripts/package-release.sh
# -> dist-release/opendance-web-v0.2.0.tar.gz
```
内容:`web-server/`(源码 + dist)、`admin/`(构建产物)、`deploy/`
(含 `proxy/` 反代文档)、`contracts/` 快照、`THIRD_PARTY.md`、`README.txt`。
打包脚本会对产物做硬校验:`src/**/*.ts` 必须都有对应 `dist/*.js`,
关键文件(`dist/main.js`、`dist/storage/storage.module.js`、`admin/index.html`、
`deploy/proxy/*`)必须齐全,且不得含真实 `.env` 与 Docker/nginx 安装配置。
v0.2.0 起校验清单追加模型新模块:`dist/models/model-inspector.js`、
`dist/models/model-thumbnail.js`、`dist/models/vrm-writer.js`、
`dist/models/gltf-packer.js`、`dist/migrations/1789460000000-AddModelStats.js`。
> **发布包名变更**:`opendance-web-v0.1.0.tar.gz` -> **`opendance-web-v0.2.0.tar.gz`**
> (`scripts/package-release.sh` 的 `VERSION` 默认值已改为 `0.2.0`,
> 与 `project.yaml` 的 `version: 0.2.0`、`web-server/package.json` 一致)。