This commit is contained in:
lz_db
2026-10-25 11:46:44 +08:00
parent 893dfed0ed
commit 494f667719
63 changed files with 5213 additions and 239 deletions
Executable
+463
View File
@@ -0,0 +1,463 @@
#!/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 "============================================================================"