From 7b79d542a8beae788557fb13f452bca02dcbceb2 Mon Sep 17 00:00:00 2001 From: xcs Date: Wed, 26 Aug 2026 02:09:06 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=BD=AE=201(wecom=20pull):?= =?UTF-8?q?=E6=B8=B8=E6=A0=87=E6=8E=A8=E8=BF=9B=E4=B8=8E=20more=20?= =?UTF-8?q?=E6=94=B9=E7=94=B1=E5=90=8C=E4=B8=80=E5=88=A4=E6=8D=AE=E5=86=B3?= =?UTF-8?q?=E5=AE=9A,=E6=B2=BB=E5=8D=95=E9=A1=B5=20has=5Fmore:0=20?= =?UTF-8?q?=E4=B9=9F=E8=A2=AB=20limit=20=E6=88=AA=E6=96=AD=E6=97=B6?= =?UTF-8?q?=E4=B8=A2=E4=BF=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评审逐字节比对确认 wecom-provider.ts 与 brief 转录一致——问题在计划本身:`exhausted` 只说明 页翻完了,而 LIST_PAGE(100)与 opts.limit 无关,最后一页可能同时 has_more:0 且新信多过本轮预算, 此时按 exhausted 推进游标会把 wanted 截掉的那几封永久丢弃(重叠窗只兜 10 分钟)。 改法:先算 more = !exhausted || fresh.length > wanted.length,再据同一个 more 决定 cursor 是否 推进到窗口上沿——游标与 more 从此不会分裂成两个判据。补两条测试:①单页 has_more:0 但新信 多过 limit 时游标原样退回(改前 RED:游标被错误推到 endTime,改后 GREEN);②-1 连续繁忙到 边界时 get_email_alias 恰被调 4 次(首次 + BUSY_RETRIES 3 次),此前已隐性通过,补断言钉死。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr --- .../mail/__tests__/wecom-provider.test.ts | 26 +++++++++++++++++++ .../mail/internal/adapter/wecom-provider.ts | 10 ++++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/apps/api/src/modules/mail/__tests__/wecom-provider.test.ts b/apps/api/src/modules/mail/__tests__/wecom-provider.test.ts index c1758b4..d3d94f1 100644 --- a/apps/api/src/modules/mail/__tests__/wecom-provider.test.ts +++ b/apps/api/src/modules/mail/__tests__/wecom-provider.test.ts @@ -60,6 +60,17 @@ describe('token', () => { createWecomProvider({ fetch: scripted({ '/cgi-bin/gettoken': () => ({ errcode: 40001, errmsg: 'invalid credential' }) }).fetch }).pull(account, wecomCursor(1), { limit: 5, now, backfillFrom: null, known }), ).rejects.toBeInstanceOf(WecomApiError); }); + + it('-1 连击到边界不再重试(首次 + 3 次重试恰好 4 次)', async () => { + const { fetch, calls } = scripted({ + '/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }), + 'get_email_alias': () => ({ errcode: -1, errmsg: 'system busy' }), + }); + const report = await createWecomProvider({ fetch }).probe(account); + expect(report.receive.ok).toBe(false); + expect(report.receive.error).toContain('-1'); + expect(calls.filter((c) => c.url.includes('get_email_alias'))).toHaveLength(4); + }); }); describe('probe / anchor', () => { @@ -100,6 +111,21 @@ describe('pull', () => { expect(result.more).toBe(false); }); + it('单页翻完但新信多过 limit —— 游标不许推进(has_more:0 也可能被 limit 截断)', async () => { + const { fetch } = scripted({ + '/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }), + 'get_mail_list': () => ({ errcode: 0, next_cursor: '', has_more: 0, mail_list: [{ mail_id: 'm1' }, { mail_id: 'm2' }, { mail_id: 'm3' }] }), + 'read_mail': () => ({ errcode: 0, mail_data: EML }), + }); + const cursor = wecomCursor(1); + const result = await createWecomProvider({ fetch }).pull(account, cursor, { limit: 2, now, backfillFrom: null, known }); + expect(result.messages.map((m) => m.providerRef)).toEqual(['m1', 'm2']); + expect(result.more).toBe(true); + // 页翻完了(has_more:0)不等于「这一窗的新信全吃完了」—— LIST_PAGE(100)与 opts.limit 无关, + // 最后一页仍可能被 limit 截断;截断时游标必须原样退回,否则 m3 就永久丢了(重叠窗只兜 10 分钟)。 + expect(result.cursor).toEqual(cursor); + }); + it('取满 limit 就停,more=true', async () => { const { fetch } = scripted({ '/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }), diff --git a/apps/api/src/modules/mail/internal/adapter/wecom-provider.ts b/apps/api/src/modules/mail/internal/adapter/wecom-provider.ts index 1336c4f..abd6577 100644 --- a/apps/api/src/modules/mail/internal/adapter/wecom-provider.ts +++ b/apps/api/src/modules/mail/internal/adapter/wecom-provider.ts @@ -158,11 +158,13 @@ export function createWecomProvider(deps: { fetch?: FetchLike; now?: () => numbe messages.push({ providerRef: mailId, eml: emlBytesOf(data), receivedAt: opts.now }); } /* - * 游标**只在把这个窗口翻完时**才推到窗口上沿;被 limit 截断时原样退回旧游标 —— - * 否则没读到的那几封就被跳过去了(重叠窗只兜 10 分钟,兜不住一次积压)。 - * 退回不会卡住:已入库的下一轮被 `known` 滤掉,每轮照旧往前吃一批。 + * 游标推进与 `more` **必须由同一个判据决定**:只有「这一窗的新信全吃完了」才推到窗口上沿。 + * `exhausted` 只说明**页翻完了** —— 最后一页可能同时 `has_more:0` 且新信多过本轮预算 + * (`LIST_PAGE` 是 100,与 `opts.limit` 无关),那时 `wanted` 截掉的那些若跟着推进游标就**永久丢**了 + * (重叠窗只兜 10 分钟)。退回不会卡住:已入库的下一轮被 `known` 滤掉,每轮照旧往前吃一批。 */ - return { messages, cursor: exhausted ? wecomCursor(endTime) : cursor, more: !exhausted || fresh.length > wanted.length }; + const more = !exhausted || fresh.length > wanted.length; + return { messages, cursor: more ? cursor : wecomCursor(endTime), more }; }, async send(account, message) {