fix: 修复账号推送异步竞态与分页边界

This commit is contained in:
sunlei 2026-07-24 18:25:03 +08:00
parent d65be70048
commit c67fc1b3de
4 changed files with 151 additions and 4 deletions

View File

@ -283,6 +283,15 @@ function createBinding(): QqbotMessagePushApi.QqbotMessagePublishBindingView {
};
}
/** Creates one manually resolved promise for async session-race assertions. */
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((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();

View File

@ -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<AccountMessagePushFormValues>();
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 {

View File

@ -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();

View File

@ -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<QqbotMessagePushApi.PageResult<Row>>,
): Promise<Row[]> {
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;
}