| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372 |
- 'use strict';
- /**
- * ops 存储 · PostgreSQL 驱动
- * ----------------------------------------------------------------------------
- * 与 SQLite 驱动同接口。`pg` 为可选依赖(本期不安装):未安装时 health() 返回明确原因,
- * 只有「店铺运营」功能不可用,服务与其它功能不受影响。
- *
- * 上线切换步骤见设计文档 §5.6:改 config.storage.driver = "postgres" + 填连接串 + npm i pg。
- */
- const SCHEMA_VERSION = '1';
- const DDL = [
- `CREATE TABLE IF NOT EXISTS ops_meta (
- key text PRIMARY KEY,
- value text NOT NULL
- )`,
- `CREATE TABLE IF NOT EXISTS ops_upload (
- id text PRIMARY KEY,
- original_name text NOT NULL,
- sha256 text NOT NULL,
- size_bytes integer NOT NULL,
- uploaded_at timestamptz NOT NULL,
- year integer NOT NULL,
- parser_kind text NOT NULL,
- parser_version integer NOT NULL,
- report jsonb NOT NULL DEFAULT '{}'::jsonb,
- content bytea NOT NULL
- )`,
- `CREATE INDEX IF NOT EXISTS ops_upload_sha256_idx ON ops_upload (sha256, uploaded_at DESC)`,
- `CREATE TABLE IF NOT EXISTS ops_shop (
- key text PRIMARY KEY,
- shop_id text,
- shop_name text NOT NULL,
- school text NOT NULL,
- status text,
- last_upload_id text REFERENCES ops_upload(id) ON DELETE SET NULL,
- first_seen timestamptz NOT NULL,
- last_seen timestamptz NOT NULL
- )`,
- `CREATE TABLE IF NOT EXISTS ops_row (
- date char(8) NOT NULL,
- shop_key text NOT NULL,
- upload_id text NOT NULL REFERENCES ops_upload(id) ON DELETE CASCADE,
- shop_id text,
- shop_name text NOT NULL,
- school text NOT NULL,
- status text,
- order_cnt integer,
- revenue numeric(14,2),
- exposure integer,
- click_rate numeric(8,6),
- raw jsonb NOT NULL DEFAULT '{}'::jsonb,
- quality text[] NOT NULL DEFAULT '{}',
- updated_at timestamptz NOT NULL,
- PRIMARY KEY (date, shop_key)
- )`,
- `CREATE INDEX IF NOT EXISTS ops_row_school_date_idx ON ops_row (school, date)`,
- `CREATE TABLE IF NOT EXISTS ops_platform_row (
- date char(8) NOT NULL,
- shop_id text NOT NULL,
- upload_id text NOT NULL REFERENCES ops_upload(id) ON DELETE CASCADE,
- school text NOT NULL DEFAULT '',
- shop_name text NOT NULL DEFAULT '',
- data jsonb NOT NULL DEFAULT '{}'::jsonb,
- updated_at timestamptz NOT NULL,
- PRIMARY KEY (date, shop_id)
- )`,
- `CREATE INDEX IF NOT EXISTS ops_platform_row_date_idx ON ops_platform_row (date)`,
- ];
- function rowToApi(row) {
- if (!row) return null;
- return {
- date: String(row.date || '').trim(),
- shopKey: row.shop_key,
- shopId: row.shop_id,
- shopName: row.shop_name,
- school: row.school,
- status: row.status,
- order_cnt: row.order_cnt == null ? null : Number(row.order_cnt),
- revenue: row.revenue == null ? null : Number(row.revenue),
- exposure: row.exposure == null ? null : Number(row.exposure),
- click_rate: row.click_rate == null ? null : Number(row.click_rate),
- raw: row.raw || {},
- quality: row.quality || [],
- uploadId: row.upload_id,
- updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : row.updated_at,
- };
- }
- function createPgStore({ connectionString, ssl = true, max = 5, schema = 'public', connectTimeoutMs = 10000 }) {
- let pool = null;
- let initPromise = null;
- function getPool() {
- if (pool) return pool;
- let pg;
- try {
- pg = require('pg');
- } catch {
- const err = new Error('未安装 pg(npm i pg)');
- err.reason = 'pg-not-installed';
- throw err;
- }
- pool = new pg.Pool({
- connectionString,
- max: Number(max) || 5,
- ssl: ssl ? { rejectUnauthorized: false } : false,
- connectionTimeoutMillis: Number(connectTimeoutMs) || 10000,
- options: `-c search_path=${schema || 'public'}`,
- });
- return pool;
- }
- async function init() {
- if (!initPromise) {
- initPromise = (async () => {
- const client = await getPool().connect();
- try {
- for (const sql of DDL) await client.query(sql);
- await client.query(
- 'INSERT INTO ops_meta (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value',
- ['schema_version', SCHEMA_VERSION],
- );
- } finally {
- client.release();
- }
- return true;
- })().catch((e) => {
- initPromise = null;
- throw e;
- });
- }
- return initPromise;
- }
- async function health() {
- try {
- await getPool().query('SELECT 1 AS ok');
- return { ok: true, kind: 'postgres', detail: (schema || 'public') };
- } catch (e) {
- return { ok: false, kind: 'postgres', detail: e.message || String(e) };
- }
- }
- async function withTransaction(fn) {
- const client = await getPool().connect();
- try {
- await client.query('BEGIN');
- const result = await fn(makeTx(client));
- await client.query('COMMIT');
- return result;
- } catch (e) {
- try { await client.query('ROLLBACK'); } catch { /* 已结束 */ }
- throw e;
- } finally {
- client.release();
- }
- }
- function makeTx(client) {
- return {
- async insertUpload(upload) {
- await client.query(
- `INSERT INTO ops_upload (id, original_name, sha256, size_bytes, uploaded_at, year,
- parser_kind, parser_version, report, content)
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
- [upload.id, upload.originalName, upload.sha256, upload.sizeBytes, upload.uploadedAt,
- upload.year, upload.parserKind, upload.parserVersion, JSON.stringify(upload.report || {}), upload.content],
- );
- },
- async upsertRows(rows) {
- const now = new Date().toISOString();
- for (const r of rows) {
- await client.query(
- `INSERT INTO ops_row (date, shop_key, upload_id, shop_id, shop_name, school, status,
- order_cnt, revenue, exposure, click_rate, raw, quality, updated_at)
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
- ON CONFLICT (date, shop_key) DO UPDATE SET
- upload_id = EXCLUDED.upload_id, shop_id = EXCLUDED.shop_id, shop_name = EXCLUDED.shop_name,
- school = EXCLUDED.school, status = EXCLUDED.status, order_cnt = EXCLUDED.order_cnt,
- revenue = EXCLUDED.revenue, exposure = EXCLUDED.exposure, click_rate = EXCLUDED.click_rate,
- raw = EXCLUDED.raw, quality = EXCLUDED.quality, updated_at = EXCLUDED.updated_at`,
- [r.date, r.shopKey, r.uploadId, r.shopId || null, r.shopName, r.school, r.status || null,
- r.order_cnt, r.revenue, r.exposure, r.click_rate,
- JSON.stringify(r.raw || {}), r.quality || [], now],
- );
- }
- },
- async upsertShops(shops) {
- const now = new Date().toISOString();
- for (const s of shops) {
- await client.query(
- `INSERT INTO ops_shop (key, shop_id, shop_name, school, status, last_upload_id, first_seen, last_seen)
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
- ON CONFLICT (key) DO UPDATE SET
- shop_id = EXCLUDED.shop_id, shop_name = EXCLUDED.shop_name, school = EXCLUDED.school,
- status = EXCLUDED.status, last_upload_id = EXCLUDED.last_upload_id, last_seen = EXCLUDED.last_seen`,
- [s.key, s.shopId || null, s.shopName, s.school, s.status || null, s.lastUploadId || null, now, now],
- );
- }
- },
- async deleteRowsByUpload(uploadId) {
- await client.query('DELETE FROM ops_row WHERE upload_id = $1', [uploadId]);
- },
- async upsertPlatformRows(rows) {
- const now = new Date().toISOString();
- for (const r of rows) {
- await client.query(
- `INSERT INTO ops_platform_row (date, shop_id, upload_id, school, shop_name, data, updated_at)
- VALUES ($1,$2,$3,$4,$5,$6,$7)
- ON CONFLICT (date, shop_id) DO UPDATE SET
- upload_id = EXCLUDED.upload_id, school = EXCLUDED.school,
- shop_name = EXCLUDED.shop_name, data = EXCLUDED.data, updated_at = EXCLUDED.updated_at`,
- [r.date, r.shopId, r.uploadId, r.school || '', r.shopName || '', JSON.stringify(r.data || {}), now],
- );
- }
- },
- async deletePlatformRowsByUpload(uploadId) {
- await client.query('DELETE FROM ops_platform_row WHERE upload_id = $1', [uploadId]);
- },
- async deletePlatformRowsBySha(sha256) {
- await client.query('DELETE FROM ops_platform_row WHERE upload_id IN (SELECT id FROM ops_upload WHERE sha256 = $1)', [sha256]);
- },
- async deleteRowsBySha(sha256) {
- await client.query('DELETE FROM ops_row WHERE upload_id IN (SELECT id FROM ops_upload WHERE sha256 = $1)', [sha256]);
- },
- async pruneOrphanShops() {
- await client.query('DELETE FROM ops_shop s WHERE NOT EXISTS (SELECT 1 FROM ops_row r WHERE r.shop_key = s.key)');
- },
- async updateReport(id, report) {
- await client.query('UPDATE ops_upload SET report = $1 WHERE id = $2', [JSON.stringify(report || {}), id]);
- },
- };
- }
- async function findUploadBySha(sha256) {
- const r = await getPool().query('SELECT id FROM ops_upload WHERE sha256 = $1 ORDER BY uploaded_at DESC LIMIT 1', [sha256]);
- return r.rows.length ? r.rows[0].id : null;
- }
- function uploadRowToApi(row) {
- if (!row) return null;
- const report = row.report || {};
- return {
- id: row.id,
- originalName: row.original_name,
- sha256: row.sha256,
- sizeBytes: row.size_bytes,
- uploadedAt: row.uploaded_at instanceof Date ? row.uploaded_at.toISOString() : row.uploaded_at,
- year: row.year,
- parserKind: row.parser_kind,
- parserVersion: row.parser_version,
- report,
- rows: report.rows || 0,
- shops: report.shops || 0,
- warningCount: report.warningCount || 0,
- };
- }
- async function listUploads() {
- const r = await getPool().query(`SELECT id, original_name, sha256, size_bytes, uploaded_at, year,
- parser_kind, parser_version, report FROM ops_upload ORDER BY uploaded_at DESC`);
- return r.rows.map(uploadRowToApi);
- }
- async function getUpload(id) {
- const r = await getPool().query(`SELECT id, original_name, sha256, size_bytes, uploaded_at, year,
- parser_kind, parser_version, report FROM ops_upload WHERE id = $1`, [id]);
- return uploadRowToApi(r.rows[0]);
- }
- async function getContent(id) {
- const r = await getPool().query('SELECT content, original_name FROM ops_upload WHERE id = $1', [id]);
- if (!r.rows.length) return null;
- return { content: r.rows[0].content, originalName: r.rows[0].original_name };
- }
- async function queryRows({ from, to, schools, shops } = {}) {
- const where = [];
- const params = [];
- if (from) { params.push(String(from)); where.push(`date >= $${params.length}`); }
- if (to) { params.push(String(to)); where.push(`date <= $${params.length}`); }
- if (Array.isArray(schools) && schools.length) { params.push(schools.map(String)); where.push(`school = ANY($${params.length})`); }
- if (Array.isArray(shops) && shops.length) { params.push(shops.map(String)); where.push(`shop_key = ANY($${params.length})`); }
- const sql = `SELECT * FROM ops_row ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
- ORDER BY school ASC, shop_name ASC, date ASC`;
- const r = await getPool().query(sql, params);
- return r.rows.map(rowToApi);
- }
- async function queryPlatformRows({ from, to } = {}) {
- const where = [];
- const params = [];
- if (from) { params.push(String(from)); where.push(`date >= ${params.length}`); }
- if (to) { params.push(String(to)); where.push(`date <= ${params.length}`); }
- const sql = `SELECT * FROM ops_platform_row ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY date ASC, shop_id ASC`;
- const r = await getPool().query(sql, params);
- return r.rows.map((row) => ({
- date: String(row.date).trim(),
- shopId: row.shop_id,
- uploadId: row.upload_id,
- school: row.school,
- shopName: row.shop_name,
- data: row.data || {},
- updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : row.updated_at,
- }));
- }
- async function summary() {
- const poolRef = getPool();
- const [uploadCount, rowCount, platformRowCount, range, matrix, schools, shops, last] = await Promise.all([
- poolRef.query('SELECT COUNT(*)::int AS c FROM ops_upload'),
- poolRef.query('SELECT COUNT(*)::int AS c FROM ops_row'),
- poolRef.query('SELECT COUNT(*)::int AS c FROM ops_platform_row'),
- poolRef.query(`SELECT MIN(d) AS f, MAX(d) AS t FROM (
- SELECT date AS d FROM ops_row UNION SELECT date AS d FROM ops_platform_row)`),
- poolRef.query(`SELECT COUNT(*)::int AS c FROM (
- SELECT date, shop_key FROM ops_row UNION SELECT date, shop_id FROM ops_platform_row)`),
- poolRef.query(`SELECT school, COUNT(DISTINCT shop)::int AS c FROM (
- SELECT school, shop_id AS shop FROM ops_platform_row WHERE school <> ''
- UNION SELECT school, shop_key AS shop FROM ops_row WHERE school <> ''
- UNION SELECT school, key AS shop FROM ops_shop WHERE school <> ''
- ) AS t GROUP BY school ORDER BY school ASC`),
- poolRef.query('SELECT key, shop_id, shop_name, school, status FROM ops_shop ORDER BY school ASC, shop_name ASC'),
- poolRef.query('SELECT id, original_name, uploaded_at, report FROM ops_upload ORDER BY uploaded_at DESC LIMIT 1'),
- ]);
- return {
- schools: schools.rows.map((r) => ({ name: r.school, shopCount: r.c })),
- shops: shops.rows.map((r) => ({ key: r.key, shopId: r.shop_id, shopName: r.shop_name, school: r.school, status: r.status })),
- uploadCount: uploadCount.rows[0].c,
- rowCount: rowCount.rows[0].c,
- platformRowCount: platformRowCount.rows[0].c,
- matrixRowCount: matrix.rows[0].c,
- 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,
- lastUpload: last.rows.length ? {
- id: last.rows[0].id,
- at: last.rows[0].uploaded_at instanceof Date ? last.rows[0].uploaded_at.toISOString() : last.rows[0].uploaded_at,
- originalName: last.rows[0].original_name,
- rows: (last.rows[0].report || {}).rows || 0,
- warningCount: (last.rows[0].report || {}).warningCount || 0,
- } : null,
- };
- }
- async function close() {
- if (pool) {
- try { await pool.end(); } catch { /* ignore */ }
- pool = null;
- initPromise = null;
- }
- }
- return {
- kind: 'postgres',
- init,
- health,
- withTransaction,
- findUploadBySha,
- queryPlatformRows,
- listUploads,
- getUpload,
- getContent,
- queryRows,
- summary,
- close,
- };
- }
- module.exports = { createPgStore };
|