江西医务康复系统 · 阿里云 ECS 部署手册

核查日期:2026-09-08。用户已明确本地使用 SQLite,阿里云部署使用 MySQL。适用源码:zhctproject/江西医务康复系统,本地 master,HEAD 1bc9e59,包含当前四个未提交的前端文件修改。本次只检查源码与生成文档,没有修改业务代码,没有连接或部署 ECS。

结论:可以按“Ubuntu + Nginx + Node.js + MySQL + systemd”部署,但当前代码存在上线阻断项。必须先完成第 2 节修复并在测试 MySQL 上验收,再执行正式上线。安装完成、首页能打开、健康接口返回 200,都不等于业务已经正常运行。

1. 软件、服务和网络结构

建议先使用单台 ECS,部署一个 Node 进程和一个数据库实例。前端编译后由 Express 托管,Nginx 统一代理整个站点。业务增长后可以把 MySQL迁至同 VPC 的 RDS;附件共享和 ID 并发安全完成前不做多实例。

浏览器 ── HTTPS 443 ── Nginx
                         └── HTTP 3001 ── Node / Express
                                             ├── dist 前端静态文件
                                             ├── MySQL 3306
                                             └── data/uploads 附件
软件或组件 建议版本/方式 作用 是否常驻
操作系统 Ubuntu Server 24.04 LTS,x86_64 本手册的命令基线
Node.js 22.x 最新安全修订版,至少 22.13;安装时固定版本并记录 Express 后端、首次构建 后端常驻
npm 随 Node 安装 使用 package-lock.json 安装依赖
MySQL Ubuntu 仓库提供的 8.0 系列,安装前确认实际版本与维护状态 生产数据库 是,mysql.service
Nginx Ubuntu 软件源维护版本 HTTPS、反向代理、日志 是,nginx.service
systemd 系统自带 开机自启、异常重启、日志
Git、rsync、curl、CA 证书、xz-utils、openssl Ubuntu 软件源版本 上传、下载安装、检查
cron Ubuntu 软件源版本 定时备份 是,cron.service
Certbot + Nginx 插件 可选,或使用阿里云签发并下载的证书 申请和续期 HTTPS 证书 续期任务

没有发现必须安装 Java、Tomcat、PHP、Redis、RabbitMQ、MongoDB、Electron 或独立队列服务的依据。无需 PM2,systemd 已负责进程管理;无需运行 Vite 开发服务器。

硬件容量是起步建议,尚未压测:正式试运行建议 4 vCPU / 8 GiB 内存 / 80 GiB 系统盘 / 独立数据盘 100 GiB 起;2 vCPU / 4 GiB 可用于小规模测试。附件量决定数据盘容量,公网带宽可从 5–10 Mbps 试起,最终按同时在线人数、上传量及监控结果调整。此单机方案不是高可用架构。

端口 服务 监听和放通范围
TCP 22 SSH 安全组仅放通运维公网 IP 或堡垒机
TCP 80 Nginx HTTP 转 HTTPS;使用 HTTP 验证证书时需可达
TCP 443 Nginx 正式访问;内部业务优先限制单位出口 IP/VPN
TCP 3001 Node API + 前端 目标绑定 127.0.0.1,仅本机;安全组不放通
TCP 3306 MySQL 本机 127.0.0.1;RDS 则仅同 VPC 应用来源
TCP 33060 MySQL X Plugin(可能存在) 不用于本系统,不对公网放通
TCP 5173 Vite 开发服务 生产不启动、不放通

阿里云安全组与操作系统防火墙是两层独立控制。检查实例关联的所有安全组,删除覆盖上述限制的宽泛放通规则。安装和续期需要可用 DNS、HTTPS 出站;使用 RDS 还需内网 3306 出站。IPv6 开启时要同步限制,不能只限制 IPv4。阿里云安全组说明

2. 当前必须先解决的问题

这些是代码事实与定向验证结果,不是泛泛的安全建议。交给开发人员修复后,在全新 MySQL 测试库执行迁移和业务回归;本手册没有替你改这些代码。

问题与源码位置 实际影响 上线前处理
tsconfig.server.json 只编译 TS dist-server 中没有迁移 SQL;启动会略过缺失目录,随后访问不存在的 users 表 构建后复制 server/database/migrations 到 dist-server/database/migrations,并检查 9 个 MySQL SQL 文件
server/database/migrations/mysql/001_init.sql 的 system_settings 使用未引用的 key KEY 是 MySQL 保留字,初始化有语法错误 用反引号引用列名,或统一更名并同步引用
server/database/index.ts 仅按“分号后换行”拆 SQL,007 的 ALTER 后有行内注释 多条 SQL 被拼成一次 mysql2.query,连接又没有打开多语句 调整 SQL 注释布局为独立行,或使用可靠的迁移工具/拆分器;不要简单全局开启多语句
server/services/DashboardService.ts addFavorite 使用 INSERT OR IGNORE SQLite 语法,MySQL 收藏常用队员会失败 按驱动选 SQLite INSERT OR IGNORE / MySQL INSERT IGNORE,并验证重复收藏
server/database/adapters/mysql.ts 的 transaction 创建 conn,但 run/all/exec 仍调用 pool 事务中的业务 SQL 不在同一事务连接上,回滚不能保护业务写入 让事务回调内所有查询绑定同一个连接;注入异常验证多表写入真正回滚
server/utils/ids.ts 用内存 counters,seedCounter 没有调用者 同一天两个进程首次生成的 ID 一样;本次已复现,重启可能新增失败 使用持久化且并发安全的 ID 分配;同时检查病例编号唯一性;修复前不要开集群
附件先写磁盘,再插数据库元数据,ID 同样来自内存 重启后 ID 冲突还可能先覆盖已有附件,再插库失败 附件用不会重复的 ID,独占创建文件;数据库失败时清理本次新文件,不能覆盖旧文件
server/index.ts app.listen(PORT) 没有指定 host 日志虽然写 127.0.0.1,实际可能监听所有地址 增加 HOST 环境变量并传给 listen,默认 127.0.0.1;设置 HOST 但不改代码没有效果
server/database/index.ts 自动创建默认 admin,AuthService 有开发 JWT 默认值 新装存在公开默认口令和可预测 JWT 密钥 仅在访问受限时首次启动;开放前修改 admin 密码并配置随机 JWT_SECRET

MySQL 保留字依据:MySQL 官方文档。SQL 问题目前是源码核查及拆分逻辑复现,尚未在 MySQL 8.0 实例上完成实测。

MySQL DDL 可能隐式提交;即使修复业务事务也不能假设迁移失败会自动完整回滚。当前迁移不是全部幂等,失败后不要反复重启盲目重试。全新测试库可以另建干净库重测;已有数据的库应先备份,再逐语句核对实际结构与 schema_migrations,不能通过删除迁移记录“重跑全部”。

3. 部署前准备

  1. 完成第 2 节修复,锁定经过测试的代码版本;本地四个未提交修改需要纳入发布包并留存 diff,或者由负责人确定不用,不能只 clone HEAD 后假设它等于当前工作目录。
  2. 购买 ECS,选择 Ubuntu 24.04 LTS x86_64;确认 VPC、公网 IP/EIP、可用区和数据盘。全新专用机器适用下面命令;已有共享机器先检查现有服务和端口。
  3. 准备域名,例如 medical.example.com,配置 A 记录指向 ECS。这个域名是示例,所有配置必须替换成真实域名。
  4. 对中国内地服务器上向外提供 Web 服务的域名,按阿里云流程完成 ICP 备案,再开放正式访问。阿里云备案说明
  5. 准备运维 SSH 公钥、数据库专用账号、证书、备份位置和责任人。
  6. 数据库路线已经确定:本地 SQLite → 阿里云 MySQL。默认按保留现有数据设计迁移;当前 data 中数据是否全部需要保留,切换前由业务负责人确定。本次没有读取其中人员与医务数据,不假设它只是演示数据。

以下 Linux 命令在 ECS 的有 sudo 权限的运维账号下执行。凡是 CHANGE_ME、示例域名、IP 和路径,都必须先替换。不要在 Mac 本机执行服务器安装命令。

4. 安装基础环境

sudo apt update
sudo apt install -y nginx mysql-server mysql-client git rsync curl \
  ca-certificates xz-utils openssl cron
sudo timedatectl set-timezone Asia/Shanghai
sudo systemctl enable --now mysql nginx cron
mysql --version
nginx -v

Ubuntu MySQL 安装方法参考:Ubuntu 官方说明。如果实际安装版本不是计划中的版本,先调整验证基线,不要直接升级旧库。

Node 使用官方 Linux x64 二进制,以避免 Ubuntu 默认 Node 版本不匹配。先在官方 22.x 下载目录确认最新安全修订版,把实际版本写入 NODE_VERSION;示例不固定一个可能很快过期的补丁版本。

NODE_VERSION='v22.CHANGE_ME'
case "$NODE_VERSION" in *CHANGE_ME*) echo '请先填写实际 Node 版本'; exit 1;; esac
mkdir -p /tmp/medical-node-install
cd /tmp/medical-node-install
curl -fSLO "https://nodejs.org/dist/$NODE_VERSION/node-$NODE_VERSION-linux-x64.tar.xz"
curl -fSLO "https://nodejs.org/dist/$NODE_VERSION/SHASUMS256.txt"
awk -v f="node-$NODE_VERSION-linux-x64.tar.xz" '$2 == f {print}' SHASUMS256.txt > node.sha256
test -s node.sha256 && sha256sum -c node.sha256
sudo tar -xJf "node-$NODE_VERSION-linux-x64.tar.xz" -C /opt
sudo ln -sfn "/opt/node-$NODE_VERSION-linux-x64" /opt/medical-node
/opt/medical-node/bin/node --version
/opt/medical-node/bin/node -e "require('node:sqlite'); console.log('sqlite builtin OK')"
export PATH="/opt/medical-node/bin:$PATH"
npm --version

每条下载与校验命令必须成功才继续。使用 ARM ECS 时必须改成 Linux arm64 包,不能混装。源码会静态导入 node:sqlite,即使 DB_DRIVER=mysql 也会加载,所以不能用 Node 18/20。22.13 起不再需要 experimental-sqlite 启动标记。Node SQLite 文档 · Node 22 官方下载目录

5. 建立目录与运行用户

sudo useradd --system --home-dir /opt/medical --shell /usr/sbin/nologin medical
sudo install -d -m 0755 /opt/medical/app
sudo install -d -o medical -g medical -m 0750 /opt/medical/app/data
sudo install -d -o medical -g medical -m 0750 /opt/medical/app/data/uploads
sudo install -d -m 0700 /etc/medical /var/backups/medical

如果用户或目录已存在,先检查,不重复创建或覆盖。目录设计:

目录 内容 备份/权限要求
/opt/medical/app 代码、dist、dist-server、node_modules 发布物留存;运行用户只读
/opt/medical/app/data/uploads 真实附件二进制 持久化、medical 可写,必须备份
/etc/medical/medical.env 环境配置和密钥 root 0600,禁止提交 Git
/var/lib/mysql MySQL 数据目录 数据库工具备份,不能热拷原始目录代替备份
/var/backups/medical 本机备份暂存 0700,并复制到异机/私有对象存储
/var/log/nginx 访问与错误日志 日志轮转、受限读取

若使用独立数据盘,先由运维识别、挂载并配置 UUID 开机挂载,再把 data/uploads 或 MySQL 数据目录放到该盘。不要对已有磁盘直接格式化。数据盘挂载要加入服务依赖,避免挂载失败时程序写到系统盘的同名目录。

6. 配置数据库

在 ECS 运行 sudo mysql,交互执行:

CREATE DATABASE medical CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'medical_app'@'127.0.0.1' IDENTIFIED BY 'CHANGE_ME_RANDOM_DB_PASSWORD';
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, DROP, INDEX, REFERENCES
ON medical.* TO 'medical_app'@'127.0.0.1';

数据库密码先用 openssl rand -hex 24 生成,妥善保存。上面是库级权限,不是全局管理员权限;当前程序启动会执行迁移,因此需要建表、改表和删除旧表权限。将迁移和运行账号拆开需要配套调整启动流程。

检查 /etc/mysql/mysql.conf.d/mysqld.cnf,设置 bind-address = 127.0.0.1。若启用 X Plugin,同时限制 mysqlx-bind-address。修改后:

sudo systemctl restart mysql
mysql -h 127.0.0.1 -u medical_app -p medical -e 'SELECT VERSION(), DATABASE();'
sudo ss -lntp

使用 RDS 时跳过本机 mysql-server 的安装与建库服务启动,改在 RDS 创建库、账号、白名单,使用内网连接地址。当前适配器没有 SSL 配置项;若 RDS 强制 TLS,需要先在适配器增加 CA/SSL 支持,不能靠虚构环境变量解决。

7. 上传代码与构建

不要上传本机 node_modules、旧 dist、.git、.env 或未核定的数据;依赖必须在 Linux 重新安装。本地依赖目录已经出现 tsc 可执行权限丢失,直接整体上传会把环境问题带过去。

先在 Mac 把经修复和确认的源码上传到 ECS 运维账号的临时目录,替换目标用户和 IP:

rsync -av --exclude node_modules --exclude dist --exclude dist-server \
  --exclude .git --exclude .DS_Store --exclude data --exclude '.env*' \
  '/Users/wiscourper/Documents/workspace/zhct/zhctproject/江西医务康复系统/' \
  'DEPLOY_USER@ECS_IP:~/medical-upload/'

首次安装,在 ECS 执行:

sudo rsync -a "$HOME/medical-upload/" /opt/medical/app/
sudo chown -R "$USER":"$(id -gn)" /opt/medical/app
cd /opt/medical/app
export PATH="/opt/medical-node/bin:$PATH"
npm ci --include=dev
npm run typecheck
npm run build:web
npm run build:server
cp -R server/database/migrations dist-server/database/
test -f dist/index.html
test -f dist-server/index.js
find dist-server/database/migrations/mysql -name '*.sql' | wc -l
npm prune --omit=dev
sudo chown -R root:medical /opt/medical/app
sudo chmod -R g+rX,o-rwx /opt/medical/app
sudo chown -R medical:medical /opt/medical/app/data

SQL 文件数量在当前版本应为 9;后续版本按实际发布清单核对。任一安装/构建失败立即停止,不要沿用旧 dist 假装成功。npm ci 需要开发依赖来构建,因此只能在构建后 prune。不要用 npm audit fix --force 临时改变锁文件。

生产启动入口是 node dist-server/index.js。仓库没有 npm start,也没有 npm run build 聚合命令。不要用 npm run devnpm run seednpm run seed:mysql 启动生产;seed 脚本会删除人员、组织、病例、诊疗及审计等业务数据并写入演示账号。

8. 环境变量

sudo install -m 0600 /dev/null /etc/medical/medical.env
sudoedit /etc/medical/medical.env

填入以下内容,随机 JWT 用 openssl rand -hex 32 生成:

NODE_ENV=production
PORT=3001
HOST=127.0.0.1
DB_DRIVER=mysql
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_DATABASE=medical
MYSQL_USER=medical_app
MYSQL_PASSWORD=CHANGE_ME_RANDOM_DB_PASSWORD
MYSQL_CHARSET=utf8mb4
MYSQL_TIMEZONE=+08:00
TZ=Asia/Shanghai
JWT_SECRET=CHANGE_ME_RANDOM_JWT_SECRET
CORS_ORIGIN=https://medical.example.com
WEB_DIST_DIR=/opt/medical/app/dist

HOST 只有第 2 节修复后才生效。当前程序没有 dotenv 自动加载逻辑;仅放一个 .env 在项目根目录不会生效。这里由 systemd 的 EnvironmentFile 注入。MYSQL_TIMEZONE 主要影响 mysql2 日期转换,不能替代数据库时区核对;验收时检查数据库 NOW()、网页日期和运行系统时区是否一致。

9. systemd 托管后端

创建 /etc/systemd/system/medical.service

[Unit]
Description=Jiangxi Medical Rehabilitation Web
After=network.target mysql.service
Requires=mysql.service
StartLimitIntervalSec=300
StartLimitBurst=3

[Service]
Type=simple
User=medical
Group=medical
WorkingDirectory=/opt/medical/app
EnvironmentFile=/etc/medical/medical.env
ExecStart=/opt/medical-node/bin/node /opt/medical/app/dist-server/index.js
Restart=on-failure
RestartSec=10
TimeoutStopSec=15
UMask=0027
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
ReadWritePaths=/opt/medical/app/data
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

如果使用 RDS,删掉 Requires=mysql.service,并把 After 调整为 network-online.target,增加 Wants=network-online.target。本机数据库主方案保持上面的配置。

sudo systemctl daemon-reload
sudo systemctl enable --now medical
sudo systemctl status medical --no-pager
sudo journalctl -u medical -n 100 --no-pager
curl -fsS http://127.0.0.1:3001/api/health
mysql -h 127.0.0.1 -u medical_app -p medical \
  -e 'SELECT version FROM schema_migrations ORDER BY version;'

首次启动会自动建表和创建默认管理员。健康接口期望 {"ok":true,"ts":...},迁移表应包含 001 到 009。健康接口没有执行实时数据库查询,所以还要登录、查询业务列表验证数据库读写。

如启动失败先停服务检查日志;不要连续自动重跑部分失败的数据库迁移。修复后执行 sudo systemctl reset-failed medical 再启动。

10. 开放前修改管理员密码

当前代码没有查到通用修改密码 API;“系统管理”现有路由主要管理角色和数据权限,不能假设登录后有改密按钮。默认 admin 口令为 admin123,只用于受限初始化,不能公开运行。

在上述服务创建管理员后,生成 bcrypt 哈希并参数化更新数据库。下面在 ECS 执行,输入的新密码不会回显,也不作为命令行参数:

cd /opt/medical/app
sudo /opt/medical-node/bin/node --env-file=/etc/medical/medical.env <<'NODE'
const fs = require('node:fs');
const {execFileSync} = require('node:child_process');
const bcrypt = require('bcryptjs');
const mysql = require('mysql2/promise');
(async () => {
  const fd = fs.openSync('/dev/tty', 'r+');
  const old = execFileSync('stty', ['-g'], {stdio:[fd,'pipe',fd]}).toString().trim();
  const bytes=[];
  let password;
  try {
    fs.writeSync(fd, '新管理员密码(至少16位,不回显): ');
    execFileSync('stty', ['-echo'], {stdio:[fd,fd,fd]});
    const b=Buffer.alloc(1);
    while(fs.readSync(fd,b,0,1,null) && b[0]!==10) bytes.push(b[0]);
    password=Buffer.from(bytes).toString('utf8').replace(/\r$/, '');
  } finally {
    execFileSync('stty', [old], {stdio:[fd,fd,fd]});
    fs.writeSync(fd, '\n'); fs.closeSync(fd);
  }
  if(password.length<16) throw new Error('密码长度不足');
  const db=await mysql.createConnection({host:process.env.MYSQL_HOST,
    port:Number(process.env.MYSQL_PORT||3306),user:process.env.MYSQL_USER,
    password:process.env.MYSQL_PASSWORD,database:process.env.MYSQL_DATABASE});
  try {
    const [r]=await db.execute('UPDATE users SET password_hash=? WHERE username=?',
      [bcrypt.hashSync(password,12),'admin']);
    if(r.affectedRows!==1) throw new Error('管理员记录数量异常');
    console.log('管理员密码已更新');
  } finally {await db.end();}
})().catch(e=>{console.error(e.message);process.exitCode=1});
NODE

这是按当前 MySQL 表与 bcrypt 实现编写的运维步骤,本次没有在生产执行。修改密码不会自动撤销已签发 JWT;如果默认口令曾经暴露或有人登录,重新生成 JWT_SECRET 并重启服务,使旧令牌失效。不要删除/改名唯一的 admin 记录后直接重启,当前初始化可能再次创建默认 admin。正式使用所需其他账号也要建立明确的安全开户流程,不能靠运行演示 seed 解决。

11. Nginx、域名与 HTTPS

最终推荐所有 URL 都代理到 Express,保留原路径,避免 /api 前缀丢失及 SPA 刷新 404。附件继续通过鉴权 API 读取,不要把 data/uploads 直接配成公网静态目录

先从阿里云证书控制台申请匹配正式域名的证书,下载 Nginx 格式。把完整证书链和私钥通过安全渠道放到 /etc/nginx/ssl/medical.fullchain.pem/etc/nginx/ssl/medical.key,目录 0700、私钥 0600、属主 root。若申请流程采用 DNS 验证,按页面设置 TXT 记录完成验证。

创建 /etc/nginx/sites-available/medical

server {
    listen 80;
    server_name medical.example.com;
    return 301 https://medical.example.com$request_uri;
}
server {
    listen 443 ssl;
    server_name medical.example.com;
    ssl_certificate /etc/nginx/ssl/medical.fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/medical.key;
    ssl_protocols TLSv1.2 TLSv1.3;
    server_tokens off;
    client_max_body_size 10m;
    access_log /var/log/nginx/medical.access.log;
    error_log /var/log/nginx/medical.error.log;
    location / {
        proxy_pass http://127.0.0.1:3001;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_connect_timeout 5s;
        proxy_read_timeout 60s;
        proxy_send_timeout 60s;
    }
}
sudo ln -s /etc/nginx/sites-available/medical /etc/nginx/sites-enabled/medical
sudo nginx -t
sudo systemctl reload nginx
curl -fsS https://medical.example.com/api/health

已有链接时不重复创建。检查默认站点与其他 server_name,避免命中 Nginx 欢迎页;不要删除共享机器上的其他站点。只有 nginx -t 成功才能 reload。

证书必须在到期前更新并 reload;设置到期前 30/14/7 天告警。若选 Certbot,可通过 Ubuntu 包 certbot python3-certbot-nginx 安装,在可达的域名 HTTP 站点基础上运行 sudo certbot --nginx -d 真实域名,随后运行 sudo certbot renew --dry-run 检查自动续期。证书方案二选一,避免两套工具同时改配置。

若用 UFW,先允许运维实际 SSH 端口与来源,确认第二个 SSH 会话能登录后再启用;不能在唯一远程会话里盲目开启防火墙。3001、3306、33060、5173 不放通。执行 sudo ss -lntp 验证实际监听范围。

12. 数据和附件迁移

全新部署:初始化空库后建立真实组织、人员、角色、数据范围和正式账号。空库工作台没有数据是正常状态,不能为了“看起来有内容”执行演示初始化。

已有 MySQL:停旧系统写入,使用 mysqldump 导出、同步附件、导入新库,再启动新系统。必须一并保留 schema_migrations,防止旧迁移重复执行;核对目标版本的升级路径。

本次已明确本地为 SQLite:复制 medical.db 到服务器并设置 DB_DRIVER=mysql 不会迁移数据。需要单独开发/验证 SQLite → MySQL 数据迁移,覆盖角色、权限、账号、组织、人员、病例、会话、草稿、收藏、审计、迁移版本等;保留 ID、bcrypt 密码哈希、关系和空值语义,检查日期与字段类型,校验逐表数量和关键关联,再同步全部 data/uploads。现有仓库未发现可直接采用的完整迁移脚本。

本次正式 ECS 环境必须使用 MySQL,不把 SQLite 作为部署回退选项。SQLite 热复制主 db 文件会遗漏 WAL 未合并内容,应停止本地应用后做完整快照或用 SQLite backup API,并与附件保持一致。

本次 SQLite → MySQL 数据迁移执行顺序

  1. 先演练、后正式切换。 使用本地库的受控副本和独立的 MySQL 测试库演练,不能直接在唯一原库操作。源码中的 DB_PATH 可以覆盖默认 data/medical.db,先确认本地启动时的真实路径。
  2. 固定源版本并停写。 约定切换窗口,停止本地 Node 服务和任何其他写入进程;记录 SQLite 文件与附件目录位置。完整备份 data,保存快照校验和、源 schema_migrations 和逐表行数。不要只复制运行中的 medical.db 而遗漏 WAL。
  3. 修复并验证 MySQL 兼容项。 按第 2 节处理 SQL、事务、ID 和附件问题,在干净库验证 001–009。迁移失败不得在业务原库试错。
  4. 建立目标结构。 使用目标版本的 MySQL 迁移创建表,验证 schema_migrations 全部成功后停掉目标应用。不能直接把 SQLite 的 CREATE TABLE 和 .dump 原样送给 MySQL。
  5. 准备专用转换程序。 从只读 SQLite 快照逐表读取,用 mysql2 参数化 INSERT 写入 MySQL;数据表名/列名采用明确白名单,禁止从任意输入拼接标识符。输出只包含表名、数量和校验结果,不记录医务内容或密码哈希。当前仓库没有该程序,开发需要补齐并在测试副本演练。
  6. 明确导入顺序。 基础角色 roles、权限 permissions → 组织 organizations → 人员 persons → 用户 users → role_permissions、user_data_scopes → 病例 injury_cases → injury_sessions → injury_assessments、injury_attachments、injury_status_logs → draft_records、favorite_persons、audit_logs、scale_templates、system_settings。实际源/目标都存在的表才导入;源仍有 teams/team_members 时,先完成到 organizations/persons.organization_id 的显式映射,不能静默丢弃。
  7. 解决初始种子冲突。 目标结构初始化会写角色、权限、量表和 admin。以经过确认的源数据为准,按主键做显式合并并验证业务字段;源 admin 的 bcrypt 哈希应保留,不用默认口令覆盖。不能为图省事使用 INSERT IGNORE 跳过所有冲突,那会掩盖漏迁。仅针对全新、专用目标库决定清空或合并种子,不能清理未知线上库。
  8. 处理类型与迁移版本。 保留全部主键和关系;布尔映射到 TINYINT;空字符串与 NULL 按字段约束明确转换;DATE/DATETIME 规范格式和时区;保留 JSON 文本,不重复 JSON 编码;检查 VARCHAR 长度(特别是用户名、文件名、ID)、无效日期和自增列。schema_migrations 以目标 MySQL 实际执行结果为准,不能把 SQLite 的迁移标记提前复制过去当作 MySQL 已迁移。不能靠关闭 SQL strict mode 来吞掉坏数据。
  9. 同步附件。 目标应用保持停写,将源 data/uploads 完整同步到 /opt/medical/app/data/uploads,保留目录和文件名,属主改为 medical。逐条核对 injury_attachments 对应实体文件、大小和 SHA-256;缺失、覆盖或零字节文件必须形成差异清单处理。数据库与文件必须来自同一停写窗口。
  10. 验收数据一致性。 对逐表行数、主键集合、组织父子关系、人员归属、病例—诊疗—评定—附件关联和用户角色/范围进行比对;检查 MySQL 自增值高于现有最大值。抽验多个历史病例、时间、看板指标、附件和原账号登录。迁移后的 ID 生成逻辑必须能避开已有 ID。
  11. 切换与回退窗口。 把 systemd 环境明确设置为 DB_DRIVER=mysql,启动目标后验证新建记录确实写入 MySQL。切换域名/入口,让旧系统维持停写,避免两边新增分叉。若新系统尚未接收正式写入,可切回停写快照;若已经有新写入,必须先保存目标新增/变更并制定回迁,不能直接切回 SQLite 导致数据丢失。
  12. 交付迁移报告。 留存源/目标版本、迁移时间、表数量对账、关系与附件校验、异常处理、验收人和回退截止点。实际数据和数据库导出放受控私有位置,不进入 Git。

如果业务明确要求“只上线空系统,不迁移本地演示数据”,可跳过上述业务数据导入,但仍须修复 MySQL 兼容项、建立真实组织账号并完成验收;本手册默认保留当前数据。

附件限制:Express 接收最大 10 MiB JSON,请求中的文件是 base64,体积约增大 1/3。因此单个原文件有效上限低于约 7.5 MiB,建议先按 7 MiB 内验收;不是把 Nginx 调成 100m 就能上传 100 MiB。较大影像文件要同时修改后端上传方式、限制和前端提示,并测试内存占用。DICOM 当前代码只提供下载,不能承诺网页影像阅片。

13. 必须完成的上线验收

用明确标记的验收数据执行,记录时间、版本、操作者和结果。以下全部通过才可认定部署完成:

检查 成功标准
进程与端口 mysql/nginx/medical 为 active;3001 与本机 MySQL 不公开
数据库迁移 001–009 全部有记录;业务表结构完整
域名和证书 HTTPS 无证书告警,HTTP 正确跳转
首页与刷新 登录、页面跳转、深层 URL 刷新均成功,无静态资源 404
身份 新 admin 密码登录成功,默认密码失败;无令牌接口被拒绝
组织与人员 新建组织、人员,查询、编辑保存后读回一致
伤病闭环 新建病例、初诊/复诊、恢复状态变更,列表与详情一致
草稿 保存、重新打开、提交,不重复创建病例
收藏 加入常用队员、重复加入、取消收藏全部正常,尤其验证 MySQL
附件 上传图片/PDF,预览或下载;删除正确;无权限访问受限
看板 指标与验收病例对应,空数据正常,不只看页面是否返回 200
数据权限 队医、教练等角色按组织范围可见;不能越权写入或读取
重启稳定性 创建数据和附件后重启,再创建第二条;ID 不重复,旧文件哈希不变
事务失败 测试环境故意触发写入失败,多表数据不残留半成品
整机重启 重启 ECS 后服务自动恢复、数据盘正常挂载、资料完整
备份恢复 在隔离库恢复数据库和附件,登录与病例查询通过
负载 按实际预计同时在线人数测试查询与上传,记录 p95 和错误率

本次核查结果:绕过本地 .bin 权限问题后 TypeScript 前后端检查通过;后端编译通过;前端 Vite 构建未通过:本机 Rollup 原生库被 macOS 签名策略拒绝加载(ERR_DLOPEN_FAILED),没有修改本机安全策略,Linux 干净依赖构建仍待验证;在 /tmp 隔离 SQLite 数据库启动,/api/health 200、管理员登录 200。已复现两个独立进程首次生成相同人员 ID。尚未执行 MySQL 8.0 运行验证、ECS 上线、HTTPS 配置、负载与完整浏览器业务回归;不把这些标为已通过。

14. 备份、恢复与日常维护

至少备份三类内容:数据库、data/uploads、发布包与私有配置。数据库记录与附件必须能对应;仅 ECS 快照不能替代可验证的应用一致性备份。

简单单机方案可以接受每天短暂停写时,建立 /usr/local/sbin/medical-backup,root 0700:

#!/usr/bin/env bash
set -euo pipefail
umask 077
exec 9>/run/lock/medical-backup.lock
flock -n 9 || exit 0
backup_dir="/var/backups/medical/$(date +%Y%m%d-%H%M%S)"
mkdir -p "$backup_dir"
systemctl stop medical
trap 'systemctl start medical' EXIT
mysqldump --single-transaction --quick --no-tablespaces \
  --set-gtid-purged=OFF --databases medical | gzip > "$backup_dir/medical.sql.gz"
tar -czf "$backup_dir/uploads.tar.gz" -C /opt/medical/app/data uploads
install -m 0600 /etc/medical/medical.env "$backup_dir/medical.env"
cp /etc/systemd/system/medical.service "$backup_dir/medical.service"
cp /etc/nginx/sites-available/medical "$backup_dir/nginx.conf"
(cd "$backup_dir" && sha256sum medical.sql.gz uploads.tar.gz > SHA256SUMS)
systemctl start medical
trap - EXIT

此脚本假设 Ubuntu 默认 root 通过 Unix socket 管理本机 MySQL,且没有其他写入程序;若数据库认证方式不同,使用 root-only 的 MySQL option file,不把密码直接写在 cron 或命令行中。RDS 应使用 RDS 备份与相应私有凭据。

第一次手动备份成功并完成恢复演练后再配置 /etc/cron.d/medical-backup

0 3 * * * root /usr/local/sbin/medical-backup >> /var/log/medical-backup.log 2>&1

每天 03:00 Asia/Shanghai 会短暂停服,需选定维护窗口。需要不停服时应设计数据库与附件一致性方案,不能简单去掉 stop/start 后仍宣称一致备份。备份失败必须告警;备份文件加密复制到异机或私有 OSS,配置保留策略,例如日备 7 份、周备 4 份、月备按业务要求。保留期不应由本脚本自动删库决定。

恢复先在隔离实例验证:检查 SHA256SUMS,导入 SQL,再恢复 uploads、配置和对应代码版本;正式恢复在停写窗口执行。示例 gzip -dc medical.sql.gz | sudo mysql 会修改同名库,仅用于确认的恢复目标,不能随意对在线库执行。全库回滚可能丢失备份后的新增数据,须先明确恢复点并保留现场。

常用维护命令:

sudo systemctl status medical mysql nginx --no-pager
sudo journalctl -u medical -f
sudo tail -n 100 /var/log/nginx/medical.error.log
sudo ss -lntp
free -h
df -h
curl -fsS http://127.0.0.1:3001/api/health

建议告警覆盖:进程退出、HTTPS 不可用、业务接口 5xx、磁盘剩余不足 20%、持续高 CPU/内存、数据库连接失败、备份失败和证书到期。对 journald、Nginx 和备份日志配置轮转,避免日志填满磁盘。

更新时先在测试环境验证新版本,备份数据库和附件,留存旧 dist/dist-server/锁文件/配置,在停写窗口安装发布物、复制迁移、重启和验收。不要 rsync --delete 整个应用目录,否则可能删除 data。数据库结构若不兼容旧代码,不能只切回旧程序;按已验证的数据库恢复/向前修复方案处理。

15. 常见故障定位

现象 优先检查
502 Bad Gateway medical 是否启动、3001 监听、数据库初始化报错
users 表不存在 是否建库、迁移 SQL 是否复制、001 是否失败
SQL syntax error 001 key 保留字、007 SQL 拆分、收藏 INSERT OR IGNORE
Duplicate entry / UNIQUE constraint failed 重启后的内存 ID 重复、病例编号竞争
unknown built-in module node:sqlite Node 版本及 systemd ExecStart 是否用错二进制
页面空白/静态资源 404 dist 是否重新构建、WEB_DIST_DIR、Nginx 是否命中正确站点
接口 404 代理是否保留 /api/v1 前缀;健康接口是 /api/health
上传 413 或 500 Nginx 10m 与 Express 10mb、base64 膨胀、错误处理未转译 413
附件记录在但下载空文件 是否迁移了 data/uploads、工作目录是否改变、文件是否被覆盖
数据不见了 DB_DRIVER/数据库名是否正确,是否误用 SQLite 或执行 seed
Access denied MySQL 账号 host 范围、密码、权限和 RDS 白名单
重启后服务未恢复 systemd enable、迁移失败、数据盘挂载和 start-limit
改 .env 没效果 当前项目不自动读取 .env;修改 EnvironmentFile 并 restart

部署交接最终应留下:ECS 与域名清单、软件实际版本、发布版本/包校验和、端口清单、私有凭据保管位置、验收记录、备份恢复记录、监控与证书续期责任人。凭据本身不要写入这份手册。