diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a67956..bce6e63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ - fix: |Worker| 地址活跃时间保活增加 1 天写入窗口,用户设置和邮箱访问不再重复更新近期活跃地址,降低 D1 写入量(issue #1103) +- feat: |用户系统| 用户绑定地址列表改用服务端分页,并仅在第一页查询总数;用户邮件列表改用 JOIN、删除改用 `EXISTS` 在数据库侧校验地址归属,避免为大用户加载全部绑定地址(issue #1103) + ### Testing - fix: |E2E| 新增近期地址活跃时间不会被用户设置接口重复写入的回归测试 diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md index 1fefdc8..0f0b723 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -16,6 +16,8 @@ - fix: |Worker| Throttle address-activity touches to one write per day so user settings and mailbox access do not repeatedly update recently active addresses, reducing D1 writes (issue #1103) +- feat: |User| Add server-side pagination for bound addresses, with totals queried only on the first page; validate user-mail list ownership with a JOIN and delete ownership with `EXISTS` instead of loading every bound address for large users (issue #1103) + ### Testing - fix: |E2E| Add regression coverage ensuring user settings do not rewrite recent address activity timestamps diff --git a/e2e/tests/api/user-address-pagination.spec.ts b/e2e/tests/api/user-address-pagination.spec.ts new file mode 100644 index 0000000..ac41bd2 --- /dev/null +++ b/e2e/tests/api/user-address-pagination.spec.ts @@ -0,0 +1,170 @@ +import { test, expect, type APIRequestContext } from '@playwright/test'; +import { + WORKER_URL, + createTestAddress, + deleteAddress, + hashPassword, + seedTestMail, +} from '../../fixtures/test-helpers'; + +async function createUser(request: APIRequestContext) { + const email = `address-page-${Date.now()}@test.example.com`; + const password = hashPassword('test-password-123'); + + const registerRes = await request.post(`${WORKER_URL}/user_api/register`, { + data: { email, password }, + }); + expect(registerRes.ok()).toBe(true); + + const loginRes = await request.post(`${WORKER_URL}/user_api/login`, { + data: { email, password }, + }); + expect(loginRes.ok()).toBe(true); + const { jwt } = await loginRes.json(); + const payload = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8')); + return { jwt, userId: payload.user_id as number }; +} + +test.describe('User address pagination', () => { + test('paginates addresses and enforces mail ownership', async ({ request }) => { + const addresses: Awaited>[] = []; + let outsider: Awaited> | undefined; + let originalUserSettings: Record | undefined; + let userId: number | undefined; + + try { + const settingsRes = await request.get(`${WORKER_URL}/admin/user_settings`); + expect(settingsRes.ok()).toBe(true); + originalUserSettings = await settingsRes.json(); + const enableUserRes = await request.post(`${WORKER_URL}/admin/user_settings`, { + data: { + ...originalUserSettings, + enable: true, + enableMailVerify: false, + maxAddressCount: 0, + }, + }); + expect(enableUserRes.ok()).toBe(true); + + const user = await createUser(request); + const userJwt = user.jwt; + userId = user.userId; + addresses.push(...await Promise.all([ + createTestAddress(request, 'user-page-a'), + createTestAddress(request, 'user-page-b'), + createTestAddress(request, 'user-page-c'), + ])); + outsider = await createTestAddress(request, 'user-page-outsider'); + + for (const item of addresses) { + const bindRes = await request.post(`${WORKER_URL}/user_api/bind_address`, { + headers: { + Authorization: `Bearer ${item.jwt}`, + 'x-user-token': userJwt, + }, + }); + expect(bindRes.ok()).toBe(true); + } + + const defaultPageRes = await request.get(`${WORKER_URL}/user_api/bind_address`, { + headers: { 'x-user-token': userJwt }, + }); + expect(defaultPageRes.ok()).toBe(true); + const defaultPage = await defaultPageRes.json(); + expect(defaultPage.count).toBe(3); + expect(defaultPage.results).toHaveLength(3); + + const firstPageRes = await request.get( + `${WORKER_URL}/user_api/bind_address?limit=2&offset=0`, + { headers: { 'x-user-token': userJwt } }, + ); + expect(firstPageRes.ok()).toBe(true); + const firstPage = await firstPageRes.json(); + expect(firstPage.count).toBe(3); + expect(firstPage.results).toHaveLength(2); + expect(firstPage.results[0].mail_count).toBe(0); + expect(firstPage.results[0].send_count).toBe(0); + expect(firstPage.results[0]).toHaveProperty('source_meta'); + expect(firstPage.results[0]).not.toHaveProperty('password'); + + const secondPageRes = await request.get( + `${WORKER_URL}/user_api/bind_address?limit=2&offset=2`, + { headers: { 'x-user-token': userJwt } }, + ); + expect(secondPageRes.ok()).toBe(true); + const secondPage = await secondPageRes.json(); + expect(secondPage.count).toBe(0); + expect(secondPage.results).toHaveLength(1); + + const invalidLimitRes = await request.get( + `${WORKER_URL}/user_api/bind_address?limit=101&offset=0`, + { headers: { 'x-user-token': userJwt } }, + ); + expect(invalidLimitRes.status()).toBe(400); + + const invalidOffsetRes = await request.get( + `${WORKER_URL}/user_api/bind_address?limit=20&offset=-1`, + { headers: { 'x-user-token': userJwt } }, + ); + expect(invalidOffsetRes.status()).toBe(400); + + await seedTestMail(request, addresses[0].address, { subject: 'Bound mail' }); + await seedTestMail(request, outsider.address, { subject: 'Outsider mail' }); + + const userMailsRes = await request.get(`${WORKER_URL}/user_api/mails?limit=20&offset=0`, { + headers: { 'x-user-token': userJwt }, + }); + expect(userMailsRes.ok()).toBe(true); + const userMails = await userMailsRes.json(); + expect(userMails.count).toBe(1); + expect(userMails.results[0].address).toBe(addresses[0].address); + + const filteredOutsiderMailsRes = await request.get( + `${WORKER_URL}/user_api/mails?limit=20&offset=0&address=${encodeURIComponent(outsider.address)}`, + { headers: { 'x-user-token': userJwt } }, + ); + expect(filteredOutsiderMailsRes.ok()).toBe(true); + const filteredOutsiderMails = await filteredOutsiderMailsRes.json(); + expect(filteredOutsiderMails.count).toBe(0); + expect(filteredOutsiderMails.results).toHaveLength(0); + + const outsiderMailsRes = await request.get(`${WORKER_URL}/api/mails?limit=20&offset=0`, { + headers: { Authorization: `Bearer ${outsider.jwt}` }, + }); + expect(outsiderMailsRes.ok()).toBe(true); + const outsiderMails = await outsiderMailsRes.json(); + expect(outsiderMails.results).toHaveLength(1); + + const forbiddenDeleteRes = await request.delete( + `${WORKER_URL}/user_api/mails/${outsiderMails.results[0].id}`, + { headers: { 'x-user-token': userJwt } }, + ); + expect(forbiddenDeleteRes.ok()).toBe(true); + + const outsiderAfterRes = await request.get(`${WORKER_URL}/api/mails?limit=20&offset=0`, { + headers: { Authorization: `Bearer ${outsider.jwt}` }, + }); + expect(outsiderAfterRes.ok()).toBe(true); + const outsiderAfter = await outsiderAfterRes.json(); + expect(outsiderAfter.results).toHaveLength(1); + } finally { + try { + await Promise.allSettled( + [...addresses, outsider].filter((item) => item !== undefined) + .map((item) => deleteAddress(request, item.jwt)), + ); + if (userId !== undefined) { + const deleteUserRes = await request.delete(`${WORKER_URL}/admin/users/${userId}`); + expect(deleteUserRes.ok()).toBe(true); + } + } finally { + if (originalUserSettings) { + const restoreSettingsRes = await request.post(`${WORKER_URL}/admin/user_settings`, { + data: originalUserSettings, + }); + expect(restoreSettingsRes.ok()).toBe(true); + } + } + } + }); +}); diff --git a/e2e/tests/browser/user-address-pagination.spec.ts b/e2e/tests/browser/user-address-pagination.spec.ts new file mode 100644 index 0000000..d8d67f6 --- /dev/null +++ b/e2e/tests/browser/user-address-pagination.spec.ts @@ -0,0 +1,135 @@ +import { expect, request as apiRequest, test } from '@playwright/test'; +import type { APIRequestContext } from '@playwright/test'; + +import { + FRONTEND_URL, + WORKER_URL, + createTestAddress, + deleteAddress, + hashPassword, +} from '../../fixtures/test-helpers'; + +async function saveUserSettings(request: APIRequestContext, settings: Record) { + const response = await request.post(`${WORKER_URL}/admin/user_settings`, { data: settings }); + expect(response.ok()).toBe(true); +} + +async function createUser(request: APIRequestContext) { + const email = `address-browser-${Date.now()}@test.example.com`; + const password = hashPassword('test-password-123'); + const registerResponse = await request.post(`${WORKER_URL}/user_api/register`, { + data: { email, password }, + }); + expect(registerResponse.ok()).toBe(true); + + const loginResponse = await request.post(`${WORKER_URL}/user_api/login`, { + data: { email, password }, + }); + expect(loginResponse.ok()).toBe(true); + const { jwt } = await loginResponse.json(); + const payload = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8')); + return { email, jwt, userId: payload.user_id as number }; +} + +async function bindAddress(request: APIRequestContext, userJwt: string, addressJwt: string) { + const response = await request.post(`${WORKER_URL}/user_api/bind_address`, { + headers: { + Authorization: `Bearer ${addressJwt}`, + 'x-user-token': userJwt, + }, + }); + expect(response.ok()).toBe(true); +} + +test.describe('User address pagination browser flow', () => { + test('paginates addresses and filters mail', async ({ page }) => { + test.setTimeout(120_000); + + const request = await apiRequest.newContext(); + const createdAddresses: Awaited>[] = []; + let originalUserSettings: Record | undefined; + let userId: number | undefined; + + try { + const settingsResponse = await request.get(`${WORKER_URL}/admin/user_settings`); + expect(settingsResponse.ok()).toBe(true); + originalUserSettings = await settingsResponse.json(); + await saveUserSettings(request, { + ...originalUserSettings, + enable: true, + enableMailVerify: false, + maxAddressCount: 0, + }); + + const user = await createUser(request); + userId = user.userId; + + for (let index = 0; index < 21; index += 1) { + const address = await createTestAddress(request, `browser-page-${index}-`); + createdAddresses.push(address); + await bindAddress(request, user.jwt, address.jwt); + } + const defaultAddressPageResponse = await request.get( + `${WORKER_URL}/user_api/bind_address`, + { headers: { 'x-user-token': user.jwt } }, + ); + expect(defaultAddressPageResponse.ok()).toBe(true); + const defaultAddressPage = await defaultAddressPageResponse.json(); + expect(defaultAddressPage.count).toBe(21); + expect(defaultAddressPage.results).toHaveLength(20); + + await page.goto(`${FRONTEND_URL}/en/`); + await page.evaluate((userJwt) => { + localStorage.setItem('userJwt', userJwt); + }, user.jwt); + await page.goto(`${FRONTEND_URL}/en/user`); + + await expect(page.getByText(user.email)).toBeVisible({ timeout: 15_000 }); + const pagination = page.locator('.n-pagination').first(); + const addressRows = page.locator('.n-data-table-tbody .n-data-table-tr'); + await expect(pagination).toContainText(/Total:\s*21/); + await expect(addressRows).toHaveCount(20); + + await pagination.locator('.n-pagination-item').filter({ hasText: /^2$/ }).click(); + await expect(addressRows).toHaveCount(1); + + const selectedAddress = createdAddresses[20]; + + const initialMailboxAddressesResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + return url.pathname === '/user_api/bind_address' + && url.searchParams.get('limit') === '100'; + }); + await page.getByText('Mail Box', { exact: true }).click(); + const initialMailboxResponse = await initialMailboxAddressesResponse; + expect(initialMailboxResponse.ok()).toBe(true); + const mailboxAddressSelect = page.locator('.n-input-group .n-select').first(); + await mailboxAddressSelect.click(); + + const mailboxOptions = page.locator('.n-base-select-menu:visible'); + await expect(mailboxOptions).toContainText(selectedAddress.address); + const filteredMailResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + return url.pathname === '/user_api/mails' + && url.searchParams.get('address') === selectedAddress.address; + }); + await mailboxOptions.getByText(selectedAddress.address, { exact: true }).click(); + expect((await filteredMailResponse).ok()).toBe(true); + } finally { + try { + try { + await Promise.allSettled(createdAddresses.map((address) => deleteAddress(request, address.jwt))); + if (userId !== undefined) { + await request.delete(`${WORKER_URL}/admin/users/${userId}`); + } + } finally { + if (originalUserSettings) { + await saveUserSettings(request, originalUserSettings); + } + } + } finally { + await request.dispose(); + } + } + }); +}); diff --git a/frontend/src/components/AddressSelect.vue b/frontend/src/components/AddressSelect.vue index b890b41..faa6583 100644 --- a/frontend/src/components/AddressSelect.vue +++ b/frontend/src/components/AddressSelect.vue @@ -97,7 +97,7 @@ const buildLocalOptions = (excludeAddresses = new Set()) => { const buildUserOptions = async () => { const children = []; try { - const { results } = await api.fetch(`/user_api/bind_address`); + const { results } = await api.fetch(`/user_api/bind_address?limit=100&offset=0`); for (const row of results || []) { const address = row.address || row.name; if (!address) continue; diff --git a/frontend/src/i18n/message-registry.ts b/frontend/src/i18n/message-registry.ts index 20333d1..2f52dfb 100644 --- a/frontend/src/i18n/message-registry.ts +++ b/frontend/src/i18n/message-registry.ts @@ -852,6 +852,10 @@ export const MESSAGE_REGISTRY = { "en": "Mail Count", "zh": "邮件数量" }, + "itemCount": { + "en": "Total", + "zh": "总数" + }, "name": { "en": "Name", "zh": "名称" diff --git a/frontend/src/views/user/AddressManagement.vue b/frontend/src/views/user/AddressManagement.vue index 00c8994..1312b73 100644 --- a/frontend/src/views/user/AddressManagement.vue +++ b/frontend/src/views/user/AddressManagement.vue @@ -1,5 +1,5 @@