Sfoglia il codice sorgente

feat: scan dimension cache and daily auto-sync for reports

- /api/scan: cache image dimensions keyed by mtime+size; repeat scans
  skip reading every dish image (~3x faster warm)
- auto-sync service: once per day at configured time (default 06:00),
  when the merchant client debug port is online, sync yesterday's data
  into the lake; retried while offline the same day; disabled by default
- sync modal gains auto-sync toggle + time picker; settings API carries
  autoSyncEnabled/autoSyncTime with HH:mm validation
- smoke suite extended to 46 checks
KenZ1117 2 settimane fa
parent
commit
5fe1a891b3

+ 11 - 0
public/index.html

@@ -692,6 +692,17 @@
         </select>
         <div class="help">经营数据后台保留约 180 天;当天数据未结算,同步区间右端取昨日</div>
       </div>
+      <div class="field">
+        <label>每日自动同步</label>
+        <div style="display:flex;gap:10px;align-items:center;">
+          <label style="display:flex;align-items:center;gap:6px;margin:0;white-space:nowrap;">
+            <input type="checkbox" id="autoSyncEnabled" />
+            <span>每天自动同步昨日数据</span>
+          </label>
+          <input type="time" id="autoSyncTime" value="06:00" style="width:110px;" />
+        </div>
+        <div class="help">需商家客户端以调试端口运行;到点时客户端在线即自动补同步昨日数据,离线则当天在线后补跑。同步记录照常进入同步记录列表。</div>
+      </div>
       <div id="syncProgressWrap" hidden>
         <div class="sync-progress-row">
           <div class="sync-progress-bar"><span id="syncProgressBar"></span></div>

+ 24 - 0
public/reports.js

@@ -456,12 +456,34 @@
     }, 900);
   }
 
+  async function loadAutoSyncPrefs() {
+    try {
+      const s = await jsonFetch('/api/settings');
+      $('autoSyncEnabled').checked = !!s.autoSyncEnabled;
+      if (s.autoSyncTime) $('autoSyncTime').value = s.autoSyncTime;
+    } catch { /* 设置读取失败时保留默认值 */ }
+  }
+
+  async function saveAutoSyncPrefs() {
+    try {
+      await jsonFetch('/api/settings', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ autoSyncEnabled: $('autoSyncEnabled').checked, autoSyncTime: $('autoSyncTime').value }),
+      });
+      showToast('自动同步设置已保存');
+    } catch (e) {
+      showToast('自动同步设置保存失败:' + e.message, 'error');
+    }
+  }
+
   async function openSyncModal() {
     $('modalReportSync').hidden = false;
     $('syncEntityBadge').textContent = '识别主体中…';
     $('syncEntityBadge').className = 'badge badge-info';
     $('syncShopPickWrap').hidden = true;
     $('syncProgressWrap').hidden = true;
+    loadAutoSyncPrefs();
     try {
       const tree = await jsonFetch('/api/reports/tree');
       const badge = $('syncEntityBadge');
@@ -935,6 +957,8 @@
     $('syncScope').addEventListener('change', () => {
       $('syncShopPickWrap').hidden = $('syncScope').value !== 'custom';
     });
+    $('autoSyncEnabled').addEventListener('change', saveAutoSyncPrefs);
+    $('autoSyncTime').addEventListener('change', saveAutoSyncPrefs);
     $('syncShopTrigger').addEventListener('click', () => toggleDropdown('syncShopMenu', 'syncShopTrigger'));
     $('syncShopMenu').addEventListener('click', makeDropdownHandler($('syncShopMenu'), state.syncShops, $('syncShopText'), '选择门店'));
     document.addEventListener('click', (event) => {

+ 9 - 0
scripts/smoke.js

@@ -131,6 +131,15 @@ async function main() {
       const r = await request(port, 'POST', '/api/settings', { delayMs: -5 });
       expect(r.status === 400, `status=${r.status}`);
     });
+    await check('POST /api/settings 非法 autoSyncTime → 400', async () => {
+      const r = await request(port, 'POST', '/api/settings', { autoSyncTime: '99:99' });
+      expect(r.status === 400, `status=${r.status}`);
+    });
+    await check('GET /api/settings 含 autoSync 字段', async () => {
+      const r = await request(port, 'GET', '/api/settings');
+      const j = r.json();
+      expect(typeof j.autoSyncEnabled === 'boolean' && /^\d{1,2}:\d{2}$/.test(j.autoSyncTime || ''), 'autoSync 字段缺失');
+    });
 
     // ---- 采集域 ----
     await check('GET /api/session', async () => {

+ 2 - 0
server.js

@@ -12,6 +12,7 @@ const { createContext } = require('./server/context.js');
 const { createRouter } = require('./server/router.js');
 const { createCrawlService } = require('./server/services/crawl.js');
 const { createGenerationService } = require('./server/services/generation.js');
+const { createAutoSyncService } = require('./server/services/auto-sync.js');
 const { registerRoutes } = require('./server/routes/index.js');
 
 const HOST = process.env.HOST || '127.0.0.1';
@@ -19,6 +20,7 @@ const HOST = process.env.HOST || '127.0.0.1';
 const ctx = createContext();
 ctx.crawl = createCrawlService(ctx);
 ctx.generation = createGenerationService(ctx);
+ctx.autoSync = createAutoSyncService(ctx);
 
 const app = createRouter();
 registerRoutes(app, ctx);

+ 3 - 0
server/context.js

@@ -26,6 +26,9 @@ const CONFIG_DEFAULTS = {
   chatModel: '',
   chatApiBase: '',
   chatApiKey: '',
+  accessToken: '',
+  // 经营数据每日自动同步(商家客户端在线时自动补昨日数据),默认关闭
+  autoSync: { enabled: false, time: '06:00' },
 };
 
 function writeConfigFile(cfg) {

+ 15 - 0
server/routes/history.js

@@ -9,9 +9,24 @@ const { getImageSize } = require('../../imageMeta.js');
 
 // ---------- scan helpers ----------
 
+// 尺寸缓存:按 mtime+size 命中即免读整个文件。/api/scan 每次要列出全部门店的
+// 全部菜品图尺寸,无缓存时每次扫描都逐张 readFileSync,是生成页打开慢的主因。
+const dimCache = new Map(); // path -> { mtimeMs, size, width, height }
+
 function fileInfo(p) {
+  const stat = fs.statSync(p);
+  const hit = dimCache.get(p);
+  if (hit && hit.mtimeMs === stat.mtimeMs && hit.size === stat.size) {
+    return {
+      url: '/files?path=' + encodeURIComponent(p),
+      width: hit.width,
+      height: hit.height,
+      fileSizeBytes: stat.size,
+    };
+  }
   const buf = fs.readFileSync(p);
   const dims = getImageSize(buf) || {};
+  dimCache.set(p, { mtimeMs: stat.mtimeMs, size: stat.size, width: dims.width || null, height: dims.height || null });
   return {
     url: '/files?path=' + encodeURIComponent(p),
     width: dims.width || null,

+ 14 - 0
server/routes/settings.js

@@ -28,6 +28,8 @@ function register(app, ctx) {
       chatApiBase: ctx.config.chatApiBase || ctx.config.apiBase,
       chatApiKey: ctx.config.chatApiKey ? ctx.config.chatApiKey.slice(0, 8) + '••••••••' : '',
       chatKeyFallback: !ctx.config.chatApiKey && !!ctx.config.apiKey,
+      autoSyncEnabled: !!(ctx.config.autoSync && ctx.config.autoSync.enabled),
+      autoSyncTime: (ctx.config.autoSync && ctx.config.autoSync.time) || '06:00',
     });
   });
 
@@ -62,6 +64,18 @@ function register(app, ctx) {
     if (body.chatModel !== undefined) config.chatModel = String(body.chatModel).trim();
     if (body.chatApiBase !== undefined) config.chatApiBase = String(body.chatApiBase).trim();
     if (body.chatApiKey !== undefined && !String(body.chatApiKey).includes('••••')) config.chatApiKey = String(body.chatApiKey).trim();
+    if (body.autoSyncEnabled !== undefined || body.autoSyncTime !== undefined) {
+      if (!config.autoSync || typeof config.autoSync !== 'object') config.autoSync = { enabled: false, time: '06:00' };
+      if (body.autoSyncEnabled !== undefined) config.autoSync.enabled = !!body.autoSyncEnabled;
+      if (body.autoSyncTime !== undefined) {
+        const t = String(body.autoSyncTime).trim();
+        const hm = /^\d{1,2}:\d{2}$/.test(t) ? t.split(':').map(Number) : null;
+        if (!hm || hm[0] > 23 || hm[1] > 59) {
+          return sendJson(res, 400, { error: '自动同步时间格式应为 HH:mm' });
+        }
+        config.autoSync.time = t;
+      }
+    }
     ctx.saveConfig();
     if (body.cookie !== undefined) {
       ctx.session.cookie = body.cookie;

+ 111 - 0
server/services/auto-sync.js

@@ -0,0 +1,111 @@
+'use strict';
+
+// 经营数据定时自动同步
+// ----------------------------------------------------------------------------
+// 每分钟检查一次:到达配置时间(默认 06:00,本地时区)且今天还没跑过、
+// 商家客户端调试端口在线时,自动同步「昨日」数据进本地数据湖。
+// - 客户端不在线 → 每分钟重试,当天在线的任意时刻补跑(跨夜作废)
+// - 同步失败也计为已跑(避免分钟级重试刷屏);次日再试
+// - 默认关闭,config.json → autoSync.enabled 开启;手动同步不受影响
+// - 同步记录照常写入数据湖 syncs,可在「数据报表 → 同步记录」里查看
+
+const fs = require('fs');
+const path = require('path');
+const naposService = require('../../napos-service.js');
+
+function todayYmd() {
+  const d = new Date();
+  const z = (n) => String(n).padStart(2, '0');
+  return `${d.getFullYear()}${z(d.getMonth() + 1)}${z(d.getDate())}`;
+}
+
+function ymdShift(ymd, delta) {
+  const d = new Date(Number(ymd.slice(0, 4)), Number(ymd.slice(4, 6)) - 1, Number(ymd.slice(6, 8)));
+  d.setDate(d.getDate() + delta);
+  const z = (n) => String(n).padStart(2, '0');
+  return `${d.getFullYear()}${z(d.getMonth() + 1)}${z(d.getDate())}`;
+}
+
+function createAutoSyncService(ctx) {
+  const { config, workspace, logs } = ctx;
+
+  const stateFile = () => path.join(workspace.getWorkspaceRoot(), '.genpic', 'auto-sync.json');
+
+  function loadState() {
+    try {
+      const parsed = JSON.parse(fs.readFileSync(stateFile(), 'utf8'));
+      return parsed && typeof parsed.lastRunDate === 'string' ? parsed : {};
+    } catch {
+      return {};
+    }
+  }
+
+  function saveState(state) {
+    try {
+      const file = stateFile();
+      fs.mkdirSync(path.dirname(file), { recursive: true });
+      const temp = `${file}.${process.pid}.tmp`;
+      fs.writeFileSync(temp, JSON.stringify(state, null, 2), 'utf8');
+      fs.renameSync(temp, file);
+    } catch { /* 状态落盘失败不阻断同步 */ }
+  }
+
+  let running = false; // 本服务的单飞标记(与 naposService 的 SYNC_BUSY 双保险)
+
+  async function tick() {
+    const cfg = config.autoSync || {};
+    if (!cfg.enabled) return;
+    if (running) return;
+    if (naposService.syncSnapshot().running) return; // 手动同步进行中,让路
+
+    const time = /^\d{1,2}:\d{2}$/.test(String(cfg.time || '')) ? cfg.time : '06:00';
+    const [hh, mm] = time.split(':').map(Number);
+    const now = new Date();
+    const nowMinutes = now.getHours() * 60 + now.getMinutes();
+    const schedMinutes = Math.min(23, Math.max(0, hh)) * 60 + Math.min(59, Math.max(0, mm));
+    if (nowMinutes < schedMinutes) return;
+
+    const today = todayYmd();
+    const state = loadState();
+    if (state.lastRunDate === today) return;
+
+    let status;
+    try {
+      status = await naposService.naposStatus();
+    } catch {
+      return; // 探测异常,下一分钟再试
+    }
+    if (!status.connected) return; // 客户端不在线,等待在线时刻补跑
+
+    running = true;
+    const from = ymdShift(today, -1);
+    saveState({ ...state, lastRunDate: today, lastTriggeredAt: new Date().toISOString() });
+    logs.pushSystemLog(`自动同步开始:同步昨日(${from})经营数据`, 'info');
+    try {
+      const snap = await naposService.runSync({ from, to: from, scope: 'all' });
+      if (snap.error) {
+        logs.pushSystemLog(`自动同步失败:${snap.error}`, 'error');
+      } else {
+        logs.pushSystemLog(
+          `自动同步完成:${snap.entity ? snap.entity.name + ',' : ''}新增 ${snap.added} 行、更新 ${snap.updated} 行、失败 ${snap.failed} 项`,
+          snap.failed > 0 ? 'warn' : 'success'
+        );
+      }
+    } catch (e) {
+      logs.pushSystemLog(`自动同步失败:${e.message || e}`, 'error');
+    } finally {
+      running = false;
+    }
+  }
+
+  const timer = setInterval(() => { tick().catch(() => {}); }, 60 * 1000);
+  timer.unref();
+
+  return {
+    /** 立即执行一次检查(供测试/手动触发) */
+    tickNow: tick,
+    stop: () => clearInterval(timer),
+  };
+}
+
+module.exports = { createAutoSyncService };