Files
opendance-web/tasks/010-deploy.md
T
zijunchiang e4351c5062 feat(models): 模型压缩包自动转换 + 默认模型写进源码;首次启动 .env 引导;HOST 默认 0.0.0.0
需求 1:模型上传支持压缩包(MMD 模型包)
- common/zip-reader.ts:纯 TS 极简 ZIP 读取器(EOCD/中央目录、STORE+DEFLATE、
  ZIP64 大小字段、Shift-JIS/GBK 文件名回退、拒绝 zip-slip 与加密条目)
- models/model-convert.ts:解压 → 选主模型(指名字/最浅/最大)→ Assimp 导出 FBX
  → **assimp info 回读校验**(Assimp 对带蒙皮大骨架模型会写出损坏文件:
  实测 714 骨骼 PMX 导出退出码 11、产物回读报 FBX-DOM no Objects dictionary found)
  → 失败自动回退 GLB;两种都失败则 422
- models/glb-textures.ts:把 Assimp 写成外部 URI 的贴图内嵌进 GLB 的 BIN chunk,
  保证模型库里单文件即可加载(否则是白模)
- 溯源:压缩包原件存为 model-archive 资产;models 新增 source_asset_id /
  conversion_note(迁移 1789450000000-AddModelConversionFields,已在真实库上
  验证 up/down 可逆)
- 默认模型:转换成功且 autoDefault 不为 false → is_primary=1 + published,
  并把身份(名称/格式/产物 sha256)写进源码 src/config/default-model.ts;
  resolveDefaultModelId 增加 DEFAULT_MODEL_ID 与源码种子两步(按 sha256 匹配,
  换库仍有效),实测库中有 2 个 VRM 时默认模型仍解析为该 GLB
- scripts/import-default-model.mjs:运维 CLI(上传 zip 并写源码 / --write-source-only)
- 修复 multipart 中文文件名乱码(upload.util.decodeUploadName)
- DTO/契约新增 conversionNote、fileSha256;管理端 accept 增加 .zip 并展示转换来源

需求 2:首次启动 .env 引导
- deploy/init-env.sh:交互式逐项填写 8 个键(回车保留默认、口令不回显不加引号、
  端口校验、mysql2 连通性自检且口令只经环境变量传递、只改这 8 个键并保留模板注释、
  chmod 600、结尾打码摘要);--force/--check/-y/-i/--skip-db-check 等开关;
  非交互(-y、OPENDANCE_NONINTERACTIVE、stdin 关闭)绝不提问、读取带超时
- install.sh 缺 .env 时改为调用引导,失败才 die

需求 3:默认监听 0.0.0.0
- 代码默认值、两份 .env 模板、install.sh、init-env.sh 五处一致,并新增测试守护
  (未设置 HOST 时为 0.0.0.0;两份模板都写着 HOST=0.0.0.0)

契约:rest-api / data-models / model-formats → 0.3.0,database → 0.2.0(另两库已同步)

验证:125 项单测通过(7 文件);契约 6 项与 29 条路由一致;typecheck/build 通过;
真实包端到端 HTTP 201 / 0.89s / format=glb / isPrimary=true / published;
GLB 1,152,464 字节、1 mesh / 240 nodes / 1 skin(238 joints) / 2 张内嵌贴图;
迁移 revert→run 实测可逆且数据无损恢复
2026-09-15 13:55:54 +08:00

16 KiB
Raw Blame History

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 必须不小于此值
  • 迁移用编译产物: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 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 张业务表已创建并验证
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 实机验收。

发布产物

./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 安装配置。