feat: Grid-DB ↔ Google Drive bridge (ZY-DRIVE-BRIDGE-2026-0323-001)

- Add workflow: sync-griddb-to-drive.yml (仓库→Drive mirror)
- Add script: sync-to-drive.js (Drive upload logic with hash dedup)
- Add script: drive-to-github-bridge.gs (Apps Script for Drive→仓库)
- Add doc: DRIVE-BRIDGE-DEPLOY.md (deployment guide)

Co-authored-by: qinfendebingshuo <207279273+qinfendebingshuo@users.noreply.github.com>
Agent-Logs-Url: https://github.com/qinfendebingshuo/guanghulab/sessions/a581dc42-267b-44e1-b669-6ad503a47f49
This commit is contained in:
copilot-swe-agent[bot] 2026-03-23 04:06:05 +00:00
parent 35a108ebdd
commit ec8f85522a
4 changed files with 645 additions and 0 deletions

View File

@ -0,0 +1,40 @@
name: 🪞 Grid-DB → Google Drive 镜像同步
on:
push:
paths:
- 'grid-db/memory/**'
- 'grid-db/outbox/latest/**'
- 'grid-db/rules/**'
schedule:
- cron: '*/15 * * * *'
workflow_dispatch:
permissions:
contents: write
jobs:
sync-to-drive:
if: github.actor != 'zhuyuan-bot' && !contains(github.event.head_commit.message, '[skip ci]')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 🔧 安装依赖
run: npm install googleapis
- name: 🪞 同步到 Google Drive
env:
GOOGLE_DRIVE_SERVICE_ACCOUNT: ${{ secrets.GOOGLE_DRIVE_SERVICE_ACCOUNT }}
DRIVE_FOLDER_ID: ${{ secrets.DRIVE_MIRROR_FOLDER_ID }}
run: node scripts/grid-db/sync-to-drive.js
- name: 📋 写同步日志
run: |
mkdir -p grid-db/logs
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | sync-to-drive | OK" >> grid-db/logs/drive-sync.log
git config user.name "zhuyuan-bot"
git config user.email "zhuyuan@guanghulab.com"
git add -f grid-db/logs/drive-sync.log
git diff --cached --quiet || git commit -m "log: Drive sync [skip ci]"
git push

View File

@ -0,0 +1,114 @@
# 🪞 Grid-DB ↔ Google Drive 桥接部署指南
> 指令编号: ZY-DRIVE-BRIDGE-2026-0323-001
>
> 签发: 霜砚 · PER-SY001
>
> 守护: 铸渊 · PER-ZY001
>
> 系统: SYS-GLW-0001
---
## 📐 架构概述
Gemini 无法直接读写 GitHub 仓库文件。本方案通过 Google Drive 作为中转站,实现 Gemini 间接读写 `grid-db/`
```
仓库 grid-db/ ──GitHub Action──→ Drive mirror/ ──Gemini 读取──→ 用户看到
用户说话 → Gemini 写入 → Drive inbox/ ──Apps Script──→ 仓库 grid-db/inbox/ → 铸渊处理
```
**数据主权原则Drive 里的东西全是「副本」。正本永远在仓库和 Notion。**
---
## Phase A · 仓库 → Drive让 Gemini 能读)
### A.1 Google Cloud 配置
1. 前往 [Google Cloud Console](https://console.cloud.google.com)
2. 创建项目或使用已有项目
3. 启用 **Google Drive API**
4. 创建 **Service Account**,下载 JSON 密钥文件
5. 在用户的 Google Drive 中创建「光湖格点库」文件夹
6. 将 Service Account 邮箱分享到该文件夹(**编辑者**权限)
### A.2 配置 GitHub Secrets
在仓库 Settings → Secrets 中添加:
| Secret 名称 | 值 |
|---|---|
| `GOOGLE_DRIVE_SERVICE_ACCOUNT` | Service Account JSON 密钥的完整内容 |
| `DRIVE_MIRROR_FOLDER_ID` | Drive「光湖格点库」文件夹的 IDURL 中最后一段) |
### A.3 自动触发
Workflow `.github/workflows/sync-griddb-to-drive.yml` 会在以下情况自动运行:
- `grid-db/memory/**` 文件变更时
- `grid-db/outbox/latest/**` 文件变更时
- `grid-db/rules/**` 文件变更时
- 每 15 分钟定时同步
- 手动触发workflow_dispatch
### A.4 同步范围
| 仓库路径 | Drive 镜像路径 | 同步 |
|---|---|---|
| `grid-db/memory/DEV-XXX/` | `mirror/memory/DEV-XXX/` | ✅ |
| `grid-db/outbox/latest/` | `mirror/outbox/` | ✅ |
| `grid-db/rules/` | `mirror/rules/` | ✅ |
| `grid-db/interactions/` | — | ❌ 不同步 |
| `grid-db/training-lake/` | — | ❌ 不同步 |
| `grid-db/inbox/` | — | ❌ 不同步 |
| `grid-db/processing/` | — | ❌ 不同步 |
---
## Phase B · Drive → 仓库(让 Gemini 能写)
### B.1 部署 Apps Script
1. 打开 [Google Apps Script](https://script.google.com) → 新建项目
2. 项目名:`光湖格点库桥接器`
3. 将 `scripts/grid-db/drive-to-github-bridge.gs` 的内容粘贴到编辑器
4. 项目设置 → 脚本属性 → 添加 `GITHUB_TOKEN`(需要 `repo` 权限的 PAT
5. 触发器 → 添加:
- 函数:`processInbox`
- 事件源:时间驱动
- 间隔:每 1 分钟
6. 首次运行 → 授权 Drive 访问权限
### B.2 Drive 文件夹结构
Apps Script 会自动创建以下 Drive 文件夹结构:
```
光湖格点库/
├── inbox/ ← Gemini 在这里创建新文档
│ └── 已处理/ ← 成功写入 GitHub 后自动移入
├── mirror/ ← GitHub Action 同步的只读镜像
│ ├── memory/
│ │ ├── DEV-001/
│ │ ├── DEV-002/
│ │ └── ...
│ ├── outbox/
│ └── rules/
└── logs/ ← 桥接器处理日志
```
### B.3 Gemini 使用方式
Gemini 通过 Personal Context 读取 Drive `mirror/` 中的文件,通过在 `inbox/` 创建 Google Docs 来写入消息。用户全程只跟 Gemini 对话,无需感知 Drive 中转层。
---
## 安全注意事项
- **GITHUB_TOKEN** 必须存在 Apps Script 的 Script Properties 中,禁止硬编码
- **Service Account** 密钥只存在 GitHub Secrets 中,不入库
- Drive 中的所有文件都是副本,丢失可从仓库重新同步
- Apps Script 只处理 `inbox/` 文件夹,不碰 `mirror/`
- 所有自动提交包含 `[skip ci]` 防止循环触发

View File

@ -0,0 +1,229 @@
/**
* drive-to-github-bridge.gs
*
* Google Apps Script — 光湖格点库桥接器
*
* 功能:监听 Drive「光湖格点库/inbox/」文件夹
* 发现 Gemini 新建的文档 → 读取内容 → 通过 GitHub API 写入仓库
*
* 触发方式:时间驱动触发器,每 1 分钟执行一次
*
* 流程:
* ① 扫描 inbox 文件夹中所有文件
* ② 读取文件内容Google Docs → 提取纯文本/JSON
* ③ 构造标准 inbox 消息格式
* ④ 调用 GitHub Contents API 写入 grid-db/inbox/[filename].json
* ⑤ 写入成功 → 将 Drive 文件移到「已处理」文件夹(不删除,留审计轨迹)
* ⑥ 写入失败 → 标记文件,下次重试
*
* 安全:
* - GitHub Token 存在 Script Properties 中(不硬编码)
* - 只处理 inbox 文件夹内的文件,不碰 mirror
* - 每次处理后写日志到 Drive「光湖格点库/logs/」
*
* 部署步骤:
* 1. 打开 https://script.google.com → 新建项目
* 2. 项目名:光湖格点库桥接器
* 3. 粘贴此文件代码
* 4. 项目设置 → 脚本属性 → 添加 GITHUB_TOKEN需要 repo 权限)
* 5. 触发器 → 添加 → 函数 processInbox → 时间驱动 → 每 1 分钟
* 6. 首次运行:点运行 → 授权 Drive 访问权限
*
* 守护: PER-ZY001 铸渊
* 系统: SYS-GLW-0001
*/
var CONFIG = {
INBOX_FOLDER_NAME: '光湖格点库/inbox',
PROCESSED_FOLDER_NAME: '光湖格点库/inbox/已处理',
LOG_FOLDER_NAME: '光湖格点库/logs',
GITHUB_REPO: 'qinfendebingshuo/guanghulab',
GITHUB_BRANCH: 'main',
GRID_DB_INBOX_PATH: 'grid-db/inbox/'
};
/**
* 主入口:处理 Drive inbox 中的新文件
* 设置为时间驱动触发器,每 1 分钟执行
*/
function processInbox() {
var token = PropertiesService.getScriptProperties()
.getProperty('GITHUB_TOKEN');
if (!token) {
Logger.log('ERROR: GITHUB_TOKEN not set in Script Properties');
return;
}
var inboxFolder = getOrCreateFolder(CONFIG.INBOX_FOLDER_NAME);
var processedFolder = getOrCreateFolder(CONFIG.PROCESSED_FOLDER_NAME);
var logFolder = getOrCreateFolder(CONFIG.LOG_FOLDER_NAME);
var files = inboxFolder.getFiles();
var processedCount = 0;
var errorCount = 0;
while (files.hasNext()) {
var file = files.next();
try {
var content = extractContent(file);
if (!content) continue;
// 生成带时间戳的文件名
var timestamp = Utilities.formatDate(
new Date(), 'Asia/Shanghai', 'yyyyMMdd-HHmmss'
);
var safeName = file.getName().replace(/[^a-zA-Z0-9\-_]/g, '');
var filename = timestamp + '-' + safeName + '.json';
// 写入 GitHub 仓库
var success = writeToGitHub(
token,
CONFIG.GRID_DB_INBOX_PATH + filename,
content
);
if (success) {
file.moveTo(processedFolder);
processedCount++;
Logger.log('OK: ' + filename);
} else {
errorCount++;
Logger.log('FAIL: ' + filename);
}
} catch (e) {
errorCount++;
Logger.log('ERROR: ' + file.getName() + ': ' + e.message);
}
}
// 写处理日志
if (processedCount > 0 || errorCount > 0) {
var logTimestamp = Utilities.formatDate(
new Date(), 'Asia/Shanghai', 'yyyy-MM-dd HH:mm:ss'
);
var logLine = logTimestamp + ' | processed: ' + processedCount
+ ' | errors: ' + errorCount + '\n';
var logFileName = Utilities.formatDate(
new Date(), 'Asia/Shanghai', 'yyyyMMdd'
) + '-bridge.log';
appendToLogFile(logFolder, logFileName, logLine);
}
}
/**
* 从 Drive 文件中提取内容
* 支持 Google Docs 和纯文本/JSON 文件
*/
function extractContent(file) {
var mimeType = file.getMimeType();
if (mimeType === MimeType.GOOGLE_DOCS) {
// Google Docs → 提取纯文本
var doc = DocumentApp.openById(file.getId());
var text = doc.getBody().getText().trim();
// 尝试解析为 JSONGemini 可能直接写 JSON 格式)
try {
JSON.parse(text);
return text;
} catch (e) {
// 非 JSON → 包装为标准 inbox 消息格式
return JSON.stringify({
schema_version: '1.0',
message_id: Utilities.getUuid(),
timestamp: new Date().toISOString(),
source: 'gemini-via-drive',
type: 'raw-message',
priority: 'info',
payload: {
text: text
}
}, null, 2);
}
} else if (mimeType === MimeType.PLAIN_TEXT
|| file.getName().endsWith('.json')) {
// 纯文本或 JSON 文件 → 直接读取
return file.getBlob().getDataAsString();
}
Logger.log('SKIP: unsupported mime type ' + mimeType
+ ' for file ' + file.getName());
return null;
}
/**
* 通过 GitHub Contents API 写入文件到仓库
* @returns {boolean} 是否写入成功
*/
function writeToGitHub(token, filePath, content) {
var url = 'https://api.github.com/repos/'
+ CONFIG.GITHUB_REPO + '/contents/' + filePath;
var payload = {
message: 'auto: Gemini inbox via Drive bridge [skip ci]',
content: Utilities.base64Encode(content, Utilities.Charset.UTF_8),
branch: CONFIG.GITHUB_BRANCH
};
var options = {
method: 'PUT',
headers: {
'Authorization': 'Bearer ' + token,
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28'
},
contentType: 'application/json',
payload: JSON.stringify(payload),
muteHttpExceptions: true
};
var response = UrlFetchApp.fetch(url, options);
var code = response.getResponseCode();
if (code !== 200 && code !== 201) {
Logger.log('GitHub API error: ' + code + ' ' + response.getContentText());
}
return (code === 200 || code === 201);
}
/**
* 按路径逐级获取或创建 Drive 文件夹
*/
function getOrCreateFolder(folderPath) {
var parts = folderPath.split('/');
var folder = DriveApp.getRootFolder();
for (var i = 0; i < parts.length; i++) {
var folders = folder.getFoldersByName(parts[i]);
if (folders.hasNext()) {
folder = folders.next();
} else {
folder = folder.createFolder(parts[i]);
}
}
return folder;
}
/**
* 向日志文件追加内容(不存在则创建)
*/
function appendToLogFile(folder, fileName, content) {
var files = folder.getFilesByName(fileName);
if (files.hasNext()) {
var file = files.next();
var existing = file.getBlob().getDataAsString();
file.setContent(existing + content);
} else {
folder.createFile(fileName, content, MimeType.PLAIN_TEXT);
}
}
// ═══════════════════════════════════════════════════════════
// 触发器设置说明:
// Apps Script 编辑器 → 触发器 → 添加
// 函数processInbox
// 事件源:时间驱动
// 间隔:每 1 分钟
// ═══════════════════════════════════════════════════════════

View File

@ -0,0 +1,262 @@
/**
* scripts/grid-db/sync-to-drive.js
*
* 功能 grid-db/ 中的关键文件同步到 Google Drive 镜像目录
*
* 同步范围
* grid-db/memory/DEV-XXX/*.json Drive/mirror/memory/DEV-XXX/
* grid-db/outbox/latest/*.json Drive/mirror/outbox/
* grid-db/rules/*.json Drive/mirror/rules/
*
* 同步策略
* - 比较文件内容 MD5 hash仅同步有变更的文件节省 API 配额
* - 文件不存在则创建已存在则覆盖更新
* - Drive 中多出的文件不删除防误删
*
* 注意
* - 不同步 interactions/ training-lake/数据量大 + 无需 Gemini 读取
* - 不同步 inbox/ processing/系统内部流转区
*
* 环境变量
* - GOOGLE_DRIVE_SERVICE_ACCOUNT: Service Account JSON 密钥内容
* - DRIVE_FOLDER_ID: Drive 镜像根文件夹 ID
*
* 守护: PER-ZY001 铸渊
* 系统: SYS-GLW-0001
*/
const { google } = require('googleapis');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const GRID_DB_ROOT = path.join(__dirname, '../../grid-db');
// 同步目录映射:本地相对路径 → Drive 镜像子目录
const SYNC_DIRS = [
{ local: 'memory', drivePrefix: 'mirror/memory', recursive: true },
{ local: 'outbox/latest', drivePrefix: 'mirror/outbox', recursive: false },
{ local: 'rules', drivePrefix: 'mirror/rules', recursive: false }
];
/**
* 计算文件内容的 MD5 哈希
*/
function md5(content) {
return crypto.createHash('md5').update(content).digest('hex');
}
/**
* 递归收集目录下的所有 JSON 文件
*/
function collectFiles(dirPath, recursive) {
const results = [];
if (!fs.existsSync(dirPath)) return results;
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory() && recursive) {
results.push(...collectFiles(fullPath, true));
} else if (entry.isFile() && entry.name.endsWith('.json')) {
results.push(fullPath);
}
}
return results;
}
/**
* Drive 中查找或创建文件夹按路径逐级创建
*/
async function getOrCreateDriveFolder(drive, parentId, folderPath) {
const parts = folderPath.split('/').filter(Boolean);
let currentParentId = parentId;
for (const part of parts) {
const query = `name='${part}' and '${currentParentId}' in parents and mimeType='application/vnd.google-apps.folder' and trashed=false`;
const res = await drive.files.list({
q: query,
fields: 'files(id, name)',
pageSize: 1
});
if (res.data.files && res.data.files.length > 0) {
currentParentId = res.data.files[0].id;
} else {
const createRes = await drive.files.create({
requestBody: {
name: part,
mimeType: 'application/vnd.google-apps.folder',
parents: [currentParentId]
},
fields: 'id'
});
currentParentId = createRes.data.id;
console.log(`[sync-to-drive] Created folder: ${part}`);
}
}
return currentParentId;
}
/**
* Drive 目标文件夹中查找同名文件
*/
async function findFileInDrive(drive, folderId, fileName) {
const query = `name='${fileName}' and '${folderId}' in parents and trashed=false`;
const res = await drive.files.list({
q: query,
fields: 'files(id, name, md5Checksum)',
pageSize: 1
});
if (res.data.files && res.data.files.length > 0) {
return res.data.files[0];
}
return null;
}
/**
* 上传或更新单个文件到 Drive
*/
async function uploadFile(drive, folderId, fileName, content) {
const existingFile = await findFileInDrive(drive, folderId, fileName);
const { Readable } = require('stream');
const media = {
mimeType: 'application/json',
body: Readable.from([content])
};
if (existingFile) {
// 检查内容是否变更Drive 对上传文件自动计算 MD5
const localHash = md5(content);
if (existingFile.md5Checksum === localHash) {
return { action: 'skipped', fileName };
}
await drive.files.update({
fileId: existingFile.id,
media
});
return { action: 'updated', fileName };
} else {
await drive.files.create({
requestBody: {
name: fileName,
parents: [folderId]
},
media,
fields: 'id'
});
return { action: 'created', fileName };
}
}
/**
* 主同步流程
*/
async function main() {
// 验证环境变量
const serviceAccountJson = process.env.GOOGLE_DRIVE_SERVICE_ACCOUNT;
const rootFolderId = process.env.DRIVE_FOLDER_ID;
if (!serviceAccountJson) {
console.log('[sync-to-drive] GOOGLE_DRIVE_SERVICE_ACCOUNT not set, skipping');
return;
}
if (!rootFolderId) {
console.log('[sync-to-drive] DRIVE_FOLDER_ID not set, skipping');
return;
}
// 解析 Service Account 凭证
let credentials;
try {
credentials = JSON.parse(serviceAccountJson);
} catch (err) {
console.error('[sync-to-drive] Failed to parse service account JSON:', err.message);
process.exit(1);
}
// 认证 Google Drive API
const auth = new google.auth.GoogleAuth({
credentials,
scopes: ['https://www.googleapis.com/auth/drive']
});
const drive = google.drive({ version: 'v3', auth });
console.log('[sync-to-drive] Starting Grid-DB → Drive mirror sync');
let totalCreated = 0;
let totalUpdated = 0;
let totalSkipped = 0;
let totalErrors = 0;
// 遍历每个同步目录
for (const syncDir of SYNC_DIRS) {
const localDir = path.join(GRID_DB_ROOT, syncDir.local);
const files = collectFiles(localDir, syncDir.recursive);
if (files.length === 0) {
console.log(`[sync-to-drive] ${syncDir.local}: no files to sync`);
continue;
}
console.log(`[sync-to-drive] ${syncDir.local}: found ${files.length} file(s)`);
for (const filePath of files) {
try {
// 计算 Drive 中的目标路径
const relativePath = path.relative(localDir, filePath);
const dirParts = path.dirname(relativePath);
const fileName = path.basename(filePath);
// 构建 Drive 文件夹路径
let driveFolderPath = syncDir.drivePrefix;
if (dirParts !== '.') {
driveFolderPath = `${syncDir.drivePrefix}/${dirParts}`;
}
// 确保 Drive 文件夹存在
const targetFolderId = await getOrCreateDriveFolder(
drive, rootFolderId, driveFolderPath
);
// 读取文件内容并上传
const content = fs.readFileSync(filePath, 'utf8');
const result = await uploadFile(drive, targetFolderId, fileName, content);
if (result.action === 'created') {
totalCreated++;
console.log(`[sync-to-drive] + ${relativePath} (created)`);
} else if (result.action === 'updated') {
totalUpdated++;
console.log(`[sync-to-drive] ~ ${relativePath} (updated)`);
} else {
totalSkipped++;
}
} catch (err) {
totalErrors++;
console.error(`[sync-to-drive] ! ${filePath}: ${err.message}`);
}
}
}
// 输出统计
console.log('[sync-to-drive] Sync complete:');
console.log(` Created: ${totalCreated}`);
console.log(` Updated: ${totalUpdated}`);
console.log(` Skipped (unchanged): ${totalSkipped}`);
console.log(` Errors: ${totalErrors}`);
if (totalErrors > 0) {
process.exit(1);
}
}
main().catch(err => {
console.error('[sync-to-drive] Fatal error:', err.message);
process.exit(1);
});