diff --git a/.gitignore b/.gitignore index 1c17f63..ae35def 100644 --- a/.gitignore +++ b/.gitignore @@ -22,13 +22,7 @@ ai-service/.env # 本地意见稿 / 不入库 database_fastapi修改意见.md -# 一键部署脚本(本机/运维侧保留,不推远程) -start.sh -stop.sh -restart.sh -pull-and-restart.sh -reload-config.sh -deploy-menu.sh +# Windows 本机启动脚本(不入库);Linux 一键脚本入库以便生产 pull 更新 start.bat start.ps1 stop.bat @@ -36,6 +30,7 @@ stop.ps1 restart.bat restart.ps1 start-host.ps1 -scripts/lib-aijz-deploy.sh +deploy-menu.sh +reload-config.sh scripts/linux/ diff --git a/pull-and-restart.sh b/pull-and-restart.sh new file mode 100644 index 0000000..5f94c3a --- /dev/null +++ b/pull-and-restart.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# AI 建站 — 拉取代码并重启(生产一键更新) +# 用法:./pull-and-restart.sh +# 等价:./restart.sh --pull +# 默认仓库:https://gitea.yuxindazhineng.com/whm/ai_site.git +# 环境变量: +# AIJZ_GIT_URL 覆盖仓库地址 +# GIT_BRANCH 分支(默认 main) +# FORCE_GIT_RESET 默认 1;硬对齐并丢弃已跟踪文件的本地改动(保留 .env 等未跟踪文件) +set -euo pipefail +ROOT="$(cd "$(dirname "$0")" && pwd)" +cd "$ROOT" +exec "$ROOT/restart.sh" --pull diff --git a/restart.sh b/restart.sh new file mode 100644 index 0000000..1dce415 --- /dev/null +++ b/restart.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# AI 建站 — 重启 +# 用法: +# ./restart.sh # 不拉代码:停 → 构建 → 起 +# ./restart.sh --pull # 强制对齐远程后再重启(生产一键更新) +# ./restart.sh --no-rebuild # 重启但不强制重建镜像 +set -euo pipefail +ROOT="$(cd "$(dirname "$0")" && pwd)" +cd "$ROOT" +# shellcheck disable=SC1091 +. "$ROOT/scripts/lib-aijz-deploy.sh" + +REBUILD=1 +DO_PULL=0 +for a in "$@"; do + case "$a" in + --pull|--update) DO_PULL=1 ;; + --no-pull) DO_PULL=0 ;; + --no-rebuild) REBUILD=0 ;; + -h|--help) + echo "用法: $0 [--pull|--update] [--no-rebuild]" + echo " 默认不拉代码;--pull 时 git fetch + checkout -f + reset --hard 对齐远程后重启" + exit 0 + ;; + esac +done + +echo "========================================" +echo " AI 建站 重启" +echo " 路径: $ROOT" +if [ "$DO_PULL" = "1" ]; then + echo " 拉取: 是(强制对齐 ${GIT_BRANCH:-main})" +else + echo " 拉取: 否(加 --pull 可拉代码)" +fi +echo "========================================" + +ensure_docker +ensure_env_file +export_defaults +if [ "$DO_PULL" = "1" ]; then + git_pull_hard +fi +stack_down +sleep 1 +stack_up "$REBUILD" +healthcheck +print_endpoints diff --git a/scripts/lib-aijz-deploy.sh b/scripts/lib-aijz-deploy.sh new file mode 100644 index 0000000..b57e44f --- /dev/null +++ b/scripts/lib-aijz-deploy.sh @@ -0,0 +1,454 @@ +# 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)" +} diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..27baf97 --- /dev/null +++ b/start.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# AI 建站 — Linux 一键启动(Docker Compose) +# 用法:cd 项目根 && ./start.sh [--rebuild] [--no-pull] +# 默认从 Gitea 强制对齐最新代码再启动(可用 --no-pull / AIJZ_SKIP_GIT_PULL=1 跳过) +# 生产一键更新也可用:./restart.sh --pull 或 ./pull-and-restart.sh +# 行尾:LF +set -euo pipefail +ROOT="$(cd "$(dirname "$0")" && pwd)" +cd "$ROOT" +# shellcheck disable=SC1091 +. "$ROOT/scripts/lib-aijz-deploy.sh" + +REBUILD=0 +DO_PULL=1 +for a in "$@"; do + case "$a" in + --rebuild|-r) REBUILD=1 ;; + --no-pull) DO_PULL=0 ;; + --pull) DO_PULL=1 ;; + -h|--help) + echo "用法: $0 [--rebuild] [--no-pull|--pull]" + echo " 默认拉取并硬对齐: ${AIJZ_GIT_URL:-https://gitea.yuxindazhineng.com/whm/ai_site.git} 分支 ${GIT_BRANCH:-main}" + echo " 跳过拉取: --no-pull 或 AIJZ_SKIP_GIT_PULL=1" + echo " 仅更新重启: ./restart.sh --pull" + exit 0 + ;; + esac +done +if [ "${AIJZ_SKIP_GIT_PULL:-0}" = "1" ]; then + DO_PULL=0 +fi + +echo "========================================" +echo " AI 建站 启动" +echo " 路径: $ROOT" +echo "========================================" + +if [ "$DO_PULL" = "1" ]; then + git_pull_hard +else + echo "已跳过代码拉取(--no-pull / AIJZ_SKIP_GIT_PULL)" +fi + +ensure_docker +ensure_env_file +export_defaults +stack_up "$REBUILD" +healthcheck +print_endpoints diff --git a/stop.sh b/stop.sh new file mode 100644 index 0000000..66f3fbd --- /dev/null +++ b/stop.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# AI 建站 — 停止 Docker 栈 +# 用法:./stop.sh +set -euo pipefail +ROOT="$(cd "$(dirname "$0")" && pwd)" +cd "$ROOT" +# shellcheck disable=SC1091 +. "$ROOT/scripts/lib-aijz-deploy.sh" + +echo "停止 AI 建站 ($ROOT) ..." +ensure_docker +stack_down +echo "已停止."