webhook-store.js 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. const fs = require('fs');
  2. const path = require('path');
  3. const crypto = require('crypto');
  4. const { categoryDir, createRunDir, writeRunManifest } = require('./output-paths');
  5. const PROCESSED_EVENT_IDS_FILE = path.join(categoryDir('webhook'), 'processed-event-ids.json');
  6. const MAX_PROCESSED_IDS = 5000;
  7. function safeReadJson(filePath, fallback = null) {
  8. try {
  9. if (!filePath || !fs.existsSync(filePath)) return fallback;
  10. return JSON.parse(fs.readFileSync(filePath, 'utf8'));
  11. } catch {
  12. return fallback;
  13. }
  14. }
  15. function atomicWriteJson(filePath, data) {
  16. const dir = path.dirname(filePath);
  17. fs.mkdirSync(dir, { recursive: true });
  18. const tmpPath = `${filePath}.tmp.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
  19. try {
  20. fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), 'utf8');
  21. fs.renameSync(tmpPath, filePath);
  22. } catch (err) {
  23. try { fs.unlinkSync(tmpPath); } catch {}
  24. throw err;
  25. }
  26. return filePath;
  27. }
  28. function sanitizePayload(payload) {
  29. if (!payload || typeof payload !== 'object') return payload;
  30. const output = {};
  31. for (const [key, value] of Object.entries(payload)) {
  32. if (/^(token|tokenId|secret|signature|privateKey|authorization)$/i.test(key)) {
  33. output[key] = '••••••••';
  34. continue;
  35. }
  36. if (value && typeof value === 'object') {
  37. output[key] = sanitizePayload(value);
  38. } else {
  39. output[key] = value;
  40. }
  41. }
  42. return output;
  43. }
  44. function readProcessedEventIds() {
  45. const data = safeReadJson(PROCESSED_EVENT_IDS_FILE, { ids: [], count: 0 });
  46. const ids = Array.isArray(data.ids) ? data.ids : [];
  47. return new Set(ids);
  48. }
  49. function writeProcessedEventIds(set) {
  50. let ids = Array.from(set);
  51. if (ids.length > MAX_PROCESSED_IDS) {
  52. ids = ids.slice(ids.length - MAX_PROCESSED_IDS);
  53. }
  54. atomicWriteJson(PROCESSED_EVENT_IDS_FILE, {
  55. ids,
  56. count: ids.length,
  57. updatedAt: new Date().toISOString()
  58. });
  59. return ids;
  60. }
  61. function markEventProcessed(eventId) {
  62. const set = readProcessedEventIds();
  63. set.add(eventId);
  64. writeProcessedEventIds(set);
  65. return true;
  66. }
  67. function isDuplicateEvent(eventId) {
  68. const set = readProcessedEventIds();
  69. return set.has(eventId);
  70. }
  71. function saveWebhookEventStructured(event, source = 'callback', rawBody = null) {
  72. const runDir = createRunDir('webhook', source);
  73. const eventId = event.eventId || `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
  74. const fileName = `event-${eventId}.json`;
  75. const filePath = path.join(runDir, fileName);
  76. const record = {
  77. receivedAt: new Date().toISOString(),
  78. source,
  79. eventId,
  80. parsedType: event.parsedType,
  81. cmd: event.cmd,
  82. msgType: event.msgType,
  83. guid: event.guid,
  84. externalUserId: event.externalUserId || undefined,
  85. status: 'PENDING',
  86. event: sanitizePayload(event.raw),
  87. rawBody: rawBody ? sanitizePayload(typeof rawBody === 'string' ? JSON.parse(rawBody) : rawBody) : undefined
  88. };
  89. atomicWriteJson(filePath, record);
  90. writeRunManifest(runDir, {
  91. tool: 'webhook-receiver',
  92. summary: { eventId, parsedType: event.parsedType, source },
  93. files: [fileName]
  94. });
  95. return filePath;
  96. }
  97. function saveRawEventFailed(body, source, reason, rawBody = null) {
  98. const runDir = createRunDir('webhook', `${source}-failed`);
  99. const hash = crypto.createHash('sha256').update(JSON.stringify(body || {})).digest('hex').slice(0, 16);
  100. const fileName = `event-failed-${hash}.json`;
  101. const filePath = path.join(runDir, fileName);
  102. const record = {
  103. receivedAt: new Date().toISOString(),
  104. source,
  105. reason,
  106. status: 'FAILED',
  107. body: sanitizePayload(body),
  108. rawBody: rawBody ? sanitizePayload(typeof rawBody === 'string' ? JSON.parse(rawBody) : rawBody) : undefined
  109. };
  110. atomicWriteJson(filePath, record);
  111. writeRunManifest(runDir, {
  112. tool: 'webhook-receiver',
  113. summary: { reason, source },
  114. files: [fileName]
  115. });
  116. return filePath;
  117. }
  118. function updateWebhookEventStatus(eventFilePath, status, result = null) {
  119. if (!eventFilePath || !fs.existsSync(eventFilePath)) return null;
  120. try {
  121. const record = safeReadJson(eventFilePath) || {};
  122. record.status = status;
  123. record.processedAt = new Date().toISOString();
  124. if (result !== null) record.result = result;
  125. atomicWriteJson(eventFilePath, record);
  126. return record;
  127. } catch (err) {
  128. console.warn('[WebhookStore] 更新事件状态失败:', err.message);
  129. return null;
  130. }
  131. }
  132. module.exports = {
  133. PROCESSED_EVENT_IDS_FILE,
  134. readProcessedEventIds,
  135. writeProcessedEventIds,
  136. markEventProcessed,
  137. isDuplicateEvent,
  138. saveWebhookEventStructured,
  139. saveRawEventFailed,
  140. updateWebhookEventStatus,
  141. sanitizePayload
  142. };