◍ 我的收藏箱自己攒的东西,自己说了算
← 看板 📊 看板 2026-10-20 19:28 · admin

店铺订单看板 · 智能体施工手册(无数据版)

给「执行施工的智能体」看的规格书:交付物清单、config 字段契约、口径单测、聚合算法伪代码、_growth.json 字段表、前端公式、crontab、A1–A9 验收命令、14 条 MUST NOT。照着做就能复刻同一套看板,不含任何店铺数据。

读者是执行施工的智能体,不是人。 全文用 MUST / MUST NOT 表述,每条都可执行、可验收。<br>目标:在一台机器上从零跑起一套「多店订单看板」,行为与本手册逐条一致。<br>所有店铺数据用占位符:<APPID>、<店铺名>、1,234。你接手的真实店铺信息由用户提供,替换占位符即可。

0. 你的执行步骤(先做这个,不要跳)

按顺序做,每阶段结束跑对应验收,通过了再进下一阶段:

  1. 建骨架:按第 2 节建目录,写 config.json + _shops.json + _cred.json(第 3 节)。
  2. 假数据跑通前端:手写一份符合第 6.1 节 schema 的 _growth.json(2 家店 × 3 个商品,数字随便编),把第 8 节的前端做出来。先看见页面,再谈数据。
  3. 接真数据:按第 4 节实现 CSV 读取与口径,用用户提供的一份真实导出做对照,通过第 10.1 节 A1–A4。
  4. 接定时任务:第 9 节,跑满 30 分钟,确认产物在更新、页面数据龄正常。
  5. 验收 + 交付报告:第 10 节全过,按第 13 节输出报告。

MUST NOT:跳过第 2 步直接接 API。没有页面就没有反馈,口径错了你也看不出来。


1. 交付物清单(Definition of Done)

全部落在一个目录下,命名 MUST 完全一致:

路径类型职责验收
config.json配置全部可调参数(第 3.1 节)改一个阈值,重启后生效
orders/_shops.json配置<APPID> → 店名前端下拉能列出
orders/_cred.json配置凭据,不入库权限 600
orders/导出_YYYY-MM-DD/<APPID>.api.slim.csv数据每日订单快照第 10.1 A1
orders/_growth.json产物预聚合结果,前端唯一数据源第 6.1 schema 校验
app/collect_growth.cjs采集快照 → _growth.json单次 < 10 秒
app/40-server.mjs服务静态页 + /api/*第 7 节路由表
page/index.html前端单文件看板第 8 节 DOM 断言
app/healthcheck.sh运维数据不更新自愈 + 内存护栏第 9.3
app/verify.cjs验收一次性跑完第 10 节输出 PASS

2. 技术约束(硬性)

项约束理由(不要试图改)
运行时Node ≥ 22原生 readline/fetch/node:test,不需要构建
依赖零 npm 依赖部署 = 拷文件 + 起进程,没有 npm install
存储文件系统(CSV + JSON)出问题能 tail、能 diff、能手改;单机内存小
前端单文件 HTML,原生 JS,无框架无构建改一行推上去刷新即生效
数据库MUST NOT 引入单机单进程,DB 只会多一个要调优的东西
前端计算MUST NOT 在请求时现算聚合小机器会被打死;聚合只在定时任务里做
页面读盘每请求读盘(不缓存 HTML)换页面文件不用重启
时区一律业务时区(config.tzOffsetHours,默认 8)别用 UTC 切天,0–8 点的单会串到前一天

3. 配置契约

3.1 config.json(字段名 MUST 一致)

{
  "tzOffsetHours": 8,
  "snapshotKeepDays": 30,
  "hotDays": 30,
  "hotStore": 30,
  "hotShow": 100,
  "minItemOrders": 10,
  "shapeMin": 30,
  "shapeFloor": 0.08,
  "noiseMin": 3,
  "noiseCum": 21,
  "baselineR": { "burst": 3, "trend": 7 },
  "baselineB": { "burst": 7, "trend": 14 },
  "earlyMinutes": 10,
  "refreshMs": 45000,
  "staleMin": 45,
  "staleRedMin": 25,
  "predictMinCum": 0.08
}
字段含义改动影响
hotShow破百视图默认阈值只影响展示,hotStore 才是落盘门槛
hotStore破百落盘最小单量,MUST < hotShow留出前端下探空间,否则用户切低阈值要重算
minItemOrders进入「今日单品」榜的最小单量低于它的商品只进全店合计
shapeMin小时曲线最小样本见 6.9 shapeOk
noiseMin单小时样本下限低于它不给环比
noiseCum7 天同时段累计下限(noiseMin*7)低于它标「新品」
earlyMinutes整点后多少分钟内不给环比样本太少,给了就是噪声

3.2 orders/_shops.json

{ "<APPID>": { "name": "<店铺名>", "note": "" } }

3.3 orders/_cred.json(两种存法必须都支持)

{ "appid": "<APPID>", "secret": "<SECRET>", "secrets": { "<APPID>": "<SECRET>" } }

必须用一个取值函数收口,MUST NOT 在各处直接读 secrets[appid]:

const secretOf = (a) => cred.secrets?.[a] || (a === cred.appid ? cred.secret : '');
只读 secrets{} 会让「只写在顶层的店」被判成缺密钥;这是真实踩过的坑。

4. 数据层:CSV 与口径

4.1 输入格式:11 列 slim CSV

一行 = 一个订单的一个商品(明细行),不是一单一行。列序固定:

#列名说明
1订单号去重唯一键
2订单下单时间YYYY-MM-DD HH:mm:ss,日/小时分桶唯一依据
3订单确认收货时间签收类指标用,本手册不使用
4订单状态只用于判「已取消」
5商品名称展示
6商品编码(平台)商品主键 pid
7商品数量件数
8商品售后本手册不使用
9支付时间判「已支付」
10订单实际支付金额金额口径 A
11订单实际收款金额金额口径 B(选一个,MUST NOT 混用)

4.2 parseCsvLine(商品名可能带逗号,必须支持引号)

function parseCsvLine(line) {
  const out = []; let cur = ''; let q = false;
  for (let i = 0; i < line.length; i++) {
    const ch = line[i];
    if (q) {
      if (ch === '"') { if (line[i + 1] === '"') { cur += '"'; i++; } else q = false; }
      else cur += ch;
    } else if (ch === '"') q = true;
    else if (ch === ',') { out.push(cur); cur = ''; }
    else cur += ch;
  }
  out.push(cur);
  return out;
}

MUST NOT 用 line.split(','),也 MUST NOT 用 awk -F',' 数字段——商品名里有逗号。

4.3 isValidRow(口径核心,只有这一处)

const isValidRow = (r) =>
  String(r.payTime || '').trim() !== '' &&      // 已支付
  String(r.status || '').trim() !== '已取消';    // 未取消

MUST 用 pay_time 判付款,MUST NOT 用状态文案判断「未付款」——平台的「部分售后」文案实际是超时未付。

4.4 单测用例(写完口径先跑这 6 条)

输入期望
pay_time 有值,status=已完成有效
pay_time 有值,status=已取消无效
pay_time 空,status=部分售后无效
pay_time 空,status=待付款无效
pay_time 有值,status=待发货有效
pay_time 有值,status=已发货有效

4.5 countOrders(去重)

CSV 中同一订单的多行相邻,用游标去重即可;每个店铺、每一天都要重置游标:

let lastOid = '';
const bump = (oid) => { if (oid && oid !== lastOid) { n++; lastOid = oid; } };

MUST NOT 用 pid + oid 组合去重(会把同单多规格拆成多单)。
MUST NOT 用整行去重(同商品两规格行完全相同,会吃掉真·两件)。
若无法保证相邻(换数据源),改用 Set 存 oid,语义相同、内存更大。

4.6 金额规则:行级

逐行累加 amt。MUST NOT 按订单去重后取首行,也 MUST NOT 每行取订单总额(会翻倍)。
同一订单不同商品各行金额不同(主商品 X.XX + 赠品 0.00);同一商品多规格是两行、金额相同——都按行加。


5. 快照层

5.1 路径规则(MUST 完全一致)

orders/导出_${YYYY-MM-DD}/${appid}.api.slim.csv
orders/导出_${YYYY-MM-DD}/${appid}.api.slim.csv.gz
orders/导出_${YYYY-MM-DD}/${appid}.api.slim.json      // 元信息,可能 0 字节
  • 日期用业务时区。
  • 同一天多次同步 → 覆盖同名文件(天然保留 snapshotKeepDays 天历史)。

5.2 原子写(MUST)

fs.writeFileSync(p + '.tmp', buf);
fs.renameSync(p + '.tmp', p);

直接覆盖会导致读端拿到半行残缺数据(真实事故:一行只有前半截)。

5.3 枚举某店最新快照(这段照抄,别自己发挥)

function newestSnapshot(dir, appid) {
  let best = null;
  for (const d of fs.readdirSync(dir).sort().reverse()) {          // 新日期优先
    if (!/^导出_\d{4}-\d{2}-\d{2}$/.test(d)) continue;
    const p = path.join(dir, d, `${appid}.api.slim.csv`);
    if (!fs.existsSync(p)) continue;
    const st = fs.statSync(p);
    if (!best || st.mtimeMs > best.m) best = { m: st.mtimeMs, p };
    else break;                                                    // 只要最近一个有
  }
  return best;
}

三条硬规则:

  1. 只认 /\.api\.slim\.csv$/i。同名的 .json 是 0 字节元信息,按「最新文件」挑会挑到它 → 读出 0 单。
  2. 首次赋值必须带 m(if (!best || ...) best = { m, p }),否则永远比不出结果。
  3. snapAt = 该文件 mtimeMs,写进聚合产物,前端用它算数据龄。

6. 聚合层(核心)

6.1 _growth.json schema(字段名 MUST 一致,类型 MUST 一致)

{
  "at": 1730000000000,
  "dataEnd": "2026-01-31",
  "shops": {
    "<APPID>": {
      "name": "<店铺名>",
      "snapAt": 1730000000000,
      "todayOrders": 1234,
      "allOrders": 9876,
      "shape7": [24 个正整数,过去 7 天各小时订单数合计],
      "items": [
        { "p": "<pid>", "n": "<商品名>", "o": 321, "q": 340, "a": 12345.0,
          "tH": [24 个整数,今天各小时订单数],
          "h7": [24 个整数,过去 7 天同小时订单数合计],
          "o7": 1560, "o7up": 210,
          "lastH": 21, "h7last": 140 }
      ]
    }
  },
  "today": {
    "allO": 2502,
    "items": [
      { "p": "<pid>", "n": "<商品名>", "o": 189, "q": 190, "a": 9876.0,
        "h": [24], "h7": [24], "o7": 900, "o7up": 120, "lastH": 21, "h7last": 140,
        "shops": ["<APPID>"] }
    ]
  },
  "hot": [
    { "p": "<pid>", "n": "<商品名>", "day": "2026-01-30", "o": 461, "q": 470,
      "a": 23456.0, "base": 179.7, "shop": "<APPID>" }
  ],
  "growth": [
    { "p": "<pid>", "n": "<商品名>", "shop": "<APPID>",
      "r3": 120, "b7": 40, "r7": 240, "b14": 120, "pred7": 840, "mode": "compare" }
  ]
}

字段语义:

字段语义
at生成本文件的毫秒时间戳(前端靠它判断要不要重绘)
dataEnd数据最后一天(YYYY-MM-DD),落后今天 > 1 天 → 页面标红
shape7[h]该店过去 7 天、第 h 小时的订单数合计
tH[h] / h7[h]该商品今天 / 过去 7 天同小时订单数
o / q / a订单数(去重)/ 件数 / 金额(行级加总)
o7该商品过去 7 天订单数合计
o7up该商品过去 7 天、0 点到上一整点的订单数合计(做「比平时」用)
base破百日的「前 7 天该商品日均订单数」
modecompare(可比)或 volume(样本不足,只按量排)

6.2 主流程

function aggregate(shopIds, cfg, todayTs) {
  const today = dayIndex(todayTs, cfg.tzOffsetHours);   // 天序号
  const out = { at: Date.now(), dataEnd: '', shops: {}, today: { allO: 0, items: [] },
                hot: [], growth: [] };
  const todayMap = new Map();   // pid -> 跨店合并的今日项

  for (const appid of shopIds) {
    const snap = newestSnapshot(ORDERS, appid);
    if (!snap) continue;
    const r = aggregateShop(appid, snap, cfg, today);   // 流式读,见 6.3
    out.shops[appid] = r.shop;
    for (const it of r.items) mergeToday(todayMap, appid, it, cfg);
    out.hot.push(...r.hot);
    out.growth.push(...r.growth);
  }

  out.today.items = [...todayMap.values()]
    .filter((x) => x.o >= cfg.minItemOrders)
    .sort((a, b) => b.o - a.o);
  out.today.allO = [...todayMap.values()].reduce((s, x) => s + x.o, 0);  // 含小单品
  out.hot.sort((a, b) => b.o - a.o);
  out.growth.sort((a, b) => b.r3 / Math.max(b.b7, 1) - a.r3 / Math.max(a.b7, 1));
  out.dataEnd = yesterdayStr(cfg);
  return out;
}

6.3 单店聚合(流式,一行一行喂)

async function aggregateShop(appid, snap, cfg, today) {
  const shop = { name: shops[appid].name, snapAt: snap.m, todayOrders: 0, allOrders: 0,
                 shape7: new Array(24).fill(0), items: [] };
  const M = new Map();          // pid -> { p,n,o,q,a,tH,h7,o7,o7up,lastH,h7last,d:Map }
  let lastOidToday = '';

  for await (const line of readLines(snap.p)) {           // readline,不整文件读入
    const c = parseCsvLine(line);
    if (c.length < 11) continue;
    const r = row(c);
    if (!isValidRow(r)) continue;
    const off = dayOffset(r.ts, today, cfg.tzOffsetHours);  // 0=今天
    if (off < 0 || off > cfg.hotDays - 1) continue;         // 窗口外丢弃
    const h = hourOf(r.ts, cfg.tzOffsetHours);

    let m = M.get(r.pid);
    if (!m) { m = newItem(r); M.set(r.pid, m); }

    // 店铺级
    if (off === 0) {
      if (r.oid !== lastOidToday) { shop.todayOrders++; lastOidToday = r.oid; }
      shop.shape7[h]++;                       // 注:shape7 用"今天"形状,见 6.5 修正
    }
    // 商品级
    if (off === 0) {
      if (r.oid !== m.lastOidDay0) { m.o++; m.tH[h]++; m.lastOidDay0 = r.oid; m.lastH = h; }
      m.q += r.qty; m.a += r.amt;
    } else if (off <= 7) {
      if (r.oid !== m.lastOidH7) { m.h7[h]++; m.o7++; m.lastOidH7 = r.oid; }
      if (h < nowHour) m.o7up++;
      m.q += 0; // 见下:q/a 只算今日,历史另算
    }
    // 历史按天(破百 + 增速都要)
    const dm = m.d.get(off) || { o: 0, q: 0, a: 0, lastOid: '' };
    if (r.oid !== dm.lastOid) { dm.o++; dm.lastOid = r.oid; }
    dm.q += r.qty; dm.a += r.amt;
    m.d.set(off, dm);
  }
  ...
}
上面是骨架。三条必须遵守的实现纪律:<br>1. 游标分开:今日游标、7 天游标、每日游标各自独立,混用会漏计或重复计。<br>2. 流式:readline 逐行,单店算完就丢 Map,MUST NOT 把多家店的明细同时驻留内存。<br>3. q/a 只对今日求和(榜单只展示今日件数金额);历史只保留 o(破百/增速只要单量)。需要历史金额时再加,别默认全存。

6.4 dayOffset / hourOf(时区处理唯一入口)

const dayIndex = (ms, tz) => Math.floor((ms + tz * 3600e3) / 86400e3);
const dayOffset = (ms, today, tz) => today - dayIndex(ms, tz);
const hourOf = (ms, tz) => Math.floor(((ms + tz * 3600e3) % 86400e3) / 3600e3);

ts 从 YYYY-MM-DD HH:mm:ss 解析时,MUST 按业务时区解释,不要 new Date(str)(会按进程时区)。

6.5 shape7 的正确来源(易错)

shape7 必须是过去 7 天(off=1..7)的全店小时合计,不是今天。上面骨架里写在 off===0 分支是错误示范,实际写法:

if (off === 0) shop.todayOrders++;                     // 今天只数单量
else if (off <= 7) shop.shape7[h]++;                   // 过去 7 天累积形状

6.6 破百 hot[]

for (const m of M.values()) {
  for (let d = 1; d <= cfg.hotDays; d++) {
    const cur = m.d.get(d);
    if (!cur || cur.o < cfg.hotStore) continue;
    // base = 该日之前 7 天该商品日均
    let s = 0;
    for (let k = d + 1; k <= d + 7; k++) s += (m.d.get(k)?.o || 0);
    const base = s / 7;
    if (cur.o < cfg.hotShow) continue;                 // 展示阈值
    hot.push({ shop: appid, p: m.p, n: m.n, day: dayStr(d), o: cur.o, q: cur.q, a: cur.a, base });
  }
}
  • d 是 dayOffset(0=今天),所以「前 7 天」是 d+1 .. d+7。
  • base MUST 保留 1 位小数以上(至少 2 位),MUST NOT 先四舍五入到 0.1 再用(+3% 会被抹成 0%)。

6.7 增速 growth[]

const sum = (m, a, b) => { let s = 0; for (let d = a; d <= b; d++) s += (m.d.get(d)?.o || 0); return s; };
const span = maxOffWithData(M);                       // 数据覆盖到第几天

for (const m of M.values()) {
  const r3 = sum(m, 1, 3) / 3,  b7r = sum(m, 4, 10) / 7;
  const r7 = sum(m, 1, 7) / 7,  b14 = sum(m, 8, 21) / 14;

  const bEff = Math.min(7, span - 3);                 // 自适应基线
  if (bEff < 2) { growth.push({ ...vol(m), mode: 'volume' }); continue; }

  growth.push({ shop: appid, p: m.p, n: m.n, r3, b7: b7r, r7, b14,
                pred7: Math.round(r7 * 7), mode: 'compare' });
}

bEff < 2(历史不足)→ mode='volume',只按绝对量排序,不给百分比。否则新店全员「新品」,榜单没有信息量。

6.8 今日视图排序与合并

  • 同一 pid 跨店合并:o/q/a 相加,tH/h7 逐元素相加,shops[] 记来源店。
  • 榜内只保留 o >= minItemOrders(config 默认 10)。
  • today.allO MUST 用合并后全量求和(含被过滤掉的小单品),MUST NOT 用榜内合计——两者差约 10%,用户会拿它跟后台对数。

6.9 护栏常量(MUST 全部实现,少一个就出爆表数字)

名称判定不满足时的行为
shapeOksum(shape7) >= max(shapeMin, todayOrders)shapeCum = null,预估显示「—」,汇总标「≥ 已卖」
shapeCum见 8.5,且 >= shapeFloor不估
prevOk上一小时 >= noiseMin环比显示「—」
cumOko7up >= noiseCum「比平时」显示「新品」
baseOkbase >= noiseMin显示「平时少」
earlylive && nowMinute < earlyMinutes不比环比

6.10 内存纪律

  • 单店聚合完立即 M.clear(),不跨店持有。
  • 全局只保留:_growth.json 的内存对象 + 当前店的 Map。
  • 重批处理(回补历史)必须限内存:systemd-run --unit=x --collect -p MemoryMax=900M -p MemorySwapMax=1G ...,日志 tail 轮询。
  • 单次运行结束打印 rssMb,超过 400MB 视为回归。

7. 服务层 40-server.mjs

7.1 路由表

方法路径返回缓存
GET/growthpage/index.html不缓存(每请求读盘)
GET/api/growth_growth.jsonmtime 缓存
GET/api/shops_shops.jsonmtime 缓存
GET/api/mem进程内存 JSON不缓存
GET/api/health{ ok, at, dataEnd, shops, staleShops }不缓存

7.2 mtime 缓存(照抄)

let cache = { m: 0, d: null };
function growthData() {
  const m = fs.statSync(GROWTH_JSON).mtimeMs;
  if (m !== cache.m) cache = { m, d: JSON.parse(fs.readFileSync(GROWTH_JSON, 'utf8')) };
  return cache.d;
}

7.3 /api/mem 字段

{ rssMb, heapTotalMb, heapUsedMb, uptimeSec, lastCollectAt, collecting }

collecting 用于排查「数据不动」:true 卡住 > 5 分钟 = 任务死了。

7.4 错误处理

  • 任何 /api/* 异常 MUST 返回 500 + {"error":"..."},MUST NOT 返回 HTML。
  • JSON 响应 MUST 带 Cache-Control: no-store(否则前端 45 秒自检拿不到新数据)。

8. 前端 page/index.html(单文件)

8.1 区块顺序(MUST)

  1. 顶部条:标题 + 数据龄「数据截至 HH:MM(N 分钟前)」+ dataEnd 红标
  2. 警示条(staleWarn):跟得慢的店
  3. Tab:今日单品(默认)/ 单日破百 / 增速
  4. 控件:店铺下拉(全部 + 单店)、阈值切换、搜索框
  5. 汇总卡:当前筛选合计 + 逐小时走势图
  6. 表格(行可展开)
  7. 行展开面板:逐小时趋势图 + 文字结论

8.2 状态

const state = { view: 'today', shop: '', q: '', hot: 100, openKey: '', sortKey: 'o', sortDir: -1 };

openKey = appid + '|' + pid(合并视图下没有唯一 appid 时用 '*|' + pid)。

8.3 渲染流程

function render() {
  const rows = pickRows();          // 按 view 选数据集 + 筛选 + 排序
  renderMeta(); renderWarn(); renderStat(rows);
  renderTable(rows);                // 表头随 view 变
  bindRowClick();                   // 事件委托,别逐行绑
}

MUST 用事件委托(table.addEventListener('click')),MUST NOT 在 500 行表格上逐行 onclick。

8.4 列定义(三视图各自固定)

今日单品:商品 / 店铺数 / 今日单量 / 件数 / 金额 / 本小时 / 上一小时 / 小时环比 / 比平时 / 店内进度 / 预估全天

单日破百:日期 / 商品 / 店铺 / 当日单量 / 件数 / 金额 / 比平时(base)

增速:商品 / 店铺 / 近3天 / 前7天日均 / 比之前 / 近7天日均 / 前14天日均 / 预计7天

8.5 计算函数(照抄,别改公式)

const nowBJ = () => { const d = new Date(Date.now() + TZ * 3600e3);
  return { h: d.getUTCHours(), m: d.getUTCMinutes() + d.getUTCSeconds() / 60 }; };

function hourState(h, cfg) {                      // h: [24] 今日小时单量
  const n = h.length, lastIdx = n - 1, bj = nowBJ();
  const live = lastIdx === bj.h;                  // 最新桶 == 当前小时?
  const frac = live ? Math.max(3, bj.m) / 60 : 1; // 折算成整小时
  const cur = n ? h[lastIdx] : 0;
  const prev = n >= 2 ? h[lastIdx - 1] : 0;
  const pace = frac > 0 ? cur / frac : cur;
  const early = live && bj.m < cfg.earlyMinutes;
  const mom = prev >= cfg.noiseMin && !early ? (live ? pace / prev : cur / prev) : null;
  return { live, early, frac, cur, prev, pace, mom, lastIdx, nowH: bj.h, minNow: Math.round(bj.m) };
}

function estimateFullDay(todayO, shape7, cfg) {   // 见 6.9 护栏
  const bj = nowBJ();
  const tot = shape7.reduce((s, x) => s + x, 0);
  const ok = tot >= Math.max(cfg.shapeMin, todayO);
  const frac = Math.max(3, bj.m) / 60;
  let up = 0;
  for (let i = 0; i <= bj.h; i++) up += shape7[i] * (i === bj.h ? frac : 1);
  const cum = ok && tot > 0 ? up / tot : null;
  if (cum == null || cum < cfg.shapeFloor) return { est: null, cum };
  return { est: Math.round(todayO / cum), cum };
}

function upct(m) {                                 // 倍数 → 百分比,全局唯一入口
  if (m == null || !isFinite(m)) return null;
  const p = Math.round((m - 1) * 100);
  if (p > 99999) return '>+99,999%';
  return (p > 0 ? '+' : '') + (Math.abs(p) >= 1000 ? p.toLocaleString('zh-CN') : p) + '%';
}
const pCls = (p) => (p >= 20 ? 'up' : p <= -20 ? 'down' : 'flat');

8.6 逐小时趋势图(内联 SVG,无图表库)

function trendChart(cur, base7, opts) {          // cur: [24] 今日, base7: [24] 7 天同小时合计/7
  const W = 940, H = 190, padL = 28, padR = 8, padT = 12, padB = 22;
  const bw = (W - padL - padR) / 24;
  const max = Math.max(1, ...cur, ...base7) * 1.15;
  const y = (v) => padT + (H - padT - padB) * (1 - v / max);
  // 柱:今天每小时;颜色 = 比上一小时高 ? 绿 : 灰;当前小时 = 橙
  // 虚线:base7 折线(7 天均值)
  // 竖线:当前小时位置
  // 柱顶:单量数字;当前小时前缀 '≈'
}
  • 尺寸固定(W=940),移动端用 CSS overflow-x:auto 横滑,不要做响应式重算。
  • MUST 画「7 天均值虚线」,否则用户分不清「今天异常」和「每天这个点都高」。

8.7 展开交互

table.addEventListener('click', (e) => {
  const tr = e.target.closest('tr[data-key]'); if (!tr) return;
  state.openKey = state.openKey === tr.dataset.key ? '' : tr.dataset.key;
  render();                                  // 整表重绘比手动插 DOM 更省 bug
});

8.8 自动刷新(45 秒自检)

let refreshing = false;
setInterval(async () => {
  if (refreshing || document.hidden) return;
  refreshing = true;
  try {
    const j = await (await fetch('/api/growth', { cache: 'no-store' })).json();
    if (j.at === DATA.at) return;            // 时间戳没变 → 不重绘
    const keepShop = el.shop.value;
    DATA = j; fillShops(); el.shop.value = keepShop; render();
  } finally { refreshing = false; }
}, cfg.refreshMs);

MUST 保留用户当前筛选与展开行(keepShop、state.openKey),否则每次刷新页面都跳回顶部。

8.9 数据龄与警示条

const ageMin = (ms) => Math.round((Date.now() - ms) / 60000);
// 顶部:ageMin(DATA.at) > staleRedMin → 红
// 警示条:shops 里 snapAt 存在 && kept>0 && ageMin(snapAt) > staleMin
//         → 橙条「N 家店的数据跟得慢:<店名>(264 分钟前)」

8.10 样式约束

  • 涨/跌/平:绿 #0f9d58 / 灰 #8a8a8a / 红 #c2452f(这里是涨绿跌灰,不是股票的红涨绿跌;颜色语义必须在页脚写清)。
  • 所有数字等宽(font-variant-numeric: tabular-nums),否则列不齐。
  • 空值统一 —,且 title 里写原因(「样本不足」「平时没量」「查不了」),MUST NOT 把「无数据」和「就是 0」显示成同一个符号。

9. 定时任务

9.1 crontab

3,13,23,33,43,53 * * * *  cd <APP> && node app/collect_growth.cjs >> logs/collect.log 2>&1
*/15 * * * *              cd <APP> && bash app/healthcheck.sh >> logs/health.log 2>&1
20 3 * * *                cd <APP> && bash app/daily_sync.sh >> logs/sync.log 2>&1
40 5 * * *                cd <APP> && bash app/cleanup_snapshots.sh >> logs/clean.log 2>&1
  • 采集 MUST 错开 */15 的整点(用 3,13,23,...),否则和巡检抢 IO。
  • MUST NOT 用小时级 cron 做「今日实时」:用户会拿后台对数,差 1 小时就是「你这数据不准」。

9.2 采集任务纪律

  • 单次运行 MUST < 10 秒(几十家店);超时说明在整文件读入内存。
  • 写 _growth.json MUST 原子写(5.2)。
  • 失败 MUST 保留上一次产物(不要清空文件),并把错误写进 logs/collect.log。

9.3 healthcheck.sh(三条自愈,缺一条系统就会静默烂掉)

# 1. 数据 3 小时没更新 → 重启采集
# 2. 内存超限 → 回收 / 告警(AVAIL<250MB 或 cgroup>1400MB,冷却 1h)
# 3. 昨日成交守卫:昨日单量为 0 但前 7 天日均 > 0 → 告警,不要静默写 0

10. 验收(逐条跑,全 PASS 才算完)

10.1 数据正确性

A1 CSV 完整性

node app/verify.cjs --check csv
# 期望: bad_shops=0 bad_lines=0

A2 行数

node app/verify.cjs --check rows
# 期望: 每个店的 解析行数 == wc -l 行数 - 1;且今天的 >= 昨天快照的 0.9

A3 订单号集合(最硬)

node app/verify.cjs --check oids
# 期望: 服务端导出的订单号集合 ⊇ 本地重算集合,差集为空

A4 接口一致性

curl -s http://127.0.0.1:8901/api/growth | node -e "..."   # 逐店对比 o 与重算值
# 期望: 所有店 diff=0

A5 聚合可复现

node app/collect_growth.cjs && cp orders/_growth.json /tmp/a.json
node app/collect_growth.cjs && diff <(jq -S 'del(.at,.shops[].snapAt)' /tmp/a.json) <(jq -S 'del(.at,.shops[].snapAt)' orders/_growth.json)
# 期望: 无差异(同一批 CSV 两次跑必须一致;不一致 = 有隐藏随机/顺序依赖)

10.2 前端

A6 DOM 断言(用无头浏览器直读文本,不要靠截图)

node app/verify.cjs --check dom --url http://127.0.0.1:8901/growth
# 期望:
#   console 无错误(window.onerror 计数 = 0)
#   表头存在「今日单量」「小时环比」「比平时」「预估全天」
#   至少 1 行数据;数字列不含 "undefined" / "NaN"
#   顶部含「数据截至」

A7 噪声护栏生效

造 3 条边界数据(o=1、o7up=5、base=0),期望分别显示「—」「新品」「平时少」,不出现 +115150% 这类数字。

10.3 稳定性

A8 内存:连续采 10 轮,/api/mem 的 rssMb 峰值 < 400,且不呈单调上升(锯齿正常,单调 = 泄漏)。

A9 数据龄:人为停掉采集 20 分钟,页面顶部必须变红并显示分钟数;恢复后 1 轮内消失。


11. MUST NOT 清单

  1. MUST NOT 用状态文案判付款(用 pay_time)。
  2. MUST NOT 用整行去重、pid+oid 去重。
  3. MUST NOT 按订单去重后取首行算金额。
  4. MUST NOT 用 split(',') 解析 CSV。
  5. MUST NOT 按「最新文件」挑快照(要按 .api.slim.csv 正则 + 带 m 的比较)。
  6. MUST NOT 只读 secrets{}(顶层单店凭据会丢)。
  7. MUST NOT 直接覆盖写文件(原子写)。
  8. MUST NOT 在请求路径里现算聚合。
  9. MUST NOT 用 UTC 切天/切小时。
  10. MUST NOT 在样本不足时输出百分比(三档护栏见 6.9)。
  11. MUST NOT 把「无数据」显示成 0。
  12. MUST NOT 只在一处实现口径(服务端与前端各算一遍时,必须同源同公式)。
  13. MUST NOT 在没有「数据龄」的情况下上线(用户无法区分「没单」和「没同步」)。
  14. MUST NOT 一次性把多店明细读进内存。

12. 可替换 vs 不可动

你想改改哪风险
榜单门槛、阈值、刷新间隔config.json无,纯参数
金额口径(支付 vs 收款)4.1 选定列,一处改全站金额变化,要重跑历史
时间粒度(改成周)6.4 分桶函数所有视图列要跟着改
「预估全天」算法8.5 estimateFullDay失去与历史曲线的可比性
三档护栏阈值config.json阈值过低会出爆表数字
口径函数 isValidRow不要动一动全部历史数字变化,必须全量重算并重新对数
游标去重规则不要动一动订单数全错,且错得很隐蔽
原子写 / 最新快照选择不要动这两种错都表现为「偶尔少数据」,极难查

13. 交付报告模板(做完照这个汇报)

【交付】多店订单看板
- 店铺数: N / 快照覆盖: D1 ~ D2
- 今日全店单量: X(上榜商品: Y,差 Z%)
- 验收: A1..A9 全部 PASS(贴每条的期望/实际)
- 性能: 采集单次 Xs / 峰值 RSS YMB / 页面首屏 Zs
- 已知限制: (例如 同步延迟 N 分钟、hours 曲线样本不足的店 M 家)
- 待用户提供: appid+secret、店铺名单、阈值偏好

一句话:粒度定死(店铺 × 商品 × 日 × 小时)→ 口径只有一处(pay_time + 未取消)→ 定时预聚合成一份 JSON → 前端只筛不算 → 所有「看起来不对」都在界面上给解释(数据截至几点、哪家店慢、样本够不够)。

类型
随手记
状态
待整理
可见性
公开可见
浏览
16

我的备注 0

登录后才能写备注。

还没有备注。