Browse Source

feat: 经营报告落库缓存,重启后秒开最近一次生成的报告

- 新增 ops_report_cache 表:按「筛选参数 + 数据版本」缓存整份 Report JSON
- 数据版本 = 上传次数|平台行数|最新日期,导入新数据会自动失效
- 报告页默认读缓存(不强制调模型),「重新生成」才让 AI 重写并落库
- 若缓存只有规则版,页面先秒开再后台升级为 AI 版(每次加载只尝试一次)
- 修复:重启后首次打开报告页要等模型生成的问题
- 测试:二次读取命中缓存(49/49)
liangzhao 2 tuần trước cách đây
mục cha
commit
0ce1805e53
6 tập tin đã thay đổi với 120 bổ sung và 14 xóa
  1. 2 0
      ops-store.js
  2. 31 7
      public/report.js
  3. 7 0
      scripts/test-ops-import.js
  4. 25 7
      server/routes/ops.js
  5. 28 0
      server/stores/ops-pg.js
  6. 27 0
      server/stores/ops-sqlite.js

+ 2 - 0
ops-store.js

@@ -124,6 +124,8 @@ function createOpsStore(config) {
     queryRows: call('queryRows'),
     queryPlatformRows: call('queryPlatformRows'),
     insertReport: call('insertReport'),
+    getReportCache: call('getReportCache'),
+    saveReportCache: call('saveReportCache'),
     listReports: call('listReports'),
     getReport: call('getReport'),
     deleteReport: call('deleteReport'),

+ 31 - 7
public/report.js

@@ -472,6 +472,31 @@
 
   // ---------- 数据 ----------
 
+  function buildParams(force) {
+    const params = new URLSearchParams();
+    if (state.from) params.set('from', state.from);
+    if (state.to) params.set('to', state.to);
+    if (state.schools.length) params.set('schools', state.schools.join(','));
+    if (state.shops.length) params.set('shops', state.shops.join(','));
+    if (force) params.set('ai', '1');
+    return params;
+  }
+
+  /** 先秒开已生成的报告,再在后台让 AI 重写一版(只尝试一次) */
+  async function upgradeToAi(seq) {
+    try {
+      const params = buildParams(false);
+      params.set('ai', '1');
+      const { res, body } = await api('/api/ops/report?' + params.toString());
+      if (!res.ok || !body || !body.report || seq !== state.loadSeq) return;
+      const report = body.report;
+      if (report.ai && report.ai.source === 'llm') {
+        state.report = report;
+        renderReport(report);
+      }
+    } catch { /* 后台升级失败保持规则版 */ }
+  }
+
   async function load(force) {
     if (state.loading) { state.pending = true; return; }
     state.loading = true;
@@ -481,13 +506,8 @@
     if (saveBtn) saveBtn.disabled = true;
     if (page && !state.report) page.innerHTML = '<div class="rp-loading">正在生成报告…</div>';
     try {
-      const params = new URLSearchParams();
-      if (state.from) params.set('from', state.from);
-      if (state.to) params.set('to', state.to);
-      if (state.schools.length) params.set('schools', state.schools.join(','));
-      if (state.shops.length) params.set('shops', state.shops.join(','));
-      params.set('ai', '1'); // 默认走 AI,失败自动退回规则版
-      const { res, body } = await api('/api/ops/report?' + params.toString());
+      const seq = ++state.loadSeq;
+      const { res, body } = await api('/api/ops/report?' + buildParams(force).toString());
       if (!res.ok) throw new Error((body && (body.detail || body.error)) || ('HTTP ' + res.status));
       if (body.empty) {
         page.innerHTML = '<div class="rp-loading">所选范围没有数据,请先导入明细。</div>';
@@ -495,6 +515,10 @@
       }
       state.report = body.report;
       renderReport(state.report);
+      if (!force && body.report.ai && body.report.ai.source !== 'llm' && !state.aiUpgradeTried) {
+        state.aiUpgradeTried = true;
+        upgradeToAi(seq);
+      }
     } catch (e) {
       page.innerHTML = `<div class="rp-loading">报告生成失败:${esc(e.message || e)}</div>`;
     } finally {

+ 7 - 0
scripts/test-ops-import.js

@@ -456,6 +456,13 @@ async function main() {
       expect(!!r.body.aiError, '应提示模型未配置');
     });
 
+    await check('E2E 报告缓存:二次读取命中', async () => {
+      const first = await fetch(base + '/api/ops/report').then(readJson);
+      const second = await fetch(base + '/api/ops/report').then(readJson);
+      expect(first.status === 200 && second.status === 200, `status ${first.status}/${second.status}`);
+      expect(second.body.report && second.body.report.cached === true, '二次读取未命中缓存');
+    });
+
     await check('E2E 报告中心:保存 / 列表 / 查看 / 删除', async () => {
       const saved = await fetch(base + '/api/ops/reports', {
         method: 'POST',

+ 25 - 7
server/routes/ops.js

@@ -1128,15 +1128,23 @@ function register(app, ctx) {
   });
 
   // ---------- 统一报告生成(经营解读与经营报告共用一份 JSON) ----------
-  const REPORT_CACHE_TTL_MS = 10 * 60 * 1000;
-  const reportCache = new Map();
+  async function currentDataVersion() {
+    try {
+      const summary = await store.summary();
+      return `${summary.uploadCount || 0}|${summary.platformRowCount || 0}|${(summary.coverage && summary.coverage.to) || ''}`;
+    } catch {
+      return '';
+    }
+  }
 
   async function getReportPayload(params, { ai = false, force = false } = {}) {
     const key = JSON.stringify(params);
-    const hit = reportCache.get(key);
-    const fresh = hit && Date.now() - hit.at < REPORT_CACHE_TTL_MS;
-    if (!force && fresh && (!ai || hit.source === 'llm')) {
-      return { ...hit.payload, cached: true };
+    const version = await currentDataVersion();
+    if (!force && version) {
+      const cached = await store.getReportCache(key, version);
+      if (cached && cached.report && (!ai || cached.source === 'llm')) {
+        return { ...cached.report, cached: true };
+      }
     }
 
     const data = await buildDashboard(store, params);
@@ -1189,7 +1197,17 @@ function register(app, ctx) {
     }
     if (aiError) payload.aiError = aiError;
 
-    reportCache.set(key, { at: Date.now(), source, payload });
+    if (version) {
+      try {
+        await store.saveReportCache({
+          cacheKey: key,
+          updatedAt: new Date().toISOString(),
+          dataVersion: version,
+          source,
+          report: payload,
+        });
+      } catch { /* 缓存写失败不影响返回 */ }
+    }
     return { ...payload, cached: false };
   }
 

+ 28 - 0
server/stores/ops-pg.js

@@ -81,6 +81,13 @@ const DDL = [
      report jsonb NOT NULL
    )`,
   `CREATE INDEX IF NOT EXISTS ops_report_created_idx ON ops_report (created_at DESC)`,
+  `CREATE TABLE IF NOT EXISTS ops_report_cache (
+     cache_key text PRIMARY KEY,
+     updated_at timestamptz NOT NULL,
+     data_version text NOT NULL,
+     source text NOT NULL DEFAULT 'rules',
+     report jsonb NOT NULL
+   )`,
 ];
 
 function rowToApi(row) {
@@ -367,6 +374,25 @@ function createPgStore({ connectionString, ssl = true, max = 5, schema = 'public
     };
   }
 
+  async function getReportCache(cacheKey, dataVersion) {
+    const r = await getPool().query('SELECT report, source, data_version FROM ops_report_cache WHERE cache_key = $1', [cacheKey]);
+    if (!r.rows.length || r.rows[0].data_version !== dataVersion) return null;
+    return { report: r.rows[0].report, source: r.rows[0].source };
+  }
+
+  async function saveReportCache(record) {
+    await getPool().query(
+      `INSERT INTO ops_report_cache (cache_key, updated_at, data_version, source, report)
+       VALUES ($1,$2,$3,$4,$5)
+       ON CONFLICT (cache_key) DO UPDATE SET
+         updated_at = EXCLUDED.updated_at,
+         data_version = EXCLUDED.data_version,
+         source = EXCLUDED.source,
+         report = EXCLUDED.report`,
+      [record.cacheKey, record.updatedAt, record.dataVersion, record.source, JSON.stringify(record.report)],
+    );
+  }
+
   async function insertReport(record) {
     await getPool().query(
       `INSERT INTO ops_report (id, created_at, period_from, period_to, schools, shops, source, model, title, report)
@@ -420,6 +446,8 @@ function createPgStore({ connectionString, ssl = true, max = 5, schema = 'public
     findUploadBySha,
     queryPlatformRows,
     insertReport,
+    getReportCache,
+    saveReportCache,
     listReports,
     getReport,
     deleteReport,

+ 27 - 0
server/stores/ops-sqlite.js

@@ -82,6 +82,13 @@ const DDL = [
      report TEXT NOT NULL
    )`,
   `CREATE INDEX IF NOT EXISTS ops_report_created_idx ON ops_report (created_at DESC)`,
+  `CREATE TABLE IF NOT EXISTS ops_report_cache (
+     cache_key TEXT PRIMARY KEY,
+     updated_at TEXT NOT NULL,
+     data_version TEXT NOT NULL,
+     source TEXT NOT NULL DEFAULT 'rules',
+     report TEXT NOT NULL
+   )`,
 ];
 
 function rowToApi(row) {
@@ -400,6 +407,24 @@ function createSqliteStore({ file }) {
     };
   }
 
+  async function getReportCache(cacheKey, dataVersion) {
+    const row = open().prepare('SELECT report, source, data_version FROM ops_report_cache WHERE cache_key = ?').get(cacheKey);
+    if (!row || row.data_version !== dataVersion) return null;
+    return { report: safeJson(row.report, null), source: row.source };
+  }
+
+  async function saveReportCache(record) {
+    open().prepare(`INSERT INTO ops_report_cache (cache_key, updated_at, data_version, source, report)
+      VALUES (?, ?, ?, ?, ?)
+      ON CONFLICT(cache_key) DO UPDATE SET
+        updated_at = excluded.updated_at,
+        data_version = excluded.data_version,
+        source = excluded.source,
+        report = excluded.report`).run(
+      record.cacheKey, record.updatedAt, record.dataVersion, record.source, JSON.stringify(record.report),
+    );
+  }
+
   async function insertReport(record) {
     open().prepare(`INSERT INTO ops_report
       (id, created_at, period_from, period_to, schools, shops, source, model, title, report)
@@ -462,6 +487,8 @@ function createSqliteStore({ file }) {
     findUploadBySha,
     queryPlatformRows,
     insertReport,
+    getReportCache,
+    saveReportCache,
     listReports,
     getReport,
     deleteReport,