ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Node.js 到货监控系统:低频采集、状态机与通知幂等

Node.js 到货监控系统:低频采集、状态机与通知幂等

简介:这是一份基于 Node.js 实现的京东商品到货监控与自动下单脚本,面向具备一定 JavaScript 基础、希望了解浏览器自动化与抢购流程的开发者。项目通过扫码登录、按地区查询商品库存,并在库存大于 0 时触发下单,同时支持抢购场景与本地缓存登录状态;入口、参数、日志和工具函数做了模块拆分,便于按流程阅读调试。需要说明的是,京东接口更新后部分功能已过期,仓库标注为 DEPRECATED,更适合作为自动化流程与页面分析思路的参考,而非直接投入生产。压缩包共 11 个文件,约 1.7MB,以 5 个 js 源码为核心,另含 package.json 与 yarn.lock 依赖配置、README 说明文档、LICENSE、.gitignore 及演示 gif,覆盖运行配置、日志输出和操作展示。已有 820 人学习,适合用来拆解扫码登录、库存轮询与下单触发之间的协作关系,并积累接口失效后的排查与改造经验。

1. 用 Node 写一个到货监控:先把需求钉在「低频、可查、可控」上

某电商平台上有一件商品长期缺货,页面上的「到货通知」按钮是唯一的线索,手动刷新几十次也未必能撞上补货的那几分钟。这个需求催生了这套 Node 爬虫方案:定时采集商品页,判定库存状态变化,触发通知,再进入一个带人工确认的下单草稿流程。它适合两类人,一类是要交毕设或课设的学生,需要一条从采集到动作的完整链路来证明工程能力;另一类是从业者,想把这套监控骨架改成价格监控、库存预警、竞品快照。方案的前提必须说清楚:单机、低频、个人自用、尊重平台规则,遇到验证环节就停下转人工,不追求毫秒级抢购,也不做批量囤货。先把边界钉死,后面的架构才有讨论价值。

2. 总体架构:四层结构与三张核心表的职责划分

这个项目最容易被写成一堆 setInterval 加几个 axios 请求的堆砌脚本,跑两天就不知道哪条数据是真的。要让它成为一份能答辩、能交付的方案,第一步是把职责切开。我在做类似项目时习惯先画数据流,再决定代码目录,因为数据流的断点往往就是后面 bug 的藏身处。

2.1 四层划分:调度层、采集层、判定层、动作层

调度层只干一件事,决定「什么时候去抓哪个 SKU」。它不关心页面长什么样,也不关心抓回来是涨是跌,手里维护的是一张任务表,每个任务有下次触发时间、最小间隔、当前是否在执行。把所有时间逻辑收在一处,后面要改成固定间隔、随机抖动、错峰批量,只动这一个文件。

采集层负责「把页面变成结构化数据」。它需要处理超时、重试、条件请求、编码异常这些脏活,输出一份干净的快照对象:标题、价格、库存文案、抓取时间、数据来源标记。这一层的价值在于,它让上层永远不接触原始 HTML,页面改版的影响面被压缩到一个解析函数里。

判定层是整套系统的大脑,维护每个 SKU 的状态机。它拿新快照和上一条快照比对,决定是「无变化」「状态迁移」还是「数据可疑」。可疑这个词很重要,解析失败、价格为空、库存文案消失,都不应该被当成「无货」直接吞掉,而要单独标记出来,否则你会得到一条看起来很安静、实际上早就瞎掉的监控。

动作层负责对外产生副作用:发通知、写事件表、生成下单草稿。副作用必须可重放、可去重、可关闭。我一般会给动作层加一个全局开关,调试时把通知关掉只写日志,避免在开发阶段把消息通道刷爆。

四层之间的通信建议用普通的函数调用加一个薄薄的事件总线,不要一上来就上消息队列。单机监控的并发量通常在个位数,引入 MQ 只会让你在答辩时多解释十分钟,还解释不清。

2.2 数据模型:商品表、快照表、事件表怎么设

存储选 SQLite 就够,单文件、零部署、方便随项目一起打包提交。三张表的字段我按实际用过的最小集合给出来:

表名关键字段说明
productssku、title、url、max_price、interval_ms、enabled监控目标配置,max_price 是价格上限,超过就不生成订单草稿
snapshotsid、sku、price、stock_text、source、etag、fetched_at每次采集落一条,保留原始判定依据
eventsid、sku、event_key、from_state、to_state、payload、created_at状态迁移事件,event_key 唯一索引用于幂等
ordersid、sku、price、qty、status、created_at下单草稿,status 只有 draft / confirmed / cancelled 三种

快照表会快速膨胀,一天 288 条乘以监控数量,跑一个月就是几万行。通常的做法是给 fetched_at 建索引,再加一个清理任务,只保留最近 7 天的明细,历史状态压缩成每日一条。这个细节写进方案里,答辩时被问到「数据量大了怎么办」就有话可说。

2.3 技术选型:为什么是 Node 而不是一堆定时脚本

选 Node 的理由不是「流行」,而是这个场景恰好是 IO 密集型:大量等待网络返回,CPU 几乎空转,单线程事件循环能把并发请求安排得明明白白。再配上 JavaScript 天然的 JSON 处理能力,解析页面内嵌数据几乎零成本。

依赖清单控制在六个以内,多了说明设计有问题:

依赖用途备注
undici 或内置 fetchHTTP 请求Node 18 之后内置 fetch 已可用,不额外装也行
cheerioDOM 解析兜底只在页面没有内嵌 JSON 时启用
better-sqlite3本地存储同步 API,代码短,适合单机
pino结构化日志输出 JSON,方便 grep 和后续接采集
node-cron低频兜底任务清理快照、日报汇总用
dotenv配置注入把 webhook 地址、cookie 放环境变量

不要引入 puppeteer 这类浏览器自动化方案。它启动慢、吃内存,而且在这个场景里属于明显的过度设计,个人自用的低频监控用不上渲染,反而会把机器拖慢。真遇到必须渲染的页面,正确做法是放弃这个目标,换一个数据源,而不是把复杂度翻三倍。

提示:所有凭证、cookie、webhook 地址一律走环境变量,并在 .gitignore 里排掉 .env。我见过太多把登录态硬编码进源码然后提交到公开仓库的例子,那是最不该犯的错。

3. 采集层实现:调度、节流与解析的三角平衡

采集层是整个项目里最容易翻车的地方,因为它直接面对外部系统的不确定性。写得糙一点,跑一天就能收到一堆 429 和空数据;写得好一点,跑一个月不用人管。这一章把调度器、请求封装、解析兜底三块拆开讲,每块都给可运行的代码。

3.1 调度器:单进程时间轮与并发闸门

调度器的核心设计是「一次扫描 + 并发上限」,而不是给每个 SKU 起一个 setInterval。后者的血泪经验是:任务一多,定时器互相打架,某个任务卡住不影响别人但也没人发现,最后内存里躺着一堆没人清理的句柄。

// scheduler.js —— 单进程时间轮,按 tick 扫描到期任务 class Scheduler { constructor({ tickMs = 1000, concurrency = 3 } = {}) { this.tickMs = tickMs; // 扫描粒度,1s 足够,别设成 10ms 空转 this.concurrency = concurrency; // 同时在跑的请求上限,个人自用 2~3 即可 this.tasks = new Map(); // sku -> { nextAt, intervalMs, running } this.active = 0; // 当前在跑的任务数 this.timer = null; } add(sku, intervalMs, jitterRatio = 0.2) { this.tasks.set(sku, { intervalMs, jitterRatio, // 首次触发加随机偏移,避免所有任务在同一秒集中打出去 nextAt: Date.now() + this.rand(intervalMs * jitterRatio), running: false, }); } rand(ms) { return Math.floor(Math.random() * ms); } start(handler) { this.timer = setInterval(() => this.tick(handler), this.tickMs); this.timer.unref?.(); // 不阻塞进程退出,写测试脚本时非常关键 } async tick(handler) { if (this.active >= this.concurrency) return; const now = Date.now(); for (const [sku, t] of this.tasks) { if (this.active >= this.concurrency) break; if (t.running || t.nextAt > now) continue; t.running = true; this.active += 1; handler(sku) .catch((err) => console.error(`[task:${sku}]`, err.message)) .finally(() => { t.running = false; this.active -= 1; // 下一次触发在基准间隔上叠加抖动,避免形成固定节奏 const base = t.intervalMs; t.nextAt = Date.now() + base + this.rand(base * t.jitterRatio); }); } } stop() { clearInterval(this.timer); } }

tickMs 决定扫描精度,设成 1000 意味着最坏情况下任务延后一秒执行,对分钟级的监控间隔完全无感,而设成 10 只会让 CPU 白白空转。concurrency 是并发闸门,它的作用是把自己伪装成一个「有礼貌的访客」,不要让十来个请求同时涌出去。jitterRatio 控制抖动的比例,0.2 表示在间隔上下浮动 20%,配上随机偏移,整体请求节奏看起来就自然多了。

running 标记是防止同一个 SKU 的任务重入。如果上一次请求卡在网络超时里,下一次 tick 到点时它还在跑,直接跳过,避免堆积。这一行代码省掉的问题比它看起来多得多。

3.2 请求封装:超时、退避与条件请求

请求封装要覆盖四类返回:正常 200、未修改 304、限流 429、其他错误。四类的处理策略完全不同,混在一起写就会得到「一出错就疯狂重试」的经典反模式。

// fetchSku.js —— 带超时、退避与条件请求的抓取 const DEFAULT_HEADERS = { 'accept-language': 'zh-CN,zh;q=0.9', 'cache-control': 'no-cache', }; async function fetchWithPolicy(url, { timeoutMs = 8000, retries = 2, etag } = {}) { let lastErr; for (let attempt = 0; attempt <= retries; attempt += 1) { const ac = new AbortController(); const timer = setTimeout(() => ac.abort(), timeoutMs); try { const res = await fetch(url, { signal: ac.signal, headers: { ...DEFAULT_HEADERS, ...(etag ? { 'if-none-match': etag } : {}) }, }); // 304 说明页面没变,直接复用上次解析结果,省掉一次解析开销 if (res.status === 304) return { notModified: true, etag }; // 429 和 5xx 是"让你慢一点"的信号,退避后重试 if (res.status === 429 || res.status >= 500) { const wait = 1000 * 2 ** attempt + Math.random() * 500; await new Promise((r) => setTimeout(r, wait)); lastErr = new Error(`HTTP ${res.status}`); continue; } // 其他 4xx 属于确定性失败,重试没有意义,直接抛 if (!res.ok) throw new Error(`HTTP ${res.status}`); return { notModified: false, body: await res.text(), etag: res.headers.get('etag'), }; } catch (err) { lastErr = err; } finally { clearTimeout(timer); } } throw lastErr; }

timeoutMs 设 8 秒,是因为页面本身不大,超过这个时间基本可以判定链路异常,继续等只会拖住并发槽位。retries 给 2 次,配合 1s、2s 的指数退避,总耗时上限约 11 秒,仍然控制在单次任务的可接受范围内。etag 是条件请求的关键,服务端返回 304 时省下的不只是流量,还有一次完整的解析与状态比对,在监控大量 SKU 时这个优化相当可观。

有一点容易忽略:AbortController 的 clearTimeout 必须放在 finally 里。如果只在成功分支清理,超时抛错的那条路径会留下一个悬空定时器,跑久了就是内存缓慢上涨,非常隐蔽。

3.3 解析与归一化:选择器失效的兜底策略

解析策略我一般按优先级排三级:页面内嵌 JSON、语义化 DOM 选择器、纯文本正则。第一级最可靠,因为前端渲染的数据接口通常会带一个结构化的初始状态;第二级依赖 class 名,改版就挂;第三级最糙,但对文案型字段(有货 / 无货 / 预订)意外地耐用。

// parse.js —— 三级解析,输出统一的快照结构 function normalizeText(s = '') { return s.replace(/\s+/g, ' ').trim(); } function parseSkuPage(html) { // 一级:页面内嵌的初始状态 JSON,改版风险最低 const jsonMatch = html.match(/window\.__SKU_DATA__\s*=\s*(\{[\s\S]*?\});/); if (jsonMatch) { try { const data = JSON.parse(jsonMatch[1]); return { source: 'json', title: normalizeText(data.name), price: Number(data.price), stockText: data.stockText, }; } catch { /* 解析失败落到下一级,不在这里中断 */ } } // 二级 + 三级:DOM 与文本兜底 const price = html.match(/<span[^>]*class="?price"?[^>]*>([\d.,]+)</)?.[1]; const stockText = html.match(/(有货|无货|到货通知|预订)/)?.[1]; return { source: 'dom', title: null, price: price ? Number(price.replace(/,/g, '')) : null, stockText, }; }

source 字段是整个解析层最重要的输出。它的含义是「这条数据是从哪一级拿到的」,后续判定层看到 source 从 json 变成 dom,即使价格和库存都没变,也应该在日志里升一级告警,因为这说明页面结构动了,下一次可能就彻底解析不出来。这是给未来的自己留的后悔药。

price 为空时不要填 0,0 会在后面的价格守卫里被误判成「极低价」,逻辑上等价于放行。空值就是空值,让判定层去处理未知态,这才是正确的边界处理。

3.4 冷启动:把全量抓取切成小批

监控 50 个 SKU,程序一启动就并发全抓,十有八九会被限流。通常的做法是冷启动分片:把任务列表按 5 个一批,批间间隔 30 秒,边跑边写数据库,第一批结果出来时后面的批次还没开始,用户已经能看到部分数据,体验上也不至于「启动后干等三分钟」。

实现上不需要额外组件,在 Scheduler.add 之前做一次分批循环,配合await sleep(batchGapMs)即可。参数上,批大小取决于并发闸门,批大小应该小于等于 concurrency 的两倍,否则排队等待的任务太多,抖动计算会失真。

4. 状态判定与通知:库存状态机与消息幂等

采集层给的是「这一刻的事实」,判定层要回答的是「和上一刻相比,发生了什么值得通知的事」。这两件事混在一起写,就会出现「每次抓到有货都发一条消息」的结果,一晚上能给你发两百条。

4.1 状态机定义:五种状态与合法迁移

状态用五个就够:UNKNOWN(首次采集或数据可疑)、OUT_OF_STOCK(无货)、IN_STOCK(有货未通知)、NOTIFIED(已通知待处理)、ORDER_DRAFT(已生成订单草稿)。迁移表如下:

当前状态允许迁移到触发条件
UNKNOWNOUT_OF_STOCK、IN_STOCK首次拿到有效库存文案
OUT_OF_STOCKIN_STOCK库存文案变为有货
IN_STOCKNOTIFIED、ORDER_DRAFT、OUT_OF_STOCK通知发出 / 生成草稿 / 又卖光
NOTIFIEDORDER_DRAFT、OUT_OF_STOCK人工确认后生成草稿 / 再次缺货
ORDER_DRAFTOUT_OF_STOCK草稿未及时处理,商品售罄

这张表的价值在于,任何不在表内的迁移都会被拒绝并记一条日志。比如从 UNKNOWN 直接跳到 ORDER_DRAFT,说明中间漏了判定,属于逻辑错误而不是业务变化,必须拦下来。

// stateMachine.js —— 迁移校验与价格守卫 const TRANSITIONS = { UNKNOWN: ['OUT_OF_STOCK', 'IN_STOCK'], OUT_OF_STOCK: ['IN_STOCK'], IN_STOCK: ['NOTIFIED', 'ORDER_DRAFT', 'OUT_OF_STOCK'], NOTIFIED: ['ORDER_DRAFT', 'OUT_OF_STOCK'], ORDER_DRAFT: ['OUT_OF_STOCK'], }; function transition(current, next, ctx) { const allowed = TRANSITIONS[current] || []; if (!allowed.includes(next)) { return { ok: false, reason: `illegal transition ${current} -> ${next}` }; } // 价格守卫:到货不等于该买,超过心理价位就不生成草稿 if (next === 'ORDER_DRAFT' && (!ctx.price || ctx.price > ctx.maxPrice)) { return { ok: false, reason: `price ${ctx.price} > maxPrice ${ctx.maxPrice}` }; } return { ok: true, state: next }; }

ctx 里带的是当次快照的价格和配置里的 maxPrice。价格守卫放在状态机里而不是下单函数里,是为了让「不买」这件事也留下一条可查的记录,事后复盘时能分清是没抢到还是主动放弃。

4.2 判定逻辑:四个维度的组合

光看「有货 / 无货」两个词不够,实际判定要组合四个维度:库存文案、价格、是否预售、配送地区。库存文案为主,价格用于过滤异常(比如标价 1 元的占位商品),预售标记用于降级(预售到货通知意义不大,可以只记日志不推送),配送地区影响的是「有货但送不到」,这一项如果没有数据源就先不做,别硬编。

判定函数的输出应该是三种之一:NO_CHANGE、TRANSITION(带目标状态)、SUSPECT(数据可疑)。SUSPECT 的处理方式是保持原状态不变,同时把这次快照标记出来,连续三次 SUSPECT 就暂停这个 SKU 的调度并告警。这个「连续三次」的阈值是可配的,我一般设 3,因为单次解析失败太常见,不值得打扰人。

4.3 通知去重:事件键与静默窗口

去重靠两样东西:事件键和静默窗口。事件键用 sku + 状态 + 日期拼接,配合数据库唯一索引,同一天同一个 SKU 的同一种状态迁移只能产生一条事件。静默窗口是第二道闸,即使状态在一天内反复横跳(有货、无货、又有货),也只发第一条,之后半小时内的同类事件只入库不推送。

// event.js —— 事件键与静默窗口 function eventKey(sku, state, date = new Date()) { const day = date.toISOString().slice(0, 10); return `${sku}:${state}:${day}`; } function shouldNotify(lastEvent, key, silentMs = 30 * 60 * 1000) { if (!lastEvent) return true; if (lastEvent.key !== key) return true; return Date.now() - lastEvent.at > silentMs; }

用 UTC 日期做键会有一个小坑:跨零点时事件键切换,同一晚的两次通知可能都被放行。要更严谨就把时区偏移算进去,取本地日期的字符串。这类细节不影响主流程,但在答辩被追问「幂等怎么保证」时,能答上来就是加分项。

4.4 通知渠道:降级链而不是单点

通知渠道建议配两条:主通道走 Webhook 机器人,备通道走 SMTP 邮件。发送逻辑做成降级链,主通道失败重试两次仍失败,就落到备通道,两条都失败才写进失败队列等下一轮补发。这样做的原因很实际——单一通道的可用性不可能百分百,而库存通知的时效窗口往往只有几分钟,错过就是错过。

消息体保持极简:商品标题、当前价格、状态迁移方向、时间、直达链接。不要塞长文,通知是给人扫一眼的,字越多越容易被忽略。链接必须是能直接打开商品页的,别让人再去搜一遍。

5. 下单链路编排与常见问题排查

到了下单这一步,态度必须转变。前面几层追求自动化,这一层恰恰要主动加人为关卡。原因不复杂:下单是不可逆的副作用,涉及真实支付,任何自动化错误都会被放大。我采用的方案是半自动——系统只负责把订单草稿准备好,提交动作由人点。

5.1 半自动下单:从事件到草稿的三步

第一步,事件触发后生成草稿记录,状态 draft,写入 sku、价格、数量(固定 1)。第二步,把草稿推送到通知渠道,附带一个本地控制台的确认入口。第三步,人工确认后状态置为 confirmed,此时才走提交流程。整个链路里,qty 被硬限制为 1,这是刻意的,防止参数被误改成批量。

// orderGuard.js —— 提交前把所有能挡的都挡掉 async function buildOrderDraft(sku, ctx) { // 库存文案必须是有货,不能用"可能有货"这类模糊值放行 if (ctx.stockText !== '有货') throw new Error('库存文案非有货,放弃'); // 价格必须存在且不超过上限,null 和 0 都不放行 if (!ctx.price || ctx.price > ctx.maxPrice) throw new Error('价格超出上限'); // 登录态失效是最高频的失败原因,提前挡住 if (ctx.loginStatus !== 'valid') throw new Error('登录态失效,需人工重新登录'); // 没有人工确认标记,一律不进入提交 if (!ctx.confirmedByHuman) throw new Error('未人工确认,不进入提交'); return { sku, price: ctx.price, qty: Math.min(ctx.qty ?? 1, 1), // 数量硬上限 1 createdAt: new Date().toISOString(), }; }

四个守卫的顺序有讲究:先判断库存,再判断价格,然后是登录态,最后是人工确认。前两个是业务条件,后两个是执行条件,业务不满足就没必要浪费一次登录态校验。qty 用 Math.min 而不是直接赋值,是为了在参数被外部污染时仍然能兜住。

5.2 登录态管理:加密存储与失效检测

登录凭证只存本地,用一个环境变量里的密钥做对称加密,解密后只在内存中保留,不落日志。失效检测的做法很简单:每轮采集时顺带请求一个需要登录的轻量接口,返回未登录就立即把 loginStatus 置为 invalid,并推送一条「需要重新登录」的通知。这条通知要独立于商品通知,不然用户会以为又是到货消息而忽略。

不要试图用代码去自动处理验证码或人机校验。这是明确的边界,遇到就停下转人工,既是合规要求,也是工程上最省事的做法。

5.3 五条最容易翻车的排查清单

下面这五条,每一条我都在不同项目里真实遇到过,按出现频率排序。

一、定时器泄漏,进程内存缓慢上涨。现象是跑三天后 RSS 从 80MB 涨到 600MB,重启就恢复。原因多半是每次 tick 都新建 setInterval 或 setTimeout,却没有清理,或者 AbortController 的超时定时器没在 finally 里清。解决办法是把所有定时器句柄集中登记,进程退出时统一 clear,同时在封装层强制 finally 清理。加一个每天打印一次process.memoryUsage()的定时任务,能提前发现趋势。

二、页面改版后静默失败,监控变成睁眼瞎。现象是连续几天没有任何事件,你以为一直缺货,其实解析早就返回 null 了。原因是解析函数对空结果过于宽容,返回{price: null, stockText: undefined}而不抛错。解决办法是加 SUSPECT 状态与连续失败计数,连续三次解析异常就暂停该 SKU 并推送告警。同时在日志里打出每次解析命中的是 json 还是 dom,用于判断改版时机。

三、请求频率过高触发限流,429 之后重试把情况变得更糟。现象是某个时间点后所有请求全部 429,越重试越糟。原因是没有区分「确定性失败」和「临时失败」,把 429 当成普通错误立刻重试。解决办法是 429 与 5xx 走指数退避,退避期间该 SKU 的下次触发时间同步推迟,避免闸门放开后又立刻打满。低频是这套方案最重要的前提之一。

四、SQLite 并发写入冲突。现象是偶发SQLITE_BUSY。原因是 better-sqlite3 虽然是同步 API,但多个异步任务在同一事件循环里交替写入,事务粒度没控制好。解决办法是把写入集中到一个串行队列,或者开启 WAL 模式并设置 busy_timeout。批量写入时用事务包住,别一条一条 commit。

五、时间漂移导致错过窗口。现象是通知时间戳和实际到货时间差了好几分钟,排查发现是机器时间同步没开。原因在于本地时钟漂移,计时逻辑全部基于 Date.now(),漂移会累积。解决办法是容器或主机开启 NTP 同步,同时在启动时打印一次服务器时间与上游响应 Date 头的差值,差超过阈值就告警。这个点有点玄学,但真遇上会非常难查。

5.4 日志与可观测性:把黑匣子打开

日志用结构化输出,每条至少带四个字段:sku、阶段(fetch、parse、judge、act)、结果、耗时。这样出问题时一条 grep 就能拉出某个 SKU 的完整链路。另外单独记一份「状态迁移流水」,只写事件不管细节,用于事后复盘「到底哪一刻到的货、通知什么时候发的、为什么没下单」。

我习惯在项目根目录留一个logs/,按天切割,保留 14 天。日志里绝不能出现 cookie、token、手机号这类东西,写之前统一过一遍脱敏函数。这个习惯养成之后,把日志发给别人帮忙看也不会出问题。

6. 验收与答辩:用三个脚本证明监控可信

到这一步,代码跑起来了,但「跑起来了」不等于「可信」。验收要解决的核心问题是:你怎么证明它在页面改版、网络抖动、状态反复的情况下依然给出正确结论。

第一个脚本做回放测试。把提前抓好的十份 HTML 样本(有货、无货、预售、结构变异、半截 HTML)放进 fixtures 目录,逐个喂给解析函数,断言输出符合预期。这个脚本跑通,说明解析层至少不会对异常输入产生幻觉。

第二个脚本做状态机穷举。遍历所有状态对,检查非法迁移是否被拒绝、价格守卫是否生效、事件键是否唯一。用表驱动的方式写,二十来行覆盖全部分支,比手写十几个用例可靠得多。

第三个脚本做端到端演练。把调度间隔临时改成 3 秒,用一个本地 mock 服务模拟库存从无到有,观察事件是否只产生一条、通知是否只发一次、草稿是否生成且 qty 为 1。这一步跑完,答辩时的现场演示基本不会出意外。

验收项通过标准对应脚本
解析健壮性10 份样本全部正确或正确标记为可疑回放测试
状态机完备无非法迁移被放行,无合法迁移被误拒穷举测试
通知幂等同一事件一天内只发一次端到端演练
下单守卫缺任一条守卫条件即拒绝生成草稿端到端演练
资源占用24 小时内存增幅小于 20%长跑观察

答辩时被问得最多的三个问题,我提前给出答法。问「为什么不用浏览器自动化」,答复杂度与收益不匹配,低频场景不需要渲染,且浏览器方案在长时间运行下资源占用不可控。问「怎么防止被封」,答控制频率、加抖动、尊重条件请求、遇到校验就停,不追求极限速度。问「下单环节为什么不做全自动」,答不可逆操作必须留人工确认,工程上这叫安全边界,不叫功能缺失。

从那以后我每次做带定时任务的采集项目,都会在第一天就把「回放测试脚本」写出来,哪怕只有五个样本。它不花什么时间,但能在后面每一次改解析逻辑时给你兜底。这套东西不大,结构却完整,从采集到判定再到动作,每一层都能单独拿出来讲。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进