ops-sqlite.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504
  1. 'use strict';
  2. /**
  3. * ops 存储 · SQLite 驱动(node:sqlite,零依赖)
  4. * ----------------------------------------------------------------------------
  5. * 只负责「表和读写」,不含业务判断。接口与 PG 驱动保持一致,路由层不感知方言。
  6. * 需要 Node >= 22.5(node:sqlite 内置模块)。
  7. */
  8. const fs = require('fs');
  9. const path = require('path');
  10. const SCHEMA_VERSION = '1';
  11. const DDL = [
  12. `CREATE TABLE IF NOT EXISTS ops_meta (
  13. key TEXT PRIMARY KEY,
  14. value TEXT NOT NULL
  15. )`,
  16. `CREATE TABLE IF NOT EXISTS ops_upload (
  17. id TEXT PRIMARY KEY,
  18. original_name TEXT NOT NULL,
  19. sha256 TEXT NOT NULL,
  20. size_bytes INTEGER NOT NULL,
  21. uploaded_at TEXT NOT NULL,
  22. year INTEGER NOT NULL,
  23. parser_kind TEXT NOT NULL,
  24. parser_version INTEGER NOT NULL,
  25. report TEXT NOT NULL DEFAULT '{}',
  26. content BLOB NOT NULL
  27. )`,
  28. `CREATE INDEX IF NOT EXISTS ops_upload_sha256_idx ON ops_upload (sha256, uploaded_at DESC)`,
  29. `CREATE TABLE IF NOT EXISTS ops_shop (
  30. key TEXT PRIMARY KEY,
  31. shop_id TEXT,
  32. shop_name TEXT NOT NULL,
  33. school TEXT NOT NULL,
  34. status TEXT,
  35. last_upload_id TEXT REFERENCES ops_upload(id) ON DELETE SET NULL,
  36. first_seen TEXT NOT NULL,
  37. last_seen TEXT NOT NULL
  38. )`,
  39. `CREATE TABLE IF NOT EXISTS ops_row (
  40. date TEXT NOT NULL,
  41. shop_key TEXT NOT NULL,
  42. upload_id TEXT NOT NULL REFERENCES ops_upload(id) ON DELETE CASCADE,
  43. shop_id TEXT,
  44. shop_name TEXT NOT NULL,
  45. school TEXT NOT NULL,
  46. status TEXT,
  47. order_cnt INTEGER,
  48. revenue REAL,
  49. exposure INTEGER,
  50. click_rate REAL,
  51. raw TEXT NOT NULL DEFAULT '{}',
  52. quality TEXT NOT NULL DEFAULT '[]',
  53. updated_at TEXT NOT NULL,
  54. PRIMARY KEY (date, shop_key)
  55. )`,
  56. `CREATE INDEX IF NOT EXISTS ops_row_school_date_idx ON ops_row (school, date)`,
  57. `CREATE TABLE IF NOT EXISTS ops_platform_row (
  58. date TEXT NOT NULL,
  59. shop_id TEXT NOT NULL,
  60. upload_id TEXT NOT NULL REFERENCES ops_upload(id) ON DELETE CASCADE,
  61. school TEXT NOT NULL DEFAULT '',
  62. shop_name TEXT NOT NULL DEFAULT '',
  63. data TEXT NOT NULL DEFAULT '{}',
  64. updated_at TEXT NOT NULL,
  65. PRIMARY KEY (date, shop_id)
  66. )`,
  67. `CREATE INDEX IF NOT EXISTS ops_platform_row_date_idx ON ops_platform_row (date)`,
  68. `CREATE TABLE IF NOT EXISTS ops_report (
  69. id TEXT PRIMARY KEY,
  70. created_at TEXT NOT NULL,
  71. period_from TEXT NOT NULL,
  72. period_to TEXT NOT NULL,
  73. schools TEXT NOT NULL DEFAULT '[]',
  74. shops TEXT NOT NULL DEFAULT '[]',
  75. source TEXT NOT NULL DEFAULT 'rules',
  76. model TEXT NOT NULL DEFAULT '',
  77. title TEXT NOT NULL DEFAULT '',
  78. report TEXT NOT NULL
  79. )`,
  80. `CREATE INDEX IF NOT EXISTS ops_report_created_idx ON ops_report (created_at DESC)`,
  81. `CREATE TABLE IF NOT EXISTS ops_report_cache (
  82. cache_key TEXT PRIMARY KEY,
  83. updated_at TEXT NOT NULL,
  84. data_version TEXT NOT NULL,
  85. source TEXT NOT NULL DEFAULT 'rules',
  86. report TEXT NOT NULL
  87. )`,
  88. ];
  89. function rowToApi(row) {
  90. if (!row) return null;
  91. return {
  92. date: row.date,
  93. shopKey: row.shop_key,
  94. shopId: row.shop_id,
  95. shopName: row.shop_name,
  96. school: row.school,
  97. status: row.status,
  98. order_cnt: row.order_cnt,
  99. revenue: row.revenue,
  100. exposure: row.exposure,
  101. click_rate: row.click_rate,
  102. raw: safeJson(row.raw, {}),
  103. quality: safeJson(row.quality, []),
  104. uploadId: row.upload_id,
  105. updatedAt: row.updated_at,
  106. };
  107. }
  108. function safeJson(text, fallback) {
  109. try {
  110. const value = JSON.parse(text);
  111. return value == null ? fallback : value;
  112. } catch {
  113. return fallback;
  114. }
  115. }
  116. function createSqliteStore({ file }) {
  117. const dbFile = path.resolve(String(file || 'data/ops.db'));
  118. let db = null;
  119. let initPromise = null;
  120. function open() {
  121. if (db) return db;
  122. fs.mkdirSync(path.dirname(dbFile), { recursive: true });
  123. let DatabaseSync;
  124. try {
  125. ({ DatabaseSync } = require('node:sqlite'));
  126. } catch (e) {
  127. // Node < 22.5 没有内置 sqlite:单独给一个可读原因,前端据此提示升级 Node
  128. const err = new Error(`当前 Node 版本不支持内置 SQLite(需要 22.5 及以上,当前 ${process.version})`);
  129. err.reason = 'sqlite-unavailable';
  130. err.cause = e;
  131. throw err;
  132. }
  133. db = new DatabaseSync(dbFile);
  134. db.exec('PRAGMA journal_mode = WAL');
  135. db.exec('PRAGMA busy_timeout = 5000');
  136. db.exec('PRAGMA foreign_keys = ON');
  137. return db;
  138. }
  139. async function init() {
  140. if (!initPromise) {
  141. initPromise = (async () => {
  142. const handle = open();
  143. for (const sql of DDL) handle.exec(sql);
  144. handle.prepare('INSERT INTO ops_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
  145. .run('schema_version', SCHEMA_VERSION);
  146. // 建表后 checkpoint:空库阶段 data/ops.db 也是自包含的
  147. try { handle.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch { /* ignore */ }
  148. return true;
  149. })().catch((e) => {
  150. initPromise = null; // 允许下次重试
  151. throw e;
  152. });
  153. }
  154. return initPromise;
  155. }
  156. async function health() {
  157. try {
  158. open().prepare('SELECT 1 AS ok').get();
  159. return { ok: true, kind: 'sqlite', detail: dbFile };
  160. } catch (e) {
  161. return { ok: false, kind: 'sqlite', detail: e.message || String(e) };
  162. }
  163. }
  164. function insertUpload(upload) {
  165. open().prepare(`INSERT INTO ops_upload
  166. (id, original_name, sha256, size_bytes, uploaded_at, year, parser_kind, parser_version, report, content)
  167. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
  168. upload.id,
  169. upload.originalName,
  170. upload.sha256,
  171. upload.sizeBytes,
  172. upload.uploadedAt,
  173. upload.year,
  174. upload.parserKind,
  175. upload.parserVersion,
  176. JSON.stringify(upload.report || {}),
  177. upload.content,
  178. );
  179. }
  180. function upsertRows(rows) {
  181. const stmt = open().prepare(`INSERT INTO ops_row
  182. (date, shop_key, upload_id, shop_id, shop_name, school, status,
  183. order_cnt, revenue, exposure, click_rate, raw, quality, updated_at)
  184. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  185. ON CONFLICT(date, shop_key) DO UPDATE SET
  186. upload_id = excluded.upload_id,
  187. shop_id = excluded.shop_id,
  188. shop_name = excluded.shop_name,
  189. school = excluded.school,
  190. status = excluded.status,
  191. order_cnt = excluded.order_cnt,
  192. revenue = excluded.revenue,
  193. exposure = excluded.exposure,
  194. click_rate = excluded.click_rate,
  195. raw = excluded.raw,
  196. quality = excluded.quality,
  197. updated_at = excluded.updated_at`);
  198. const now = new Date().toISOString();
  199. for (const r of rows) {
  200. stmt.run(
  201. r.date, r.shopKey, r.uploadId, r.shopId || null, r.shopName, r.school,
  202. r.status || null, r.order_cnt, r.revenue, r.exposure, r.click_rate,
  203. JSON.stringify(r.raw || {}), JSON.stringify(r.quality || []), now,
  204. );
  205. }
  206. }
  207. function upsertShops(shops) {
  208. const stmt = open().prepare(`INSERT INTO ops_shop
  209. (key, shop_id, shop_name, school, status, last_upload_id, first_seen, last_seen)
  210. VALUES (?, ?, ?, ?, ?, ?, ?, ?)
  211. ON CONFLICT(key) DO UPDATE SET
  212. shop_id = excluded.shop_id,
  213. shop_name = excluded.shop_name,
  214. school = excluded.school,
  215. status = excluded.status,
  216. last_upload_id = excluded.last_upload_id,
  217. last_seen = excluded.last_seen`);
  218. const now = new Date().toISOString();
  219. for (const s of shops) {
  220. stmt.run(s.key, s.shopId || null, s.shopName, s.school, s.status || null, s.lastUploadId || null, now, now);
  221. }
  222. }
  223. function deleteRowsByUpload(uploadId) {
  224. open().prepare('DELETE FROM ops_row WHERE upload_id = ?').run(uploadId);
  225. }
  226. function upsertPlatformRows(rows) {
  227. const stmt = open().prepare(`INSERT INTO ops_platform_row
  228. (date, shop_id, upload_id, school, shop_name, data, updated_at)
  229. VALUES (?, ?, ?, ?, ?, ?, ?)
  230. ON CONFLICT(date, shop_id) DO UPDATE SET
  231. upload_id = excluded.upload_id,
  232. school = excluded.school,
  233. shop_name = excluded.shop_name,
  234. data = excluded.data,
  235. updated_at = excluded.updated_at`);
  236. const now = new Date().toISOString();
  237. for (const r of rows) {
  238. stmt.run(r.date, r.shopId, r.uploadId, r.school || '', r.shopName || '', JSON.stringify(r.data || {}), now);
  239. }
  240. }
  241. function deletePlatformRowsByUpload(uploadId) {
  242. open().prepare('DELETE FROM ops_platform_row WHERE upload_id = ?').run(uploadId);
  243. }
  244. function deletePlatformRowsBySha(sha256) {
  245. open().prepare('DELETE FROM ops_platform_row WHERE upload_id IN (SELECT id FROM ops_upload WHERE sha256 = ?)').run(sha256);
  246. }
  247. async function queryPlatformRows({ from, to } = {}) {
  248. const where = [];
  249. const params = [];
  250. if (from) { where.push('date >= ?'); params.push(String(from)); }
  251. if (to) { where.push('date <= ?'); params.push(String(to)); }
  252. const sql = `SELECT * FROM ops_platform_row ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY date ASC, shop_id ASC`;
  253. return open().prepare(sql).all(...params).map((row) => ({
  254. date: row.date,
  255. shopId: row.shop_id,
  256. uploadId: row.upload_id,
  257. school: row.school,
  258. shopName: row.shop_name,
  259. data: safeJson(row.data, {}),
  260. updatedAt: row.updated_at,
  261. }));
  262. }
  263. function deleteRowsBySha(sha256) {
  264. open().prepare('DELETE FROM ops_row WHERE upload_id IN (SELECT id FROM ops_upload WHERE sha256 = ?)').run(sha256);
  265. }
  266. function pruneOrphanShops() {
  267. open().prepare('DELETE FROM ops_shop WHERE key NOT IN (SELECT DISTINCT shop_key FROM ops_row)').run();
  268. }
  269. function updateReport(id, report) {
  270. open().prepare('UPDATE ops_upload SET report = ? WHERE id = ?').run(JSON.stringify(report || {}), id);
  271. }
  272. async function withTransaction(fn) {
  273. const handle = open();
  274. handle.exec('BEGIN IMMEDIATE');
  275. try {
  276. const result = await fn({ insertUpload, upsertRows, upsertShops, upsertPlatformRows, deleteRowsByUpload, deleteRowsBySha, deletePlatformRowsByUpload, deletePlatformRowsBySha, pruneOrphanShops, updateReport, findUploadBySha });
  277. handle.exec('COMMIT');
  278. // 写事务结束后做一次 checkpoint:让 data/ops.db 自包含,备份时不必关心 -wal
  279. try { handle.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch { /* 有读事务时跳过 */ }
  280. return result;
  281. } catch (e) {
  282. try { handle.exec('ROLLBACK'); } catch { /* 事务可能已结束 */ }
  283. throw e;
  284. }
  285. }
  286. async function findUploadBySha(sha256) {
  287. const row = open().prepare('SELECT id FROM ops_upload WHERE sha256 = ? ORDER BY uploaded_at DESC LIMIT 1').get(sha256);
  288. return row ? row.id : null;
  289. }
  290. function uploadRowToApi(row) {
  291. if (!row) return null;
  292. const report = safeJson(row.report, {});
  293. return {
  294. id: row.id,
  295. originalName: row.original_name,
  296. sha256: row.sha256,
  297. sizeBytes: row.size_bytes,
  298. uploadedAt: row.uploaded_at,
  299. year: row.year,
  300. parserKind: row.parser_kind,
  301. parserVersion: row.parser_version,
  302. report,
  303. rows: report.rows || 0,
  304. shops: report.shops || 0,
  305. warningCount: report.warningCount || 0,
  306. };
  307. }
  308. async function listUploads() {
  309. return open().prepare(`SELECT id, original_name, sha256, size_bytes, uploaded_at, year,
  310. parser_kind, parser_version, report FROM ops_upload ORDER BY uploaded_at DESC`).all().map(uploadRowToApi);
  311. }
  312. async function getUpload(id) {
  313. const row = open().prepare(`SELECT id, original_name, sha256, size_bytes, uploaded_at, year,
  314. parser_kind, parser_version, report FROM ops_upload WHERE id = ?`).get(id);
  315. return uploadRowToApi(row);
  316. }
  317. async function getContent(id) {
  318. const row = open().prepare('SELECT content, original_name FROM ops_upload WHERE id = ?').get(id);
  319. if (!row) return null;
  320. return { content: Buffer.from(row.content), originalName: row.original_name };
  321. }
  322. async function queryRows({ from, to, schools, shops } = {}) {
  323. const where = [];
  324. const params = [];
  325. if (from) { where.push('date >= ?'); params.push(String(from)); }
  326. if (to) { where.push('date <= ?'); params.push(String(to)); }
  327. if (Array.isArray(schools) && schools.length) {
  328. where.push(`school IN (${schools.map(() => '?').join(',')})`);
  329. params.push(...schools.map(String));
  330. }
  331. if (Array.isArray(shops) && shops.length) {
  332. where.push(`shop_key IN (${shops.map(() => '?').join(',')})`);
  333. params.push(...shops.map(String));
  334. }
  335. const sql = `SELECT * FROM ops_row ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
  336. ORDER BY school ASC, shop_name ASC, date ASC`;
  337. return open().prepare(sql).all(...params).map(rowToApi);
  338. }
  339. async function summary() {
  340. const handle = open();
  341. const uploadCount = handle.prepare('SELECT COUNT(*) AS c FROM ops_upload').get().c;
  342. const rowCount = handle.prepare('SELECT COUNT(*) AS c FROM ops_row').get().c;
  343. const platformRowCount = handle.prepare('SELECT COUNT(*) AS c FROM ops_platform_row').get().c;
  344. const range = handle.prepare(`SELECT MIN(d) AS f, MAX(d) AS t FROM (
  345. SELECT date AS d FROM ops_row UNION SELECT date AS d FROM ops_platform_row)`).get();
  346. const matrixRowCount = handle.prepare(`SELECT COUNT(*) AS c FROM (
  347. SELECT date, shop_key FROM ops_row UNION SELECT date, shop_id FROM ops_platform_row)`).get().c;
  348. const schools = handle.prepare(`SELECT school, COUNT(DISTINCT shop) AS c FROM (
  349. SELECT school, shop_id AS shop FROM ops_platform_row WHERE school <> ''
  350. UNION SELECT school, shop_key AS shop FROM ops_row WHERE school <> ''
  351. UNION SELECT school, key AS shop FROM ops_shop WHERE school <> ''
  352. ) GROUP BY school ORDER BY school ASC`)
  353. .all().map((r) => ({ name: r.school, shopCount: r.c }));
  354. const shops = handle.prepare(`SELECT key, MAX(shop_id) AS shop_id, MAX(shop_name) AS shop_name,
  355. MAX(school) AS school, MAX(status) AS status FROM (
  356. SELECT key, shop_id, shop_name, school, status FROM ops_shop
  357. UNION ALL
  358. SELECT shop_id AS key, shop_id, shop_name, school, NULL AS status FROM ops_platform_row
  359. ) GROUP BY key ORDER BY school ASC, shop_name ASC`)
  360. .all().map((r) => ({ key: r.key, shopId: r.shop_id, shopName: r.shop_name, school: r.school, status: r.status }));
  361. const last = handle.prepare(`SELECT id, original_name, uploaded_at, report FROM ops_upload
  362. ORDER BY uploaded_at DESC LIMIT 1`).get();
  363. return {
  364. schools,
  365. shops,
  366. uploadCount,
  367. rowCount,
  368. platformRowCount,
  369. matrixRowCount,
  370. coverage: range && range.f ? { from: range.f, to: range.t } : null,
  371. lastUpload: last ? {
  372. id: last.id,
  373. at: last.uploaded_at,
  374. originalName: last.original_name,
  375. rows: (safeJson(last.report, {}) || {}).rows || 0,
  376. warningCount: (safeJson(last.report, {}) || {}).warningCount || 0,
  377. } : null,
  378. };
  379. }
  380. async function getReportCache(cacheKey, dataVersion) {
  381. const row = open().prepare('SELECT report, source, data_version FROM ops_report_cache WHERE cache_key = ?').get(cacheKey);
  382. if (!row || row.data_version !== dataVersion) return null;
  383. return { report: safeJson(row.report, null), source: row.source };
  384. }
  385. async function saveReportCache(record) {
  386. open().prepare(`INSERT INTO ops_report_cache (cache_key, updated_at, data_version, source, report)
  387. VALUES (?, ?, ?, ?, ?)
  388. ON CONFLICT(cache_key) DO UPDATE SET
  389. updated_at = excluded.updated_at,
  390. data_version = excluded.data_version,
  391. source = excluded.source,
  392. report = excluded.report`).run(
  393. record.cacheKey, record.updatedAt, record.dataVersion, record.source, JSON.stringify(record.report),
  394. );
  395. }
  396. async function insertReport(record) {
  397. open().prepare(`INSERT INTO ops_report
  398. (id, created_at, period_from, period_to, schools, shops, source, model, title, report)
  399. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
  400. record.id, record.createdAt, record.periodFrom, record.periodTo,
  401. JSON.stringify(record.schools || []), JSON.stringify(record.shops || []),
  402. record.source || 'rules', record.model || '', record.title || '',
  403. JSON.stringify(record.report),
  404. );
  405. }
  406. async function listReports() {
  407. return open().prepare(`SELECT id, created_at, period_from, period_to, schools, shops, source, model, title, report
  408. FROM ops_report ORDER BY created_at DESC LIMIT 100`).all().map((row) => {
  409. const report = safeJson(row.report, {});
  410. return {
  411. id: row.id,
  412. createdAt: row.created_at,
  413. from: row.period_from,
  414. to: row.period_to,
  415. source: row.source,
  416. model: row.model,
  417. title: row.title,
  418. schools: safeJson(row.schools, []),
  419. shops: safeJson(row.shops, []),
  420. kpis: (report.kpis || []).slice(0, 3).map((k) => ({ label: k.label, current: k.current, unit: k.unit })),
  421. };
  422. });
  423. }
  424. async function getReport(id) {
  425. const row = open().prepare('SELECT report FROM ops_report WHERE id = ?').get(id);
  426. return row ? safeJson(row.report, null) : null;
  427. }
  428. async function deleteReport(id) {
  429. return open().prepare('DELETE FROM ops_report WHERE id = ?').run(id).changes;
  430. }
  431. async function close() {
  432. if (db) {
  433. try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch { /* ignore */ }
  434. try { db.close(); } catch { /* ignore */ }
  435. db = null;
  436. initPromise = null;
  437. }
  438. }
  439. return {
  440. kind: 'sqlite',
  441. file: dbFile,
  442. init,
  443. health,
  444. withTransaction,
  445. insertUpload,
  446. upsertRows,
  447. upsertShops,
  448. deleteRowsByUpload,
  449. updateReport,
  450. findUploadBySha,
  451. queryPlatformRows,
  452. insertReport,
  453. getReportCache,
  454. saveReportCache,
  455. listReports,
  456. getReport,
  457. deleteReport,
  458. listUploads,
  459. getUpload,
  460. getContent,
  461. queryRows,
  462. summary,
  463. close,
  464. };
  465. }
  466. module.exports = { createSqliteStore };