计划再修:游标推进与 more 必须同一个判据 —— 「页翻完」不等于「新信吃完」

上一次修的是「多页时截断不许推进游标」,但漏了一形:最后一页可能同时 has_more:0
且新信多过本轮预算(LIST_PAGE 是 100,与 perTick 的 50 无关)—— 那时 exhausted 为真、
游标照推,而 wanted 截掉的那几封从此永久丢(重叠窗只兜 10 分钟)。

评审的话说得准:more 的判据是对的,cursor 的不是,两者本该是同一个。
改成 `const more = !exhausted || fresh.length > wanted.length; cursor: more ? cursor : 推进`。
补两条测试:单页 has_more:0 但新信超预算(游标不许动)、-1 连击到边界不再重试。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr
This commit is contained in:
xcs
2026-08-26 02:05:39 +08:00
co-authored by Claude Fable 5
parent 5bd92f2e93
commit 58358210e2
+33 -4
View File
@@ -5070,6 +5070,18 @@ describe('token', () => {
expect(calls[0]?.url).toContain('corpsecret=cs-1'); expect(calls[0]?.url).toContain('corpsecret=cs-1');
}); });
it('**-1 连着来到边界就不再重试**:第 4 次仍是 -1 即抛', 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');
// BUSY_RETRIES = 3 —— 首次 + 3 次重试 = 4 次调用,然后放弃
expect(calls.filter((c) => c.url.includes('get_email_alias'))).toHaveLength(4);
});
it('-1 系统繁忙重试 ≤3 次;别的错误码抛 WecomApiError 带 api 名', async () => { it('-1 系统繁忙重试 ≤3 次;别的错误码抛 WecomApiError 带 api 名', async () => {
let busy = 2; let busy = 2;
const { fetch } = scripted({ const { fetch } = scripted({
@@ -5125,6 +5137,21 @@ describe('pull', () => {
expect(result.more).toBe(false); expect(result.more).toBe(false);
}); });
it('**单页翻完但新信多过 limit**:游标不许推进(否则截掉的那些永久丢)', async () => {
const { fetch } = scripted({
'/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }),
// has_more:0 —— 页是翻完了,可这一页的新信比本轮预算多
'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);
// 「页翻完了」≠「新信吃完了」—— 游标跟着 exhausted 走就会把 m3 跳过去
expect(result.cursor).toEqual(cursor);
});
it('取满 limit 就停,more=true', async () => { it('取满 limit 就停,more=true', async () => {
const { fetch } = scripted({ const { fetch } = scripted({
'/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }), '/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }),
@@ -5341,11 +5368,13 @@ export function createWecomProvider(deps: { fetch?: FetchLike; now?: () => numbe
messages.push({ providerRef: mailId, eml: emlBytesOf(data), receivedAt: opts.now }); messages.push({ providerRef: mailId, eml: emlBytesOf(data), receivedAt: opts.now });
} }
/* /*
* 游标**只在把这个窗口翻完时**才推到窗口上沿;被 limit 截断时原样退回旧游标 —— * 游标推进与 `more` **必须由同一个判据决定**:只有「这一窗的新信全吃完了」才推到窗口上沿。
* 否则没读到的那几封就被跳过去了(重叠窗只兜 10 分钟,兜不住一次积压)。 * `exhausted` 只说明**页翻完了** —— 最后一页可能同时 `has_more:0` 且新信多过本轮预算
* 退回不会卡住:已入库的下一轮被 `known` 滤掉,每轮照旧往前吃一批。 * (`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) { async send(account, message) {