Files
opendance-web/tasks/010-deploy.md
T
zijunchiang bba5bcf59e feat(web): 端口分离 + VMD 动作支持;契约升至 0.2.0
需求 1:管理端端口与软件 API 端口分离
- 软件 API 端口 PORT(默认 3000)只提供 /api、/health、/static、/contracts,
  不提供管理页面(未命中路径 404);管理端端口 ADMIN_PORT(默认 3001)
  提供管理页面与全部接口;ADMIN_HOST 可单独限制管理端来源
- main.ts 同进程监听两个端口,用 req.socket.localPort 区分端口行为;
  ADMIN_PORT=PORT 为单端口兼容模式(只创建一个 server,无 EADDRINUSE)
- 端口非法在生产环境中止启动;ADMIN_PORT=PORT 只 warn
- 启动日志分别打印两个端口的访问地址

需求 2:动作数据支持 VMD(MikuMikuDance)
- vmd-parser.ts:二进制解析(30 字节头 + Shift-JIS 模型名 + 111 字节/条骨骼
  关键帧 + morph 段)、MMD 贝塞尔插值、GBK 模型名回退、固定 30fps
- mmd-skeleton.ts:标准 MMD 骨架;静止比例从参考 PMX 骨骼段实测得到
  (足首 1.43 / 膝 6.57 / 髋 11.50 / 首 16.21 / 頭 17.16,腿长 10.09)
- mmd-pose.ts:FK + 两骨骼解析腿部 IK(膝盖朝正面,不可达收敛为伸直)
  + 左手系→右手系 Z 镜像(由真实数据对照实验确定:94.1% vs 5.9%)
- retarget.ts:抽出 worldFramesToKeypointFrames(),BVH 与 VMD 共用重定向
- MotionFormat 增 vmd、扩展名映射增 .vmd;管理端上传支持 .vmd
- scripts/inspect-vmd.mjs:上传前自查工具

契约:rest-api / data-models / model-formats → 0.2.0(另两库已同步逐字节一致)

验证:85 项单测通过(VMD 31 项);契约与 29 条路由一致;typecheck/build 通过;
真实样本经 HTTP 端到端:霜雪千年 2901 帧(IK 腿)、恋爱循环 4604 帧(FK 腿)
均 preprocessState=done、0 NaN、0 越界(测试数据已从数据库清理)。
2026-09-15 08:47:37 +08:00

182 lines
11 KiB
Markdown
Raw 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 的汇总与反代说明改为两个端口。
## load
```
DSH.md
contracts/database.md
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/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 模块) |
## 进程管理:四层降级
"运行在 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` 以保证开箱可用
- **端口校验**:`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` 必须不小于此值
- **迁移用编译产物**:`web-server/scripts/migrate.mjs` 检测到 `dist/` 时走
`typeorm -d dist/db/data-source.js`,无需在服务器上装 devDependencies
- **依赖安装显式带 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
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 张业务表已创建并验证 |
| `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 中无任何反代安装动作 |
> **环境限制说明**:本次执行环境**无 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` 语法校验
> - 反代文档中的占位符与后端实际行为逐条对照(两个端口、路径、上传上限、健康检查)
>
> 需在一台有 root 的 Linux 服务器上补做一次 `install.sh` 实机验收。
## 发布产物
```bash
./scripts/package-release.sh
# → dist-release/opendance-web-v0.1.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 安装配置。