464 lines
15 KiB
Bash
Executable File
464 lines
15 KiB
Bash
Executable File
#!/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 "============================================================================"
|