#!/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,改用显式错误处理,避免管道命令导致意外退出 # ============================================================ # 颜色和日志 # ============================================================ 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_I_BACKUP='-i' if is_mac; then SED_I_BACKUP='-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 # ============================================================ # 选择站点 # ============================================================ 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 "" printf "请输入编号 (1/2/3/4/5/6): " read choice SITE=""; LOCAL_DIR=""; REMOTE_DIR=""; CONTAINER="" case "$choice" in 1) SITE="www"; LOCAL_DIR="/Users/niuniu/programs/docker/www-ai/src"; REMOTE_DIR="/tmp/project-extract-2/turing-engine" ;; 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" ;; *) echo "无效选择"; exit 1 ;; esac echo "" log "部署目标: $LOCAL_DIR → $REMOTE_DIR" echo "" # ============================================================ # 链接替换 # ============================================================ replace_url() { local mode="$1" local from to if [[ "$mode" == "prod" ]]; then from="localhost"; to="https://www.tlyq.ai" else from="https://www.tlyq.ai"; to="localhost" fi case "$SITE" in www) cd "$LOCAL_DIR" find src -type f -name '*.tsx' -exec sed $SED_I_BACKUP \ -e "s|${from}:6174|https://cloud.tlyq.ai|g" \ -e "s|${from}:6175|https://token.tlyq.ai|g" \ -e "s|https://cloud.tlyq.ai|${from}:6174|g" \ -e "s|https://token.tlyq.ai|${from}:6175|g" \ {} + 2>/dev/null || true ;; cloud) sed $SED_I_BACKUP "s|${from}:6173|https://www.tlyq.ai|g" "$LOCAL_DIR/index.html" 2>/dev/null || true sed $SED_I_BACKUP "s|https://www.tlyq.ai|${from}:6173|g" "$LOCAL_DIR/index.html" 2>/dev/null || true ;; token) sed $SED_I_BACKUP "s|${from}:6173|https://www.tlyq.ai|g" "$LOCAL_DIR/index.html" 2>/dev/null || true sed $SED_I_BACKUP "s|https://www.tlyq.ai|${from}:6173|g" "$LOCAL_DIR/index.html" 2>/dev/null || true ;; esac } # OA 专属 URL 替换(三类:前端导航 / 服务端内部 API / 退出登录) replace_oa_urls() { local mode="$1" # prod | local cd "$LOCAL_DIR" if [[ "$mode" == "prod" ]]; then # A. 前端导航卡片 → 生产域名 sed $SED_I_BACKUP \ -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 $SED_I_BACKUP \ -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 $SED_I_BACKUP "s|'http://localhost:6179'|'https://oa.tlyq.ai'|g" src/app/api/auth/logout/route.ts else # 恢复本地 URL sed $SED_I_BACKUP \ -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 $SED_I_BACKUP \ -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 $SED_I_BACKUP "s|'https://oa.tlyq.ai'|'http://localhost:6179'|g" src/app/api/auth/logout/route.ts fi } to_prod() { [[ "$SITE" != "issue" && "$SITE" != "assets" && "$SITE" != "oa" ]] && replace_url prod; } to_local() { [[ "$SITE" != "issue" && "$SITE" != "assets" && "$SITE" != "oa" ]] && replace_url local; } trap 'to_local; [[ "$SITE" == "oa" ]] && replace_oa_urls local' EXIT [[ "$SITE" == "oa" ]] && replace_oa_urls prod [[ "$SITE" != "issue" && "$SITE" != "assets" && "$SITE" != "oa" ]] && to_prod # ============================================================ # 核心构建函数(服务器上直接 npm run build) # ============================================================ SNAPSHOT_FILE="/tmp/.snapshot.${SITE}.md5" BUILD_START=$(date +%s) build_on_server() { local extra_env="${1:-}" # 1. 计算源码快照 log "检查源码是否有变化..." local_md5=$(find "$LOCAL_DIR" \ -not -path '*/node_modules/*' \ -not -path '*/.next/*' \ -not -path '*/out/*' \ -not -path '*/data/*' \ -not -path '*/reports/*' \ -not -path '*/docs/*' \ -not -path '*/.env*' \ -not -path '*/.git/*' \ -not -name '._*' \ -type f \ -exec md5 -q {} \; \ | sort \ | md5 -q) 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 || true" 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 已设置(退出登录 cookie domain 依赖此变量) 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" # 清理可能被上传的本地环境变量文件(.env.local 等会覆盖 .env 中的生产配置) ssh txjp "rm -f $REMOTE_DIR/.env.local $REMOTE_DIR/.env.development $REMOTE_DIR/.env.production 2>/dev/null; echo '已清理本地环境文件'" # 5. 服务器上直接 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)" | \ tee /tmp/build_output.txt | \ tail -15 build_exit_code=${PIPESTATUS[0]} build_end=$(date +%s) build_dur=$((build_end - build_start)) timing "构建耗时: ${build_dur}s" if [[ "$build_exit_code" -ne 0 ]]; then warn "构建可能有警告,请检查上面的输出" fi # 清理 standalone 中的 musl 原生模块(Alpine builder 残留,Debian runner 不兼容) if [[ "$SITE" == "issue" || "$SITE" == "assets" || "$SITE" == "oa" ]]; 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. 重建容器(应用 .next 卷挂载;若存在 Dockerfile 则 --build 确保原生模块兼容; # 无论有无 Dockerfile,restart 确保 Next.js 进程重新加载 .next) log "重建容器..." local build_flag="" ssh txjp "test -f $REMOTE_DIR/Dockerfile" 2>/dev/null && build_flag="--build" ssh txjp "cd $REMOTE_DIR && docker compose up -d $build_flag && docker compose restart" # 7. 保存快照 ssh txjp "echo '$local_md5' > ${SNAPSHOT_FILE}" } # ============================================================ # 执行部署 # ============================================================ echo "" case "$SITE" in www) build_on_server ;; cloud) info "上传静态文件..." scp "$LOCAL_DIR"/index.html txjp:$REMOTE_DIR/index.html 2>/dev/null || true ssh txjp "docker restart ${SITE}-ai" ;; token) info "上传静态文件..." scp "$LOCAL_DIR"/index.html txjp:$REMOTE_DIR/index.html 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 -s reload 2>/dev/null || true" # 验证 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 error "issue→assets API 连通性检查失败!请检查 ASSETS_API_KEY 是否在 assets-ai 中注册" echo " $CONN_CHECK" exit 1 fi log "issue→assets API 连通性正常" ;; 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 -s reload 2>/dev/null || true" # 验证 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 error "assets→issue API 连通性检查失败!请检查 ISSUE_API_KEY 是否在 issue-ai 中注册" echo " $CONN_CHECK" exit 1 fi log "assets→issue API 连通性正常" ;; oa) build_on_server || exit 1 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" ;; esac # ============================================================ # 验证 # ============================================================ 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" ;; esac STATUS=$(ssh txjp "curl -s -o /dev/null -w '%{http_code}' -k '$URL' 2>/dev/null" 2>/dev/null || echo "???") if [[ "$STATUS" == "200" || "$STATUS" == "307" || "$STATUS" == "301" ]]; then log "部署成功!总耗时: ${TOTAL_DUR}s | 访问 $URL" exit 0 else warn "返回状态码: $STATUS,请检查" exit 1 fi