店铺订单看板 · 智能体施工手册(无数据版)
给「执行施工的智能体」看的规格书:交付物清单、config 字段契约、口径单测、聚合算法伪代码、_growth.json 字段表、前端公式、crontab、A1–A9 验收命令、14 条 MUST NOT。照着做就能复刻同一套看板,不含任何店铺数据。
读者是执行施工的智能体,不是人。 全文用 MUST / MUST NOT 表述,每条都可执行、可验收。<br>目标:在一台机器上从零跑起一套「多店订单看板」,行为与本手册逐条一致。<br>所有店铺数据用占位符:<APPID>、<店铺名>、1,234。你接手的真实店铺信息由用户提供,替换占位符即可。
0. 你的执行步骤(先做这个,不要跳)
按顺序做,每阶段结束跑对应验收,通过了再进下一阶段:
- 建骨架:按第 2 节建目录,写
config.json+_shops.json+_cred.json(第 3 节)。 - 假数据跑通前端:手写一份符合第 6.1 节 schema 的
_growth.json(2 家店 × 3 个商品,数字随便编),把第 8 节的前端做出来。先看见页面,再谈数据。 - 接真数据:按第 4 节实现 CSV 读取与口径,用用户提供的一份真实导出做对照,通过第 10.1 节 A1–A4。
- 接定时任务:第 9 节,跑满 30 分钟,确认产物在更新、页面数据龄正常。
- 验收 + 交付报告:第 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 | 单小时样本下限 | 低于它不给环比 |
noiseCum | 7 天同时段累计下限(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;
}
三条硬规则:
- 只认
/\.api\.slim\.csv$/i。同名的.json是 0 字节元信息,按「最新文件」挑会挑到它 → 读出 0 单。 - 首次赋值必须带
m(if (!best || ...) best = { m, p }),否则永远比不出结果。 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 天该商品日均订单数」 |
mode | compare(可比)或 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。baseMUST 保留 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.allOMUST 用合并后全量求和(含被过滤掉的小单品),MUST NOT 用榜内合计——两者差约 10%,用户会拿它跟后台对数。
6.9 护栏常量(MUST 全部实现,少一个就出爆表数字)
| 名称 | 判定 | 不满足时的行为 |
|---|---|---|
shapeOk | sum(shape7) >= max(shapeMin, todayOrders) | shapeCum = null,预估显示「—」,汇总标「≥ 已卖」 |
shapeCum | 见 8.5,且 >= shapeFloor | 不估 |
prevOk | 上一小时 >= noiseMin | 环比显示「—」 |
cumOk | o7up >= noiseCum | 「比平时」显示「新品」 |
baseOk | base >= noiseMin | 显示「平时少」 |
early | live && 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 | /growth | page/index.html | 不缓存(每请求读盘) |
| GET | /api/growth | _growth.json | mtime 缓存 |
| GET | /api/shops | _shops.json | mtime 缓存 |
| 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)
- 顶部条:标题 + 数据龄「数据截至 HH:MM(N 分钟前)」+
dataEnd红标 - 警示条(
staleWarn):跟得慢的店 - Tab:
今日单品(默认)/单日破百/增速 - 控件:店铺下拉(全部 + 单店)、阈值切换、搜索框
- 汇总卡:当前筛选合计 + 逐小时走势图
- 表格(行可展开)
- 行展开面板:逐小时趋势图 + 文字结论
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),移动端用 CSSoverflow-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.jsonMUST 原子写(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 清单
- MUST NOT 用状态文案判付款(用
pay_time)。 - MUST NOT 用整行去重、
pid+oid去重。 - MUST NOT 按订单去重后取首行算金额。
- MUST NOT 用
split(',')解析 CSV。 - MUST NOT 按「最新文件」挑快照(要按
.api.slim.csv正则 + 带m的比较)。 - MUST NOT 只读
secrets{}(顶层单店凭据会丢)。 - MUST NOT 直接覆盖写文件(原子写)。
- MUST NOT 在请求路径里现算聚合。
- MUST NOT 用 UTC 切天/切小时。
- MUST NOT 在样本不足时输出百分比(三档护栏见 6.9)。
- MUST NOT 把「无数据」显示成
0。 - MUST NOT 只在一处实现口径(服务端与前端各算一遍时,必须同源同公式)。
- MUST NOT 在没有「数据龄」的情况下上线(用户无法区分「没单」和「没同步」)。
- 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
还没有备注。