327 lines
12 KiB
JavaScript
327 lines
12 KiB
JavaScript
|
|
// scripts/receive-syslog.js
|
|||
|
|
// 铸渊 · SYSLOG 接收处理脚本
|
|||
|
|
//
|
|||
|
|
// 接收 repository_dispatch 传来的 SYSLOG JSON,执行三步操作:
|
|||
|
|
// ① 存入 syslog/YYYY-MM-DD_BC-XXX-YYY-ZZ.json
|
|||
|
|
// ② Notion API 创建 SYSLOG 收件箱条目
|
|||
|
|
// ③ Notion API 创建霜砚工单(触发巡检引擎)
|
|||
|
|
//
|
|||
|
|
// 环境变量:
|
|||
|
|
// SYSLOG_JSON SYSLOG 完整 JSON 字符串
|
|||
|
|
// NOTION_TOKEN Notion API token
|
|||
|
|
// NOTION_SYSLOG_DB_ID SYSLOG 收件箱数据库 ID
|
|||
|
|
// NOTION_TICKET_DB_ID 霜砚工单数据库 ID
|
|||
|
|
|
|||
|
|
'use strict';
|
|||
|
|
|
|||
|
|
const https = require('https');
|
|||
|
|
const fs = require('fs');
|
|||
|
|
const path = require('path');
|
|||
|
|
|
|||
|
|
// ══════════════════════════════════════════════════════════
|
|||
|
|
// 常量
|
|||
|
|
// ══════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
const NOTION_VERSION = '2022-06-28';
|
|||
|
|
const NOTION_API_HOSTNAME = 'api.notion.com';
|
|||
|
|
const NOTION_RICH_TEXT_MAX = 2000;
|
|||
|
|
const SYSLOG_DIR = path.resolve(__dirname, '..', 'syslog');
|
|||
|
|
|
|||
|
|
// ══════════════════════════════════════════════════════════
|
|||
|
|
// HTTP 请求工具
|
|||
|
|
// ══════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
function notionPost(endpoint, body, token) {
|
|||
|
|
return new Promise((resolve, reject) => {
|
|||
|
|
const payload = JSON.stringify(body);
|
|||
|
|
const opts = {
|
|||
|
|
hostname: NOTION_API_HOSTNAME,
|
|||
|
|
port: 443,
|
|||
|
|
path: endpoint,
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {
|
|||
|
|
'Authorization': 'Bearer ' + token,
|
|||
|
|
'Content-Type': 'application/json',
|
|||
|
|
'Notion-Version': NOTION_VERSION,
|
|||
|
|
'Content-Length': Buffer.byteLength(payload),
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
const req = https.request(opts, res => {
|
|||
|
|
let data = '';
|
|||
|
|
res.on('data', chunk => data += chunk);
|
|||
|
|
res.on('end', () => {
|
|||
|
|
try {
|
|||
|
|
const parsed = JSON.parse(data);
|
|||
|
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|||
|
|
resolve(parsed);
|
|||
|
|
} else {
|
|||
|
|
reject(new Error(`Notion API ${res.statusCode}: ${parsed.message || data}`));
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
reject(new Error(`Notion API parse error: ${data}`));
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
req.on('error', reject);
|
|||
|
|
req.write(payload);
|
|||
|
|
req.end();
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ══════════════════════════════════════════════════════════
|
|||
|
|
// Notion 属性构建辅助
|
|||
|
|
// ══════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
function richText(content) {
|
|||
|
|
const str = String(content || '');
|
|||
|
|
const chunks = [];
|
|||
|
|
for (let i = 0; i < str.length; i += NOTION_RICH_TEXT_MAX) {
|
|||
|
|
chunks.push({ type: 'text', text: { content: str.slice(i, i + NOTION_RICH_TEXT_MAX) } });
|
|||
|
|
}
|
|||
|
|
return chunks;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function titleProp(text) {
|
|||
|
|
return { title: [{ type: 'text', text: { content: String(text || '').slice(0, 120) } }] };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function richTextProp(text) {
|
|||
|
|
return { rich_text: richText(text) };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function selectProp(name) {
|
|||
|
|
return { select: { name: String(name) } };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function dateProp(dateStr) {
|
|||
|
|
return { date: { start: dateStr } };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function checkboxProp(val) {
|
|||
|
|
return { checkbox: !!val };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ══════════════════════════════════════════════════════════
|
|||
|
|
// SYSLOG 验证
|
|||
|
|
// ══════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
const VALID_PROTOCOL_VERSIONS = ['4.0', 'v4.0', '4.0.0'];
|
|||
|
|
|
|||
|
|
function validateSyslog(syslog) {
|
|||
|
|
const errors = [];
|
|||
|
|
if (!syslog.protocol_version) {
|
|||
|
|
errors.push('缺少 protocol_version 字段');
|
|||
|
|
} else if (!VALID_PROTOCOL_VERSIONS.includes(String(syslog.protocol_version))) {
|
|||
|
|
errors.push('protocol_version 不合法: ' + syslog.protocol_version);
|
|||
|
|
}
|
|||
|
|
if (!syslog.dev_id && !syslog.developer_id) {
|
|||
|
|
errors.push('缺少 dev_id 或 developer_id 字段');
|
|||
|
|
}
|
|||
|
|
return errors;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ══════════════════════════════════════════════════════════
|
|||
|
|
// 步骤 ①:存入仓库
|
|||
|
|
// ══════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
function saveSyslogToFile(syslog) {
|
|||
|
|
if (!fs.existsSync(SYSLOG_DIR)) {
|
|||
|
|
fs.mkdirSync(SYSLOG_DIR, { recursive: true });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const broadcastId = syslog.broadcast_id || syslog.broadcastId || 'UNKNOWN';
|
|||
|
|
const devId = syslog.dev_id || syslog.developer_id || 'UNKNOWN';
|
|||
|
|
const dateStr = syslog.date || syslog.timestamp || new Date().toISOString().split('T')[0];
|
|||
|
|
const filename = dateStr + '_' + broadcastId + '_' + devId + '.json';
|
|||
|
|
const filepath = path.join(SYSLOG_DIR, filename);
|
|||
|
|
|
|||
|
|
// 幂等性检查:同一 broadcast_id + dev_id + date 不重复创建
|
|||
|
|
if (fs.existsSync(filepath)) {
|
|||
|
|
console.log(' ⚠️ 幂等跳过: syslog/' + filename + ' 已存在');
|
|||
|
|
return { filepath, dateStr, broadcastId, devId, duplicate: true };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fs.writeFileSync(filepath, JSON.stringify(syslog, null, 2), 'utf8');
|
|||
|
|
console.log(' → 已保存: syslog/' + filename);
|
|||
|
|
return { filepath, dateStr, broadcastId, devId, duplicate: false };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ══════════════════════════════════════════════════════════
|
|||
|
|
// 步骤 ②:创建 Notion SYSLOG 收件箱条目
|
|||
|
|
// ══════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
async function createSyslogEntry(syslog, token, dbId, dateStr) {
|
|||
|
|
const broadcastId = syslog.broadcast_id || syslog.broadcastId || 'UNKNOWN';
|
|||
|
|
const devId = syslog.dev_id || syslog.developer_id || 'UNKNOWN';
|
|||
|
|
|
|||
|
|
const properties = {
|
|||
|
|
'标题': titleProp('SYSLOG · ' + broadcastId + ' · ' + devId),
|
|||
|
|
'广播编号': richTextProp(broadcastId),
|
|||
|
|
'开发者编号': richTextProp(devId),
|
|||
|
|
'协议版本': richTextProp(String(syslog.protocol_version || '')),
|
|||
|
|
'提交日期': dateProp(dateStr),
|
|||
|
|
'霜砚已读': checkboxProp(false),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 可选字段
|
|||
|
|
if (syslog.status) properties['状态'] = selectProp(syslog.status);
|
|||
|
|
if (syslog.summary) properties['摘要'] = richTextProp(syslog.summary);
|
|||
|
|
|
|||
|
|
const body = {
|
|||
|
|
parent: { database_id: dbId },
|
|||
|
|
properties,
|
|||
|
|
children: [
|
|||
|
|
{
|
|||
|
|
object: 'block',
|
|||
|
|
type: 'code',
|
|||
|
|
code: {
|
|||
|
|
rich_text: richText(JSON.stringify(syslog, null, 2)),
|
|||
|
|
language: 'json',
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const result = await notionPost('/v1/pages', body, token);
|
|||
|
|
console.log(' → Notion SYSLOG 收件箱条目已创建: ' + result.id);
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ══════════════════════════════════════════════════════════
|
|||
|
|
// 步骤 ③:创建霜砚工单
|
|||
|
|
// ══════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
async function createTicket(syslog, token, dbId, dateStr) {
|
|||
|
|
const broadcastId = syslog.broadcast_id || syslog.broadcastId || 'UNKNOWN';
|
|||
|
|
const devId = syslog.dev_id || syslog.developer_id || 'UNKNOWN';
|
|||
|
|
const devName = syslog.developer_name || syslog.dev_name || '';
|
|||
|
|
const now = new Date().toISOString();
|
|||
|
|
|
|||
|
|
const properties = {
|
|||
|
|
'标题': titleProp('[自动] SYSLOG处理|' + broadcastId + ' · ' + devId),
|
|||
|
|
'操作类型': selectProp('其他'),
|
|||
|
|
'提交者': richTextProp('铸渊Agent·自动管道'),
|
|||
|
|
'提交日期': dateProp(dateStr),
|
|||
|
|
'状态': selectProp('待处理'),
|
|||
|
|
'优先级': selectProp('P1'),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 添加关联信息
|
|||
|
|
if (broadcastId !== 'UNKNOWN') properties['广播编号'] = richTextProp(broadcastId);
|
|||
|
|
if (devId !== 'UNKNOWN') properties['开发者编号'] = richTextProp(devId);
|
|||
|
|
|
|||
|
|
// Phase B1 标准化工单内容
|
|||
|
|
const ticketContent = [
|
|||
|
|
'## 📡 标准化工单 · Phase B1',
|
|||
|
|
'',
|
|||
|
|
'| 字段 | 值 |',
|
|||
|
|
'|------|-----|',
|
|||
|
|
'| 工单类型 | SYSLOG处理 |',
|
|||
|
|
'| 状态 | 待处理 |',
|
|||
|
|
'| 来源 | GitHub Actions |',
|
|||
|
|
'| taskId | ' + broadcastId + ' |',
|
|||
|
|
'| developer | ' + devId + ' ' + devName + ' |',
|
|||
|
|
'| created_at | ' + now + ' |',
|
|||
|
|
'| retry_count | 0 |',
|
|||
|
|
'| receipt_status | pending |',
|
|||
|
|
].join('\n');
|
|||
|
|
|
|||
|
|
const body = {
|
|||
|
|
parent: { database_id: dbId },
|
|||
|
|
properties,
|
|||
|
|
children: [
|
|||
|
|
{
|
|||
|
|
object: 'block',
|
|||
|
|
type: 'paragraph',
|
|||
|
|
paragraph: {
|
|||
|
|
rich_text: [{
|
|||
|
|
type: 'text',
|
|||
|
|
text: { content: ticketContent },
|
|||
|
|
}],
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
object: 'block',
|
|||
|
|
type: 'code',
|
|||
|
|
code: {
|
|||
|
|
rich_text: richText(JSON.stringify(syslog, null, 2)),
|
|||
|
|
language: 'json',
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const result = await notionPost('/v1/pages', body, token);
|
|||
|
|
console.log(' → 标准化工单已创建: ' + result.id);
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ══════════════════════════════════════════════════════════
|
|||
|
|
// 主流程
|
|||
|
|
// ══════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
async function main() {
|
|||
|
|
const syslogJson = process.env.SYSLOG_JSON;
|
|||
|
|
const notionToken = process.env.NOTION_TOKEN;
|
|||
|
|
const syslogDbId = process.env.NOTION_SYSLOG_DB_ID;
|
|||
|
|
const ticketDbId = process.env.NOTION_TICKET_DB_ID;
|
|||
|
|
|
|||
|
|
if (!syslogJson) {
|
|||
|
|
console.error('❌ 缺少 SYSLOG_JSON 环境变量');
|
|||
|
|
process.exit(1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 解析 SYSLOG JSON
|
|||
|
|
let syslog;
|
|||
|
|
try {
|
|||
|
|
syslog = JSON.parse(syslogJson);
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('❌ SYSLOG JSON 解析失败: ' + e.message);
|
|||
|
|
process.exit(1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 验证 SYSLOG
|
|||
|
|
console.log('🔍 验证 SYSLOG...');
|
|||
|
|
const errors = validateSyslog(syslog);
|
|||
|
|
if (errors.length > 0) {
|
|||
|
|
console.error('❌ SYSLOG 验证失败:');
|
|||
|
|
errors.forEach(e => console.error(' - ' + e));
|
|||
|
|
process.exit(1);
|
|||
|
|
}
|
|||
|
|
console.log(' → 验证通过 (protocol_version: ' + syslog.protocol_version + ')');
|
|||
|
|
|
|||
|
|
// 步骤①:存入仓库
|
|||
|
|
console.log('💾 步骤①: 存入仓库 syslog/ 目录...');
|
|||
|
|
const { dateStr, duplicate } = saveSyslogToFile(syslog);
|
|||
|
|
|
|||
|
|
// 幂等性:重复的 SYSLOG 不再创建 Notion 工单
|
|||
|
|
if (duplicate) {
|
|||
|
|
console.log('⚠️ 相同 SYSLOG 已存在,跳过 Notion 操作(幂等保护)');
|
|||
|
|
console.log('✅ SYSLOG 接收处理完成(幂等跳过)');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 步骤②:创建 Notion SYSLOG 收件箱条目
|
|||
|
|
if (notionToken && syslogDbId) {
|
|||
|
|
console.log('📝 步骤②: 创建 Notion SYSLOG 收件箱条目...');
|
|||
|
|
await createSyslogEntry(syslog, notionToken, syslogDbId, dateStr);
|
|||
|
|
} else {
|
|||
|
|
console.log('⚠️ 步骤②: 缺少 NOTION_TOKEN 或 NOTION_SYSLOG_DB_ID,跳过 Notion 收件箱');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 步骤③:创建霜砚工单
|
|||
|
|
if (notionToken && ticketDbId) {
|
|||
|
|
console.log('📋 步骤③: 创建霜砚工单...');
|
|||
|
|
await createTicket(syslog, notionToken, ticketDbId, dateStr);
|
|||
|
|
} else {
|
|||
|
|
console.log('⚠️ 步骤③: 缺少 NOTION_TOKEN 或 NOTION_TICKET_DB_ID,跳过工单创建');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log('✅ SYSLOG 接收处理完成');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
main().catch(err => {
|
|||
|
|
console.error('❌ 处理失败: ' + err.message);
|
|||
|
|
process.exit(1);
|
|||
|
|
});
|