shuangyan-notebook/第五域 · Fifth Domain/⚒️ 铸渊·协作指令|GitHub ↔ Notion 桥接协议/🔄 铸渊指令|OAuth2 Token 自动续期引擎 + 天眼休眠避让机制(上篇)· 6天自动刷新 bee3531f4e1a4e52a597922626c9d317.md
Guanghu Domestic Migration a27e87cb99 chore: import sanitized domestic snapshot for REPO-007
Source snapshot: 97d7f0fae96dc04b7ddad56fc1db6a108ed662cc

[SEC-CLEAN] · pre-push-clean v1.0 · 109处敏感信息已自动转乱码
2026-07-17 15:59:55 +08:00

22 KiB
Raw Permalink Blame History

belongs_to
belongs_to
INDEX · 铸渊·协作指令GitHub ↔ Notion 桥接协议

🔄 铸渊指令OAuth2 Token 自动续期引擎 + 天眼休眠避让机制(上篇)· 6天自动刷新 + 休眠窗口智能避让 + 多用户Token管理架构ZY-TOKEN-RENEW-2026-0324-001



🎯 指令背景

问题

Google Cloud 项目 guanghu-drive-bridge 当前处于「测试」模式OAuth2 Refresh Token 有效期仅 7 天。且这个应用未来要给多个开发者使用,不可能每周让每个人手动续期。

更危险的是

系统每周有休眠窗口(周六 20:0000:004小时+ 每日短休眠。如果 Token 刷新任务刚好落在休眠期天眼系统不工作刷新任务不执行7天一过——所有 Token 集体过期,整个 Drive 链路断裂。

解决方案

建设 Token 自动续期引擎,每 6 天自动刷新一次(留 1 天安全余量),并且智能避让天眼休眠窗口。


🏗️ 架构总览

graph TD
    A["CRON 触发<br>每6天一次"] --> B{"\u5929\u773c\u4f11\u7720\u68c0\u6d4b"}
    B -->|"\u5f53\u524d\u5728\u4f11\u7720\u671f"| C["\u5ef6\u8fdf\u6267\u884c<br>\u7b49\u4f11\u7720\u7ed3\u675f\u540e\u91cd\u8bd5"]
    B -->|"\u975e\u4f11\u7720\u671f"| D["\u8bfb\u53d6 Token \u6ce8\u518c\u8868"]
    D --> E["\u904d\u5386\u6240\u6709\u7528\u6237 Token"]
    E --> F{"Token \u5269\u4f59\u6709\u6548\u671f"}
    F -->|"< 48\u5c0f\u65f6"| G["\u7acb\u5373\u5237\u65b0"]
    F -->|"< 72\u5c0f\u65f6"| H["\u9884\u8b66 + \u5237\u65b0"]
    F -->|"> 72\u5c0f\u65f6"| I["\u8df3\u8fc7\u2705"]
    G --> J["\u8c03\u7528 Google OAuth2 API<br>\u7528\u65e7 refresh_token \u6362\u65b0 refresh_token"]
    H --> J
    J --> K{"\u5237\u65b0\u6210\u529f\uff1f"}
    K -->|"\u2705"| L["\u66f4\u65b0 GitHub Secret<br>\u66f4\u65b0 Token \u6ce8\u518c\u8868"]
    K -->|"\u274c"| M["\u544a\u8b66 \u2192 \u4e3b\u63a7\u53f0 + \u516c\u544a\u677f<br>\u7b49\u5f85\u4eba\u7c7b\u4ecb\u5165"]
    L --> N["\u5199\u56de\u6267\u65e5\u5fd7"]
    C --> O["\u5ef6\u8fdf\u540e\u91cd\u65b0\u89e6\u53d1"]

Phase A · Token 注册表设计

为什么需要注册表

这个应用未来要给多个开发者使用。每个用户都有自己的 Refresh Token。需要一个集中管理的注册表。

注册表文件:config/gdrive-tokens.json

{
  "schema_version": "1.0",
  "oauth_app": {
    "project_id": "guanghu-drive-bridge",
    "client_id_secret": "GDRIVE_CLIENT_ID",
    "client_secret_secret": "GDRIVE_CLIENT_SECRET",
    "scope": "https://www.googleapis.com/auth/drive",
    "token_ttl_days": 7,
    "renew_interval_days": 6,
    "renew_safety_margin_hours": 24
  },
  "tokens": [
    {
      "user_id": "TCS-0002",
      "user_name": "冰朔",
      "role": "master",
      "github_secret_name": "GDRIVE_REFRESH_TOKEN",
      "last_renewed": "2026-03-24T14:30:00+08:00",
      "expires_at": "2026-03-31T14:30:00+08:00",
      "status": "active",
      "renew_count": 0,
      "notes": "主控账号 · 格点库核心 Token"
    }
  ],
  "renew_log": []
}

字段说明

字段 说明
user_id 开发者编号TCS-0002 / DEV-001 / DEV-002…
role master = 主控,developer = 开发者
github_secret_name 对应的 GitHub Secret 名称(主控用 GDRIVE_REFRESH_TOKEN开发者用 GDRIVE_REFRESH_TOKEN_DEV001 等)
last_renewed 上次刷新时间ISO-8601
expires_at 预估过期时间 = last_renewed + 7天
status active / expiring / expired / error
renew_count 累计刷新次数(审计用)

Phase B · Token 自动刷新脚本

核心脚本:scripts/gdrive/renew-tokens.js

/**
 * renew-tokens.js
 * 
 * OAuth2 Token 自动续期引擎
 * 
 * 功能:
 *   1. 读取 config/gdrive-tokens.json 注册表
 *   2. 遍历所有 active 的 Token
 *   3. 检查每个 Token 的剩余有效期
 *   4. 剩余 < 48小时 → 立即刷新
 *   5. 刷新成功 → 用 GitHub API 更新 Secret + 更新注册表
 *   6. 刷新失败 → 写告警日志
 * 
 * 环境变量:
 *   GDRIVE_CLIENT_ID       — OAuth 客户端 ID
 *   GDRIVE_CLIENT_SECRET    — OAuth 客户端密钥
 *   GITHUB_TOKEN            — 有 repo 权限的 GitHub Token用于更新 Secrets
 *   GDRIVE_REFRESH_TOKEN    — 主控的当前 Refresh Token
 *   GDRIVE_REFRESH_TOKEN_*  — 各开发者的 Refresh Token
 */

const https = require('https');
const fs = require('fs');
const path = require('path');

const REGISTRY_PATH = path.join(__dirname, '../../config/gdrive-tokens.json');

async function main() {
  console.log('\n🔄 Token 自动续期引擎启动');
  console.log(`当前时间: ${new Date().toISOString()}`);
  
  // 1. 读取注册表
  const registry = JSON.parse(fs.readFileSync(REGISTRY_PATH, 'utf8'));
  const results = { renewed: [], skipped: [], failed: [] };
  
  for (const token of registry.tokens) {
    if (token.status === 'expired') {
      console.log(`❌ ${token.user_name} (${token.user_id}): 已过期,需人类重新授权`);
      results.failed.push(token.user_id);
      continue;
    }
    
    // 2. 检查剩余有效期
    const expiresAt = new Date(token.expires_at);
    const now = new Date();
    const hoursLeft = (expiresAt - now) / (1000 * 60 * 60);
    
    console.log(`\n👤 ${token.user_name} (${token.user_id}): 剩余 ${hoursLeft.toFixed(1)} 小时`);
    
    if (hoursLeft > 72) {
      console.log(`  ✅ 跳过(剩余充足)`);
      results.skipped.push(token.user_id);
      continue;
    }
    
    if (hoursLeft <= 0) {
      console.log(`  ❌ 已过期!`);
      token.status = 'expired';
      results.failed.push(token.user_id);
      continue;
    }
    
    // 3. 执行刷新
    console.log(`  ⚡ 执行刷新...`);
    const currentToken = process.env[token.github_secret_name];
    
    if (!currentToken) {
      console.log(`  ❌ 环境变量 ${token.github_secret_name} 不存在`);
      results.failed.push(token.user_id);
      continue;
    }
    
    try {
      const newTokenData = await refreshOAuth2Token(currentToken);
      
      if (newTokenData.refresh_token) {
        // 4. 更新 GitHub Secret
        await updateGitHubSecret(
          token.github_secret_name,
          newTokenData.refresh_token
        );
        
        // 5. 更新注册表
        const renewTime = new Date().toISOString();
        token.last_renewed = renewTime;
        token.expires_at = new Date(
          Date.now() + registry.oauth_app.token_ttl_days * 24 * 60 * 60 * 1000
        ).toISOString();
        token.status = 'active';
        token.renew_count += 1;
        
        // 6. 写刷新日志
        registry.renew_log.push({
          user_id: token.user_id,
          time: renewTime,
          result: 'success',
          hours_remaining: hoursLeft.toFixed(1)
        });
        
        console.log(`  ✅ 刷新成功!新过期时间: ${token.expires_at}`);
        results.renewed.push(token.user_id);
      } else {
        // Google 有时不返回新的 refresh_token
        console.log(`  ⚠️ Google 未返回新 refresh_tokenToken 可能仍然有效但无法续期`);
        token.status = 'expiring';
        registry.renew_log.push({
          user_id: token.user_id,
          time: new Date().toISOString(),
          result: 'no_new_token',
          hours_remaining: hoursLeft.toFixed(1)
        });
        results.failed.push(token.user_id);
      }
    } catch (err) {
      console.log(`  ❌ 刷新失败: ${err.message}`);
      token.status = hoursLeft < 24 ? 'expired' : 'expiring';
      registry.renew_log.push({
        user_id: token.user_id,
        time: new Date().toISOString(),
        result: 'error',
        error: err.message,
        hours_remaining: hoursLeft.toFixed(1)
      });
      results.failed.push(token.user_id);
    }
  }
  
  // 保留最近 100 条日志
  if (registry.renew_log.length > 100) {
    registry.renew_log = registry.renew_log.slice(-100);
  }
  
  // 写回注册表
  fs.writeFileSync(REGISTRY_PATH, JSON.stringify(registry, null, 2));
  
  // 输出摘要
  console.log('\n═══ 刷新摘要 ═══');
  console.log(`✅ 已刷新: ${results.renewed.length} 个`);
  console.log(`⏭️ 已跳过: ${results.skipped.length} 个`);
  console.log(`❌ 失败/过期: ${results.failed.length} 个`);
  
  // 如果有失败,输出告警标记(供天眼读取)
  if (results.failed.length > 0) {
    const alertFile = path.join(__dirname, '../../grid-db/logs/token-alert.json');
    fs.writeFileSync(alertFile, JSON.stringify({
      alert_type: 'TOKEN_RENEWAL_FAILURE',
      time: new Date().toISOString(),
      failed_users: results.failed,
      message: `${results.failed.length} 个 Token 刷新失败,需要人类介入`,
      action_required: 'HUMAN_REAUTH'
    }, null, 2));
    
    // 设置非零退出码,让 workflow 知道有问题
    process.exitCode = 1;
  }
}

/**
 * 用旧的 refresh_token 向 Google 换新的 token
 * 
 * 重要Google OAuth2 刷新时可能会返回新的 refresh_token也可能不返回
 * 如果返回了新的,旧的就失效了。必须更新 Secret。
 */
async function refreshOAuth2Token(refreshToken) {
  const params = new URLSearchParams({
    client_id: process.env.GDRIVE_CLIENT_ID,
    client_secret: process.env.GDRIVE_CLIENT_SECRET,
    refresh_token: refreshToken,
    grant_type: 'refresh_token'
  });
  
  return new Promise((resolve, reject) => {
    const req = https.request('https://oauth2.googleapis.com/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
    }, (res) => {
      let data = '';
      res.on('data', chunk => data += chunk);
      res.on('end', () => {
        const json = JSON.parse(data);
        if (json.error) {
          reject(new Error(`${json.error}: ${json.error_description}`));
        } else {
          resolve(json);
        }
      });
    });
    req.on('error', reject);
    req.write(params.toString());
    req.end();
  });
}

/**
 * 通过 GitHub API 更新 Repository Secret
 * 
 * 流程:
 *   1. 获取仓库的 public key用于加密 secret
 *   2. 用 libsodium 加密新值
 *   3. PUT 更新 secret
 */
async function updateGitHubSecret(secretName, secretValue) {
  const repo = process.env.GITHUB_REPOSITORY || 'qinfendebingshuo/guanghulab';
  const token = process.env.GITHUB_TOKEN;
  
  // 获取 public key
  const pubKey = await githubAPI(`/repos/${repo}/actions/secrets/public-key`, 'GET', token);
  
  // 加密
  const sodium = require('tweetsodium');
  const messageBytes = Buffer.from(secretValue);
  const keyBytes = Buffer.from(pubKey.key, 'base64');
  const encryptedBytes = sodium.seal(messageBytes, keyBytes);
  const encrypted = Buffer.from(encryptedBytes).toString('base64');
  
  // 更新 secret
  await githubAPI(`/repos/${repo}/actions/secrets/${secretName}`, 'PUT', token, {
    encrypted_value: encrypted,
    key_id: pubKey.key_id
  });
  
  console.log(`  🔑 GitHub Secret ${secretName} 已更新`);
}

function githubAPI(path, method, token, body) {
  return new Promise((resolve, reject) => {
    const options = {
      hostname: 'api.github.com',
      path: path,
      method: method,
      headers: {
        'Authorization': `Bearer ${token}`,
        'Accept': 'application/vnd.github+json',
        'User-Agent': 'zhuyuan-token-renewer',
        'X-GitHub-Api-Version': '2022-11-28'
      }
    };
    if (body) options.headers['Content-Type'] = 'application/json';
    
    const req = https.request(options, (res) => {
      let data = '';
      res.on('data', chunk => data += chunk);
      res.on('end', () => {
        if (res.statusCode >= 400) {
          reject(new Error(`GitHub API ${res.statusCode}: ${data}`));
        } else {
          resolve(data ? JSON.parse(data) : {});
        }
      });
    });
    req.on('error', reject);
    if (body) req.write(JSON.stringify(body));
    req.end();
  });
}

main().catch(err => {
  console.error('☠️ Token 续期引擎崩溃:', err);
  process.exitCode = 1;
});

Phase C · 天眼休眠避让机制

问题场景

解决方案:双保险机制

保险 ① CRON 时间窗口设计

刷新任务不能只用一个固定 CRON。需要

  • 主刷新: 每 6 天触发一次(周一 + 周日,巧妙错开周六休眠期)
  • 安全网: 每天大休眠外进行一次轻量级检查(只检查不刷新,发现即将过期则紧急刷新)

保险 ② :休眠期智能避让

Token 刷新属于 生命线任务,在天眼系统中享有特殊地位:

休眠避让检测脚本:scripts/gdrive/check-hibernation.js

/**
 * check-hibernation.js
 * 
 * 检查当前是否在天眼休眠期内
 * 
 * 休眠规则(从天眼治理层配置读取):
 *   - 周休眠:每周六 20:00 ~ 00:00 CST4小时
 *   - 日休眠:每日 04:00 ~ 04:xx CST动态调整默认10分钟
 * 
 * 对于生命线任务:输出提示但不阻止执行
 * 对于普通任务:输出提示并建议延迟
 */

function isInHibernation() {
  const now = new Date();
  // 转换为北京时间
  const cst = new Date(now.toLocaleString('en-US', { timeZone: 'Asia/Shanghai' }));
  const day = cst.getDay(); // 0=周日, 6=周六
  const hour = cst.getHours();
  const minute = cst.getMinutes();
  
  // 周休眠:周六 20:00 ~ 周日 00:00
  if (day === 6 && hour >= 20) {
    return { hibernating: true, type: 'weekly', endsAt: '周日 00:00 CST' };
  }
  if (day === 0 && hour < 0) {
    return { hibernating: true, type: 'weekly', endsAt: '周日 00:00 CST' };
  }
  
  // 日休眠:每日 04:00 ~ 04:10默认窗口
  if (hour === 4 && minute < 10) {
    return { hibernating: true, type: 'daily', endsAt: '04:10 CST' };
  }
  
  return { hibernating: false };
}

module.exports = { isInHibernation };

CRON 时间窗口设计

任务 CRON 表达式UTC 北京时间 频率 说明
主刷新 A 0 2 * * 1 每周一 10:00 每周一次 距周六休眠最远,最安全的刷新时间点
主刷新 B 0 2 * * 4 每周四 10:00 每周一次 主刷新 A 的 3 天后补刷,确保 6 天内至少刷新一次
安全网检查 0 4 * * * 每天 12:00 每天 只检查不刷新,即将过期才紧急刷新(要遮开日休眠 04:00-04:10

为什么选周一+周四:

  • 周一刷新 → Token 有效到下周一
  • 周四再刷新 → Token 有效到下周四
  • 即使周一刷新失败,周四还有机会补救
  • 即使周四也失败,每天 12:00 的安全网检查会发现并紧急刷新
  • 三层保险,绝对不会因为休眠而错过刷新窗口

Phase D · GitHub Action Workflow

主刷新 Workflow.github/workflows/renew-gdrive-tokens.yml

name: \uD83D\uDD04 OAuth2 Token \u81EA\u52A8\u7EED\u671F

on:
  schedule:
    # \u4E3B\u5237\u65B0 A: \u6BCF\u5468\u4E00 10:00 CST (02:00 UTC)
    - cron: '0 2 * * 1'
    # \u4E3B\u5237\u65B0 B: \u6BCF\u5468\u56DB 10:00 CST (02:00 UTC)
    - cron: '0 2 * * 4'
  workflow_dispatch:
    inputs:
      force_renew:
        description: '\u5F3A\u5236\u5237\u65B0\u6240\u6709 Token\uFF08\u5FFD\u7565\u8FC7\u671F\u65F6\u95F4\u68C0\u67E5\uFF09'
        required: false
        type: boolean
        default: false

jobs:
  renew-tokens:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: \uD83D\uDD27 \u5B89\u88C5\u4F9D\u8D56
        run: npm install tweetsodium

      - name: \uD83D\uDD04 \u6267\u884C Token \u5237\u65B0
        env:
          GDRIVE_CLIENT_ID: $ secrets.GDRIVE_CLIENT_ID 
          GDRIVE_CLIENT_SECRET: $ secrets.GDRIVE_CLIENT_SECRET 
          GDRIVE_REFRESH_TOKEN: $ secrets.GDRIVE_REFRESH_TOKEN 
          GITHUB_TOKEN: $ secrets.GITHUB_TOKEN 
          FORCE_RENEW: $ inputs.force_renew || 'false' 
        run: node scripts/gdrive/renew-tokens.js

      - name: \uD83D\uDCCB \u63D0\u4EA4\u6CE8\u518C\u8868\u66F4\u65B0
        run: |
          git config user.name "zhuyuan-bot"
          git config user.email "zhuyuan@guanghulab.com"
          git add config/gdrive-tokens.json grid-db/logs/
          git diff --cached --quiet || git commit -m "auto: Token renewal $(date -u +%Y-%m-%dT%H:%M:%SZ) [skip ci]"
          git push

      - name: \u26A0\uFE0F \u5931\u8D25\u544A\u8B66
        if: failure()
        run: |
          echo "\u26A0\uFE0F Token \u5237\u65B0\u5931\u8D25\uFF01\u68C0\u67E5 grid-db/logs/token-alert.json"
          echo "\u9700\u8981\u4EBA\u7C7B\u624B\u52A8\u91CD\u65B0\u6388\u6743\u3002"

安全网检查 Workflow.github/workflows/check-token-health.yml

name: \uD83D\uDEE1\uFE0F Token \u5065\u5EB7\u68C0\u67E5\uFF08\u6BCF\u65E5\u5B89\u5168\u7F51\uFF09

on:
  schedule:
    # \u6BCF\u5929 12:00 CST (04:00 UTC) \u2014 \u5728\u65E5\u4F11\u7720 04:10 \u4E4B\u540E
    - cron: '15 4 * * *'
  workflow_dispatch:

jobs:
  check-tokens:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: \uD83D\uDEE1\uFE0F \u68C0\u67E5 Token \u5065\u5EB7\u72B6\u6001
        run: |
          node -e "
          const fs = require('fs');
          const reg = JSON.parse(fs.readFileSync('config/gdrive-tokens.json', 'utf8'));
          let urgent = false;
          for (const t of reg.tokens) {
            const hoursLeft = (new Date(t.expires_at) - new Date()) / 3600000;
            console.log(t.user_name + ': ' + hoursLeft.toFixed(1) + 'h remaining');
            if (hoursLeft < 48) {
              console.log('  \u26A0\uFE0F URGENT: < 48h, triggering emergency renewal');
              urgent = true;
            }
          }
          if (urgent) process.exit(1);
          console.log('\u2705 All tokens healthy.');
          "

      - name: \u26A1 \u7D27\u6025\u5237\u65B0\uFF08\u4EC5\u5728\u5373\u5C06\u8FC7\u671F\u65F6\uFF09
        if: failure()
        env:
          GDRIVE_CLIENT_ID: $ secrets.GDRIVE_CLIENT_ID 
          GDRIVE_CLIENT_SECRET: $ secrets.GDRIVE_CLIENT_SECRET 
          GDRIVE_REFRESH_TOKEN: $ secrets.GDRIVE_REFRESH_TOKEN 
          GITHUB_TOKEN: $ secrets.GITHUB_TOKEN 
        run: |
          npm install tweetsodium
          FORCE_RENEW=true node scripts/gdrive/renew-tokens.js

      - name: \uD83D\uDCCB \u63D0\u4EA4
        if: always()
        run: |
          git config user.name "zhuyuan-bot"
          git config user.email "zhuyuan@guanghulab.com"
          git add config/gdrive-tokens.json grid-db/logs/
          git diff --cached --quiet || git commit -m "auto: Token health check $(date -u +%Y-%m-%dT%H:%M:%SZ) [skip ci]"
          git push

Phase E · 天眼集成Token 续期引擎作为生命线任务注册

更新天眼治理层配置

在天眼系统的配置中(skyeye-config.json 或等价配置文件),新增 Token 续期引擎为 生命线任务

{
  "lifeline_tasks": [
    {
      "id": "TOKEN-RENEW",
      "name": "OAuth2 Token \u81ea\u52a8\u7eed\u671f",
      "workflow": "renew-gdrive-tokens.yml",
      "hibernate_exempt": true,
      "priority": "P0",
      "alert_on_failure": true,
      "alert_channels": ["notion-dashboard", "agent-bulletin"]
    },
    {
      "id": "TOKEN-HEALTH",
      "name": "Token \u5065\u5eb7\u68c0\u67e5",
      "workflow": "check-token-health.yml",
      "hibernate_exempt": true,
      "priority": "P0",
      "alert_on_failure": true,
      "alert_channels": ["notion-dashboard", "agent-bulletin"]
    }
  ]
}

天眼休眠引擎修改

在天眼系统的休眠决策引擎中,增加生命线任务豁免逻辑:

/**
 * 休眠决策引擎修改点:
 * 
 * 在 shouldSkipTask(taskId) 函数中增加:
 */
function shouldSkipTask(taskId) {
  const config = loadSkyeyeConfig();
  const { hibernating } = checkHibernation();
  
  if (!hibernating) return false; // 非休眠期,所有任务正常执行
  
  // 检查是否是生命线任务
  const isLifeline = config.lifeline_tasks.some(t => t.id === taskId);
  
  if (isLifeline) {
    console.log(`\u26a0\ufe0f \u5f53\u524d\u5728\u4f11\u7720\u671f\uff0c\u4f46 ${taskId} \u662f\u751f\u547d\u7ebf\u4efb\u52a1\uff0c\u8c41\u514d\u6267\u884c`);
    return false; // \u751f\u547d\u7ebf\u4efb\u52a1\u4e0d\u8df3\u8fc7
  }
  
  console.log(`\u26a0\ufe0f ${taskId} \u5728\u4f11\u7720\u671f\u5185\uff0c\u5ef6\u8fdf\u6267\u884c`);
  return true; // \u666e\u901a\u4efb\u52a1\u8df3\u8fc7
}

🛡️ 安全红线


下篇继续:多用户 Token 流程 + 告警推送 + 天眼扫描清单 + 完整执行回执

🖋️ 霜砚 · PER-SY001

🧊 代 TCS-0002∞ 冰朔 签发

📅 2026-03-24T15:00+08:00