webhook-store.js 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  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 eventScopeKey(eventId, account = {}) {
  62. const userId = String(account.userId || '').trim();
  63. return userId ? `${userId}:${eventId}` : String(eventId || '');
  64. }
  65. function markEventProcessed(eventId, account = {}) {
  66. const set = readProcessedEventIds();
  67. set.add(eventScopeKey(eventId, account));
  68. writeProcessedEventIds(set);
  69. return true;
  70. }
  71. function isDuplicateEvent(eventId, account = {}) {
  72. const set = readProcessedEventIds();
  73. return set.has(eventScopeKey(eventId, account));
  74. }
  75. function saveWebhookEventStructured(event, source = 'callback', rawBody = null) {
  76. const runDir = createRunDir('webhook', source);
  77. const eventId = event.eventId || `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
  78. const fileName = `event-${eventId}.json`;
  79. const filePath = path.join(runDir, fileName);
  80. const record = {
  81. receivedAt: new Date().toISOString(),
  82. source,
  83. eventId,
  84. parsedType: event.parsedType,
  85. cmd: event.cmd,
  86. msgType: event.msgType,
  87. guid: event.guid,
  88. externalUserId: event.externalUserId || undefined,
  89. status: 'PENDING',
  90. event: sanitizePayload(event.raw),
  91. rawBody: rawBody ? sanitizePayload(typeof rawBody === 'string' ? JSON.parse(rawBody) : rawBody) : undefined
  92. };
  93. atomicWriteJson(filePath, record);
  94. writeRunManifest(runDir, {
  95. tool: 'webhook-receiver',
  96. summary: { eventId, parsedType: event.parsedType, source },
  97. files: [fileName]
  98. });
  99. return filePath;
  100. }
  101. function saveRawEventFailed(body, source, reason, rawBody = null) {
  102. const runDir = createRunDir('webhook', `${source}-failed`);
  103. const hash = crypto.createHash('sha256').update(JSON.stringify(body || {})).digest('hex').slice(0, 16);
  104. const fileName = `event-failed-${hash}.json`;
  105. const filePath = path.join(runDir, fileName);
  106. const record = {
  107. receivedAt: new Date().toISOString(),
  108. source,
  109. reason,
  110. status: 'FAILED',
  111. body: sanitizePayload(body),
  112. rawBody: rawBody ? sanitizePayload(typeof rawBody === 'string' ? JSON.parse(rawBody) : rawBody) : undefined
  113. };
  114. atomicWriteJson(filePath, record);
  115. writeRunManifest(runDir, {
  116. tool: 'webhook-receiver',
  117. summary: { reason, source },
  118. files: [fileName]
  119. });
  120. return filePath;
  121. }
  122. function updateWebhookEventStatus(eventFilePath, status, result = null) {
  123. if (!eventFilePath || !fs.existsSync(eventFilePath)) return null;
  124. try {
  125. const record = safeReadJson(eventFilePath) || {};
  126. record.status = status;
  127. record.processedAt = new Date().toISOString();
  128. if (result !== null) record.result = result;
  129. atomicWriteJson(eventFilePath, record);
  130. return record;
  131. } catch (err) {
  132. console.warn('[WebhookStore] 更新事件状态失败:', err.message);
  133. return null;
  134. }
  135. }
  136. module.exports = {
  137. PROCESSED_EVENT_IDS_FILE,
  138. readProcessedEventIds,
  139. writeProcessedEventIds,
  140. markEventProcessed,
  141. isDuplicateEvent,
  142. saveWebhookEventStructured,
  143. saveRawEventFailed,
  144. updateWebhookEventStatus,
  145. sanitizePayload
  146. };