Files
video/deploy-full.sh
T
2026-10-25 11:46:44 +08:00

464 lines
15 KiB
Bash
Executable File
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.
#!/usr/bin/env bash
# ============================================================================
# 全栈部署脚本:从零到上线(首次部署 / 服务器重装 / 数据迁移后的完整拉起)
#
# 与 deploy.sh 区别:
# deploy.sh = App 发版专用(编译 APK → 部署 → 同步 DB 版本号)
# deploy-full.sh = 整个项目首装/重装/迁移恢复,覆盖 Docker / 后端 / nginx / 后台前端
#
# 用法:
# bash deploy-full.sh # 完整执行(首次部署)
# bash deploy-full.sh --skip-apk # 跳过 App 编译(已有 APK 时提速)
# bash deploy-full.sh --skip-nginx # 跳过 nginx 配置写入(仅重启服务)
# bash deploy-full.sh --skip-admin # 跳过 admin 前端构建
# bash deploy-full.sh --only-backend # 只重启后端 + 健康检查
#
# 前置条件:
# - Debian/Ubuntu 系统,已安装:nginx, docker, mysql-client, python3.11+,
# nodejs 20+, npm, flutter SDK (在 /opt/flutter)
# - 项目代码已 clone 到 /root/project/video
# - DNS 已把 h5/vm/vapi.256kb.top 解析到本机
# ============================================================================
set -euo pipefail
# ---------- 参数解析 ----------
SKIP_APK=0
SKIP_NGINX=0
SKIP_ADMIN=0
ONLY_BACKEND=0
for arg in "$@"; do
case "$arg" in
--skip-apk) SKIP_APK=1 ;;
--skip-nginx) SKIP_NGINX=1 ;;
--skip-admin) SKIP_ADMIN=1 ;;
--only-backend) ONLY_BACKEND=1 ;;
-h|--help)
sed -n '1,30p' "$0"; exit 0 ;;
*) echo "未知参数: $arg"; exit 1 ;;
esac
done
PROJECT_ROOT="/root/project/video"
SERVER_DIR="$PROJECT_ROOT/server"
APP_DIR="$PROJECT_ROOT/app"
ADMIN_DIR="$PROJECT_ROOT/admin"
RUNTIME_DIR="$PROJECT_ROOT/runtime"
H5_DIR="$PROJECT_ROOT/h5"
NGINX_CONF_DIR="/www/server/panel/vhost/nginx"
ACME_WEBROOT="/www/wwwroot/acme-challenge"
DB_NAME="video"
DB_USER="root"
DB_PASS="256kb.cn"
DB_PORT="3307"
log() { echo; echo "▶ $*"; }
ok() { echo " ✅ $*"; }
warn() { echo " ⚠️ $*"; }
die() { echo " ❌ $*"; exit 1; }
# ---------------------------------------------------------------------------
# 0. 前置检查
# ---------------------------------------------------------------------------
log "0. 前置检查"
[ -d "$PROJECT_ROOT" ] || die "项目目录不存在:$PROJECT_ROOT"
[ -f "$SERVER_DIR/.env" ] || die "缺少 $SERVER_DIR/.env,请参考 docs/04-部署运行文档.md 4.2 节配置"
[ -d "$SERVER_DIR/.venv" ] || warn "$SERVER_DIR/.venv 不存在,稍后需要执行 python -m venv .venv && pip install -r requirements.txt"
command -v nginx >/dev/null || die "未安装 nginx"
command -v docker >/dev/null || die "未安装 docker"
command -v mysql >/dev/null || die "未安装 mysql-client"
ok "前置检查通过"
# ---------------------------------------------------------------------------
# 1. 目录结构
# ---------------------------------------------------------------------------
log "1. 创建运行目录"
mkdir -p "$RUNTIME_DIR"/{uploads,videos,downloads,backups} "$H5_DIR" "$ACME_WEBROOT"
chmod 755 "$RUNTIME_DIR" "$RUNTIME_DIR"/{uploads,videos,downloads,backups}
ok "目录创建完成"
[ "$ONLY_BACKEND" = "1" ] && { SKIP_APK=1; SKIP_NGINX=1; SKIP_ADMIN=1; }
# ---------------------------------------------------------------------------
# 2. Docker 组件(MySQL / Redis)
# ---------------------------------------------------------------------------
log "2. 启动 Docker 容器"
# 2.1 MySQL 8(容器名 mysql8,端口 3307,数据 /opt/mysql8-data)
if ! docker ps -a --format '{{.Names}}' | grep -qx mysql8; then
log "2.1 首次创建 mysql8 容器"
mkdir -p /opt/mysql8-data
docker run -d --name mysql8 \
--restart unless-stopped \
-p 3307:3306 \
-e MYSQL_ROOT_PASSWORD="$DB_PASS" \
-e TZ=Asia/Shanghai \
-v /opt/mysql8-data:/var/lib/mysql \
mysql:8 \
--character-set-server=utf8mb4 \
--collation-server=utf8mb4_unicode_ci \
--default-authentication-plugin=mysql_native_password
ok "mysql8 创建完成"
else
docker start mysql8 >/dev/null && ok "mysql8 已启动"
fi
# 2.2 Redis(容器名 my-redis,端口 6379)
if ! docker ps -a --format '{{.Names}}' | grep -qx my-redis; then
log "2.2 首次创建 my-redis 容器"
mkdir -p /home/redis/data /home/redis/conf
docker run -d --name my-redis \
--restart unless-stopped \
-p 6379:6379 \
-v /home/redis/data:/data \
-v /home/redis/conf:/usr/local/etc/redis \
redis:latest \
redis-server --appendonly yes
ok "my-redis 创建完成"
else
docker start my-redis >/dev/null && ok "my-redis 已启动"
fi
# 2.3 等待容器就绪
echo -n " 等待 MySQL 就绪..."
for i in $(seq 1 30); do
docker exec mysql8 mysqladmin ping -h 127.0.0.1 -uroot -p"$DB_PASS" >/dev/null 2>&1 && { echo " OK"; break; }
[ "$i" = "30" ] && die "MySQL 启动超时"
sleep 1
done
echo -n " 等待 Redis 就绪..."
for i in $(seq 1 15); do
[ "$(docker exec my-redis redis-cli ping 2>/dev/null)" = "PONG" ] && { echo " OK"; break; }
[ "$i" = "15" ] && die "Redis 启动超时"
sleep 1
done
# 2.4 创建数据库(首次)
docker exec mysql8 mysql -uroot -p"$DB_PASS" -e \
"CREATE DATABASE IF NOT EXISTS $DB_NAME CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" \
&& ok "数据库 $DB_NAME 已确认存在"
# ---------------------------------------------------------------------------
# 3. 后端服务(video-api.service)
# ---------------------------------------------------------------------------
log "3. 部署后端服务"
# 3.1 Python 虚拟环境
if [ ! -d "$SERVER_DIR/.venv" ]; then
log "3.1 创建虚拟环境"
cd "$SERVER_DIR"
python3 -m venv .venv
.venv/bin/pip install -U pip
.venv/bin/pip install -r requirements.txt
ok ".venv 安装完成"
fi
# 3.2 数据库迁移
log "3.2 执行 alembic 迁移"
cd "$SERVER_DIR"
.venv/bin/alembic upgrade head
ok "数据库迁移完成"
# 3.3 systemd 单元
log "3.3 写入 systemd 单元"
cat > /etc/systemd/system/video-api.service <<'UNIT'
[Unit]
Description=Video App FastAPI Backend (uvicorn)
After=network.target docker.service redis.service
Requires=docker.service
[Service]
Type=simple
User=root
WorkingDirectory=/root/project/video/server
ExecStart=/root/project/video/server/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 --workers 2
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable --now video-api.service
sleep 3
systemctl is-active --quiet video-api.service && ok "video-api.service 已启动并设为开机自启" \
|| die "video-api.service 启动失败,请 journalctl -u video-api -n 50 查看"
# 3.4 后端健康检查
echo -n " 健康检查..."
for i in $(seq 1 10); do
code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8000/health || true)
[ "$code" = "200" ] && { echo " OK ($code)"; break; }
[ "$i" = "10" ] && die "后端健康检查失败(10 次尝试后仍非 200)"
sleep 2
done
# ---------------------------------------------------------------------------
# 4. nginx 配置
# ---------------------------------------------------------------------------
if [ "$SKIP_NGINX" = "0" ]; then
log "4. 写入 nginx 配置(h5 / vm / vapi 三个站点)"
# 通用 SSL 配置片段(这里用 heredoc 内嵌,避免每次写三个文件重复)
_ssl_block() {
local d="$1"
cat <<SSL
ssl_certificate /www/server/panel/vhost/cert/$d/fullchain.pem;
ssl_certificate_key /www/server/panel/vhost/cert/$d/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers EECDH+CHACHA20:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:!MD5;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
SSL
}
_acme_block() {
cat <<ACME
location ^~ /.well-known/acme-challenge/ {
root /www/wwwroot/acme-challenge/;
}
ACME
}
_proxy_block() {
cat <<PROXY
proxy_pass http://127.0.0.1:8000;
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
}
# 4.1 h5.256kb.top
cat > "$NGINX_CONF_DIR/h5.256kb.top.conf" <<NGINX
server {
listen 80;
server_name h5.256kb.top;
$(_acme_block)
location / { return 301 https://\$host\$request_uri; }
}
server {
listen 443 ssl http2;
server_name h5.256kb.top;
$(_ssl_block h5.256kb.top)
index index.html;
root $H5_DIR;
$(_acme_block)
location ~ /\\. { deny all; }
location /downloads/ {
alias $RUNTIME_DIR/downloads/;
autoindex off;
}
location /videos/ {
alias $RUNTIME_DIR/videos/;
autoindex off;
add_header Access-Control-Allow-Origin *;
location ~* \\.m3u8\$ {
add_header Content-Type application/vnd.apple.mpegurl;
add_header Cache-Control "no-cache";
}
location ~* \\.ts\$ {
add_header Content-Type video/mp2t;
add_header Cache-Control "max-age=86400";
}
}
location /uploads/ {
alias $RUNTIME_DIR/uploads/;
autoindex off;
}
location /r/ {
$(_proxy_block)
}
location /api/v1/invite/ {
$(_proxy_block)
}
access_log /www/wwwlogs/h5.256kb.top.log;
error_log /www/wwwlogs/h5.256kb.top.error.log;
}
NGINX
ok "h5.256kb.top.conf"
# 4.2 vm.256kb.top
cat > "$NGINX_CONF_DIR/vm.256kb.top.conf" <<NGINX
server {
listen 80;
server_name vm.256kb.top;
$(_acme_block)
location / { return 301 https://\$host\$request_uri; }
}
server {
listen 443 ssl http2;
server_name vm.256kb.top;
$(_ssl_block vm.256kb.top)
index index.html;
root $ADMIN_DIR/dist;
$(_acme_block)
location ~ /\\. { deny all; }
location /api/ {
$(_proxy_block)
}
location /static/ {
$(_proxy_block)
}
location /r/ {
$(_proxy_block)
}
location / {
try_files \$uri \$uri/ /index.html;
}
access_log /www/wwwlogs/vm.256kb.top.log;
error_log /www/wwwlogs/vm.256kb.top.error.log;
}
NGINX
ok "vm.256kb.top.conf"
# 4.3 vapi.256kb.top
cat > "$NGINX_CONF_DIR/vapi.256kb.top.conf" <<NGINX
server {
listen 80;
server_name vapi.256kb.top;
$(_acme_block)
location / { return 301 https://\$host\$request_uri; }
}
server {
listen 443 ssl http2;
server_name vapi.256kb.top;
$(_ssl_block vapi.256kb.top)
$(_acme_block)
location ~ /\\. { deny all; }
location / {
$(_proxy_block)
client_max_body_size 500m;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
access_log /www/wwwlogs/vapi.256kb.top.log;
error_log /www/wwwlogs/vapi.256kb.top.error.log;
}
NGINX
ok "vapi.256kb.top.conf"
nginx -t && nginx -s reload && ok "nginx 配置已重载"
fi
# ---------------------------------------------------------------------------
# 5. HTTPS 证书(acme.sh)
# ---------------------------------------------------------------------------
if [ "$SKIP_NGINX" = "0" ] && [ ! -f "/www/server/panel/vhost/cert/h5.256kb.top/fullchain.pem" ]; then
log "5. 签发 Let's Encrypt 证书(acme.sh,ECC)"
if [ ! -d "$HOME/.acme.sh" ]; then
warn "未检测到 acme.sh,请先安装:curl https://get.acme.sh | sh -s email=you@example.com"
warn "然后重新执行 bash deploy-full.sh"
else
for d in h5.256kb.top vm.256kb.top vapi.256kb.top; do
if [ ! -f "/www/server/panel/vhost/cert/$d/fullchain.pem" ]; then
echo " 签发 $d..."
~/.acme.sh/acme.sh --issue -d "$d" --webroot "$ACME_WEBROOT" --keylength ec-256
mkdir -p "/www/server/panel/vhost/cert/$d"
~/.acme.sh/acme.sh --install-cert -d "$d" --ecc \
--fullchain-file "/www/server/panel/vhost/cert/$d/fullchain.pem" \
--key-file "/www/server/panel/vhost/cert/$d/privkey.pem" \
--reloadcmd "nginx -t && nginx -s reload"
fi
done
ok "证书签发完成"
fi
fi
# ---------------------------------------------------------------------------
# 6. 后台前端构建
# ---------------------------------------------------------------------------
if [ "$SKIP_ADMIN" = "0" ]; then
log "6. 构建后台前端 admin/"
cd "$ADMIN_DIR"
[ -d node_modules ] || npm install
npm run build
ok "admin/dist 构建完成(nginx vm.256kb.top 直接指向这里,无需重启)"
fi
# ---------------------------------------------------------------------------
# 7. App 编译与部署(APK)
# ---------------------------------------------------------------------------
if [ "$SKIP_APK" = "0" ]; then
log "7. 编译 App APK"
[ -f "$APP_DIR/pubspec.yaml" ] || die "缺少 $APP_DIR/pubspec.yaml"
cd "$APP_DIR"
/opt/flutter/bin/flutter build apk --release
mkdir -p "$RUNTIME_DIR/downloads"
cp -f "$APP_DIR/build/app/outputs/flutter-apk/app-release.apk" \
"$RUNTIME_DIR/downloads/video-app.apk"
chmod 644 "$RUNTIME_DIR/downloads/video-app.apk"
(cd "$APP_DIR/android" && ./gradlew --stop >/dev/null 2>&1) || true
ok "APK 已部署到 $RUNTIME_DIR/downloads/video-app.apk"
# 同步 DB 版本号(让老用户收到更新提示)
PUBSPEC_VERSION=$(grep -E '^version:' "$APP_DIR/pubspec.yaml" | awk '{print $2}')
VERSION_NAME="${PUBSPEC_VERSION%+*}"
VERSION_CODE="${PUBSPEC_VERSION##*+}"
MIN_CODE=$(mysql -h 127.0.0.1 -u"$DB_USER" -p"$DB_PASS" -P"$DB_PORT" "$DB_NAME" -Nse \
"SELECT JSON_UNQUOTE(JSON_EXTRACT(config_value,'\$.min_version_code')) \
FROM system_configs WHERE config_key='app.version.android';" 2>/dev/null || echo "")
MIN_CODE="${MIN_CODE:-1}"
UPDATE_JSON="{\"latest_version\":\"$VERSION_NAME\",\"latest_version_code\":$VERSION_CODE,\"min_version_code\":$MIN_CODE,\"update_url\":\"https://h5.256kb.top/downloads/video-app.apk\",\"notice\":\"\"}"
mysql -h 127.0.0.1 -u"$DB_USER" -p"$DB_PASS" -P"$DB_PORT" "$DB_NAME" -e "
INSERT INTO system_configs (config_key, config_value, description)
VALUES ('app.version.android', '$UPDATE_JSON', 'Android 版本检查配置')
ON DUPLICATE KEY UPDATE config_value=VALUES(config_value), updated_at=NOW();
"
docker exec my-redis redis-cli DEL config:system >/dev/null 2>&1 || true
ok "DB 版本号同步:$VERSION_NAME+$VERSION_CODE"
fi
# ---------------------------------------------------------------------------
# 8. 端到端验证
# ---------------------------------------------------------------------------
log "8. 端到端验证"
echo -n " 后端本地接口..."
curl -sf http://127.0.0.1:8000/health >/dev/null && echo " OK" || echo " FAIL"
echo -n " vapi 公网接口..."
curl -sf https://vapi.256kb.top/api/v1/app/version >/dev/null && echo " OK" || echo " FAIL"
echo -n " h5 下载页..."
curl -sfI https://h5.256kb.top/downloads/video-app.apk >/dev/null && echo " OK" || echo " FAIL"
echo -n " vm 后台首页..."
curl -sf https://vm.256kb.top/ >/dev/null && echo " OK" || echo " FAIL"
echo
echo "============================================================================"
echo "✅ 部署完成"
echo "============================================================================"
echo " 后端: http://127.0.0.1:8000 (systemd: video-api.service)"
echo " 后台: https://vm.256kb.top (admin/dist 静态站点)"
echo " 接口: https://vapi.256kb.top/api/v1/..."
echo " 下载页: https://h5.256kb.top/downloads/video-app.apk"
echo
echo " 启停: bash $PROJECT_ROOT/project-ctl.sh {start|stop|restart|status}"
echo " 日志: journalctl -u video-api -f"
echo " 备份: https://vm.256kb.top → 运营管理 → 数据库备份"
echo "============================================================================"