698 lines
32 KiB
Bash
Executable File
698 lines
32 KiB
Bash
Executable File
#!/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 " 10) monitor.tlyq.ai (告警监控中心)"
|
||
echo ""
|
||
printf "请输入编号 (1/2/3/4/5/6/7/8/9/10): "
|
||
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" ;;
|
||
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"
|
||
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
|
||
# 使用生产配置(configuration.prod.yml → configuration.yml)
|
||
scp /Users/niuniu/programs/docker/sso-ai/config/authelia/configuration.prod.yml txjp:/root/docker/sso-ai/config/authelia/configuration.yml
|
||
# 上传其他 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 '密钥已生成'
|
||
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/PRODUCTION_LDAP_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)
|
||
|
||
# ============================================================
|
||
# 同步 shared 共享库(快照比对,无变化跳过)
|
||
# ============================================================
|
||
SHARED_SNAPSHOT="/tmp/.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
|
||
local_md5=$(find "$SHARED_LOCAL" \
|
||
-not -path '*/node_modules/*' \
|
||
-not -path '*/.git/*' \
|
||
-not -name '._*' \
|
||
-type f \
|
||
-exec md5 -q {} \; 2>/dev/null \
|
||
| sort \
|
||
| md5 -q)
|
||
|
||
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
|
||
scp /tmp/shared-deploy.tar.gz txjp:/tmp/shared-deploy.tar.gz
|
||
ssh txjp "rm -rf ${SHARED_REMOTE} && mkdir -p ${SHARED_REMOTE} && \
|
||
tar xzf /tmp/shared-deploy.tar.gz -C /root/docker/ && \
|
||
echo '${local_md5}' > ${SHARED_SNAPSHOT}"
|
||
log "shared 已同步到 ${SHARED_REMOTE}"
|
||
}
|
||
|
||
build_on_server() {
|
||
local extra_env="${1:-}"
|
||
|
||
# 0. 同步 shared 共享库(所有使用 @shared 的站点自动受益)
|
||
sync_shared
|
||
|
||
# 1. 计算源码快照(含 shared,确保 shared 变化也触发重构建)
|
||
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 {} \; ; \
|
||
find "$SHARED_LOCAL" \
|
||
-not -path '*/node_modules/*' \
|
||
-not -path '*/.git/*' \
|
||
-not -name '._*' \
|
||
-type f \
|
||
-exec md5 -q {} \; 2>/dev/null) \
|
||
| 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/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.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" || "$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. 重建容器(应用 .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"
|
||
|
||
# Docker 无法跟随指向构建上下文外的符号链接,临时将 shared 符号链接替换为实际目录
|
||
ssh txjp "cd $REMOTE_DIR && if [ -L shared ]; then
|
||
target=\$(readlink shared)
|
||
rm shared
|
||
cp -r \$target shared
|
||
echo 'shared 符号链接已临时替换为实际目录'
|
||
fi"
|
||
|
||
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 "清理构建缓存..."
|
||
# 清理悬空镜像(无标签或未被引用的中间层)
|
||
ssh txjp "docker image prune -f 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 ;;
|
||
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 -s reload 2>/dev/null || true" ;;
|
||
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" ;;
|
||
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}' -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
|