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:
parent
3dd394d943
commit
9c9e5d7c44
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 代码仓库 + 配置
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
# 打包
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 "无效选择" ;;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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) })
|
||||
Loading…
Reference in New Issue