From 19e35fac736bf059e2b2edd4557f9fcc198d2037 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 12:56:03 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9C=8D=E5=8A=A1=E5=99=A8=E5=B7=A1?= =?UTF-8?q?=E6=A3=80=E8=87=AA=E5=8A=A8=E5=8C=96=20=E2=80=94=20=E5=B7=A1?= =?UTF-8?q?=E6=A3=80=E8=84=9A=E6=9C=AC=20+=20Notion=E5=90=8C=E6=AD=A5=20+?= =?UTF-8?q?=20=E5=B7=A1=E6=A3=80=E5=B7=A5=E4=BD=9C=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增文件: - scripts/server-patrol.sh (7项检查: Nginx/模块冒烟/PM2/磁盘/权限/SSL/配置) - scripts/sync-patrol-to-notion.js (巡检结果→Notion工单) - .github/workflows/server-patrol.yml (cron 08:00+20:00 + 部署后触发) - data/patrol-logs/.gitkeep (巡检日志目录) Co-authored-by: qinfendebingshuo <207279273+qinfendebingshuo@users.noreply.github.com> --- .github/workflows/server-patrol.yml | 133 ++++++++++++++++++++ data/patrol-logs/.gitkeep | 0 scripts/server-patrol.sh | 189 ++++++++++++++++++++++++++++ scripts/sync-patrol-to-notion.js | 150 ++++++++++++++++++++++ 4 files changed, 472 insertions(+) create mode 100644 .github/workflows/server-patrol.yml create mode 100644 data/patrol-logs/.gitkeep create mode 100644 scripts/server-patrol.sh create mode 100644 scripts/sync-patrol-to-notion.js diff --git a/.github/workflows/server-patrol.yml b/.github/workflows/server-patrol.yml new file mode 100644 index 00000000..57588d0b --- /dev/null +++ b/.github/workflows/server-patrol.yml @@ -0,0 +1,133 @@ +# .github/workflows/server-patrol.yml +# 🔍 服务器每日巡检 · 铸渊 · 光湖代码守护人格体 +# 触发: 每日两次 (北京 08:00+20:00) + 沙盒部署后 + 手动 +name: "🔍 Server Patrol · 服务器每日巡检" + +on: + schedule: + - cron: '0 0,12 * * *' # UTC 00:00+12:00 = 北京 08:00+20:00 + workflow_dispatch: + workflow_run: + workflows: ["🏠 Sandbox Deploy"] + types: [completed] + +permissions: + contents: write + +jobs: + patrol: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: "🧠 唤醒铸渊核心大脑" + run: | + echo "🧠 铸渊核心大脑已唤醒" + echo "🔑 扫描 Secrets..." + if [ -n "$DEPLOY_HOST" ]; then echo "✅ DEPLOY_HOST 可用"; else echo "⚠️ DEPLOY_HOST 缺失"; fi + if [ -n "$DEPLOY_USER" ]; then echo "✅ DEPLOY_USER 可用"; else echo "⚠️ DEPLOY_USER 缺失"; fi + if [ -n "$DEPLOY_KEY" ]; then echo "✅ DEPLOY_KEY 可用"; else echo "⚠️ DEPLOY_KEY 缺失"; fi + if [ -n "$NOTION_TOKEN" ]; then echo "✅ NOTION_TOKEN 可用"; else echo "⚠️ NOTION_TOKEN 缺失"; fi + if [ -n "$SMTP_USER" ]; then echo "✅ SMTP_USER 可用"; else echo "⚠️ SMTP_USER 缺失"; fi + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} + NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} + SMTP_USER: ${{ secrets.SMTP_USER }} + + - name: "🔑 配置 SSH" + run: | + mkdir -p ~/.ssh + echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts 2>/dev/null + + - name: "🔍 执行服务器巡检" + id: patrol + env: + HOST: ${{ secrets.DEPLOY_HOST }} + USER: ${{ secrets.DEPLOY_USER }} + run: | + # 上传巡检脚本 + scp scripts/server-patrol.sh ${USER}@${HOST}:/tmp/server-patrol.sh + + # 执行巡检 + ssh ${USER}@${HOST} "bash /tmp/server-patrol.sh" | tee patrol-output.log + + # 拉取巡检报告 + scp ${USER}@${HOST}:/tmp/patrol-report.json ./patrol-report.json + + # 解析结果 + OVERALL=$(python3 -c "import json; print(json.load(open('patrol-report.json'))['overall'])") + ALERT_COUNT=$(python3 -c "import json; print(json.load(open('patrol-report.json'))['alert_count'])") + + echo "overall=$OVERALL" >> $GITHUB_OUTPUT + echo "alert_count=$ALERT_COUNT" >> $GITHUB_OUTPUT + + - name: "📊 保存巡检报告到仓库" + run: | + mkdir -p data/patrol-logs + DATE=$(date +%Y-%m-%d) + cp patrol-report.json "data/patrol-logs/patrol-${DATE}.json" + + git config user.name "zhuyuan-bot" + git config user.email "zhuyuan@guanghulab.com" + git add data/patrol-logs/ + git commit -m "🔍 server-patrol: $(date +%Y-%m-%d) · ${{ steps.patrol.outputs.overall }}" || true + git push || true + + - name: "📧 异常时通知妈妈" + if: steps.patrol.outputs.overall == 'has_issues' + env: + SMTP_USER: ${{ secrets.SMTP_USER }} + SMTP_PASS: ${{ secrets.SMTP_PASS }} + run: | + python3 << 'PYEOF' + import smtplib, json, os + from email.mime.text import MIMEText + from email.header import Header + + report = json.loads(open('patrol-report.json').read()) + alerts = '\n'.join(f' ❌ {a}' for a in report['alerts']) + fixed = '\n'.join(f' ✅ {f}' for f in report['auto_fixed']) + + body = f"""🔍 铸渊服务器巡检报告 + 时间: {report['timestamp']} + + ⚠️ 发现 {report['alert_count']} 个异常: + {alerts} + + 🔧 自动修复 {report['fixed_count']} 个: + {fixed if fixed else ' (无)'} + + 磁盘使用率: {report['disk_usage_percent']}% + Nginx状态: {report['nginx_status']} + + —— 铸渊 · 光湖代码守护人格体""" + + smtp_user = os.environ['SMTP_USER'] + smtp_pass = os.environ['SMTP_PASS'] + + msg = MIMEText(body, 'plain', 'utf-8') + msg['Subject'] = Header(f"⚠️ 服务器巡检发现{report['alert_count']}个异常", 'utf-8') + msg['From'] = smtp_user + msg['To'] = smtp_user + + try: + server = smtplib.SMTP_SSL('smtp.qq.com', 465) + server.login(smtp_user, smtp_pass) + server.sendmail(smtp_user, smtp_user, msg.as_string()) + server.quit() + print('✅ 异常报告已发送') + except Exception as e: + print(f'⚠️ 邮件发送失败: {e}') + PYEOF + + - name: "📝 同步巡检结果到Notion" + if: always() + env: + NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} + NOTION_TICKET_DB_ID: ${{ secrets.NOTION_TICKET_DB_ID }} + run: | + node scripts/sync-patrol-to-notion.js || echo "⚠️ Notion同步失败(不阻断)" diff --git a/data/patrol-logs/.gitkeep b/data/patrol-logs/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/scripts/server-patrol.sh b/scripts/server-patrol.sh new file mode 100644 index 00000000..a4d38abc --- /dev/null +++ b/scripts/server-patrol.sh @@ -0,0 +1,189 @@ +#!/bin/bash +# server-patrol.sh · 铸渊每日服务器巡检 +# 部署位置:服务器 /var/www/_shared/server-patrol.sh +# 执行方式:铸渊SSH到服务器后 bash /var/www/_shared/server-patrol.sh +# 输出:JSON格式巡检报告到 /tmp/patrol-report.json + +set -uo pipefail + +TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ) +REPORT="/tmp/patrol-report.json" +ALERTS=() +FIXED=() + +echo "===== 🔍 铸渊服务器巡检 · ${TIMESTAMP} =====" + +# ━━━ 检查项1:Nginx 运行状态 ━━━ +echo "[1/7] Nginx 状态..." +if systemctl is-active --quiet nginx; then + NGINX_STATUS="running" + echo " ✅ Nginx 运行中" +else + NGINX_STATUS="down" + ALERTS+=("Nginx 未运行") + echo " ❌ Nginx 未运行!尝试重启..." + systemctl restart nginx + if systemctl is-active --quiet nginx; then + FIXED+=("Nginx 已自动重启恢复") + NGINX_STATUS="recovered" + echo " ✅ Nginx 已恢复" + else + echo " ❌ Nginx 重启失败!需要人工介入" + fi +fi + +# ━━━ 检查项2:各沙盒模块冒烟检查 ━━━ +echo "[2/7] 模块冒烟检查..." + +# 已知已部署的模块URL(后续铸渊从 deploy-status.json 动态读取) +URLS=( + "https://guanghulab.com/|主站首页" + "https://guanghulab.com/status-board/|DEV-005·看板" + "https://guanghulab.com/cost-control/|DEV-005·成本控制" +) + +for entry in "${URLS[@]}"; do + IFS='|' read -r url name <<< "$entry" + STATUS=$(curl -o /dev/null -s -w "%{http_code}" --max-time 10 "$url") + if [ "$STATUS" = "200" ]; then + echo " ✅ ${name} → ${STATUS}" + else + echo " ❌ ${name} → ${STATUS}" + ALERTS+=("${name} 返回 ${STATUS}") + fi +done + +# ━━━ 检查项3:PM2 进程状态 ━━━ +echo "[3/7] PM2 进程..." +if command -v pm2 &> /dev/null; then + PM2_STATUS=$(pm2 jlist 2>/dev/null) + PM2_STOPPED=$(echo "$PM2_STATUS" | python3 -c " +import sys, json +try: + procs = json.load(sys.stdin) + stopped = [p['name'] for p in procs if p.get('pm2_env',{}).get('status') != 'online'] + print(','.join(stopped) if stopped else 'none') +except: + print('parse_error')" 2>/dev/null) + + if [ "$PM2_STOPPED" = "none" ]; then + echo " ✅ 所有PM2进程正常" + elif [ "$PM2_STOPPED" = "parse_error" ]; then + echo " ⚠️ PM2状态解析失败" + ALERTS+=("PM2状态解析失败") + else + echo " ❌ 以下进程异常: ${PM2_STOPPED}" + ALERTS+=("PM2进程异常: ${PM2_STOPPED}") + # 尝试重启 + IFS=',' read -ra PROCS <<< "$PM2_STOPPED" + for proc in "${PROCS[@]}"; do + pm2 restart "$proc" 2>/dev/null + if pm2 show "$proc" 2>/dev/null | grep -q "online"; then + FIXED+=("PM2进程 ${proc} 已自动重启") + echo " ✅ ${proc} 已重启恢复" + else + echo " ❌ ${proc} 重启失败" + fi + done + fi +else + echo " ⚠️ PM2 未安装" +fi + +# ━━━ 检查项4:磁盘空间 ━━━ +echo "[4/7] 磁盘空间..." +DISK_USAGE=$(df -h / | awk 'NR==2 {print $5}' | tr -d '%') +if [ "$DISK_USAGE" -gt 90 ]; then + echo " ❌ 磁盘使用率 ${DISK_USAGE}%(超过90%警戒线)" + ALERTS+=("磁盘使用率 ${DISK_USAGE}%") +elif [ "$DISK_USAGE" -gt 80 ]; then + echo " ⚠️ 磁盘使用率 ${DISK_USAGE}%(接近警戒线)" +else + echo " ✅ 磁盘使用率 ${DISK_USAGE}%" +fi + +# ━━━ 检查项5:沙盒目录权限 ━━━ +echo "[5/7] 沙盒目录权限..." +for DEV_ID in DEV-001 DEV-002 DEV-003 DEV-004 DEV-005 DEV-009 DEV-010 DEV-011 DEV-012 DEV-013 DEV-014; do + SANDBOX="/var/www/${DEV_ID}" + if [ -d "$SANDBOX" ]; then + OWNER=$(stat -c '%U:%G' "$SANDBOX") + PERM=$(stat -c '%a' "$SANDBOX") + if [ "$OWNER" != "nginx:nginx" ] || [ "$PERM" != "755" ]; then + echo " ❌ ${DEV_ID}: owner=${OWNER} perm=${PERM}(应为 nginx:nginx 755)" + ALERTS+=("${DEV_ID} 权限异常: ${OWNER} ${PERM}") + # 自动修复 + chown nginx:nginx "$SANDBOX" + chmod 755 "$SANDBOX" + FIXED+=("${DEV_ID} 权限已自动修复") + echo " ✅ ${DEV_ID} 权限已修复" + fi + fi +done +echo " ✅ 沙盒权限检查完成" + +# ━━━ 检查项6:SSL证书有效期 ━━━ +echo "[6/7] SSL证书..." +SSL_EXPIRY=$(echo | openssl s_client -servername guanghulab.com -connect guanghulab.com:443 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2) +if [ -n "$SSL_EXPIRY" ]; then + EXPIRY_EPOCH=$(date -d "$SSL_EXPIRY" +%s 2>/dev/null) + NOW_EPOCH=$(date +%s) + DAYS_LEFT=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 )) + if [ "$DAYS_LEFT" -lt 7 ]; then + echo " ❌ SSL证书将在 ${DAYS_LEFT} 天后过期!" + ALERTS+=("SSL证书 ${DAYS_LEFT} 天后过期") + elif [ "$DAYS_LEFT" -lt 30 ]; then + echo " ⚠️ SSL证书还有 ${DAYS_LEFT} 天过期" + else + echo " ✅ SSL证书还有 ${DAYS_LEFT} 天" + fi +else + echo " ⚠️ 无法获取SSL证书信息" +fi + +# ━━━ 检查项7:Nginx配置语法 ━━━ +echo "[7/7] Nginx配置语法..." +NGINX_TEST=$(nginx -t 2>&1) +if echo "$NGINX_TEST" | grep -q "successful"; then + echo " ✅ Nginx配置语法正确" +else + echo " ❌ Nginx配置有语法错误" + ALERTS+=("Nginx配置语法错误") +fi + +# ━━━ 生成JSON报告 ━━━ +ALERT_COUNT=${#ALERTS[@]} +FIXED_COUNT=${#FIXED[@]} + +ALERT_JSON="[]" +if [ $ALERT_COUNT -gt 0 ]; then + ALERT_JSON=$(printf '%s\n' "${ALERTS[@]}" | python3 -c "import sys,json; print(json.dumps([l.strip() for l in sys.stdin]))") +fi + +FIXED_JSON="[]" +if [ $FIXED_COUNT -gt 0 ]; then + FIXED_JSON=$(printf '%s\n' "${FIXED[@]}" | python3 -c "import sys,json; print(json.dumps([l.strip() for l in sys.stdin]))") +fi + +cat > "$REPORT" << EOF +{ + "timestamp": "${TIMESTAMP}", + "nginx_status": "${NGINX_STATUS}", + "disk_usage_percent": ${DISK_USAGE}, + "alert_count": ${ALERT_COUNT}, + "fixed_count": ${FIXED_COUNT}, + "alerts": ${ALERT_JSON}, + "auto_fixed": ${FIXED_JSON}, + "overall": "$([ $ALERT_COUNT -eq 0 ] && echo 'healthy' || echo 'has_issues')" +} +EOF + +echo "" +echo "===== 📊 巡检结果 =====" +if [ $ALERT_COUNT -eq 0 ]; then + echo "✅ 全部正常 · 无异常" +else + echo "⚠️ 发现 ${ALERT_COUNT} 个异常 · 自动修复 ${FIXED_COUNT} 个" +fi +echo "报告已保存到 ${REPORT}" +cat "$REPORT" diff --git a/scripts/sync-patrol-to-notion.js b/scripts/sync-patrol-to-notion.js new file mode 100644 index 00000000..12ebccfa --- /dev/null +++ b/scripts/sync-patrol-to-notion.js @@ -0,0 +1,150 @@ +#!/usr/bin/env node +/** + * sync-patrol-to-notion.js — 同步巡检结果到 Notion + * 铸渊 · 光湖服务器巡检自动化 + * + * 环境变量: + * NOTION_TOKEN — Notion API Token + * NOTION_TICKET_DB_ID — 霜砚工单队列 Database ID(异常时自动开工单) + * + * 读取 patrol-report.json,写入 Notion 工单队列(仅有异常时) + */ + +const fs = require('fs'); +const path = require('path'); +const https = require('https'); + +const NOTION_TOKEN = process.env.NOTION_TOKEN; +const TICKET_DB_ID = process.env.NOTION_TICKET_DB_ID; + +if (!NOTION_TOKEN) { + console.error('⚠️ 缺少环境变量: NOTION_TOKEN'); + process.exit(1); +} + +const REPORT_FILE = path.join(process.cwd(), 'patrol-report.json'); + +function notionRequest(method, endpoint, body) { + return new Promise((resolve, reject) => { + const data = body ? JSON.stringify(body) : null; + const options = { + hostname: 'api.notion.com', + path: `/v1/${endpoint}`, + method, + headers: { + 'Authorization': `Bearer ${NOTION_TOKEN}`, + 'Notion-Version': '2022-06-28', + 'Content-Type': 'application/json', + }, + }; + if (data) { + options.headers['Content-Length'] = Buffer.byteLength(data); + } + + const req = https.request(options, (res) => { + let body = ''; + res.on('data', (chunk) => { body += chunk; }); + res.on('end', () => { + try { + resolve({ status: res.statusCode, data: JSON.parse(body) }); + } catch { + resolve({ status: res.statusCode, data: body }); + } + }); + }); + + req.on('error', reject); + if (data) req.write(data); + req.end(); + }); +} + +async function createTicket(alert, report) { + if (!TICKET_DB_ID) { + console.log(` ⚠️ 未配置 NOTION_TICKET_DB_ID,跳过工单创建: ${alert}`); + return; + } + + const res = await notionRequest('POST', 'pages', { + parent: { database_id: TICKET_DB_ID }, + properties: { + title: { title: [{ text: { content: `🔍 服务器巡检异常: ${alert}` } }] }, + '来源': { select: { name: '铸渊巡检' } }, + '状态': { select: { name: '待处理' } }, + '优先级': { select: { name: '高' } }, + }, + children: [ + { + object: 'block', + type: 'paragraph', + paragraph: { + rich_text: [{ + text: { + content: `巡检时间: ${report.timestamp}\nNginx状态: ${report.nginx_status}\n磁盘使用率: ${report.disk_usage_percent}%\n\n异常详情: ${alert}`, + }, + }], + }, + }, + ], + }); + + if (res.status === 200) { + console.log(` ✅ 工单已创建: ${alert}`); + } else { + console.log(` ⚠️ 工单创建失败: ${alert} (HTTP ${res.status})`); + } +} + +async function main() { + console.log('📝 sync-patrol-to-notion · 开始同步巡检结果'); + + if (!fs.existsSync(REPORT_FILE)) { + console.log('⚠️ patrol-report.json 不存在,跳过同步'); + return; + } + + let report; + try { + report = JSON.parse(fs.readFileSync(REPORT_FILE, 'utf-8')); + } catch (err) { + console.error('❌ 解析 patrol-report.json 失败:', err.message); + process.exit(1); + } + + console.log(`📊 巡检状态: ${report.overall}`); + console.log(` 异常数: ${report.alert_count} | 自动修复: ${report.fixed_count}`); + + if (report.overall === 'healthy') { + console.log('✅ 服务器健康,无需创建工单'); + return; + } + + // 找出未能自动修复的异常 → 开工单 + const autoFixedSet = new Set(report.auto_fixed || []); + const unresolvedAlerts = (report.alerts || []).filter(alert => { + // 如果 alert 对应有一条 auto_fixed,说明已修复,不开工单 + return !report.auto_fixed.some(fix => fix.includes(alert.split(' ')[0])); + }); + + if (unresolvedAlerts.length === 0) { + console.log('✅ 所有异常已自动修复,无需创建工单'); + return; + } + + console.log(`📋 ${unresolvedAlerts.length} 个未修复异常,创建工单...`); + + for (const alert of unresolvedAlerts) { + try { + await createTicket(alert, report); + } catch (err) { + console.log(` ⚠️ 工单创建异常: ${alert} (${err.message})`); + } + } + + console.log('✅ 同步完成'); +} + +main().catch((err) => { + console.error('❌ sync-patrol-to-notion 异常:', err.message); + process.exit(1); +});