Procházet zdrojové kódy

fix: 采集链路加固与体验修复

- 取数链路:HTTP 层失败/形态异常拒静默落库,pageFetch 增强(cdp/kit/ws)
- 安全:外部数据(店名/账号/参考图路径)进 innerHTML 前统一 esc 转义,
  参考图钳制在店铺目录内;写请求 Origin 同源校验(本地 CSRF 防护)
- config 嵌套默认值合并(局部保存不再清掉组织级默认);aitable 推送单飞锁
- 体验:采集表格轮询保留勾选状态、生成任务终态修剪防内存累积、分析模板微调
KenZ1117 před 2 týdny
rodič
revize
7339f1bbf7

+ 9 - 1
napos-service.js

@@ -387,6 +387,10 @@ async function callHistory(scopeKey, ctx, port = DEFAULT_CDP_PORT) {
   const r = await kit.statsHistory(o);
   if (r.risk) throw new Error('触发客户端风控(请稍后在客户端完成验证后重试)');
   if (r.authFail) throw new Error('登录态失效(ksid 过期),请在商家客户端重新登录');
+  // HTTP 层失败:pageFetch 拿不到业务数据,不检查会把失败伪装成 0 行落库
+  if (r.ok === false) throw new Error('接口请求失败:' + (r.error || `HTTP ${r.status || '?'}`));
+  // 形态守卫:有原始行但无任何 ds 形态行 = 接口/页面结构变化,拒绝静默落空
+  if (r.shapeWarning) throw new Error(`接口返回形态异常:${(r.rows || []).length} 行均无 ds 字段(接口/页面结构可能已变化),已拒绝把空数据落库`);
   if (r.code != null && r.code !== 0 && String(r.code) !== '0') {
     throw new Error('接口返回异常 code=' + r.code + (r.msg ? ' ' + r.msg : ''));
   }
@@ -486,7 +490,11 @@ function aggregateReport({ entity, from, to, group = 'shop', shops } = {}) {
 
 function csvEscape(v) {
   if (v == null) return '';
-  const s = typeof v === 'object' ? JSON.stringify(v) : String(v);
+  let s = typeof v === 'object' ? JSON.stringify(v) : String(v);
+  // 防 Excel 公式注入:非数字值以 = + - @ 开头时前置单引号(负数是数字,不受影响)
+  if (/^[=+@\t\r]/.test(s) || (/^-/.test(s) && !Number.isFinite(Number(s)))) {
+    s = "'" + s;
+  }
   return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
 }
 

+ 42 - 6
napos/cdp.js

@@ -282,15 +282,32 @@ class CDP {
     this.pending = new Map();
     this.handlers = new Map();
     this.attached = new Set();
+    this.dead = false;
   }
 
   async open(timeout = 10000) {
     this.ws = new RawWebSocket(this.wsUrl);
     this.ws.on('message', (m) => this._onMessage(m));
+    // 必须接住 error/close:底层 socket 出错时 EventEmitter 无人监听 'error'
+    // 会直接崩掉整个进程;断连后 pending 也要立刻兑现而不是干等超时。
+    this.ws.on('error', (e) => this._onDead(e instanceof Error ? e : new Error(String(e || 'WebSocket 连接错误'))));
+    this.ws.on('close', () => this._onDead(new Error('WebSocket 连接已关闭')));
     await this.ws.connect(timeout);
     return this;
   }
 
+  /** 连接死亡:把所有在途请求立即以该错误兑现,后续 send 也会快速失败 */
+  _onDead(err) {
+    if (this.dead) return;
+    this.dead = true;
+    const waiting = [...this.pending.values()];
+    this.pending.clear();
+    for (const { reject, timer } of waiting) {
+      clearTimeout(timer);
+      reject(err);
+    }
+  }
+
   _onMessage(raw) {
     let msg;
     try { msg = JSON.parse(raw); } catch { return; }
@@ -310,6 +327,9 @@ class CDP {
 
   send(method, params = {}, sessionId) {
     return new Promise((resolve, reject) => {
+      if (this.dead || !this.ws || this.ws.readyState !== 1) {
+        return reject(new Error(`CDP 连接不可用: ${method}`));
+      }
       const id = ++this.seq;
       const payload = { id, method, params };
       if (sessionId) payload.sessionId = sessionId;
@@ -338,7 +358,10 @@ class CDP {
     this.attached.delete(sessionId);
   }
 
-  close() { try { this.ws && this.ws.close(); } catch { /* ignore */ } }
+  close() {
+    this._onDead(new Error('CDP 连接已关闭'));
+    try { this.ws && this.ws.close(); } catch { /* ignore */ }
+  }
 }
 
 /**
@@ -349,7 +372,12 @@ async function openAppTarget(port, app) {
   if (!app || !app.openUrl) return null;
   const p = await probe(port);
   if (!p.ok) return null;
-  const cdp = await new CDP(p.version.webSocketDebuggerUrl).open(12000);
+  const cdp = new CDP(p.version.webSocketDebuggerUrl);
+  try {
+    await cdp.open(12000);
+  } catch {
+    return null; // 连接没建起来,cdp.close() 里已无 socket 可关
+  }
   try {
     let res = null;
     for (const opts of [
@@ -471,8 +499,9 @@ async function tryOpenApp(port, app, fallbackTarget) {
     if (id) return true;
   }
   if (!app.openPath || !fallbackTarget) return false;
-  const cdp = await new CDP(p.version.webSocketDebuggerUrl).open(12000);
+  const cdp = new CDP(p.version.webSocketDebuggerUrl);
   try {
+    await cdp.open(12000);
     const sessionId = await cdp.attachTo(fallbackTarget.id);
     const url = `https://melody.shop.ele.me${app.openPath}`;
     await cdp.send('Runtime.evaluate', {
@@ -514,9 +543,16 @@ async function withPageFor(port, urlOrApp, fn, { needExact = false, autoOpen = t
     if (!found || !found.target) {
       throw new Error('没有找到可用的页面 target(客户端可能还没加载完业务页面)');
     }
-    const cdp = await new CDP(p.version.webSocketDebuggerUrl).open(timeout);
-    if (!cdp) throw new Error('CDP 连接失败');
-    const sessionId = await cdp.attachTo(found.target.id);
+    const cdp = new CDP(p.version.webSocketDebuggerUrl);
+    let sessionId;
+    try {
+      await cdp.open(timeout);
+      sessionId = await cdp.attachTo(found.target.id);
+    } catch (e) {
+      // 建连/附加失败必须关掉半开连接,否则 socket 泄漏拖住进程退不出
+      cdp.close();
+      throw new Error(`CDP 连接失败(第 ${attempt} 次尝试):${(e && e.message) || e}`);
+    }
     const ctxMap = await collectContexts(cdp, sessionId, attempt === 1 ? 800 : 1500);
     lastFound = found;
     lastCtx = ctxMap;

+ 53 - 15
napos/kit.js

@@ -124,8 +124,12 @@ async function statsHistory(o = {}) {
     else if (Array.isArray(d.result)) rowsRaw = d.result;
     const daily = rowsRaw.filter((x) => x && x.ds && /^\d{8}$/.test(String(x.ds)));
     const all = rowsRaw.find((x) => x && x.ds === 'all') || null;
+    // 形态守卫:有原始行却一条 ds 形态都对不上 = 接口/页面结构变了。
+    // 不标记的话上层会把这批行当"合法空数据"静默落库(全零类问题同根因)。
+    const shapeWarning = rowsRaw.length > 0 && !daily.length && !all;
     return {
       ok: !!res.ok, status: res.status, risk: !!res.risk, authFail: !!res.authFail,
+      shapeWarning,
       code: d.code != null ? d.code : (d.success === false ? 'success:false' : undefined),
       msg: d.msg || d.message || undefined,
       contextOrigin: res.contextOrigin || null,
@@ -374,10 +378,13 @@ async function melody(o = {}) {
  * 订单履约巡检 —— 一次抓「当前」的异常/待办订单计数(全公司或指定门店)。
  * ⚠️ 这是**实时快照**,不是历史;要看趋势必须定时落库累积。
  *
- * @returns {Promise<{newOrder:number, urge:number, preparing:number, callDelivery:number,
- *   riderAccept:number, unprocessedBooking:number, bookingOrderIds:string[],
- *   exceptional:number, cancel:number, refund:number, fastRefund:number,
- *   noProcesNegotiate:*, timeOutNegotiate:*, needRemindException:boolean, raw:object}>}
+ * @returns {Promise<{ok:boolean, newOrder:?number, urge:?number, preparing:?number, callDelivery:?number,
+ *   riderAccept:?number, unprocessedBooking:?number, bookingOrderIds:string[],
+ *   exceptional:?number, cancel:?number, refund:?number, fastRefund:?number,
+ *   noProcesNegotiate:*, timeOutNegotiate:*, needRemindException:boolean, raw:object,
+ *   risk:boolean, authFail:boolean}>}
+ *   任一子调用失败(HTTP/风控/鉴权/业务错误)时 ok=false 且各计数为 **null** ——
+ *   绝不返回 0:那会把"取数失败"伪装成"健康零负载"快照。
  */
 async function orderAlerts(o = {}) {
   const shopId = o.shopId == null ? -1 : o.shopId;
@@ -387,12 +394,16 @@ async function orderAlerts(o = {}) {
     melody({ ...P, service: 'PollingService', method: 'nonCoreOrders', path: '/fulfill/weborder/nonCoreOrders/', params: { shopId } }),
     melody({ ...P, service: 'PollingService', method: 'unprocessedOrders', path: '/fulfill/weborder/unprocessedOrders/', params: { shopId } }),
   ]);
-  const A = (ab.result && typeof ab.result === 'object') ? ab.result : {};
-  const N = (non.result && typeof non.result === 'object') ? non.result : {};
-  const U = (un.result && typeof un.result === 'object') ? un.result : {};
-  const n = (v) => Number(v) || 0;
-  const bookingIds = (N.bookingNotice && Array.isArray(N.bookingNotice.orderIds)) ? N.bookingNotice.orderIds : [];
+  const subFail = (r) => !r || r.ok === false || !!r.risk || !!r.authFail || !!r.error;
+  const anyFail = subFail(ab) || subFail(non) || subFail(un);
+  const A = (ab && ab.result && typeof ab.result === 'object') ? ab.result : {};
+  const N = (non && non.result && typeof non.result === 'object') ? non.result : {};
+  const U = (un && un.result && typeof un.result === 'object') ? un.result : {};
+  // 失败时计数不可信 → 一律 null,让调用方显式处理"不可知",而不是当成 0
+  const n = (v) => (anyFail ? null : (Number(v) || 0));
+  const bookingIds = (!anyFail && N.bookingNotice && Array.isArray(N.bookingNotice.orderIds)) ? N.bookingNotice.orderIds : [];
   return {
+    ok: !anyFail,
     shopId,
     newOrder: n(U.newOrderCount),
     urge: n(N.urgeOrderCount),
@@ -405,11 +416,12 @@ async function orderAlerts(o = {}) {
     cancel: n(A.cancelOrderCount),
     refund: n(A.refundOrderCount),
     fastRefund: n(A.fastRefundOrderCount),
-    noProcesNegotiate: A.noProcesNegotiateOrder == null ? null : n(A.noProcesNegotiateOrder),
-    timeOutNegotiate: A.timeOutNegotiateOrder == null ? null : n(A.timeOutNegotiateOrder),
-    needRemindException: !!A.needRemindExceptionOrder,
+    noProcesNegotiate: anyFail ? null : (A.noProcesNegotiateOrder == null ? null : n(A.noProcesNegotiateOrder)),
+    timeOutNegotiate: anyFail ? null : (A.timeOutNegotiateOrder == null ? null : n(A.timeOutNegotiateOrder)),
+    needRemindException: !anyFail && !!A.needRemindExceptionOrder,
     raw: { abnormal: A, nonCore: N, unprocessed: U },
-    risk: ab.risk || non.risk || un.risk,
+    risk: !!(ab && ab.risk) || !!(non && non.risk) || !!(un && un.risk),
+    authFail: !!(ab && ab.authFail) || !!(non && non.authFail) || !!(un && un.authFail),
   };
 }
 
@@ -578,10 +590,14 @@ async function settleList(o = {}) {
   const maxPage = Number(o.maxPage || 60);
   const rows = [];
   let pageNo = 1, totalPage = 1, meta = null;
+  let last = null;      // 末次翻页响应:决定整体 ok(失败不得伪装成"成功 0 行")
+  let pages = 0;
   while (pageNo <= Math.min(totalPage, maxPage)) {
     const r = await agw('bill', '/gw/h5api/ele-shop-bill-query.ShopSummaryQueryFacade.queryChainSettleListByPage',
       [{ shopId: unit, pageNo, pageSize: size, startBillDate: msOf(from, 'start'), endBillDate: msOf(to, 'end'), chainId: unit, settleBizType: biz }],
       { port });
+    last = r;
+    pages++;
     const d = r.payload || {};
     if (Array.isArray(d.list)) rows.push(...d.list);
     totalPage = Number(d.totalPage || 1) || 1;
@@ -589,7 +605,17 @@ async function settleList(o = {}) {
     if (!Array.isArray(d.list) || d.list.length < size) break;
     pageNo++;
   }
-  return { ok: true, unitId: unit, biz, from, to, rows, meta, ensured: ens };
+  // 口径统一:末页 HTTP 失败 / 风控 / 鉴权失效 / 业务层没回 list(空区间成功时 list 也是数组,只是为空)
+  const ld = (last && last.payload) || null;
+  const failed = !last || !last.ok || !!last.risk || !!last.authFail || !ld || !Array.isArray(ld.list);
+  return {
+    ok: !failed,
+    risk: !!(last && last.risk), authFail: !!(last && last.authFail),
+    error: failed
+      ? `结算账单翻页异常(第 ${pages} 页):${!last ? '无响应' : last.authFail ? '鉴权失效' : last.risk ? '触发风控' : !last.ok ? `HTTP ${last.status || '?'}` : '响应缺少 list 字段(接口形态可能已变化)'}`
+      : null,
+    unitId: unit, biz, from, to, rows, meta, ensured: ens,
+  };
 }
 
 /** 结算账单区间汇总(按日聚合 → 收入/支出/结算额/天数/状态分布) */
@@ -640,16 +666,28 @@ async function growthTaskRate(o = {}) {
   const size = Number(o.size || 50), maxPage = Number(o.maxPage || 20);
   const rows = [];
   let page = 1, total = 1;
+  let last = null;
   while (page <= Math.min(total, maxPage)) {
     const r = await agw('growth', '/gw/h5api/ele-shop-growth-product.ChainBusinessTaskPageWebService.getBusinessTaskCompleteRateByPage',
       [{ chainId, page, size, data: { detailType: o.detailType || 'DETAIL_TYPE_SHOP', brandId: o.brandId == null ? -1 : o.brandId, useNew: true } }], { port });
+    last = r;
     const d = r.payload || {};
     if (Array.isArray(d.data)) rows.push(...d.data);
     total = Number(d.total || 0) ? Math.ceil(Number(d.total) / size) : 1;
     if (!Array.isArray(d.data) || d.data.length < size) break;
     page++;
   }
-  return { ok: true, rows };
+  // 口径统一:末页失败/形态异常不再报 ok:true(否则翻页失败会被当"完成率全空"误读)
+  const ld = (last && last.payload) || null;
+  const failed = !last || !last.ok || !!last.risk || !!last.authFail || !ld || !Array.isArray(ld.data);
+  return {
+    ok: !failed,
+    risk: !!(last && last.risk), authFail: !!(last && last.authFail),
+    error: failed
+      ? `生意任务完成率翻页异常:${!last ? '无响应' : last.authFail ? '鉴权失效' : last.risk ? '触发风控' : !last.ok ? `HTTP ${last.status || '?'}` : '响应缺少 data 字段(接口形态可能已变化)'}`
+      : null,
+    rows,
+  };
 }
 
 /** 成长中心品牌列表(brandId 用于周报 / 任务过滤) */

+ 17 - 3
napos/ws.js

@@ -82,12 +82,26 @@ class RawWebSocket extends EventEmitter {
         this.readyState = 1;
         socket.setNoDelay(true);
         socket.on('data', (d) => this._feed(d));
-        socket.on('close', () => { this.readyState = 3; this.emit('close'); });
-        socket.on('error', (e) => this.emit('error', e));
+        socket.on('close', () => {
+          const wasOpen = this.readyState === 1;
+          this.readyState = 3;
+          this.emit('close');
+          // 从未 emit 过 error 的连接断掉时,给上层一个明确的失败信号而不是静默
+          if (wasOpen) this.emit('error', new Error('WebSocket 连接已断开'));
+        });
+        socket.on('error', (e) => {
+          this.readyState = 3;
+          this.emit('error', e);
+        });
         if (head && head.length) this._feed(head);
         resolve(res.headers);
       });
-      req.on('response', (res) => fail(new Error(`握手被拒: HTTP ${res.statusCode}`)));
+      req.on('response', (res) => {
+        // 握手被拒:必须销毁请求释放底层 socket,否则每次探测失败都泄漏一条连接
+        res.resume();
+        req.destroy();
+        fail(new Error(`握手被拒: HTTP ${res.statusCode}`));
+      });
       req.on('error', fail);
       req.on('timeout', () => req.destroy(new Error('WebSocket 握手超时')));
       req.end();

+ 12 - 5
public/analysis-templates.js

@@ -28,6 +28,13 @@
 
     const WEEKDAY_CN = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
 
+    // 账号名/门店名来自外部平台数据,进 innerHTML(表格与 tooltip 默认按 HTML 渲染)前必须转义
+    function esc(value) {
+      const div = document.createElement('div');
+      div.textContent = String(value ?? '');
+      return div.innerHTML;
+    }
+
     // ---------- 模板一:综合总览(原有四图,保持默认行为) ----------
 
     function renderOverviewTpl(grid, data) {
@@ -428,7 +435,7 @@
             trigger: 'axis',
             formatter: (ps) => {
               const idx = ps[0].dataIndex;
-              return `<b>${sorted[idx].name}</b><br>${col.label}:${fmtByCol(col, sorted[idx].curV)}<br>累计占比:${cumPct[idx]}%`;
+              return `<b>${esc(sorted[idx].name)}</b><br>${col.label}:${fmtByCol(col, sorted[idx].curV)}<br>累计占比:${cumPct[idx]}%`;
             },
           },
           grid: { left: 10, right: 40, top: 30, bottom: 64, containLabel: true },
@@ -467,7 +474,7 @@
           tooltip: {
             formatter: (p) => {
               const s = p.data.meta;
-              return `<b>${s.name}</b><br>本期 ${fmtByCol(col, s.curV)}<br>上期 ${fmtByCol(col, s.prevV)}<br>环比 ${(ratio(s) * 100).toFixed(1)}%${s.pay ? `<br>客单价 ${fmtMoney(s.pay)}` : ''}`;
+              return `<b>${esc(s.name)}</b><br>本期 ${fmtByCol(col, s.curV)}<br>上期 ${fmtByCol(col, s.prevV)}<br>环比 ${(ratio(s) * 100).toFixed(1)}%${s.pay ? `<br>客单价 ${fmtMoney(s.pay)}` : ''}`;
             },
           },
           grid: { left: 64, right: 30, top: 30, bottom: 46 },
@@ -881,7 +888,7 @@
       wrap.innerHTML = `<table class="ana-table">
         <thead><tr><th>账号</th><th>本期</th><th>上期</th><th>环比</th><th>日均</th><th>有效订单</th><th>客单价</th><th>档口数</th></tr></thead>
         <tbody>${stats.map((e) => `<tr>
-          <td>${e.name}</td>
+          <td>${esc(e.name)}</td>
           <td>${fmtByCol(col, e.cur)}</td>
           <td>${fmtByCol(col, e.prev)}</td>
           <td>${deltaBadge(e.cur, e.prev)}</td>
@@ -1042,8 +1049,8 @@
           <tr><td>本店(${isPct ? '加权均值' : '合计'})</td><td>${fmtByCol(col, ownTotal)}</td></tr>
           <tr><td>商圈同行均值</td><td>${hasAvg ? fmtByCol(col, avgTotal) : '—'}</td></tr>
           <tr><td>超越同行</td><td>${hasAvg && avgTotal > 0 ? `<span class="delta ${ownTotal >= avgTotal ? 'up' : 'down'}">${ownTotal >= avgTotal ? '↑' : '↓'} ${Math.abs((ownTotal / avgTotal - 1) * 100).toFixed(1)}%</span>` : '—'}</td></tr>
-          <tr><td>最强档口</td><td>${ranked.length ? `${ranked[0][0]}(${fmtByCol(col, ranked[0][1])})` : '—'}</td></tr>
-          <tr><td>最弱档口</td><td>${ranked.length > 1 ? `${ranked[ranked.length - 1][0]}(${fmtByCol(col, ranked[ranked.length - 1][1])})` : '—'}</td></tr>
+          <tr><td>最强档口</td><td>${ranked.length ? `${esc(ranked[0][0])}(${fmtByCol(col, ranked[0][1])})` : '—'}</td></tr>
+          <tr><td>最弱档口</td><td>${ranked.length > 1 ? `${esc(ranked[ranked.length - 1][0])}(${fmtByCol(col, ranked[ranked.length - 1][1])})` : '—'}</td></tr>
         </tbody></table>`;
       tbl.cell.appendChild(wrap);
     }

binární
public/assets/logo.png


+ 40 - 6
public/crawl.js

@@ -8,6 +8,7 @@ const state = {
 };
 
 let pollTimer = null;
+let pollFailStreak = 0;
 let crawlRoot = '';
 let initialized = false;
 
@@ -30,8 +31,20 @@ function jsonFetch(url, opts) {
   );
 }
 
+// 店铺名/目录名来自外部平台数据,凡进 innerHTML 的文本必须先过 esc()
+function esc(value) {
+  const div = document.createElement('div');
+  div.textContent = String(value ?? '');
+  return div.innerHTML;
+}
+
 function renderShopTable() {
   const body = document.getElementById('shopTableBody');
+  // 表格每 1.5s 随轮询全量重建,重建前记住用户已手动取消勾选的店铺,避免勾选被重置
+  const uncheckedBefore = new Set();
+  body.querySelectorAll('input[type=checkbox]').forEach((cb) => {
+    if (!cb.checked) uncheckedBefore.add(cb.dataset.shopId);
+  });
   body.innerHTML = '';
   document.getElementById('shopTableEmpty').style.display = state.shops.length ? 'none' : 'block';
   const countEl = document.getElementById('shopCount');
@@ -40,10 +53,10 @@ function renderShopTable() {
     const tr = document.createElement('tr');
     const tdCb = document.createElement('td');
     const cb = document.createElement('input');
-    cb.type = 'checkbox'; cb.checked = true; cb.dataset.shopId = String(shop.shopId);
+    cb.type = 'checkbox'; cb.checked = !uncheckedBefore.has(String(shop.shopId)); cb.dataset.shopId = String(shop.shopId);
     tdCb.appendChild(cb); tr.appendChild(tdCb);
     const tdName = document.createElement('td');
-    tdName.innerHTML = `<div class="shop-name">${shop.shopName}</div>`;
+    tdName.innerHTML = `<div class="shop-name">${esc(shop.shopName)}</div>`;
     tr.appendChild(tdName);
     const tdId = document.createElement('td');
     tdId.textContent = shop.shopId;
@@ -58,7 +71,7 @@ function renderShopTable() {
     const tdFolder = document.createElement('td');
     tdFolder.className = 'col-folder';
     tdFolder.innerHTML = folderName
-      ? `<span class="badge badge-ok">${folderName}</span>`
+      ? `<span class="badge badge-ok">${esc(folderName)}</span>`
       : '<span class="badge">—</span>';
     tr.appendChild(tdFolder);
     tr.dataset.shopId = String(shop.shopId);
@@ -74,7 +87,7 @@ function statusBadgeHtml(st) {
     failed: '失败', stopped: '已停止', interrupted: '已中断',
   }[st.state] || st.state;
   const cls = { done: 'badge-ok', failed: 'badge-fail', running: 'badge-warn' }[st.state] || 'badge-info';
-  return `<span class="badge ${cls}">${label}</span>`;
+  return `<span class="badge ${cls}">${esc(label)}</span>`;
 }
 
 const ICON_PLAY = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>';
@@ -123,7 +136,7 @@ function renderCrawlState(cs) {
     row.className = 'progress-row';
     const pct = s.total > 0 ? Math.round((s.downloaded + s.skipped) / s.total * 100) : 0;
     row.innerHTML = `
-      <div class="left"><span class="badge badge-info">采集</span><span class="name">${s.shopName}</span><span class="meta">· 菜品图</span></div>
+      <div class="left"><span class="badge badge-info">采集</span><span class="name">${esc(s.shopName)}</span><span class="meta">· 菜品图</span></div>
       <div class="right">
         <div class="progress-bar"><span style="width:${pct}%;"></span></div>
         <span>${s.downloaded + s.skipped} / ${s.total || 0}</span>
@@ -137,7 +150,14 @@ function renderCrawlState(cs) {
 
 function startPolling() {
   if (pollTimer) clearInterval(pollTimer);
-  const tick = () => jsonFetch('/api/crawl/status').then(renderCrawlState).catch(() => {});
+  const tick = () => jsonFetch('/api/crawl/status').then((cs) => {
+    pollFailStreak = 0;
+    renderCrawlState(cs);
+  }).catch(() => {
+    // 静默重试,但连续失败要给一次提示(服务可能重启),之后仍自动恢复
+    pollFailStreak++;
+    if (pollFailStreak === 3) showToast('抓取状态轮询失败(服务可能已重启),恢复后自动继续', 'error');
+  });
   tick();
   pollTimer = setInterval(tick, 1500);
 }
@@ -219,4 +239,18 @@ window.shanCrawlInit = function () {
     jsonFetch('/api/crawl/stop', { method: 'POST' }).then(() => showToast('已发送停止请求'));
   });
 };
+
+// 视图感知:离开采集页暂停状态轮询,回页时按服务端实际状态决定是否恢复
+//(app.js switchView 调用;shanCrawlInit 只在首次进入时绑事件,不会重复拉状态)
+window.shanCrawlSetActive = function (active) {
+  if (active) {
+    jsonFetch('/api/crawl/status').then((cs) => {
+      renderCrawlState(cs);
+      if (cs.status === 'running') startPolling();
+    }).catch(() => {});
+  } else if (pollTimer) {
+    clearInterval(pollTimer);
+    pollTimer = null;
+  }
+};
 })();

+ 53 - 24
public/gen.js

@@ -757,13 +757,17 @@ function buildShopCard(shop) {
         const value = panel.prompt.value.trim();
         if (value && panel.prompt.dataset.inherit !== '1') prompts.dishes[shop.dishes[index].file] = value;
       });
-      const response = await fetch('/api/save-config', {
-        method: 'POST', headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ root: state.root, folderName, shopName: shopNameInput.value, prompts }),
-      });
-      const data = await response.json().catch(() => ({}));
-      if (!response.ok) return showToast(data.error || '保存失败', 'error');
-      showToast(`已保存「${shopNameInput.value}」配置`);
+      try {
+        const response = await fetch('/api/save-config', {
+          method: 'POST', headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ root: state.root, folderName, shopName: shopNameInput.value, prompts }),
+        });
+        const data = await response.json().catch(() => ({}));
+        if (!response.ok) return showToast(data.error || '保存失败', 'error');
+        showToast(`已保存「${shopNameInput.value}」配置`);
+      } catch (error) {
+        showToast('保存失败:' + error.message, 'error');
+      }
     });
     refreshWorkflow();
     sharedPrompt.addEventListener('input', () => {
@@ -785,21 +789,28 @@ function buildShopCard(shop) {
 }
 
 async function openShopFolder(folderName) {
-  const response = await fetch('/api/open-folder', {
-    method: 'POST', headers: { 'Content-Type': 'application/json' },
-    body: JSON.stringify({ root: state.root, folderName }),
-  });
-  const data = await response.json().catch(() => ({}));
-  if (!response.ok) showToast(data.error || '打开目录失败', 'error');
+  try {
+    const response = await fetch('/api/open-folder', {
+      method: 'POST', headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ root: state.root, folderName }),
+    });
+    const data = await response.json().catch(() => ({}));
+    if (!response.ok) showToast(data.error || '打开目录失败', 'error');
+  } catch (error) {
+    showToast('打开目录失败:' + error.message, 'error');
+  }
 }
 
 function trackJob(jobId, container, kind, done, context) {
   activeJobs.set(jobId, { state: 'queued', cost: 0 });
   updateStats();
+  const label = (BRAND_META[kind] && BRAND_META[kind].label) || '作品';
+  let pollErrors = 0; // 连续失败上限:服务端任务丢失/重启后不再无限轮询
   const tick = async () => {
     try {
       const response = await fetch(`/api/job/${jobId}`);
       const job = await response.json();
+      pollErrors = 0;
       activeJobs.set(jobId, { state: job.state, cost: job.cost || 0 });
       renderResult(container, { kind, ...job }, context);
       if (job.state === 'queued' || job.state === 'running') {
@@ -808,10 +819,20 @@ function trackJob(jobId, container, kind, done, context) {
       }
       activeJobs.delete(jobId);
       updateStats();
-      if (job.state === 'success') showToast(`${job.dishName || BRAND_META[kind]?.label || '作品'}生成完成`);
-      else if (job.state === 'failed') showToast(`${job.dishName || BRAND_META[kind]?.label || '作品'}生成失败:${job.error}`, 'error');
+      if (job.state === 'success') showToast(`${job.dishName || label}生成完成`);
+      else if (job.state === 'failed') showToast(`${job.dishName || label}生成失败:${job.error}`, 'error');
       if (done) done(job);
-    } catch { setTimeout(tick, 4000); }
+    } catch {
+      pollErrors++;
+      if (pollErrors >= 5) {
+        activeJobs.delete(jobId);
+        updateStats();
+        renderResult(container, { kind, id: jobId, state: 'failed', error: '任务状态查询失败(服务可能已重启),请重新提交' }, context);
+        showToast(`${label}任务跟踪失败,已停止轮询`, 'error');
+        return;
+      }
+      setTimeout(tick, 4000);
+    }
   };
   tick();
 }
@@ -909,6 +930,8 @@ function handleScanDropdownClick(event) {
 
 async function scan() {
   if (!state.root) return;
+  if (state.scanning) return; // 重入保护:刷新按钮与初始化两个入口可能并发
+  state.scanning = true;
   const edits = captureCurrentEdits();
   const selected = Array.from(state.scanSelection).filter((value) => value !== '__all__');
   const scanAll = !selected.length;
@@ -934,6 +957,7 @@ async function scan() {
     updateScanOptions(); renderShops();
     showToast(scanAll ? `扫描完成,找到 ${data.shops.length} 家店铺` : `扫描完成,已更新 ${data.shops.length} 家门店`);
   } catch (error) { showToast(`扫描失败:${error.message}`, 'error'); }
+  state.scanning = false;
   button.disabled = false;
   document.getElementById('scanIcon').innerHTML = ICON_PLAY;
   document.getElementById('scanText').textContent = '扫描店铺';
@@ -956,13 +980,18 @@ window.shanGenInit = async function () {
       state.model = settings.model || '';
     } catch {}
   }
-  const response = await fetch('/api/default-root');
-  const data = await response.json();
-  const changed = !state.root || state.root !== data.root;
-  state.root = data.root;
-  state.expandedShops = readStoredSet('expanded');
-  state.scanSelection = readStoredSet('scan-selection');
-  if (!state.scanSelection.size) state.scanSelection.add('__all__');
-  if (changed) scan();
+  try {
+    const response = await fetch('/api/default-root');
+    if (!response.ok) throw new Error(`HTTP ${response.status}`);
+    const data = await response.json();
+    const changed = !state.root || state.root !== data.root;
+    state.root = data.root;
+    state.expandedShops = readStoredSet('expanded');
+    state.scanSelection = readStoredSet('scan-selection');
+    if (!state.scanSelection.size) state.scanSelection.add('__all__');
+    if (changed) scan();
+  } catch (error) {
+    showToast('初始化失败:' + error.message, 'error');
+  }
 };
 })();

+ 4 - 1
public/history.js

@@ -136,6 +136,7 @@
     render();
     try {
       const rootResponse = await fetch('/api/default-root');
+      if (!rootResponse.ok) throw new Error(`默认目录获取失败 HTTP ${rootResponse.status}`);
       const rootData = await rootResponse.json();
       state.root = rootData.root;
       const response = await fetch(`/api/creations?root=${encodeURIComponent(state.root)}&limit=600`);
@@ -156,7 +157,9 @@
     document.getElementById('btnRefreshHistory').addEventListener('click', loadHistory);
     document.getElementById('historySearch').addEventListener('input', (event) => {
       state.keyword = event.target.value;
-      render();
+      // 每次击键全量重建最多 600 张卡,防抖后再渲染
+      clearTimeout(state.searchTimer);
+      state.searchTimer = setTimeout(render, 300);
     });
     document.getElementById('historyFilters').addEventListener('click', (event) => {
       const button = event.target.closest('[data-filter]');

+ 9 - 0
public/tasks.js

@@ -198,6 +198,7 @@
     try {
       if (!state.root) {
         const rootResponse = await fetch('/api/default-root');
+        if (!rootResponse.ok) throw new Error(`默认目录获取失败 HTTP ${rootResponse.status}`);
         const rootData = await rootResponse.json();
         state.root = rootData.root;
       }
@@ -240,4 +241,12 @@
       }, 3000);
     }
   };
+
+  // 离开任务页时清掉轮询(app.js switchView 调用),回页时 shanTasksInit 会重建
+  window.shanTasksSetActive = function (active) {
+    if (!active && state.timer) {
+      clearInterval(state.timer);
+      state.timer = null;
+    }
+  };
 })();

+ 12 - 6
report-html.js

@@ -19,6 +19,12 @@ function esc(v) {
     .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
 }
 
+// JSON 内插进 <script> 时 JSON.stringify 不转义 "/",
+// 店名等外部数据含 "</script>" 会提前闭合脚本标签,必须把 "<" 转成 \u003c
+function jsonForScript(v) {
+  return JSON.stringify(v).replace(/</g, '\\u003c');
+}
+
 function fmtMoney(v) { return v == null ? '—' : Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }
 function fmtInt(v) { return v == null ? '—' : Number(v).toLocaleString('zh-CN', { maximumFractionDigits: 0 }); }
 function fmtPct(v) { return v == null || v === '' ? '—' : (Number(v) * 100).toFixed(2) + '%'; }
@@ -113,14 +119,14 @@ function buildReportHtml({ entity, from, to }) {
     tooltip: { trigger: 'axis' },
     legend: { data: ['营业额(元)', '有效订单'] },
     grid: { left: 60, right: 60, top: 40, bottom: 30 },
-    xAxis: { type: 'category', data: ${JSON.stringify(trendDates)}, ...AX },
+    xAxis: { type: 'category', data: ${jsonForScript(trendDates)}, ...AX },
     yAxis: [
       { type: 'value', name: '营业额', ...AX },
       { type: 'value', name: '订单', ...AX }
     ],
     series: [
-      { name: '营业额(元)', type: 'line', smooth: true, data: ${JSON.stringify(trendGross)}, itemStyle: { color: '#ff6b35' }, areaStyle: { color: 'rgba(255,107,53,.08)' } },
-      { name: '有效订单', type: 'line', smooth: true, yAxisIndex: 1, data: ${JSON.stringify(trendOrders)}, itemStyle: { color: '#2563eb' } }
+      { name: '营业额(元)', type: 'line', smooth: true, data: ${jsonForScript(trendGross)}, itemStyle: { color: '#ff6b35' }, areaStyle: { color: 'rgba(255,107,53,.08)' } },
+      { name: '有效订单', type: 'line', smooth: true, yAxisIndex: 1, data: ${jsonForScript(trendOrders)}, itemStyle: { color: '#2563eb' } }
     ]
   });
   var rank = echarts.init(document.getElementById('rank'));
@@ -128,8 +134,8 @@ function buildReportHtml({ entity, from, to }) {
     tooltip: { trigger: 'axis' },
     grid: { left: 10, right: 30, top: 10, bottom: 30, containLabel: true },
     xAxis: { type: 'value', ...AX },
-    yAxis: { type: 'category', data: ${JSON.stringify(topNames)}, axisLabel: { color: '#2b2a28', width: 150, overflow: 'truncate' }, axisLine: { lineStyle: { color: '#e8e4df' } } },
-    series: [{ type: 'bar', data: ${JSON.stringify(topGross)}, itemStyle: { color: '#ff6b35', borderRadius: [0, 6, 6, 0] } }]
+    yAxis: { type: 'category', data: ${jsonForScript(topNames)}, axisLabel: { color: '#2b2a28', width: 150, overflow: 'truncate' }, axisLine: { lineStyle: { color: '#e8e4df' } } },
+    series: [{ type: 'bar', data: ${jsonForScript(topGross)}, itemStyle: { color: '#ff6b35', borderRadius: [0, 6, 6, 0] } }]
   });
   window.addEventListener('resize', function () { trend.resize(); rank.resize(); });
 </script>
@@ -137,4 +143,4 @@ function buildReportHtml({ entity, from, to }) {
 </html>`;
 }
 
-module.exports = { buildReportHtml };
+module.exports = { buildReportHtml, jsonForScript };

+ 8 - 1
scripts/diagnose-napos.js

@@ -56,6 +56,7 @@ async function runCase(tag, params) {
     out.msg = r.msg || null;
     out.risk = r.risk || false;
     out.authFail = r.authFail || false;
+    out.shapeWarning = r.shapeWarning || false;
     out.ok = r.ok;
   } catch (e) {
     out.error = e.message || String(e);
@@ -99,7 +100,13 @@ async function runCase(tag, params) {
   report.shopCount = shops.length;
 
   const testDate = /^\d{8}$/.test(process.argv[2] || '') ? process.argv[2] : ymdShift(todayYmd(), -1);
-  const chainId = (report.chains[0] || {}).chainId || '96059480';
+  // 不做硬编码 chainId 兜底:对着陌生主体跑对照实验只会产出误导性报告
+  if (!report.chains.length) {
+    report.conclusion = '组织树没有连锁信息,无法构造连锁对照实验。请确认该主体确有连锁结构后重跑。';
+    finish(report);
+    return;
+  }
+  const chainId = report.chains[0].chainId;
   const picked = [];
   if (/^\d+$/.test(process.argv[3] || '')) {
     const one = shops.find((s) => s.shopId === process.argv[3]);

+ 15 - 4
server.js

@@ -10,7 +10,7 @@ const { execFile } = require('child_process');
 
 const { createContext } = require('./server/context.js');
 const { createRouter } = require('./server/router.js');
-const { makeTokenGuard } = require('./server/middleware.js');
+const { makeTokenGuard, makeOriginGuard } = require('./server/middleware.js');
 const { sendJson } = require('./server/util.js');
 const { createCrawlService } = require('./server/services/crawl.js');
 const { createGenerationService } = require('./server/services/generation.js');
@@ -31,6 +31,8 @@ registerRoutes(app, ctx);
 
 // 局域网访问口令:config.accessToken(或环境变量 ACCESS_TOKEN)非空时启用
 const tokenGuard = makeTokenGuard(ctx.config);
+// 本地 CSRF 防护:带 Origin 的写请求必须同源(浏览器跨站 no-cors POST 被拒)
+const originGuard = makeOriginGuard();
 
 const server = http.createServer((req, res) => {
   const startedAt = Date.now();
@@ -40,6 +42,9 @@ const server = http.createServer((req, res) => {
       console.log(`[http] ${req.method} ${req.url} -> ${res.statusCode} ${ms}ms`);
     }
   });
+  if (!originGuard(req)) {
+    return sendJson(res, 403, { error: '已拒绝跨站写请求(Origin 与服务地址不符)' });
+  }
   try {
     const parsed = new URL(req.url, `http://${req.headers.host}`);
     if (!tokenGuard(req, parsed.pathname, parsed.searchParams)) {
@@ -153,9 +158,15 @@ server.listen(LISTEN_PORT, HOST, () => {
   const url = `http://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${LISTEN_PORT}`;
   ctx.logs.pushSystemLog(`服务已启动:${url}`, 'success');
   console.log(`智慧运营中台已启动: ${url}`);
-  // "start" is a cmd.exe builtin (not a standalone executable), so it must be run via cmd /c;
-  // the empty "" argument is the window-title placeholder start expects before the URL.
-  execFile('cmd', ['/c', 'start', '', url], () => {});
+  // 启动后自动打开浏览器(按平台选命令;失败静默,不影响服务)
+  try {
+    if (process.platform === 'darwin') execFile('open', [url], () => {});
+    else if (process.platform === 'win32') {
+      // "start" is a cmd.exe builtin (not a standalone executable), so it must be run
+      // via cmd /c; the empty "" argument is the window-title placeholder start expects.
+      execFile('cmd', ['/c', 'start', '', url], () => {});
+    } else execFile('xdg-open', [url], () => {});
+  } catch { /* ignore */ }
 });
 
 // ---------- 优雅停机:Ctrl+C / kill 时先落盘任务与采集状态,再退出 ----------

+ 16 - 2
server/context.js

@@ -54,17 +54,30 @@ function writeConfigFile(cfg) {
   fs.renameSync(temp, CONFIG_PATH);
 }
 
+// 嵌套默认值按键合并(一层足够):config.json 只写 aitable:{enabled:true} 时,
+// 组织级默认的 appKey/baseId/operatorId 不应被浅合并整块清掉
+function mergeDefaults(defaults, saved) {
+  const out = { ...defaults };
+  for (const [k, v] of Object.entries(saved || {})) {
+    out[k] = (v && typeof v === 'object' && !Array.isArray(v)
+      && out[k] && typeof out[k] === 'object' && !Array.isArray(out[k]))
+      ? { ...out[k], ...v }
+      : v;
+  }
+  return out;
+}
+
 // 缺失/损坏时自动用模板(或内置默认)在本机生成新的 config.json —— 分发包开箱即用
 function loadConfig() {
   try {
-    return { ...CONFIG_DEFAULTS, ...JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) };
+    return mergeDefaults(CONFIG_DEFAULTS, JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')));
   } catch (e) {
     if (e.code !== 'ENOENT') {
       console.warn(`config.json 解析失败(${e.message || e}),已改用默认配置;保存设置后会重建该文件。`);
     }
     let seeded = {};
     try { seeded = JSON.parse(fs.readFileSync(CONFIG_EXAMPLE_PATH, 'utf8')); } catch { /* 无模板则用内置默认 */ }
-    const cfg = { ...CONFIG_DEFAULTS, ...seeded };
+    const cfg = mergeDefaults(CONFIG_DEFAULTS, seeded);
     try {
       writeConfigFile(cfg);
       console.log('已生成本机默认 config.json —— 请在「系统设置」中填写你自己的 API Key。');
@@ -299,4 +312,5 @@ module.exports = {
   NO_1K_SUPPORT,
   DEFAULT_CHAT_MODEL,
   makeChatCfg,
+  mergeDefaults,
 };

+ 36 - 4
server/middleware.js

@@ -7,6 +7,7 @@
 //   · 其余 → 500
 // 响应体形状与拆分前一致:{ error: message, code?: errCode }
 
+const crypto = require('crypto');
 const { sendJson } = require('./util.js');
 
 const ASK_CLIENT_CODES = new Set(['ASK_NO_MODEL', 'ASK_EMPTY', 'ASK_NO_ENTITY']);
@@ -60,6 +61,17 @@ function isLoopbackRemote(req) {
   return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1';
 }
 
+// 常量时间比较:LAN 场景防时序侧信道逐字节猜口令
+function safeEqual(a, b) {
+  const ba = Buffer.from(String(a));
+  const bb = Buffer.from(String(b));
+  if (ba.length !== bb.length) {
+    crypto.timingSafeEqual(ba, ba); // 长度不等也耗一遍比较,不泄漏长度信息
+    return false;
+  }
+  return crypto.timingSafeEqual(ba, bb);
+}
+
 function makeTokenGuard(config) {
   return function tokenGuard(req, pathname, searchParams) {
     const expected = config.accessToken;
@@ -67,11 +79,31 @@ function makeTokenGuard(config) {
     if (pathname === '/healthz' || pathname === '/readyz') return true;
     if (!pathname.startsWith('/api/') && !pathname.startsWith('/files')) return true;
     if (isLoopbackRemote(req)) return true;
-    if (searchParams.get('token') === expected) return true;
-    if (req.headers['x-access-token'] === expected) return true;
-    if (parseCookie(req.headers.cookie).accessToken === expected) return true;
+    if (safeEqual(searchParams.get('token') || '', expected)) return true;
+    if (safeEqual(req.headers['x-access-token'] || '', expected)) return true;
+    if (safeEqual(parseCookie(req.headers.cookie).accessToken || '', expected)) return true;
     return false;
   };
 }
 
-module.exports = { ASK_CLIENT_CODES, errorStatus, sendError, errorBoundary, makeTokenGuard };
+// ---------- 本地 CSRF 防护 ----------
+// 回环请求在口令守卫里全放行,意味着用户浏览器里任意网页都能向 127.0.0.1 打
+// no-cors POST(不带自定义头也能被 readBody 解析)。浏览器发起的跨站写请求必带
+// Origin;本站同源请求的 Origin 与 Host 一致;非浏览器客户端(curl/脚本)不带
+// Origin → 放行,仍受口令守卫约束。
+
+function makeOriginGuard() {
+  const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
+  return function originGuard(req) {
+    if (SAFE_METHODS.has(req.method)) return true;
+    const origin = req.headers.origin;
+    if (!origin) return true;
+    try {
+      return new URL(origin).host === (req.headers.host || '');
+    } catch {
+      return false;
+    }
+  };
+}
+
+module.exports = { ASK_CLIENT_CODES, errorStatus, sendError, errorBoundary, makeTokenGuard, makeOriginGuard };

+ 24 - 1
server/services/generation.js

@@ -127,8 +127,11 @@ function createGenerationService(ctx) {
     const { addLogo, logoPosition, logoMode, addSticker, stickerPosition, stickerMode } = opts || {};
     const images = [];
     for (const f of (refDishFiles || []).slice(0, 3)) {
+      // 参考图名来自请求体,必须钳在店铺目录内,否则可借 data URI 读任意本地文件
+      const refPath = path.resolve(shopDir, String(f || ''));
+      if (!refPath.startsWith(shopDir + path.sep)) continue;
       try {
-        images.push(readAsDataUri(path.join(shopDir, f)));
+        images.push(readAsDataUri(refPath));
       } catch {
         // skip unreadable reference image
       }
@@ -447,6 +450,24 @@ function createGenerationService(ctx) {
     runJob(job);
   }
 
+  // jobs Map 只进不出:终态任务保留最近 MAX_JOBS 条,更旧的连同 tasks.json 记录一起清掉
+  //(参考图 data URI 已在终态释放,这里防的是长期运行下任务条目本身无限累积)
+  function pruneFinishedJobs() {
+    const MAX_JOBS = 200;
+    if (jobs.size <= MAX_JOBS) return;
+    const finished = [...jobs.values()]
+      .filter((j) => j.state === 'success' || j.state === 'failed')
+      .sort((a, b) => (a.finishedAt || 0) - (b.finishedAt || 0));
+    const drop = finished.slice(0, jobs.size - MAX_JOBS);
+    for (const j of drop) {
+      jobs.delete(j.id);
+      persistedTasks.delete(j.id);
+    }
+    if (drop.length) {
+      try { writeTaskStore(); } catch { /* ignore */ }
+    }
+  }
+
   function runJob(job) {
     activeCount++;
     job.state = 'running';
@@ -461,8 +482,10 @@ function createGenerationService(ctx) {
         const jobTitle = job.dishName ? `${job.shopName} / ${job.dishName}` : `${job.shopName} / ${job.kind}`;
         if (job.state === 'success') logs.pushSystemLog(`生成完成:${jobTitle}`, 'success');
         else if (job.state === 'failed') logs.pushSystemLog(`生成失败:${jobTitle},${job.error || '未知错误'}`, 'error');
+        job.images = []; // 终态后参考图 data URI(可达数 MB/张)不再需要,立即释放
         job.finishedAt = Date.now();
         persistJob(job, { urgent: true });
+        pruneFinishedJobs();
         activeCount--;
         const nextId = pendingQueue.shift();
         if (nextId) {