From c67fc1b3de687262f0d515f21696505b13472432 Mon Sep 17 00:00:00 2001 From: sunlei Date: Fri, 24 Jul 2026 18:25:03 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E6=8E=A8=E9=80=81=E5=BC=82=E6=AD=A5=E7=AB=9E=E6=80=81=E4=B8=8E?= =?UTF-8?q?=E5=88=86=E9=A1=B5=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AccountMessagePushModal.spec.tsx | 79 +++++++++++++++++++ .../components/AccountMessagePushModal.tsx | 5 +- .../AccountMessagePushPanel.spec.tsx | 52 ++++++++++++ .../components/AccountMessagePushPanel.tsx | 19 ++++- 4 files changed, 151 insertions(+), 4 deletions(-) diff --git a/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushModal.spec.tsx b/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushModal.spec.tsx index 084fd1d..e163f18 100644 --- a/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushModal.spec.tsx +++ b/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushModal.spec.tsx @@ -283,6 +283,15 @@ function createBinding(): QqbotMessagePushApi.QqbotMessagePublishBindingView { }; } +/** Creates one manually resolved promise for async session-race assertions. */ +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + /** Mounts the modal with one implicit publisher and page-owned metadata. */ function mountModal(selfId = '10000000000000001') { return mount(AccountMessagePushModal, { @@ -537,6 +546,76 @@ describe('account message-push modal', () => { expect(mocks.update).not.toHaveBeenCalled(); }); + it('does not persist or close a new-account session after old validation resumes', async () => { + const validation = deferred<{ valid: boolean }>(); + mocks.formApi.validate.mockReturnValueOnce(validation.promise); + const wrapper = mountModal(); + (wrapper.vm as any).openCreate(); + await flushPromises(); + mocks.currentValues = { + enabled: true, + subscriptionId: '20000000000000001', + targets: [{ targetId: '12345', targetType: 'group' }], + templateId: '50000000000000001', + }; + + const staleConfirmation = mocks.modalOptions.onConfirm(); + await flushPromises(); + await wrapper.setProps({ selfId: '10000000000000002' }); + await flushPromises(); + (wrapper.vm as any).openCreate(); + await flushPromises(); + mocks.currentValues = { + enabled: true, + subscriptionId: '20000000000000001', + targets: [{ targetId: '54321', targetType: 'private' }], + templateId: '50000000000000001', + }; + const closeCountBeforeResume = mocks.modalApi.close.mock.calls.length; + + validation.resolve({ valid: true }); + await staleConfirmation; + await flushPromises(); + + expect(mocks.create).not.toHaveBeenCalled(); + expect(mocks.update).not.toHaveBeenCalled(); + expect(mocks.modalApi.lock).not.toHaveBeenCalled(); + expect(mocks.modalApi.close).toHaveBeenCalledTimes(closeCountBeforeResume); + expect(mocks.modalApi.setData).toHaveBeenLastCalledWith( + expect.objectContaining({ selfId: '10000000000000002' }), + ); + expect(wrapper.emitted('saved')).toBeUndefined(); + }); + + it('does not borrow a newer same-account edit identity after validation resumes', async () => { + const validation = deferred<{ valid: boolean }>(); + mocks.formApi.validate.mockReturnValueOnce(validation.promise); + const wrapper = mountModal(); + (wrapper.vm as any).openCreate(); + await flushPromises(); + mocks.currentValues = { + enabled: true, + subscriptionId: '20000000000000001', + targets: [{ targetId: '12345', targetType: 'group' }], + templateId: '50000000000000001', + }; + + const staleConfirmation = mocks.modalOptions.onConfirm(); + await flushPromises(); + (wrapper.vm as any).openEdit(createBinding()); + await flushPromises(); + + validation.resolve({ valid: true }); + await staleConfirmation; + await flushPromises(); + + expect(mocks.create).not.toHaveBeenCalled(); + expect(mocks.update).not.toHaveBeenCalled(); + expect(mocks.modalApi.lock).not.toHaveBeenCalled(); + expect(mocks.modalApi.close).not.toHaveBeenCalled(); + expect(wrapper.emitted('saved')).toBeUndefined(); + }); + it('keeps a rejected session open and closes/emits exactly once on success', async () => { mocks.create.mockRejectedValueOnce(new Error('save failed')); const wrapper = mountModal(); diff --git a/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushModal.tsx b/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushModal.tsx index f6de598..9bf2ca9 100644 --- a/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushModal.tsx +++ b/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushModal.tsx @@ -191,13 +191,16 @@ export default defineComponent({ async function submit() { const revision = sessionRevision; const selfId = sessionSelfId; + const currentEditingId = editingId.value; if (!selfId || selfId !== props.selfId) return; const { valid } = await formApi.validate(); + if (revision !== sessionRevision || selfId !== props.selfId) return; if (!valid) return; const values = await formApi.getValues(); + if (revision !== sessionRevision || selfId !== props.selfId) return; const payload = normalizeBindingPayload(props, values); if (!payload) return; - const currentEditingId = editingId.value; + if (revision !== sessionRevision || selfId !== props.selfId) return; modalApi.lock(); try { diff --git a/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushPanel.spec.tsx b/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushPanel.spec.tsx index a9b8358..507ff8c 100644 --- a/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushPanel.spec.tsx +++ b/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushPanel.spec.tsx @@ -380,6 +380,58 @@ describe('account message-push panel', () => { expect(modal.props('templates')).toHaveLength(101); }); + it('stops after a short page even when the reported total is stale and high', async () => { + mocks.api.getSubscriptions.mockResolvedValue({ + items: [createSubscription()], + total: 100_000, + }); + const wrapper = mountPanel(); + await flushPromises(); + + expect(mocks.api.getSubscriptions).toHaveBeenCalledOnce(); + expect( + wrapper + .getComponent({ name: 'MockAccountMessagePushModal' }) + .props('subscriptions'), + ).toHaveLength(1); + }); + + it('stops safely when a metadata page is empty', async () => { + mocks.api.getSubscriptions.mockResolvedValue({ + items: [], + total: 100_000, + }); + const wrapper = mountPanel(); + await flushPromises(); + + expect(mocks.api.getSubscriptions).toHaveBeenCalledOnce(); + expect( + wrapper + .getComponent({ name: 'MockAccountMessagePushModal' }) + .props('subscriptions'), + ).toEqual([]); + }); + + it.each([ + ['NaN', Number.NaN], + ['positive infinity', Number.POSITIVE_INFINITY], + ['negative', -1], + ])('stops safely for an invalid %s total', async (_label, total) => { + const items = Array.from({ length: 100 }, (_, index) => + createSubscription(`2${String(index).padStart(16, '0')}`), + ); + mocks.api.getSubscriptions.mockResolvedValue({ items, total }); + const wrapper = mountPanel(); + await flushPromises(); + + expect(mocks.api.getSubscriptions).toHaveBeenCalledOnce(); + expect( + wrapper + .getComponent({ name: 'MockAccountMessagePushModal' }) + .props('subscriptions'), + ).toHaveLength(100); + }); + it('uses a revision guard for real selfId changes and ignores stale rows', async () => { const wrapper = mountPanel('10001'); await flushPromises(); diff --git a/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushPanel.tsx b/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushPanel.tsx index b8379bd..6fc0c1f 100644 --- a/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushPanel.tsx +++ b/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushPanel.tsx @@ -47,6 +47,9 @@ const PERMISSIONS = { update: 'QqBot:Account:MessagePush:Update', } as const; +const METADATA_MAX_PAGES = 100; +const METADATA_PAGE_SIZE = 100; + export default defineComponent({ name: 'AccountMessagePushPanel', props: { @@ -272,10 +275,20 @@ export default defineComponent({ }) => Promise>, ): Promise { const rows: Row[] = []; - for (let pageNo = 1; pageNo <= 1000; pageNo += 1) { - const page = await loader({ pageNo, pageSize: 100 }); + for (let pageNo = 1; pageNo <= METADATA_MAX_PAGES; pageNo += 1) { + const page = await loader({ + pageNo, + pageSize: METADATA_PAGE_SIZE, + }); rows.push(...page.items); - if (rows.length >= page.total || page.items.length === 0) break; + if ( + !Number.isFinite(page.total) || + page.total < 0 || + rows.length >= page.total || + page.items.length < METADATA_PAGE_SIZE + ) { + break; + } } return rows; }