ops-sqlite.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  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. ];
  58. function rowToApi(row) {
  59. if (!row) return null;
  60. return {
  61. date: row.date,
  62. shopKey: row.shop_key,
  63. shopId: row.shop_id,
  64. shopName: row.shop_name,
  65. school: row.school,
  66. status: row.status,
  67. order_cnt: row.order_cnt,
  68. revenue: row.revenue,
  69. exposure: row.exposure,
  70. click_rate: row.click_rate,
  71. raw: safeJson(row.raw, {}),
  72. quality: safeJson(row.quality, []),
  73. uploadId: row.upload_id,
  74. updatedAt: row.updated_at,
  75. };
  76. }
  77. function safeJson(text, fallback) {
  78. try {
  79. const value = JSON.parse(text);
  80. return value == null ? fallback : value;
  81. } catch {
  82. return fallback;
  83. }
  84. }
  85. function createSqliteStore({ file }) {
  86. const dbFile = path.resolve(String(file || 'data/ops.db'));
  87. let db = null;
  88. let initPromise = null;
  89. function open() {
  90. if (db) return db;
  91. fs.mkdirSync(path.dirname(dbFile), { recursive: true });
  92. let DatabaseSync;
  93. try {
  94. ({ DatabaseSync } = require('node:sqlite'));
  95. } catch (e) {
  96. // Node < 22.5 没有内置 sqlite:单独给一个可读原因,前端据此提示升级 Node
  97. const err = new Error(`当前 Node 版本不支持内置 SQLite(需要 22.5 及以上,当前 ${process.version})`);
  98. err.reason = 'sqlite-unavailable';
  99. err.cause = e;
  100. throw err;
  101. }
  102. db = new DatabaseSync(dbFile);
  103. db.exec('PRAGMA journal_mode = WAL');
  104. db.exec('PRAGMA busy_timeout = 5000');
  105. db.exec('PRAGMA foreign_keys = ON');
  106. return db;
  107. }
  108. async function init() {
  109. if (!initPromise) {
  110. initPromise = (async () => {
  111. const handle = open();
  112. for (const sql of DDL) handle.exec(sql);
  113. handle.prepare('INSERT INTO ops_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
  114. .run('schema_version', SCHEMA_VERSION);
  115. return true;
  116. })().catch((e) => {
  117. initPromise = null; // 允许下次重试
  118. throw e;
  119. });
  120. }
  121. return initPromise;
  122. }
  123. async function health() {
  124. try {
  125. open().prepare('SELECT 1 AS ok').get();
  126. return { ok: true, kind: 'sqlite', detail: dbFile };
  127. } catch (e) {
  128. return { ok: false, kind: 'sqlite', detail: e.message || String(e) };
  129. }
  130. }
  131. function insertUpload(upload) {
  132. open().prepare(`INSERT INTO ops_upload
  133. (id, original_name, sha256, size_bytes, uploaded_at, year, parser_kind, parser_version, report, content)
  134. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
  135. upload.id,
  136. upload.originalName,
  137. upload.sha256,
  138. upload.sizeBytes,
  139. upload.uploadedAt,
  140. upload.year,
  141. upload.parserKind,
  142. upload.parserVersion,
  143. JSON.stringify(upload.report || {}),
  144. upload.content,
  145. );
  146. }
  147. function upsertRows(rows) {
  148. const stmt = open().prepare(`INSERT INTO ops_row
  149. (date, shop_key, upload_id, shop_id, shop_name, school, status,
  150. order_cnt, revenue, exposure, click_rate, raw, quality, updated_at)
  151. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  152. ON CONFLICT(date, shop_key) DO UPDATE SET
  153. upload_id = excluded.upload_id,
  154. shop_id = excluded.shop_id,
  155. shop_name = excluded.shop_name,
  156. school = excluded.school,
  157. status = excluded.status,
  158. order_cnt = excluded.order_cnt,
  159. revenue = excluded.revenue,
  160. exposure = excluded.exposure,
  161. click_rate = excluded.click_rate,
  162. raw = excluded.raw,
  163. quality = excluded.quality,
  164. updated_at = excluded.updated_at`);
  165. const now = new Date().toISOString();
  166. for (const r of rows) {
  167. stmt.run(
  168. r.date, r.shopKey, r.uploadId, r.shopId || null, r.shopName, r.school,
  169. r.status || null, r.order_cnt, r.revenue, r.exposure, r.click_rate,
  170. JSON.stringify(r.raw || {}), JSON.stringify(r.quality || []), now,
  171. );
  172. }
  173. }
  174. function upsertShops(shops) {
  175. const stmt = open().prepare(`INSERT INTO ops_shop
  176. (key, shop_id, shop_name, school, status, last_upload_id, first_seen, last_seen)
  177. VALUES (?, ?, ?, ?, ?, ?, ?, ?)
  178. ON CONFLICT(key) DO UPDATE SET
  179. shop_id = excluded.shop_id,
  180. shop_name = excluded.shop_name,
  181. school = excluded.school,
  182. status = excluded.status,
  183. last_upload_id = excluded.last_upload_id,
  184. last_seen = excluded.last_seen`);
  185. const now = new Date().toISOString();
  186. for (const s of shops) {
  187. stmt.run(s.key, s.shopId || null, s.shopName, s.school, s.status || null, s.lastUploadId || null, now, now);
  188. }
  189. }
  190. function deleteRowsByUpload(uploadId) {
  191. open().prepare('DELETE FROM ops_row WHERE upload_id = ?').run(uploadId);
  192. }
  193. function updateReport(id, report) {
  194. open().prepare('UPDATE ops_upload SET report = ? WHERE id = ?').run(JSON.stringify(report || {}), id);
  195. }
  196. async function withTransaction(fn) {
  197. const handle = open();
  198. handle.exec('BEGIN IMMEDIATE');
  199. try {
  200. const result = await fn({ insertUpload, upsertRows, upsertShops, deleteRowsByUpload, updateReport });
  201. handle.exec('COMMIT');
  202. // 写事务结束后做一次 checkpoint:让 data/ops.db 自包含,备份时不必关心 -wal
  203. try { handle.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch { /* 有读事务时跳过 */ }
  204. return result;
  205. } catch (e) {
  206. try { handle.exec('ROLLBACK'); } catch { /* 事务可能已结束 */ }
  207. throw e;
  208. }
  209. }
  210. async function findUploadBySha(sha256) {
  211. const row = open().prepare('SELECT id FROM ops_upload WHERE sha256 = ? ORDER BY uploaded_at DESC LIMIT 1').get(sha256);
  212. return row ? row.id : null;
  213. }
  214. function uploadRowToApi(row) {
  215. if (!row) return null;
  216. const report = safeJson(row.report, {});
  217. return {
  218. id: row.id,
  219. originalName: row.original_name,
  220. sha256: row.sha256,
  221. sizeBytes: row.size_bytes,
  222. uploadedAt: row.uploaded_at,
  223. year: row.year,
  224. parserKind: row.parser_kind,
  225. parserVersion: row.parser_version,
  226. report,
  227. rows: report.rows || 0,
  228. shops: report.shops || 0,
  229. warningCount: report.warningCount || 0,
  230. };
  231. }
  232. async function listUploads() {
  233. return open().prepare(`SELECT id, original_name, sha256, size_bytes, uploaded_at, year,
  234. parser_kind, parser_version, report FROM ops_upload ORDER BY uploaded_at DESC`).all().map(uploadRowToApi);
  235. }
  236. async function getUpload(id) {
  237. const row = open().prepare(`SELECT id, original_name, sha256, size_bytes, uploaded_at, year,
  238. parser_kind, parser_version, report FROM ops_upload WHERE id = ?`).get(id);
  239. return uploadRowToApi(row);
  240. }
  241. async function getContent(id) {
  242. const row = open().prepare('SELECT content, original_name FROM ops_upload WHERE id = ?').get(id);
  243. if (!row) return null;
  244. return { content: Buffer.from(row.content), originalName: row.original_name };
  245. }
  246. async function queryRows({ from, to, schools, shops } = {}) {
  247. const where = [];
  248. const params = [];
  249. if (from) { where.push('date >= ?'); params.push(String(from)); }
  250. if (to) { where.push('date <= ?'); params.push(String(to)); }
  251. if (Array.isArray(schools) && schools.length) {
  252. where.push(`school IN (${schools.map(() => '?').join(',')})`);
  253. params.push(...schools.map(String));
  254. }
  255. if (Array.isArray(shops) && shops.length) {
  256. where.push(`shop_key IN (${shops.map(() => '?').join(',')})`);
  257. params.push(...shops.map(String));
  258. }
  259. const sql = `SELECT * FROM ops_row ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
  260. ORDER BY school ASC, shop_name ASC, date ASC`;
  261. return open().prepare(sql).all(...params).map(rowToApi);
  262. }
  263. async function summary() {
  264. const handle = open();
  265. const uploadCount = handle.prepare('SELECT COUNT(*) AS c FROM ops_upload').get().c;
  266. const rowCount = handle.prepare('SELECT COUNT(*) AS c FROM ops_row').get().c;
  267. const range = handle.prepare('SELECT MIN(date) AS f, MAX(date) AS t FROM ops_row').get();
  268. const schools = handle.prepare('SELECT school, COUNT(*) AS c FROM ops_shop GROUP BY school ORDER BY school ASC')
  269. .all().map((r) => ({ name: r.school, shopCount: r.c }));
  270. const shops = handle.prepare('SELECT key, shop_id, shop_name, school, status FROM ops_shop ORDER BY school ASC, shop_name ASC')
  271. .all().map((r) => ({ key: r.key, shopId: r.shop_id, shopName: r.shop_name, school: r.school, status: r.status }));
  272. const last = handle.prepare(`SELECT id, original_name, uploaded_at, report FROM ops_upload
  273. ORDER BY uploaded_at DESC LIMIT 1`).get();
  274. return {
  275. schools,
  276. shops,
  277. uploadCount,
  278. rowCount,
  279. coverage: range && range.f ? { from: range.f, to: range.t } : null,
  280. lastUpload: last ? {
  281. id: last.id,
  282. at: last.uploaded_at,
  283. originalName: last.original_name,
  284. rows: (safeJson(last.report, {}) || {}).rows || 0,
  285. warningCount: (safeJson(last.report, {}) || {}).warningCount || 0,
  286. } : null,
  287. };
  288. }
  289. async function close() {
  290. if (db) {
  291. try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch { /* ignore */ }
  292. try { db.close(); } catch { /* ignore */ }
  293. db = null;
  294. initPromise = null;
  295. }
  296. }
  297. return {
  298. kind: 'sqlite',
  299. file: dbFile,
  300. init,
  301. health,
  302. withTransaction,
  303. insertUpload,
  304. upsertRows,
  305. upsertShops,
  306. deleteRowsByUpload,
  307. updateReport,
  308. findUploadBySha,
  309. listUploads,
  310. getUpload,
  311. getContent,
  312. queryRows,
  313. summary,
  314. close,
  315. };
  316. }
  317. module.exports = { createSqliteStore };