website-scripts/deploy-ai.sh

592 lines
28 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 " 7) ldap.tlyq.ai (LLDAP 用户目录服务)"
echo " 8) git.tlyq.ai (Gitea 代码托管)"
echo " 9) sso.tlyq.ai (Authelia SSO — 统一认证中心)"
echo ""
printf "请输入编号 (1/2/3/4/5/6/7/8/9): "
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" ;;
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" ;;
*) 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
# ============================================================
# 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
scp -r /Users/niuniu/programs/docker/sso-ai/config txjp:/root/docker/sso-ai/config
# 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 '密钥已生成'
else
echo '已存在 .env跳过密钥生成'
fi"
# 3. 注入 LDAP 密码到 configuration.yml
log "注入 LDAP 密码..."
ssh txjp "cd /root/docker/sso-ai && \
LLDAP_PASSWORD=\$(docker exec lldap printenv LLDAP_ADMIN_PASSWORD) && \
sed -i \"s/password: 'placeholder'/password: '\$LLDAP_PASSWORD'/\" config/authelia/configuration.yml 2>/dev/null || true"
# 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. 启动容器
log "启动 Authelia..."
ssh txjp "cd /root/docker/sso-ai && docker compose up -d"
# 6. 验证
sleep 5
ssh txjp "curl -sk https://127.0.0.1:9091/api/health" && log "Authelia 健康检查通过" || warn "Authelia 可能未就绪"
# 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_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"
# OIDC 环境变量配置oa/assets/issue 站点)
if [[ "$SITE" == "oa" || "$SITE" == "assets" || "$SITE" == "issue" ]]; 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" ;;
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.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 确保原生模块兼容;
# 无论有无 Dockerfilerestart 确保 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. 清理构建产生的悬空镜像和缓存(根据磁盘使用率动态调整)
log "清理构建缓存..."
ssh txjp "docker image prune -f --filter 'label=com.docker.compose.project=${SITE}-ai' 2>/dev/null || true"
# 根据磁盘使用率动态调整构建缓存清理策略
DISK_USAGE=$(ssh txjp "df / --output=pcent | tail -1 | tr -d ' %'" 2>/dev/null || echo "50")
if [ "$DISK_USAGE" -gt 95 ]; then
warn "磁盘使用率 ${DISK_USAGE}%,执行紧急清理"
ssh txjp "docker builder prune -f 2>/dev/null || true"
elif [ "$DISK_USAGE" -gt 85 ]; then
info "磁盘使用率 ${DISK_USAGE}%执行激进清理6小时"
ssh txjp "docker builder prune -f --filter 'until=6h' 2>/dev/null || true"
elif [ "$DISK_USAGE" -gt 70 ]; then
info "磁盘使用率 ${DISK_USAGE}%执行正常清理24小时"
ssh txjp "docker builder prune -f --filter 'until=24h' 2>/dev/null || true"
else
info "磁盘使用率 ${DISK_USAGE}%执行保守清理48小时"
ssh txjp "docker builder prune -f --filter 'until=48h' 2>/dev/null || true"
fi
# 8. 保存快照
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"
# 批量同步 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 \
echo 'LLDAP_JWT_SECRET='\$(docker exec lldap printenv LLDAP_JWT_SECRET 2>/dev/null) > .env && \
echo 'LLDAP_LDAP_USER_PASS='\$(docker exec lldap printenv LLDAP_LDAP_USER_PASS 2>/dev/null) >> .env && \
echo 'LLDAP_LDAP_BASE_DN=dc=tlyq,dc=ai' >> .env && \
echo 'LLDAP_HTTP_PORT=17170' >> .env && \
echo 'LLDAP_LDAP_PORT=3890' >> .env && \
echo 'LLDAP_DATABASE_PATH=/data/users.db' >> .env && \
echo 'LLDAP_ADMIN_PASSWORD='\$(docker exec lldap printenv LLDAP_ADMIN_PASSWORD 2>/dev/null) >> .env && \
fi && \
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
ssh txjp "cd $REMOTE_DIR && docker compose up -d && docker restart nginx-ai"
log "Gitea 已部署" ;;
sso)
deploy_sso ;;
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" ;;
ldap) URL="http://localhost:6178" ;;
gitea) URL="https://git.tlyq.ai" ;;
sso) URL="https://sso.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}' -k '$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