# shellcheck shell=bash # AI 建站 Linux 部署公共函数。由 start/restart/stop/pull-and-restart 在 ROOT 下 source。 run_sudo() { if [ "$(id -u)" -eq 0 ]; then "$@" else sudo "$@" fi } ensure_runtime_dirs() { mkdir -p "$ROOT/.runtime/pgdata" "$ROOT/.runtime/uploads" "$ROOT/.runtime/logs" \ "$ROOT/.runtime/logs/generate" "$ROOT/web/dist" } ensure_env_file() { if [ ! -f "$ROOT/.env" ]; then if [ -f "$ROOT/.env.example" ]; then cp "$ROOT/.env.example" "$ROOT/.env" echo "已从 .env.example 创建 .env,请按需填写 API Key。" else cat >"$ROOT/.env" <<'EOF' LLM_PROVIDER=deepseek DEEPSEEK_API_KEY= MINIMAX_API_KEY= EOF echo "已创建默认 .env" fi fi sed -i 's/\r$//' "$ROOT/.env" 2>/dev/null || true # 供 compose 变量替换与本脚本健康检查使用 set -a # shellcheck disable=SC1091 [ -f "$ROOT/.env" ] && . "$ROOT/.env" set +a export AIJZ_WEB_PUBLISH="${AIJZ_WEB_PUBLISH:-127.0.0.1:5173}" export AIJZ_GATEWAY_PUBLISH="${AIJZ_GATEWAY_PUBLISH:-127.0.0.1:8180}" export AIJZ_PLATFORM_PUBLISH="${AIJZ_PLATFORM_PUBLISH:-127.0.0.1:8888}" export AIJZ_AI_PUBLISH="${AIJZ_AI_PUBLISH:-127.0.0.1:8001}" export AIJZ_PG_PUBLISH="${AIJZ_PG_PUBLISH:-127.0.0.1:5432}" export AIJZ_ENABLE_HOST_NGINX="${AIJZ_ENABLE_HOST_NGINX:-0}" export AIJZ_DOMAIN="${AIJZ_DOMAIN:-}" export AIJZ_PUBLIC_BASE_URL="${AIJZ_PUBLIC_BASE_URL:-}" } # 若 .env 设了 AIJZ_PUBLIC_BASE_URL,同步到 platform.docker.yaml(挂载文件,restart platform 即生效) apply_public_base_url() { local base="${AIJZ_PUBLIC_BASE_URL:-}" local pf="$ROOT/platform/etc/platform.docker.yaml" [ -n "$base" ] || return 0 [ -f "$pf" ] || return 0 base="${base%/}" local storage_base="${base}/api/v1/storage" if grep -q '^PublicBaseURL:' "$pf"; then sed -i "s|^PublicBaseURL:.*|PublicBaseURL: \"${base}\"|" "$pf" fi if grep -q '^ PublicBase:' "$pf"; then sed -i "s|^ PublicBase:.*| PublicBase: \"${storage_base}\"|" "$pf" fi echo "已同步 PublicBaseURL -> ${base}(platform/etc/platform.docker.yaml)" } sync_ssl_certs() { local domain="${AIJZ_DOMAIN:-}" [ -n "$domain" ] || return 0 local ssl_dir="/etc/ssl/aijianzhan/$domain" run_sudo mkdir -p "$ssl_dir" if [ -f "$ROOT/nginx/$domain.pem" ] && [ -f "$ROOT/nginx/$domain.key" ]; then run_sudo cp -f "$ROOT/nginx/$domain.pem" "$ssl_dir/fullchain.pem" run_sudo cp -f "$ROOT/nginx/$domain.key" "$ssl_dir/privkey.pem" elif [ -f "$ROOT/nginx/fullchain.pem" ] && [ -f "$ROOT/nginx/privkey.pem" ]; then run_sudo cp -f "$ROOT/nginx/fullchain.pem" "$ROOT/nginx/privkey.pem" "$ssl_dir/" elif [ -f "$ROOT/nginx/$domain/fullchain.pem" ] && [ -f "$ROOT/nginx/$domain/privkey.pem" ]; then run_sudo cp -f "$ROOT/nginx/$domain/fullchain.pem" "$ROOT/nginx/$domain/privkey.pem" "$ssl_dir/" else echo "提示: 未找到 nginx/ 下证书,宿主机 HTTPS 需手动放入 $ssl_dir" >&2 return 0 fi run_sudo chmod 644 "$ssl_dir/fullchain.pem" 2>/dev/null || true run_sudo chmod 600 "$ssl_dir/privkey.pem" 2>/dev/null || true echo "证书已同步 -> $ssl_dir" } host_nginx_online() { command -v systemctl >/dev/null 2>&1 || return 1 systemctl is-active nginx >/dev/null 2>&1 && return 0 systemctl is-active nginx.service >/dev/null 2>&1 && return 0 return 1 } ensure_host_nginx_started() { command -v nginx >/dev/null 2>&1 || { echo "错误: 未安装 nginx,无法启用 AIJZ_ENABLE_HOST_NGINX" >&2 exit 1 } if host_nginx_online; then return 0 fi echo "宿主机 Nginx 未在线,尝试启动..." run_sudo systemctl start nginx 2>/dev/null || run_sudo systemctl start nginx.service run_sudo systemctl enable nginx 2>/dev/null || true host_nginx_online || { echo "错误: 无法启动宿主机 nginx" >&2 exit 1 } } install_host_nginx_site_conf() { local domain="${AIJZ_DOMAIN:-}" [ -n "$domain" ] || return 0 [ "${AIJZ_ENABLE_HOST_NGINX:-0}" = "1" ] || return 0 local tpl="$ROOT/nginx/aijz.host.conf" local out="/etc/nginx/conf.d/aijz_${domain}.conf" local web_port web_port="$(publish_host_port "${AIJZ_WEB_PUBLISH:-127.0.0.1:5173}")" [ -f "$tpl" ] || { echo "未找到 $tpl,跳过宿主机 Nginx 配置" >&2 return 0 } mkdir -p "$ROOT/verify-root" sync_ssl_certs sed -e "s|__DOMAIN__|${domain}|g" \ -e "s|__WEB_PORT__|${web_port}|g" \ -e "s|__VERIFY_ROOT__|${ROOT}/verify-root|g" \ "$tpl" | run_sudo tee "$out" >/dev/null if ! run_sudo nginx -t 2>/dev/null; then echo "错误: nginx -t 失败,请检查 $out" >&2 exit 1 fi if host_nginx_online; then run_sudo systemctl reload nginx 2>/dev/null && echo "宿主机 Nginx 已重载($out)" else ensure_host_nginx_started fi } reload_web_nginx() { local cid cid="$(compose_cmd ps -q web 2>/dev/null | head -1)" if [ -z "$cid" ]; then echo "web 容器未运行,跳过 reload" >&2 return 1 fi run_sudo docker exec "$cid" nginx -t >/dev/null 2>&1 || { echo "错误: 容器内 nginx -t 失败" >&2 return 1 } run_sudo docker exec "$cid" nginx -s reload echo "web 容器 nginx 已 reload(web/nginx.conf)" } restart_service() { local svc="$1" compose_cmd restart "$svc" echo "已 restart $svc" } reload_host_nginx() { [ "${AIJZ_ENABLE_HOST_NGINX:-0}" = "1" ] || { echo "AIJZ_ENABLE_HOST_NGINX 未启用,跳过宿主机 Nginx" >&2 return 0 } install_host_nginx_site_conf } reload_all_config() { ensure_env_file apply_public_base_url reload_host_nginx || true reload_web_nginx || true restart_service platform restart_service gateway compose_cmd up -d --force-recreate ai >/dev/null 2>&1 || compose_cmd restart ai echo "配置重载完成(未 rebuild 镜像、未重建 web/dist)" } ensure_docker() { if command -v docker >/dev/null 2>&1 && run_sudo docker info >/dev/null 2>&1; then echo "Docker 已就绪." return 0 fi if command -v docker >/dev/null 2>&1; then echo "Docker 守护进程未连接,尝试启动..." run_sudo systemctl start docker 2>/dev/null || run_sudo systemctl start podman 2>/dev/null || true if run_sudo docker info >/dev/null 2>&1; then echo "Docker 已就绪." return 0 fi fi echo "错误: Docker 不可用。请先安装并启动 docker 后再执行。" >&2 exit 1 } resolve_compose_cmd() { if run_sudo docker compose version >/dev/null 2>&1; then echo "docker compose" return fi if command -v docker-compose >/dev/null 2>&1; then echo "docker-compose" return fi if [ -x /usr/local/bin/docker-compose ]; then echo "/usr/local/bin/docker-compose" return fi echo "" } COMPOSE_CMD="" compose_cmd() { # stack_down 可能早于 stack_up,先补默认值,避免 set -u 静默退出 export_defaults : "${AIJZ_WEB_PUBLISH:=127.0.0.1:5173}" : "${AIJZ_GATEWAY_PUBLISH:=127.0.0.1:8180}" : "${AIJZ_PLATFORM_PUBLISH:=127.0.0.1:8888}" : "${AIJZ_AI_PUBLISH:=127.0.0.1:8001}" : "${AIJZ_PG_PUBLISH:=127.0.0.1:5432}" if [ -z "$COMPOSE_CMD" ]; then COMPOSE_CMD="$(resolve_compose_cmd)" fi if [ -z "$COMPOSE_CMD" ]; then echo "错误: 找不到 docker compose / docker-compose" >&2 exit 1 fi # shellcheck disable=SC2086 run_sudo env \ DOCKER_BASE_REGISTRY="${DOCKER_BASE_REGISTRY}" \ GOPROXY="${GOPROXY}" \ AIJZ_WEB_PUBLISH="${AIJZ_WEB_PUBLISH}" \ AIJZ_GATEWAY_PUBLISH="${AIJZ_GATEWAY_PUBLISH}" \ AIJZ_PLATFORM_PUBLISH="${AIJZ_PLATFORM_PUBLISH}" \ AIJZ_AI_PUBLISH="${AIJZ_AI_PUBLISH}" \ AIJZ_PG_PUBLISH="${AIJZ_PG_PUBLISH}" \ $COMPOSE_CMD "$@" } export_defaults() { export DOCKER_BASE_REGISTRY="${DOCKER_BASE_REGISTRY:-docker.m.daocloud.io/library}" export GOPROXY="${GOPROXY:-https://goproxy.cn,direct}" export REGISTRY_MIRROR="${REGISTRY_MIRROR:-docker.m.daocloud.io/library/}" } # 从 "127.0.0.1:5173" 或 "5173" 取出宿主机端口号 publish_host_port() { local pub="$1" echo "$pub" | awk -F: '{print $NF}' } port_in_use() { local port="$1" if command -v ss >/dev/null 2>&1; then run_sudo ss -tlnH "sport = :$port" 2>/dev/null | grep -q . && return 0 ss -tlnH "sport = :$port" 2>/dev/null | grep -q . && return 0 return 1 fi if command -v lsof >/dev/null 2>&1; then lsof -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1 && return 0 return 1 fi return 1 } # 检查 AI 建站将占用的宿主机端口;若已被本项目容器占用则放行 check_aijz_ports() { local pairs=( "web:$AIJZ_WEB_PUBLISH" "gateway:$AIJZ_GATEWAY_PUBLISH" "platform:$AIJZ_PLATFORM_PUBLISH" "ai:$AIJZ_AI_PUBLISH" "postgres:$AIJZ_PG_PUBLISH" ) echo "端口规划核对(相对 yh_web):AI建站 5173/8180/8888/8001/5432;宇恒 8088/9080/9081 + 宿主机 80/443。" local name pub port conflict=0 local self_running=0 if compose_cmd ps -q 2>/dev/null | grep -q .; then self_running=1 fi for item in "${pairs[@]}"; do name="${item%%:*}" pub="${item#*:}" port="$(publish_host_port "$pub")" [ -z "$port" ] && continue if port_in_use "$port"; then if [ "$self_running" -eq 1 ]; then echo " 提示: 端口 $port($name)已占用,疑为本栈,继续。" else echo " 错误: 宿主机端口 $port 已被占用(服务 $name ← $pub)。" >&2 echo " 请改 .env 中对应 AIJZ_*_PUBLISH,或停掉占用进程。" >&2 conflict=1 fi else echo " OK $name → $pub" fi done [ "$conflict" -eq 0 ] || exit 1 } # 构建 web/dist:优先本机 npm;否则用 Docker node 镜像 build_web_dist() { echo "构建 web dist ..." if command -v npm >/dev/null 2>&1; then ( cd "$ROOT/web" if [ -f package-lock.json ]; then npm ci --legacy-peer-deps 2>/dev/null || npm install --legacy-peer-deps else npm install --legacy-peer-deps fi npm run build ) else echo "本机无 npm,使用 Docker node 构建..." run_sudo docker run --rm \ -v "$ROOT/web:/app" \ -v "$ROOT/web/dist:/app/dist" \ -w /app \ "${REGISTRY_MIRROR}node:20-alpine" \ sh -c "(npm ci --legacy-peer-deps 2>/dev/null || npm install --legacy-peer-deps) && npm run build" fi if [ ! -f "$ROOT/web/dist/index.html" ]; then echo "错误: web/dist/index.html 不存在,构建失败。" >&2 exit 1 fi # nginx 容器用户需可读;避免 Podman 下 Permission denied → 403 chmod -R a+rX "$ROOT/web/dist" 2>/dev/null || true echo "web/dist 就绪." } stack_up() { local rebuild="${1:-0}" export_defaults ensure_runtime_dirs ensure_env_file apply_public_base_url check_aijz_ports build_web_dist if [ "$rebuild" = "1" ]; then compose_cmd up -d --build --force-recreate else if ! compose_cmd up -d; then echo "compose up 失败,尝试 --build ..." compose_cmd up -d --build fi fi install_host_nginx_site_conf || true } stack_down() { # 不吞掉 unbound 等错误;compose down 失败仍继续 if ! compose_cmd down --remove-orphans; then echo "提示: compose down 未完全成功,继续启动..." >&2 fi } healthcheck() { local ok=0 i local gw_port web_port gw_port="$(publish_host_port "${AIJZ_GATEWAY_PUBLISH:-127.0.0.1:8180}")" web_port="$(publish_host_port "${AIJZ_WEB_PUBLISH:-127.0.0.1:5173}")" echo "等待 gateway(:$gw_port) / web(:$web_port) ..." for i in $(seq 1 60); do if curl -fsS --max-time 2 "http://127.0.0.1:${gw_port}/gateway/health" >/dev/null 2>&1 \ && curl -fsS --max-time 2 "http://127.0.0.1:${web_port}/" >/dev/null 2>&1; then ok=1 break fi sleep 2 done if [ "$ok" -eq 1 ]; then echo "健康检查通过." else echo "警告: 健康检查超时,请查看: docker compose -f $ROOT/docker-compose.yml logs" >&2 fi compose_cmd exec -T postgres psql -U platform -d platform -c 'ALTER USER platform CREATEDB;' >/dev/null 2>&1 || true } print_endpoints() { local pub_line="" if [ -n "${AIJZ_DOMAIN:-}" ] && [ "${AIJZ_ENABLE_HOST_NGINX:-0}" = "1" ]; then pub_line=" Public https://${AIJZ_DOMAIN}" fi cat </dev/null 2>&1; then echo "错误: 未安装 git,无法拉取代码。请先安装 git。" >&2 return 1 fi if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo "当前目录不是 git 仓库,初始化并关联 origin -> $url" git init git remote add origin "$url" return 0 fi if git remote get-url origin >/dev/null 2>&1; then local cur cur="$(git remote get-url origin)" if [ "$cur" != "$url" ]; then echo "更新 origin: $cur -> $url" git remote set-url origin "$url" fi else echo "添加 origin -> $url" git remote add origin "$url" fi } # 从 Gitea ai_site 拉取并与远程对齐(默认硬重置,保留未跟踪文件如 .env) # 环境变量: # AIJZ_GIT_URL 仓库地址(默认 https://gitea.yuxindazhineng.com/whm/ai_site.git) # GIT_BRANCH 分支(默认 main) # FORCE_GIT_RESET 1=reset --hard 对齐远程(默认 1);0=仅快进合并 git_pull_hard() { local url="${AIJZ_GIT_URL:-$AIJZ_GIT_URL_DEFAULT}" local branch="${GIT_BRANCH:-main}" echo "拉取代码($url branch=$branch)..." ensure_git_origin "$url" || return 1 if ! git fetch origin --progress; then echo "错误: git fetch 失败。私有仓库请配置凭据,或设置 AIJZ_GIT_URL 为带 token 的地址。" >&2 return 1 fi if ! git rev-parse --verify "origin/$branch" >/dev/null 2>&1; then echo "错误: 远程不存在分支 origin/$branch" >&2 return 1 fi if [ "${FORCE_GIT_RESET:-1}" = "1" ]; then # -f:丢弃已跟踪文件的本地改动,避免 checkout 被挡住(.env 等未跟踪文件保留) git checkout -f -B "$branch" "origin/$branch" git reset --hard "origin/$branch" else git checkout "$branch" 2>/dev/null || git checkout -b "$branch" "origin/$branch" git merge --ff-only "origin/$branch" fi echo "当前: $(git rev-parse --short HEAD) $(git log -1 --pretty=%s)" }