Compare commits

..

No commits in common. "main" and "v2026.07.02" have entirely different histories.

18 changed files with 1796 additions and 2109 deletions

View File

@ -13,15 +13,17 @@
| `deploy-ai.sh` | 部署站点到 tlyq.aitxjp 服务器) | 云端 |
| `deploy-cc.sh` | 部署站点到 tlyq.cctgz 服务器) | 云端 |
| `edit-site-cc.sh` | www.tlyq.cc 官网内容管理 | 云端 |
| `check-compliance.sh` | D1-D8 合规检查LESSONS-LEARNED 自动化) | 本地 |
| `docker-cache-cleanup.sh` | Docker 缓存清理(构建缓存、悬空镜像、测试镜像) | 本地/云端 |
### 备份与恢复(`backup/` 目录)
| 脚本 | 用途 | 说明 |
|------|------|------|
| `backup/backup.sh` | 统一备份 | --db-only / --full |
| `backup/restore.sh` | 统一恢复 | --db / --full |
| `backup/backup-sites.sh` | 站点独立备份 | **推荐**:按站点独立打包 |
| `backup/backup-db.sh` | 仅数据库备份 | 轻量快速 |
| `backup/backup-full.sh` | 完整灾难恢复备份 | 包含所有数据 |
| `backup/restore-sites.sh` | 站点恢复 | 交互式选择恢复 |
| `backup/restore-db.sh` | 数据库恢复 | 恢复单个数据库 |
| `backup/restore-full.sh` | 完整恢复 | 灾难恢复使用 |
> 详细文档见 [backup/README.md](backup/README.md)
@ -46,7 +48,6 @@ bash scripts/sites-manage-local.sh status # 查看状态
| oa | `npm run dev`(宿主机) | 6179 |
| issue | `npm run dev`(宿主机) | 6176 |
| assets | `npm run dev`(宿主机) | 6177 |
| monitor | `npm run dev`(宿主机) | 6181 |
| www / cloud / token | Docker | 61736175 |
## sites-manage-cloud.sh — 云服务器站点管理
@ -75,7 +76,7 @@ bash scripts/deploy-ai.sh --force # 强制完整构建
bash scripts/deploy-ai.sh --restart # 仅重启容器
```
交互式选择站点110www / cloud / token / issue / assets / oa / ldap / gitea / sso / monitor
交互式选择站点17www / cloud / token / issue / assets / oa / ldap。
流程:源码打包 → 上传 → 服务器 `npm install``npm run build` → 容器重建。部署后自动验证。
@ -84,57 +85,24 @@ LDAP 部署特殊处理:上传 Dockerfile + docker-compose.yml自动创建
## backup/ — 备份与恢复
```bash
# 站点完整备份(推荐)
bash scripts/backup/backup.sh # 备份所有
bash scripts/backup/backup.sh assets monitor # 指定站点
bash scripts/backup/backup.sh --db-only # 仅数据库
bash scripts/backup/backup.sh --full # 灾难备份
bash scripts/backup/backup.sh --local # 本地模式(在服务器上直接运行)
# 站点独立备份(推荐)
bash scripts/backup/backup-sites.sh # 备份所有站点
bash scripts/backup/backup-sites.sh assets issue # 备份指定站点
# 恢复
bash scripts/backup/restore.sh # 交互式
bash scripts/backup/restore.sh --db issue file.db # 数据库恢复
bash scripts/backup/restore.sh --full backup.tar # 灾难恢复
bash scripts/backup/restore.sh --local --full ... # 本地灾难恢复(新服务器)
bash scripts/backup/restore-sites.sh # 交互式恢复
bash scripts/backup/restore-sites.sh 20260622_101858 # 指定时间戳
# 数据库单独备份
bash scripts/backup/backup-db.sh
```
支持站点:`assets` / `issue` / `ldap` / `gitea` / `oa`
备份内容:数据库 + 上传文件 + 配置文件 + nginx + SSL 证书
> 详细文档见 [backup/README.md](backup/README.md)
## docker-cache-cleanup.sh — Docker 缓存清理
自动检测运行环境:通过 `curl ip.sb` 获取当前公网 IP与生产环境服务器 IP 列表匹配。
- 在生产服务器上运行:直接执行 Docker 命令
- 在本地运行:通过 SSH 连接服务器执行
```bash
bash scripts/docker-cache-cleanup.sh # 默认:保留 3 小时内的缓存
bash scripts/docker-cache-cleanup.sh --aggressive # 激进:保留 1 小时内
bash scripts/docker-cache-cleanup.sh --conservative # 保守:保留 6 小时内
bash scripts/docker-cache-cleanup.sh --full # 完全:删除所有缓存
```
清理内容:
- **dangling 镜像**:无标签或未被引用的中间层
- **测试镜像**:以 `test-` 开头的镜像
- **构建缓存**:根据模式保留不同时长
- **磁盘紧张处理**:使用率 > 85% 时自动深度清理
配置:在脚本开头的 `PRODUCTION_SERVER_IPS` 数组中添加/删除服务器 IP。
```bash
PRODUCTION_SERVER_IPS=(
"43.133.38.210" # txjp (腾讯云北京)
# "159.75.83.209" # tgz (腾讯云广州)
)
```
定时清理(可选):
```bash
# 每天凌晨 3 点执行保守清理
ssh txjp "crontab -l 2>/dev/null; echo '0 3 * * * /root/docker/scripts/docker-cache-cleanup.sh --conservative' | crontab -"
```
## 备份存储路径
| 位置 | 路径 |

View File

@ -1,89 +1,121 @@
# 备份与恢复
# 备份与恢复脚本
统一备份恢复工具,支持远程(从本地 Mac 操作服务器)和本地(直接在服务器上执行)两种模式
云服务器站点数据备份和灾难恢复工具集
## 快速上手
## 目录结构
```bash
# 备份
bash scripts/backup/backup.sh # 站点完整备份(推荐)
bash scripts/backup/backup.sh --db-only # 仅数据库
bash scripts/backup/backup.sh --full # 灾难备份
bash scripts/backup/backup.sh assets monitor # 仅指定站点
# 恢复
bash scripts/backup/restore.sh # 交互式恢复
bash scripts/backup/restore.sh 20260708_120000 # 指定时间戳
bash scripts/backup/restore.sh --db monitor file.db # 数据库恢复
bash scripts/backup/restore.sh --full backup.tar.gz # 灾难恢复
```
scripts/backup/
├── backup-db.sh # 数据库备份(轻量)
├── backup-full.sh # 完整灾难恢复备份
├── backup-sites.sh # 站点独立备份(推荐)
├── restore-db.sh # 数据库恢复
├── restore-full.sh # 完整恢复
└── restore-sites.sh # 站点恢复
```
## 远程 vs 本地模式
## 备份策略对比
| | 远程模式(默认) | 本地模式 `--local` |
|------|------|------|
| 执行位置 | 本地 Mac | 服务器上 |
| Docker 操作 | `ssh txjp "docker ..."` | 直接 `docker ...` |
| 文件传输 | `scp` | 本地 `cp` |
| 适用场景 | 日常运维 | 灾难恢复 / 新服务器 |
| 脚本 | 内容 | 大小 | 用途 |
|------|------|------|------|
| backup-db.sh | 仅数据库 | ~3M | 日常快速备份 |
| backup-sites.sh | 数据库+配置+文件 | ~135M | **推荐**:按站点独立备份 |
| backup-full.sh | 全部数据 | ~135M | 完整灾难恢复 |
## 使用方法
### 站点独立备份(推荐)
```bash
# 远程(默认)
bash scripts/backup/backup.sh
# 备份所有站点
bash scripts/backup/backup-sites.sh
# 本地
bash scripts/backup/backup.sh --local
bash scripts/backup/restore.sh --local --full full-backup.tar.gz
# 仅备份指定站点
bash scripts/backup/backup-sites.sh assets issue
# 可用站点assets, issue, ldap, gitea, oa
```
## 新服务器灾难恢复流程
### 恢复站点
```bash
# 1. 在新服务器上安装 Docker + Compose V2
# 交互式恢复(自动检测备份,选择站点)
bash scripts/backup/restore-sites.sh
# 2. 上传脚本和备份文件
scp scripts/backup/*.sh user@new-server:/root/docker/scripts/backup/
scp full-backup-20260708.tar.gz user@new-server:/root/docker/
# 指定时间戳恢复
bash scripts/backup/restore-sites.sh 20260622_101858
```
# 3. 在新服务器上执行本地恢复
ssh user@new-server
cd /root/docker && bash scripts/backup/restore.sh --local --full full-backup-20260708.tar.gz
### 数据库单独备份
```bash
# 仅备份数据库(快速)
bash scripts/backup/backup-db.sh
```
## 备份内容
| 站点 | 备份项 |
|------|------|
| assets | assets.db + uploads/ + docker-compose.yml + .env + Dockerfile |
| issue | issue.db + uploads/ + reports/ + docker-compose.yml + .env + Dockerfile |
| ldap | users.db + docker-compose.yml + .env + Dockerfile |
| gitea | gitea.db + git仓库/ + conf/ + docker-compose.yml |
| monitor | monitor.db + docker-compose.yml + .env |
| oa | docker-compose.yml + .env无独立数据库 |
| shared | nginx配置 + SSL证书 + Docker网络配置 |
### 各站点备份内容
## 备份存储
| 站点 | 包含内容 |
|------|---------|
| assets | 数据库 + 上传文件 + docker-compose + .env + Dockerfile |
| issue | 数据库 + 上传文件 + 报告 + docker-compose + .env + Dockerfile |
| ldap | 数据库 + docker-compose + .env + Dockerfile |
| gitea | 数据库 + 代码仓库 + 配置 + docker-compose |
| oa | docker-compose + .env无独立数据库 |
### 共享资源备份
| 资源 | 内容 |
|------|------|
| nginx | 9 个反向代理配置文件 |
| ssl | Let's Encrypt 证书 |
| docker-network | webnet 网络配置 |
## 备份存储位置
| 位置 | 路径 |
|------|------|
| 本地 | `/Users/niuniu/programs/docker/db-backups/` |
| 云端 | `/root/docker/db-backups/` |
## 自动备份
## 恢复流程
恢复脚本会自动处理:
1. Docker 网络创建webnet
2. SSL 证书恢复
3. Nginx 配置恢复并重载
4. 各站点容器启动
5. 数据库恢复
## 灾难恢复步骤
当云服务器完全不可用时,在新服务器上:
```bash
# 每天凌晨 3 点
0 3 * * * cd /Users/niuniu/programs/docker && bash scripts/backup/backup.sh
# 1. 安装 Docker 和 docker-compose
# 2. 下载备份文件到本地
scp user@backup-server:/path/to/backup/*.tar.gz ./
# 3. 执行恢复
bash scripts/backup/restore-sites.sh
```
## 新增站点
## 自动备份
无需修改脚本——自动发现 `*-ai/docker-compose.yml` 中含 DB 的站点。新站点只需在 `backup.sh` 的 case 语句中添加专用备份逻辑(或走 wildcard 通用备份)。
建议配置 crontab 定期执行:
```bash
# 每天凌晨 3 点执行站点备份
0 3 * * * cd /Users/niuniu/programs/docker && bash scripts/backup/backup-sites.sh >> /var/log/backup.log 2>&1
```
## 注意事项
- 备份前自动执行 WAL checkpoint确保数据库完整性
- 恢复会重启相关容器,**短暂影响服务**
- Gitea 代码仓库备份较大(~130M可单独调整备份频率
- `--full` 灾难备份不含 uploads 等动态文件,仅保存数据库 + 代码仓库 + 配置
- 备份前会自动执行 WAL checkpoint确保数据库完整性
- 恢复会重启相关容器,短暂影响服务
- Gitea 代码仓库备份较大(~130M可考虑单独备份频率
- 建议定期验证备份文件可恢复性

66
backup/backup-db.sh Executable file
View File

@ -0,0 +1,66 @@
#!/bin/bash
# 云端数据库一键备份脚本
# 用法bash backup-db.sh
set -e
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
LOCAL_DIR="/Users/niuniu/programs/docker/db-backups"
log() { echo "[$(date '+%H:%M:%S')] $1"; }
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"
# 云端打包(使用 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"
# 确保本地备份目录存在
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"
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}/"
# 验证备份文件为有效 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
if ! sqlite3 "$f" "SELECT count(*) FROM sqlite_master" > /dev/null 2>&1; then
echo "[错误] 备份文件无效或损坏: $f"
exit 1
fi
done
log "备份文件验证通过"
# 清理云端临时文件
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"
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

124
backup/backup-full.sh Executable file
View File

@ -0,0 +1,124 @@
#!/bin/bash
# 完整灾难恢复备份脚本
# 包含数据库、代码仓库、用户数据、配置文件、SSL证书
# 用法bash backup-full.sh
set -e
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
LOCAL_DIR="/Users/niuniu/programs/docker/db-backups"
BACKUP_NAME="full-backup-${TIMESTAMP}"
log() { echo "[$(date '+%H:%M:%S')] $1"; }
warn() { echo "[$(date '+%H:%M:%S')] [警告] $1"; }
log "开始完整灾难恢复备份..."
# ============================================================
# 1. 数据库备份WAL checkpoint + docker cp
# ============================================================
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 失败"
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"
# ============================================================
# 2. Gitea 代码仓库 + 配置
# ============================================================
log "备份 Gitea 代码仓库..."
ssh txjp "mkdir -p /tmp/full-backup/gitea && \
cp -a /root/docker/gitea-ai/data/git /tmp/full-backup/gitea/ && \
cp -a /root/docker/gitea-ai/data/gitea/conf /tmp/full-backup/gitea/ 2>/dev/null || true"
# ============================================================
# 3. 用户上传文件
# ============================================================
log "备份用户上传文件..."
ssh txjp "mkdir -p /tmp/full-backup/uploads && \
cp -a /root/docker/assets-ai/data/uploads /tmp/full-backup/uploads/assets-uploads 2>/dev/null || true && \
cp -a /root/docker/issue-ai/data/uploads /tmp/full-backup/uploads/issue-uploads 2>/dev/null || true && \
cp -a /root/docker/issue-ai/data/reports /tmp/full-backup/uploads/issue-reports 2>/dev/null || true"
# ============================================================
# 4. Nginx 配置
# ============================================================
log "备份 Nginx 配置..."
ssh txjp "mkdir -p /tmp/full-backup/nginx && \
cp -a /root/docker/nginx-proxy-ai/conf.d/* /tmp/full-backup/nginx/ 2>/dev/null || true"
# ============================================================
# 5. 各站点 docker-compose.yml + .env
# ============================================================
log "备份站点配置文件..."
ssh txjp "mkdir -p /tmp/full-backup/configs && \
for site in assets-ai issue-ai oa-ai ldap-ai gitea-ai; do \
mkdir -p /tmp/full-backup/configs/\$site && \
cp /root/docker/\$site/docker-compose.yml /tmp/full-backup/configs/\$site/ 2>/dev/null || true && \
cp /root/docker/\$site/.env /tmp/full-backup/configs/\$site/ 2>/dev/null || true && \
cp /root/docker/\$site/Dockerfile /tmp/full-backup/configs/\$site/ 2>/dev/null || true; \
done"
# ============================================================
# 6. SSL 证书
# ============================================================
log "备份 SSL 证书..."
ssh txjp "mkdir -p /tmp/full-backup/ssl && \
cp -a /etc/letsencrypt/live /tmp/full-backup/ssl/ 2>/dev/null || true && \
cp -a /etc/letsencrypt/archive /tmp/full-backup/ssl/ 2>/dev/null || true && \
cp -a /etc/letsencrypt/renewal /tmp/full-backup/ssl/ 2>/dev/null || true"
# ============================================================
# 7. 打包压缩
# ============================================================
log "打包压缩..."
ssh txjp "cd /tmp && tar czf ${BACKUP_NAME}.tar.gz full-backup/ && rm -rf /tmp/full-backup"
# ============================================================
# 8. 复制到本地
# ============================================================
log "复制到本地..."
mkdir -p "${LOCAL_DIR}"
scp "txjp:/tmp/${BACKUP_NAME}.tar.gz" "${LOCAL_DIR}/"
# ============================================================
# 9. 云端保留一份
# ============================================================
CLOUD_DIR="/root/docker/db-backups"
ssh txjp "mkdir -p ${CLOUD_DIR} && cp /tmp/${BACKUP_NAME}.tar.gz ${CLOUD_DIR}/"
log "云端备份已保存至: ${CLOUD_DIR}"
# ============================================================
# 10. 清理云端临时文件
# ============================================================
ssh txjp "rm -f /tmp/${BACKUP_NAME}.tar.gz"
# ============================================================
# 11. 验证备份
# ============================================================
log "验证备份完整性..."
cd "${LOCAL_DIR}"
if tar tzf "${BACKUP_NAME}.tar.gz" > /dev/null 2>&1; then
log "备份文件验证通过"
else
echo "[错误] 备份文件损坏"
exit 1
fi
# 显示备份内容
log "备份内容清单:"
tar tzf "${BACKUP_NAME}.tar.gz" | head -30
echo ""
log "备份完成:${TIMESTAMP}"
echo " 文件: ${LOCAL_DIR}/${BACKUP_NAME}.tar.gz"
echo " 大小: $(du -h ${LOCAL_DIR}/${BACKUP_NAME}.tar.gz | cut -f1)"
echo ""
echo " 云端: ${CLOUD_DIR}/${BACKUP_NAME}.tar.gz"
ssh txjp "ls -lh ${CLOUD_DIR}/${BACKUP_NAME}.tar.gz 2>/dev/null"

188
backup/backup-sites.sh Executable file
View File

@ -0,0 +1,188 @@
#!/bin/bash
# 站点独立备份脚本
# 每个站点单独一个备份文件共享资源nginx/ssl/network单独备份
# 用法bash backup-sites.sh [站点名]
# 无参数:备份所有站点
# 指定站点:仅备份指定站点
set -e
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
LOCAL_DIR="/Users/niuniu/programs/docker/db-backups"
REMOTE_BASE="/root/docker"
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"
# ============================================================
# 备份单个站点
# ============================================================
backup_site() {
local site="$1"
local backup_name="${site}-${TIMESTAMP}.tar.gz"
log "备份 ${site}..."
# 创建临时目录
ssh txjp "rm -rf /tmp/backup-${site} && mkdir -p /tmp/backup-${site}/data"
case "$site" in
assets)
# 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 || true
# 数据库
ssh txjp "docker cp assets-ai:/app/data/assets.db /tmp/backup-${site}/data/"
# 上传文件
ssh txjp "cp -a /root/docker/assets-ai/data/uploads /tmp/backup-${site}/data/ 2>/dev/null || true"
# 配置文件
ssh txjp "cp /root/docker/assets-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true"
ssh txjp "cp /root/docker/assets-ai/.env /tmp/backup-${site}/ 2>/dev/null || true"
ssh txjp "cp /root/docker/assets-ai/Dockerfile /tmp/backup-${site}/ 2>/dev/null || true"
;;
issue)
# WAL 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 || true
# 数据库
ssh txjp "docker cp issue-ai:/app/data/issue.db /tmp/backup-${site}/data/"
# 上传文件和报告
ssh txjp "cp -a /root/docker/issue-ai/data/uploads /tmp/backup-${site}/data/ 2>/dev/null || true"
ssh txjp "cp -a /root/docker/issue-ai/data/reports /tmp/backup-${site}/data/ 2>/dev/null || true"
# 配置文件
ssh txjp "cp /root/docker/issue-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true"
ssh txjp "cp /root/docker/issue-ai/.env /tmp/backup-${site}/ 2>/dev/null || true"
ssh txjp "cp /root/docker/issue-ai/Dockerfile /tmp/backup-${site}/ 2>/dev/null || true"
;;
ldap)
# WAL checkpoint
ssh txjp "docker exec lldap sqlite3 /data/users.db 'PRAGMA wal_checkpoint(TRUNCATE);'" 2>/dev/null || true
# 数据库
ssh txjp "docker cp lldap:/data/users.db /tmp/backup-${site}/data/"
# 配置文件
ssh txjp "cp /root/docker/ldap-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true"
ssh txjp "cp /root/docker/ldap-ai/.env /tmp/backup-${site}/ 2>/dev/null || true"
ssh txjp "cp /root/docker/ldap-ai/Dockerfile /tmp/backup-${site}/ 2>/dev/null || true"
;;
gitea)
# 数据库gitea 不允许以 root 运行 dump直接复制数据库文件
ssh txjp "docker cp gitea-ai:/data/gitea.db /tmp/backup-${site}/data/"
# 代码仓库
ssh txjp "cp -a /root/docker/gitea-ai/data/git /tmp/backup-${site}/ 2>/dev/null || true"
# 配置
ssh txjp "cp -a /root/docker/gitea-ai/data/gitea/conf /tmp/backup-${site}/gitea-conf 2>/dev/null || true"
# docker-compose
ssh txjp "cp /root/docker/gitea-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true"
;;
oa)
# 配置文件
ssh txjp "cp /root/docker/oa-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true"
ssh txjp "cp /root/docker/oa-ai/.env /tmp/backup-${site}/ 2>/dev/null || true"
# OA 没有独立数据库,数据存在 assets/issue 中
;;
esac
# 打包
ssh txjp "cd /tmp && tar czf ${backup_name} backup-${site}/"
# 复制到本地
mkdir -p "${LOCAL_DIR}"
scp "txjp:/tmp/${backup_name}" "${LOCAL_DIR}/"
# 云端保留
ssh txjp "mkdir -p ${CLOUD_DIR} && cp /tmp/${backup_name} ${CLOUD_DIR}/"
# 清理临时文件
ssh txjp "rm -rf /tmp/backup-${site} /tmp/${backup_name}"
log "${site} 备份完成: ${backup_name}"
}
# ============================================================
# 备份共享资源nginx + ssl + docker网络
# ============================================================
backup_shared() {
local backup_name="shared-${TIMESTAMP}.tar.gz"
log "备份共享资源..."
ssh txjp "rm -rf /tmp/backup-shared && mkdir -p /tmp/backup-shared"
# Nginx 配置
ssh txjp "mkdir -p /tmp/backup-shared/nginx && cp -a /root/docker/nginx-proxy-ai/conf.d/* /tmp/backup-shared/nginx/ 2>/dev/null || true"
# SSL 证书
ssh txjp "mkdir -p /tmp/backup-shared/ssl && \
cp -a /etc/letsencrypt/live /tmp/backup-shared/ssl/ 2>/dev/null || true && \
cp -a /etc/letsencrypt/archive /tmp/backup-shared/ssl/ 2>/dev/null || true && \
cp -a /etc/letsencrypt/renewal /tmp/backup-shared/ssl/ 2>/dev/null || true"
# Docker 网络配置
ssh txjp "cat > /tmp/backup-shared/docker-network.sh << 'NETEOF'
#!/bin/bash
# 恢复 docker 网络
if ! docker network inspect webnet >/dev/null 2>&1; then
docker network create --driver bridge --subnet 172.18.0.0/16 webnet
echo 'Docker 网络 webnet 已创建'
else
echo 'Docker 网络 webnet 已存在'
fi
NETEOF"
# Nginx docker-compose用于恢复 nginx 容器)
ssh txjp "cp /root/docker/nginx-proxy-ai/docker-compose.yml /tmp/backup-shared/ 2>/dev/null || true"
# 打包
ssh txjp "cd /tmp && tar czf ${backup_name} backup-shared/"
# 复制到本地
mkdir -p "${LOCAL_DIR}"
scp "txjp:/tmp/${backup_name}" "${LOCAL_DIR}/"
# 云端保留
ssh txjp "mkdir -p ${CLOUD_DIR} && cp /tmp/${backup_name} ${CLOUD_DIR}/"
# 清理
ssh txjp "rm -rf /tmp/backup-shared /tmp/${backup_name}"
log "共享资源备份完成: ${backup_name}"
}
# ============================================================
# 主流程
# ============================================================
log "开始站点独立备份..."
# 解析参数
TARGET_SITES="${1:-all}"
if [[ "$TARGET_SITES" == "all" ]]; then
# 备份所有站点
for site in $ALL_SITES; do
backup_site "$site"
done
backup_shared
else
# 备份指定站点
for site in $TARGET_SITES; do
if echo "$ALL_SITES" | grep -qw "$site"; then
backup_site "$site"
else
warn "未知站点: $site"
fi
done
fi
# ============================================================
# 显示备份结果
# ============================================================
echo ""
log "备份完成!"
echo ""
echo "本地备份文件:"
ls -lh ${LOCAL_DIR}/*-${TIMESTAMP}.tar.gz 2>/dev/null
echo ""
echo "云端备份文件:"
ssh txjp "ls -lh ${CLOUD_DIR}/*-${TIMESTAMP}.tar.gz 2>/dev/null"

View File

@ -1,205 +0,0 @@
#!/bin/bash
# 统一备份脚本 — 支持远程模式(从本地 Mac 执行)和本地模式(在服务器上直接执行)
# 用法bash backup.sh [--local] [--db-only|--full] [站点名...]
set -e
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
LOCAL_DIR="/Users/niuniu/programs/docker/db-backups"
REMOTE_BASE="/root/docker"
CLOUD_DIR="/root/docker/db-backups"
MODE="remote"
log() { echo "[$(date '+%H:%M:%S')] $1"; }
warn() { echo "[$(date '+%H:%M:%S')] [警告] $1"; }
# 参数解析(提取 --local / --db-only / --full其余为站点名
ARGS=(); ACTION="default"
for arg in "$@"; do
case "$arg" in
--local) MODE="local" ;;
--db-only|--full) ACTION="${arg#--}" ;;
--help|-h) ACTION="help" ;;
*) ARGS+=("$arg") ;;
esac
done
# 远程/本地适配
runcmd() { [[ "$MODE" == "local" ]] && bash -c "$*" || ssh txjp "$*"; }
fetch() { [[ "$MODE" == "local" ]] && cp "$1" "$2" || scp "txjp:$1" "$2"; }
# === 自动发现含数据库的站点 ===
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="${s%-ai}"; [[ "$s" == "oa" ]] && continue
DB_SITES="$DB_SITES $s"
done
container_for() { [[ "$1" == "ldap" ]] && echo "lldap" || echo "${1}-ai"; }
# === 模式 1站点完整备份默认===
backup_site() {
local site="$1" backup_name="${site}-${TIMESTAMP}.tar.gz"
log "备份 ${site}..."
runcmd "rm -rf /tmp/backup-${site} && mkdir -p /tmp/backup-${site}/data"
case "$site" in
assets)
runcmd "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 || true
runcmd "docker cp assets-ai:/app/data/assets.db /tmp/backup-${site}/data/"
runcmd "cp -a /root/docker/assets-ai/data/uploads /tmp/backup-${site}/data/ 2>/dev/null || true"
runcmd "cp /root/docker/assets-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true"
runcmd "cp /root/docker/assets-ai/.env /tmp/backup-${site}/ 2>/dev/null || true"
runcmd "cp /root/docker/assets-ai/Dockerfile /tmp/backup-${site}/ 2>/dev/null || true"
;;
issue)
runcmd "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 || true
runcmd "docker cp issue-ai:/app/data/issue.db /tmp/backup-${site}/data/"
runcmd "cp -a /root/docker/issue-ai/data/uploads /tmp/backup-${site}/data/ 2>/dev/null || true"
runcmd "cp -a /root/docker/issue-ai/data/reports /tmp/backup-${site}/data/ 2>/dev/null || true"
runcmd "cp /root/docker/issue-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true"
runcmd "cp /root/docker/issue-ai/.env /tmp/backup-${site}/ 2>/dev/null || true"
runcmd "cp /root/docker/issue-ai/Dockerfile /tmp/backup-${site}/ 2>/dev/null || true"
;;
ldap)
runcmd "docker exec lldap sqlite3 /data/users.db 'PRAGMA wal_checkpoint(TRUNCATE);'" 2>/dev/null || true
runcmd "docker cp lldap:/data/users.db /tmp/backup-${site}/data/"
runcmd "cp /root/docker/ldap-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true"
runcmd "cp /root/docker/ldap-ai/.env /tmp/backup-${site}/ 2>/dev/null || true"
runcmd "cp /root/docker/ldap-ai/Dockerfile /tmp/backup-${site}/ 2>/dev/null || true"
;;
gitea)
runcmd "docker cp gitea-ai:/data/gitea.db /tmp/backup-${site}/data/"
runcmd "cp -a /root/docker/gitea-ai/data/git /tmp/backup-${site}/ 2>/dev/null || true"
runcmd "cp -a /root/docker/gitea-ai/data/gitea/conf /tmp/backup-${site}/gitea-conf 2>/dev/null || true"
runcmd "cp /root/docker/gitea-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true"
;;
oa)
runcmd "cp /root/docker/oa-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true"
runcmd "cp /root/docker/oa-ai/.env /tmp/backup-${site}/ 2>/dev/null || true"
;;
monitor)
runcmd "docker exec monitor-ai sqlite3 /app/data/monitor.db 'PRAGMA wal_checkpoint(TRUNCATE);'" 2>/dev/null || true
runcmd "docker cp monitor-ai:/app/data/monitor.db /tmp/backup-${site}/data/"
runcmd "cp /root/docker/monitor-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true"
runcmd "cp /root/docker/monitor-ai/.env /tmp/backup-${site}/ 2>/dev/null || true"
;;
*)
warn "${site}: 无专用备份逻辑,仅备份配置文件"
runcmd "cp /root/docker/${site}-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true"
runcmd "cp /root/docker/${site}-ai/.env /tmp/backup-${site}/ 2>/dev/null || true"
;;
esac
runcmd "cd /tmp && tar czf ${backup_name} backup-${site}/"
mkdir -p "${LOCAL_DIR}"
fetch "/tmp/${backup_name}" "${LOCAL_DIR}/"
runcmd "mkdir -p ${CLOUD_DIR} && cp /tmp/${backup_name} ${CLOUD_DIR}/"
runcmd "rm -rf /tmp/backup-${site} /tmp/${backup_name}"
log "${site} 备份完成: ${backup_name}"
}
backup_shared() {
local backup_name="shared-${TIMESTAMP}.tar.gz"
log "备份共享资源..."
runcmd "rm -rf /tmp/backup-shared && mkdir -p /tmp/backup-shared"
runcmd "mkdir -p /tmp/backup-shared/nginx && cp -a /root/docker/nginx-proxy-ai/conf.d/* /tmp/backup-shared/nginx/ 2>/dev/null || true"
runcmd "mkdir -p /tmp/backup-shared/ssl && cp -a /etc/letsencrypt/live /tmp/backup-shared/ssl/ 2>/dev/null || true && cp -a /etc/letsencrypt/archive /tmp/backup-shared/ssl/ 2>/dev/null || true && cp -a /etc/letsencrypt/renewal /tmp/backup-shared/ssl/ 2>/dev/null || true"
runcmd "cp /root/docker/nginx-proxy-ai/docker-compose.yml /tmp/backup-shared/ 2>/dev/null || true"
runcmd "cd /tmp && tar czf ${backup_name} backup-shared/"
mkdir -p "${LOCAL_DIR}"; fetch "/tmp/${backup_name}" "${LOCAL_DIR}/"
runcmd "mkdir -p ${CLOUD_DIR} && cp /tmp/${backup_name} ${CLOUD_DIR}/"
runcmd "rm -rf /tmp/backup-shared /tmp/${backup_name}"
log "共享资源备份完成: ${backup_name}"
}
# === 模式 2仅数据库备份 ===
backup_db_only() {
log "开始数据库备份..."
for s in $DB_SITES; do
c=$(container_for "$s")
[[ "$s" == "ldap" ]] && runcmd "docker exec ${c} sqlite3 /data/users.db 'PRAGMA wal_checkpoint(TRUNCATE);' 2>/dev/null || true" &
[[ "$s" != "ldap" ]] && runcmd "docker exec ${c} sqlite3 /app/data/${s}.db 'PRAGMA wal_checkpoint(TRUNCATE);' 2>/dev/null || true" &
done; wait
runcmd "mkdir -p /tmp/db-backup"
for s in $DB_SITES; do
c=$(container_for "$s")
dbpath="/app/data/${s}.db"; [[ "$s" == "ldap" ]] && dbpath="/data/users.db"; [[ "$s" == "gitea" ]] && dbpath="/data/gitea.db"
log "备份 ${s}..."
runcmd "docker cp ${c}:${dbpath} /tmp/db-backup/${s}-${TIMESTAMP}.db"
done
mkdir -p "${LOCAL_DIR}"; runcmd "mkdir -p ${CLOUD_DIR}"
for s in $DB_SITES; do
runcmd "cp /tmp/db-backup/${s}-${TIMESTAMP}.db ${CLOUD_DIR}/"
fetch "/tmp/db-backup/${s}-${TIMESTAMP}.db" "${LOCAL_DIR}/"
f="${LOCAL_DIR}/${s}-${TIMESTAMP}.db"
sqlite3 "$f" "SELECT count(*) FROM sqlite_master" > /dev/null 2>&1 || warn "${s} 备份文件无效"
done
runcmd "rm -rf /tmp/db-backup"
log "数据库备份完成"
}
# === 模式 3灾难恢复完整备份 ===
backup_full() {
log "开始完整灾难恢复备份..."
for s in $DB_SITES; do
c=$(container_for "$s")
[[ "$s" == "ldap" ]] && runcmd "docker exec ${c} sqlite3 /data/users.db 'PRAGMA wal_checkpoint(TRUNCATE);'" 2>/dev/null || true
[[ "$s" != "ldap" ]] && runcmd "docker exec ${c} sqlite3 /app/data/${s}.db 'PRAGMA wal_checkpoint(TRUNCATE);' 2>/dev/null || true" &
done; wait
runcmd "mkdir -p /tmp/full-backup/databases"
for s in $DB_SITES; do
c=$(container_for "$s")
dbpath="/app/data/${s}.db"; [[ "$s" == "ldap" ]] && dbpath="/data/users.db"; [[ "$s" == "gitea" ]] && dbpath="/data/gitea.db"
runcmd "docker cp ${c}:${dbpath} /tmp/full-backup/databases/${s}.db"
done
runcmd "mkdir -p /tmp/full-backup/gitea && cp -a /root/docker/gitea-ai/data/git /tmp/full-backup/gitea/ && cp -a /root/docker/gitea-ai/data/gitea/conf /tmp/full-backup/gitea/ 2>/dev/null || true"
runcmd "mkdir -p /tmp/full-backup/uploads && cp -a /root/docker/assets-ai/data/uploads /tmp/full-backup/uploads/assets-uploads 2>/dev/null || true && cp -a /root/docker/issue-ai/data/uploads /tmp/full-backup/uploads/issue-uploads 2>/dev/null || true && cp -a /root/docker/issue-ai/data/reports /tmp/full-backup/uploads/issue-reports 2>/dev/null || true"
runcmd "mkdir -p /tmp/full-backup/nginx && cp -a /root/docker/nginx-proxy-ai/conf.d/* /tmp/full-backup/nginx/ 2>/dev/null || true"
runcmd "mkdir -p /tmp/full-backup/ssl && cp -a /etc/letsencrypt/live /tmp/full-backup/ssl/ 2>/dev/null || true && cp -a /etc/letsencrypt/archive /tmp/full-backup/ssl/ 2>/dev/null || true && cp -a /etc/letsencrypt/renewal /tmp/full-backup/ssl/ 2>/dev/null || true"
local name="full-backup-${TIMESTAMP}"
runcmd "cd /tmp && tar czf ${name}.tar.gz full-backup/"
mkdir -p "${LOCAL_DIR}"; fetch "/tmp/${name}.tar.gz" "${LOCAL_DIR}/"
runcmd "mkdir -p ${CLOUD_DIR} && cp /tmp/${name}.tar.gz ${CLOUD_DIR}/"
runcmd "rm -rf /tmp/full-backup /tmp/${name}.tar.gz"
log "完整备份完成: ${name}.tar.gz"
}
# === 主流程 ===
case "$ACTION" in
help)
echo "用法: bash backup.sh [--local] [--db-only|--full] [站点名...]"
echo " --local 本地模式(直接在服务器上运行)"
echo " 无参数 站点完整备份(全部含 DB 站点)"
echo " --db-only 仅数据库备份"
echo " --full 灾难恢复完整备份"
echo " 站点名... 备份指定站点"
;;
db-only) backup_db_only ;;
full) backup_full ;;
*)
TARGETS="${ARGS[*]:-all}"
log "站点完整备份: ${TARGETS} (模式: ${MODE})"
if [[ "$TARGETS" == "all" ]]; then
for site in $DB_SITES oa; do backup_site "$site"; done
backup_shared
else
for site in $TARGETS; do backup_site "$site"; done
fi
;;
esac
echo ""; log "备份完成!"
ls -lh ${LOCAL_DIR}/*-${TIMESTAMP}* 2>/dev/null

284
backup/restore-db.sh Executable file
View File

@ -0,0 +1,284 @@
#!/bin/bash
# ============================================
# 数据库恢复脚本
#
# 用法:
# 恢复本地数据库: bash scripts/restore-db.sh local <服务名> <备份文件名>
# 恢复云端数据库: bash scripts/restore-db.sh cloud <服务名> <备份文件名>
#
# 服务名: issue / assets / ldap
#
# 示例:
# 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
# ============================================
set -e
# ---- 参数检查 ----
if [ $# -lt 3 ]; then
echo "用法: $0 <local|cloud> <issue|assets|ldap> <备份文件名>"
echo ""
echo "支持格式: .db 或 .tar.gz自动解压"
echo ""
echo "示例:"
echo " $0 local issue issue-2026-04-29_1753.db"
echo " $0 local issue issue-20260622_101858.tar.gz"
echo " $0 cloud ldap ldap-2026-05-15_1200.db"
echo " $0 cloud assets assets-2026-04-29_1753.db"
echo ""
echo "可用备份文件:"
echo " 本地:"
ls -1 /Users/niuniu/programs/docker/db-backups/ 2>/dev/null || echo " (无)"
echo " 云端:"
ssh txjp "ls -1 /root/docker/db-backups/" 2>/dev/null || echo " (无)"
exit 1
fi
TARGET=$1
SERVICE=$2
BACKUP_FILE=$3
# ---- 路径定义 ----
LOCAL_BACKUP_DIR="/Users/niuniu/programs/docker/db-backups"
CLOUD_BACKUP_DIR="/root/docker/db-backups"
case $SERVICE in
issue)
PROJECT_DIR="issue-ai"
CONTAINER="issue-ai"
DB_PATH_IN_CONTAINER="/app/data/issue.db"
LOCAL_DB_PATH="/Users/niuniu/programs/docker/issue-ai/data/issue.db"
;;
assets)
PROJECT_DIR="assets-ai"
CONTAINER="assets-ai"
DB_PATH_IN_CONTAINER="/app/data/assets.db"
LOCAL_DB_PATH="/Users/niuniu/programs/docker/assets-ai/data/assets.db"
;;
ldap)
PROJECT_DIR="ldap-ai"
CONTAINER="lldap"
DB_PATH_IN_CONTAINER="/data/users.db"
LOCAL_DB_PATH="/Users/niuniu/programs/docker/ldap-ai/data/lldap/users.db"
;;
*)
echo "错误: 服务名必须是 issue、assets 或 ldap"
exit 1
;;
esac
# ============================================
# 本地恢复
# ============================================
restore_local() {
local BACKUP_PATH="${LOCAL_BACKUP_DIR}/${BACKUP_FILE}"
local DB_FILE="$BACKUP_PATH"
echo "========================================"
echo " 本地数据库恢复"
echo "========================================"
echo " 服务: ${SERVICE} (${CONTAINER})"
echo " 备份文件: ${BACKUP_PATH}"
echo " 目标数据库: ${LOCAL_DB_PATH}"
echo "========================================"
echo ""
# 1. 检查备份文件
if [ ! -f "$BACKUP_PATH" ]; then
echo "[错误] 备份文件不存在: ${BACKUP_PATH}"
exit 1
fi
# 2. 如果是 .tar.gz 文件,解压获取 .db 文件
if [[ "$BACKUP_PATH" == *.tar.gz ]]; then
echo "[信息] 检测到 tar.gz 压缩包,正在解压..."
TEMP_DIR=$(mktemp -d)
tar xzf "$BACKUP_PATH" -C "$TEMP_DIR"
# 查找 .db 文件
DB_FILE=$(find "$TEMP_DIR" -name "*.db" -type f | head -1)
if [ -z "$DB_FILE" ]; then
echo "[错误] 压缩包中未找到 .db 文件"
rm -rf "$TEMP_DIR"
exit 1
fi
echo "[信息] 解压得到数据库文件: $(basename $DB_FILE)"
fi
# 3. 验证备份文件是有效的 SQLite 数据库
if ! sqlite3 "$DB_FILE" "SELECT count(*) FROM sqlite_master" > /dev/null 2>&1; then
echo "[错误] 备份文件不是有效的 SQLite 数据库"
[ -n "$TEMP_DIR" ] && rm -rf "$TEMP_DIR"
exit 1
fi
# 4. 确认操作
echo "[警告] 这将覆盖本地 ${CONTAINER} 数据库!"
echo " 当前数据库: ${LOCAL_DB_PATH}"
echo " 将替换为: ${DB_FILE}"
echo ""
read -p "确认恢复? (输入 yes 继续): " CONFIRM
if [ "$CONFIRM" != "yes" ]; then
echo "已取消"
[ -n "$TEMP_DIR" ] && rm -rf "$TEMP_DIR"
exit 0
fi
# 5. 备份当前数据库(安全起见)
local SAFE_BACKUP="${LOCAL_DB_PATH}.before-restore-$(date +%Y%m%d_%H%M%S)"
if [ -f "$LOCAL_DB_PATH" ]; then
cp "$LOCAL_DB_PATH" "$SAFE_BACKUP"
echo "[备份] 当前数据库已备份至: ${SAFE_BACKUP}"
fi
# 6. WAL checkpoint on backup (确保数据完整)
sqlite3 "$DB_FILE" "PRAGMA wal_checkpoint(TRUNCATE)" 2>/dev/null || true
# 7. 停止本地容器(如果在运行)
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER}$"; then
echo "[操作] 停止容器 ${CONTAINER}..."
docker stop "$CONTAINER"
fi
# 8. 替换数据库
cp "$DB_FILE" "$LOCAL_DB_PATH"
rm -f "${LOCAL_DB_PATH}-shm" "${LOCAL_DB_PATH}-wal"
echo "[完成] 数据库已替换"
# 9. 清理临时文件
[ -n "$TEMP_DIR" ] && rm -rf "$TEMP_DIR"
# 10. 启动容器
echo "[操作] 启动容器 ${CONTAINER}..."
docker start "$CONTAINER"
# 11. 验证
sleep 2
echo ""
echo "[验证] 恢复后数据:"
if [ "$SERVICE" = "issue" ]; then
echo " 工单数: $(sqlite3 "$LOCAL_DB_PATH" "SELECT COUNT(*) FROM tickets")"
echo " 配件名称: $(sqlite3 "$LOCAL_DB_PATH" "SELECT COUNT(*) FROM tickets WHERE parts_name IS NOT NULL AND parts_name != ''")"
elif [ "$SERVICE" = "ldap" ]; then
echo " 用户数: $(sqlite3 "$LOCAL_DB_PATH" "SELECT COUNT(*) FROM users")"
else
echo " 设备数: $(sqlite3 "$LOCAL_DB_PATH" "SELECT COUNT(*) FROM assets")"
fi
echo ""
echo "恢复完成!"
}
# ============================================
# 云端恢复
# ============================================
restore_cloud() {
local BACKUP_PATH="${CLOUD_BACKUP_DIR}/${BACKUP_FILE}"
echo "========================================"
echo " 云端数据库恢复 (txjp)"
echo "========================================"
echo " 服务: ${SERVICE} (${CONTAINER})"
echo " 备份文件: ${BACKUP_PATH}"
echo "========================================"
echo ""
# 1. 检查备份文件
if ! ssh txjp "test -f ${BACKUP_PATH}"; then
echo "[错误] 云端备份文件不存在: ${BACKUP_PATH}"
exit 1
fi
# 2. 验证备份文件(通过容器内 better-sqlite3避免依赖宿主机或容器内 sqlite3 CLI
if ! ssh txjp "docker cp ${BACKUP_PATH} ${CONTAINER}:/tmp/restore-check.db 2>/dev/null && \
docker exec ${CONTAINER} node -e \"const D=require('better-sqlite3');const db=new D('/tmp/restore-check.db',{readonly:true});console.log(db.prepare('SELECT count(*) as c FROM sqlite_master').get().c);db.close();\" > /dev/null 2>&1 && \
docker exec ${CONTAINER} rm /tmp/restore-check.db"; then
echo "[错误] 备份文件不是有效的 SQLite 数据库"
exit 1
fi
# 3. 显示备份和当前状态
echo "[信息] 备份文件信息:"
ssh txjp "ls -lh ${BACKUP_PATH}"
echo ""
echo "[信息] 当前云端数据库状态:"
if [ "$SERVICE" = "issue" ]; then
ssh txjp "docker exec ${CONTAINER} node -e \"
const D=require('better-sqlite3');
const db=new D('/app/data/issue.db',{readonly:true});
console.log(' 工单数:', db.prepare('SELECT COUNT(*) as c FROM tickets').get().c);
db.close();
\""
else
ssh txjp "docker exec ${CONTAINER} node -e \"
const D=require('better-sqlite3');
const db=new D('/app/data/assets.db',{readonly:true});
console.log(' 设备数:', db.prepare('SELECT COUNT(*) as c FROM assets').get().c);
db.close();
\""
fi
# 4. 确认
echo ""
echo "[警告] 这将覆盖云端 ${CONTAINER} 数据库!"
read -p "确认恢复? (输入 yes 继续): " CONFIRM
if [ "$CONFIRM" != "yes" ]; then
echo "已取消"
exit 0
fi
# 5. 停止容器(确保数据库不被写入,安全替换)
echo "[操作] 停止容器 ${CONTAINER}..."
ssh txjp "cd /root/docker/${PROJECT_DIR} && docker compose stop ${CONTAINER}"
# 6. 备份当前数据库(容器已停止,安全复制)
local SAFE_NAME="before-restore-$(date +%Y%m%d_%H%M%S).db"
echo "[操作] 备份云端当前数据库..."
ssh txjp "docker cp ${CONTAINER}:${DB_PATH_IN_CONTAINER} /root/docker/db-backups/${SAFE_NAME}"
echo "[备份] 当前数据库已保存至云端: /root/docker/db-backups/${SAFE_NAME}"
# 7. 替换数据库
echo "[操作] 复制备份文件到容器..."
ssh txjp "docker cp ${BACKUP_PATH} ${CONTAINER}:${DB_PATH_IN_CONTAINER}"
# 8. 删除残留 WAL/SHM 文件,防止旧日志与新数据库不匹配导致损坏
echo "[操作] 清理残留 WAL/SHM 文件..."
ssh txjp "docker exec ${CONTAINER} rm -f ${DB_PATH_IN_CONTAINER}-shm ${DB_PATH_IN_CONTAINER}-wal" 2>/dev/null || true
# 9. 启动容器
echo "[操作] 启动容器 ${CONTAINER}..."
ssh txjp "cd /root/docker/${PROJECT_DIR} && docker compose start ${CONTAINER}"
# 10. 验证
sleep 3
echo ""
echo "[验证] 恢复后数据:"
if [ "$SERVICE" = "issue" ]; then
ssh txjp "docker exec ${CONTAINER} node -e \"
const D=require('better-sqlite3');
const db=new D('/app/data/issue.db',{readonly:true});
console.log(' 工单数:', db.prepare('SELECT COUNT(*) as c FROM tickets').get().c);
db.close();
\""
else
ssh txjp "docker exec ${CONTAINER} node -e \"
const D=require('better-sqlite3');
const db=new D('/app/data/assets.db',{readonly:true});
console.log(' 设备数:', db.prepare('SELECT COUNT(*) as c FROM assets').get().c);
db.close();
\""
fi
echo ""
echo "恢复完成!"
}
# ---- 执行 ----
case $TARGET in
local) restore_local ;;
cloud) restore_cloud ;;
*)
echo "错误: 目标必须是 local 或 cloud"
exit 1
;;
esac

272
backup/restore-full.sh Executable file
View File

@ -0,0 +1,272 @@
#!/bin/bash
# 灾难恢复脚本 - 从完整备份恢复站点
# 用法bash restore-full.sh [备份文件名]
# 无参数则自动检测当前目录下的最新备份
set -e
BACKUP_DIR="/Users/niuniu/programs/docker/db-backups"
REMOTE_BASE="/root/docker"
log() { echo "[$(date '+%H:%M:%S')] $1"; }
warn() { echo "[$(date '+%H:%M:%S')] [警告] $1"; }
error() { echo "[$(date '+%H:%M:%S')] [错误] $1"; exit 1; }
# ============================================================
# 1. 选择备份文件
# ============================================================
if [[ -n "$1" ]]; then
BACKUP_FILE="$1"
else
echo ""
echo "=========================================="
echo " 可用的完整备份文件"
echo "=========================================="
echo ""
# 列出本地备份
if [[ -d "$BACKUP_DIR" ]]; then
local_files=$(ls -t ${BACKUP_DIR}/full-backup-*.tar.gz 2>/dev/null | head -10)
if [[ -n "$local_files" ]]; then
echo "本地备份:"
index=1
while IFS= read -r file; do
filename=$(basename "$file")
filesize=$(du -h "$file" | cut -f1)
echo " $index) $filename ($filesize)"
files[$index]="$file"
((index++))
done <<< "$local_files"
echo ""
fi
fi
# 列出云端备份
echo "云端备份:"
cloud_files=$(ssh txjp "ls -t /root/docker/db-backups/full-backup-*.tar.gz 2>/dev/null | head -10" 2>/dev/null || true)
if [[ -n "$cloud_files" ]]; then
while IFS= read -r file; do
filename=$(basename "$file")
echo " $index) [云端] $filename"
files[$index]="cloud:$file"
((index++))
done <<< "$cloud_files"
echo ""
fi
if [[ ${#files[@]} -eq 0 ]]; then
error "未找到任何备份文件"
fi
printf "请选择备份文件 (1-$((index-1))): "
read choice
if [[ -z "${files[$choice]}" ]]; then
error "无效选择"
fi
BACKUP_FILE="${files[$choice]}"
fi
# ============================================================
# 2. 下载云端备份到临时目录
# ============================================================
RESTORE_DIR="/tmp/restore-$$"
mkdir -p "$RESTORE_DIR"
if [[ "$BACKUP_FILE" == cloud:* ]]; then
cloud_path="${BACKUP_FILE#cloud:}"
log "从云端下载备份..."
scp "txjp:$cloud_path" "$RESTORE_DIR/"
BACKUP_FILE="$RESTORE_DIR/$(basename $cloud_path)"
fi
# ============================================================
# 3. 解压备份
# ============================================================
log "解压备份文件..."
cd "$RESTORE_DIR"
tar xzf "$BACKUP_FILE"
# 检测备份内容
BACKUP_CONTENT="$RESTORE_DIR/full-backup"
if [[ ! -d "$BACKUP_CONTENT" ]]; then
error "备份文件格式错误"
fi
log "备份内容:"
ls -la "$BACKUP_CONTENT/"
# ============================================================
# 4. 选择要恢复的站点
# ============================================================
echo ""
echo "=========================================="
echo " 选择要恢复的内容"
echo "=========================================="
echo ""
echo " 1) assets - 资产管理系统"
echo " 2) issue - 工单系统"
echo " 3) ldap - 用户目录服务"
echo " 4) gitea - 代码仓库"
echo " 5) nginx - 反向代理配置"
echo " 6) 全部恢复"
echo ""
printf "请选择 (1-6): "
read site_choice
# ============================================================
# 5. 执行恢复
# ============================================================
restore_assets() {
log "恢复 assets 站点..."
# 恢复数据库
if [[ -f "$BACKUP_CONTENT/databases/assets.db" ]]; then
log "恢复 assets 数据库..."
scp "$BACKUP_CONTENT/databases/assets.db" txjp:/tmp/assets.db
ssh txjp "docker cp /tmp/assets.db assets-ai:/app/data/assets.db && rm /tmp/assets.db"
ssh txjp "docker restart assets-ai"
log "assets 数据库恢复完成"
fi
# 恢复配置
if [[ -d "$BACKUP_CONTENT/configs/assets-ai" ]]; then
log "恢复 assets 配置..."
ssh txjp "mkdir -p $REMOTE_BASE/assets-ai"
scp "$BACKUP_CONTENT/configs/assets-ai/"* txjp:$REMOTE_BASE/assets-ai/ 2>/dev/null || true
fi
# 恢复上传文件
if [[ -d "$BACKUP_CONTENT/uploads/assets-uploads" ]]; then
log "恢复 assets 上传文件..."
ssh txjp "mkdir -p $REMOTE_BASE/assets-ai/data"
scp -r "$BACKUP_CONTENT/uploads/assets-uploads/"* txjp:$REMOTE_BASE/assets-ai/data/uploads/ 2>/dev/null || true
fi
}
restore_issue() {
log "恢复 issue 站点..."
# 恢复数据库
if [[ -f "$BACKUP_CONTENT/databases/issue.db" ]]; then
log "恢复 issue 数据库..."
scp "$BACKUP_CONTENT/databases/issue.db" txjp:/tmp/issue.db
ssh txjp "docker cp /tmp/issue.db issue-ai:/app/data/issue.db && rm /tmp/issue.db"
ssh txjp "docker restart issue-ai"
log "issue 数据库恢复完成"
fi
# 恢复配置
if [[ -d "$BACKUP_CONTENT/configs/issue-ai" ]]; then
log "恢复 issue 配置..."
ssh txjp "mkdir -p $REMOTE_BASE/issue-ai"
scp "$BACKUP_CONTENT/configs/issue-ai/"* txjp:$REMOTE_BASE/issue-ai/ 2>/dev/null || true
fi
# 恢复上传文件
if [[ -d "$BACKUP_CONTENT/uploads/issue-uploads" ]]; then
log "恢复 issue 上传文件..."
ssh txjp "mkdir -p $REMOTE_BASE/issue-ai/data"
scp -r "$BACKUP_CONTENT/uploads/issue-uploads/"* txjp:$REMOTE_BASE/issue-ai/data/uploads/ 2>/dev/null || true
fi
# 恢复报告文件
if [[ -d "$BACKUP_CONTENT/uploads/issue-reports" ]]; then
log "恢复 issue 报告文件..."
ssh txjp "mkdir -p $REMOTE_BASE/issue-ai/data"
scp -r "$BACKUP_CONTENT/uploads/issue-reports/"* txjp:$REMOTE_BASE/issue-ai/data/reports/ 2>/dev/null || true
fi
}
restore_ldap() {
log "恢复 ldap 站点..."
# 恢复数据库
if [[ -f "$BACKUP_CONTENT/databases/ldap.db" ]]; then
log "恢复 ldap 数据库..."
scp "$BACKUP_CONTENT/databases/ldap.db" txjp:/tmp/ldap.db
ssh txjp "docker cp /tmp/ldap.db lldap:/data/users.db && rm /tmp/ldap.db"
ssh txjp "docker restart lldap"
log "ldap 数据库恢复完成"
fi
# 恢复配置
if [[ -d "$BACKUP_CONTENT/configs/ldap-ai" ]]; then
log "恢复 ldap 配置..."
ssh txjp "mkdir -p $REMOTE_BASE/ldap-ai"
scp "$BACKUP_CONTENT/configs/ldap-ai/"* txjp:$REMOTE_BASE/ldap-ai/ 2>/dev/null || true
fi
}
restore_gitea() {
log "恢复 gitea 站点..."
# 恢复数据库
if [[ -f "$BACKUP_CONTENT/databases/gitea.db" ]]; then
log "恢复 gitea 数据库..."
scp "$BACKUP_CONTENT/databases/gitea.db" txjp:/tmp/gitea.db
ssh txjp "docker cp /tmp/gitea.db gitea-ai:/data/gitea.db && rm /tmp/gitea.db"
fi
# 恢复代码仓库
if [[ -d "$BACKUP_CONTENT/gitea/git" ]]; then
log "恢复 gitea 代码仓库..."
ssh txjp "mkdir -p $REMOTE_BASE/gitea-ai/data"
scp -r "$BACKUP_CONTENT/gitea/git" txjp:$REMOTE_BASE/gitea-ai/data/
fi
# 恢复配置
if [[ -d "$BACKUP_CONTENT/gitea/conf" ]]; then
log "恢复 gitea 配置..."
ssh txjp "mkdir -p $REMOTE_BASE/gitea-ai/data/gitea"
scp -r "$BACKUP_CONTENT/gitea/conf" txjp:$REMOTE_BASE/gitea-ai/data/gitea/
fi
# 恢复 docker-compose
if [[ -d "$BACKUP_CONTENT/configs/gitea-ai" ]]; then
log "恢复 gitea docker-compose..."
scp "$BACKUP_CONTENT/configs/gitea-ai/"* txjp:$REMOTE_BASE/gitea-ai/ 2>/dev/null || true
fi
# 重启容器
ssh txjp "cd $REMOTE_BASE/gitea-ai && docker compose down && docker compose up -d"
log "gitea 恢复完成"
}
restore_nginx() {
log "恢复 nginx 配置..."
if [[ -d "$BACKUP_CONTENT/nginx" ]]; then
ssh txjp "mkdir -p $REMOTE_BASE/nginx-proxy-ai/conf.d"
scp "$BACKUP_CONTENT/nginx/"* txjp:$REMOTE_BASE/nginx-proxy-ai/conf.d/
ssh txjp "docker exec nginx-ai nginx -t && docker exec nginx-ai nginx -s reload"
log "nginx 配置恢复完成"
fi
}
# 执行恢复
case "$site_choice" in
1) restore_assets ;;
2) restore_issue ;;
3) restore_ldap ;;
4) restore_gitea ;;
5) restore_nginx ;;
6)
restore_assets
restore_issue
restore_ldap
restore_gitea
restore_nginx
;;
*) error "无效选择" ;;
esac
# ============================================================
# 6. 清理临时文件
# ============================================================
log "清理临时文件..."
rm -rf "$RESTORE_DIR"
echo ""
log "恢复完成!"

367
backup/restore-sites.sh Executable file
View File

@ -0,0 +1,367 @@
#!/bin/bash
# 站点恢复脚本 - 支持选择恢复单个站点或全部
# 用法bash restore-sites.sh [备份时间戳]
# 无参数则自动检测最新备份
set -e
BACKUP_DIR="/Users/niuniu/programs/docker/db-backups"
REMOTE_BASE="/root/docker"
log() { echo "[$(date '+%H:%M:%S')] $1"; }
warn() { echo "[$(date '+%H:%M:%S')] [警告] $1"; }
error() { echo "[$(date '+%H:%M:%S')] [错误] $1"; exit 1; }
# ============================================================
# 1. 选择备份时间戳
# ============================================================
if [[ -n "$1" ]]; then
TIMESTAMP="$1"
else
echo ""
echo "=========================================="
echo " 可用的备份时间戳"
echo "=========================================="
echo ""
# 列出可用的时间戳
timestamps=$(ls ${BACKUP_DIR}/assets-*.tar.gz 2>/dev/null | sed 's/.*assets-\(.*\)\.tar\.gz/\1/' | sort -r | head -10)
if [[ -z "$timestamps" ]]; then
error "未找到任何备份文件"
fi
index=1
while IFS= read -r ts; do
echo " $index) $ts"
ts_list[$index]="$ts"
((index++))
done <<< "$timestamps"
echo ""
printf "请选择备份时间戳 (1-$((index-1))): "
read choice
if [[ -z "${ts_list[$choice]}" ]]; then
error "无效选择"
fi
TIMESTAMP="${ts_list[$choice]}"
fi
# ============================================================
# 2. 检测可用的备份文件
# ============================================================
echo ""
log "检测备份文件: ${TIMESTAMP}"
LOCAL_BACKUP="${BACKUP_DIR}"
CLOUD_BACKUP="txjp:/root/docker/db-backups"
# 检查本地备份
check_backup() {
local site="$1"
local found=""
# 检查本地
if [[ -f "${LOCAL_BACKUP}/${site}-${TIMESTAMP}.tar.gz" ]]; then
found="local:${LOCAL_BACKUP}/${site}-${TIMESTAMP}.tar.gz"
# 检查云端
elif ssh txjp "test -f ${CLOUD_BACKUP}/${site}-${TIMESTAMP}.tar.gz" 2>/dev/null; then
found="cloud:${site}-${TIMESTAMP}.tar.gz"
fi
echo "$found"
}
# ============================================================
# 3. 选择要恢复的站点
# ============================================================
echo ""
echo "=========================================="
echo " 可恢复的站点"
echo "=========================================="
echo ""
SITES="shared assets issue ldap gitea oa"
index=1
declare -A site_status
for site in $SITES; do
backup=$(check_backup "$site")
if [[ -n "$backup" ]]; then
if [[ "$backup" == local:* ]]; then
status="✅ 本地"
else
status="✅ 云端"
fi
echo " $index) ${site} ${status}"
site_list[$index]="$site"
site_source[$index]="$backup"
((index++))
else
echo " $index) ${site} ❌ 未找到"
((index++))
fi
done
echo ""
echo " $index) 全部恢复"
echo ""
printf "请选择要恢复的内容 (1-$index): "
read choice
if [[ "$choice" == "$index" ]]; then
# 全部恢复
restore_list=("${site_list[@]}")
else
restore_list=("${site_list[$choice]}")
fi
# ============================================================
# 4. 下载并解压备份文件
# ============================================================
RESTORE_DIR="/tmp/restore-$$"
mkdir -p "$RESTORE_DIR"
download_and_extract() {
local site="$1"
local source="${site_source[$choice]}"
local file_path=""
if [[ "$source" == local:* ]]; then
file_path="${source#local:}"
elif [[ "$source" == cloud:* ]]; then
local filename="${source#cloud:}"
log "从云端下载 ${site} 备份..."
scp "${CLOUD_BACKUP}/${filename}" "$RESTORE_DIR/"
file_path="$RESTORE_DIR/${filename}"
fi
# 解压
cd "$RESTORE_DIR"
tar xzf "$file_path"
}
# ============================================================
# 5. 恢复函数
# ============================================================
restore_shared() {
log "恢复共享资源..."
download_and_extract "shared"
# 恢复 docker 网络
if [[ -f "$RESTORE_DIR/backup-shared/docker-network.sh" ]]; then
log "创建 Docker 网络..."
bash "$RESTORE_DIR/backup-shared/docker-network.sh"
fi
# 恢复 nginx 配置
if [[ -d "$RESTORE_DIR/backup-shared/nginx" ]]; then
log "恢复 nginx 配置..."
ssh txjp "mkdir -p $REMOTE_BASE/nginx-proxy-ai/conf.d"
scp "$RESTORE_DIR/backup-shared/nginx/"* txjp:$REMOTE_BASE/nginx-proxy-ai/conf.d/
fi
# 恢复 SSL 证书
if [[ -d "$RESTORE_DIR/backup-shared/ssl" ]]; then
log "恢复 SSL 证书..."
ssh txjp "cp -a $RESTORE_DIR/backup-shared/ssl/live /etc/letsencrypt/ 2>/dev/null || true"
ssh txjp "cp -a $RESTORE_DIR/backup-shared/ssl/archive /etc/letsencrypt/ 2>/dev/null || true"
ssh txjp "cp -a $RESTORE_DIR/backup-shared/ssl/renewal /etc/letsencrypt/ 2>/dev/null || true"
fi
# 恢复 nginx 容器配置
if [[ -f "$RESTORE_DIR/backup-shared/docker-compose.yml" ]]; then
log "恢复 nginx docker-compose..."
scp "$RESTORE_DIR/backup-shared/docker-compose.yml" txjp:$REMOTE_BASE/nginx-proxy-ai/
fi
}
restore_assets() {
log "恢复 assets 站点..."
download_and_extract "assets"
# 创建目录
ssh txjp "mkdir -p $REMOTE_BASE/assets-ai/data"
# 恢复数据库
if [[ -f "$RESTORE_DIR/backup-assets/data/assets.db" ]]; then
log "恢复 assets 数据库..."
scp "$RESTORE_DIR/backup-assets/data/assets.db" txjp:/tmp/assets.db
ssh txjp "docker cp /tmp/assets.db assets-ai:/app/data/assets.db && rm /tmp/assets.db"
fi
# 恢复上传文件
if [[ -d "$RESTORE_DIR/backup-assets/data/uploads" ]]; then
log "恢复 assets 上传文件..."
scp -r "$RESTORE_DIR/backup-assets/data/uploads/"* txjp:$REMOTE_BASE/assets-ai/data/uploads/ 2>/dev/null || true
fi
# 恢复配置文件
for f in docker-compose.yml .env Dockerfile; do
if [[ -f "$RESTORE_DIR/backup-assets/$f" ]]; then
scp "$RESTORE_DIR/backup-assets/$f" txjp:$REMOTE_BASE/assets-ai/
fi
done
# 重启容器
log "重启 assets 容器..."
ssh txjp "cd $REMOTE_BASE/assets-ai && docker compose down 2>/dev/null || true && docker compose up -d"
}
restore_issue() {
log "恢复 issue 站点..."
download_and_extract "issue"
# 创建目录
ssh txjp "mkdir -p $REMOTE_BASE/issue-ai/data"
# 恢复数据库
if [[ -f "$RESTORE_DIR/backup-issue/data/issue.db" ]]; then
log "恢复 issue 数据库..."
scp "$RESTORE_DIR/backup-issue/data/issue.db" txjp:/tmp/issue.db
ssh txjp "docker cp /tmp/issue.db issue-ai:/app/data/issue.db && rm /tmp/issue.db"
fi
# 恢复上传文件
if [[ -d "$RESTORE_DIR/backup-issue/data/uploads" ]]; then
log "恢复 issue 上传文件..."
scp -r "$RESTORE_DIR/backup-issue/data/uploads/"* txjp:$REMOTE_BASE/issue-ai/data/uploads/ 2>/dev/null || true
fi
# 恢复报告文件
if [[ -d "$RESTORE_DIR/backup-issue/data/reports" ]]; then
log "恢复 issue 报告文件..."
scp -r "$RESTORE_DIR/backup-issue/data/reports/"* txjp:$REMOTE_BASE/issue-ai/data/reports/ 2>/dev/null || true
fi
# 恢复配置文件
for f in docker-compose.yml .env Dockerfile; do
if [[ -f "$RESTORE_DIR/backup-issue/$f" ]]; then
scp "$RESTORE_DIR/backup-issue/$f" txjp:$REMOTE_BASE/issue-ai/
fi
done
# 重启容器
log "重启 issue 容器..."
ssh txjp "cd $REMOTE_BASE/issue-ai && docker compose down 2>/dev/null || true && docker compose up -d"
}
restore_ldap() {
log "恢复 ldap 站点..."
download_and_extract "ldap"
# 创建目录
ssh txjp "mkdir -p $REMOTE_BASE/ldap-ai/data"
# 恢复数据库
if [[ -f "$RESTORE_DIR/backup-ldap/data/users.db" ]]; then
log "恢复 ldap 数据库..."
scp "$RESTORE_DIR/backup-ldap/data/users.db" txjp:/tmp/ldap.db
ssh txjp "docker cp /tmp/ldap.db lldap:/data/users.db && rm /tmp/ldap.db"
fi
# 恢复配置文件
for f in docker-compose.yml .env Dockerfile; do
if [[ -f "$RESTORE_DIR/backup-ldap/$f" ]]; then
scp "$RESTORE_DIR/backup-ldap/$f" txjp:$REMOTE_BASE/ldap-ai/
fi
done
# 重启容器
log "重启 ldap 容器..."
ssh txjp "cd $REMOTE_BASE/ldap-ai && docker compose down 2>/dev/null || true && docker compose up -d"
}
restore_gitea() {
log "恢复 gitea 站点..."
download_and_extract "gitea"
# 创建目录
ssh txjp "mkdir -p $REMOTE_BASE/gitea-ai/data"
# 恢复数据库
if [[ -f "$RESTORE_DIR/backup-gitea/data/gitea.db" ]]; then
log "恢复 gitea 数据库..."
scp "$RESTORE_DIR/backup-gitea/data/gitea.db" txjp:/tmp/gitea.db
ssh txjp "docker cp /tmp/gitea.db gitea-ai:/data/gitea.db && rm /tmp/gitea.db"
fi
# 恢复代码仓库
if [[ -d "$RESTORE_DIR/backup-gitea/git" ]]; then
log "恢复 gitea 代码仓库..."
scp -r "$RESTORE_DIR/backup-gitea/git" txjp:$REMOTE_BASE/gitea-ai/data/
fi
# 恢复配置
if [[ -d "$RESTORE_DIR/backup-gitea/gitea-conf" ]]; then
log "恢复 gitea 配置..."
ssh txjp "mkdir -p $REMOTE_BASE/gitea-ai/data/gitea"
scp -r "$RESTORE_DIR/backup-gitea/gitea-conf" txjp:$REMOTE_BASE/gitea-ai/data/gitea/
fi
# 恢复 docker-compose
if [[ -f "$RESTORE_DIR/backup-gitea/docker-compose.yml" ]]; then
scp "$RESTORE_DIR/backup-gitea/docker-compose.yml" txjp:$REMOTE_BASE/gitea-ai/
fi
# 重启容器
log "重启 gitea 容器..."
ssh txjp "cd $REMOTE_BASE/gitea-ai && docker compose down 2>/dev/null || true && docker compose up -d"
}
restore_oa() {
log "恢复 oa 站点..."
download_and_extract "oa"
# 恢复配置文件
for f in docker-compose.yml .env; do
if [[ -f "$RESTORE_DIR/backup-oa/$f" ]]; then
scp "$RESTORE_DIR/backup-oa/$f" txjp:$REMOTE_BASE/oa-ai/
fi
done
# 重启容器
log "重启 oa 容器..."
ssh txjp "cd $REMOTE_BASE/oa-ai && docker compose down 2>/dev/null || true && docker compose up -d"
}
# ============================================================
# 6. 执行恢复
# ============================================================
for site in "${restore_list[@]}"; do
case "$site" in
shared) restore_shared ;;
assets) restore_assets ;;
issue) restore_issue ;;
ldap) restore_ldap ;;
gitea) restore_gitea ;;
oa) restore_oa ;;
esac
done
# ============================================================
# 7. 重载 nginx
# ============================================================
log "重载 nginx 配置..."
ssh txjp "docker exec nginx-ai nginx -t && docker exec nginx-ai nginx -s reload" 2>/dev/null || warn "nginx 重载失败,请手动检查"
# ============================================================
# 8. 清理临时文件
# ============================================================
log "清理临时文件..."
rm -rf "$RESTORE_DIR"
# ============================================================
# 9. 验证恢复结果
# ============================================================
echo ""
log "恢复完成!验证容器状态..."
ssh txjp "docker ps --format 'table {{.Names}}\t{{.Status}}' | grep -E '(assets|issue|ldap|gitea|oa|nginx)'"

View File

@ -1,264 +0,0 @@
#!/bin/bash
# 统一恢复脚本 — 支持远程/本地模式
# 用法:
# bash restore.sh # 交互式站点恢复(默认)
# bash restore.sh 20260708_120000 # 指定时间戳
# bash restore.sh --db issue issue.db # 数据库恢复
# bash restore.sh --full backup.tar.gz # 灾难恢复
# bash restore.sh --local # 本地模式(直接在服务器上运行)
set -e
BACKUP_DIR="/Users/niuniu/programs/docker/db-backups"
REMOTE_BASE="/root/docker"
MODE="remote"
log() { echo "[$(date '+%H:%M:%S')] $1"; }
warn() { echo "[$(date '+%H:%M:%S')] [警告] $1"; }
error() { echo "[$(date '+%H:%M:%S')] [错误] $1"; exit 1; }
# 参数解析
ARGS=(); ACTION="default"
for arg in "$@"; do
case "$arg" in
--local) MODE="local" ;;
--db) ACTION="db" ;;
--full) ACTION="full" ;;
--help|-h) ACTION="help" ;;
*) ARGS+=("$arg") ;;
esac
done
# 远程/本地适配
runcmd() { [[ "$MODE" == "local" ]] && bash -c "$*" || ssh txjp "$*"; }
fetch() { [[ "$MODE" == "local" ]] && cp "$1" "$2" || scp "txjp:$1" "$2"; }
upload() { [[ "$MODE" == "local" ]] && cp "$1" "$2" || scp "$1" "txjp:$2"; }
RESTORE_DIR="$(mktemp -d)"
# === 自动发现可恢复站点 ===
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
ALL_SITES="shared"
for dir in "$PROJECT_ROOT"/*-ai/; do
s=$(basename "$dir"); [[ "$s" == "nginx-proxy-ai" ]] && continue
[ -f "$dir/docker-compose.yml" ] || continue
s="${s%-ai}"; ALL_SITES="$ALL_SITES $s"
done
# === 工具函数 ===
check_backup() {
local site="$1" ts="$2"
[[ -f "${BACKUP_DIR}/${site}-${ts}.tar.gz" ]] && echo "local:${BACKUP_DIR}/${site}-${ts}.tar.gz" && return
runcmd "test -f /root/docker/db-backups/${site}-${ts}.tar.gz" 2>/dev/null && echo "cloud:${site}-${ts}.tar.gz" && return
echo ""
}
download_and_extract() {
local site="$1" ts="$2"
local bk=$(check_backup "$site" "$ts")
[[ -z "$bk" ]] && error "${site} 备份文件不存在: ${ts}"
rm -rf "$RESTORE_DIR/backup-${site}"; mkdir -p "$RESTORE_DIR/backup-${site}"
if [[ "$bk" == local:* ]]; then
log "从本地解压: ${BACKUP_DIR}/${site}-${ts}.tar.gz"
tar xzf "${BACKUP_DIR}/${site}-${ts}.tar.gz" -C "$RESTORE_DIR/backup-${site}"
else
log "从服务器下载: ${ts}"
fetch "/root/docker/db-backups/${site}-${ts}.tar.gz" "$RESTORE_DIR/"
tar xzf "$RESTORE_DIR/${site}-${ts}.tar.gz" -C "$RESTORE_DIR/backup-${site}"
fi
}
restore_src() {
local src="${RESTORE_DIR}/backup-$1/backup-$1"
[[ -d "$src" ]] && echo "$src" || echo "${RESTORE_DIR}/backup-$1"
}
# === 站点恢复函数 ===
restore_shared() {
local src=$(restore_src "shared")
[[ -f "$src/nginx"/*.conf ]] && { mkdir -p "$src/nginx"; upload "$src/nginx/"* "$REMOTE_BASE/nginx-proxy-ai/conf.d/"; }
runcmd "docker exec nginx-ai nginx -t && docker exec nginx-ai nginx -s reload" 2>/dev/null || warn "nginx 重载失败"
log "共享配置恢复完成"
}
restore_assets() {
log "恢复 assets..."; download_and_extract "assets" "$TIMESTAMP"
local src=$(restore_src "assets")
for f in docker-compose.yml .env Dockerfile; do [[ -f "$src/$f" ]] && upload "$src/$f" "$REMOTE_BASE/assets-ai/"; done
[[ -d "$src/data/uploads" ]] && runcmd "cp -a $src/data/uploads /root/docker/assets-ai/data/"
[[ -f "$src/data/assets.db" ]] && runcmd "docker cp $src/data/assets.db assets-ai:/app/data/assets.db"
runcmd "cd $REMOTE_BASE/assets-ai && docker compose down 2>/dev/null || true && docker compose up -d"
log "assets 恢复完成"
}
restore_issue() {
log "恢复 issue..."; download_and_extract "issue" "$TIMESTAMP"
local src=$(restore_src "issue")
for f in docker-compose.yml .env Dockerfile; do [[ -f "$src/$f" ]] && upload "$src/$f" "$REMOTE_BASE/issue-ai/"; done
[[ -d "$src/data/uploads" ]] && runcmd "cp -a $src/data/uploads /root/docker/issue-ai/data/"
[[ -d "$src/data/reports" ]] && runcmd "cp -a $src/data/reports /root/docker/issue-ai/data/"
[[ -f "$src/data/issue.db" ]] && runcmd "docker cp $src/data/issue.db issue-ai:/app/data/issue.db"
runcmd "cd $REMOTE_BASE/issue-ai && docker compose down 2>/dev/null || true && docker compose up -d"
log "issue 恢复完成"
}
restore_ldap() {
log "恢复 ldap..."; download_and_extract "ldap" "$TIMESTAMP"
local src=$(restore_src "ldap")
for f in docker-compose.yml .env Dockerfile; do [[ -f "$src/$f" ]] && upload "$src/$f" "$REMOTE_BASE/ldap-ai/"; done
[[ -f "$src/data/users.db" ]] && runcmd "docker cp $src/data/users.db lldap:/data/users.db"
runcmd "cd $REMOTE_BASE/ldap-ai && docker compose down 2>/dev/null || true && docker compose up -d"
log "ldap 恢复完成"
}
restore_gitea() {
log "恢复 gitea..."; download_and_extract "gitea" "$TIMESTAMP"
local src=$(restore_src "gitea")
[[ -f "$src/docker-compose.yml" ]] && upload "$src/docker-compose.yml" "$REMOTE_BASE/gitea-ai/"
[[ -f "$src/data/gitea.db" ]] && runcmd "docker cp $src/data/gitea.db gitea-ai:/data/gitea.db"
runcmd "cd $REMOTE_BASE/gitea-ai && docker compose down && docker compose up -d"
log "gitea 恢复完成"
}
restore_oa() {
log "恢复 oa..."; download_and_extract "oa" "$TIMESTAMP"
local src=$(restore_src "oa")
for f in docker-compose.yml .env; do [[ -f "$src/$f" ]] && upload "$src/$f" "$REMOTE_BASE/oa-ai/"; done
runcmd "cd $REMOTE_BASE/oa-ai && docker compose down 2>/dev/null || true && docker compose up -d"
log "oa 恢复完成"
}
restore_monitor() {
log "恢复 monitor..."; download_and_extract "monitor" "$TIMESTAMP"
local src=$(restore_src "monitor")
for f in docker-compose.yml .env; do [[ -f "$src/$f" ]] && upload "$src/$f" "$REMOTE_BASE/monitor-ai/"; done
[[ -f "$src/data/monitor.db" ]] && runcmd "docker cp $src/data/monitor.db monitor-ai:/app/data/monitor.db"
runcmd "cd $REMOTE_BASE/monitor-ai && docker compose down 2>/dev/null || true && docker compose up -d --build"
log "monitor 恢复完成"
}
restore_generic() {
local site="$1"; log "恢复 ${site}(通用模式)..."
download_and_extract "${site}" "$TIMESTAMP"
local src=$(restore_src "${site}")
for f in docker-compose.yml .env Dockerfile; do [[ -f "$src/$f" ]] && upload "$src/$f" "$REMOTE_BASE/${site}-ai/"; done
runcmd "cd $REMOTE_BASE/${site}-ai && docker compose down 2>/dev/null || true && docker compose up -d" 2>/dev/null || warn "${site} 容器重启失败"
}
# === 模式 2数据库恢复 ===
restore_db() {
local service="$1" file="$2"
[[ -z "$service" || -z "$file" ]] && error "用法: bash restore.sh --db <assets|issue|ldap|gitea|monitor> <文件名>"
local container="" dbpath="" project=""
case "$service" in
assets) project="assets-ai"; container="assets-ai"; dbpath="/app/data/assets.db" ;;
issue) project="issue-ai"; container="issue-ai"; dbpath="/app/data/issue.db" ;;
ldap) project="ldap-ai"; container="lldap"; dbpath="/data/users.db" ;;
gitea) project="gitea-ai"; container="gitea-ai"; dbpath="/data/gitea.db" ;;
monitor) project="monitor-ai"; container="monitor-ai"; dbpath="/app/data/monitor.db" ;;
*) error "服务名必须是: assets, issue, ldap, gitea, monitor" ;;
esac
local src="$BACKUP_DIR/$file"; [[ ! -f "$src" ]] && src="$file"
log "恢复 ${service} 数据库: ${src}..."
upload "$src" "/tmp/db-restore.db"
runcmd "docker cp /tmp/db-restore.db ${container}:${dbpath} && rm /tmp/db-restore.db"
runcmd "cd $REMOTE_BASE/$project && docker compose restart" 2>/dev/null || true
log "${service} 数据库恢复完成"
}
# === 模式 3灾难恢复 ===
restore_full() {
local file="$1"; [[ -z "$file" ]] && error "用法: bash restore.sh --full <backup.tar.gz>"
local src="$BACKUP_DIR/$file"; [[ ! -f "$src" ]] && src="$file"
[[ ! -f "$src" ]] && error "备份文件不存在: $file"
log "完整恢复: $file (模式: ${MODE})"
rm -rf "$RESTORE_DIR/full"; mkdir -p "$RESTORE_DIR/full"
tar xzf "$src" -C "$RESTORE_DIR/full"
local content="$RESTORE_DIR/full/full-backup"
echo ""; echo " 1) assets 2) issue 3) ldap 4) gitea"
echo " 5) monitor 6) nginx 7) 全部恢复"
echo ""; printf "请选择 (1-7): "; read choice
fdb() { [[ -f "$content/databases/$1.db" ]] && upload "$content/databases/$1.db" "/tmp/${1}.db" && runcmd "docker cp /tmp/${1}.db ${2}:/app/data/${1}.db; rm /tmp/${1}.db"; }
local c="$content"
case "$choice" in
1) fdb assets assets-ai; runcmd "cd $REMOTE_BASE/assets-ai && docker compose down && docker compose up -d" ;;
2) fdb issue issue-ai; runcmd "cd $REMOTE_BASE/issue-ai && docker compose down && docker compose up -d" ;;
3) [[ -f "$c/databases/ldap.db" ]] && upload "$c/databases/ldap.db" "/tmp/ldap.db"; runcmd "docker cp /tmp/ldap.db lldap:/data/users.db; rm /tmp/ldap.db"; runcmd "cd $REMOTE_BASE/ldap-ai && docker compose down && docker compose up -d" ;;
4) fdb gitea gitea-ai; runcmd "cd $REMOTE_BASE/gitea-ai && docker compose down && docker compose up -d" ;;
5) fdb monitor monitor-ai; runcmd "cd $REMOTE_BASE/monitor-ai && docker compose down && docker compose up -d --build" ;;
6) [[ -d "$c/nginx" ]] && { mkdir -p "$c/nginx"; upload "$c/nginx/"* "$REMOTE_BASE/nginx-proxy-ai/conf.d/"; }; runcmd "docker exec nginx-ai nginx -t && docker exec nginx-ai nginx -s reload" ;;
7) for s in assets issue ldap gitea monitor; do fdb $s ${s}-ai 2>/dev/null || true; done
runcmd "docker exec nginx-ai nginx -t && docker exec nginx-ai nginx -s reload"
for s in assets-ai issue-ai ldap-ai gitea-ai monitor-ai; do runcmd "cd $REMOTE_BASE/$s && docker compose down && docker compose up -d" 2>/dev/null || true; done ;;
*) error "无效选择" ;;
esac
log "灾难恢复完成"
}
# === 主流程 ===
case "$ACTION" in
help)
echo "用法: bash restore.sh [--local] [--db|--full] [参数...]"
echo " --local 本地模式(直接在服务器上运行)"
echo " 无参数 交互式站点恢复"
echo " restore.sh TIMESTAMP 指定时间戳恢复"
echo " --db <site> <file> 数据库恢复"
echo " --full <file> 灾难恢复"
;;
db) restore_db "${ARGS[0]}" "${ARGS[1]}" ;;
full) restore_full "${ARGS[0]}" ;;
*)
TIMESTAMP="${ARGS[0]:-}"
if [[ -z "$TIMESTAMP" ]]; then
echo ""; echo "可用的备份时间戳:"; echo ""
timestamps=$(ls ${BACKUP_DIR}/assets-*.tar.gz 2>/dev/null | sed 's/.*assets-\(.*\)\.tar\.gz/\1/' | sort -r | head -10)
[[ -z "$timestamps" ]] && error "未找到任何备份文件"
declare -A ts_list; index=1
while IFS= read -r ts; do echo " $index) $ts"; ts_list[$index]="$ts"; ((index++)); done <<< "$timestamps"
echo ""; printf "请选择 (1-$((index-1))): "; read choice
TIMESTAMP="${ts_list[$choice]}"
[[ -z "$TIMESTAMP" ]] && error "无效选择"
fi
echo ""; echo "可恢复的站点:"; echo ""
declare -A site_list; index=1
for site in $ALL_SITES; do
bk=$(check_backup "$site" "$TIMESTAMP")
[[ -n "$bk" ]] && echo " $index) $site" && site_list[$index]="$site" || echo " -) $site (无备份)"
((index++))
done
echo ""; echo " a) 全部恢复"; printf "请选择 (1-$((index-1)) 或 a): "; read s_choice
declare -a restore_list
if [[ "$s_choice" == "a" ]]; then
for idx in "${!site_list[@]}"; do restore_list+=("${site_list[$idx]}"); done
else
restore_list=("${site_list[$s_choice]}")
[[ -z "${restore_list[0]}" ]] && error "无效选择或无备份"
fi
for site in "${restore_list[@]}"; do
case "$site" in
shared) restore_shared ;;
assets) restore_assets ;;
issue) restore_issue ;;
ldap) restore_ldap ;;
gitea) restore_gitea ;;
oa) restore_oa ;;
monitor) restore_monitor ;;
*) restore_generic "$site" ;;
esac
done
runcmd "docker exec nginx-ai nginx -t && docker exec nginx-ai nginx -s reload" 2>/dev/null || true
rm -rf "$RESTORE_DIR"
echo ""; log "恢复完成!"
runcmd "docker ps --format 'table {{.Names}}\t{{.Status}}' | grep -E '(assets|issue|ldap|gitea|oa|monitor|nginx|sso)'"
;;
esac

View File

@ -1,287 +0,0 @@
#!/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

View File

@ -43,7 +43,7 @@ bash scripts/deploy-ai.sh [选项]
5. **依赖安装**:服务器 `npm install --prefer-offline`
6. **环境变量**:写入生产环境所需的环境变量到 `.env`
7. **构建**:服务器 `npm run build`
8. **清理**:删除 Alpine musl 残留sharp、swc,清理 Docker 构建缓存(调用 `docker-cache-cleanup.sh`
8. **清理**:删除 Alpine musl 残留sharp、swc
9. **重启容器**`docker compose up -d` + `docker compose restart`
10. **快照保存**:保存本次源码 MD5 用于下次比对

File diff suppressed because it is too large Load Diff

89
deploy-monitor.sh Normal file
View File

@ -0,0 +1,89 @@
#!/bin/bash
# deploy-monitor.sh — 部署 monitor-ai 到云服务器
# 用法: bash deploy-monitor.sh [--remote --host <IP>] [--dev]
set -e
MODE="${1:-local}"
REMOTE_HOST=""
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
MONITOR_DIR="$PROJECT_DIR/monitor-ai"
SHARED_DIR="$PROJECT_DIR/shared"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
# 解析参数
while [[ $# -gt 0 ]]; do
case $1 in
--remote) MODE="remote"; shift ;;
--host) REMOTE_HOST="$2"; shift 2 ;;
--dev) MODE="dev"; shift ;;
*) shift ;;
esac
done
echo "=== deploy-monitor.sh === Mode: $MODE ==="
# 打包源码
echo "[1/5] 打包源码..."
TARBALL="/tmp/monitor-ai-${TIMESTAMP}.tar.gz"
cd "$PROJECT_DIR"
tar czf "$TARBALL" --exclude='node_modules' --exclude='.next' --exclude='data/*.db' \
shared/lib/ monitor-ai/
echo "[2/5] 上传..."
if [ "$MODE" = "remote" ]; then
if [ -z "$REMOTE_HOST" ]; then
echo "Error: --remote 模式需要 --host <IP>"
exit 1
fi
ssh "$REMOTE_HOST" "mkdir -p /root/docker"
scp "$TARBALL" "$REMOTE_HOST:/root/docker/"
ssh "$REMOTE_HOST" "cd /root/docker && tar xzf monitor-ai-${TIMESTAMP}.tar.gz && rm monitor-ai-${TIMESTAMP}.tar.gz"
else
# local 模式: 部署到当前服务器的 /root/docker/
sudo mkdir -p /root/docker
sudo cp "$TARBALL" /root/docker/
cd /root/docker
sudo tar xzf "monitor-ai-${TIMESTAMP}.tar.gz"
sudo rm "monitor-ai-${TIMESTAMP}.tar.gz"
fi
echo "[3/5] 构建..."
if [ "$MODE" = "remote" ]; then
ssh "$REMOTE_HOST" "cd /root/docker/monitor-ai && npm install && npm run build"
else
cd /root/docker/monitor-ai
sudo npm install
sudo npm run build
fi
echo "[4/5] 配置首次部署..."
if [ "$MODE" = "remote" ]; then
# 生成密钥
OIDC_SECRET=$(openssl rand -hex 32)
LOCALADMIN_PWD=$(openssl rand -hex 16)
ssh "$REMOTE_HOST" "cd /root/docker/monitor-ai && \
echo 'NODE_ENV=production' > .env && \
echo 'DATABASE_PATH=/data/monitor.db' >> .env && \
echo 'MONITOR_MODE=local' >> .env && \
echo 'AUTHELIA_URL=https://sso.tlyq.ai' >> .env && \
echo 'OIDC_CLIENT_ID=monitor-oidc' >> .env && \
echo 'OIDC_CLIENT_SECRET=${OIDC_SECRET}' >> .env && \
echo 'OIDC_REDIRECT_URI=https://monitor.tlyq.ai/api/auth/callback' >> .env && \
echo 'JWT_SECRET=<与全站一致>' >> .env && \
echo 'COOKIE_DOMAIN=.tlyq.ai' >> .env && \
echo 'LDAP_URL=ldap://ldap-ai:3890' >> .env && \
echo 'LOCALADMIN_PASSWORD=${LOCALADMIN_PWD}' >> .env && \
echo 'NODE_TLS_REJECT_UNAUTHORIZED=0' >> .env"
fi
echo "[5/5] 启动容器..."
if [ "$MODE" = "remote" ]; then
ssh "$REMOTE_HOST" "cd /root/docker/monitor-ai && docker compose up -d --build"
else
cd /root/docker/monitor-ai
sudo docker compose up -d --build
fi
echo "=== 部署完成 ==="
echo "monitor-ai: https://monitor.tlyq.ai"
echo "健康检查: curl https://monitor.tlyq.ai/api/health"

View File

@ -1,200 +0,0 @@
#!/bin/bash
# Docker 缓存清理脚本
# 用法:
# ./docker-cache-cleanup.sh # 默认清理(保留 3 小时内的缓存)
# ./docker-cache-cleanup.sh --aggressive # 激进清理(保留 1 小时内)
# ./docker-cache-cleanup.sh --conservative # 保守清理(保留 6 小时内)
# ./docker-cache-cleanup.sh --full # 完全清理(删除所有缓存)
set -euo pipefail
# ============================================================
# 配置
# ============================================================
REMOTE_HOST="${REMOTE_HOST:-txjp}"
CLEANUP_MODE="${1:---default}"
RETENTION_HOURS=3 # 默认保留 3 小时
# 生产环境服务器 IP 列表(用于判断是否在服务器本地运行)
# 新增服务器时,只需在此添加 IP 地址即可
PRODUCTION_SERVER_IPS=(
"43.133.38.210" # txjp (腾讯云北京)
# "159.75.83.209" # tgz (腾讯云广州) - 如需添加
# "10.0.0.1" # 其他服务器 - 如需添加
)
# 检测运行环境:
# 通过 curl ip.sb 获取当前公网 IP与生产环境服务器 IP 列表匹配
IS_LOCAL=false
# 获取当前公网 IP
CURRENT_IP=$(curl -s --connect-timeout 5 ip.sb 2>/dev/null || echo "")
if [ -n "$CURRENT_IP" ]; then
# 遍历生产环境服务器 IP 列表,检查是否匹配
for SERVER_IP in "${PRODUCTION_SERVER_IPS[@]}"; do
if [ "$CURRENT_IP" = "$SERVER_IP" ]; then
IS_LOCAL=true
break
fi
done
fi
# 如果是本地运行,不需要 SSH
if [ "$IS_LOCAL" = true ]; then
REMOTE_HOST=""
fi
# 颜色
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
# ============================================================
# 日志函数
# ============================================================
log() { echo -e "${GREEN}[$(date '+%H:%M:%S')]${NC} $*"; }
warn() { echo -e "${YELLOW}[$(date '+%H:%M:%S')] WARNING:${NC} $*"; }
error() { echo -e "${RED}[$(date '+%H:%M:%S')] ERROR:${NC} $*" >&2; }
# ============================================================
# 清理模式设置
# ============================================================
case "$CLEANUP_MODE" in
--aggressive)
RETENTION_HOURS=1
log "激进清理模式:保留 ${RETENTION_HOURS} 小时内的缓存"
;;
--conservative)
RETENTION_HOURS=6
log "保守清理模式:保留 ${RETENTION_HOURS} 小时内的缓存"
;;
--full)
log "完全清理模式:删除所有缓存"
RETENTION_HOURS=0
;;
*)
log "默认清理模式:保留 ${RETENTION_HOURS} 小时内的缓存"
;;
esac
# ============================================================
# 辅助函数
# ============================================================
# 执行命令(根据是否本地决定是否使用 SSH
run_cmd() {
if [ "$IS_LOCAL" = true ]; then
eval "$@"
else
ssh "$REMOTE_HOST" "$@"
fi
}
# 获取磁盘使用率
get_disk_usage() {
run_cmd "df / --output=pcent | tail -1 | tr -d ' %'" 2>/dev/null || echo "0"
}
# 获取构建缓存大小
get_cache_size() {
run_cmd "docker system df --format '{{.Size}}' 2>/dev/null | head -1" || echo "0"
}
# 获取镜像数量
get_image_count() {
run_cmd "docker images -q 2>/dev/null | wc -l" || echo "0"
}
# ============================================================
# 清理函数
# ============================================================
# 清理 dangling 镜像(无标签或未被引用的中间层)
cleanup_dangling_images() {
log "清理 dangling 镜像..."
run_cmd "docker image prune -f 2>/dev/null" || warn "docker image prune 失败"
}
# 清理测试镜像(以 test- 开头的镜像)
cleanup_test_images() {
log "清理测试镜像..."
local test_images=$(run_cmd "docker images --format '{{.Repository}}:{{.Tag}}' | grep -E '^test-' 2>/dev/null" || true)
if [ -n "$test_images" ]; then
echo "$test_images" | while read -r img; do
log " 删除: $img"
done
run_cmd "docker images --format '{{.Repository}}:{{.Tag}}' | grep -E '^test-' | xargs -r docker rmi -f 2>/dev/null" || true
else
log " 无测试镜像"
fi
}
# 清理构建缓存
cleanup_build_cache() {
if [ "$RETENTION_HOURS" -eq 0 ]; then
log "清理所有构建缓存..."
run_cmd "docker builder prune -af 2>/dev/null" || warn "docker builder prune 失败"
else
log "清理 ${RETENTION_HOURS} 小时前的构建缓存..."
run_cmd "docker builder prune -f --filter 'until=${RETENTION_HOURS}h' 2>/dev/null" || warn "docker builder prune 失败"
fi
}
# 根据磁盘使用率决定是否需要深度清理
cleanup_by_disk_usage() {
local DISK_USAGE=$(get_disk_usage)
log "当前磁盘使用率: ${DISK_USAGE}%"
if [ "$DISK_USAGE" -gt 85 ]; then
warn "磁盘使用率 ${DISK_USAGE}% > 85%,执行深度清理"
# 清理所有构建缓存
run_cmd "docker builder prune -af 2>/dev/null" || true
# 清理容器日志
log "截断容器日志..."
run_cmd "truncate -s 0 /var/lib/docker/containers/\$(docker ps -q)/\*-json.log 2>/dev/null" || true
elif [ "$DISK_USAGE" -gt 75 ]; then
cleanup_build_cache
else
log "磁盘使用率正常,仅执行常规清理"
cleanup_build_cache
fi
}
# ============================================================
# 打印清理结果
# ============================================================
print_result() {
local DISK_AFTER=$(get_disk_usage)
local CACHE_AFTER=$(get_cache_size)
local IMAGE_AFTER=$(get_image_count)
echo ""
log "========== 清理完成 =========="
log "磁盘使用率: ${DISK_AFTER}%"
log "构建缓存大小: ${CACHE_AFTER}"
log "镜像数量: ${IMAGE_AFTER}"
}
# ============================================================
# 主函数
# ============================================================
main() {
local DISK_BEFORE=$(get_disk_usage)
local CACHE_BEFORE=$(get_cache_size)
local IMAGE_BEFORE=$(get_image_count)
echo ""
log "========== Docker 缓存清理 =========="
log "清理前 - 磁盘使用率: ${DISK_BEFORE}%, 缓存大小: ${CACHE_BEFORE}, 镜像数量: ${IMAGE_BEFORE}"
echo ""
# 执行清理
cleanup_dangling_images
cleanup_test_images
cleanup_by_disk_usage
# 打印结果
print_result
}
# 执行主函数
main "$@"

View File

@ -6,7 +6,7 @@
#
# 用法bash sites-manage-cloud.sh [命令] [站点名...]
# 不加命令默认 status
# 站点名ldap www cloud token gitea issue assets oa nginx sso monitor
# 站点名ldap www cloud token gitea issue assets oa nginx
# ai上述全部、all同 ai
set -e
@ -48,41 +48,32 @@ run() {
BASE="/root/docker"
# 站点 → 目录
site_dir() {
case $1 in
ldap) echo "ldap-ai" ;;
www) echo "www-ai" ;;
cloud) echo "cloud-ai" ;;
token) echo "token-ai" ;;
gitea) echo "gitea-ai" ;;
issue) echo "issue-ai" ;;
assets) echo "assets-ai" ;;
oa) echo "oa-ai" ;;
nginx) echo "nginx-proxy-ai" ;;
sso) echo "sso-ai" ;;
monitor) echo "monitor-ai" ;;
esac
}
# 站点 → 目录 & 容器名
declare -A SITE_DIR
SITE_DIR[ldap]="ldap-ai"
SITE_DIR[www]="www-ai"
SITE_DIR[cloud]="cloud-ai"
SITE_DIR[token]="token-ai"
SITE_DIR[gitea]="gitea-ai"
SITE_DIR[issue]="issue-ai"
SITE_DIR[assets]="assets-ai"
SITE_DIR[oa]="oa-ai"
SITE_DIR[nginx]="nginx-proxy-ai"
SITE_DIR[sso]="sso-ai"
# 站点 → 容器名
site_container() {
case $1 in
ldap) echo "lldap" ;;
www) echo "www-ai" ;;
cloud) echo "cloud-ai" ;;
token) echo "token-ai" ;;
gitea) echo "gitea-ai" ;;
issue) echo "issue-ai" ;;
assets) echo "assets-ai" ;;
oa) echo "oa-ai" ;;
nginx) echo "nginx-ai" ;;
sso) echo "authelia" ;;
monitor) echo "monitor-ai" ;;
esac
}
declare -A SITE_CONTAINER
SITE_CONTAINER[ldap]="lldap"
SITE_CONTAINER[www]="www-ai"
SITE_CONTAINER[cloud]="cloud-ai"
SITE_CONTAINER[token]="token-ai"
SITE_CONTAINER[gitea]="gitea-ai"
SITE_CONTAINER[issue]="issue-ai"
SITE_CONTAINER[assets]="assets-ai"
SITE_CONTAINER[oa]="oa-ai"
SITE_CONTAINER[nginx]="nginx-ai"
SITE_CONTAINER[sso]="sso-ai"
AI_SITES="ldap www cloud token gitea issue assets oa nginx sso monitor"
AI_SITES="ldap www cloud token gitea issue assets oa nginx"
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; RED='\033[0;31m'; NC='\033[0m'
log() { printf "${GREEN}[✓]${NC} %s\n" "$1"; }
@ -121,8 +112,8 @@ expand_sites() {
# 操作
# ============================================================
remote_start() {
local name=$1 dir="${BASE}/$(site_dir $name)"
local container="$(site_container $name)"
local name=$1 dir="${BASE}/${SITE_DIR[$name]}"
local container="${SITE_CONTAINER[$name]}"
if run "docker ps --format '{{.Names}}' | grep -q '^${container}$'" 2>/dev/null; then
warn "$name 已在运行"
@ -133,8 +124,8 @@ remote_start() {
}
remote_stop() {
local name=$1 dir="${BASE}/$(site_dir $name)"
local container="$(site_container $name)"
local name=$1 dir="${BASE}/${SITE_DIR[$name]}"
local container="${SITE_CONTAINER[$name]}"
if ! run "docker ps --format '{{.Names}}' | grep -q '^${container}$'" 2>/dev/null; then
warn "$name 未在运行"
@ -153,7 +144,7 @@ remote_status() {
echo ""
for name in "${targets[@]}"; do
local container="$(site_container $name)"
local container="${SITE_CONTAINER[$name]}"
local status
status=$(run "docker ps --format '{{.Status}}' -f name=^${container}$" 2>/dev/null || echo "")
if [ -n "$status" ]; then
@ -187,8 +178,6 @@ show_help() {
echo " assets 设备资产管理"
echo " oa OA 统一门户"
echo " nginx 反向代理"
echo " sso Authelia OIDC 认证"
echo " monitor 告警监控中心"
echo " ai 全部 tlyq.ai 站点"
echo ""
echo "示例:"
@ -205,9 +194,9 @@ show_help() {
ACTION=${1:-status}
shift || true
TARGETS=($(expand_sites "$@"))
mapfile -t TARGETS < <(expand_sites "$@")
if [ ${#TARGETS[@]} -eq 0 ]; then
TARGETS=($(expand_sites ai))
mapfile -t TARGETS < <(expand_sites ai)
fi
# 检查 Docker 服务help 命令除外)

View File

@ -5,7 +5,7 @@
#
# 站点名:
# cc: www-cc cloud-cc token-cc
# ai: www-ai cloud-ai token-ai issue assets ldap oa sso gitea monitor
# ai: www-ai cloud-ai token-ai issue assets ldap
# all: 全部站点
set -e
@ -16,8 +16,7 @@ ASSETS_DIR="$BASE_DIR/assets-ai"
LDAP_DIR="$BASE_DIR/ldap-ai"
OA_DIR="$BASE_DIR/oa-ai"
GITEA_DIR="$BASE_DIR/gitea-ai"
SSO_DIR="$BASE_DIR/sso-ai"
MONITOR_DIR="$BASE_DIR/monitor-ai"
OA_PORT=6179
# 颜色
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; RED='\033[0;31m'; NC='\033[0m'
@ -26,21 +25,13 @@ warn() { printf "${YELLOW}[!]${NC} %s\n" "$1"; }
err() { printf "${RED}[✗]${NC} %s\n" "$1"; }
# 端口
WWW_AI_PORT=6173
CLOUD_AI_PORT=6174
TOKEN_AI_PORT=6175
ISSUE_PORT=6176
ASSETS_PORT=6177
LDAP_WEB_PORT=6178
OA_PORT=6179
SSO_PORT=6180
MONITOR_PORT=6181
WWW_CC_PORT=5173
CLOUD_CC_PORT=5174
TOKEN_CC_PORT=5175
GITEA_PORT=6182
GITEA_PORT=3000
is_port() { lsof -i :"$1" >/dev/null 2>&1; }
is_running() { docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^$1$"; }
# 检查 Docker 服务是否运行
check_docker() {
@ -55,21 +46,16 @@ check_docker() {
# ============================================================
docker_start() {
local name=$1 dir=$2
if [ "$(cd "$dir" && docker compose ps -q --status running 2>/dev/null)" ]; then
if is_running $name; then
warn "$name 已在运行"
else
log "启动 $name..."
cd "$dir" && docker compose up -d 2>/dev/null || { err "$name 启动失败"; return 1; }
for i in $(seq 1 30); do
[ "$(cd "$dir" && docker compose ps -q --status running 2>/dev/null)" ] && { log "$name 已启动"; return 0; }
sleep 1
done
err "$name 启动异常(可能处于重启循环,请检查 docker compose logs"
cd "$dir" && docker compose up -d 2>/dev/null && log "$name 已启动" || err "$name 启动失败"
fi
}
docker_stop() {
local name=$1 dir=$2
if [ "$(cd "$dir" && docker compose ps -q --status running --status restarting 2>/dev/null)" ]; then
if is_running $name; then
log "停止 $name..."
cd "$dir" && docker compose down 2>/dev/null && log "$name 已停止" || err "$name 停止失败"
else
@ -106,28 +92,26 @@ node_stop() {
start_all() {
for name in "$@"; do
case $name in
www-cc) docker_start www-cc "$BASE_DIR/cc-site/www-cc" ;;
cloud-cc) docker_start cloud-cc "$BASE_DIR/cc-site/cloud-cc" ;;
token-cc) docker_start token-cc "$BASE_DIR/cc-site/token-cc" ;;
www-ai) docker_start www-ai "$BASE_DIR/www-ai" ;;
www-cc) docker_start www-local "$BASE_DIR/www-cc" ;;
cloud-cc) docker_start cloud-cc "$BASE_DIR/cloud-cc" ;;
token-cc) docker_start token-cc "$BASE_DIR/token-cc" ;;
www-ai) docker_start www-local-ai "$BASE_DIR/www-ai" ;;
cloud-ai) docker_start cloud-ai "$BASE_DIR/cloud-ai" ;;
token-ai) docker_start token-ai "$BASE_DIR/token-ai" ;;
issue) node_start issue "$ISSUE_DIR" $ISSUE_PORT "/tmp/issue-ai-dev.log"
issue) node_start issue-ai "$ISSUE_DIR" $ISSUE_PORT "/tmp/issue-ai-dev.log"
if ! is_port $ASSETS_PORT; then
node_start assets "$ASSETS_DIR" $ASSETS_PORT "/tmp/assets-ai-dev.log"
node_start assets-ai "$ASSETS_DIR" $ASSETS_PORT "/tmp/assets-ai-dev.log"
warn "已自动启动 assets-aiissue 依赖 assets API"
fi ;;
assets) node_start assets "$ASSETS_DIR" $ASSETS_PORT "/tmp/assets-ai-dev.log" ;;
ldap) docker_start ldap "$LDAP_DIR"
assets) node_start assets-ai "$ASSETS_DIR" $ASSETS_PORT "/tmp/assets-ai-dev.log" ;;
ldap) docker_start lldap "$LDAP_DIR"
if is_port $LDAP_WEB_PORT; then
log "LLDAP Web UI: http://127.0.0.1:$LDAP_WEB_PORT"
fi ;;
oa) node_start oa "$OA_DIR" $OA_PORT "/tmp/oa-ai-dev.log" ;;
sso) docker_start sso "$SSO_DIR" ;;
gitea) docker_start gitea "$GITEA_DIR" ;;
monitor) node_start monitor "$MONITOR_DIR" $MONITOR_PORT "/tmp/monitor-ai-dev.log" ;;
oa) node_start oa-ai "$OA_DIR" $OA_PORT "/tmp/oa-ai-dev.log" ;;
gitea) docker_start gitea-ai "$GITEA_DIR" ;;
cc) start_all www-cc cloud-cc token-cc ;;
ai) start_all www-ai cloud-ai token-ai issue assets ldap oa sso monitor gitea ;;
ai) start_all ldap www-ai cloud-ai token-ai issue assets oa gitea ;;
all) start_all cc ai ;;
*) err "未知站点: $name" ;;
esac
@ -137,21 +121,19 @@ start_all() {
stop_all() {
for name in "$@"; do
case $name in
www-cc) docker_stop www-cc "$BASE_DIR/cc-site/www-cc" ;;
cloud-cc) docker_stop cloud-cc "$BASE_DIR/cc-site/cloud-cc" ;;
token-cc) docker_stop token-cc "$BASE_DIR/cc-site/token-cc" ;;
www-ai) docker_stop www-ai "$BASE_DIR/www-ai" ;;
www-cc) docker_stop www-local "$BASE_DIR/www-cc" ;;
cloud-cc) docker_stop cloud-cc "$BASE_DIR/cloud-cc" ;;
token-cc) docker_stop token-cc "$BASE_DIR/token-cc" ;;
www-ai) docker_stop www-local-ai "$BASE_DIR/www-ai" ;;
cloud-ai) docker_stop cloud-ai "$BASE_DIR/cloud-ai" ;;
token-ai) docker_stop token-ai "$BASE_DIR/token-ai" ;;
issue) node_stop issue $ISSUE_PORT ;;
assets) node_stop assets $ASSETS_PORT ;;
ldap) docker_stop ldap "$LDAP_DIR" ;;
oa) node_stop oa $OA_PORT ;;
sso) docker_stop sso "$SSO_DIR" ;;
gitea) docker_stop gitea "$GITEA_DIR" ;;
monitor) node_stop monitor $MONITOR_PORT ;;
issue) node_stop issue-ai $ISSUE_PORT ;;
assets) node_stop assets-ai $ASSETS_PORT ;;
ldap) docker_stop lldap "$LDAP_DIR" ;;
oa) node_stop oa-ai $OA_PORT ;;
gitea) docker_stop gitea-ai "$GITEA_DIR" ;;
cc) stop_all www-cc cloud-cc token-cc ;;
ai) stop_all www-ai cloud-ai token-ai issue assets ldap oa sso monitor gitea ;;
ai) stop_all ldap www-ai cloud-ai token-ai issue assets oa gitea ;;
all) stop_all cc ai ;;
*) err "未知站点: $name" ;;
esac
@ -174,9 +156,9 @@ show_status() {
local expanded=()
for t in "${targets[@]}"; do
case $t in
all) eval "expanded=(www-cc cloud-cc token-cc www-ai cloud-ai token-ai issue assets ldap oa sso monitor gitea)" ;;
all) eval "expanded=(www-cc cloud-cc token-cc ldap www-ai cloud-ai token-ai issue assets oa gitea)" ;;
cc) eval "expanded=(\${expanded[@]} www-cc cloud-cc token-cc)" ;;
ai) eval "expanded=(\${expanded[@]} www-ai cloud-ai token-ai issue assets ldap oa sso monitor gitea)" ;;
ai) eval "expanded=(\${expanded[@]} ldap www-ai cloud-ai token-ai issue assets oa gitea)" ;;
*) expanded+=($t) ;;
esac
done
@ -187,51 +169,69 @@ show_status() {
printf "${CYAN}=========================================${NC}\n"
echo ""
# 获取站点对应端口
get_port() {
case $1 in
www-cc) echo $WWW_CC_PORT ;;
cloud-cc) echo $CLOUD_CC_PORT ;;
token-cc) echo $TOKEN_CC_PORT ;;
www-ai) echo $WWW_AI_PORT ;;
cloud-ai) echo $CLOUD_AI_PORT ;;
token-ai) echo $TOKEN_AI_PORT ;;
issue) echo $ISSUE_PORT ;;
assets) echo $ASSETS_PORT ;;
ldap) echo $LDAP_WEB_PORT ;;
oa) echo $OA_PORT ;;
sso) echo $SSO_PORT ;;
gitea) echo $GITEA_PORT ;;
monitor) echo $MONITOR_PORT ;;
esac
}
local running=0
for name in "${expanded[@]}"; do
case $name in
www-cc|cloud-cc|token-cc|cloud-ai|token-ai)
if is_running $name; then
printf " ${GREEN}${NC} %-12s 运行中\n" "$name"
running=$((running + 1))
else
printf " ${RED}${NC} %-12s 未运行\n" "$name"
fi
;;
www-ai)
if is_running www-local-ai; then
printf " ${GREEN}${NC} %-12s 运行中\n" "$name"
running=$((running + 1))
else
printf " ${RED}${NC} %-12s 未运行\n" "$name"
fi
;;
issue)
if is_port $ISSUE_PORT; then
printf " ${GREEN}${NC} %-12s 运行中 (port %d)\n" "$name" $ISSUE_PORT
running=$((running + 1))
else
printf " ${RED}${NC} %-12s 未运行\n" "$name"
fi
;;
ldap)
if is_running lldap; then
printf " ${GREEN}${NC} %-12s 运行中 (port %d)\n" "$name" $LDAP_WEB_PORT
running=$((running + 1))
else
printf " ${RED}${NC} %-12s 未运行\n" "$name"
fi
;;
assets)
if is_port $ASSETS_PORT; then
printf " ${GREEN}${NC} %-12s 运行中 (port %d)\n" "$name" $ASSETS_PORT
running=$((running + 1))
else
printf " ${RED}${NC} %-12s 未运行\n" "$name"
fi
;;
oa)
if is_port $OA_PORT; then
printf " ${GREEN}${NC} %-12s 运行中 (port %d)\n" "$name" $OA_PORT
running=$((running + 1))
else
printf " ${RED}${NC} %-12s 未运行\n" "$name"
fi
;;
gitea)
if is_running gitea-ai; then
printf " ${GREEN}${NC} %-12s 运行中 (port %d)\n" "$name" $GITEA_PORT
running=$((running + 1))
else
printf " ${RED}${NC} %-12s 未运行\n" "$name"
fi
;;
esac
done
echo ""
# 按 cc/ai 分组输出
_status_group() {
local label=$1; shift
local group=() gname n
for gname in "$@"; do
for n in "${expanded[@]}"; do
[ "$n" = "$gname" ] && group+=("$n") && break
done
done
[ ${#group[@]} -eq 0 ] && return
printf " ${CYAN}%s站点:${NC}\n" "$label"
for gname in "${group[@]}"; do
local port=$(get_port $gname)
if [ -n "$port" ] && is_port $port; then
printf " ${GREEN}${NC} %-12s 运行中 (port %d)\n" "$gname" $port
running=$((running + 1))
else
printf " ${RED}${NC} %-12s 未运行\n" "$gname"
fi
done
echo ""
}
_status_group "cc " www-cc cloud-cc token-cc
_status_group "ai " www-ai cloud-ai token-ai issue assets ldap oa sso monitor gitea
printf "${CYAN}=========================================${NC}\n"
printf "${CYAN} 访问地址${NC}\n"
printf "${CYAN}=========================================${NC}\n"
@ -245,12 +245,10 @@ show_status() {
echo " www-ai http://127.0.0.1:6173"
echo " cloud-ai http://127.0.0.1:6174"
echo " token-ai http://127.0.0.1:6175"
echo " issue http://127.0.0.1:$ISSUE_PORT"
echo " assets http://127.0.0.1:$ASSETS_PORT"
echo " ldap http://127.0.0.1:$LDAP_WEB_PORT (LLDAP Web UI)"
echo " oa http://127.0.0.1:$OA_PORT"
echo " sso http://127.0.0.1:$SSO_PORT (Authelia OIDC)"
echo " monitor http://127.0.0.1:$MONITOR_PORT"
echo " issue http://127.0.0.1:$ISSUE_PORT"
echo " assets http://127.0.0.1:$ASSETS_PORT"
echo " gitea http://127.0.0.1:$GITEA_PORT"
echo ""
}
@ -270,9 +268,9 @@ show_help() {
echo ""
echo " 站点名(可指定多个,空格分隔):"
echo " www-cc cloud-cc token-cc"
echo " ldap www-ai cloud-ai token-ai issue assets oa sso gitea monitor"
echo " ldap www-ai cloud-ai token-ai issue assets oa gitea"
echo " cc — tlyq.cc 全部 (www-cc cloud-cc token-cc)"
echo " ai — tlyq.ai 全部 (ldap www-ai cloud-ai token-ai issue assets oa sso gitea monitor)"
echo " ai — tlyq.ai 全部 (ldap www-ai cloud-ai token-ai issue assets oa gitea)"
echo " all — 全部站点"
echo ""
echo "示例:"

View File

@ -1,291 +0,0 @@
#!/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) })