fix: backup/restore scripts — auto-discover sites + add monitor-ai coverage

- backup-sites.sh: 自动发现含 DB 站点 + monitor case + wildcard 通用备份
- backup-db.sh: 自动发现站点 + 并行 WAL checkpoint + 动态 docker cp
- backup-full.sh: 自动发现 + monitor WAL + db export
- restore-sites.sh: 自动发现 + restore_monitor + restore_generic 通用恢复
- restore-db.sh: 新增 monitor/gitea case
- restore-full.sh: 新增 restore_monitor + 菜单位移
This commit is contained in:
aiyimickey 2026-07-08 12:03:40 +08:00
parent 3dd394d943
commit 9c9e5d7c44
8 changed files with 765 additions and 44 deletions

View File

@ -9,46 +9,78 @@ LOCAL_DIR="/Users/niuniu/programs/docker/db-backups"
log() { echo "[$(date '+%H:%M:%S')] $1"; }
# 自动发现含数据库的站点
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DB_SITES=""
for dir in "$PROJECT_ROOT"/*-ai/; do
site=$(basename "$dir")
[[ "$site" == "nginx-proxy-ai" ]] && continue
[ -f "$dir/docker-compose.yml" ] || continue
grep -qE "\.db|DATABASE_PATH|sqlite" "$dir/docker-compose.yml" 2>/dev/null || continue
DB_SITES="$DB_SITES $site"
done
log "发现数据库站点:${DB_SITES}"
log "开始备份云端数据库..."
# 备份前执行 WAL checkpoint确保数据全部合并到 .db 主文件
log "执行 WAL checkpoint (assets)..."
ssh txjp "docker exec assets-ai node -e \"const D=require('better-sqlite3');const db=new D('/app/data/assets.db');db.pragma('wal_checkpoint(TRUNCATE)');db.close();\""
log "执行 WAL checkpoint (issue)..."
ssh txjp "docker exec issue-ai node -e \"const D=require('better-sqlite3');const db=new D('/app/data/issue.db');db.pragma('wal_checkpoint(TRUNCATE)');db.close();\""
log "执行 WAL checkpoint (LLDAP)..."
ssh txjp "docker exec lldap sqlite3 /data/users.db 'PRAGMA wal_checkpoint(TRUNCATE);' 2>/dev/null || true"
# 站点 → 容器名 + DB路径 映射(自动推导:<site> → <site>:/app/data/<site去掉-ai>.db
# LLDAP/gitea 容器名特殊,显式声明
declare -A DB_CONTAINERS=([ldap]="lldap:/data/users.db" [gitea]="gitea-ai:/data/gitea.db" [oa]="")
# 云端打包(使用 docker cp 从容器内复制,确保拿到真实数据而非旧宿主机文件)
ssh txjp "mkdir -p /tmp/db-backup && \
docker cp assets-ai:/app/data/assets.db /tmp/db-backup/assets-${TIMESTAMP}.db && \
docker cp issue-ai:/app/data/issue.db /tmp/db-backup/issue-${TIMESTAMP}.db && \
docker cp lldap:/data/users.db /tmp/db-backup/ldap-${TIMESTAMP}.db && \
docker cp gitea-ai:/data/gitea.db /tmp/db-backup/gitea-${TIMESTAMP}.db"
# 并行 WAL checkpoint
for site in $DB_SITES; do
[[ "$site" == "oa" ]] && continue # OA 无独立 DB
if [[ -n "${DB_CONTAINERS[$site]}" ]]; then
cname="${DB_CONTAINERS[$site]%%:*}"
else
cname="${site}" # assets-ai / issue-ai / monitor-ai
fi
log "WAL checkpoint: ${site} (${cname})..."
ssh txjp "docker exec ${cname} sqlite3 /app/data/*.db 'PRAGMA wal_checkpoint(TRUNCATE);' 2>/dev/null || true" &
done
wait
# 云端打包
ssh txjp "mkdir -p /tmp/db-backup"
for site in $DB_SITES; do
[[ "$site" == "oa" ]] && continue
if [[ -n "${DB_CONTAINERS[$site]}" ]]; then
cname="${DB_CONTAINERS[$site]%%:*}"
dbpath="${DB_CONTAINERS[$site]##*:}"
else
cname="${site}"
short="${site%-ai}" # assets-ai → assets
dbpath="/app/data/${short}.db"
fi
log "备份 ${site} (${cname}:${dbpath})..."
ssh txjp "docker cp ${cname}:${dbpath} /tmp/db-backup/${site}-${TIMESTAMP}.db"
done
# 确保本地备份目录存在
mkdir -p "${LOCAL_DIR}"
# 云端留一份持久备份
CLOUD_DIR="/root/docker/db-backups"
ssh txjp "mkdir -p ${CLOUD_DIR} && \
cp /tmp/db-backup/assets-${TIMESTAMP}.db ${CLOUD_DIR}/assets-${TIMESTAMP}.db && \
cp /tmp/db-backup/issue-${TIMESTAMP}.db ${CLOUD_DIR}/issue-${TIMESTAMP}.db && \
cp /tmp/db-backup/ldap-${TIMESTAMP}.db ${CLOUD_DIR}/ldap-${TIMESTAMP}.db && \
cp /tmp/db-backup/gitea-${TIMESTAMP}.db ${CLOUD_DIR}/gitea-${TIMESTAMP}.db"
ssh txjp "mkdir -p ${CLOUD_DIR}"
for site in $DB_SITES; do
[[ "$site" == "oa" ]] && continue
ssh txjp "cp /tmp/db-backup/${site}-${TIMESTAMP}.db ${CLOUD_DIR}/"
done
log "云端备份已保存至: ${CLOUD_DIR}"
# 复制到本地
log "复制到本地..."
mkdir -p "${LOCAL_DIR}"
scp "txjp:/tmp/db-backup/assets-${TIMESTAMP}.db" "${LOCAL_DIR}/"
scp "txjp:/tmp/db-backup/issue-${TIMESTAMP}.db" "${LOCAL_DIR}/"
scp "txjp:/tmp/db-backup/ldap-${TIMESTAMP}.db" "${LOCAL_DIR}/"
scp "txjp:/tmp/db-backup/gitea-${TIMESTAMP}.db" "${LOCAL_DIR}/"
for site in $DB_SITES; do
[[ "$site" == "oa" ]] && continue
scp "txjp:/tmp/db-backup/${site}-${TIMESTAMP}.db" "${LOCAL_DIR}/"
done
# 验证备份文件为有效 SQLite 数据库
log "验证备份完整性..."
for f in "${LOCAL_DIR}/assets-${TIMESTAMP}.db" "${LOCAL_DIR}/issue-${TIMESTAMP}.db" "${LOCAL_DIR}/ldap-${TIMESTAMP}.db" "${LOCAL_DIR}/gitea-${TIMESTAMP}.db"; do
for site in $DB_SITES; do
[[ "$site" == "oa" ]] && continue
f="${LOCAL_DIR}/${site}-${TIMESTAMP}.db"
if ! sqlite3 "$f" "SELECT count(*) FROM sqlite_master" > /dev/null 2>&1; then
echo "[错误] 备份文件无效或损坏: $f"
exit 1
@ -61,6 +93,6 @@ ssh txjp "rm -rf /tmp/db-backup"
log "备份完成:${TIMESTAMP}"
echo " 云端: ${CLOUD_DIR}/"
ssh txjp "ls -lh ${CLOUD_DIR}/assets-${TIMESTAMP}.db ${CLOUD_DIR}/issue-${TIMESTAMP}.db ${CLOUD_DIR}/ldap-${TIMESTAMP}.db ${CLOUD_DIR}/gitea-${TIMESTAMP}.db 2>/dev/null"
ssh txjp "ls -lh ${CLOUD_DIR}/*-${TIMESTAMP}.db 2>/dev/null"
echo " 本地:"
ls -lh "${LOCAL_DIR}"/assets-${TIMESTAMP}.db "${LOCAL_DIR}"/issue-${TIMESTAMP}.db "${LOCAL_DIR}"/ldap-${TIMESTAMP}.db "${LOCAL_DIR}"/gitea-${TIMESTAMP}.db 2>/dev/null
ls -lh "${LOCAL_DIR}"/*-${TIMESTAMP}.db 2>/dev/null

View File

@ -17,17 +17,33 @@ log "开始完整灾难恢复备份..."
# ============================================================
# 1. 数据库备份WAL checkpoint + docker cp
# ============================================================
# 自动发现数据库站点
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DB_SITES=""
for dir in "$PROJECT_ROOT"/*-ai/; do
s=$(basename "$dir"); [[ "$s" == "nginx-proxy-ai" ]] && continue
[ -f "$dir/docker-compose.yml" ] || continue
grep -qE "\.db|DATABASE_PATH|sqlite" "$dir/docker-compose.yml" 2>/dev/null || continue
[[ "$s" == "oa" ]] && continue; DB_SITES="$DB_SITES $s"
done
log "执行 WAL checkpoint..."
ssh txjp "docker exec assets-ai node -e \"const D=require('better-sqlite3');const db=new D('/app/data/assets.db');db.pragma('wal_checkpoint(TRUNCATE)');db.close();\"" 2>/dev/null || warn "assets checkpoint 失败"
ssh txjp "docker exec issue-ai node -e \"const D=require('better-sqlite3');const db=new D('/app/data/issue.db');db.pragma('wal_checkpoint(TRUNCATE)');db.close();\"" 2>/dev/null || warn "issue checkpoint 失败"
ssh txjp "docker exec lldap sqlite3 /data/users.db 'PRAGMA wal_checkpoint(TRUNCATE);'" 2>/dev/null || warn "ldap checkpoint 失败"
for s in $DB_SITES; do
case "$s" in
ldap) ssh txjp "docker exec lldap sqlite3 /data/users.db 'PRAGMA wal_checkpoint(TRUNCATE);'" 2>/dev/null || warn "ldap checkpoint 失败" ;;
*) ssh txjp "docker exec ${s} sqlite3 /app/data/*.db 'PRAGMA wal_checkpoint(TRUNCATE);' 2>/dev/null || true" & ;;
esac
done; wait
log "导出数据库..."
ssh txjp "mkdir -p /tmp/full-backup/databases && \
docker cp assets-ai:/app/data/assets.db /tmp/full-backup/databases/assets.db && \
docker cp issue-ai:/app/data/issue.db /tmp/full-backup/databases/issue.db && \
docker cp lldap:/data/users.db /tmp/full-backup/databases/ldap.db && \
docker cp gitea-ai:/data/gitea.db /tmp/full-backup/databases/gitea.db"
ssh txjp "mkdir -p /tmp/full-backup/databases"
for s in $DB_SITES; do
case "$s" in
ldap) ssh txjp "docker cp lldap:/data/users.db /tmp/full-backup/databases/ldap.db" ;;
gitea) ssh txjp "docker cp gitea-ai:/data/gitea.db /tmp/full-backup/databases/gitea.db" ;;
*) short="${s%-ai}"; ssh txjp "docker cp ${s}:/app/data/${short}.db /tmp/full-backup/databases/${short}.db" ;;
esac
done
# ============================================================
# 2. Gitea 代码仓库 + 配置

View File

@ -15,8 +15,16 @@ CLOUD_DIR="/root/docker/db-backups"
log() { echo "[$(date '+%H:%M:%S')] $1"; }
warn() { echo "[$(date '+%H:%M:%S')] [警告] $1"; }
# 要备份的站点列表
ALL_SITES="assets issue ldap gitea oa"
# 自动发现含数据库的站点(按目录约定:*-ai/docker-compose.yml 含 .db 或 DATABASE_PATH
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
ALL_SITES=""
for dir in "$PROJECT_ROOT"/*-ai/; do
site=$(basename "$dir")
[[ "$site" == "nginx-proxy-ai" ]] && continue
[ -f "$dir/docker-compose.yml" ] || continue
grep -qE "\.db|DATABASE_PATH|sqlite" "$dir/docker-compose.yml" 2>/dev/null || continue
ALL_SITES="$ALL_SITES $site"
done
# ============================================================
# 备份单个站点
@ -82,6 +90,19 @@ backup_site() {
ssh txjp "cp /root/docker/oa-ai/.env /tmp/backup-${site}/ 2>/dev/null || true"
# OA 没有独立数据库,数据存在 assets/issue 中
;;
monitor)
ssh txjp "docker exec monitor-ai sqlite3 /app/data/monitor.db 'PRAGMA wal_checkpoint(TRUNCATE);'" 2>/dev/null || true
ssh txjp "docker cp monitor-ai:/app/data/monitor.db /tmp/backup-${site}/data/"
ssh txjp "cp /root/docker/monitor-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true"
ssh txjp "cp /root/docker/monitor-ai/.env /tmp/backup-${site}/ 2>/dev/null || true"
;;
*)
# 未知站点:仅备份配置文件(保守策略,确保新增站点不会因缺 case 而崩溃)
warn "${site}: 无专用备份逻辑,仅备份配置文件"
ssh txjp "cp /root/docker/${site}/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true"
ssh txjp "cp /root/docker/${site}/.env /tmp/backup-${site}/ 2>/dev/null || true"
ssh txjp "cp /root/docker/${site}/Dockerfile /tmp/backup-${site}/ 2>/dev/null || true"
;;
esac
# 打包

View File

@ -6,12 +6,12 @@
# 恢复本地数据库: bash scripts/restore-db.sh local <服务名> <备份文件名>
# 恢复云端数据库: bash scripts/restore-db.sh cloud <服务名> <备份文件名>
#
# 服务名: issue / assets / ldap
# 服务名: assets / issue / ldap / gitea / monitor
#
# 示例:
# bash scripts/restore-db.sh local issue issue-2026-04-29_1753.db
# bash scripts/restore-db.sh cloud assets assets-2026-04-29_1753.db
# bash scripts/restore-db.sh cloud ldap ldap-2026-05-15_1200.db
# bash scripts/restore-db.sh cloud monitor monitor-2026-07-08_1200.db
# ============================================
set -e
@ -63,8 +63,20 @@ case $SERVICE in
DB_PATH_IN_CONTAINER="/data/users.db"
LOCAL_DB_PATH="/Users/niuniu/programs/docker/ldap-ai/data/lldap/users.db"
;;
gitea)
PROJECT_DIR="gitea-ai"
CONTAINER="gitea-ai"
DB_PATH_IN_CONTAINER="/data/gitea.db"
LOCAL_DB_PATH="/Users/niuniu/programs/docker/gitea-ai/data/gitea/gitea.db"
;;
monitor)
PROJECT_DIR="monitor-ai"
CONTAINER="monitor-ai"
DB_PATH_IN_CONTAINER="/app/data/monitor.db"
LOCAL_DB_PATH="/Users/niuniu/programs/docker/monitor-ai/data/monitor.db"
;;
*)
echo "错误: 服务名必须是 issue、assets 或 ldap"
echo "错误: 服务名必须是 assets、issue、ldap、gitea 或 monitor"
exit 1
;;
esac

View File

@ -109,10 +109,11 @@ echo " 1) assets - 资产管理系统"
echo " 2) issue - 工单系统"
echo " 3) ldap - 用户目录服务"
echo " 4) gitea - 代码仓库"
echo " 5) nginx - 反向代理配置"
echo " 6) 全部恢复"
echo " 5) monitor - 告警监控中心"
echo " 6) nginx - 反向代理配置"
echo " 7) 全部恢复"
echo ""
printf "请选择 (1-6): "
printf "请选择 (1-7): "
read site_choice
# ============================================================
@ -245,18 +246,34 @@ restore_nginx() {
fi
}
restore_monitor() {
log "恢复 monitor 站点..."
if [[ -f "$BACKUP_CONTENT/databases/monitor.db" ]]; then
ssh txjp "docker cp $BACKUP_CONTENT/databases/monitor.db monitor-ai:/app/data/monitor.db"
log "monitor 数据库恢复完成"
else
warn "未找到 monitor 数据库备份"
fi
ssh txjp "cd $REMOTE_BASE/monitor-ai && docker compose down && docker compose up -d --build"
log "monitor 恢复完成"
}
# 执行恢复
case "$site_choice" in
1) restore_assets ;;
2) restore_issue ;;
3) restore_ldap ;;
4) restore_gitea ;;
5) restore_nginx ;;
6)
5) restore_monitor ;;
6) restore_nginx ;;
7)
restore_assets
restore_issue
restore_ldap
restore_gitea
restore_monitor
restore_nginx
;;
*) error "无效选择" ;;

View File

@ -83,7 +83,13 @@ echo " 可恢复的站点"
echo "=========================================="
echo ""
SITES="shared assets issue ldap gitea oa"
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SITES="shared"
for dir in "$PROJECT_ROOT"/*-ai/; do
s=$(basename "$dir"); [[ "$s" == "nginx-proxy-ai" ]] && continue
[ -f "$dir/docker-compose.yml" ] || continue
SITES="$SITES $s"
done
index=1
declare -A site_status
@ -333,6 +339,43 @@ restore_oa() {
ssh txjp "cd $REMOTE_BASE/oa-ai && docker compose down 2>/dev/null || true && docker compose up -d"
}
restore_monitor() {
log "恢复 monitor 站点..."
download_and_extract "monitor"
for f in docker-compose.yml .env; do
if [[ -f "$RESTORE_DIR/backup-monitor/$f" ]]; then
scp "$RESTORE_DIR/backup-monitor/$f" txjp:$REMOTE_BASE/monitor-ai/
fi
done
# 恢复数据库
if [[ -f "$RESTORE_DIR/backup-monitor/data/monitor.db" ]]; then
ssh txjp "docker cp $RESTORE_DIR/backup-monitor/data/monitor.db monitor-ai:/app/data/monitor.db"
fi
log "重启 monitor 容器..."
ssh txjp "cd $REMOTE_BASE/monitor-ai && docker compose down 2>/dev/null || true && docker compose up -d --build"
}
# 通用恢复(未知站点,仅恢复配置文件)
restore_generic() {
local site="$1"
log "恢复 ${site} 站点(通用模式)..."
download_and_extract "${site#*-}" # monitor-ai → monitor
for f in docker-compose.yml .env Dockerfile; do
if [[ -f "$RESTORE_DIR/backup-${site#*-}/$f" ]]; then
scp "$RESTORE_DIR/backup-${site#*-}/$f" txjp:$REMOTE_BASE/${site#*-}-ai/
fi
done
# 尝试重启容器
ssh txjp "cd $REMOTE_BASE/${site#*-}-ai && docker compose down 2>/dev/null || true && docker compose up -d" 2>/dev/null || warn "${site} 容器重启失败,请手动检查"
}
# ============================================================
# 6. 执行恢复
# ============================================================
@ -344,6 +387,8 @@ for site in "${restore_list[@]}"; do
ldap) restore_ldap ;;
gitea) restore_gitea ;;
oa) restore_oa ;;
monitor) restore_monitor ;;
*) warn "${site}: 无专用恢复逻辑,仅恢复配置"; restore_generic "$site" ;;
esac
done

287
check-compliance.sh Executable file
View File

@ -0,0 +1,287 @@
#!/usr/bin/env bash
# check-compliance.sh — 部署前合规检查(对照 CLAUDE.md + LESSONS-LEARNED.md
# 用法: bash scripts/check-compliance.sh [--strict]
# 退出码: 0=通过, 1=违规
set -o pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
PASS=0; WARN=0; FAIL=0
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
ok() { echo -e " ${GREEN}[✓]${NC} $1"; ((PASS++)); }
warn() { echo -e " ${YELLOW}[!]${NC} $1"; ((WARN++)); }
err() { echo -e " ${RED}[✗]${NC} $1"; ((FAIL++)); }
echo "========================================="
echo " 部署前合规检查"
echo "========================================="
echo ""
# ============================================================
# D1: healthcheck 禁止用 curl
# ============================================================
echo "--- D1: healthcheck 禁止 curl ---"
for f in "$PROJECT_ROOT"/*/docker-compose.yml; do
site=$(basename "$(dirname "$f")")
if grep -A2 'healthcheck' "$f" 2>/dev/null | grep -q 'curl'; then
err "$site: healthcheck 使用了 curl必须用 wget --spider 或 node fetch"
else
ok "$site"
fi
done
echo ""
# ============================================================
# D2: 含数据库的站点容器必须添加 healthcheck
# ============================================================
echo "--- D2: 含数据库容器 healthcheck ---"
DB_SITES="issue-ai assets-ai oa-ai monitor-ai gitea-ai"
for site in $DB_SITES; do
f="$PROJECT_ROOT/$site/docker-compose.yml"
[ ! -f "$f" ] && continue
if grep -q 'healthcheck' "$f"; then
ok "$site"
else
err "$site: 含数据库但缺少 healthcheck"
fi
done
echo ""
# ============================================================
# D3: 禁止 entrypoint 延迟构建
# ============================================================
echo "--- D3: 禁止 entrypoint 延迟构建 ---"
# 仅检查 entrypoint.shDockerfile 中 builder 阶段的 npm run build 是合法的多阶段构建)
for f in "$PROJECT_ROOT"/*/entrypoint.sh; do
[ ! -f "$f" ] && continue
site=$(echo "$f" | sed 's|.*/\([^/]*\)/.*|\1|')
if grep -q 'npm run build\|npm run dev\|next build\|next dev' "$f" 2>/dev/null; then
err "$site: entrypoint.sh 中包含构建命令"
fi
done
ok "所有 entrypoint.sh 不含构建命令"
echo ""
# ============================================================
# D4: 必须使用 docker compose V2禁止 docker-compose
# ============================================================
echo "--- D4: docker compose V2 ---"
if grep -rn 'docker-compose ' "$PROJECT_ROOT/scripts/" --include='*.sh' 2>/dev/null | grep -v '^[[:space:]]*#' | grep -v 'docker compose' | grep -q .; then
err "脚本中存在 docker-composeV1应使用 docker composeV2"
else
ok "所有脚本使用 docker compose V2"
fi
echo ""
# ============================================================
# 时区规范: 禁止 Date.toISOString() 格式化本地日期
# ============================================================
echo "--- 时区: 禁止 Date.toISOString() ---"
# 仅标记直接 toISOString()(无 UTC+8 偏移的用法),允许 +8h 偏移模式
VIOLATIONS=$(grep -rn 'new Date()\.toISOString()\|new Date\.toISOString()' "$PROJECT_ROOT"/*/src --include='*.ts' --include='*.tsx' 2>/dev/null | grep -v node_modules | grep -v 'shared/')
if [ -n "$VIOLATIONS" ]; then
echo "$VIOLATIONS" | while IFS= read -r line; do
err "$line"
done
else
ok "所有站点未使用裸 Date.toISOString()"
fi
echo ""
# ============================================================
# 时区规范: SQLite datetime('now') 必须带 +8 hours
# ============================================================
echo "--- 时区: SQLite datetime('now') → +8 hours ---"
VIOLATIONS=$(grep -rn "datetime('now')" "$PROJECT_ROOT"/*/src --include='*.ts' --include='*.tsx' 2>/dev/null | grep -v "+8 hours" | grep -v node_modules | grep -v shared)
if [ -n "$VIOLATIONS" ]; then
echo "$VIOLATIONS" | while IFS= read -r line; do
err "$line"
done
else
ok "所有 SQLite datetime('now') 均带 +8 hours"
fi
echo ""
# ============================================================
# JWT_SECRET + COOKIE_DOMAIN 在所有站点一致
# ============================================================
echo "--- JWT_SECRET + COOKIE_DOMAIN 一致性 ---"
JWT_VALUES=""
COOKIE_VALUES=""
for f in "$PROJECT_ROOT"/*/docker-compose.yml; do
site=$(basename "$(dirname "$f")")
jwt=$(grep -o "JWT_SECRET=\${JWT_SECRET:-[^}]*}\|JWT_SECRET=[^ ]*" "$f" 2>/dev/null | head -1)
cookie=$(grep -o "COOKIE_DOMAIN=[^ ]*" "$f" 2>/dev/null | head -1)
[ -n "$jwt" ] && JWT_VALUES="$JWT_VALUES\n$site: $jwt"
[ -n "$cookie" ] && COOKIE_VALUES="$COOKIE_VALUES\n$site: $cookie"
done
echo -e "$JWT_VALUES" | sed '/^$/d'
echo -e "$COOKIE_VALUES" | sed '/^$/d'
# Check consistency
jwt_count=$(echo -e "$JWT_VALUES" | grep -o 'JWT_SECRET=[^ ]*' | sort -u | wc -l)
cookie_count=$(echo -e "$COOKIE_VALUES" | grep -o 'COOKIE_DOMAIN=[^ ]*' | sort -u | wc -l)
[ "$jwt_count" -le 1 ] && ok "JWT_SECRET 一致" || err "JWT_SECRET 不一致($jwt_count 个不同值)"
[ "$cookie_count" -le 1 ] && ok "COOKIE_DOMAIN 一致" || err "COOKIE_DOMAIN 不一致($cookie_count 个不同值)"
echo ""
# ============================================================
# LESSONS-LEARNED #24: cookie domain 必须设置
# ============================================================
echo "--- LESSONS-LEARNED #24: cookie domain ---"
for f in "$PROJECT_ROOT"/*/docker-compose.yml; do
site=$(basename "$(dirname "$f")")
if grep -q 'COOKIE_DOMAIN' "$f"; then
ok "$site"
else
warn "$site: docker-compose.yml 未设置 COOKIE_DOMAIN"
fi
done
echo ""
# ============================================================
# LESSONS-LEARNED #30: globals.css 含 @source "../../shared"
# ============================================================
echo "--- LESSONS-LEARNED #30: globals.css @source ---"
for f in "$PROJECT_ROOT"/issue-ai/src/app/globals.css "$PROJECT_ROOT"/assets-ai/src/app/globals.css "$PROJECT_ROOT"/oa-ai/src/app/globals.css "$PROJECT_ROOT"/monitor-ai/src/app/globals.css; do
[ ! -f "$f" ] && continue
site=$(echo "$f" | sed 's|.*/\([^/]*\)/src/.*|\1|')
if grep -q '@source.*shared' "$f" 2>/dev/null; then
ok "$site"
else
warn "$site: globals.css 缺少 @source \"../../shared\""
fi
done
echo ""
# ============================================================
# LESSONS-LEARNED #35: outputFileTracingIncludes
# ============================================================
echo "--- LESSONS-LEARNED #35: outputFileTracingIncludes ---"
for f in "$PROJECT_ROOT"/issue-ai/next.config.ts "$PROJECT_ROOT"/assets-ai/next.config.ts "$PROJECT_ROOT"/oa-ai/next.config.ts "$PROJECT_ROOT"/monitor-ai/next.config.ts; do
[ ! -f "$f" ] && continue
site=$(echo "$f" | sed 's|.*/\([^/]*\)/next.*|\1|')
if grep -q 'outputFileTracingIncludes' "$f" 2>/dev/null; then
ok "$site"
else
warn "$site: next.config.ts 缺少 outputFileTracingIncludes"
fi
done
echo ""
# ============================================================
# LESSONS-LEARNED #40: sso cert 使用 Let's Encrypt
# ============================================================
echo "--- LESSONS-LEARNED #40: sso nginx cert ---"
SSO_CONF="$PROJECT_ROOT/nginx-proxy-ai/conf.d/sso-ai.conf"
if [ -f "$SSO_CONF" ]; then
if grep -q 'www.tlyq.ai-0001\|letsencrypt' "$SSO_CONF" 2>/dev/null; then
ok "sso-ai.conf 使用 Let's Encrypt 证书"
else
err "sso-ai.conf 未使用 Let's Encrypt 证书LESSONS-LEARNED #40"
fi
else
warn "sso-ai.conf 不存在"
fi
echo ""
# ============================================================
# LESSONS-LEARNED #37: 禁用 request.url
# ============================================================
echo "--- LESSONS-LEARNED #37: 禁用 request.url ---"
# 仅检查 V2 middleware旧 middleware.ts 保持向后兼容,迁移后删除)
VIOLATIONS=$(grep -rn 'request\.url' "$PROJECT_ROOT/shared/lib/auth/middleware-v2.ts" 2>/dev/null | grep -v '\.origin\|node_modules')
if [ -n "$VIOLATIONS" ]; then
echo "$VIOLATIONS" | while IFS= read -r line; do
err "$line"
done
else
ok "middleware-v2 未直接使用 request.url"
fi
echo ""
# ============================================================
# LESSONS-LEARNED #41: 容器内禁止 docker exec 获取凭据
# ============================================================
echo "--- LESSONS-LEARNED #41: docker exec in container ---"
VIOLATIONS=$(grep -rn "execFileSync.*docker.*exec\|execSync.*docker.*exec\|exec('docker'" "$PROJECT_ROOT"/*/src --include='*.ts' --include='*.tsx' 2>/dev/null | grep -v node_modules)
if [ -n "$VIOLATIONS" ]; then
echo "$VIOLATIONS" | while IFS= read -r line; do
warn "$line (容器内可能无 docker CLI见 LESSONS-LEARNED #41"
done
else
ok "无 docker exec 依赖"
fi
echo ""
# ============================================================
# OA: LLDAP_ADMIN_PASSWORD 环境变量注入
# ============================================================
echo "--- OA: LLDAP_ADMIN_PASSWORD ---"
if grep -q 'LLDAP_ADMIN_PASSWORD' "$PROJECT_ROOT/oa-ai/docker-compose.yml" 2>/dev/null; then
ok "oa-ai docker-compose.yml 含 LLDAP_ADMIN_PASSWORD"
else
err "oa-ai docker-compose.yml 缺少 LLDAP_ADMIN_PASSWORDLESSONS-LEARNED #41"
fi
echo ""
# ============================================================
# D8 + LESSONS-LEARNED #48: OIDC token_endpoint_auth_method 一致性
# ============================================================
echo "--- D8: OIDC token_endpoint_auth_method 一致性 ---"
SSO_CONFIG="$PROJECT_ROOT/sso-ai/config/authelia/configuration.yml"
if [ -f "$SSO_CONFIG" ]; then
# 列出每个 OIDC client 的 token_endpoint_auth_method
while IFS= read -r line; do
if echo "$line" | grep -q 'client_id:'; then
current_client=$(echo "$line" | sed "s/.*client_id: *'\(.*\)'/\1/" | sed 's/.*client_id: *"\(.*\)"/\1/' | sed 's/.*client_id: *//' | tr -d "'\"")
fi
if echo "$line" | grep -q 'token_endpoint_auth_method:'; then
method=$(echo "$line" | grep -o "client_secret_[a-z]*")
if [ "$method" = "client_secret_post" ]; then
ok "$current_client: $method"
elif [ "$method" = "client_secret_basic" ]; then
err "$current_client: $method(应为 client_secret_post见 LESSONS-LEARNED #48"
fi
current_client=""
fi
done < "$SSO_CONFIG"
# 检查是否有 OIDC client 完全没有 token_endpoint_auth_methodAuthelia 默认 client_secret_basic
# 注意:在 YAML 中 token_endpoint_auth_method 可能出现在任意位置,需读完整个 client 块
unset_client=""
prev_client=""
while IFS= read -r line; do
# 遇到新 client_id → 结算上一个
if echo "$line" | grep -q '^[[:space:]]*-[[:space:]]*client_id:'; then
if [ -n "$prev_client" ] && [ "$prev_has_method" -eq 0 ]; then
err "$prev_client: 未设置 token_endpoint_auth_method默认 client_secret_basic → 与代码 client_secret_post 不匹配)"
fi
prev_client=$(echo "$line" | sed "s/.*client_id: *'\(.*\)'/\1/" | sed 's/.*client_id: *"\(.*\)"/\1/' | sed 's/.*client_id: *//' | tr -d "'\"")
prev_has_method=0
fi
if echo "$line" | grep -q 'token_endpoint_auth_method:'; then
prev_has_method=1
fi
done < "$SSO_CONFIG"
# 结算最后一个 client
if [ -n "$prev_client" ] && [ "$prev_has_method" -eq 0 ]; then
err "$prev_client: 未设置 token_endpoint_auth_method默认 client_secret_basic → 与代码 client_secret_post 不匹配)"
fi
else
warn "sso-ai/config/authelia/configuration.yml 不存在"
fi
echo ""
# ============================================================
# 结果
# ============================================================
echo "========================================="
echo -e " 通过: ${GREEN}${PASS}${NC} 警告: ${YELLOW}${WARN}${NC} 违规: ${RED}${FAIL}${NC}"
echo "========================================="
if [ "$FAIL" -gt 0 ]; then
echo -e "${RED}存在 ${FAIL} 项违规,部署前必须修复${NC}"
exit 1
elif [ "$WARN" -gt 0 ]; then
echo -e "${YELLOW}存在 ${WARN} 项警告,建议修复后部署${NC}"
fi
exit 0

291
test-wechat-messages.mjs Normal file
View File

@ -0,0 +1,291 @@
#!/usr/bin/env node
/**
* 企业微信消息推送模拟测试
* 模拟 29.237.253.1 服务器 OEM 诊断和 OEM 维修故障完整生命周期
* 每条消息间隔 5
*/
const WEBHOOK_URL = process.env.WECOM_WEBHOOK_URL || (() => { throw new Error('WECOM_WEBHOOK_URL not set') })()
const SERVER_IP = '29.237.253.1'
const SERVER_SN = '210235A4M7H241000098'
const RACK = 'D01-404-I02-3U'
const INTERVAL = 5000
// 随机故障内容
const OEM_DIAG_FAULTS = [
'agent已经5分钟没有上报数据',
'GPU温度传感器读数异常超过阈值',
'系统日志显示内核恐慌(Kernel Panic)',
'NVMe硬盘SMART检测到坏块',
'内存ECC错误计数超标',
]
const OEM_REPAIR_FAULTS = [
'GPU缓存发生不可纠正错误',
'RAID阵列降级一块硬盘离线',
'电源模块冗余失效,单电源运行',
'主板BMC固件异常无法远程管理',
'网卡绑定故障,网络中断',
]
const randomFrom = (arr) => arr[Math.floor(Math.random() * arr.length)]
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms))
// 北京时间格式化
function bjTime(date = new Date()) {
const d = new Date(date.getTime() + 8 * 3600_000)
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')} ${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}:${String(d.getUTCSeconds()).padStart(2, '0')}`
}
// 向前推算时间
function minusHours(date, hours) {
return new Date(date.getTime() - hours * 3600_000)
}
function minusMinutes(date, minutes) {
return new Date(date.getTime() - minutes * 60_000)
}
async function sendText(text) {
const res = await fetch(WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ msgtype: 'text', text: { content: text } }),
})
const data = await res.json()
return data.errcode === 0
}
async function send(label, text) {
console.log(`\n📤 [${label}]`)
console.log(text)
console.log('─'.repeat(50))
const ok = await sendText(text)
console.log(ok ? '✅ 发送成功' : '❌ 发送失败')
await sleep(INTERVAL)
}
// ============================================================
// OEM 诊断完整生命周期
// ============================================================
async function runOemDiag() {
const now = new Date()
const faultTime = minusHours(now, 2)
const orderNum = 'S' + Date.now().toString().slice(-10)
const fault = randomFrom(OEM_DIAG_FAULTS)
const oemDeadline = minusHours(faultTime, -6) // 故障后6小时到期
console.log('\n' + '═'.repeat(60))
console.log(' OEM 诊断 — 完整生命周期模拟')
console.log('═'.repeat(60))
console.log(`工单号: ${orderNum}`)
console.log(`故障时间: ${bjTime(faultTime)}`)
console.log(`到期时间: ${bjTime(oemDeadline)}`)
// 1. 初始故障通知
await send('初始通知', [
`【服务器故障单】 - OEM诊断`,
'',
`工单号:${orderNum}`,
`服务器IP${SERVER_IP}`,
`服务器SN${SERVER_SN}`,
`机架位置:${RACK}`,
`故障时间:${bjTime(faultTime)}`,
`到期时间:${bjTime(oemDeadline)}`,
`故障信息:${fault}`,
].join('\n'))
// 2. 每小时提醒剩余4小时
const t4h = minusMinutes(oemDeadline, 240)
await send('每小时提醒剩余4小时0分', [
`⏰ OEM诊断工单提醒剩余4小时0分`,
'',
`工单号:${orderNum}`,
`服务器IP${SERVER_IP}`,
`服务器SN${SERVER_SN}`,
`机架位置:${RACK}`,
`到期时间:${bjTime(oemDeadline)}`,
'',
`服务器尚未录入恢复时间,请及时处理!`,
].join('\n'))
// 3. 30分钟到期提醒
await send('30分钟到期提醒', [
`⚠️ OEM诊断工单即将到期剩余30分钟`,
'',
`工单号:${orderNum}`,
`服务器IP${SERVER_IP}`,
`服务器SN${SERVER_SN}`,
`机架位置:${RACK}`,
`到期时间:${bjTime(oemDeadline)}`,
'',
`服务器尚未录入恢复时间,请尽快处理!`,
].join('\n'))
// 4. 超时提醒
await send('超时提醒', [
`⚠️ OEM诊断工单已超时`,
'',
`工单号:${orderNum}`,
`服务器IP${SERVER_IP}`,
`服务器SN${SERVER_SN}`,
`机架位置:${RACK}`,
`到期时间:${bjTime(oemDeadline)}`,
'',
`服务器尚未录入恢复时间,请尽快处理!`,
].join('\n'))
// 5. 结单消息
const closeTime = minusMinutes(now, -30)
const durationMin = Math.floor((closeTime.getTime() - faultTime.getTime()) / 60000)
const durH = Math.floor(durationMin / 60)
const durM = durationMin % 60
await send('结单消息', [
`${orderNum}工单已结单`,
'',
`服务器IP${SERVER_IP}`,
`服务器SN${SERVER_SN}`,
`机架位置:${RACK}`,
`故障类型OEM诊断`,
`故障时间:${bjTime(faultTime)}`,
`结单时间:${bjTime(closeTime)}`,
`本次处理时长:${durH}小时${durM}分钟`,
].join('\n'))
}
// ============================================================
// OEM 维修完整生命周期
// ============================================================
async function runOemRepair() {
const now = new Date()
const faultTime = minusHours(now, 4)
const orderNum = 'S' + Date.now().toString().slice(-10)
const fault = randomFrom(OEM_REPAIR_FAULTS)
// 五档截止时间
const tiers = [
{ name: '第一档', desc: '99%(含)以上(不扣费)', hours: 8, penalty: '10%' },
{ name: '第二档', desc: '99%-97%扣10%', hours: 24, penalty: '25%' },
{ name: '第三档', desc: '97%-95%扣25%', hours: 48, penalty: '50%' },
{ name: '第四档', desc: '95%-90%扣50%', hours: 72, penalty: '100%' },
{ name: '第五档', desc: '90%不含以下扣100%', hours: 168, penalty: '' },
]
const deadlines = tiers.map(t => ({
...t,
deadline: new Date(faultTime.getTime() + t.hours * 3600_000),
}))
console.log('\n' + '═'.repeat(60))
console.log(' OEM 维修 — 完整生命周期模拟')
console.log('═'.repeat(60))
console.log(`工单号: ${orderNum}`)
console.log(`故障时间: ${bjTime(faultTime)}`)
deadlines.forEach(d => console.log(` ${d.name}截止: ${bjTime(d.deadline)} (${d.hours}h)`))
// 1. 初始故障通知含5档截止时间
const tierLines = deadlines.map((d, i) => {
const emojis = ['1⃣', '2⃣', '3⃣', '4⃣', '5⃣']
return `${emojis[i]} ${d.desc}\n 截止时间:${bjTime(d.deadline)}${d.hours}小时)`
}).join('\n\n')
await send('初始通知', [
`【服务器故障单】 - OEM维修`,
'',
`工单号:${orderNum}`,
`服务器IP${SERVER_IP}`,
`服务器SN${SERVER_SN}`,
`机架位置:${RACK}`,
`故障时间:${bjTime(faultTime)}`,
`故障信息:${fault}`,
'',
`📊 服务可用性各档位截止时间(基于月度内累计故障时长计算):`,
'',
tierLines,
].join('\n'))
// 2-5. 每个档位的提前1小时提醒
for (let i = 0; i < 4; i++) {
const t = deadlines[i]
const isFinalTier = t.name === '第四档'
const tierLabel = isFinalTier ? '全额扣费100%' : `${t.name}${t.penalty}`
await send(`${t.name}提前1小时提醒`, [
`OEM维修工单超时提醒距离${tierLabel}还剩1小时`,
'',
`工单号:${orderNum}`,
`服务器IP${SERVER_IP}`,
`服务器SN${SERVER_SN}`,
`机架位置:${RACK}`,
`${t.name}到期时间:${bjTime(t.deadline)}`,
'',
`服务器尚未录入恢复时间,请及时处理!`,
].join('\n'))
}
// 6-9. 每个档位的超时提醒
for (let i = 0; i < 4; i++) {
const t = deadlines[i]
const isFinalTier = t.name === '第四档'
const title = isFinalTier
? `OEM维修工单超时提醒${t.name}已超时已触发全额扣费100%`
: `OEM维修工单超时提醒${t.name}已超时,达到${t.name}${t.penalty}`
await send(`${t.name}超时提醒`, [
title,
'',
`工单号:${orderNum}`,
`服务器IP${SERVER_IP}`,
`服务器SN${SERVER_SN}`,
`机架位置:${RACK}`,
`${t.name}到期时间:${bjTime(t.deadline)}`,
'',
`服务器尚未录入恢复时间,请尽快处理!`,
].join('\n'))
}
// 10. 结单消息(含本月统计)
const closeTime = minusMinutes(now, -30)
const durationMin = Math.floor((closeTime.getTime() - faultTime.getTime()) / 60000)
const durH = Math.floor(durationMin / 60)
const durM = durationMin % 60
await send('结单消息', [
`${orderNum}工单已结单`,
'',
`服务器IP${SERVER_IP}`,
`服务器SN${SERVER_SN}`,
`机架位置:${RACK}`,
`故障类型OEM维修`,
`故障时间:${bjTime(faultTime)}`,
`结单时间:${bjTime(closeTime)}`,
`本次处理时长:${durH}小时${durM}分钟`,
`本月故障次数3次`,
`本月总处理时长18小时30分钟`,
].join('\n'))
}
// ============================================================
// 主流程
// ============================================================
async function main() {
console.log('🚀 开始模拟企业微信消息推送')
console.log(`Webhook: ${WEBHOOK_URL.slice(0, 50)}...`)
console.log(`服务器: ${SERVER_IP} (${SERVER_SN})`)
console.log(`机架: ${RACK}`)
console.log(`间隔: ${INTERVAL / 1000}s`)
console.log('\n' + '═'.repeat(60))
console.log(' 第一部分OEM 诊断5 条消息)')
console.log('═'.repeat(60))
await runOemDiag()
console.log('\n⏸ 等待 10 秒...\n')
await sleep(10000)
console.log('═'.repeat(60))
console.log(' 第二部分OEM 维修10 条消息)')
console.log('═'.repeat(60))
await runOemRepair()
console.log('\n' + '═'.repeat(60))
console.log('✅ 全部消息发送完成(共 15 条)')
console.log('═'.repeat(60))
}
main().catch(e => { console.error(e); process.exit(1) })