From 9d80b4c73ea862936f4ec8e4f5c366484ddcd222 Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Fri, 23 Oct 2026 08:43:35 +0800 Subject: [PATCH] =?UTF-8?q?fix(wle):=20=E7=8E=B0=E5=9C=BA=E6=8B=A6?= =?UTF-8?q?=E6=88=AA=EF=BC=88=E5=94=AE=E5=90=8E=E5=B7=B2=E7=AD=BE=E6=94=B6?= =?UTF-8?q?=EF=BC=89=E4=B8=8D=E5=86=8D=E7=AE=97=E4=BB=93=E5=82=A8=E7=89=A9?= =?UTF-8?q?=E6=B5=81=E8=BD=AC=E8=BF=90=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 业务口径(2026-10-23):发货后被快递当场拦下、货被售后仓签收的单,物流环节 其实已经结束,不该报「转运异常」。判据 = 售后单**签收单号 `l_id` 非空** 且 明细**实退数量 `r_qty` 合计 > 0**。 改动: - registry 新增 INTERCEPT_AFTERSALE_SOURCES,登记两张互斥的店铺售后表 (jst_returnAndRefundInfo/Item、jst_qmcust_refundlist/Item); - service 新增 loadInterceptedOrders(),在主循环前一次性批量判定: · 只查「本来会报异常」的 oId(9800 单 → 几十单); · 两张表**并发**查(两表在 rpt 副本里都是堆表,全表扫 4.5s + 8.5s,并发后取 8.5s); · 售后表查询失败自动降级为「不剔除」,不让整页挂掉; - 命中即从异常清单摘掉,但**保留发货分母**(货确实发出去了),异常率不被放大; - exclusions 新增 interceptPackages/Orders/Reasons,页面页脚与钉钉推送各补一行说明。 实测(2026-10-22 批次):异常 36 → 33,剔除 3 单(9854409/9850736/9858367, 全是「拒收退货 + 实退 1 件 + 签收单号=原运单号 + 卖家已收货」); 10-21 批次剔除 2 单;同时 r_qty=0 的 9859198/9871523 正确地没被剔。 验证: - node --test warehouseLogisticsException(+Notify):47/47 通过; - 本地 5173 真渲染:表格 33 行、页脚显示「另有 3 单属现场拦截(售后已签收)」、无 pageerror; - 后端 npm run check:469/471,2 个失败为既有平台差异(secretStore 权限位、cloudDatabase 负缓存)。 --- .../warehouseLogisticsExceptionNotifyJob.js | 5 + .../warehouseLogisticsExceptionRegistry.js | 38 +++++++ .../warehouseLogisticsExceptionService.js | 92 ++++++++++++++- .../test/warehouseLogisticsException.test.js | 107 ++++++++++++++++-- .../WarehouseLogisticsException/index.jsx | 17 ++- 5 files changed, 248 insertions(+), 11 deletions(-) diff --git a/backend/src/jobs/warehouseLogisticsExceptionNotifyJob.js b/backend/src/jobs/warehouseLogisticsExceptionNotifyJob.js index 2a93a89..1e117f1 100644 --- a/backend/src/jobs/warehouseLogisticsExceptionNotifyJob.js +++ b/backend/src/jobs/warehouseLogisticsExceptionNotifyJob.js @@ -255,6 +255,11 @@ function buildNotifyText(data = {}, options = {}) { if (excludedCount > 0) { head.push(`已剔除 ${excludedCount} 个包裹(承运商 / 已终止 / 刷单·信誉单,不计考核)`); } + // 现场拦截(售后已签收):货被快递当场拉回、售后仓已签收,不算异常;仍算发货分母 + const interceptCount = Number(exclusions.interceptPackages) || 0; + if (interceptCount > 0) { + head.push(`另有 ${interceptCount} 个包裹属现场拦截(售后已签收),不计异常`); + } const centerLines = totalPackages > 0 ? buildCenterLines(data) : []; // 明细按仓聚集 + 每仓保底 1 条(避免大仓吃满额度),组间按异常数降序、组内保持时长降序 diff --git a/backend/src/services/warehouseLogisticsExceptionRegistry.js b/backend/src/services/warehouseLogisticsExceptionRegistry.js index 21adb2e..77ddd11 100644 --- a/backend/src/services/warehouseLogisticsExceptionRegistry.js +++ b/backend/src/services/warehouseLogisticsExceptionRegistry.js @@ -158,6 +158,43 @@ const EXCLUDED_ORDER_TYPE_LABELS = Object.freeze([ { keyword: '转空包', label: '信誉单' }, ]); +// ── 现场拦截(货已被售后仓签收)不算转运异常(业务 2026-10-23 指定)──────── +// 业务原话:「把现场拦截的给去掉,也就是发的货被售后签收了」,判据是 +// **售后单有「签收单号」且「实退数量」不为 0**。 +// +// 这是什么形态:客户不要了 / 派送时被拦下,快递当场把货拉回(轨迹常写 +// 「异常签收(拦截退回)」),货退回后由售后仓签收入库。物流环节其实已经结束, +// 不属于「卡在网点发不出去」,报成转运异常是假警报。 +// +// 落到库里的字段(聚水潭售后单两张表,店铺互斥,必须都读): +// · jst_returnAndRefundInfo + jst_returnAndRefundItem (绝大多数店铺) +// · jst_qmcust_refundlist + jst_qmcust_refundlist_item (企业微信/千牛客服单) +// 签收单号 = 主表 `l_id`(退回那一票的运单号,实测常与原发货单号相同) +// 实退数量 = 明细表 `r_qty`(申请数量是 `qty`,**只能看 r_qty**: +// 拒收未签收的 r_qty=0、仅退款/补发的 r_qty 恒为 0) +// +// 实测(2026-10-23 批次,36 个异常包裹)命中 3 单,全是「拒收退货 + r_qty=1 + +// l_id 非空」,和业务描述完全一致;同期 9859198/9871523 虽然 l_id 非空但 +// r_qty=0(货还在路上),正确地没被剔。 +const INTERCEPT_AFTERSALE_SOURCES = Object.freeze([ + { + key: 'returnAndRefund', + header: 'dbo.jst_returnAndRefundInfo', + item: 'dbo.jst_returnAndRefundItem', + // 主表 as_id=bigint、明细 as_id=int → 显式收窄,避免隐式转换报错 + headerAsId: 'TRY_CONVERT(int, h.as_id)', + label: '售后单', + }, + { + key: 'qmcustRefund', + header: 'dbo.jst_qmcust_refundlist', + item: 'dbo.jst_qmcust_refundlist_item', + // 两张表 as_id 都是 nvarchar,直接等值(别传数字 IN,会把列转 int 撞脏数据) + headerAsId: 'h.as_id', + label: '客服售后单', + }, +]); + // 关键词按长度倒序 —— 「最长匹配优先」,品牌仓/唯品仓不会被主仓抢走 const SORTED_KEYWORDS = CENTERS .flatMap((center) => center.keywords.map((kw) => ({ kw, center }))) @@ -370,6 +407,7 @@ module.exports = { EXCLUDED_ORDER_STATUS_KEYWORDS, EXCLUDED_ORDER_LABEL_KEYWORDS, EXCLUDED_ORDER_TYPE_LABELS, + INTERCEPT_AFTERSALE_SOURCES, EXCEPTION_TYPES, TYPE_MAP, TRANSIT_INFO_KEYWORDS, diff --git a/backend/src/services/warehouseLogisticsExceptionService.js b/backend/src/services/warehouseLogisticsExceptionService.js index 74303b4..9707483 100644 --- a/backend/src/services/warehouseLogisticsExceptionService.js +++ b/backend/src/services/warehouseLogisticsExceptionService.js @@ -148,6 +148,72 @@ async function loadShippedPackages(batch) { return rows || []; } +// 售后拦截判定:一次 IN 多少单 + 查询超时。 +// 这两张售后表在 rpt 副本里**都是堆表(零索引)**,`o_id IN (...)` 只能全表扫, +// 实测 125 万行的 returnAndRefund 4.5s、176 万行的 qmcust 8.5s —— 所以: +// · 只查「本来就会报异常」的 oId(正常单不查,量级从 9800 降到几十); +// · 两套店铺表**并发**查,总耗时取较慢的那条(~8.5s),而不是相加的 13s; +// · 结果进 5 分钟结果缓存,页面自动刷新时基本命中缓存。 +const INTERCEPT_QUERY_BATCH = 500; +const INTERCEPT_QUERY_TIMEOUT_MS = 240000; + +/** + * 找出「现场拦截、货已被售后仓签收」的订单(业务 2026-10-23 口径)。 + * + * 判据 = 售后单**签收单号 `l_id` 非空** 且 明细**实退数量 `r_qty` 合计 > 0**。 + * 两张店铺售后表互斥,必须都查(见 registry.INTERCEPT_AFTERSALE_SOURCES)。 + * + * @param {Array} rows 状态表原始行 + * @returns {Promise>} 内部单号字符串集合 + */ +async function loadInterceptedOrders(rows) { + const ids = []; + const seen = new Set(); + for (const row of rows) { + // 只关心本来会被判成异常的单:正常单(已签收/运输中)剔不剔都一样 + if (!classify(row)) continue; + const id = Number(row.inner_order_id); + if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue; + seen.add(id); + ids.push(id); + } + const intercepted = new Set(); + if (!ids.length) return intercepted; + + const tasks = []; + for (let i = 0; i < ids.length; i += INTERCEPT_QUERY_BATCH) { + const oIds = ids.slice(i, i + INTERCEPT_QUERY_BATCH).join(','); + for (const source of registry.INTERCEPT_AFTERSALE_SOURCES) { + tasks.push((async () => { + const hits = await cloudDatabase.query( + `SELECT h.o_id + FROM ${source.header} h WITH (NOLOCK) + JOIN ${source.item} it WITH (NOLOCK) ON it.as_id = ${source.headerAsId} + WHERE h.o_id IN (${oIds}) + AND ISNULL(h.l_id, '') <> '' + GROUP BY h.o_id + HAVING SUM(ISNULL(it.r_qty, 0)) > 0`, + {}, + { timeoutMs: INTERCEPT_QUERY_TIMEOUT_MS } + ); + return hits || []; + })().catch((err) => { + // 售后表跟不上不该让整页挂掉:退化成「不剔除」,页脚会显示 0 单,口径说明仍在 + console.warn(`[WLE] 售后拦截判定失败(${source.key}):${err.message}`); + return []; + })); + } + } + + const results = await Promise.all(tasks); + for (const list of results) { + for (const r of list) { + if (r && r.o_id !== null && r.o_id !== undefined) intercepted.add(String(r.o_id)); + } + } + return intercepted; +} + function emptySummary() { return { shippedOrders: 0, @@ -220,6 +286,9 @@ async function getLogisticsExceptions(options = {}) { nonRealPackages: 0, nonRealOrders: 0, nonRealReasons: {}, + interceptPackages: 0, + interceptOrders: 0, + interceptReasons: {}, }, orders: [], detailLimit: limit, @@ -243,6 +312,10 @@ async function getLogisticsExceptions(options = {}) { const rows = await loadShippedPackages(batch); + // ── 现场拦截(售后已签收)—— 只对会报异常的单去售后表核对 ───────────── + // 必须放在主循环之前:售后表要批量 IN 查,逐单查会把页面拖到几分钟。 + const interceptedOrders = await loadInterceptedOrders(rows); + // ── 归属主仓 + 去重 ────────────────────────────────────────────────── const centerStat = new Map(); for (const center of registry.CENTERS) { @@ -266,6 +339,7 @@ async function getLogisticsExceptions(options = {}) { const skippedByCarrier = { packages: 0, orders: new Set(), reasons: {} }; const skippedByVoid = { packages: 0, orders: new Set(), reasons: {} }; const skippedByType = { packages: 0, orders: new Set(), reasons: {} }; + const skippedByIntercept = { packages: 0, orders: new Set(), reasons: {} }; for (const row of rows) { const queryTime = formatDateTime(row.query_time); @@ -318,11 +392,23 @@ async function getLogisticsExceptions(options = {}) { const type = classify(row); if (!type) continue; + const typeMeta = registry.TYPE_MAP.get(type) || { label: type, tone: 'default' }; + + // ④ 现场拦截:售后单有签收单号 + 实退数量 > 0 —— 货已被售后仓签收, + // 物流环节其实结束了(派送时被拦下、原车拉回),不算「卡在网点发不出去」。 + // ⚠️ 与上面 a/b/c 三类不同:这里**保留发货分母**(货确实发出去了), + // 只从异常清单里摘掉,异常率才不会被错误地放大。 + if (interceptedOrders.has(oId)) { + skippedByIntercept.packages += 1; + skippedByIntercept.orders.add(oId); + skippedByIntercept.reasons[typeMeta.label] = (skippedByIntercept.reasons[typeMeta.label] || 0) + 1; + continue; + } + const sendAt = formatDateTime(row.send_time); const logisticAt = formatDateTime(row.logistic_time); const pendingMinutes = diffMinutes(sendAt, batch.deadline); const staleMinutes = diffMinutes(logisticAt, batch.deadline); - const typeMeta = registry.TYPE_MAP.get(type) || { label: type, tone: 'default' }; stat.exceptionOrders.add(oId); stat.byType[type] = (stat.byType[type] || 0) + 1; @@ -420,6 +506,10 @@ async function getLogisticsExceptions(options = {}) { nonRealPackages: skippedByType.packages, nonRealOrders: skippedByType.orders.size, nonRealReasons: skippedByType.reasons, + // 现场拦截(售后已签收)—— 只从异常里摘掉,仍计入发货分母 + interceptPackages: skippedByIntercept.packages, + interceptOrders: skippedByIntercept.orders.size, + interceptReasons: skippedByIntercept.reasons, }, orders: exceptions.slice(0, limit), detailLimit: limit, diff --git a/backend/test/warehouseLogisticsException.test.js b/backend/test/warehouseLogisticsException.test.js index d197f60..5b261f6 100644 --- a/backend/test/warehouseLogisticsException.test.js +++ b/backend/test/warehouseLogisticsException.test.js @@ -144,12 +144,16 @@ test('状态为空时按运单号区分:有号=未揽收,无号=无物流单 // ── ④ 服务层(打桩数据库)────────────────────────────────────────────── const dbPath = require.resolve('../src/config/cloudDatabase'); -function stubDb(rows) { +function stubDb(rows, options = {}) { const calls = { times: 0, sql: [], params: [] }; const handler = async (sql, params) => { calls.times += 1; calls.sql.push(String(sql)); calls.params.push(params || {}); + // 售后拦截判定会额外查两张售后表 —— 按表名分流,别把物流行当成售后行 + if (/jst_returnAndRefundInfo|jst_qmcust_refundlist/u.test(String(sql))) { + return options.interceptRows || []; + } return rows; }; require.cache[dbPath] = { @@ -206,9 +210,9 @@ const rows = [ send_time: '2026-10-20 11:00:00' }), ]; -async function withStub(fn, stubRows = rows) { +async function withStub(fn, stubRows = rows, options = {}) { const realDb = require.cache[dbPath]; - const calls = stubDb(stubRows); + const calls = stubDb(stubRows, options); delete require.cache[svcPath]; delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')]; try { @@ -276,18 +280,20 @@ test('未到 09:00 的批次返回 notStarted,不查库也不给数字', async test('结果缓存生效,force=1 绕过缓存重查', async () => { await withStub(async (svc, calls) => { + // 只数主查询:售后拦截判定会额外查两张售后表,别把它们的次数算进来 + const mainQueries = () => calls.sql.filter((s) => /jst_order_express_details/u.test(s)).length; const opts = { date: '2026-10-21', now: at(21, 10, 0) }; const first = await svc.getLogisticsExceptions(opts); assert.equal(first.cached, false); - assert.equal(calls.times, 1); + assert.equal(mainQueries(), 1); const second = await svc.getLogisticsExceptions(opts); assert.equal(second.cached, true); - assert.equal(calls.times, 1, '第二次应命中缓存'); + assert.equal(mainQueries(), 1, '第二次应命中缓存'); const third = await svc.getLogisticsExceptions({ ...opts, force: 1 }); assert.equal(third.cached, false); - assert.equal(calls.times, 2, 'force=1 必须真查库'); + assert.equal(mainQueries(), 2, 'force=1 必须真查库'); }); }); @@ -322,17 +328,18 @@ test('导出:放开明细上限、返回全量异常(不受列表 2000 上 test('缓存按 limit 分键:页面列表(1000)与导出(5000)互不踢掉', async () => { await withStub(async (svc, calls) => { + const mainQueries = () => calls.sql.filter((s) => /jst_order_express_details/u.test(s)).length; const opts = { date: '2026-10-21', now: at(21, 10, 0) }; await svc.getLogisticsExceptions({ ...opts, limit: 1000 }); - assert.equal(calls.times, 1); + assert.equal(mainQueries(), 1); // 导出用另一个 limit —— 不能把页面那份缓存顶掉 await svc.getLogisticsExceptionExportRows(opts); - assert.equal(calls.times, 2, '导出是首次查,应真查库'); + assert.equal(mainQueries(), 2, '导出是首次查,应真查库'); const listAgain = await svc.getLogisticsExceptions({ ...opts, limit: 1000 }); assert.equal(listAgain.cached, true, '页面那份缓存应还在'); - assert.equal(calls.times, 2, '列表应命中缓存,不再查库'); + assert.equal(mainQueries(), 2, '列表应命中缓存,不再查库'); }); }); @@ -523,5 +530,87 @@ test('未到 09:00 也返回剔除口径,前端不用判空', async () => { assert.ok(Array.isArray(data.exclusions.carriers)); assert.ok(data.exclusions.carriers.includes('唯品顺丰')); assert.equal(data.exclusions.carrierOrders, 0); + assert.equal(data.exclusions.interceptOrders, 0, '未开跑也要给出拦截剔除字段'); }); }); + +// ── ⑦ 现场拦截(售后已签收)不算转运异常(业务 2026-10-23)───────────── +test('现场拦截判据:签收单号非空 + 实退数量>0 才剔,货还在路上的不剔', async () => { + const mixed = [ + // 30:已揽收未中转 → 售后单有签收单号 + 实退 1 件 → 已签收,必须剔 + mk({ inner_order_id: 30, so_id: 'SO-30', logistic_status_name: '已揽收', + logistic_time: '2026-10-20 02:00:00', send_time: '2026-10-20 01:00:00', + last_info: '中通快递 已收取快件' }), + // 31:同形态但售后 r_qty=0(货还在路上)→ **不能剔**,仍是真异常 + mk({ inner_order_id: 31, so_id: 'SO-31', logistic_status_name: '已揽收', + logistic_time: '2026-10-20 02:00:00', send_time: '2026-10-20 01:00:00', + last_info: '中通快递 已收取快件' }), + // 32:未揽收 + 售后已签收 → 同样剔(不挑异常类型) + mk({ inner_order_id: 32, so_id: 'SO-32', logistic_status_name: '未揽收', logistic_time: null }), + // 33:正常已签收 → 本来就不算异常,不受影响 + mk({ inner_order_id: 33, so_id: 'SO-33' }), + ]; + await withStub(async (svc, calls) => { + const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 100 }); + + // 30 / 32 被剔;31 仍是异常;33 本来正常 + assert.deepEqual(data.orders.map((o) => o.oId), ['31']); + assert.equal(data.summary.exceptionOrders, 1); + // 🔥 发货分母不能掉:货确实发出去了,只是物流环节已结束 + assert.equal(data.summary.shippedOrders, 4, '拦截单仍算发货分母,否则异常率被放大'); + assert.equal(data.summary.exceptionRate, 25, '1 / 4'); + + assert.equal(data.exclusions.interceptPackages, 2); + assert.equal(data.exclusions.interceptOrders, 2); + assert.equal(data.exclusions.interceptReasons['已揽收未中转'], 1); + assert.equal(data.exclusions.interceptReasons['未揽收'], 1); + + // 判据 SQL:两张店铺表都要查、都要 join 明细、都必须卡 l_id 与 r_qty + const asSql = calls.sql.filter((s) => /jst_returnAndRefundInfo|jst_qmcust_refundlist/u.test(s)); + assert.equal(asSql.length, 2, `售后表要查两套(店铺互斥),实际 ${asSql.length}`); + assert.ok(asSql.some((s) => /jst_returnAndRefundItem/u.test(s)), '必须关联明细表拿实退数量'); + assert.ok(asSql.some((s) => /jst_qmcust_refundlist_item/u.test(s)), '客服售后单也要关联明细表'); + for (const s of asSql) { + assert.match(s, /l_id[\s\S]*<> ''/u, '必须有「签收单号非空」条件'); + assert.match(s, /SUM\(ISNULL\(it\.r_qty, 0\)\) > 0/u, '必须有「实退数量 > 0」条件'); + assert.doesNotMatch(s, /it\.qty[\s\S]*> 0/u, '不能拿申请数量 qty 当实退数量'); + } + }, mixed, { interceptRows: [{ o_id: 30 }, { o_id: 32 }] }); +}); + +test('售后表查询失败时降级:不剔单、不炸页面', async () => { + const mixed = [ + mk({ inner_order_id: 40, so_id: 'SO-40', logistic_status_name: '未揽收', logistic_time: null }), + ]; + const realDb = require.cache[dbPath]; + const calls = { times: 0, sql: [] }; + require.cache[dbPath] = { + id: dbPath, + filename: dbPath, + loaded: true, + exports: { + query: async (sql) => { + calls.sql.push(String(sql)); + if (/jst_returnAndRefundInfo|jst_qmcust_refundlist/u.test(String(sql))) { + throw new Error('售后表超时'); + } + return mixed; + }, + querySlow: async () => mixed, + }, + }; + try { + delete require.cache[svcPath]; + delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')]; + const svc = require(svcPath); + svc.clearLogisticsExceptionCache(); + const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 100 }); + assert.equal(data.summary.exceptionOrders, 1, '售后表挂了就该照常报异常,别把异常吞掉'); + assert.equal(data.exclusions.interceptOrders, 0); + } finally { + if (realDb) require.cache[dbPath] = realDb; + else delete require.cache[dbPath]; + delete require.cache[svcPath]; + delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')]; + } +}); diff --git a/frontend/src/pages/WarehouseLogisticsException/index.jsx b/frontend/src/pages/WarehouseLogisticsException/index.jsx index 65e0726..8120995 100644 --- a/frontend/src/pages/WarehouseLogisticsException/index.jsx +++ b/frontend/src/pages/WarehouseLogisticsException/index.jsx @@ -779,7 +779,8 @@ function WarehouseLogisticsException() { ? <>(含标签「{exclusions.orderLabelKeywords.join('」「')}」) : null} ;{exclusions?.orderTypeLabels?.length ? exclusions.orderTypeLabels.join(' / ') : '刷单 / 信誉单'} - (按订单标签「刷单」「转空包」识别)—— 都不是真实发货,不用跟物流。 + (按订单标签「刷单」「转空包」识别)—— 都不是真实发货,不用跟物流; + 现场拦截(售后单有签收单号且实退数量 > 0,货已被售后仓签收)—— 物流环节已结束,不算异常。 {exclusions && (exclusions.carrierOrders > 0 || exclusions.voidedOrders > 0 || exclusions.nonRealOrders > 0) ? ( <> @@ -800,6 +801,20 @@ function WarehouseLogisticsException() { 剔除的单既不算异常也不算发货分母,所以异常率不会被稀释。 ) : null} + {exclusions?.interceptOrders > 0 ? ( + <> + {' '}另有 {exclusions.interceptOrders} 单属现场拦截(售后已签收) + {exclusions.interceptReasons && Object.keys(exclusions.interceptReasons).length ? ( + <>(原本会算「 + {Object.entries(exclusions.interceptReasons) + .sort((a, b) => b[1] - a[1]) + .map(([k, v]) => `${k} ${v} 单`) + .join(' / ')} + 」) + ) : null} + —— 货是被快递当场拦下原车拉回、由售后仓签收的,仍计入发货分母、只是不再报异常。 + + ) : null}
页面按当前物流快照计算:核验时刻之后才更新的轨迹(比如 9 点后才揽收、才签收)会让对应异常自动消失, 所以这里的数字是「到此刻为止还异常的」—— 想留档 09:00 那一刻的结论,需要每天 9 点落一份快照。