#!/usr/bin/env bash # 本地修改 → 上传服务器 → 构建/更新 → 部署 (tlyq.ai) # # 策略:服务器上直接 npm run build,复用容器内已安装的 node_modules # # 支持系统: # - macOS (本地): zsh + bash 兼容模式 # - Ubuntu/Debian (服务器): dash (符号链接到 sh) + bash # - Rocky Linux/CentOS/RHEL (服务器): bash # # 用法:bash deploy-ai.sh [选项] # 无参数 — 自动检测(初次构建 or 增量更新) # --force — 强制完整构建 # --restart — 仅重启容器(跳过构建) # --init — 强制初次完整构建 # 不使用 set -e,改用显式错误处理,避免管道命令导致意外退出 # ============================================================ # 脚本目录 # ============================================================ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # ============================================================ # 颜色和日志 # ============================================================ GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; RED='\033[0;31m'; NC='\033[0m' log() { printf "${GREEN}[✓]${NC} %s\n" "$1"; } warn() { printf "${YELLOW}[!]${NC} %s\n" "$1"; } info() { printf "${CYAN}[i]${NC} %s\n" "$1"; } error() { printf "${RED}[✗]${NC} %s\n" "$1"; } timing(){ printf " ${CYAN}⏱${NC} %s\n" "$1"; } # ============================================================ # 检测 macOS 和 Linux sed 差异 # ============================================================ is_mac() { [[ "$(uname)" == "Darwin" ]] } # macOS 的 sed -i 需要空后缀 '',Linux 不需要 # sed 原地编辑(处理 macOS BSD sed 与 Linux GNU sed 差异) # sed_i: 函数形式,用于直接调用。_sed_i: 变量形式,用于 find -exec(find 只能调用二进制) if is_mac; then sed_i() { sed -i '' "$@"; } _sed_i='sed -i ""' else sed_i() { sed -i "$@"; } _sed_i='sed -i' fi # ============================================================ # 解析参数 # ============================================================ FORCE_BUILD=0; ONLY_RESTART=0; INIT_BUILD=0 while [[ $# -gt 0 ]]; do case "$1" in --force) FORCE_BUILD=1; info "模式: 强制完整构建"; shift ;; --restart) ONLY_RESTART=1; info "模式: 仅重启容器"; shift ;; --init) INIT_BUILD=1; info "模式: 初次完整构建"; shift ;; *) warn "未知参数: $1"; shift ;; esac done # ============================================================ # 部署前合规检查(CLAUDE.md + LESSONS-LEARNED.md) # ============================================================ echo "" bash "$(dirname "$0")/check-compliance.sh" || { error "合规检查未通过,部署已中止" exit 1 } # ============================================================ # 选择站点 # ============================================================ echo "" printf "${CYAN}=========================================${NC}\n" printf "${CYAN} 选择要部署的站点 (tlyq.ai)${NC}\n" printf "${CYAN}=========================================${NC}\n" echo "" echo " 1) www.tlyq.ai (图灵引擎官网)" echo " 2) cloud.tlyq.ai (图灵智算系统云平台)" echo " 3) token.tlyq.ai (Token工厂)" echo " 4) issue.tlyq.ai (工单系统)" echo " 5) assets.tlyq.ai (资产管理系统)" echo " 6) oa.tlyq.ai (OA 统一门户 — 含 nginx 配置)" echo " 7) ldap.tlyq.ai (LLDAP 用户目录服务)" echo " 8) git.tlyq.ai (Gitea 代码托管)" echo " 9) sso.tlyq.ai (Authelia SSO — 统一认证中心)" echo " 10) monitor.tlyq.ai (告警监控中心)" echo "" printf "请输入编号 (1/2/3/4/5/6/7/8/9/10): " read -t 30 choice || { echo "超时未输入,退出"; exit 1; } SITE=""; LOCAL_DIR=""; REMOTE_DIR=""; CONTAINER="" case "$choice" in 1) SITE="www"; LOCAL_DIR="/Users/niuniu/programs/docker/www-ai/src"; REMOTE_DIR="/root/docker/www-ai"; CONTAINER="www-ai" ;; 2) SITE="cloud"; LOCAL_DIR="/Users/niuniu/programs/docker/cloud-ai/html"; REMOTE_DIR="/root/docker/cloud-ai/html" ;; 3) SITE="token"; LOCAL_DIR="/Users/niuniu/programs/docker/token-ai/html"; REMOTE_DIR="/root/docker/token-ai/html" ;; 4) SITE="issue"; LOCAL_DIR="/Users/niuniu/programs/docker/issue-ai"; REMOTE_DIR="/root/docker/issue-ai"; CONTAINER="issue-ai" ;; 5) SITE="assets"; LOCAL_DIR="/Users/niuniu/programs/docker/assets-ai"; REMOTE_DIR="/root/docker/assets-ai"; CONTAINER="assets-ai" ;; 6) SITE="oa"; LOCAL_DIR="/Users/niuniu/programs/docker/oa-ai"; REMOTE_DIR="/root/docker/oa-ai"; CONTAINER="oa-ai" ;; 7) SITE="ldap"; LOCAL_DIR="/Users/niuniu/programs/docker/ldap-ai"; REMOTE_DIR="/root/docker/ldap-ai"; CONTAINER="lldap" ;; 8) SITE="gitea"; LOCAL_DIR="/Users/niuniu/programs/docker/gitea-ai"; REMOTE_DIR="/root/docker/gitea-ai"; CONTAINER="gitea-ai" ;; 9) SITE="sso"; LOCAL_DIR="/Users/niuniu/programs/docker/sso-ai"; REMOTE_DIR="/root/docker/sso-ai"; CONTAINER="authelia" ;; 10) SITE="monitor"; LOCAL_DIR="/Users/niuniu/programs/docker/monitor-ai"; REMOTE_DIR="/root/docker/monitor-ai"; CONTAINER="monitor-ai" ;; *) echo "无效选择"; exit 1 ;; esac echo "" log "部署目标: $LOCAL_DIR → $REMOTE_DIR" echo "" # ============================================================ # 链接替换 # ============================================================ replace_url() { local mode="$1" case "$SITE" in www) cd "$LOCAL_DIR" if [[ "$mode" == "prod" ]]; then find src -type f -name '*.tsx' -exec $_sed_i \ -e 's|http://localhost:6174|https://cloud.tlyq.ai|g' \ -e 's|http://localhost:6175|https://token.tlyq.ai|g' \ {} + 2>/dev/null || true else find src -type f -name '*.tsx' -exec $_sed_i \ -e 's|https://cloud.tlyq.ai|http://localhost:6174|g' \ -e 's|https://token.tlyq.ai|http://localhost:6175|g' \ {} + 2>/dev/null || true fi ;; cloud|token) if [[ "$mode" == "prod" ]]; then sed_i 's|http://localhost:6173|https://www.tlyq.ai|g' "$LOCAL_DIR/index.html" 2>/dev/null || true else sed_i 's|https://www.tlyq.ai|http://localhost:6173|g' "$LOCAL_DIR/index.html" 2>/dev/null || true fi ;; esac } # OA 专属 URL 替换(三类:前端导航 / 服务端内部 API / 退出登录) replace_oa_urls() { local mode="$1" # prod | local cd "$LOCAL_DIR" if [[ "$mode" == "prod" ]]; then # A. 前端导航卡片 → 生产域名 sed_i \ -e 's|http://localhost:6177|https://assets.tlyq.ai|g' \ -e 's|http://localhost:6176|https://issue.tlyq.ai|g' \ -e 's|http://localhost:6173|https://www.tlyq.ai|g' \ -e 's|http://localhost:6174|https://cloud.tlyq.ai|g' \ -e 's|http://localhost:6175|https://token.tlyq.ai|g' \ src/app/page.tsx # B. 服务端内部 API 调用 → Docker 容器名(assets-ai:3000 / issue-ai:3000) find src/app/api -name "*.ts" -exec $_sed_i \ -e "s|'http://localhost:6177'|'http://assets-ai:3000'|g" \ -e "s|'http://localhost:6176'|'http://issue-ai:3000'|g" \ -e "s|\`http://localhost:\${site === 'assets' ? 6177 : 6176}\`|\`http://\${site}-ai:3000\`|g" \ {} + # C. 退出登录重定向 sed_i "s|'http://localhost:6179'|'https://oa.tlyq.ai'|g" src/app/api/auth/logout/route.ts else # 恢复本地 URL sed_i \ -e 's|https://assets.tlyq.ai|http://localhost:6177|g' \ -e 's|https://issue.tlyq.ai|http://localhost:6176|g' \ -e 's|https://www.tlyq.ai|http://localhost:6173|g' \ -e 's|https://cloud.tlyq.ai|http://localhost:6174|g' \ -e 's|https://token.tlyq.ai|http://localhost:6175|g' \ src/app/page.tsx find src/app/api -name "*.ts" -exec $_sed_i \ -e "s|'http://assets-ai:3000'|'http://localhost:6177'|g" \ -e "s|'http://issue-ai:3000'|'http://localhost:6176'|g" \ -e "s|\`http://\${site}-ai:3000\`|\`http://localhost:\${site === 'assets' ? 6177 : 6176}\`|g" \ {} + sed_i "s|'https://oa.tlyq.ai'|'http://localhost:6179'|g" src/app/api/auth/logout/route.ts fi } to_prod() { [[ "$SITE" == "www" || "$SITE" == "cloud" || "$SITE" == "token" ]] && replace_url prod; } to_local() { [[ "$SITE" == "www" || "$SITE" == "cloud" || "$SITE" == "token" ]] && replace_url local; } # 注意: SIGKILL (kill -9) 无法被 trap 捕获,暴力终止后本地文件可能残留生产 URL trap 'to_local; [[ "$SITE" == "oa" ]] && replace_oa_urls local; rm -f /tmp/${SITE}.tar.gz /tmp/shared-deploy.tar.gz' EXIT SIGINT SIGTERM [[ "$SITE" == "oa" ]] && replace_oa_urls prod to_prod # to_prod() 内部已做 SITE 判断,无需外层条件 # ============================================================ # sso-ai 部署(Docker 容器,无 npm 构建) # ============================================================ deploy_sso() { log "部署 sso-ai (Authelia SSO)..." # 1. 上传 docker-compose.yml 和 config/ log "上传配置文件..." scp /Users/niuniu/programs/docker/sso-ai/docker-compose.yml txjp:/root/docker/sso-ai/docker-compose.yml # 使用生产配置(configuration.prod.yml → configuration.yml),覆盖前备份 ssh txjp "cp /root/docker/sso-ai/config/authelia/configuration.yml /root/docker/sso-ai/config/authelia/configuration.yml.\$(date +%Y%m%d_%H%M%S).bak 2>/dev/null || true" scp /Users/niuniu/programs/docker/sso-ai/config/authelia/configuration.prod.yml txjp:/root/docker/sso-ai/config/authelia/configuration.yml # 确保 TLS 证书存在(首次部署或证书丢失时自动生成) ssh txjp "ls /root/docker/sso-ai/config/authelia/certs/cert.pem 2>/dev/null || (mkdir -p /root/docker/sso-ai/config/authelia/certs && openssl req -x509 -newkey rsa:4096 -keyout /root/docker/sso-ai/config/authelia/certs/key.pem -out /root/docker/sso-ai/config/authelia/certs/cert.pem -days 3650 -nodes -subj '/CN=sso.tlyq.ai' 2>/dev/null && echo '已生成自签名证书')" # 上传其他 config 文件(如 notifications.yml) scp /Users/niuniu/programs/docker/sso-ai/config/authelia/notifications.yml txjp:/root/docker/sso-ai/config/authelia/notifications.yml 2>/dev/null || true # 2. 首次部署:生成密钥 ssh txjp "if [ ! -f /root/docker/sso-ai/.env ]; then echo '首次部署,生成密钥...' REDIS_PASSWORD=\$(openssl rand -hex 16) AUTHELIA_JWT_SECRET=\$(openssl rand -hex 32) AUTHELIA_SESSION_SECRET=\$(openssl rand -hex 32) AUTHELIA_STORAGE_ENCRYPTION_KEY=\$(openssl rand -hex 32) LLDAP_PASSWORD=\$(docker exec lldap printenv LLDAP_ADMIN_PASSWORD) cat > /root/docker/sso-ai/.env << ENVEOF REDIS_PASSWORD=\$REDIS_PASSWORD AUTHELIA_JWT_SECRET=\$AUTHELIA_JWT_SECRET AUTHELIA_SESSION_SECRET=\$AUTHELIA_SESSION_SECRET AUTHELIA_STORAGE_ENCRYPTION_KEY=\$AUTHELIA_STORAGE_ENCRYPTION_KEY LLDAP_PASSWORD=\$LLDAP_PASSWORD ENVEOF echo '密钥已生成' chmod 600 /root/docker/sso-ai/.env else echo '已存在 .env,跳过密钥生成' chmod 600 /root/docker/sso-ai/.env 2>/dev/null || true fi" # 3. 注入占位符真实值(LDAP 密码、JWKS 密钥) log "注入 LDAP 密码和 JWKS 密钥..." ssh txjp "cd /root/docker/sso-ai && \ LLDAP_PASSWORD=\$(docker exec lldap printenv LLDAP_ADMIN_PASSWORD) && \ python3 -c \"import sys; content=open('config/authelia/configuration.yml').read(); content=content.replace('PRODUCTION_LDAP_PASSWORD', sys.argv[1]); open('config/authelia/configuration.yml','w').write(content)\" \"\$LLDAP_PASSWORD\" && \ # 如果 JWKS key 仍是占位符,从现有配置或生成新密钥 if grep -q 'OIDC_JWKS_KEY_PLACEHOLDER' config/authelia/configuration.yml; then EXISTING_KEY=\$(cat /root/docker/sso-ai/config/authelia/configuration.yml.bak 2>/dev/null | sed -n '/key: |/,/-----END/{p}' || true) if [ -n \"\$EXISTING_KEY\" ]; then echo '从备份恢复 JWKS 密钥' # 使用 python 替换多行占位符(sed 不便处理) python3 -c \" import sys content = open('config/authelia/configuration.yml').read() key = '''\$EXISTING_KEY''' content = content.replace('OIDC_JWKS_KEY_PLACEHOLDER', key.strip()) open('config/authelia/configuration.yml', 'w').write(content) \" 2>/dev/null || true else echo '警告: JWKS 密钥为占位符,需要手动配置' fi fi && \ # 检查 OIDC client_secret 占位符 if grep -q 'OIDC_.*_SECRET_HASH' config/authelia/configuration.yml; then echo '警告: 存在 OIDC client_secret 占位符,需要手动替换' grep 'OIDC_.*_SECRET_HASH' config/authelia/configuration.yml || true fi" # 4. 部署 nginx sso-ai.conf log "部署 nginx 配置..." scp /Users/niuniu/programs/docker/nginx-proxy-ai/conf.d/sso-ai.conf txjp:/root/docker/nginx-proxy-ai/conf.d/sso-ai.conf # 5. 仅启动 authelia(redis 和 lldap 为独立容器,不通过本 compose 管理) log "启动 Authelia..." ssh txjp "cd /root/docker/sso-ai && docker compose up -d --no-deps authelia" # 6. 验证 sleep 5 ssh txjp "curl -sk https://127.0.0.1:9091/api/health" && log "Authelia 健康检查通过" || warn "Authelia 可能未就绪" # 6.1 检查 OIDC token_endpoint_auth_method 是否匹配(LESSONS-LEARNED #48) sleep 3 if ssh txjp "docker logs authelia --since 30s 2>&1 | grep -q 'Client authentication failed.*token_endpoint_auth_method'"; then warn "检测到 OIDC token_endpoint_auth_method 不匹配!检查 Authelia configuration.yml 中所有 client 是否设置了 token_endpoint_auth_method: 'client_secret_post'" ssh txjp "docker logs authelia --since 30s 2>&1 | grep 'Client authentication failed' | tail -3" else log "OIDC token_endpoint_auth_method 一致性检查通过" fi # 7. 重载 nginx ssh txjp "docker exec nginx-ai nginx -t && docker exec nginx-ai nginx -s reload" && log "nginx 已重载" || warn "nginx 重载失败" log "sso-ai 部署完成" } # ============================================================ # 核心构建函数(服务器上直接 npm run build) # ============================================================ SNAPSHOT_DIR="/root/docker/.snapshots" SNAPSHOT_FILE="${SNAPSHOT_DIR}/.snapshot.${SITE}.md5" BUILD_START=$(date +%s) # ============================================================ # 同步 shared 共享库(快照比对,无变化跳过) # ============================================================ SHARED_SNAPSHOT="${SNAPSHOT_DIR}/.snapshot.shared.md5" SHARED_LOCAL="/Users/niuniu/programs/docker/shared" SHARED_REMOTE="/root/docker/shared" sync_shared() { # 统一管理 /root/docker/shared/,各站点通过 shared -> ../shared 符号链接自动解析 info "检查 shared 共享库..." local local_md5 if is_mac; then local_md5=$(find "$SHARED_LOCAL" \ -not -path '*/node_modules/*' \ -not -path '*/.git/*' \ -not -name '._*' -not -name '.DS_Store' \ -type f \ -exec md5 -q {} \; 2>/dev/null \ | sort \ | md5 -q) else local_md5=$(find "$SHARED_LOCAL" \ -not -path '*/node_modules/*' \ -not -path '*/.git/*' \ -not -name '._*' -not -name '.DS_Store' \ -type f \ -exec md5sum {} \; 2>/dev/null \ | sed 's/ .*//' \ | sort \ | md5sum | cut -d' ' -f1) fi local prev_md5 prev_md5=$(ssh txjp "cat ${SHARED_SNAPSHOT} 2>/dev/null" 2>/dev/null || echo "") if [[ "$prev_md5" == "$local_md5" ]]; then # shared 无变化,仅确保目录存在(防止首次部署或手动清理后死链接) ssh txjp "test -d ${SHARED_REMOTE}/lib || (mkdir -p ${SHARED_REMOTE} && echo 'shared 目录异常但快照存在,已恢复空目录')" 2>/dev/null return 0 fi if [[ -z "$prev_md5" ]]; then info "shared 首次部署,上传..." else info "shared 有变化,上传..." fi COPYFILE_DISABLE=1 tar czf /tmp/shared-deploy.tar.gz -C /Users/niuniu/programs/docker \ --exclude='node_modules' --exclude='.git' --exclude='._*' shared/ 2>/dev/null || { error "shared 打包失败" return 1 } scp /tmp/shared-deploy.tar.gz txjp:/tmp/shared-deploy.tar.gz || { error "shared 上传失败" return 1 } # 先解压到临时目录,成功后再替换(避免先删后建时解压失败导致 shared 丢失) ssh txjp "rm -rf /tmp/shared_tmp && mkdir -p /tmp/shared_tmp && \ tar xzf /tmp/shared-deploy.tar.gz -C /tmp/shared_tmp && \ rm -rf ${SHARED_REMOTE} && mkdir -p ${SHARED_REMOTE} && \ mv /tmp/shared_tmp/shared/* ${SHARED_REMOTE}/ && \ rm -rf /tmp/shared_tmp && \ echo '${local_md5}' > ${SHARED_SNAPSHOT}" || { error "shared 远端解压或替换失败" return 1 } log "shared 已同步到 ${SHARED_REMOTE}" # 安装 shared 自身依赖(如 ldapts),确保各站点构建时能正确解析 ssh txjp "cd ${SHARED_REMOTE} && [ -f package.json ] && npm install --prefer-offline 2>&1 | tail -3" || warn "shared 依赖安装失败,部分站点可能构建异常" } build_on_server() { local extra_env="${1:-}" # 0. 同步 shared 共享库(所有使用 @shared 的站点自动受益) sync_shared # 确保 shared 自身依赖已安装(如 ldapts),否则站点构建时模块解析失败 ssh txjp "cd ${SHARED_REMOTE} && [ -f package.json ] && [ ! -d node_modules ] && npm install --prefer-offline 2>&1 | tail -3 || true" 2>/dev/null # 1. 计算源码快照(含 shared,确保 shared 变化也触发重构建) log "检查源码是否有变化..." # 平台兼容:macOS md5 -q / Linux md5sum FIND_EXCLUDE="-not -path '*/node_modules/*' -not -path '*/.next/*' -not -path '*/out/*' -not -path '*/data/*' -not -path '*/reports/*' -not -path '*/docs/*' -not -path '*/.env.local' -not -path '*/.env.development' -not -path '*/.env.production' -not -path '*/.git/*' -not -name '._*' -not -name '.DS_Store'" if is_mac; then local_md5=$( (find "$LOCAL_DIR" $FIND_EXCLUDE -type f -exec md5 -q {} \; ; \ find "$SHARED_LOCAL" -not -path '*/node_modules/*' -not -path '*/.git/*' -not -name '._*' -not -name '.DS_Store' -type f -exec md5 -q {} \; 2>/dev/null) \ | sort | md5 -q) else local_md5=$( (find "$LOCAL_DIR" $FIND_EXCLUDE -type f -exec md5sum {} \; ; \ find "$SHARED_LOCAL" -not -path '*/node_modules/*' -not -path '*/.git/*' -not -name '._*' -not -name '.DS_Store' -type f -exec md5sum {} \; 2>/dev/null) \ | sed 's/ .*//' | sort | md5sum | cut -d' ' -f1) fi echo " 源码快照: ${local_md5}" prev_md5=$(ssh txjp "cat ${SNAPSHOT_FILE} 2>/dev/null" 2>/dev/null || echo "") if [[ "$FORCE_BUILD" == "1" || "$INIT_BUILD" == "1" ]]; then info "强制构建模式" MODE="force" elif [[ -z "$prev_md5" ]]; then info "首次部署,执行完整构建" MODE="init" elif [[ "$prev_md5" == "$local_md5" ]]; then info "源码无变化,跳过构建,仅重启容器" MODE="skip" else info "检测到源码变化,执行增量构建" MODE="update" fi if [[ "$ONLY_RESTART" == "1" ]]; then MODE="restart" info "强制重建容器,跳过构建" ssh txjp "cd $REMOTE_DIR && docker compose down && docker compose up -d" return 0 fi if [[ "$MODE" == "skip" || "$MODE" == "restart" ]]; then ssh txjp "cd $REMOTE_DIR && docker compose down && docker compose up -d" log "容器已重启" return 0 fi # 2. 打包源码(不含 node_modules) log "打包源码..." pack_start=$(date +%s) COPYFILE_DISABLE=1 tar czf /tmp/${SITE}.tar.gz -C "$LOCAL_DIR" \ --exclude='node_modules' --exclude='.next' --exclude='out' \ --exclude='data' --exclude='./reports/*.docx' --exclude='docs' \ --exclude='.env' --exclude='.env.local' --exclude='.env.development' --exclude='.env.production' \ --exclude='.git' --exclude='._*' . || { error "打包失败" return 1 } pack_end=$(date +%s) pack_dur=$((pack_end - pack_start)) local pkg_size pkg_size=$(du -h /tmp/${SITE}.tar.gz 2>/dev/null | cut -f1 || echo "未知") timing "打包耗时: ${pack_dur}s (${pkg_size})" # 3. 上传 log "上传源码包..." scp /tmp/${SITE}.tar.gz txjp:/tmp/${SITE}.tar.gz || { error "上传失败" return 1 } # 4. 解压源码到 /tmp(独立于目标目录,避免 rsync --delete 误删源文件) log "解压源码并准备依赖..." ssh txjp "\ rm -rf /tmp/deploy_${SITE} && mkdir -p /tmp/deploy_${SITE} && \ tar xzf /tmp/${SITE}.tar.gz -C /tmp/deploy_${SITE} 2>/dev/null || true && \ rsync -a --delete \ --exclude='node_modules' --exclude='.next' --exclude='data' --exclude='/reports/*.docx' --exclude='docs' \ --exclude='.env' --exclude='.env.local' --exclude='.env.development' --exclude='.env.production' \ --exclude='._*' \ /tmp/deploy_${SITE}/ ${REMOTE_DIR}/ && \ rm -rf /tmp/deploy_${SITE}" # 首次:上传 docker-compose.yml(新增 .next 卷挂载),重启容器应用 ssh txjp "cd $REMOTE_DIR && docker compose up -d" # 如果 host 上没有 node_modules,从容器内复制一份 ssh txjp "if [ ! -d '${REMOTE_DIR}/node_modules' ]; then echo ' 首次:复制容器内 node_modules 到主机...' docker cp ${CONTAINER}:/app/node_modules ${REMOTE_DIR}/node_modules echo ' 完成' fi" # 安装可能新增的依赖(已有依赖走缓存,很快) log "安装新增依赖..." ssh txjp "cd ${REMOTE_DIR} && npm install --prefer-offline 2>&1 | tail -5" || { error "npm install 失败" return 1 } if [[ -n "$extra_env" ]]; then ssh txjp "sed -i 's|ASSETS_API_URL=.*|ASSETS_API_URL=${extra_env}|' $REMOTE_DIR/.env 2>/dev/null \ || echo 'ASSETS_API_URL=${extra_env}' >> $REMOTE_DIR/.env" fi # assets 站点需要 NEXT_PUBLIC_ISSUE_URL(构建时内嵌到 JS bundle)和 ISSUE_API_URL(运行时,容器内网地址) if [[ "$SITE" == "assets" ]]; then ssh txjp "grep -q 'NEXT_PUBLIC_ISSUE_URL' $REMOTE_DIR/.env 2>/dev/null \ && sed -i 's|NEXT_PUBLIC_ISSUE_URL=.*|NEXT_PUBLIC_ISSUE_URL=https://issue.tlyq.ai/tickets|' $REMOTE_DIR/.env 2>/dev/null \ || echo 'NEXT_PUBLIC_ISSUE_URL=https://issue.tlyq.ai/tickets' >> $REMOTE_DIR/.env" ssh txjp "grep -q 'ISSUE_API_URL' $REMOTE_DIR/.env 2>/dev/null \ && sed -i 's|ISSUE_API_URL=.*|ISSUE_API_URL=http://issue-ai:3000/api|' $REMOTE_DIR/.env 2>/dev/null \ || echo 'ISSUE_API_URL=http://issue-ai:3000/api' >> $REMOTE_DIR/.env" fi # issue 站点需要 NEXT_PUBLIC_ASSETS_URL(构建时内嵌到 JS bundle) if [[ "$SITE" == "issue" ]]; then ssh txjp "grep -q 'NEXT_PUBLIC_ASSETS_URL' $REMOTE_DIR/.env 2>/dev/null \ && sed -i 's|NEXT_PUBLIC_ASSETS_URL=.*|NEXT_PUBLIC_ASSETS_URL=https://assets.tlyq.ai|' $REMOTE_DIR/.env 2>/dev/null \ || echo 'NEXT_PUBLIC_ASSETS_URL=https://assets.tlyq.ai' >> $REMOTE_DIR/.env" fi # 确保 AUTHELIA_URL 已设置(仅对使用 OIDC 的站点) if [[ "$SITE" == "oa" || "$SITE" == "assets" || "$SITE" == "issue" || "$SITE" == "monitor" ]]; then ssh txjp "grep -q 'AUTHELIA_URL' $REMOTE_DIR/.env 2>/dev/null \ && sed -i 's|AUTHELIA_URL=.*|AUTHELIA_URL=https://sso.tlyq.ai|' $REMOTE_DIR/.env 2>/dev/null \ || echo 'AUTHELIA_URL=https://sso.tlyq.ai' >> $REMOTE_DIR/.env" fi # OIDC 环境变量配置(oa/assets/issue/monitor 站点) if [[ "$SITE" == "oa" || "$SITE" == "assets" || "$SITE" == "issue" || "$SITE" == "monitor" ]]; then # 根据站点设置 OIDC 变量 case "$SITE" in oa) OIDC_ID="oa-oidc"; OIDC_URI="https://oa.tlyq.ai/api/auth/callback" ;; assets) OIDC_ID="assets-oidc"; OIDC_URI="https://assets.tlyq.ai/api/auth/callback" ;; issue) OIDC_ID="issue-oidc"; OIDC_URI="https://issue.tlyq.ai/api/auth/callback" ;; monitor) OIDC_ID="monitor-oidc"; OIDC_URI="https://monitor.tlyq.ai/api/auth/callback" ;; esac # 确保 OIDC_CLIENT_ID 已设置 ssh txjp "grep -q 'OIDC_CLIENT_ID' $REMOTE_DIR/.env 2>/dev/null \ && sed -i 's|OIDC_CLIENT_ID=.*|OIDC_CLIENT_ID=${OIDC_ID}|' $REMOTE_DIR/.env 2>/dev/null \ || echo 'OIDC_CLIENT_ID=${OIDC_ID}' >> $REMOTE_DIR/.env" # 确保 OIDC_REDIRECT_URI 已设置 ssh txjp "grep -q 'OIDC_REDIRECT_URI' $REMOTE_DIR/.env 2>/dev/null \ && sed -i 's|OIDC_REDIRECT_URI=.*|OIDC_REDIRECT_URI=${OIDC_URI}|' $REMOTE_DIR/.env 2>/dev/null \ || echo 'OIDC_REDIRECT_URI=${OIDC_URI}' >> $REMOTE_DIR/.env" # 确保 OIDC_CLIENT_SECRET 已设置(从 Authelia 配置读取) if ! ssh txjp "grep -q 'OIDC_CLIENT_SECRET' $REMOTE_DIR/.env 2>/dev/null"; then warn "OIDC_CLIENT_SECRET 未设置,请手动配置" fi fi # 使用 .env.prod 模板补充缺失的环境变量 ENV_PROD="$LOCAL_DIR/config/.env.prod" if [[ -f "$ENV_PROD" ]]; then info "使用 .env.prod 模板补充缺失变量..." # 上传模板到服务器临时目录 scp "$ENV_PROD" txjp:/tmp/.env.prod.template # 逐行检查:模板中有但 .env 中没有的变量,追加到 .env ssh txjp "while IFS= read -r line; do # 跳过注释和空行 [[ \"\$line\" =~ ^#.*$ ]] && continue [[ -z \"\$line\" ]] && continue # 提取变量名 varname=\"\$(echo \"\$line\" | cut -d= -f1)\" # 如果 .env 中没有此变量,追加(跳过占位符值) if ! grep -q \"^\${varname}=\" $REMOTE_DIR/.env 2>/dev/null; then # 跳过 __PLACEHOLDER__ 格式的值(需要真实密钥) if [[ ! \"\$line\" =~ __.*__ ]]; then echo \"\$line\" >> $REMOTE_DIR/.env fi fi done < /tmp/.env.prod.template && rm -f /tmp/.env.prod.template" fi # 自动生成缺失的密钥(首次部署场景) # 先确保 .env 文件存在且权限为 600(防止后续写入期间权限暴露) ssh txjp "touch $REMOTE_DIR/.env && chmod 600 $REMOTE_DIR/.env" 2>/dev/null # JWT_SECRET:所有 .tlyq.ai 子站点必须共用同一个密钥(方案 B:校验+按需修复) # 从服务器共享位置读取权威值(首次自动生成并持久化) JWT_SECRET=$(ssh txjp "cat /root/docker/.shared_jwt_secret 2>/dev/null" 2>/dev/null || echo "") if [[ -z "$JWT_SECRET" ]]; then JWT_SECRET=$(openssl rand -hex 32) # 部署前备份旧共享文件(如存在) ssh txjp "cp /root/docker/.shared_jwt_secret /root/docker/.shared_jwt_secret.bak.\$(date +%Y%m%d) 2>/dev/null || true" ssh txjp "cat > /root/docker/.shared_jwt_secret && chmod 600 /root/docker/.shared_jwt_secret" << EOF ${JWT_SECRET} EOF info "已生成共享 JWT_SECRET(所有站点共用)" fi # 校验站点 .env 中的 JWT_SECRET,不一致时自动修正(防御手动修改) local current_jwt current_jwt=$(ssh txjp "grep '^JWT_SECRET=' $REMOTE_DIR/.env 2>/dev/null | cut -d= -f2" 2>/dev/null || echo "") if [[ -z "$current_jwt" ]]; then ssh txjp "cat >> $REMOTE_DIR/.env" << EOF JWT_SECRET=${JWT_SECRET} EOF elif [[ "$current_jwt" != "$JWT_SECRET" ]]; then warn "检测到 JWT_SECRET 与共享密钥不一致,已自动修正" ssh txjp "sed -i '/^JWT_SECRET=/d' $REMOTE_DIR/.env" # 删旧行(命令不含密钥) ssh txjp "cat >> $REMOTE_DIR/.env" << EOF # heredoc 写新行 JWT_SECRET=${JWT_SECRET} EOF fi # OIDC_CLIENT_SECRET:从服务器现有 .env 读取(Authelia 配置存储的是 bcrypt 哈希,不可逆用) # 全程在服务器侧执行,避免 secret 跨 SSH 边界传输 if [[ "$SITE" == "oa" || "$SITE" == "assets" || "$SITE" == "issue" || "$SITE" == "monitor" ]]; then if ! ssh txjp "grep -q '^OIDC_CLIENT_SECRET=' $REMOTE_DIR/.env 2>/dev/null"; then ssh txjp " found=0 for d in oa-ai assets-ai issue-ai monitor-ai; do if [ \"\$d\" != \"${SITE}-ai\" ] && [ -f /root/docker/\$d/.env ]; then val=\$(grep '^OIDC_CLIENT_SECRET=' /root/docker/\$d/.env 2>/dev/null | head -1 | cut -d= -f2) if [ -n \"\$val\" ] && [ \"\$val\" != *SECRET_HASH* ] && [ \"\$val\" != *PLACEHOLDER* ] && ! echo \"\$val\" | grep -q '^__.*__\$'; then echo \"OIDC_CLIENT_SECRET=\$val\" >> $REMOTE_DIR/.env found=1 break fi fi done if [ \$found -eq 0 ]; then echo 'ERROR: OIDC_CLIENT_SECRET 未设置且无法从其他站点获取,请手动配置后重新部署' >&2 exit 1 fi " && info "已从其他站点复用 OIDC_CLIENT_SECRET" || { error "OIDC_CLIENT_SECRET 未设置且无法从其他站点获取,请手动配置后重新部署" return 1 } fi fi # LOCALADMIN_PASSWORD:monitor 专属 if [[ "$SITE" == "monitor" ]]; then if ! ssh txjp "grep -q '^LOCALADMIN_PASSWORD=' $REMOTE_DIR/.env 2>/dev/null"; then LOCALADMIN_GEN=$(openssl rand -hex 16) ssh txjp "cat >> $REMOTE_DIR/.env" << EOF LOCALADMIN_PASSWORD=${LOCALADMIN_GEN} EOF info "已自动生成 LOCALADMIN_PASSWORD" fi fi # 清理可能被上传的本地环境变量文件(.env.local 等会覆盖 .env 中的生产配置) ssh txjp "rm -f $REMOTE_DIR/.env.local $REMOTE_DIR/.env.development $REMOTE_DIR/.env.production 2>/dev/null; echo '已清理本地环境文件'" # 设置 .env 文件权限为 600(仅 root 可读写,防止密码泄露) ssh txjp "chmod 600 $REMOTE_DIR/.env 2>/dev/null || true" # 5. 检查宿主机与容器 node 版本一致性(防止 native 模块 ABI 不兼容) HOST_NODE_VER=$(ssh txjp "node -v 2>/dev/null | cut -d. -f1 | tr -d 'v'" 2>/dev/null || echo "0") if [[ "$HOST_NODE_VER" != "" && "$HOST_NODE_VER" != "20" ]]; then warn "宿主机 node 版本 (v${HOST_NODE_VER}) 与容器内 node:20-alpine 不一致,native 模块(如 bcrypt)可能出现 ABI 不兼容" fi # 6. 服务器上直接 npm run build(不重建 Docker) log "服务器上执行 npm run build..." build_start=$(date +%s) # 使用 tee 避免管道导致退出码丢失,并过滤无用输出 ssh txjp "cd ${REMOTE_DIR} && npm run build 2>&1" | \ grep -vE "^(info|warn|npm warn|audited|packages|funding|vulnerability|npm notice|New major)" | \ tail -50 build_exit_code=${PIPESTATUS[0]} # PIPESTATUS 为 bash 内建变量,脚本已声明 #!/usr/bin/env bash build_end=$(date +%s) build_dur=$((build_end - build_start)) timing "构建耗时: ${build_dur}s" if [[ "$build_exit_code" -ne 0 ]]; then error "构建失败(退出码: $build_exit_code),中止部署" return 1 fi # 构建新鲜度检查(自动检测 Node.js standalone 部署) if ssh txjp "test -f $REMOTE_DIR/.next/standalone/server.js" 2>/dev/null; then BUILD_MTIME=$(ssh txjp "stat -c %Y $REMOTE_DIR/.next/standalone/server.js" 2>/dev/null || echo 0) SRC_MTIME=$(ssh txjp "find $REMOTE_DIR/src -type f \( -name '*.ts' -o -name '*.tsx' \) -exec stat -c %Y {} + 2>/dev/null | sort -rn | head -1" 2>/dev/null || echo 0) if [ "$BUILD_MTIME" -lt "$SRC_MTIME" ] 2>/dev/null; then warn "构建产物旧于源码!请确保 npm run build 已重新执行" else info "构建新鲜度检查通过" fi fi # 静态导出站点(www):next build 产出 out/,移至 html/ 供 nginx 托管 if [[ "$SITE" == "www" ]]; then ssh txjp "cd ${REMOTE_DIR} && rm -rf html && mv out html 2>/dev/null" || { error "静态导出目录迁移失败(out/ → html/)" return 1 } info "静态导出已迁移至 html/" fi # 清理 standalone 中的 musl 原生模块(Alpine builder 残留,Debian runner 不兼容) if [[ "$SITE" == "www" || "$SITE" == "issue" || "$SITE" == "assets" || "$SITE" == "oa" || "$SITE" == "monitor" ]]; then ssh txjp "rm -rf $REMOTE_DIR/.next/standalone/node_modules/@img/sharp-linuxmusl-x64 \ $REMOTE_DIR/.next/standalone/node_modules/@img/sharp-libvips-linuxmusl-x64 \ $REMOTE_DIR/.next/standalone/node_modules/@next/swc-linux-x64-musl 2>/dev/null; echo '已清理 musl 残留'" fi # 6. 同步 docker-compose.yml(确保服务器 compose 与本地一致) local compose_src="$(echo "$LOCAL_DIR" | sed 's|/src$||')" scp "$compose_src/docker-compose.yml" "txjp:$REMOTE_DIR/docker-compose.yml" 2>/dev/null || warn "docker-compose.yml 上传失败" # 7. 重建容器(应用 .next 卷挂载;若存在 Dockerfile 则 --build 确保原生模块兼容; # 无论有无 Dockerfile,restart 确保 Next.js 进程重新加载 .next) log "重建容器..." local build_flag="" if ssh txjp "test -f $REMOTE_DIR/Dockerfile" 2>/dev/null; then build_flag="--build" else info "无 Dockerfile,跳过镜像重建" fi # Docker 无法跟随指向构建上下文外的符号链接,临时将 shared 符号链接替换为实际目录 ssh txjp "cd $REMOTE_DIR && if [ -L shared ]; then target=\$(readlink shared) cp -r \$target shared_tmp && rm shared && mv shared_tmp shared && \ echo 'shared 符号链接已临时替换为实际目录' || \ { echo '错误: shared 符号链接替换失败'; exit 1; } fi" || { error "shared 符号链接替换失败,中止部署" return 1 } ssh txjp "cd $REMOTE_DIR && docker compose up -d $build_flag && docker compose restart" # 恢复 shared 符号链接(仅当目录非符号链接且上级 shared 存在时) ssh txjp "cd $REMOTE_DIR && if [ -d shared ] && [ ! -L shared ] && [ -d ../shared ]; then rm -rf shared ln -s ../shared shared echo 'shared 符号链接已恢复' fi" # 7. 清理构建产生的悬空镜像和缓存(调用独立清理脚本) log "清理构建缓存..." bash "${SCRIPT_DIR}/docker-cache-cleanup.sh" --default # 8. 保存快照 ssh txjp "mkdir -p ${SNAPSHOT_DIR} && echo '$local_md5' > ${SNAPSHOT_FILE}" # 9. 清理远程临时文件(源码包、连通性检查脚本) ssh txjp "rm -f /tmp/${SITE}.tar.gz /tmp/shared-deploy.tar.gz /tmp/check-conn.js /tmp/.env.prod.template 2>/dev/null || true" } # ============================================================ # 执行部署 # ============================================================ echo "" case "$SITE" in www) scp /Users/niuniu/programs/docker/www-ai/docker-compose.yml txjp:$REMOTE_DIR/docker-compose.yml scp /Users/niuniu/programs/docker/www-ai/nginx.conf txjp:$REMOTE_DIR/nginx.conf build_on_server || exit 1 ;; cloud) info "上传静态文件..." scp "$LOCAL_DIR"/index.html txjp:$REMOTE_DIR/index.html || { error "cloud index.html 上传失败"; exit 1; } scp "$LOCAL_DIR"/nginx.conf txjp:$REMOTE_DIR/nginx.conf 2>/dev/null || true scp /Users/niuniu/programs/docker/cloud-ai/docker-compose.yml txjp:$REMOTE_DIR/docker-compose.yml 2>/dev/null || true ssh txjp "docker restart ${SITE}-ai" ;; token) info "上传静态文件..." scp "$LOCAL_DIR"/index.html txjp:$REMOTE_DIR/index.html || { error "token index.html 上传失败"; exit 1; } scp "$LOCAL_DIR"/nginx.conf txjp:$REMOTE_DIR/nginx.conf 2>/dev/null || true scp /Users/niuniu/programs/docker/token-ai/docker-compose.yml txjp:$REMOTE_DIR/docker-compose.yml 2>/dev/null || true ssh txjp "docker restart ${SITE}-ai" ;; issue) build_on_server "http://assets-ai:3000/api" || exit 1 info "部署 nginx 路由..." ssh txjp "cp $REMOTE_DIR/../nginx-proxy-ai/conf.d/issue-ai.conf /root/docker/nginx-proxy-ai/conf.d/ 2>/dev/null || true docker exec nginx-ai nginx -t && docker exec nginx-ai nginx -s reload" # 验证 issue→assets API 连通性(带重试,应对同时部署时目标容器暂不可达) info "检查 issue→assets API 连通性..." ssh txjp 'cat > /tmp/check-conn.js' << 'CHECKEOF' const http = require("http"); const url = process.env.ASSETS_API_URL + "/assets?pageSize=1"; const key = process.env.ASSETS_API_KEY || ""; let attempt = 0; function tryConnect() { attempt++; http.get(url, {headers:{"Authorization":"Bearer "+key}}, (res) => { if (res.statusCode === 200) { console.log("连通正常: HTTP 200 (第"+attempt+"次)"); process.exit(0); } console.error("连通失败: HTTP", res.statusCode, "(第"+attempt+"次)"); if (attempt < 3) { console.error("10s 后重试..."); setTimeout(tryConnect, 10000); } else { console.error("已达最大重试次数,请检查 ASSETS_API_KEY 是否在 assets-ai 中注册"); process.exit(1); } }).on("error", (e) => { console.error("连接失败:", e.message, "(第"+attempt+"次)"); if (attempt < 3) { console.error("10s 后重试..."); setTimeout(tryConnect, 10000); } else { console.error("已达最大重试次数"); process.exit(1); } }); } tryConnect(); CHECKEOF CONN_CHECK=$(ssh txjp "docker cp /tmp/check-conn.js issue-ai:/tmp/check-conn.js && docker exec issue-ai sh -c 'cd /app && NODE_PATH=/app/node_modules node /tmp/check-conn.js'" 2>&1) if [ $? -ne 0 ]; then warn "issue→assets API 连通性检查失败!站点已正常部署,请检查 ASSETS_API_KEY 是否在 assets-ai 中注册" echo " $CONN_CHECK" else log "issue→assets API 连通性正常" fi ;; assets) build_on_server || exit 1 info "部署 nginx 路由..." ssh txjp "cp $REMOTE_DIR/../nginx-proxy-ai/conf.d/assets-ai.conf /root/docker/nginx-proxy-ai/conf.d/ 2>/dev/null || true docker exec nginx-ai nginx -t && docker exec nginx-ai nginx -s reload" # 验证 assets→issue API 连通性(带重试,应对同时部署时目标容器暂不可达) info "检查 assets→issue API 连通性..." ssh txjp 'cat > /tmp/check-conn.js' << 'CHECKEOF' const http = require("http"); const url = process.env.ISSUE_API_URL + "/tickets?pageSize=1"; const key = process.env.ISSUE_API_KEY || ""; let attempt = 0; function tryConnect() { attempt++; http.get(url, {headers:{"Authorization":"Bearer "+key}}, (res) => { if (res.statusCode === 200) { console.log("连通正常: HTTP 200 (第"+attempt+"次)"); process.exit(0); } console.error("连通失败: HTTP", res.statusCode, "(第"+attempt+"次)"); if (attempt < 3) { console.error("10s 后重试..."); setTimeout(tryConnect, 10000); } else { console.error("已达最大重试次数,请检查 ISSUE_API_KEY 是否在 issue-ai 中注册"); process.exit(1); } }).on("error", (e) => { console.error("连接失败:", e.message, "(第"+attempt+"次)"); if (attempt < 3) { console.error("10s 后重试..."); setTimeout(tryConnect, 10000); } else { console.error("已达最大重试次数"); process.exit(1); } }); } tryConnect(); CHECKEOF CONN_CHECK=$(ssh txjp "docker cp /tmp/check-conn.js assets-ai:/tmp/check-conn.js && docker exec assets-ai sh -c 'cd /app && NODE_PATH=/app/node_modules node /tmp/check-conn.js'" 2>&1) if [ $? -ne 0 ]; then warn "assets→issue API 连通性检查失败!站点已正常部署,请检查 ISSUE_API_KEY 是否在 issue-ai 中注册" echo " $CONN_CHECK" else log "assets→issue API 连通性正常" fi ;; oa) build_on_server || exit 1 # 注入 LLDAP admin 密码(OA OIDC callback 需要验证 LLDAP 用户存在性) info "注入 LLDAP admin 密码到 OA .env..." ssh txjp " LLDAP_ADMIN_PASS=\$(docker exec lldap printenv LLDAP_ADMIN_PASSWORD 2>/dev/null || echo '') if [ -n \"\$LLDAP_ADMIN_PASS\" ]; then grep -q '^LLDAP_ADMIN_PASSWORD=' $REMOTE_DIR/.env 2>/dev/null \ && sed -i \"s|^LLDAP_ADMIN_PASSWORD=.*|LLDAP_ADMIN_PASSWORD=\$LLDAP_ADMIN_PASS|\" $REMOTE_DIR/.env \ || echo \"LLDAP_ADMIN_PASSWORD=\$LLDAP_ADMIN_PASS\" >> $REMOTE_DIR/.env fi " info "部署 nginx 配置..." ssh txjp "cp $REMOTE_DIR/../nginx-proxy-ai/conf.d/oa-ai.conf /root/docker/nginx-proxy-ai/conf.d/ 2>/dev/null || true docker exec nginx-ai nginx -t && docker exec nginx-ai nginx -s reload" # 批量同步 LLDAP email 至各站点(新服务器首次部署填充历史数据) info "同步 LLDAP 邮箱至各站点..." ssh txjp "node /root/docker/oa-ai/scripts/sync-emails-to-sites.js 2>/dev/null" || warn "邮箱同步失败(不影响 OA 主流程)" ;; ldap) info "部署 LLDAP..." scp "$LOCAL_DIR/docker-compose.yml" txjp:$REMOTE_DIR/docker-compose.yml scp "$LOCAL_DIR/Dockerfile" txjp:$REMOTE_DIR/Dockerfile ssh txjp "cd $REMOTE_DIR && \ if [ ! -f .env ]; then \ if docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^lldap\$'; then \ LLDAP_JWT=\$(docker exec lldap printenv LLDAP_JWT_SECRET 2>/dev/null || echo '') && \ LLDAP_PASS=\$(docker exec lldap printenv LLDAP_LDAP_USER_PASS 2>/dev/null || echo '') && \ LLDAP_ADMIN=\$(docker exec lldap printenv LLDAP_ADMIN_PASSWORD 2>/dev/null || echo '') ; \ else \ LLDAP_JWT=\$(openssl rand -hex 32) ; \ LLDAP_PASS=\$(openssl rand -hex 16) ; \ LLDAP_ADMIN=\$(openssl rand -hex 16) ; \ echo '首次部署,已生成随机密码' ; \ fi && \ printf '%s\n' \ \"LLDAP_JWT_SECRET=\$LLDAP_JWT\" \ \"LLDAP_LDAP_USER_PASS=\$LLDAP_PASS\" \ \"LLDAP_LDAP_BASE_DN=dc=tlyq,dc=ai\" \ \"LLDAP_HTTP_PORT=17170\" \ \"LLDAP_LDAP_PORT=3890\" \ \"LLDAP_DATABASE_PATH=/data/users.db\" \ \"LLDAP_ADMIN_PASSWORD=\$LLDAP_ADMIN\" \ > .env && \ fi && \ chmod 600 .env && \ sed -i '/3890:3890/d' docker-compose.yml 2>/dev/null || true && \ docker compose up -d --build && docker compose restart" log "LLDAP 已部署" ;; gitea) info "部署 Gitea..." scp "$LOCAL_DIR/docker-compose.yml" txjp:$REMOTE_DIR/docker-compose.yml # 同步证书目录(若本地存在) [ -d "$LOCAL_DIR/cert" ] && scp -r "$LOCAL_DIR"/cert/* txjp:$REMOTE_DIR/cert/ 2>/dev/null || true ssh txjp "cd $REMOTE_DIR && docker compose up -d && docker restart nginx-ai" log "Gitea 已部署" ;; sso) deploy_sso ;; monitor) build_on_server || exit 1 info "部署 nginx 配置..." ssh txjp "cp $REMOTE_DIR/../nginx-proxy-ai/conf.d/monitor-ai.conf /root/docker/nginx-proxy-ai/conf.d/ 2>/dev/null || true docker exec nginx-ai nginx -t && docker exec nginx-ai nginx -s reload" ;; esac # ============================================================ # 运行时合规检查(LESSONS-LEARNED) # ============================================================ echo "" info "运行时合规检查..." # LL #7: SSL 证书覆盖目标域名(仅 tlyq.ai 站点) if [[ "$SITE" != "ldap" ]]; then CERT_DOMAIN=$(ssh txjp "openssl x509 -in /etc/letsencrypt/live/www.tlyq.ai/fullchain.pem -noout -text 2>/dev/null | grep 'DNS:'" 2>/dev/null || echo "") if echo "$CERT_DOMAIN" | grep -q "${SITE}.tlyq.ai"; then log "SSL 证书覆盖 ${SITE}.tlyq.ai" else warn "SSL 证书可能未覆盖 ${SITE}.tlyq.ai(LESSONS-LEARNED #7)" fi fi # LL #20: nginx 重载后确认可用 if ssh txjp "docker exec nginx-ai nginx -t 2>&1" 2>/dev/null | grep -q "successful"; then log "nginx 配置检查通过" else warn "nginx 配置检查未通过,请检查 upstream 容器状态(LESSONS-LEARNED #20/#21)" fi # LL #22/#23: 数据库文件可访问(仅含 DB 站点) case "$SITE" in issue|assets|oa|monitor|gitea) DB_PATH="" case "$SITE" in issue) DB_PATH="/app/data/issue.db" ;; assets) DB_PATH="/app/data/assets.db" ;; oa) DB_PATH="/data/other-sites/assets/assets.db /data/other-sites/issue/issue.db" ;; monitor) DB_PATH="/app/data/monitor.db" ;; gitea) DB_PATH="/data/gitea.db" ;; esac for db in $DB_PATH; do if ssh txjp "docker exec ${CONTAINER} test -f $db" 2>/dev/null; then log "数据库文件 $db 可访问" else warn "数据库文件 $db 不可访问(LESSONS-LEARNED #22/#23)" fi done ;; esac # 构建新鲜度检查:编译产物必须新于源码(自动适配所有 Next.js 站点) if ssh txjp "test -f /root/docker/${REMOTE_DIR}/.next/standalone/server.js" 2>/dev/null; then BUILD_MTIME=$(ssh txjp "stat -c %Y /root/docker/${REMOTE_DIR}/.next/standalone/server.js" 2>/dev/null || echo 0) SRC_MTIME=$(ssh txjp "find /root/docker/${REMOTE_DIR}/src -type f \( -name '*.ts' -o -name '*.tsx' \) -exec stat -c %Y {} + 2>/dev/null | sort -rn | head -1" 2>/dev/null || echo 0) if [ "$BUILD_MTIME" -lt "$SRC_MTIME" ] 2>/dev/null; then warn "构建产物旧于源码!请确保 npm run build 已重新执行" else log "构建新鲜度检查通过" fi fi # ============================================================ # 验证 # ============================================================ echo "" log "验证部署..." BUILD_END=$(date +%s) TOTAL_DUR=$((BUILD_END - BUILD_START)) URL="" case "$SITE" in www) URL="https://www.tlyq.ai" ;; cloud) URL="https://cloud.tlyq.ai" ;; token) URL="https://token.tlyq.ai" ;; issue) URL="https://issue.tlyq.ai" ;; assets) URL="https://assets.tlyq.ai" ;; oa) URL="https://oa.tlyq.ai" ;; ldap) URL="http://localhost:6178" ;; gitea) URL="https://git.tlyq.ai" ;; sso) URL="https://sso.tlyq.ai" ;; monitor) URL="https://monitor.tlyq.ai" ;; esac if [[ "$SITE" == "ldap" ]]; then STATUS=$(ssh txjp "docker exec lldap wget -q -O - http://127.0.0.1:17170 > /dev/null 2>&1 && echo 200 || echo 000" 2>/dev/null || echo "???") else STATUS=$(ssh txjp "curl -s -o /dev/null -w '%{http_code}' '$URL' 2>/dev/null" 2>/dev/null || echo "???") fi if [[ "$STATUS" == "200" || "$STATUS" == "307" || "$STATUS" == "301" ]]; then log "部署成功!总耗时: ${TOTAL_DUR}s | 访问 $URL" exit 0 else warn "返回状态码: $STATUS,请检查" exit 1 fi