Jelajahi Sumber

feat: 报表新增「是否营业」列(同步时落 busyLevel 每日快照,列偏好升 v3)+ 定向采集工具(连点版本号 5 次显现,CDP 只录接口清单、ksid/secretkey 强制打码)+ 取数诊断加固(runCase 带传输层证据,连锁级无数自动追加 E/F/G 对照,结论区分请求失败/真无数据);smoke 106/106

KenZ1117 2 minggu lalu
induk
melakukan
071d183ed6
9 mengubah file dengan 532 tambahan dan 7 penghapusan
  1. 84 3
      napos-service.js
  2. 157 0
      napos/harvest.js
  3. 2 0
      public/app.js
  4. 49 0
      public/index.html
  5. 118 4
      public/reports.js
  6. 5 0
      public/style.css
  7. 39 0
      reports-store.js
  8. 45 0
      scripts/smoke.js
  9. 33 0
      server/routes/reports.js

+ 84 - 3
napos-service.js

@@ -19,6 +19,7 @@
 
 const kit = require('./napos/kit.js');
 const store = require('./reports-store.js');
+const harvest = require('./napos/harvest.js');
 
 const DEFAULT_CDP_PORT = Number(process.env.NAPOS_CDP_PORT || 9669);
 
@@ -41,6 +42,10 @@ const REPORT_COLUMNS = [
   { key: 'abnormalCloseHours', label: '异常关店时长(h)', type: 'hours', reserved: true,
     synonyms: ['异常关店', '异常关店时长', '关店时长', '异常闭店'],
     description: '预留列:非计划性关店时长,数据源暂未接入' },
+  { key: 'busyLevel', label: '是否营业', type: 'text', aggregable: false,
+    synonyms: ['是否营业', '营业状态', '营业中', '开门没', '开店没', '营业没'],
+    description: '同步时客户端回报的门店营业状态快照:营业中 / 曾营业(当日曾开业,查询时已打烊)/ 休息 / 暂停营业 / 未开业(无效门店);'
+      + '仅反映同步当天起的状态,更早的历史日期无记录' },
   { key: 'validOrderCnt', label: '有效订单', type: 'int',
     synonyms: ['有效订单', '订单量', '单量', '订单数', '有效单量'],
     description: '剔除取消/无效后的订单量' },
@@ -304,6 +309,13 @@ async function runNaposDiagnose({ date, shopId } = {}) {
         out.metricProbe = diagnoseMetricProbe(daily[0]);
         out.ok = !!r.ok;
         if (r.shapeWarning) out.shapeWarning = true;
+        // 传输层证据:区分「请求没成功(未登录/风控/HTTP错误)」和「成功但没数据」——
+        // 此前这两种在报告里都长成 0 行,KNCT666 类问题因此被误读成"当天无经营数据"
+        out.status = r.status != null ? r.status : null;
+        if (r.risk) out.risk = true;
+        if (r.authFail) out.authFail = true;
+        if (r.code != null) out.code = r.code;
+        if (r.msg) out.msg = r.msg;
       } catch (e) {
         out.error = e.message || String(e);
       }
@@ -311,6 +323,21 @@ async function runNaposDiagnose({ date, shopId } = {}) {
     };
 
     report.chainControl = await runCase('连锁对照·不带门店', { from: testDate, to: testDate, chainId, shopListIn: '' });
+    // 连锁级查不到数时追加三组对照,一次跑完即可区分「请求失败 / 当天无数据 / 账号范围或权限」:
+    //   E 账号默认范围(-1)  F 账号根ID当连锁(UNIT 型账号的树根不是 CHAIN)  G 近7天窗口(排除单日无数据)
+    report.chainFallbacks = [];
+    if (!report.chainControl.rows || !report.chainControl.ordersSum) {
+      const fallbacks = [
+        { tag: 'E 对照 chainId=-1·不带门店', params: { from: testDate, to: testDate, chainId: '-1', shopListIn: '' } },
+        { tag: `F 对照 chainId=${report.merchant && report.merchant.id}·账号根·不带门店`, params: { from: testDate, to: testDate, chainId: String((report.merchant && report.merchant.id) || ''), shopListIn: '' } },
+        { tag: `G 对照 近7天·chainId=${chainId}`, params: { from: ymdShift(testDate, -6), to: testDate, chainId, shopListIn: '' } },
+      ];
+      for (const f of fallbacks) {
+        const r = await runCase(f.tag, f.params);
+        report.chainFallbacks.push(r);
+        await new Promise((res) => setTimeout(res, 600));
+      }
+    }
     report.cases = [];
     for (const s of picked) {
       for (const c of [
@@ -331,16 +358,34 @@ async function runNaposDiagnose({ date, shopId } = {}) {
       c.missingVsChain = [...chainKeys].filter((k) => !(k in c.firstRow));
     }
 
-    // 结论:把开发侧关心的判定直接写成人话
+    // 结论:把判定直接写成人话,并区分「请求失败」和「真没数据」
     const lines = [];
     const cc = report.chainControl;
+    const isBadStatus = (c) => c && c.status != null && (c.status < 200 || c.status >= 300);
+    const transportFail = (c) => !!c && !!(c.error || c.authFail || c.risk || isBadStatus(c)
+      || (c.code != null && String(c.code) !== '0'));
+    const transportReason = (c) => [c.error && '异常', c.authFail && '未登录', c.risk && '风控',
+      isBadStatus(c) && `HTTP ${c.status}`, c.code != null && String(c.code) !== '0' && `code=${c.code}`,
+      c.msg].filter(Boolean).join('/');
     if (!cc || !cc.rows || !cc.ordersSum) {
-      lines.push('连锁对照查询也为零:该日期可能无经营数据,换一个链级行有数的日期再诊断一次。');
+      const bad = [cc, ...(report.chainFallbacks || [])].filter(transportFail);
+      if (bad.length) {
+        lines.push(`数据中心(生意参谋)接口请求没有成功(${[...new Set(bad.map(transportReason))].slice(0, 3).join(';')})——这不是「没数据」。`
+          + '多半是这个账号的数据中台登录态没恢复:请在客户端里点开一次「数据中心」页面,仍不行就完全退出客户端、带调试端口重启并重新登录,再诊断一次。');
+      } else {
+        const week = (report.chainFallbacks || []).find((c) => String(c.tag).startsWith('G') && c.rows && c.ordersSum);
+        if (week) {
+          lines.push(`查询当天(${report.testDate})确实没有数据,但近 7 天有数(有效订单 ${week.ordersSum})——参数与权限都正常,直接重新同步该主体即可取到数。`);
+        } else {
+          lines.push('各参数形式下连锁级均为零:该日期可能无经营数据,或该账号在数据中心无此范围的数据权限。可换一个确定有数的日期再诊断;若客户端「数据中心」页面里也看不到数,需平台侧确认权限。');
+        }
+      }
     } else {
       lines.push(`连锁对照正常(${cc.rows} 行 / 有效订单 ${cc.ordersSum} / 营业额 ${cc.grossSum})。`);
       const hit = report.cases.filter((c) => c.rows > 0 && (c.ordersSum > 0 || c.grossSum > 0));
       if (hit.length) {
-        lines.push(`门店维度有数据的参数形式:${[...new Set(hit.map((c) => c.tag.split(' ')[0]))].join('、')}——把本报告发回开发侧即可修正取数参数。`);
+        lines.push(`门店维度有数据的参数形式:${[...new Set(hit.map((c) => c.tag.split(' ')[0]))].join('、')}。`
+          + '同步已内置参数自动切换——把程序更新到最新版后,对该主体重新同步一次即可修正,无需人工配置。');
       } else {
         lines.push('所有参数形式下门店维度均为零:需核对数据权限/门店经营状态(详见各变体原始返回首行)。');
       }
@@ -460,6 +505,14 @@ async function runSync({ from, to, scope = 'all', chainId, shopIds, port } = {})
   try {
     const tree = await fetchTree();
     const entity = tree.entity;
+    // 营业状态快照:busyLevel 反映的是「现在」,挂在同步发生的当天;失败不阻塞同步
+    try {
+      const lvShops = (tree.shops || []).map((s) => ({
+        shopId: String(s.shopId), shopName: s.shopName,
+        chainId: s.chainId ? String(s.chainId) : '', busyLevel: s.busyLevel || null,
+      }));
+      if (lvShops.length) store.saveBusyStatus(entity.key, kit.today(), lvShops);
+    } catch { /* 快照失败不影响取数 */ }
     const fromDay = kit.normDay(from || dayShift(yesterday(), -29));
     const toDay = kit.normDay(to || yesterday());
     if (!/^\d{8}$/.test(fromDay) || !/^\d{8}$/.test(toDay) || fromDay > toDay) {
@@ -642,6 +695,23 @@ function summarizeRows(rows) {
   return s;
 }
 
+const BUSY_LEVEL_TEXT = {
+  OPEN: '营业中', CLOSE: '休息', SUSPEND_BUSINESS: '暂停营业', INVALID: '未开业',
+};
+
+/** 报表行回填营业状态:lv=当日最新快照;open=当天任一时刻曾营业中(取「曾营业」) */
+function attachBusyStatus(key, rows) {
+  let days;
+  try { days = (store.loadBusyStatus(key).days) || {}; } catch { return; }
+  for (const r of rows) {
+    if (!r.shopId || r.scope !== 'shop') continue;
+    const hit = days[r.date] && days[r.date][String(r.shopId)];
+    if (!hit || !hit.lv) continue;
+    r.busyLevel = hit.lv === 'OPEN' ? '营业中'
+      : (hit.open ? '曾营业' : (BUSY_LEVEL_TEXT[hit.lv] || hit.lv));
+  }
+}
+
 function queryReport({ entity, from, to, shopId, shops, scope, q } = {}) {
   const key = store.sanitizeKey(entity);
   const meta = store.getEntity(key);
@@ -657,6 +727,7 @@ function queryReport({ entity, from, to, shopId, shops, scope, q } = {}) {
   }
   const account = meta ? meta.name : key;
   rows = rows.map((r) => ({ ...r, account }));
+  attachBusyStatus(key, rows);
   return {
     entity: meta ? { key: meta.key, name: meta.name } : { key, name: key },
     from: from || null, to: to || null,
@@ -726,11 +797,21 @@ function rowsToCsv(columns, rows) {
   return '\ufeff' + lines.join('\r\n');
 }
 
+// ---------- 定向流量采集(接口目录补抓,配合取数诊断使用) ----------
+
+function startHarvest({ seconds } = {}) {
+  return harvest.runHarvest({ port: DEFAULT_CDP_PORT, seconds });
+}
+function stopHarvest() { return harvest.stopHarvest(); }
+function harvestStatus() { return harvest.status(); }
+function getHarvestResult() { return harvest.getResult(); }
+
 module.exports = {
   DEFAULT_CDP_PORT, REPORT_COLUMNS, FIELD_MAP,
   naposStatus, fetchTree, runNaposDiagnose,
   runSync, syncSnapshot, requestSyncCancel,
   queryReport, aggregateReport, summarizeRows,
   rowsToCsv,
+  startHarvest, stopHarvest, harvestStatus, getHarvestResult,
   store,
 };

+ 157 - 0
napos/harvest.js

@@ -0,0 +1,157 @@
+'use strict';
+/**
+ * 定向流量采集(harvest)
+ * ============================================================================
+ * 背景:napos/data/apis.json 是当年在客户端里抓 5 分钟流量归纳的,页面覆盖不全
+ * (订单页只抓到实时接口、没人点过「历史订单」)。本模块补上「定向补抓」能力:
+ * 同事在客户端里操作目标页面(翻历史订单、看营业时间设置),本模块通过 CDP
+ * Network 域把这段时间内所有业务请求录下来,输出「接口清单」(method/路径/
+ * 请求体采样/状态码),供开发侧定位目录外接口。
+ *
+ * 边界(与运营套件手册一致):
+ *   · 只录不改 —— 不重放、不模拟点击、不读响应体、不碰任何写接口;
+ *   · 只录业务域 —— CDN 静态资源/埋点/风控探针一律丢弃。
+ */
+
+const cdp = require('./cdp');
+
+/** 只关心这些业务域(后缀匹配) */
+const BIZ_HOST_SUFFIXES = ['ele.me', 'elemecdn.com', 'alibaba.com', 'taobao.com', 'koubei.com'];
+/** 静态资源/埋点/风控探针 —— 即使在业务域下也丢 */
+const NOISE_HOST_RE = /(^|\.)g\.alicdn\.com$|(^|\.)img\.alicdn\.com$|(^|\.)assets\.elemecdn\.com$|(^|\.)alidt\.alicdn\.com$|mdap\.alipay\.com|ynuf\.aliapp\.org|(^|\.)render\.alipay\.com$|(^|\.)gw\.alipayobjects\.com$|xux-web-config\.oss/i;
+const STATIC_EXT_RE = /\.(js|css|png|jpe?g|gif|webp|svg|ico|woff2?|ttf|eot|mp4|map|html?)(\?|$)/i;
+
+const state = { running: false, startedAt: null, seconds: 0, stopRequested: false };
+let lastResult = null;
+
+function hostAllowed(hostname) {
+  const h = String(hostname || '').toLowerCase();
+  if (NOISE_HOST_RE.test(h)) return false;
+  return BIZ_HOST_SUFFIXES.some((s) => h === s || h.endsWith('.' + s));
+}
+
+function status() {
+  return {
+    running: state.running,
+    startedAt: state.startedAt,
+    seconds: state.seconds,
+    stopRequested: state.stopRequested,
+  };
+}
+
+/** 请求体/URL 里常带登录态(ksid/secretkey):落清单前一律打码,报告外发不泄露凭证 */
+function redact(s) {
+  return String(s).replace(/(ksid|secretkey)(["']?\s*[:=]\s*"?)([^"'&,;\s}\]]+)/gi, '$1$2***');
+}
+
+async function runHarvest({ port = cdp.DEFAULT_PORT, seconds = 120 } = {}) {
+  if (state.running) {
+    const e = new Error('已有定向采集在进行中');
+    e.code = 'HARVEST_BUSY';
+    throw e;
+  }
+  const p = await cdp.probe(port);
+  if (!p.ok) {
+    const e = new Error(`客户端不在线(调试端口 ${port} 不可用)`);
+    e.code = 'HARVEST_NO_PORT';
+    throw e;
+  }
+  const secs = Math.max(10, Math.min(600, Number(seconds) || 120));
+  state.running = true;
+  state.stopRequested = false;
+  state.startedAt = new Date().toISOString();
+  state.seconds = secs;
+  lastResult = null; // 新一轮先清旧结果:失败/中断时绝不把上一次的清单当本次的
+
+  const map = new Map(); // key -> endpoint 条目(statuses 用 Set,最后转数组)
+  const sessions = [];
+
+  const record = (pa) => {
+    try {
+      const req = pa && pa.request;
+      if (!req || !/^https?:/i.test(req.url)) return;
+      let u;
+      try { u = new URL(req.url); } catch { return; }
+      if (!hostAllowed(u.hostname)) return;
+      if (STATIC_EXT_RE.test(u.pathname)) return;
+      const method = String(req.method || 'GET').toUpperCase();
+      const key = `${method} ${u.host}${u.pathname}`;
+      let e = map.get(key);
+      if (!e) {
+        e = { key, method, host: u.host, path: u.pathname, count: 0,
+          sampleUrl: redact(req.url).slice(0, 800), sampleBody: '', _statuses: new Set() };
+        map.set(key, e);
+      }
+      e.count += 1;
+      if (!e.sampleBody && req.postData) e.sampleBody = redact(String(req.postData)).slice(0, 2000);
+      return key;
+    } catch { return null; }
+  };
+
+  try {
+    // 逐 target 建会话;单个 target 失败不阻塞(iframe 进程可能拒绝附加)
+    const targets = await cdp.listTargets(port).catch(() => []);
+    for (const t of targets) {
+      if (!t.webSocketDebuggerUrl || !['page', 'iframe', 'webview'].includes(t.type)) continue;
+      try {
+        const c = new cdp.CDP(t.webSocketDebuggerUrl);
+        await c.open(8000);
+        const sid = await c.attachTo(t.id);
+        const ridKey = new Map(); // requestId -> endpoint key
+        c.on('Network.requestWillBeSent', (pa) => {
+          const key = record(pa);
+          if (key && pa.requestId) ridKey.set(pa.requestId, key);
+        });
+        c.on('Network.responseReceived', (pa) => {
+          try {
+            const key = pa && pa.requestId && ridKey.get(pa.requestId);
+            const e = key && map.get(key);
+            const st = pa && pa.response && pa.response.status;
+            if (e && st) e._statuses.add(st);
+          } catch { /* ignore */ }
+        });
+        await c.send('Network.enable', {}, sid);
+        sessions.push({ c, sid });
+      } catch { /* ignore this target */ }
+    }
+
+    const startedAt = Date.now();
+    const deadline = startedAt + secs * 1000;
+    while (Date.now() < deadline && !state.stopRequested) await cdp.sleep(500);
+    const actualSeconds = Math.round((Date.now() - startedAt) / 1000);
+
+    const endpoints = [...map.values()]
+      .map(({ _statuses, ...e }) => ({ ...e, statuses: [..._statuses].sort((a, b) => a - b) }))
+      .sort((a, b) => b.count - a.count);
+
+    const result = {
+      harvestedAt: new Date().toISOString(),
+      seconds: actualSeconds,
+      stoppedEarly: state.stopRequested,
+      targetCount: targets.length,
+      sessionCount: sessions.length,
+      endpoints,
+    };
+    lastResult = result;
+    return result;
+  } finally {
+    // 无论正常结束、提前停止还是半路抛异常:会话必关、状态必清,绝不卡死下次采集
+    for (const s of sessions) {
+      try { await s.c.detach(s.sid); } catch { /* ignore */ }
+      s.c.close();
+    }
+    state.running = false;
+    state.stopRequested = false;
+  }
+}
+
+/** 提前收工:把剩余等待截断,正常走收尾(会话已录到的请求不丢) */
+function stopHarvest() {
+  if (!state.running) return status();
+  state.stopRequested = true;
+  return status();
+}
+
+function getResult() { return lastResult; }
+
+module.exports = { runHarvest, stopHarvest, status, getResult, hostAllowed };

+ 2 - 0
public/app.js

@@ -434,6 +434,8 @@
           ver.textContent = '诊断已开启';
           revertTimer = setTimeout(() => { ver.textContent = verText; }, 2000);
           diagBtn.hidden = false;
+          const harvestBtn = document.getElementById('btnHarvest');
+          if (harvestBtn) harvestBtn.hidden = false;
         }
       });
     }

+ 49 - 0
public/index.html

@@ -285,6 +285,7 @@
               </div>
             </span>
             <button class="btn btn-ghost btn-small" id="btnNaposDiagnose" hidden>取数诊断</button>
+            <button class="btn btn-ghost btn-small" id="btnHarvest" hidden>定向采集</button>
             <button class="btn btn-ghost btn-small" id="btnReportRefresh">
               <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 12a8 8 0 1 1-2.3-5.6"/><path d="M20 4v5h-5"/></svg>
               刷新
@@ -924,6 +925,54 @@
   </div>
 </div>
 
+<!-- 定向采集弹窗(隐藏入口:与取数诊断一起,连点侧边栏版本号 5 次显现) -->
+<div class="modal-overlay" id="modalHarvest" hidden>
+  <div class="modal modal-wide">
+    <div class="modal-header">
+      <div class="modal-title">
+        <span class="modal-status-icon">
+          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M5 12a7 7 0 0 1 1.5-4.3M19 12a7 7 0 0 0-1.5-4.3M2.5 12a9.5 9.5 0 0 1 2-5.8M21.5 12a9.5 9.5 0 0 0-2-5.8"/></svg>
+        </span>
+        <span>定向采集接口</span>
+      </div>
+      <button class="modal-close" id="btnCloseHarvest">&times;</button>
+    </div>
+    <div class="modal-body">
+      <ol class="help-popover-steps" style="position:static;opacity:1;visibility:visible;margin:0 0 10px;">
+        <li><b>用途:</b>补录接口目录没有覆盖的页面(如「历史订单」「营业时间设置」),供开发侧定位数据源。</li>
+        <li><b>用法:</b>点「开始采集」后,切到淘宝闪购客户端,<b>正常翻看目标页面约 2 分钟</b>(比如订单页点「历史订单」翻几页),时间到自动结束。</li>
+        <li><b>安全:</b>只录下你浏览时页面自己发出的请求地址,不重放、不点击、不改任何数据。</li>
+        <li><b>完成后:</b>点「复制清单」把结果发回开发侧即可。</li>
+      </ol>
+      <div class="field-row">
+        <div class="field">
+          <label>采集时长</label>
+          <select id="harvestSeconds">
+            <option value="60">1 分钟</option>
+            <option value="120" selected>2 分钟</option>
+            <option value="300">5 分钟</option>
+          </select>
+        </div>
+        <div class="field" style="flex:0;">
+          <label>&nbsp;</label>
+          <button class="btn btn-primary btn-small" id="btnHarvestStart">开始采集</button>
+        </div>
+      </div>
+      <div class="badge" id="harvestStatusBadge" style="margin-bottom:8px;">待开始</div>
+      <div id="harvestSummary" class="diagnose-summary" hidden></div>
+      <details class="diagnose-raw" id="harvestRawWrap" hidden>
+        <summary>接口清单 JSON(复制发给开发即可)</summary>
+        <pre id="harvestJson"></pre>
+      </details>
+    </div>
+    <div class="modal-footer">
+      <button class="btn btn-ghost btn-small" id="btnCopyHarvest" hidden>复制清单</button>
+      <button class="btn btn-ghost btn-small" id="btnHarvestStop" hidden>提前结束</button>
+      <button class="btn btn-primary btn-small" id="btnDismissHarvest">关闭</button>
+    </div>
+  </div>
+</div>
+
 <!-- 数据同步弹窗 -->
 <div class="modal-overlay" id="modalReportSync" hidden>
   <div class="modal modal-wide">

+ 118 - 4
public/reports.js

@@ -699,10 +699,10 @@
     // 默认只展示原始/主指标列(营业时长、异常关店时长、商品销售额为预留列,数据源接入后自动回填);
     // 下单转化率、客单价、支出、活动订单、活动补贴、推广ROI、30日复购率等派生指标默认隐藏,可在列设置打开。
     const DEFAULT_VISIBLE_COLS = new Set([
-      'bizHours', 'abnormalCloseHours', 'validOrderCnt', 'incomeAmt', 'grossAmt',
+      'bizHours', 'abnormalCloseHours', 'busyLevel', 'validOrderCnt', 'incomeAmt', 'grossAmt',
       'goodsSaleAmt', 'expUv', 'orderUserCnt', 'clkExpRate',
     ]);
-    const saved = LS.get('shanReports.columns.v2', null);
+    const saved = LS.get('shanReports.columns.v3', null);
     const defaults = new Set(state.columns.filter((c) => DEFAULT_VISIBLE_COLS.has(c.key)).map((c) => c.key));
     if (Array.isArray(saved)) {
       const valid = new Set(state.columns.map((c) => c.key));
@@ -733,7 +733,7 @@
       if (cb.checked) picks.add(cb.value);
     });
     state.visible = picks;
-    LS.set('shanReports.columns.v2', Array.from(picks));
+    LS.set('shanReports.columns.v3', Array.from(picks));
     $('modalReportColumns').hidden = true;
     renderTable();
   }
@@ -1338,6 +1338,23 @@
     $('btnDismissDiagnose').addEventListener('click', closeDiagnoseModal);
     $('btnCopyDiagnose').addEventListener('click', copyDiagnoseReport);
     $('btnDownloadDiagnose').addEventListener('click', downloadDiagnoseReport);
+    $('btnHarvest').addEventListener('click', openHarvestModal);
+    $('btnCloseHarvest').addEventListener('click', () => { $('modalHarvest').hidden = true; stopHarvestPoll(); });
+    $('btnDismissHarvest').addEventListener('click', () => { $('modalHarvest').hidden = true; stopHarvestPoll(); });
+    $('btnHarvestStart').addEventListener('click', startHarvest);
+    $('btnHarvestStop').addEventListener('click', async () => {
+      try { await fetch('/api/reports/harvest/stop', { method: 'POST' }); } catch { /* 状态轮询会收尾 */ }
+      setHarvestBadge('正在收尾…', 'badge-info');
+    });
+    $('btnCopyHarvest').addEventListener('click', () => {
+      if (!lastHarvestResult) return;
+      const text = JSON.stringify(lastHarvestResult, null, 2);
+      if (navigator.clipboard && navigator.clipboard.writeText) {
+        navigator.clipboard.writeText(text).then(() => showToast('已复制,粘贴发给开发即可', 'success'), () => fallbackCopyDiagnose(text));
+      } else {
+        fallbackCopyDiagnose(text);
+      }
+    });
 
     $('btnClientHelp').addEventListener('click', (event) => {
       event.stopPropagation();
@@ -1441,7 +1458,7 @@
     $('btnApplyColumns').addEventListener('click', applyColumnCfg);
     $('btnResetColumns').addEventListener('click', () => {
       state.visible = new Set(state.columns.filter((c) => !c.reserved).map((c) => c.key));
-      LS.set('shanReports.columns.v2', Array.from(state.visible));
+      LS.set('shanReports.columns.v3', Array.from(state.visible));
       renderColumnCfg();
       renderTable();
     });
@@ -1648,6 +1665,103 @@
     URL.revokeObjectURL(a.href);
   }
 
+  // ---------- 定向采集(隐藏入口:与取数诊断一起显现,连点版本号 5 次) ----------
+
+  let harvestPoll = null;
+  let lastHarvestResult = null;
+
+  function setHarvestBadge(text, cls) {
+    const b = $('harvestStatusBadge');
+    b.textContent = text;
+    b.className = 'badge ' + (cls || '');
+  }
+
+  function stopHarvestPoll() { if (harvestPoll) { clearInterval(harvestPoll); harvestPoll = null; } }
+
+  function pollHarvest() {
+    stopHarvestPoll();
+    harvestPoll = setInterval(async () => {
+      try {
+        const st = await (await fetch('/api/reports/harvest/status')).json();
+        if (!st.running) {
+          stopHarvestPoll();
+          await loadHarvestResult();
+        } else {
+          const left = Math.max(0, st.seconds - Math.round((Date.now() - new Date(st.startedAt).getTime()) / 1000));
+          setHarvestBadge(`采集中… 剩余约 ${left}s——去客户端里翻目标页面`, 'badge-warn');
+        }
+      } catch { /* 静默重试 */ }
+    }, 2000);
+  }
+
+  async function loadHarvestResult() {
+    try {
+      const data = await (await fetch('/api/reports/harvest/result')).json();
+      const r = data.result;
+      if (!r) { setHarvestBadge('未拿到结果,请重试一次', 'badge-fail'); $('btnHarvestStart').disabled = false; return; }
+      lastHarvestResult = r;
+      renderHarvestResult(r);
+    } catch (e) { setHarvestBadge('结果读取失败:' + e.message, 'badge-fail'); }
+  }
+
+  function renderHarvestResult(r) {
+    setHarvestBadge(`已结束:${r.endpoints.length} 个接口 · ${r.seconds}s · 覆盖 ${r.sessionCount}/${r.targetCount} 个页面`, 'badge-ok');
+    const rows = r.endpoints.slice(0, 80).map((e) => `<tr>
+      <td>${esc(e.method)}</td>
+      <td class="harvest-path">${esc(e.host + e.path)}</td>
+      <td class="num">${e.count}</td>
+      <td class="num">${(e.statuses || []).join('/') || '–'}</td>
+      <td>${e.sampleBody ? `<details><summary>请求体</summary><pre>${esc(e.sampleBody)}</pre></details>` : '–'}</td>
+    </tr>`).join('');
+    $('harvestSummary').hidden = false;
+    $('harvestSummary').innerHTML = `
+      <table class="data-table diagnose-table"><thead><tr><th>方法</th><th>接口</th><th>次数</th><th>状态码</th><th>采样</th></tr></thead><tbody>${rows}</tbody></table>
+      <div class="help" style="margin-top:6px;">表中只显示前 80 个(按请求次数排序),完整清单在下方 JSON 里。</div>`;
+    $('harvestJson').textContent = JSON.stringify(r, null, 2);
+    $('harvestRawWrap').hidden = false;
+    $('btnCopyHarvest').hidden = false;
+    $('btnHarvestStop').hidden = true;
+    $('btnHarvestStart').disabled = false;
+  }
+
+  async function startHarvest() {
+    const seconds = Number($('harvestSeconds').value) || 120;
+    $('btnHarvestStart').disabled = true;
+    $('harvestSummary').hidden = true;
+    $('harvestRawWrap').hidden = true;
+    $('btnCopyHarvest').hidden = true;
+    $('btnHarvestStop').hidden = false;
+    setHarvestBadge('正在连接客户端…', 'badge-info');
+    try {
+      const res = await fetch('/api/reports/harvest/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ seconds }) });
+      const data = await res.json();
+      if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
+      setHarvestBadge(`采集中… 请切到淘宝闪购客户端翻看目标页面(约 ${seconds}s)`, 'badge-warn');
+      pollHarvest();
+    } catch (e) {
+      setHarvestBadge('启动失败:' + e.message, 'badge-fail');
+      $('btnHarvestStart').disabled = false;
+      $('btnHarvestStop').hidden = true;
+    }
+  }
+
+  function openHarvestModal() {
+    $('modalHarvest').hidden = false;
+    // 接上后台状态:在跑→继续轮询;已结束→直接展示既有结果(首次为空则保持待开始)
+    fetch('/api/reports/harvest/status').then((r) => r.json()).then(async (st) => {
+      if (st.running) {
+        $('btnHarvestStart').disabled = true;
+        $('btnHarvestStop').hidden = false;
+        pollHarvest();
+      } else {
+        try {
+          const data = await (await fetch('/api/reports/harvest/result')).json();
+          if (data.result && !lastHarvestResult) { lastHarvestResult = data.result; renderHarvestResult(data.result); }
+        } catch { /* ignore */ }
+      }
+    }).catch(() => {});
+  }
+
   // ---------- 初始化 ----------
 
   // 数据采集页工具栏:推送当前主体到云端(本地湖 → 钉钉 AI 表格)。返回是否推送成功。

+ 5 - 0
public/style.css

@@ -1175,3 +1175,8 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
 .ask-phase.phase-查询 { background: var(--info-bg); color: var(--info); }
 .ask-phase.phase-提取 { background: var(--warn-bg); color: var(--warn); }
 .ask-phase.phase-计算 { background: var(--ok-bg); color: var(--ok); }
+
+/* 定向采集结果表 */
+.harvest-path { max-width: 380px; white-space: normal !important; word-break: break-all; font-size: 12px; color: var(--text-2); }
+#harvestSummary details summary { cursor: pointer; font-size: 12px; color: var(--text-3); }
+#harvestSummary details pre { max-width: 420px; max-height: 160px; overflow: auto; margin: 4px 0 0; padding: 8px; border-radius: 8px; background: var(--border-light); white-space: pre-wrap; word-break: break-all; font-size: 11.5px; }

+ 39 - 0
reports-store.js

@@ -325,11 +325,50 @@ function loadSyncLogs(entityKey) {
   return readJsonSafe(syncsFile(entityKey), { runs: [] }).runs;
 }
 
+// ---------- 营业状态快照(busyLevel 按天落库) ----------
+// busyLevel 是「同步那一刻」的门店营业状态(OPEN/CLOSE/INVALID/SUSPEND_BUSINESS),
+// 只能往前积累、无法回填历史。同一天多次同步:lv/at 取最新,open 记「当天任一时刻营业中」。
+
+function busyStatusFile(entityKey) {
+  return path.join(LAKE_DIR, sanitizeKey(entityKey), 'busystatus.json');
+}
+
+function loadBusyStatus(entityKey) {
+  return readJsonSafe(busyStatusFile(entityKey), { version: 1, days: {} });
+}
+
+function saveBusyStatus(entityKey, day, shops) {
+  const data = loadBusyStatus(entityKey);
+  if (!data.days || typeof data.days !== 'object') data.days = {};
+  const now = new Date();
+  const hm = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
+  const dayMap = data.days[day] || {};
+  for (const s of shops || []) {
+    const sid = String(s.shopId || '');
+    if (!sid) continue;
+    const prev = dayMap[sid] || {};
+    const lv = s.busyLevel || prev.lv || null;
+    dayMap[sid] = {
+      lv,
+      at: hm,
+      open: Boolean(prev.open) || lv === 'OPEN',
+      name: s.shopName || prev.name || '',
+      chainId: s.chainId != null ? String(s.chainId) : (prev.chainId || ''),
+    };
+  }
+  data.days[day] = dayMap;
+  const keys = Object.keys(data.days).sort();
+  while (keys.length > 400) delete data.days[keys.shift()];
+  writeJsonAtomic(busyStatusFile(entityKey), data);
+  return data;
+}
+
 module.exports = {
   LAKE_DIR,
   loadRegistry, saveRegistry, upsertEntity, listEntities, listEntitiesFull, getEntity,
   loadRows, saveRows, upsertRows, queryRows, datesInLake, deleteEntityData, rowKey,
   appendSyncLog, loadSyncLogs,
+  loadBusyStatus, saveBusyStatus,
   treeFromRows, mergeTree,
   sanitizeKey,
 };

+ 45 - 0
scripts/smoke.js

@@ -334,6 +334,51 @@ async function main() {
         `status=${r.status} conclusion=${j.conclusion}`);
     });
 
+    // ---- 营业状态快照(busyLevel)与定向采集路由 ----
+    await check('busyLevel → 快照落库 + 报表行回填 + 不产生多余行(隔离子进程)', async () => {
+      const blake = path.join(tmpMain, 'lake-busy');
+      fs.mkdirSync(blake, { recursive: true });
+      const probe = spawnSync(process.execPath, ['-e', [
+        'const svc = require(process.env.ROOT + "/napos-service.js");',
+        'const store = svc.store;',
+        'store.upsertEntity({ merchantId: "m1", merchantName: "快照主体", chains: [{ chainId: "c1", chainName: "连锁" }], shops: [{ shopId: "s1", shopName: "一店", chainId: "c1" }] });',
+        'store.upsertRows("快照主体", [',
+        '  { date: "20261018", scope: "shop", chainId: "c1", shopId: "s1", shopName: "一店", validOrderCnt: 10, grossAmt: 100, incomeAmt: 80, expUv: 50, orderUserCnt: 8, clkExpRate: 0.1, ordClkRate: 0.16, perActualPay: 10 },',
+        '  { date: "20261017", scope: "shop", chainId: "c1", shopId: "s1", shopName: "一店", validOrderCnt: 5, grossAmt: 60, incomeAmt: 48, expUv: 30, orderUserCnt: 4, clkExpRate: 0.12, ordClkRate: 0.13, perActualPay: 12 },',
+        ']);',
+        'store.saveBusyStatus("快照主体", "20261018", [{ shopId: "s1", shopName: "一店", chainId: "c1", busyLevel: "OPEN" }]);',
+        'store.saveBusyStatus("快照主体", "20261017", [{ shopId: "s1", shopName: "一店", chainId: "c1", busyLevel: "OPEN" }]);',
+        'store.saveBusyStatus("快照主体", "20261017", [{ shopId: "s1", shopName: "一店", chainId: "c1", busyLevel: "SUSPEND_BUSINESS" }]);',
+        'const r = svc.queryReport({ entity: "快照主体", from: "20261016", to: "20261018" });',
+        'const byDate = Object.fromEntries(r.rows.map((x) => [x.date, x.busyLevel]));',
+        'console.log(JSON.stringify({ d18: byDate["20261018"], d17: byDate["20261017"], d16: byDate["20261016"], rows: r.rows.length }));',
+      ].join('\n')], {
+        encoding: 'utf8', timeout: 20000,
+        env: { ...process.env, GENPIC_LAKE_DIR: blake, ROOT },
+      });
+      expect(probe.status === 0, `探针失败:${(probe.stderr || probe.stdout || '').slice(-300)}`);
+      const out = JSON.parse(probe.stdout.trim().split('\n').pop());
+      expect(out.d18 === '营业中', `OPEN 应映射营业中:${out.d18}`);
+      // 当天先 OPEN 后 SUSPEND:最新状态是暂停,但出现过营业中 → 曾营业
+      expect(out.d17 === '曾营业', `OPEN+SUSPEND 应合并为曾营业:${out.d17}`);
+      // 只有快照、没有指标行的日期不得凭空造行
+      expect(out.rows === 2 && out.d16 === undefined, `快照不应产生指标行:rows=${out.rows} d16=${out.d16}`);
+      fs.rmSync(blake, { recursive: true, force: true });
+    });
+    await check('定向采集路由 → 状态/结果/启动(客户端离线优雅处理,不卡死)', async () => {
+      const s0 = await request(port, 'GET', '/api/reports/harvest/status');
+      expect(s0.status === 200 && s0.json().running === false, `status=${s0.status}`);
+      const r0 = await request(port, 'GET', '/api/reports/harvest/result');
+      expect(r0.status === 200 && r0.json().result === null, `首次 result 应为 null:${JSON.stringify(r0.json()).slice(0, 80)}`);
+      const st1 = await request(port, 'POST', '/api/reports/harvest/start', { seconds: 60 });
+      expect(st1.status === 200 && st1.json().ok === true, `start=${st1.status}`);
+      await new Promise((res) => setTimeout(res, 800));
+      const s2 = await request(port, 'GET', '/api/reports/harvest/status');
+      expect(s2.json().running === false, '客户端离线时采集应快速失败,状态不得卡在 running');
+      const stop = await request(port, 'POST', '/api/reports/harvest/stop', {});
+      expect(stop.status === 200 && stop.json().running === false, `stop=${stop.status}`);
+    });
+
     // ---- 钉钉三表拆分与行键(纯函数回归,不打真实钉钉 API) ----
     const aitableLib = require('../server/services/aitable.js');
     const { rowKey } = require('../reports-store.js');

+ 33 - 0
server/routes/reports.js

@@ -175,6 +175,39 @@ function register(app, ctx) {
     sendJson(res, 200, r);
   });
 
+  // 定向流量采集(只录不改):同事在客户端里操作目标页面 N 秒,录下业务接口清单供开发侧补目录。
+  // start 立即返回、后台采集;前端轮询 status,结束后取 result(顺带落盘 workspace/.genpic/harvest-latest.json)
+  app.post('/api/reports/harvest/start', async ({ req, res }) => {
+    const body = await readBody(req);
+    const st = naposService.harvestStatus();
+    if (st.running) return sendJson(res, 409, { error: '已有定向采集在进行中' });
+    const seconds = Math.max(10, Math.min(600, Number(body && body.seconds) || 120));
+    naposService.startHarvest({ seconds }).catch(() => { /* 结果经 status/result 读取 */ });
+    sendJson(res, 200, { ok: true, seconds });
+  });
+
+  app.get('/api/reports/harvest/status', async ({ res }) => {
+    sendJson(res, 200, naposService.harvestStatus());
+  });
+
+  app.post('/api/reports/harvest/stop', async ({ res }) => {
+    sendJson(res, 200, naposService.stopHarvest());
+  });
+
+  app.get('/api/reports/harvest/result', async ({ res }) => {
+    const r = naposService.getHarvestResult();
+    if (r) {
+      try {
+        const fs = require('fs');
+        const path = require('path');
+        const dir = path.join(ctx.workspace.getWorkspaceRoot(), '.genpic');
+        fs.mkdirSync(dir, { recursive: true });
+        fs.writeFileSync(path.join(dir, 'harvest-latest.json'), JSON.stringify(r, null, 2), 'utf8');
+      } catch { /* 落盘失败不影响返回 */ }
+    }
+    sendJson(res, 200, { result: r || null });
+  });
+
   app.get('/api/reports/aitable/status', async ({ res, query }) => {
     const entity = query.entity || (naposService.store.listEntities()[0] || {}).key || '';
     const a = ctx.config.aitable || {};