website-scripts/deploy-ai.sh

948 lines
46 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改用显式错误处理避免管道命令导致意外退出
# ============================================================
# 脚本目录
# ============================================================
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 -execfind 只能调用二进制)
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. 仅启动 autheliaredis 和 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_PASSWORDmonitor 专属
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
# 静态导出站点wwwnext 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 确保原生模块兼容;
# 无论有无 Dockerfilerestart 确保 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.aiLESSONS-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