404 lines
11 KiB
JavaScript
404 lines
11 KiB
JavaScript
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
|
||
// ============================================
|
||
// Notion 同步模块 · sync-to-notion.js · v2.0
|
||
// HoloLake · M-DINGTALK Phase 5
|
||
// DEV-004 之之 × 秋秋
|
||
//
|
||
// Phase 3 = 模拟模式
|
||
// Phase 5 = 真实 Notion API 连通!
|
||
// ============================================
|
||
const axios = require('axios');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
// ====== Notion API 配置 ======
|
||
const NOTION_API = 'https://api.notion.com/v1';
|
||
const NOTION_VERSION = process.env.NOTION_API_VERSION || '2022-06-28';
|
||
const NOTION_TOKEN = process.env.NOTION_TOKEN || '';
|
||
|
||
// 数据库 ID
|
||
const DB_IDS = {
|
||
syslog: process.env.NOTION_SYSLOG_DB_ID || '',
|
||
signalLog: process.env.NOTION_SIGNAL_LOG_DB_ID || '',
|
||
changeLog: process.env.NOTION_CHANGE_LOG_DB_ID || '',
|
||
ticket: process.env.NOTION_TICKET_DB_ID || ''
|
||
};
|
||
|
||
// 本地降级日志目录
|
||
const FALLBACK_DIR = path.join(__dirname, '..', 'data', 'notion-pending');
|
||
const WRITEBACK_LOG = path.join(__dirname, '..', 'data', 'notion-writeback-log.json');
|
||
|
||
// ====== 通用请求头 ======
|
||
function getHeaders() {
|
||
return {
|
||
'Authorization': 'Bearer ' + NOTION_TOKEN,
|
||
'Notion-Version': NOTION_VERSION,
|
||
'Content-Type': 'application/json'
|
||
};
|
||
}
|
||
|
||
// ====== 检查配置是否就绪 ======
|
||
function isConfigured() {
|
||
return NOTION_TOKEN && NOTION_TOKEN !== '' && NOTION_TOKEN !== 'mock-notion-token';
|
||
}
|
||
|
||
// ====== 辅助:创建 rich_text 属性 ======
|
||
function richText(str) {
|
||
if (!str) return { rich_text: [] };
|
||
const text = String(str);
|
||
if (text.length <= 2000) {
|
||
return { rich_text: [{ text: { content: text } }] };
|
||
}
|
||
var blocks = [];
|
||
for (var i = 0; i < text.length; i += 2000) {
|
||
blocks.push({ text: { content: text.substring(i, i + 2000) } });
|
||
}
|
||
return { rich_text: blocks };
|
||
}
|
||
|
||
// ====== 辅助:创建 title 属性 ======
|
||
function titleProp(str) {
|
||
return { title: [{ text: { content: String(str || '未命名') } }] };
|
||
}
|
||
|
||
// ====== 辅助:创建 select 属性 ======
|
||
function selectProp(name) {
|
||
if (!name) return undefined;
|
||
return { select: { name: String(name) } };
|
||
}
|
||
|
||
// ====== 辅助:创建 status 属性 ======
|
||
function statusProp(name) {
|
||
if (!name) return undefined;
|
||
return { status: { name: String(name) } };
|
||
}
|
||
|
||
// ====== 辅助:创建 date 属性 ======
|
||
function dateProp(isoString) {
|
||
if (!isoString) return undefined;
|
||
return { date: { start: isoString } };
|
||
}
|
||
|
||
// ====== 核心:写入 SYSLOG 收件箱 ======
|
||
async function writeSyslog(data) {
|
||
console.log('[Notion] 准备写入 SYSLOG 收件箱...');
|
||
|
||
if (!isConfigured()) {
|
||
console.log('[Notion] △ Token 未配置,降级到本地存储');
|
||
return saveFallback('syslog', data);
|
||
}
|
||
|
||
if (!DB_IDS.syslog) {
|
||
console.log('[Notion] △ SYSLOG 数据库 ID 未配置');
|
||
return saveFallback('syslog', data);
|
||
}
|
||
|
||
var properties = {
|
||
'标题': titleProp(data.title || data.session_id || 'SYSLOG-' + Date.now())
|
||
};
|
||
|
||
if (data.dev_id) {
|
||
properties['DEV编号'] = selectProp(data.dev_id);
|
||
}
|
||
|
||
if (data.broadcast_id || data.session_id) {
|
||
properties['广播编号'] = richText(data.broadcast_id || data.session_id);
|
||
}
|
||
|
||
if (data.module) {
|
||
properties['模块'] = richText(data.module);
|
||
}
|
||
|
||
if (data.phase_status || data.status) {
|
||
var statusVal = data.phase_status || data.status;
|
||
if (['completed', 'partial', 'blocked'].includes(statusVal)) {
|
||
properties['环节状态'] = selectProp(statusVal);
|
||
}
|
||
}
|
||
|
||
if (data.content || data.raw_text) {
|
||
properties['文件内容'] = richText(data.content || data.raw_text);
|
||
}
|
||
|
||
properties['协议版本'] = selectProp(data.protocol_version || 'SYSLOG-v4.0');
|
||
properties['处理状态'] = statusProp('待处理');
|
||
properties['接收时间'] = dateProp(data.timestamp || new Date().toISOString());
|
||
properties['推送方'] = richText(data.source || 'M-DINGTALK-钉钉工作台');
|
||
|
||
if (data.commit_sha) {
|
||
properties['commit_sha'] = richText(data.commit_sha);
|
||
}
|
||
|
||
if (data.source_path) {
|
||
properties['来源路径'] = richText(data.source_path);
|
||
}
|
||
|
||
try {
|
||
var response = await axios.post(NOTION_API + '/pages', {
|
||
parent: { database_id: DB_IDS.syslog },
|
||
properties: properties
|
||
}, {
|
||
headers: getHeaders(),
|
||
timeout: 15000
|
||
});
|
||
|
||
console.log('[Notion] ✅ SYSLOG 写入成功! 页面ID:', response.data.id);
|
||
saveWritebackLog({
|
||
timestamp: new Date().toISOString(),
|
||
target: 'syslog',
|
||
page_id: response.data.id,
|
||
title: data.title || data.session_id,
|
||
status: 'success'
|
||
});
|
||
|
||
return {
|
||
success: true,
|
||
written_to: 'notion',
|
||
page_id: response.data.id,
|
||
url: response.data.url
|
||
};
|
||
} catch (err) {
|
||
var errMsg = err.response ? JSON.stringify(err.response.data) : err.message;
|
||
console.error('[Notion] ❌ SYSLOG 写入失败:', errMsg);
|
||
saveWritebackLog({
|
||
timestamp: new Date().toISOString(),
|
||
target: 'syslog',
|
||
title: data.title || data.session_id,
|
||
status: 'failed',
|
||
error: errMsg
|
||
});
|
||
return saveFallback('syslog', data);
|
||
}
|
||
}
|
||
|
||
// ====== 写入信号日志 ======
|
||
async function writeSignalLog(data) {
|
||
console.log('[Notion] 准备写入信号日志...');
|
||
if (!isConfigured() || !DB_IDS.signalLog) {
|
||
return saveFallback('signal-log', data);
|
||
}
|
||
|
||
var properties = {
|
||
'信号编号': titleProp(data.signal_id || 'SIG-' + Date.now()),
|
||
'信号类型': selectProp(data.signal_type || 'GL-DATA'),
|
||
'关联DEV': selectProp(data.dev_id),
|
||
'发送方': selectProp(data.sender || '霜砚'),
|
||
'接收方': selectProp(data.receiver),
|
||
'摘要': richText(data.summary),
|
||
'方向': selectProp(data.direction),
|
||
'执行结果': statusProp(data.result || '已发送'),
|
||
'时间戳': dateProp(data.timestamp || new Date().toISOString())
|
||
};
|
||
|
||
Object.keys(properties).forEach(function(k) {
|
||
if (properties[k] === undefined) delete properties[k];
|
||
});
|
||
|
||
try {
|
||
var response = await axios.post(NOTION_API + '/pages', {
|
||
parent: { database_id: DB_IDS.signalLog },
|
||
properties: properties
|
||
}, {
|
||
headers: getHeaders(),
|
||
timeout: 15000
|
||
});
|
||
|
||
console.log('[Notion] ✅ 信号日志写入成功!');
|
||
return { success: true, written_to: 'notion', page_id: response.data.id };
|
||
} catch (err) {
|
||
console.error('[Notion] ❌ 信号日志写入失败:', err.message);
|
||
return saveFallback('signal-log', data);
|
||
}
|
||
}
|
||
|
||
// ====== 健康检查:测试 Token 是否有效 ======
|
||
async function healthCheck() {
|
||
if (!isConfigured()) {
|
||
return { online: false, reason: 'token_not_configured' };
|
||
}
|
||
|
||
try {
|
||
var response = await axios.get(NOTION_API + '/users/me', {
|
||
headers: getHeaders(),
|
||
timeout: 5000
|
||
});
|
||
|
||
return {
|
||
online: true,
|
||
bot_name: response.data.name,
|
||
bot_id: response.data.id
|
||
};
|
||
} catch (err) {
|
||
return {
|
||
online: false,
|
||
reason: err.response ? err.response.status + ': ' + (err.response.data.message || '') : err.message
|
||
};
|
||
}
|
||
}
|
||
|
||
// ====== 数据库连通测试 ======
|
||
async function testDatabase(dbName) {
|
||
var dbId = DB_IDS[dbName];
|
||
if (!dbId) {
|
||
return { accessible: false, reason: 'db_id_not_configured', db: dbName };
|
||
}
|
||
|
||
try {
|
||
var response = await axios.post(NOTION_API + '/databases/' + dbId + '/query', {
|
||
page_size: 1
|
||
}, {
|
||
headers: getHeaders(),
|
||
timeout: 10000
|
||
});
|
||
|
||
return {
|
||
accessible: true,
|
||
db: dbName,
|
||
db_id: dbId,
|
||
total_results: response.data.results.length,
|
||
has_more: response.data.has_more
|
||
};
|
||
} catch (err) {
|
||
return {
|
||
accessible: false,
|
||
db: dbName,
|
||
reason: err.response ? err.response.status + ': ' + (err.response.data.message || '') : err.message
|
||
};
|
||
}
|
||
}
|
||
|
||
// ====== 降级:保存到本地 ======
|
||
function saveFallback(type, data) {
|
||
if (!fs.existsSync(FALLBACK_DIR)) {
|
||
fs.mkdirSync(FALLBACK_DIR, { recursive: true });
|
||
}
|
||
|
||
var filename = type + '_' + Date.now() + '.json';
|
||
var filePath = path.join(FALLBACK_DIR, filename);
|
||
|
||
var record = {
|
||
type: type,
|
||
data: data,
|
||
saved_at: new Date().toISOString(),
|
||
reason: 'notion_unavailable'
|
||
};
|
||
|
||
fs.writeFileSync(filePath, JSON.stringify(record, null, 2), 'utf-8');
|
||
console.log('[Notion] 已降级存储到本地:', filename);
|
||
|
||
return {
|
||
success: true,
|
||
written_to: 'local',
|
||
file: filename,
|
||
message: 'Notion不可用,已存到本地。Token配置后可重新推送。'
|
||
};
|
||
}
|
||
|
||
// ====== 重推本地暂存数据到 Notion ======
|
||
async function flushPending() {
|
||
if (!fs.existsSync(FALLBACK_DIR)) {
|
||
console.log('[Notion] 无暂存数据');
|
||
return { flushed: 0 };
|
||
}
|
||
|
||
var files = fs.readdirSync(FALLBACK_DIR).filter(function(f) { return f.endsWith('.json'); });
|
||
console.log('[Notion] 发现 ' + files.length + ' 条暂存数据,开始重推...');
|
||
|
||
var success = 0;
|
||
var failed = 0;
|
||
|
||
for (var file of files) {
|
||
try {
|
||
var content = JSON.parse(fs.readFileSync(path.join(FALLBACK_DIR, file), 'utf-8'));
|
||
var result;
|
||
|
||
if (content.type === 'syslog') {
|
||
result = await writeSyslog(content.data);
|
||
} else if (content.type === 'signal-log') {
|
||
result = await writeSignalLog(content.data);
|
||
}
|
||
|
||
if (result && result.written_to === 'notion') {
|
||
fs.unlinkSync(path.join(FALLBACK_DIR, file));
|
||
success++;
|
||
} else {
|
||
failed++;
|
||
}
|
||
} catch (err) {
|
||
console.error('[Notion] 重推失败:', file, err.message);
|
||
failed++;
|
||
}
|
||
}
|
||
|
||
console.log('[Notion] 重推完成: 成功 ' + success + ' / 失败 ' + failed);
|
||
return { flushed: success, failed: failed, remaining: files.length - success };
|
||
}
|
||
|
||
// ====== 回写进度(兼容 Phase 3 sync-engine 调用) ======
|
||
async function updateProgress(syslogData) {
|
||
console.log('[Notion Sync] 回写进度到 Notion...');
|
||
|
||
var result = await writeSyslog({
|
||
title: (syslogData.session_id || '') + ' ' + (syslogData.dev_name || syslogData.dev_id || '') + ' ' + (syslogData.status || ''),
|
||
session_id: syslogData.session_id,
|
||
dev_id: syslogData.dev_id,
|
||
module: syslogData.module,
|
||
phase_status: syslogData.status || syslogData.completion_status,
|
||
content: JSON.stringify(syslogData, null, 2),
|
||
timestamp: syslogData.timestamp || new Date().toISOString(),
|
||
source: 'M-DINGTALK-钉钉工作台'
|
||
});
|
||
|
||
return result;
|
||
}
|
||
|
||
// ====== 全量同步(兼容 Phase 3 sync-engine 调用) ======
|
||
async function sync() {
|
||
console.log('[Notion Sync] 执行 Notion 同步...');
|
||
var health = await healthCheck();
|
||
|
||
if (health.online) {
|
||
var flushResult = await flushPending();
|
||
return {
|
||
status: 'online',
|
||
bot: health.bot_name,
|
||
flushed: flushResult.flushed,
|
||
remaining: flushResult.remaining || 0
|
||
};
|
||
} else {
|
||
return {
|
||
status: 'offline',
|
||
reason: health.reason,
|
||
message: '等待 Token 配置或网络恢复'
|
||
};
|
||
}
|
||
}
|
||
|
||
// ====== 保存回写日志 ======
|
||
function saveWritebackLog(record) {
|
||
var logDir = path.dirname(WRITEBACK_LOG);
|
||
if (!fs.existsSync(logDir)) {
|
||
fs.mkdirSync(logDir, { recursive: true });
|
||
}
|
||
|
||
var log = [];
|
||
if (fs.existsSync(WRITEBACK_LOG)) {
|
||
try { log = JSON.parse(fs.readFileSync(WRITEBACK_LOG, 'utf-8')); } catch (e) {}
|
||
}
|
||
|
||
log.unshift(record);
|
||
if (log.length > 200) log = log.slice(0, 200);
|
||
fs.writeFileSync(WRITEBACK_LOG, JSON.stringify(log, null, 2), 'utf-8');
|
||
}
|
||
|
||
module.exports = {
|
||
writeSyslog,
|
||
writeSignalLog,
|
||
healthCheck,
|
||
testDatabase,
|
||
updateProgress,
|
||
sync,
|
||
flushPending,
|
||
isConfigured
|
||
};
|