ops-pg.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463
  1. 'use strict';
  2. /**
  3. * ops 存储 · PostgreSQL 驱动
  4. * ----------------------------------------------------------------------------
  5. * 与 SQLite 驱动同接口。`pg` 为可选依赖(本期不安装):未安装时 health() 返回明确原因,
  6. * 只有「店铺运营」功能不可用,服务与其它功能不受影响。
  7. *
  8. * 上线切换步骤见设计文档 §5.6:改 config.storage.driver = "postgres" + 填连接串 + npm i pg。
  9. */
  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 timestamptz NOT NULL,
  22. year integer NOT NULL,
  23. parser_kind text NOT NULL,
  24. parser_version integer NOT NULL,
  25. report jsonb NOT NULL DEFAULT '{}'::jsonb,
  26. content bytea 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 timestamptz NOT NULL,
  37. last_seen timestamptz NOT NULL
  38. )`,
  39. `CREATE TABLE IF NOT EXISTS ops_row (
  40. date char(8) 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 numeric(14,2),
  49. exposure integer,
  50. click_rate numeric(8,6),
  51. raw jsonb NOT NULL DEFAULT '{}'::jsonb,
  52. quality text[] NOT NULL DEFAULT '{}',
  53. updated_at timestamptz 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 char(8) 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 jsonb NOT NULL DEFAULT '{}'::jsonb,
  64. updated_at timestamptz 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 timestamptz NOT NULL,
  71. period_from char(8) NOT NULL,
  72. period_to char(8) NOT NULL,
  73. schools jsonb NOT NULL DEFAULT '[]'::jsonb,
  74. shops jsonb NOT NULL DEFAULT '[]'::jsonb,
  75. source text NOT NULL DEFAULT 'rules',
  76. model text NOT NULL DEFAULT '',
  77. title text NOT NULL DEFAULT '',
  78. report jsonb 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 timestamptz NOT NULL,
  84. data_version text NOT NULL,
  85. source text NOT NULL DEFAULT 'rules',
  86. report jsonb NOT NULL
  87. )`,
  88. ];
  89. function rowToApi(row) {
  90. if (!row) return null;
  91. return {
  92. date: String(row.date || '').trim(),
  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 == null ? null : Number(row.order_cnt),
  99. revenue: row.revenue == null ? null : Number(row.revenue),
  100. exposure: row.exposure == null ? null : Number(row.exposure),
  101. click_rate: row.click_rate == null ? null : Number(row.click_rate),
  102. raw: row.raw || {},
  103. quality: row.quality || [],
  104. uploadId: row.upload_id,
  105. updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : row.updated_at,
  106. };
  107. }
  108. function createPgStore({ connectionString, ssl = true, max = 5, schema = 'public', connectTimeoutMs = 10000 }) {
  109. let pool = null;
  110. let initPromise = null;
  111. function getPool() {
  112. if (pool) return pool;
  113. let pg;
  114. try {
  115. pg = require('pg');
  116. } catch {
  117. const err = new Error('未安装 pg(npm i pg)');
  118. err.reason = 'pg-not-installed';
  119. throw err;
  120. }
  121. pool = new pg.Pool({
  122. connectionString,
  123. max: Number(max) || 5,
  124. ssl: ssl ? { rejectUnauthorized: false } : false,
  125. connectionTimeoutMillis: Number(connectTimeoutMs) || 10000,
  126. options: `-c search_path=${schema || 'public'}`,
  127. });
  128. return pool;
  129. }
  130. async function init() {
  131. if (!initPromise) {
  132. initPromise = (async () => {
  133. const client = await getPool().connect();
  134. try {
  135. for (const sql of DDL) await client.query(sql);
  136. await client.query(
  137. 'INSERT INTO ops_meta (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value',
  138. ['schema_version', SCHEMA_VERSION],
  139. );
  140. } finally {
  141. client.release();
  142. }
  143. return true;
  144. })().catch((e) => {
  145. initPromise = null;
  146. throw e;
  147. });
  148. }
  149. return initPromise;
  150. }
  151. async function health() {
  152. try {
  153. await getPool().query('SELECT 1 AS ok');
  154. return { ok: true, kind: 'postgres', detail: (schema || 'public') };
  155. } catch (e) {
  156. return { ok: false, kind: 'postgres', detail: e.message || String(e) };
  157. }
  158. }
  159. async function withTransaction(fn) {
  160. const client = await getPool().connect();
  161. try {
  162. await client.query('BEGIN');
  163. const result = await fn(makeTx(client));
  164. await client.query('COMMIT');
  165. return result;
  166. } catch (e) {
  167. try { await client.query('ROLLBACK'); } catch { /* 已结束 */ }
  168. throw e;
  169. } finally {
  170. client.release();
  171. }
  172. }
  173. function makeTx(client) {
  174. return {
  175. async insertUpload(upload) {
  176. await client.query(
  177. `INSERT INTO ops_upload (id, original_name, sha256, size_bytes, uploaded_at, year,
  178. parser_kind, parser_version, report, content)
  179. VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
  180. [upload.id, upload.originalName, upload.sha256, upload.sizeBytes, upload.uploadedAt,
  181. upload.year, upload.parserKind, upload.parserVersion, JSON.stringify(upload.report || {}), upload.content],
  182. );
  183. },
  184. async upsertRows(rows) {
  185. const now = new Date().toISOString();
  186. for (const r of rows) {
  187. await client.query(
  188. `INSERT INTO ops_row (date, shop_key, upload_id, shop_id, shop_name, school, status,
  189. order_cnt, revenue, exposure, click_rate, raw, quality, updated_at)
  190. VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
  191. ON CONFLICT (date, shop_key) DO UPDATE SET
  192. upload_id = EXCLUDED.upload_id, shop_id = EXCLUDED.shop_id, shop_name = EXCLUDED.shop_name,
  193. school = EXCLUDED.school, status = EXCLUDED.status, order_cnt = EXCLUDED.order_cnt,
  194. revenue = EXCLUDED.revenue, exposure = EXCLUDED.exposure, click_rate = EXCLUDED.click_rate,
  195. raw = EXCLUDED.raw, quality = EXCLUDED.quality, updated_at = EXCLUDED.updated_at`,
  196. [r.date, r.shopKey, r.uploadId, r.shopId || null, r.shopName, r.school, r.status || null,
  197. r.order_cnt, r.revenue, r.exposure, r.click_rate,
  198. JSON.stringify(r.raw || {}), r.quality || [], now],
  199. );
  200. }
  201. },
  202. async upsertShops(shops) {
  203. const now = new Date().toISOString();
  204. for (const s of shops) {
  205. await client.query(
  206. `INSERT INTO ops_shop (key, shop_id, shop_name, school, status, last_upload_id, first_seen, last_seen)
  207. VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
  208. ON CONFLICT (key) DO UPDATE SET
  209. shop_id = EXCLUDED.shop_id, shop_name = EXCLUDED.shop_name, school = EXCLUDED.school,
  210. status = EXCLUDED.status, last_upload_id = EXCLUDED.last_upload_id, last_seen = EXCLUDED.last_seen`,
  211. [s.key, s.shopId || null, s.shopName, s.school, s.status || null, s.lastUploadId || null, now, now],
  212. );
  213. }
  214. },
  215. async deleteRowsByUpload(uploadId) {
  216. await client.query('DELETE FROM ops_row WHERE upload_id = $1', [uploadId]);
  217. },
  218. async upsertPlatformRows(rows) {
  219. const now = new Date().toISOString();
  220. for (const r of rows) {
  221. await client.query(
  222. `INSERT INTO ops_platform_row (date, shop_id, upload_id, school, shop_name, data, updated_at)
  223. VALUES ($1,$2,$3,$4,$5,$6,$7)
  224. ON CONFLICT (date, shop_id) DO UPDATE SET
  225. upload_id = EXCLUDED.upload_id, school = EXCLUDED.school,
  226. shop_name = EXCLUDED.shop_name, data = EXCLUDED.data, updated_at = EXCLUDED.updated_at`,
  227. [r.date, r.shopId, r.uploadId, r.school || '', r.shopName || '', JSON.stringify(r.data || {}), now],
  228. );
  229. }
  230. },
  231. async deletePlatformRowsByUpload(uploadId) {
  232. await client.query('DELETE FROM ops_platform_row WHERE upload_id = $1', [uploadId]);
  233. },
  234. async deletePlatformRowsBySha(sha256) {
  235. await client.query('DELETE FROM ops_platform_row WHERE upload_id IN (SELECT id FROM ops_upload WHERE sha256 = $1)', [sha256]);
  236. },
  237. async deleteRowsBySha(sha256) {
  238. await client.query('DELETE FROM ops_row WHERE upload_id IN (SELECT id FROM ops_upload WHERE sha256 = $1)', [sha256]);
  239. },
  240. async pruneOrphanShops() {
  241. await client.query('DELETE FROM ops_shop s WHERE NOT EXISTS (SELECT 1 FROM ops_row r WHERE r.shop_key = s.key)');
  242. },
  243. async findUploadBySha(sha256) {
  244. const r = await client.query('SELECT id FROM ops_upload WHERE sha256 = $1 ORDER BY uploaded_at DESC LIMIT 1', [sha256]);
  245. return r.rows.length ? r.rows[0].id : null;
  246. },
  247. async updateReport(id, report) {
  248. await client.query('UPDATE ops_upload SET report = $1 WHERE id = $2', [JSON.stringify(report || {}), id]);
  249. },
  250. };
  251. }
  252. async function findUploadBySha(sha256) {
  253. const r = await getPool().query('SELECT id FROM ops_upload WHERE sha256 = $1 ORDER BY uploaded_at DESC LIMIT 1', [sha256]);
  254. return r.rows.length ? r.rows[0].id : null;
  255. }
  256. function uploadRowToApi(row) {
  257. if (!row) return null;
  258. const report = row.report || {};
  259. return {
  260. id: row.id,
  261. originalName: row.original_name,
  262. sha256: row.sha256,
  263. sizeBytes: row.size_bytes,
  264. uploadedAt: row.uploaded_at instanceof Date ? row.uploaded_at.toISOString() : row.uploaded_at,
  265. year: row.year,
  266. parserKind: row.parser_kind,
  267. parserVersion: row.parser_version,
  268. report,
  269. rows: report.rows || 0,
  270. shops: report.shops || 0,
  271. warningCount: report.warningCount || 0,
  272. };
  273. }
  274. async function listUploads() {
  275. const r = await getPool().query(`SELECT id, original_name, sha256, size_bytes, uploaded_at, year,
  276. parser_kind, parser_version, report FROM ops_upload ORDER BY uploaded_at DESC`);
  277. return r.rows.map(uploadRowToApi);
  278. }
  279. async function getUpload(id) {
  280. const r = await getPool().query(`SELECT id, original_name, sha256, size_bytes, uploaded_at, year,
  281. parser_kind, parser_version, report FROM ops_upload WHERE id = $1`, [id]);
  282. return uploadRowToApi(r.rows[0]);
  283. }
  284. async function getContent(id) {
  285. const r = await getPool().query('SELECT content, original_name FROM ops_upload WHERE id = $1', [id]);
  286. if (!r.rows.length) return null;
  287. return { content: r.rows[0].content, originalName: r.rows[0].original_name };
  288. }
  289. async function queryRows({ from, to, schools, shops } = {}) {
  290. const where = [];
  291. const params = [];
  292. if (from) { params.push(String(from)); where.push(`date >= $${params.length}`); }
  293. if (to) { params.push(String(to)); where.push(`date <= $${params.length}`); }
  294. if (Array.isArray(schools) && schools.length) { params.push(schools.map(String)); where.push(`school = ANY($${params.length})`); }
  295. if (Array.isArray(shops) && shops.length) { params.push(shops.map(String)); where.push(`shop_key = ANY($${params.length})`); }
  296. const sql = `SELECT * FROM ops_row ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
  297. ORDER BY school ASC, shop_name ASC, date ASC`;
  298. const r = await getPool().query(sql, params);
  299. return r.rows.map(rowToApi);
  300. }
  301. async function queryPlatformRows({ from, to } = {}) {
  302. const where = [];
  303. const params = [];
  304. if (from) { params.push(String(from)); where.push(`date >= ${params.length}`); }
  305. if (to) { params.push(String(to)); where.push(`date <= ${params.length}`); }
  306. const sql = `SELECT * FROM ops_platform_row ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY date ASC, shop_id ASC`;
  307. const r = await getPool().query(sql, params);
  308. return r.rows.map((row) => ({
  309. date: String(row.date).trim(),
  310. shopId: row.shop_id,
  311. uploadId: row.upload_id,
  312. school: row.school,
  313. shopName: row.shop_name,
  314. data: row.data || {},
  315. updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : row.updated_at,
  316. }));
  317. }
  318. async function summary() {
  319. const poolRef = getPool();
  320. const [uploadCount, rowCount, platformRowCount, range, matrix, schools, shops, last] = await Promise.all([
  321. poolRef.query('SELECT COUNT(*)::int AS c FROM ops_upload'),
  322. poolRef.query('SELECT COUNT(*)::int AS c FROM ops_row'),
  323. poolRef.query('SELECT COUNT(*)::int AS c FROM ops_platform_row'),
  324. poolRef.query(`SELECT MIN(d) AS f, MAX(d) AS t FROM (
  325. SELECT date AS d FROM ops_row UNION SELECT date AS d FROM ops_platform_row)`),
  326. poolRef.query(`SELECT COUNT(*)::int AS c FROM (
  327. SELECT date, shop_key FROM ops_row UNION SELECT date, shop_id FROM ops_platform_row)`),
  328. poolRef.query(`SELECT school, COUNT(DISTINCT shop)::int AS c FROM (
  329. SELECT school, shop_id AS shop FROM ops_platform_row WHERE school <> ''
  330. UNION SELECT school, shop_key AS shop FROM ops_row WHERE school <> ''
  331. UNION SELECT school, key AS shop FROM ops_shop WHERE school <> ''
  332. ) AS t GROUP BY school ORDER BY school ASC`),
  333. poolRef.query(`SELECT key, MAX(shop_id) AS shop_id, MAX(shop_name) AS shop_name,
  334. MAX(school) AS school, MAX(status) AS status FROM (
  335. SELECT key, shop_id, shop_name, school, status FROM ops_shop
  336. UNION ALL
  337. SELECT shop_id AS key, shop_id, shop_name, school, NULL AS status FROM ops_platform_row
  338. ) AS t GROUP BY key ORDER BY school ASC, shop_name ASC`),
  339. poolRef.query('SELECT id, original_name, uploaded_at, report FROM ops_upload ORDER BY uploaded_at DESC LIMIT 1'),
  340. ]);
  341. return {
  342. schools: schools.rows.map((r) => ({ name: r.school, shopCount: r.c })),
  343. shops: shops.rows.map((r) => ({ key: r.key, shopId: r.shop_id, shopName: r.shop_name, school: r.school, status: r.status })),
  344. uploadCount: uploadCount.rows[0].c,
  345. rowCount: rowCount.rows[0].c,
  346. platformRowCount: platformRowCount.rows[0].c,
  347. matrixRowCount: matrix.rows[0].c,
  348. coverage: range.rows[0] && range.rows[0].f ? { from: String(range.rows[0].f).replace(/-/g, ''), to: String(range.rows[0].t).replace(/-/g, '') } : null,
  349. lastUpload: last.rows.length ? {
  350. id: last.rows[0].id,
  351. at: last.rows[0].uploaded_at instanceof Date ? last.rows[0].uploaded_at.toISOString() : last.rows[0].uploaded_at,
  352. originalName: last.rows[0].original_name,
  353. rows: (last.rows[0].report || {}).rows || 0,
  354. warningCount: (last.rows[0].report || {}).warningCount || 0,
  355. } : null,
  356. };
  357. }
  358. async function getReportCache(cacheKey, dataVersion) {
  359. const r = await getPool().query('SELECT report, source, data_version FROM ops_report_cache WHERE cache_key = $1', [cacheKey]);
  360. if (!r.rows.length || r.rows[0].data_version !== dataVersion) return null;
  361. return { report: r.rows[0].report, source: r.rows[0].source };
  362. }
  363. async function saveReportCache(record) {
  364. await getPool().query(
  365. `INSERT INTO ops_report_cache (cache_key, updated_at, data_version, source, report)
  366. VALUES ($1,$2,$3,$4,$5)
  367. ON CONFLICT (cache_key) DO UPDATE SET
  368. updated_at = EXCLUDED.updated_at,
  369. data_version = EXCLUDED.data_version,
  370. source = EXCLUDED.source,
  371. report = EXCLUDED.report`,
  372. [record.cacheKey, record.updatedAt, record.dataVersion, record.source, JSON.stringify(record.report)],
  373. );
  374. }
  375. async function insertReport(record) {
  376. await getPool().query(
  377. `INSERT INTO ops_report (id, created_at, period_from, period_to, schools, shops, source, model, title, report)
  378. VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
  379. [record.id, record.createdAt, record.periodFrom, record.periodTo,
  380. JSON.stringify(record.schools || []), JSON.stringify(record.shops || []),
  381. record.source || 'rules', record.model || '', record.title || '', JSON.stringify(record.report)],
  382. );
  383. }
  384. async function listReports() {
  385. const r = await getPool().query(`SELECT id, created_at, period_from, period_to, schools, shops, source, model, title, report
  386. FROM ops_report ORDER BY created_at DESC LIMIT 100`);
  387. return r.rows.map((row) => ({
  388. id: row.id,
  389. createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at,
  390. from: String(row.period_from).trim(),
  391. to: String(row.period_to).trim(),
  392. source: row.source,
  393. model: row.model,
  394. title: row.title,
  395. schools: row.schools || [],
  396. shops: row.shops || [],
  397. kpis: ((row.report && row.report.kpis) || []).slice(0, 3).map((k) => ({ label: k.label, current: k.current, unit: k.unit })),
  398. }));
  399. }
  400. async function getReport(id) {
  401. const r = await getPool().query('SELECT report FROM ops_report WHERE id = $1', [id]);
  402. return r.rows.length ? r.rows[0].report : null;
  403. }
  404. async function deleteReport(id) {
  405. const r = await getPool().query('DELETE FROM ops_report WHERE id = $1', [id]);
  406. return r.rowCount;
  407. }
  408. async function close() {
  409. if (pool) {
  410. try { await pool.end(); } catch { /* ignore */ }
  411. pool = null;
  412. initPromise = null;
  413. }
  414. }
  415. return {
  416. kind: 'postgres',
  417. init,
  418. health,
  419. withTransaction,
  420. findUploadBySha,
  421. queryPlatformRows,
  422. insertReport,
  423. getReportCache,
  424. saveReportCache,
  425. listReports,
  426. getReport,
  427. deleteReport,
  428. listUploads,
  429. getUpload,
  430. getContent,
  431. queryRows,
  432. summary,
  433. close,
  434. };
  435. }
  436. module.exports = { createPgStore };