diff --git a/backup/backup-db.sh b/backup/backup-db.sh index 1b1a343..b182f12 100755 --- a/backup/backup-db.sh +++ b/backup/backup-db.sh @@ -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路径 映射(自动推导::/app/data/.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 diff --git a/backup/backup-full.sh b/backup/backup-full.sh index 1636efa..63973bd 100755 --- a/backup/backup-full.sh +++ b/backup/backup-full.sh @@ -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 代码仓库 + 配置 diff --git a/backup/backup-sites.sh b/backup/backup-sites.sh index c718abb..24be88b 100755 --- a/backup/backup-sites.sh +++ b/backup/backup-sites.sh @@ -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 # 打包 diff --git a/backup/restore-db.sh b/backup/restore-db.sh index a1d06bc..ddbc1f0 100755 --- a/backup/restore-db.sh +++ b/backup/restore-db.sh @@ -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 diff --git a/backup/restore-full.sh b/backup/restore-full.sh index 20dabfa..4ba01da 100755 --- a/backup/restore-full.sh +++ b/backup/restore-full.sh @@ -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 "无效选择" ;; diff --git a/backup/restore-sites.sh b/backup/restore-sites.sh index 530a2d9..86a34e2 100755 --- a/backup/restore-sites.sh +++ b/backup/restore-sites.sh @@ -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 diff --git a/check-compliance.sh b/check-compliance.sh new file mode 100755 index 0000000..8d74e9a --- /dev/null +++ b/check-compliance.sh @@ -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.sh(Dockerfile 中 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-compose(V1),应使用 docker compose(V2)" +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_PASSWORD(LESSONS-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_method(Authelia 默认 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 diff --git a/test-wechat-messages.mjs b/test-wechat-messages.mjs new file mode 100644 index 0000000..f67d863 --- /dev/null +++ b/test-wechat-messages.mjs @@ -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) })