From 58b1111e7e57e4b4cc55bf3c310a82bb8bfd1147 Mon Sep 17 00:00:00 2001 From: xcs Date: Wed, 26 Aug 2026 03:49:57 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=88=E5=AE=A1=E4=BF=AE=E5=A4=8D=E8=BD=AE?= =?UTF-8?q?=E8=A1=A5=E6=B5=8B:=E7=BF=BB=E9=A1=B5=E4=B8=8A=E9=99=90?= =?UTF-8?q?=E4=B8=8E=20lowestUid=20=E5=90=84=E9=92=89=E4=B8=80=E6=9D=A1(P1?= =?UTF-8?q?-1=20/=20P1-2=20=E5=8E=9F=E6=9C=AC=E5=8F=AA=E6=94=B9=E4=BA=86?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E6=B2=A1=E6=9C=89=E6=B5=8B=E8=AF=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wecom 翻页上限:服务端一直回 has_more:1 时不再死循环 —— 到顶交出手上这些、more=true、游标原样退回。 单请求 30s 超时护不住它:每一页都是成功返回,超时永远不触发。 - imap 回填锚点的取最小抽成纯函数 `lowestUid`(原先内联在 withImap 里,测不着): 测里拿二十万个 UID 反证 `Math.min(...uids)` 真的抛 RangeError,而 reduce 那一版稳。 - 顺手清掉 mail-service-poll.test.ts 里最后一处指向「修复报告」的注释(语境会丢,P1-6 同款)。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr --- .../mail/__tests__/imap-helpers.test.ts | 13 ++++++++- .../mail/__tests__/mail-service-poll.test.ts | 2 +- .../mail/__tests__/wecom-provider.test.ts | 28 +++++++++++++++++++ .../internal/adapter/imap-smtp-provider.ts | 14 ++++++++-- 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/apps/api/src/modules/mail/__tests__/imap-helpers.test.ts b/apps/api/src/modules/mail/__tests__/imap-helpers.test.ts index 0eb0437..af48327 100644 --- a/apps/api/src/modules/mail/__tests__/imap-helpers.test.ts +++ b/apps/api/src/modules/mail/__tests__/imap-helpers.test.ts @@ -1,6 +1,6 @@ /** IMAP/SMTP 适配器里不碰网络的那几段:设置默认值、内部时间兜底、fetch 结果排序、nodemailer 入参映射 */ import { describe, expect, it } from 'vitest'; -import { imapSettingsOf, internalDateOf, mailOptionsOf, orderFetched } from '../internal/adapter/imap-smtp-provider.ts'; +import { imapSettingsOf, internalDateOf, lowestUid, mailOptionsOf, orderFetched } from '../internal/adapter/imap-smtp-provider.ts'; import { imapCursor } from '../internal/domain/cursor.ts'; const account = { id: 'a', kind: 'imap-smtp' as const, address: 'sales@we.com', displayName: '销售部', settings: {}, secret: { password: 'pw' } }; @@ -42,6 +42,17 @@ describe('orderFetched', () => { }); }); +describe('lowestUid', () => { + it('取最小;空结果回兜底;**十万级不栈溢出**(Math.min(...uids) 会)', () => { + expect(lowestUid([12, 7, 30], 99)).toBe(7); + expect(lowestUid([], 99)).toBe(99); + expect(lowestUid([100], 3)).toBe(3); // 兜底比数组里的都小时,兜底赢(它是 uidNext,只在空集时才该赢) + const many = Array.from({ length: 200_000 }, (_, i) => i + 5); + expect(() => Math.min(...many)).toThrow(RangeError); // 反证:铺参数那一手在这个量级真的炸 + expect(lowestUid(many, Number.MAX_SAFE_INTEGER)).toBe(5); + }); +}); + describe('mailOptionsOf', () => { it('Message-ID / 显示名 / 附件字节全部带上;没 html 就不带 html 键', () => { const options = mailOptionsOf({ diff --git a/apps/api/src/modules/mail/__tests__/mail-service-poll.test.ts b/apps/api/src/modules/mail/__tests__/mail-service-poll.test.ts index 15e013b..00fde95 100644 --- a/apps/api/src/modules/mail/__tests__/mail-service-poll.test.ts +++ b/apps/api/src/modules/mail/__tests__/mail-service-poll.test.ts @@ -120,7 +120,7 @@ describe('拉取', () => { it('拉取抛错:第二次调用才炸(不是从没机会挪动的第一次)—— 游标停在上一轮的成功处、记 last_poll_error(脱敏)、租约还了、下一轮照常', async () => { /* * 这只假 provider 的 pull() 是原子的(一次要么整批给、要么整批不给),且总是把能给的一次给满 —— - * 因此单次 pollNow() 内永远只有一次真实的 pull() 调用(实测验证过,见修复报告)。 + * 因此单次 pollNow() 内永远只有一次真实的 pull() 调用(实测验证过)。 * 要测「中途失败、游标停在最后一封成功处」,失败就不能发生在第一次调用上(那样游标压根没机会挪动, * "没动"是因为它从来没有过机会,不是因为它被稳稳地拦住了)。这里让第一次调用(第一轮 pollNow) * 先成功推进游标,第二次调用(第二轮 pollNow)才炸 —— 借新加的 afterCalls 钉死"哪一次调用开始炸"。 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 24b0119..0c77d6f 100644 --- a/apps/api/src/modules/mail/__tests__/wecom-provider.test.ts +++ b/apps/api/src/modules/mail/__tests__/wecom-provider.test.ts @@ -181,6 +181,34 @@ describe('pull', () => { expect(result.cursor).toEqual(cursor); }); + it('**翻页有上限**:服务端一直回 has_more:1 也不死循环 —— 到顶当本轮吃不完,游标不推进', async () => { + /* + * 单请求 30s 超时护不住这一条:每一页都是**成功返回**,超时永远不触发。 + * 到顶就交出手上这些、报 more=true、游标原样退回,下一轮接着翻,一封都不丢。 + */ + let pages = 0; + const { fetch } = scripted({ + '/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }), + // 每页都说「还有」,而一封新信都不给(极端形:cursor 一直动、内容全被 known 滤掉) + 'get_mail_list': () => { + pages += 1; + return { errcode: 0, next_cursor: `c${pages}`, has_more: 1, mail_list: [{ mail_id: `known-${pages}` }] }; + }, + }); + const cursor = wecomCursor(1); + const result = await createWecomProvider({ fetch }).pull(account, cursor, { + limit: 50, + now, + backfillFrom: null, + known: async (refs) => new Set(refs), // 全都已入库 + }); + expect(pages).toBeLessThanOrEqual(50); + expect(pages).toBeGreaterThan(1); + expect(result.messages).toEqual([]); + expect(result.more).toBe(true); + 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/imap-smtp-provider.ts b/apps/api/src/modules/mail/internal/adapter/imap-smtp-provider.ts index 5159780..a0720e1 100644 --- a/apps/api/src/modules/mail/internal/adapter/imap-smtp-provider.ts +++ b/apps/api/src/modules/mail/internal/adapter/imap-smtp-provider.ts @@ -71,6 +71,17 @@ export function mailOptionsOf(message: OutboundMessage): Mail.Options { }; } +/** + * 回填锚点要的是**最小的那个 UID**。 + * + * `Math.min(...uids)` 在十万级 UID 的信箱上会**栈溢出**(整个数组铺成实参), + * 而回填正是「一次搜出一大把」的那条路。reduce 不铺参数,搜多少都稳。 + * 空结果回 `fallback`(uidNext:什么都没有就锚在下一封之前)。 + */ +export function lowestUid(uids: readonly number[], fallback: number): number { + return uids.reduce((low, uid) => (uid < low ? uid : low), fallback); +} + function transporterOf(account: ProviderAccount) { const { settings, login, password } = imapSettingsOf(account); return nodemailer.createTransport({ @@ -154,8 +165,7 @@ export function createImapSmtpProvider(): MailProvider { const box = mailboxOf(client); if (backfillFrom === null) return imapCursor(box.uidValidity, box.uidNext - 1); const uids = await client.search({ since: backfillFrom }, { uid: true }); - // `Math.min(...uids)` 在十万级 UID 的信箱上会**栈溢出**(展开成十万个实参);reduce 不铺参数 - const first = uids === false || uids.length === 0 ? box.uidNext : uids.reduce((a, b) => (b < a ? b : a), uids[0] ?? box.uidNext); + const first = lowestUid(uids === false ? [] : uids, box.uidNext); return imapCursor(box.uidValidity, first - 1); } finally { lock.release();