ops-pg.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  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. ];
  58. function rowToApi(row) {
  59. if (!row) return null;
  60. return {
  61. date: String(row.date || '').trim(),
  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 == null ? null : Number(row.order_cnt),
  68. revenue: row.revenue == null ? null : Number(row.revenue),
  69. exposure: row.exposure == null ? null : Number(row.exposure),
  70. click_rate: row.click_rate == null ? null : Number(row.click_rate),
  71. raw: row.raw || {},
  72. quality: row.quality || [],
  73. uploadId: row.upload_id,
  74. updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : row.updated_at,
  75. };
  76. }
  77. function createPgStore({ connectionString, ssl = true, max = 5, schema = 'public', connectTimeoutMs = 10000 }) {
  78. let pool = null;
  79. let initPromise = null;
  80. function getPool() {
  81. if (pool) return pool;
  82. let pg;
  83. try {
  84. pg = require('pg');
  85. } catch {
  86. const err = new Error('未安装 pg(npm i pg)');
  87. err.reason = 'pg-not-installed';
  88. throw err;
  89. }
  90. pool = new pg.Pool({
  91. connectionString,
  92. max: Number(max) || 5,
  93. ssl: ssl ? { rejectUnauthorized: false } : false,
  94. connectionTimeoutMillis: Number(connectTimeoutMs) || 10000,
  95. options: `-c search_path=${schema || 'public'}`,
  96. });
  97. return pool;
  98. }
  99. async function init() {
  100. if (!initPromise) {
  101. initPromise = (async () => {
  102. const client = await getPool().connect();
  103. try {
  104. for (const sql of DDL) await client.query(sql);
  105. await client.query(
  106. 'INSERT INTO ops_meta (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value',
  107. ['schema_version', SCHEMA_VERSION],
  108. );
  109. } finally {
  110. client.release();
  111. }
  112. return true;
  113. })().catch((e) => {
  114. initPromise = null;
  115. throw e;
  116. });
  117. }
  118. return initPromise;
  119. }
  120. async function health() {
  121. try {
  122. await getPool().query('SELECT 1 AS ok');
  123. return { ok: true, kind: 'postgres', detail: (schema || 'public') };
  124. } catch (e) {
  125. return { ok: false, kind: 'postgres', detail: e.message || String(e) };
  126. }
  127. }
  128. async function withTransaction(fn) {
  129. const client = await getPool().connect();
  130. try {
  131. await client.query('BEGIN');
  132. const result = await fn(makeTx(client));
  133. await client.query('COMMIT');
  134. return result;
  135. } catch (e) {
  136. try { await client.query('ROLLBACK'); } catch { /* 已结束 */ }
  137. throw e;
  138. } finally {
  139. client.release();
  140. }
  141. }
  142. function makeTx(client) {
  143. return {
  144. async insertUpload(upload) {
  145. await client.query(
  146. `INSERT INTO ops_upload (id, original_name, sha256, size_bytes, uploaded_at, year,
  147. parser_kind, parser_version, report, content)
  148. VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
  149. [upload.id, upload.originalName, upload.sha256, upload.sizeBytes, upload.uploadedAt,
  150. upload.year, upload.parserKind, upload.parserVersion, JSON.stringify(upload.report || {}), upload.content],
  151. );
  152. },
  153. async upsertRows(rows) {
  154. const now = new Date().toISOString();
  155. for (const r of rows) {
  156. await client.query(
  157. `INSERT INTO ops_row (date, shop_key, upload_id, shop_id, shop_name, school, status,
  158. order_cnt, revenue, exposure, click_rate, raw, quality, updated_at)
  159. VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
  160. ON CONFLICT (date, shop_key) DO UPDATE SET
  161. upload_id = EXCLUDED.upload_id, shop_id = EXCLUDED.shop_id, shop_name = EXCLUDED.shop_name,
  162. school = EXCLUDED.school, status = EXCLUDED.status, order_cnt = EXCLUDED.order_cnt,
  163. revenue = EXCLUDED.revenue, exposure = EXCLUDED.exposure, click_rate = EXCLUDED.click_rate,
  164. raw = EXCLUDED.raw, quality = EXCLUDED.quality, updated_at = EXCLUDED.updated_at`,
  165. [r.date, r.shopKey, r.uploadId, r.shopId || null, r.shopName, r.school, r.status || null,
  166. r.order_cnt, r.revenue, r.exposure, r.click_rate,
  167. JSON.stringify(r.raw || {}), r.quality || [], now],
  168. );
  169. }
  170. },
  171. async upsertShops(shops) {
  172. const now = new Date().toISOString();
  173. for (const s of shops) {
  174. await client.query(
  175. `INSERT INTO ops_shop (key, shop_id, shop_name, school, status, last_upload_id, first_seen, last_seen)
  176. VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
  177. ON CONFLICT (key) DO UPDATE SET
  178. shop_id = EXCLUDED.shop_id, shop_name = EXCLUDED.shop_name, school = EXCLUDED.school,
  179. status = EXCLUDED.status, last_upload_id = EXCLUDED.last_upload_id, last_seen = EXCLUDED.last_seen`,
  180. [s.key, s.shopId || null, s.shopName, s.school, s.status || null, s.lastUploadId || null, now, now],
  181. );
  182. }
  183. },
  184. async deleteRowsByUpload(uploadId) {
  185. await client.query('DELETE FROM ops_row WHERE upload_id = $1', [uploadId]);
  186. },
  187. async updateReport(id, report) {
  188. await client.query('UPDATE ops_upload SET report = $1 WHERE id = $2', [JSON.stringify(report || {}), id]);
  189. },
  190. };
  191. }
  192. async function findUploadBySha(sha256) {
  193. const r = await getPool().query('SELECT id FROM ops_upload WHERE sha256 = $1 ORDER BY uploaded_at DESC LIMIT 1', [sha256]);
  194. return r.rows.length ? r.rows[0].id : null;
  195. }
  196. function uploadRowToApi(row) {
  197. if (!row) return null;
  198. const report = row.report || {};
  199. return {
  200. id: row.id,
  201. originalName: row.original_name,
  202. sha256: row.sha256,
  203. sizeBytes: row.size_bytes,
  204. uploadedAt: row.uploaded_at instanceof Date ? row.uploaded_at.toISOString() : row.uploaded_at,
  205. year: row.year,
  206. parserKind: row.parser_kind,
  207. parserVersion: row.parser_version,
  208. report,
  209. rows: report.rows || 0,
  210. shops: report.shops || 0,
  211. warningCount: report.warningCount || 0,
  212. };
  213. }
  214. async function listUploads() {
  215. const r = await getPool().query(`SELECT id, original_name, sha256, size_bytes, uploaded_at, year,
  216. parser_kind, parser_version, report FROM ops_upload ORDER BY uploaded_at DESC`);
  217. return r.rows.map(uploadRowToApi);
  218. }
  219. async function getUpload(id) {
  220. const r = await getPool().query(`SELECT id, original_name, sha256, size_bytes, uploaded_at, year,
  221. parser_kind, parser_version, report FROM ops_upload WHERE id = $1`, [id]);
  222. return uploadRowToApi(r.rows[0]);
  223. }
  224. async function getContent(id) {
  225. const r = await getPool().query('SELECT content, original_name FROM ops_upload WHERE id = $1', [id]);
  226. if (!r.rows.length) return null;
  227. return { content: r.rows[0].content, originalName: r.rows[0].original_name };
  228. }
  229. async function queryRows({ from, to, schools, shops } = {}) {
  230. const where = [];
  231. const params = [];
  232. if (from) { params.push(String(from)); where.push(`date >= $${params.length}`); }
  233. if (to) { params.push(String(to)); where.push(`date <= $${params.length}`); }
  234. if (Array.isArray(schools) && schools.length) { params.push(schools.map(String)); where.push(`school = ANY($${params.length})`); }
  235. if (Array.isArray(shops) && shops.length) { params.push(shops.map(String)); where.push(`shop_key = ANY($${params.length})`); }
  236. const sql = `SELECT * FROM ops_row ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
  237. ORDER BY school ASC, shop_name ASC, date ASC`;
  238. const r = await getPool().query(sql, params);
  239. return r.rows.map(rowToApi);
  240. }
  241. async function summary() {
  242. const poolRef = getPool();
  243. const [uploadCount, rowCount, range, schools, shops, last] = await Promise.all([
  244. poolRef.query('SELECT COUNT(*)::int AS c FROM ops_upload'),
  245. poolRef.query('SELECT COUNT(*)::int AS c FROM ops_row'),
  246. poolRef.query('SELECT MIN(date) AS f, MAX(date) AS t FROM ops_row'),
  247. poolRef.query('SELECT school, COUNT(*)::int AS c FROM ops_shop GROUP BY school ORDER BY school ASC'),
  248. poolRef.query('SELECT key, shop_id, shop_name, school, status FROM ops_shop ORDER BY school ASC, shop_name ASC'),
  249. poolRef.query('SELECT id, original_name, uploaded_at, report FROM ops_upload ORDER BY uploaded_at DESC LIMIT 1'),
  250. ]);
  251. return {
  252. schools: schools.rows.map((r) => ({ name: r.school, shopCount: r.c })),
  253. shops: shops.rows.map((r) => ({ key: r.key, shopId: r.shop_id, shopName: r.shop_name, school: r.school, status: r.status })),
  254. uploadCount: uploadCount.rows[0].c,
  255. rowCount: rowCount.rows[0].c,
  256. 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,
  257. lastUpload: last.rows.length ? {
  258. id: last.rows[0].id,
  259. at: last.rows[0].uploaded_at instanceof Date ? last.rows[0].uploaded_at.toISOString() : last.rows[0].uploaded_at,
  260. originalName: last.rows[0].original_name,
  261. rows: (last.rows[0].report || {}).rows || 0,
  262. warningCount: (last.rows[0].report || {}).warningCount || 0,
  263. } : null,
  264. };
  265. }
  266. async function close() {
  267. if (pool) {
  268. try { await pool.end(); } catch { /* ignore */ }
  269. pool = null;
  270. initPromise = null;
  271. }
  272. }
  273. return {
  274. kind: 'postgres',
  275. init,
  276. health,
  277. withTransaction,
  278. findUploadBySha,
  279. listUploads,
  280. getUpload,
  281. getContent,
  282. queryRows,
  283. summary,
  284. close,
  285. };
  286. }
  287. module.exports = { createPgStore };