初始化
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
# 剧核工厂部署脚本
|
||||
|
||||
## 一键推送
|
||||
|
||||
在 Windows 项目根目录执行:
|
||||
|
||||
```bat
|
||||
API\deploy\push.bat root@服务器IP
|
||||
```
|
||||
|
||||
脚本会构建 WEB、ADMIN,交叉编译 Linux amd64 API,并上传二进制、静态文件、迁移、部署脚本以及 `workers`。默认增量推送不会上传本地 `API/.env`,服务器继续使用 `/etc/juyou_ai/juyou_ai.env`,避免 Windows 开发环境配置覆盖 Linux 生产配置。
|
||||
|
||||
仅上传、不执行服务器部署时也可以显式使用:
|
||||
|
||||
```bat
|
||||
API\deploy\push.bat root@服务器IP stage-only
|
||||
```
|
||||
|
||||
只有首次部署或明确需要整体替换服务器环境配置时,才使用:
|
||||
|
||||
```bat
|
||||
API\deploy\push.bat root@服务器IP with-env
|
||||
```
|
||||
|
||||
`with-env` 会把本地 `API/.env` 暂存为服务器 `/tmp/juyou_ai.env`。使用前必须确认其中的 FFmpeg、Python 和脚本路径适用于 Linux;普通增量部署不要使用该选项。
|
||||
|
||||
## 一键部署
|
||||
|
||||
上传完成后,在服务器执行:
|
||||
|
||||
```bash
|
||||
sudo bash /opt/juyou_ai/deploy/one_click_deployment.sh
|
||||
```
|
||||
|
||||
普通增量部署会继续使用 `/etc/juyou_ai/juyou_ai.env`。使用 `with-env` 推送时,部署脚本才会用 `/tmp/juyou_ai.env` 更新生产环境配置。
|
||||
|
||||
部署脚本会安装 `python3-pip`,并在缺失时安装 `faster-whisper 1.2.1`;同时会将 FFmpeg、Python 和音频识别脚本路径规范为 Linux 运行路径。`push.bat` 会在本机下载默认 `small` 模型,并在服务器缺少模型时上传到 `/opt/juyou_ai/models/faster-whisper-small`。服务器部署和运行阶段不会访问 Hugging Face;后续增量部署会保留并复用已经上传的模型。
|
||||
|
||||
项目目录固定为 `/opt/juyou_ai`,systemd 服务名固定为 `juyou_ai`,API 内部端口固定为 `8123`。脚本仅保存域名和证书邮箱;再次执行会自动复用配置。多个域名会全部作为 SAN 追加到同一张证书。脚本只复用输入域名对应的现有证书,不会扫描其他项目的证书;没有匹配证书但填写邮箱时使用本地 `API/acme/acme.sh` 申请 HTTPS,域名填 `_` 或未填写邮箱时使用 HTTP。systemd 从 `/etc/juyou_ai/juyou_ai.env` 加载环境变量,项目目录不会保留 `.env`。
|
||||
|
||||
API、AI Worker 和 HTTP 请求日志统一写入 `/var/log/juyou_ai/juyou_ai.log`。日志每天轮转,最多保留 14 份,单个文件超过 50MB 时会提前轮转;可使用 `sudo tail -f /var/log/juyou_ai/juyou_ai.log` 实时查看。
|
||||
|
||||
完整操作说明参见 [部署文档.md](部署文档.md)。
|
||||
|
||||
第三方接口、网址和密钥字段说明参见 [第三方说明及密钥.md](第三方说明及密钥.md)。
|
||||
|
||||
## 更新 acme.sh
|
||||
|
||||
```bash
|
||||
sudo bash /opt/juyou_ai/deploy/update_acme.sh
|
||||
```
|
||||
|
||||
更新脚本通过 jsDelivr CDN 下载 acmesh-official/acme.sh 的最新 master 单文件版本,不执行 GitHub clone。
|
||||
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# 剧核工厂 Linux 一键部署脚本。
|
||||
# 负责安装 Nginx/PostgreSQL/Redis/FFmpeg、执行迁移、创建 systemd 服务,
|
||||
# 并按需使用本地 API/acme/acme.sh 申请或安装 HTTPS 证书。
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; RESET='\033[0m'
|
||||
info(){ echo -e "${CYAN} -> $*${RESET}"; }
|
||||
ok(){ echo -e "${GREEN} OK $*${RESET}"; }
|
||||
warn(){ echo -e "${YELLOW} !! $*${RESET}"; }
|
||||
fail(){ echo -e "${RED} ERR $*${RESET}" >&2; exit 1; }
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
API_ROOT="$(realpath "${1:-$SCRIPT_DIR/..}")"
|
||||
PROJECT_ROOT="$(realpath "$API_ROOT/..")"
|
||||
CONFIG_FILE="$SCRIPT_DIR/.deploy.conf"
|
||||
SYSTEM_ENV_FILE="/etc/juyou_ai/juyou_ai.env"
|
||||
[ -f "$SCRIPT_DIR/prepare_server.sh" ] || fail "缺少服务器初始化脚本。"
|
||||
chmod +x "$API_ROOT/bin/jcf-api" 2>/dev/null || true
|
||||
[ -x "$API_ROOT/bin/jcf-api" ] || fail "缺少 Linux API 二进制 $API_ROOT/bin/jcf-api,请先执行 push.bat。"
|
||||
[ -f "$API_ROOT/static/web/index.html" ] || fail "缺少 WEB 构建产物。"
|
||||
[ -f "$API_ROOT/static/admin/index.html" ] || fail "缺少 ADMIN 构建产物。"
|
||||
|
||||
if [ -s "$CONFIG_FILE" ]; then
|
||||
# shellcheck disable=SC1090
|
||||
source "$CONFIG_FILE"
|
||||
fi
|
||||
SERVICE_NAME="juyou_ai"
|
||||
APP_PORT="8123"
|
||||
DOMAIN_INPUT="${SAVED_DOMAIN_INPUT:-_}"
|
||||
USER_EMAIL="${SAVED_USER_EMAIL:-}"
|
||||
DOMAINS=()
|
||||
if [ "$DOMAIN_INPUT" != "_" ]; then
|
||||
for domain in $DOMAIN_INPUT; do
|
||||
[[ "$domain" =~ ^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$ ]] || fail "域名格式无效:$domain"
|
||||
DOMAINS+=("$domain")
|
||||
done
|
||||
[ "${#DOMAINS[@]}" -gt 0 ] || fail "至少需要一个有效域名。"
|
||||
DOMAIN_INPUT="${DOMAINS[*]}"
|
||||
FIRST_DOMAIN="${DOMAINS[0]}"
|
||||
fi
|
||||
FIRST_DOMAIN="${FIRST_DOMAIN:-_}"
|
||||
command -v sudo >/dev/null 2>&1 || fail "系统缺少 sudo。"
|
||||
sudo env SAVED_DOMAIN_INPUT="$DOMAIN_INPUT" JCF_ENV_SOURCE="${JCF_ENV_SOURCE:-}" bash "$SCRIPT_DIR/prepare_server.sh" "$API_ROOT"
|
||||
[ -f "$SYSTEM_ENV_FILE" ] || fail "服务器初始化后仍缺少 $SYSTEM_ENV_FILE。"
|
||||
SOCKET_PATH="/run/$SERVICE_NAME/app.sock"
|
||||
CERT_DIR="/etc/nginx/ssl"
|
||||
NGINX_FILE="/etc/nginx/conf.d/$SERVICE_NAME.conf"
|
||||
SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service"
|
||||
LOG_DIR="/var/log/$SERVICE_NAME"
|
||||
LOG_FILE="$LOG_DIR/$SERVICE_NAME.log"
|
||||
LOGROTATE_FILE="/etc/logrotate.d/$SERVICE_NAME"
|
||||
|
||||
# 只读取迁移所需的数据库变量,不直接 source .env。
|
||||
# APP_NAME 等值可能包含空格,直接 source 会被 Bash 误解析。
|
||||
env_value() {
|
||||
local key="$1" value
|
||||
value="$(sudo sed -n -E "s/^${key}=//p" "$SYSTEM_ENV_FILE" | head -n 1)"
|
||||
value="${value#\"}"; value="${value%\"}"
|
||||
printf '%s' "$value"
|
||||
}
|
||||
DATABASE_URL="$(env_value DATABASE_URL)"
|
||||
DATABASE_NAME="$(env_value DATABASE_NAME)"
|
||||
DATABASE_HOST="$(env_value DATABASE_HOST)"
|
||||
DATABASE_PORT="$(env_value DATABASE_PORT)"
|
||||
DATABASE_USER="$(env_value DATABASE_USER)"
|
||||
DATABASE_PASSWORD="$(env_value DATABASE_PASSWORD)"
|
||||
PSQL_ARGS=()
|
||||
if [ -n "${DATABASE_URL:-}" ]; then
|
||||
PSQL_TARGET="$DATABASE_URL"
|
||||
else
|
||||
PSQL_TARGET="dbname=${DATABASE_NAME:-juchuang_factory} host=${DATABASE_HOST:-127.0.0.1} port=${DATABASE_PORT:-5432} user=${DATABASE_USER:-postgres}"
|
||||
export PGPASSWORD="${DATABASE_PASSWORD:-}"
|
||||
fi
|
||||
|
||||
# 使用独立记录表避免重复执行同一个 SQL 迁移。
|
||||
psql "$PSQL_TARGET" -v ON_ERROR_STOP=1 -c 'CREATE TABLE IF NOT EXISTS jcf_schema_migrations (version varchar(128) PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP);' >/dev/null
|
||||
for migration in "$API_ROOT"/migrations/*.up.sql; do
|
||||
[ -f "$migration" ] || continue
|
||||
version="$(basename "$migration")"
|
||||
already="$(psql "$PSQL_TARGET" -At -v ON_ERROR_STOP=1 -c "SELECT 1 FROM jcf_schema_migrations WHERE version='${version//\'/\'\'}' LIMIT 1")"
|
||||
if [ "$already" = "1" ]; then
|
||||
info "跳过已执行迁移:$version"
|
||||
continue
|
||||
fi
|
||||
info "执行数据库迁移:$version"
|
||||
psql "$PSQL_TARGET" -v ON_ERROR_STOP=1 -f "$migration" >/dev/null
|
||||
psql "$PSQL_TARGET" -v ON_ERROR_STOP=1 -c "INSERT INTO jcf_schema_migrations(version) VALUES ('${version//\'/\'\'}')" >/dev/null
|
||||
done
|
||||
unset PGPASSWORD
|
||||
|
||||
sudo chown -R www-data:www-data "$API_ROOT"
|
||||
sudo rm -f "$API_ROOT/.env"
|
||||
sudo mkdir -p "$(dirname "$SOCKET_PATH")" "$CERT_DIR" /var/www/jcf-acme
|
||||
sudo install -d -m 0750 -o www-data -g www-data "$LOG_DIR"
|
||||
sudo touch "$LOG_FILE"
|
||||
sudo chown www-data:www-data "$LOG_FILE"
|
||||
sudo chmod 0640 "$LOG_FILE"
|
||||
|
||||
# 每天轮转 API 与 Worker 的统一日志,并在单个文件过大时提前轮转。
|
||||
sudo tee "$LOGROTATE_FILE" >/dev/null <<EOF
|
||||
$LOG_FILE {
|
||||
daily
|
||||
rotate 14
|
||||
maxsize 50M
|
||||
missingok
|
||||
notifempty
|
||||
compress
|
||||
delaycompress
|
||||
copytruncate
|
||||
}
|
||||
EOF
|
||||
|
||||
# 创建 API 与队列 Worker 同进程 systemd 服务。
|
||||
sudo tee "$SERVICE_FILE" >/dev/null <<EOF
|
||||
[Unit]
|
||||
Description=JuheFactory Go API and AI Worker
|
||||
After=network.target postgresql.service redis-server.service
|
||||
|
||||
[Service]
|
||||
User=www-data
|
||||
Group=www-data
|
||||
WorkingDirectory=$API_ROOT
|
||||
EnvironmentFile=$SYSTEM_ENV_FILE
|
||||
Environment=HOME=$API_ROOT
|
||||
CacheDirectory=juyou_ai
|
||||
ExecStart=$API_ROOT/bin/jcf-api
|
||||
StandardOutput=append:$LOG_FILE
|
||||
StandardError=append:$LOG_FILE
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable "$SERVICE_NAME" --now
|
||||
|
||||
mkdir -p "$API_ROOT/static/web" "$API_ROOT/static/admin"
|
||||
EXISTING_CERT_BUNDLE="$CERT_DIR/${FIRST_DOMAIN}_bundle.crt"
|
||||
EXISTING_CERT_KEY="$CERT_DIR/${FIRST_DOMAIN}.key"
|
||||
if [ "$FIRST_DOMAIN" != "_" ] && [ -f "$EXISTING_CERT_BUNDLE" ] && [ -f "$EXISTING_CERT_KEY" ]; then
|
||||
info "检测到现有 SSL 证书,跳过 ACME 申请并复用证书"
|
||||
USE_SSL=true
|
||||
elif [ "$FIRST_DOMAIN" != "_" ] && [ -n "$USER_EMAIL" ]; then
|
||||
# 先用 HTTP 临时站点提供 ACME HTTP-01 验证目录。
|
||||
sudo tee "$NGINX_FILE" >/dev/null <<EOF
|
||||
server {
|
||||
listen 80;
|
||||
server_name $DOMAIN_INPUT;
|
||||
location ^~ /.well-known/acme-challenge/ { root /var/www/jcf-acme; }
|
||||
location / { root $API_ROOT/static/web; try_files \$uri \$uri/ /index.html; }
|
||||
}
|
||||
EOF
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
ACME_BIN="$API_ROOT/acme/acme.sh"
|
||||
[ -f "$ACME_BIN" ] || fail "缺少本地 ACME 脚本,请先执行 API/deploy/update_acme.sh。"
|
||||
chmod +x "$ACME_BIN"
|
||||
export LE_CONFIG_HOME="/root/.acme.sh"
|
||||
bash "$ACME_BIN" --set-default-ca --server letsencrypt
|
||||
ACME_DOMAIN_ARGS=()
|
||||
for domain in "${DOMAINS[@]}"; do ACME_DOMAIN_ARGS+=( -d "$domain" ); done
|
||||
bash "$ACME_BIN" --issue --webroot /var/www/jcf-acme "${ACME_DOMAIN_ARGS[@]}" --accountemail "$USER_EMAIL"
|
||||
bash "$ACME_BIN" --install-cert -d "$FIRST_DOMAIN" \
|
||||
--key-file "$CERT_DIR/${FIRST_DOMAIN}.key" \
|
||||
--fullchain-file "$CERT_DIR/${FIRST_DOMAIN}_bundle.crt" \
|
||||
--reloadcmd "systemctl reload nginx"
|
||||
USE_SSL=true
|
||||
else
|
||||
USE_SSL=false
|
||||
[ "$FIRST_DOMAIN" = "_" ] || warn "未填写证书邮箱,部署为 HTTP。"
|
||||
fi
|
||||
|
||||
if [ "$USE_SSL" = true ]; then
|
||||
PROTOCOL=https
|
||||
sudo tee "$NGINX_FILE" >/dev/null <<EOF
|
||||
server { listen 80; server_name $DOMAIN_INPUT; return 301 https://\$host\$request_uri; }
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name $DOMAIN_INPUT;
|
||||
ssl_certificate $CERT_DIR/${FIRST_DOMAIN}_bundle.crt;
|
||||
ssl_certificate_key $CERT_DIR/${FIRST_DOMAIN}.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
client_max_body_size 2g;
|
||||
location /api/ { proxy_pass http://127.0.0.1:$APP_PORT; 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_read_timeout 300; proxy_buffering off; }
|
||||
location ~ ^/(health|docs|redoc|openapi.json)(/.*)?\$ { proxy_pass http://127.0.0.1:$APP_PORT; proxy_set_header Host \$host; }
|
||||
location /admin/ { alias $API_ROOT/static/admin/; try_files \$uri \$uri/ /admin/index.html; }
|
||||
location / { root $API_ROOT/static/web; try_files \$uri \$uri/ /index.html; }
|
||||
}
|
||||
EOF
|
||||
else
|
||||
PROTOCOL=http
|
||||
sudo tee "$NGINX_FILE" >/dev/null <<EOF
|
||||
server {
|
||||
listen 80;
|
||||
server_name $DOMAIN_INPUT;
|
||||
client_max_body_size 2g;
|
||||
location /api/ { proxy_pass http://127.0.0.1:$APP_PORT; 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_read_timeout 300; proxy_buffering off; }
|
||||
location ~ ^/(health|docs|redoc|openapi.json)(/.*)?\$ { proxy_pass http://127.0.0.1:$APP_PORT; proxy_set_header Host \$host; }
|
||||
location /admin/ { alias $API_ROOT/static/admin/; try_files \$uri \$uri/ /admin/index.html; }
|
||||
location / { root $API_ROOT/static/web; try_files \$uri \$uri/ /index.html; }
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx || sudo systemctl restart nginx
|
||||
sudo systemctl restart "$SERVICE_NAME"
|
||||
if [ -n "${JCF_ENV_SOURCE:-}" ]; then
|
||||
rm -f -- "$JCF_ENV_SOURCE"
|
||||
fi
|
||||
ok "部署完成:${PROTOCOL}://${FIRST_DOMAIN},服务名:$SERVICE_NAME"
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from faster_whisper.utils import download_model
|
||||
|
||||
|
||||
MODEL_SIZE = "small"
|
||||
MODEL_DIR = Path(__file__).resolve().parent.parent / "models" / f"faster-whisper-{MODEL_SIZE}"
|
||||
REQUIRED_FILES = ("config.json", "model.bin", "tokenizer.json", "vocabulary.txt")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
missing = [name for name in REQUIRED_FILES if not (MODEL_DIR / name).is_file()]
|
||||
if not missing:
|
||||
print(f"Whisper model already exists: {MODEL_DIR}")
|
||||
return 0
|
||||
|
||||
MODEL_DIR.mkdir(parents=True, exist_ok=True)
|
||||
print(f"Downloading faster-whisper-{MODEL_SIZE} to {MODEL_DIR} ...")
|
||||
download_model(MODEL_SIZE, output_dir=str(MODEL_DIR))
|
||||
missing = [name for name in REQUIRED_FILES if not (MODEL_DIR / name).is_file()]
|
||||
if missing:
|
||||
raise RuntimeError(f"Whisper model download is incomplete; missing: {', '.join(missing)}")
|
||||
print(f"Whisper model is ready: {MODEL_DIR}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# 剧核工厂一键部署入口。
|
||||
# 第一次执行会询问部署参数并保存到 API/deploy/.deploy.conf;以后执行会自动复用。
|
||||
# 完整部署流程在同目录的 deploy_with_ssl.sh 中,便于后续单独重复执行。
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
API_ROOT="$(realpath "$SCRIPT_DIR/..")"
|
||||
PROJECT_ROOT="$(realpath "$API_ROOT/..")"
|
||||
CONFIG_FILE="$SCRIPT_DIR/.deploy.conf"
|
||||
|
||||
if [ -s "$CONFIG_FILE" ]; then
|
||||
# shellcheck disable=SC1090
|
||||
source "$CONFIG_FILE"
|
||||
else
|
||||
SAVED_DOMAIN_INPUT=""
|
||||
SAVED_USER_EMAIL=""
|
||||
EXISTING_NGINX_CONFIG="/etc/nginx/conf.d/juyou_ai.conf"
|
||||
if [ -f "$EXISTING_NGINX_CONFIG" ]; then
|
||||
SAVED_DOMAIN_INPUT="$(sed -n -E 's/^[[:space:]]*server_name[[:space:]]+([^;]+);.*$/\1/p' "$EXISTING_NGINX_CONFIG" | head -n 1)"
|
||||
SAVED_DOMAIN_INPUT="${SAVED_DOMAIN_INPUT//_/}"
|
||||
SAVED_DOMAIN_INPUT="$(printf '%s' "$SAVED_DOMAIN_INPUT" | xargs)"
|
||||
fi
|
||||
if [ -z "$SAVED_DOMAIN_INPUT" ]; then
|
||||
read -r -p "域名(多个域名空格分隔,将全部追加到证书;不申请 SSL 填 _)[_]: " SAVED_DOMAIN_INPUT
|
||||
SAVED_DOMAIN_INPUT="${SAVED_DOMAIN_INPUT:-_}"
|
||||
fi
|
||||
FIRST_SAVED_DOMAIN="${SAVED_DOMAIN_INPUT%% *}"
|
||||
EXISTING_CERT_READY=false
|
||||
if [ -n "$FIRST_SAVED_DOMAIN" ] && [ -f "/etc/nginx/ssl/${FIRST_SAVED_DOMAIN}_bundle.crt" ] && [ -f "/etc/nginx/ssl/${FIRST_SAVED_DOMAIN}.key" ]; then
|
||||
EXISTING_CERT_READY=true
|
||||
fi
|
||||
if [ "$SAVED_DOMAIN_INPUT" != "_" ] && [ "$EXISTING_CERT_READY" = true ]; then
|
||||
echo "检测到当前域名的现有证书,复用域名和证书:$SAVED_DOMAIN_INPUT"
|
||||
elif [ "$SAVED_DOMAIN_INPUT" != "_" ]; then
|
||||
read -r -p "SSL 证书邮箱(申请新证书;不申请 SSL 直接回车): " SAVED_USER_EMAIL
|
||||
fi
|
||||
cat >"$CONFIG_FILE" <<EOF
|
||||
# 部署脚本自动保存的配置,可直接编辑后重新运行。
|
||||
SAVED_DOMAIN_INPUT=$(printf '%q' "$SAVED_DOMAIN_INPUT")
|
||||
SAVED_USER_EMAIL=$(printf '%q' "$SAVED_USER_EMAIL")
|
||||
EOF
|
||||
fi
|
||||
|
||||
SAVED_DOMAIN_INPUT="${SAVED_DOMAIN_INPUT//$'\r'/}"
|
||||
SAVED_USER_EMAIL="${SAVED_USER_EMAIL//$'\r'/}"
|
||||
export SAVED_DOMAIN_INPUT SAVED_USER_EMAIL
|
||||
if [ -z "${JCF_ENV_SOURCE:-}" ] && [ -f /tmp/juyou_ai.env ]; then
|
||||
export JCF_ENV_SOURCE=/tmp/juyou_ai.env
|
||||
fi
|
||||
exec bash "$SCRIPT_DIR/deploy_with_ssl.sh" "$API_ROOT"
|
||||
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Prepare the Linux host and install the uploaded environment file outside the app directory.
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
API_ROOT="$(realpath "${1:-$SCRIPT_DIR/..}")"
|
||||
SOURCE_ENV="${JCF_ENV_SOURCE:-}"
|
||||
SYSTEM_ENV_DIR="/etc/juyou_ai"
|
||||
SYSTEM_ENV_FILE="$SYSTEM_ENV_DIR/juyou_ai.env"
|
||||
ASR_CACHE_ROOT="/var/cache/juyou_ai"
|
||||
ASR_HF_HOME="$ASR_CACHE_ROOT/huggingface"
|
||||
ASR_MODEL_PATH="$API_ROOT/models/faster-whisper-small"
|
||||
|
||||
info(){ printf ' -> %s\n' "$*"; }
|
||||
fail(){ printf ' ERR %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || fail "请使用 sudo 或 root 执行。"
|
||||
|
||||
info "安装 PostgreSQL、Redis、Nginx、FFmpeg 与基础工具..."
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update -qq
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sudo nginx postgresql postgresql-contrib redis-server ffmpeg curl socat ca-certificates openssl logrotate python3 python3-pip
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
dnf install -y sudo nginx postgresql-server postgresql-contrib redis ffmpeg curl socat ca-certificates openssl logrotate python3 python3-pip
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
yum install -y sudo nginx postgresql-server postgresql-contrib redis ffmpeg curl socat ca-certificates openssl logrotate python3 python3-pip
|
||||
else
|
||||
fail "不支持当前 Linux 发行版:未找到 apt-get、dnf 或 yum。"
|
||||
fi
|
||||
|
||||
if ! python3 -c 'import faster_whisper' >/dev/null 2>&1; then
|
||||
PIP_ARGS=(install --disable-pip-version-check)
|
||||
if python3 -m pip install --help 2>/dev/null | grep -q -- '--break-system-packages'; then
|
||||
PIP_ARGS+=(--break-system-packages)
|
||||
fi
|
||||
python3 -m pip "${PIP_ARGS[@]}" 'faster-whisper==1.2.1'
|
||||
fi
|
||||
python3 -c 'import faster_whisper' >/dev/null 2>&1 || fail "faster-whisper installation failed."
|
||||
|
||||
if command -v postgresql-setup >/dev/null 2>&1 && [ ! -f /var/lib/pgsql/data/PG_VERSION ]; then
|
||||
info "初始化 PostgreSQL 数据目录..."
|
||||
postgresql-setup --initdb
|
||||
fi
|
||||
systemctl enable --now postgresql >/dev/null 2>&1 || fail "PostgreSQL 启动失败。"
|
||||
if systemctl list-unit-files redis-server.service >/dev/null 2>&1; then
|
||||
systemctl enable --now redis-server >/dev/null
|
||||
else
|
||||
systemctl enable --now redis >/dev/null || fail "Redis 启动失败。"
|
||||
fi
|
||||
systemctl enable --now nginx >/dev/null
|
||||
sudo -u postgres pg_isready >/dev/null || fail "PostgreSQL 未就绪。"
|
||||
redis-cli ping | grep -q '^PONG$' || fail "Redis 未就绪。"
|
||||
|
||||
install -d -m 700 -o root -g root "$SYSTEM_ENV_DIR"
|
||||
if [ -n "$SOURCE_ENV" ] && [ -f "$SOURCE_ENV" ]; then
|
||||
install -m 600 -o root -g root "$SOURCE_ENV" "$SYSTEM_ENV_FILE"
|
||||
info "已将上传的环境配置安装到 $SYSTEM_ENV_FILE。"
|
||||
elif [ -f "$SYSTEM_ENV_FILE" ]; then
|
||||
info "未上传新配置,继续使用 $SYSTEM_ENV_FILE。"
|
||||
else
|
||||
fail "缺少上传的 API/.env,且服务器不存在 $SYSTEM_ENV_FILE。"
|
||||
fi
|
||||
# The application directory must not retain deployment secrets.
|
||||
rm -f "$API_ROOT/.env"
|
||||
|
||||
env_value() {
|
||||
local key="$1"
|
||||
sed -n -E "s/^${key}=//p" "$SYSTEM_ENV_FILE" | head -n 1 | tr -d '\r' | sed -E 's/^"//;s/"$//'
|
||||
}
|
||||
set_env() {
|
||||
local key="$1" value="$2" temporary
|
||||
temporary="$(mktemp "$SYSTEM_ENV_DIR/env.XXXXXX")"
|
||||
awk -v key="$key" -v value="$value" '
|
||||
BEGIN { found=0 }
|
||||
index($0, key "=")==1 { print key "=" value; found=1; next }
|
||||
{ print }
|
||||
END { if (!found) print key "=" value }
|
||||
' "$SYSTEM_ENV_FILE" >"$temporary"
|
||||
install -m 600 -o root -g root "$temporary" "$SYSTEM_ENV_FILE"
|
||||
rm -f "$temporary"
|
||||
}
|
||||
require_env() {
|
||||
local key="$1" value
|
||||
value="$(env_value "$key")"
|
||||
[ -n "$value" ] || fail "$key 未配置。"
|
||||
[[ "$value" != *replace-with* && "$value" != *example.com* && "$value" != *change-me* && "$value" != "juchuang_dev" ]] || fail "$key 仍是示例或开发环境值。"
|
||||
}
|
||||
|
||||
APP_PORT="8123"
|
||||
DOMAIN_INPUT="${SAVED_DOMAIN_INPUT:-_}"
|
||||
if ! [[ "$APP_PORT" =~ ^[0-9]+$ ]] || [ "$APP_PORT" -lt 1 ] || [ "$APP_PORT" -gt 65535 ]; then
|
||||
fail "API 端口无效:$APP_PORT"
|
||||
fi
|
||||
CORS_ORIGINS=""
|
||||
if [ "$DOMAIN_INPUT" != "_" ]; then
|
||||
for domain in $DOMAIN_INPUT; do
|
||||
[[ "$domain" =~ ^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$ ]] || fail "域名格式无效:$domain"
|
||||
[ -n "$CORS_ORIGINS" ] && CORS_ORIGINS+=","
|
||||
CORS_ORIGINS+="https://$domain,http://$domain"
|
||||
done
|
||||
fi
|
||||
|
||||
# Infrastructure values are normalized for native Linux services; application secrets stay unchanged.
|
||||
set_env APP_ENV production
|
||||
set_env DEBUG false
|
||||
set_env SERVER_ADDRESS "127.0.0.1:$APP_PORT"
|
||||
set_env DATABASE_HOST 127.0.0.1
|
||||
set_env DATABASE_PORT 5432
|
||||
set_env DATABASE_URL ""
|
||||
set_env REDIS_HOST 127.0.0.1
|
||||
set_env REDIS_PORT 6379
|
||||
set_env HTTP_PROXY ""
|
||||
set_env HTTPS_PROXY ""
|
||||
set_env ALL_PROXY ""
|
||||
set_env http_proxy ""
|
||||
set_env https_proxy ""
|
||||
set_env all_proxy ""
|
||||
set_env CORS_ORIGINS "$CORS_ORIGINS"
|
||||
set_env FFMPEG_PATH ffmpeg
|
||||
set_env FFPROBE_PATH ffprobe
|
||||
set_env PYTHON_PATH python3
|
||||
set_env ASR_SCRIPT_PATH "$API_ROOT/workers/transcribe.py"
|
||||
set_env JCF_ASR_CACHE_DIR "$ASR_CACHE_ROOT"
|
||||
set_env HF_HOME "$ASR_HF_HOME"
|
||||
set_env FASTER_WHISPER_MODEL_PATH "$ASR_MODEL_PATH"
|
||||
[ -f "$API_ROOT/workers/transcribe.py" ] || fail "Missing ASR script: $API_ROOT/workers/transcribe.py"
|
||||
for model_file in config.json model.bin tokenizer.json vocabulary.txt; do
|
||||
[ -f "$ASR_MODEL_PATH/$model_file" ] || fail "离线 Whisper 模型不完整,缺少:$ASR_MODEL_PATH/$model_file。请重新执行 push.bat 上传模型。"
|
||||
done
|
||||
|
||||
install -d -m 750 -o www-data -g www-data "$ASR_CACHE_ROOT" "$ASR_HF_HOME"
|
||||
chown -R www-data:www-data "$ASR_MODEL_PATH"
|
||||
info "使用离线 faster-whisper 模型:$ASR_MODEL_PATH。"
|
||||
|
||||
for key in DATABASE_NAME DATABASE_USER DATABASE_PASSWORD JWT_SECRET_KEY ADMIN_BOOTSTRAP_PASSWORD CONFIG_ENCRYPTION_KEY COS_SECRET_ID COS_SECRET_KEY COS_BUCKET COS_REGION COS_ENDPOINT COS_PUBLIC_BASE_URL; do
|
||||
require_env "$key"
|
||||
done
|
||||
|
||||
DB_NAME="$(env_value DATABASE_NAME)"
|
||||
DB_USER="$(env_value DATABASE_USER)"
|
||||
DB_PASSWORD="$(env_value DATABASE_PASSWORD)"
|
||||
[[ "$DB_NAME" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || fail "DATABASE_NAME 只允许字母、数字和下划线。"
|
||||
[[ "$DB_USER" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || fail "DATABASE_USER 只允许字母、数字和下划线。"
|
||||
|
||||
if ! sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'" | grep -q 1; then
|
||||
sudo -u postgres psql -v ON_ERROR_STOP=1 --set=password="$DB_PASSWORD" >/dev/null <<SQL
|
||||
SET password_encryption = 'scram-sha-256';
|
||||
CREATE ROLE "$DB_USER" LOGIN PASSWORD :'password';
|
||||
SQL
|
||||
else
|
||||
sudo -u postgres psql -v ON_ERROR_STOP=1 --set=password="$DB_PASSWORD" >/dev/null <<SQL
|
||||
SET password_encryption = 'scram-sha-256';
|
||||
ALTER ROLE "$DB_USER" WITH LOGIN PASSWORD :'password';
|
||||
SQL
|
||||
fi
|
||||
if ! sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'" | grep -q 1; then
|
||||
sudo -u postgres createdb -O "$DB_USER" "$DB_NAME"
|
||||
fi
|
||||
sudo -u postgres psql -v ON_ERROR_STOP=1 -d "$DB_NAME" -c "ALTER DATABASE \"$DB_NAME\" SET timezone TO 'Asia/Shanghai';" >/dev/null
|
||||
HBA_FILE="$(sudo -u postgres psql -Atc 'SHOW hba_file')"
|
||||
if ! grep -q '^# JCF local application access$' "$HBA_FILE"; then
|
||||
sed -i "1i# JCF local application access\nhost all all 127.0.0.1/32 scram-sha-256\nhost all all ::1/128 scram-sha-256" "$HBA_FILE"
|
||||
systemctl reload postgresql
|
||||
fi
|
||||
info "PostgreSQL 数据库与用户已初始化,Redis 已启动。"
|
||||
@@ -0,0 +1,69 @@
|
||||
@echo off
|
||||
REM JCF deployment: build locally, upload over SSH, and deploy on Linux.
|
||||
REM Usage: push.bat user@host [stage-only|with-env]
|
||||
|
||||
setlocal DisableDelayedExpansion
|
||||
cd /d "%~dp0..\.."
|
||||
if "%~1"=="" (
|
||||
echo Usage: push.bat user@host [stage-only^|with-env]
|
||||
echo Example: push.bat root@1.2.3.4
|
||||
exit /b 1
|
||||
)
|
||||
set "REMOTE=%~1"
|
||||
set "REMOTE_ROOT=/opt/juyou_ai"
|
||||
set "PACKAGE_ENV="
|
||||
if "%~2"=="with-env" set "PACKAGE_ENV=.env"
|
||||
if not "%~2"=="" if not "%~2"=="stage-only" if not "%~2"=="with-env" (
|
||||
echo The second argument can only be stage-only or with-env.
|
||||
exit /b 1
|
||||
)
|
||||
if "%~2"=="with-env" if not exist "API\.env" (
|
||||
echo Missing API\.env. Deployment environment cannot be uploaded.
|
||||
exit /b 1
|
||||
)
|
||||
if not "%~3"=="" (
|
||||
echo Too many arguments.
|
||||
exit /b 1
|
||||
)
|
||||
set "ARCHIVE=%TEMP%\juyou-deploy-%RANDOM%-%RANDOM%.tar.gz"
|
||||
set "REMOTE_ARCHIVE=/tmp/juyou-deploy.tar.gz"
|
||||
set "REMOTE_ENV=/tmp/juyou_ai.env"
|
||||
set "PACKAGE_MODEL="
|
||||
|
||||
echo [0/5] Checking offline Whisper model...
|
||||
ssh "%REMOTE%" "test -f %REMOTE_ROOT%/models/faster-whisper-small/config.json && test -f %REMOTE_ROOT%/models/faster-whisper-small/model.bin && test -f %REMOTE_ROOT%/models/faster-whisper-small/tokenizer.json && test -f %REMOTE_ROOT%/models/faster-whisper-small/vocabulary.txt" >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
python "API\deploy\download_whisper_model.py"
|
||||
if errorlevel 1 exit /b 1
|
||||
set "PACKAGE_MODEL=models"
|
||||
) else (
|
||||
echo Offline Whisper model already exists on the server.
|
||||
)
|
||||
|
||||
echo [1/5] Building WEB and ADMIN...
|
||||
call npm --prefix WEB run build
|
||||
if errorlevel 1 exit /b 1
|
||||
call npm --prefix ADMIN run build
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
echo [2/5] Building Linux amd64 API...
|
||||
pushd API
|
||||
set "GOOS=linux"
|
||||
set "GOARCH=amd64"
|
||||
go build -trimpath -ldflags "-s -w" -o bin/jcf-api ./cmd/api
|
||||
if errorlevel 1 (popd & exit /b 1)
|
||||
popd
|
||||
|
||||
echo [3/5] Packaging deployment files...
|
||||
tar --exclude=.git --exclude=tools/ffmpeg --exclude=deploy/.deploy.conf -C API -czf "%ARCHIVE%" bin static deploy migrations acme workers %PACKAGE_MODEL% %PACKAGE_ENV%
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
echo [4/5] Uploading and staging deployment files...
|
||||
scp "%ARCHIVE%" "%REMOTE%:%REMOTE_ARCHIVE%"
|
||||
if errorlevel 1 exit /b 1
|
||||
ssh "%REMOTE%" "bash -lc 'set -e; WORK=/tmp/juyou-deploy-work; ENV_UPLOAD=%REMOTE_ENV%; cleanup(){ rm -rf $WORK %REMOTE_ARCHIVE%; }; trap cleanup EXIT; rm -rf $WORK; mkdir -p $WORK; tar -xzf %REMOTE_ARCHIVE% -C $WORK; if [ -f $WORK/.env ]; then install -m 600 $WORK/.env $ENV_UPLOAD; else rm -f $ENV_UPLOAD; fi; rm -f $WORK/.env; mkdir -p %REMOTE_ROOT%; if command -v rsync >/dev/null 2>&1; then rsync -a --delete --exclude=/.env --exclude=/media/ --exclude=/backups/ --exclude=/models/ $WORK/ %REMOTE_ROOT%/; if [ -d $WORK/models ]; then mkdir -p %REMOTE_ROOT%/models; rsync -a $WORK/models/ %REMOTE_ROOT%/models/; fi; else cp -a $WORK/. %REMOTE_ROOT%/; fi; echo upload complete; echo run manually: bash %REMOTE_ROOT%/deploy/one_click_deployment.sh'"
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
echo [5/5] Cleaning local temporary archive...
|
||||
del "%ARCHIVE%" >nul 2>nul
|
||||
echo Deployment push complete.
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# 一键更新本地 acme.sh。
|
||||
# 使用 jsDelivr CDN 获取 acmesh-official/acme.sh 的 master 最新版本,
|
||||
# 不执行 GitHub clone,也不依赖 GitHub 下载地址;脚本保存到 API/acme/。
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
API_ROOT="$(realpath "$SCRIPT_DIR/..")"
|
||||
ACME_DIR="$API_ROOT/acme"
|
||||
ACME_FILE="$ACME_DIR/acme.sh"
|
||||
SOURCE_URL="https://cdn.jsdelivr.net/gh/acmesh-official/acme.sh@master/acme.sh"
|
||||
mkdir -p "$ACME_DIR"
|
||||
TEMP_FILE="$(mktemp "$ACME_DIR/acme.sh.XXXXXX")"
|
||||
trap 'rm -f "$TEMP_FILE"' EXIT
|
||||
|
||||
command -v curl >/dev/null 2>&1 || { echo "缺少 curl,请先安装 curl。" >&2; exit 1; }
|
||||
echo "正在从非 GitHub CDN 下载 acme.sh 最新版本..."
|
||||
curl -fsSL --retry 3 --connect-timeout 15 "$SOURCE_URL" -o "$TEMP_FILE"
|
||||
grep -q '^VER=' "$TEMP_FILE" || { echo "下载内容不是有效的 acme.sh 文件。" >&2; exit 1; }
|
||||
chmod 755 "$TEMP_FILE"
|
||||
mv -f "$TEMP_FILE" "$ACME_FILE"
|
||||
echo "acme.sh 已更新到:$ACME_FILE"
|
||||
grep '^VER=' "$ACME_FILE" | head -n 1
|
||||
Reference in New Issue
Block a user