mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-09-05 23:47:50 +08:00
perf: paginate user addresses and optimize ownership queries (#1105)
* perf: paginate user addresses and optimize mail ownership queries * docs: document user address pagination * fix: address pagination review feedback * fix: cover user address pagination flows * test: fix user mailbox tab selector * test: stabilize remote address search flow * test: stabilize user address browser flow * fix: preserve address pagination compatibility * refactor: simplify bound address query types * refactor: reuse list query for bound addresses * fix: preserve paginated address totals * fix: preserve bound address helper contracts * fix: require pagination for user addresses * refactor: keep shared pagination behavior unchanged * fix: align pagination docs and tests * fix: clear stale address selections * refactor: simplify user address pagination * refactor: limit user address changes to pagination * test: select a visible mailbox address * fix: preserve bound address response fields
This commit is contained in:
@@ -16,6 +16,8 @@
|
|||||||
|
|
||||||
- fix: |Worker| 地址活跃时间保活增加 1 天写入窗口,用户设置和邮箱访问不再重复更新近期活跃地址,降低 D1 写入量(issue #1103)
|
- fix: |Worker| 地址活跃时间保活增加 1 天写入窗口,用户设置和邮箱访问不再重复更新近期活跃地址,降低 D1 写入量(issue #1103)
|
||||||
|
|
||||||
|
- feat: |用户系统| 用户绑定地址列表改用服务端分页,并仅在第一页查询总数;用户邮件列表改用 JOIN、删除改用 `EXISTS` 在数据库侧校验地址归属,避免为大用户加载全部绑定地址(issue #1103)
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
- fix: |E2E| 新增近期地址活跃时间不会被用户设置接口重复写入的回归测试
|
- fix: |E2E| 新增近期地址活跃时间不会被用户设置接口重复写入的回归测试
|
||||||
|
|||||||
@@ -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)
|
- 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
|
### Testing
|
||||||
|
|
||||||
- fix: |E2E| Add regression coverage ensuring user settings do not rewrite recent address activity timestamps
|
- fix: |E2E| Add regression coverage ensuring user settings do not rewrite recent address activity timestamps
|
||||||
|
|||||||
@@ -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<ReturnType<typeof createTestAddress>>[] = [];
|
||||||
|
let outsider: Awaited<ReturnType<typeof createTestAddress>> | undefined;
|
||||||
|
let originalUserSettings: Record<string, unknown> | 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>) {
|
||||||
|
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<ReturnType<typeof createTestAddress>>[] = [];
|
||||||
|
let originalUserSettings: Record<string, unknown> | 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -97,7 +97,7 @@ const buildLocalOptions = (excludeAddresses = new Set()) => {
|
|||||||
const buildUserOptions = async () => {
|
const buildUserOptions = async () => {
|
||||||
const children = [];
|
const children = [];
|
||||||
try {
|
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 || []) {
|
for (const row of results || []) {
|
||||||
const address = row.address || row.name;
|
const address = row.address || row.name;
|
||||||
if (!address) continue;
|
if (!address) continue;
|
||||||
|
|||||||
@@ -852,6 +852,10 @@ export const MESSAGE_REGISTRY = {
|
|||||||
"en": "Mail Count",
|
"en": "Mail Count",
|
||||||
"zh": "邮件数量"
|
"zh": "邮件数量"
|
||||||
},
|
},
|
||||||
|
"itemCount": {
|
||||||
|
"en": "Total",
|
||||||
|
"zh": "总数"
|
||||||
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"en": "Name",
|
"en": "Name",
|
||||||
"zh": "名称"
|
"zh": "名称"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, h, onMounted } from 'vue';
|
import { ref, h, onMounted, watch } from 'vue';
|
||||||
import { useScopedI18n } from '@/i18n/app'
|
import { useScopedI18n } from '@/i18n/app'
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { NBadge, NPopconfirm, NButton } from 'naive-ui'
|
import { NBadge, NPopconfirm, NButton } from 'naive-ui'
|
||||||
@@ -17,6 +17,9 @@ const router = useRouter()
|
|||||||
const { locale, t } = useScopedI18n('views.user.AddressManagement')
|
const { locale, t } = useScopedI18n('views.user.AddressManagement')
|
||||||
|
|
||||||
const data = ref([])
|
const data = ref([])
|
||||||
|
const count = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
const showTranferAddress = ref(false)
|
const showTranferAddress = ref(false)
|
||||||
const currentAddress = ref("")
|
const currentAddress = ref("")
|
||||||
const currentAddressId = ref(0)
|
const currentAddressId = ref(0)
|
||||||
@@ -46,7 +49,11 @@ const unbindAddress = async (address_id) => {
|
|||||||
body: JSON.stringify({ address_id })
|
body: JSON.stringify({ address_id })
|
||||||
});
|
});
|
||||||
message.success(t('unbindAddress') + " " + t('success'));
|
message.success(t('unbindAddress') + " " + t('success'));
|
||||||
await fetchData();
|
if (page.value === 1) {
|
||||||
|
await fetchData();
|
||||||
|
} else {
|
||||||
|
page.value = 1;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
message.error(error.message || "error");
|
message.error(error.message || "error");
|
||||||
@@ -71,7 +78,11 @@ const transferAddress = async () => {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
message.success(t('transferAddress') + " " + t('success'));
|
message.success(t('transferAddress') + " " + t('success'));
|
||||||
await fetchData();
|
if (page.value === 1) {
|
||||||
|
await fetchData();
|
||||||
|
} else {
|
||||||
|
page.value = 1;
|
||||||
|
}
|
||||||
showTranferAddress.value = false;
|
showTranferAddress.value = false;
|
||||||
currentAddressId.value = 0;
|
currentAddressId.value = 0;
|
||||||
currentAddress.value = "";
|
currentAddress.value = "";
|
||||||
@@ -84,10 +95,17 @@ const transferAddress = async () => {
|
|||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const { results } = await api.fetch(
|
const params = new URLSearchParams({
|
||||||
`/user_api/bind_address`
|
limit: String(pageSize.value),
|
||||||
|
offset: String((page.value - 1) * pageSize.value),
|
||||||
|
});
|
||||||
|
const { results, count: addressCount } = await api.fetch(
|
||||||
|
`/user_api/bind_address?${params.toString()}`
|
||||||
);
|
);
|
||||||
data.value = results;
|
data.value = results;
|
||||||
|
if (page.value === 1) {
|
||||||
|
count.value = addressCount;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
message.error(error.message || "error");
|
message.error(error.message || "error");
|
||||||
@@ -178,6 +196,10 @@ const columns = [
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await fetchData()
|
await fetchData()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch([page, pageSize], async () => {
|
||||||
|
await fetchData();
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -197,6 +219,12 @@ onMounted(async () => {
|
|||||||
<n-tabs type="segment">
|
<n-tabs type="segment">
|
||||||
<n-tab-pane name="address" :tab="t('address')">
|
<n-tab-pane name="address" :tab="t('address')">
|
||||||
<div class="address-table-scroll">
|
<div class="address-table-scroll">
|
||||||
|
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
|
||||||
|
:page-sizes="[20, 50, 100]" show-size-picker>
|
||||||
|
<template #prefix="{ itemCount }">
|
||||||
|
{{ t('itemCount') }}: {{ itemCount }}
|
||||||
|
</template>
|
||||||
|
</n-pagination>
|
||||||
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
|
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
|
||||||
</div>
|
</div>
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
@@ -216,4 +244,9 @@ onMounted(async () => {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.n-pagination {
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const fetchMailData = async (limit, offset) => {
|
|||||||
const fetchAddresData = async () => {
|
const fetchAddresData = async () => {
|
||||||
try {
|
try {
|
||||||
const { results } = await api.fetch(
|
const { results } = await api.fetch(
|
||||||
`/user_api/bind_address`
|
`/user_api/bind_address?limit=100&offset=0`
|
||||||
);
|
);
|
||||||
addressFilterOptions.value = results.map((item) => {
|
addressFilterOptions.value = results.map((item) => {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -160,6 +160,36 @@ This endpoint uses **User JWT** (obtained via `/user_api/login` or `/user_api/re
|
|||||||
- User JWT uses `x-user-token: <jwt>` to access `/user_api/*` endpoints
|
- User JWT uses `x-user-token: <jwt>` to access `/user_api/*` endpoints
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
### Bound Address List
|
||||||
|
|
||||||
|
`GET /user_api/bind_address` uses server-side pagination and accepts these query parameters:
|
||||||
|
|
||||||
|
Requests without pagination parameters return the default first page. Fetching all bound addresses in one request is not supported.
|
||||||
|
|
||||||
|
| Parameter | Default | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `limit` | `20` | Page size, from 1 to 100 |
|
||||||
|
| `offset` | `0` | Pagination offset |
|
||||||
|
|
||||||
|
The `results` array contains only the current page. The total is queried only when `offset=0`; later pages return `count: 0`, so clients should retain the total from the first page.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
url = "https://<your-worker-address>/user_api/bind_address"
|
||||||
|
headers = {
|
||||||
|
"x-user-token": "<your-user-JWT-token>",
|
||||||
|
}
|
||||||
|
querystring = {
|
||||||
|
"limit": "20",
|
||||||
|
"offset": "0",
|
||||||
|
}
|
||||||
|
response = requests.get(url, headers=headers, params=querystring)
|
||||||
|
print(response.json())
|
||||||
|
```
|
||||||
|
|
||||||
|
### User Mail List
|
||||||
|
|
||||||
Supports `address` filter
|
Supports `address` filter
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
|||||||
@@ -160,6 +160,36 @@ print(response.json())
|
|||||||
- 用户 JWT 使用 `x-user-token: <jwt>` 访问 `/user_api/*` 接口
|
- 用户 JWT 使用 `x-user-token: <jwt>` 访问 `/user_api/*` 接口
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
### 用户绑定地址列表
|
||||||
|
|
||||||
|
`GET /user_api/bind_address` 使用服务端分页,支持以下查询参数:
|
||||||
|
|
||||||
|
未携带分页参数时返回默认第一页,不支持一次获取全部绑定地址。
|
||||||
|
|
||||||
|
| 参数 | 默认值 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `limit` | `20` | 每页数量,范围为 1–100 |
|
||||||
|
| `offset` | `0` | 分页偏移量 |
|
||||||
|
|
||||||
|
响应中的 `results` 仅包含当前页。仅 `offset=0` 时查询总数,后续页面的 `count` 为 `0`,客户端应保留第一页返回的总数。
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
url = "https://<你的worker地址>/user_api/bind_address"
|
||||||
|
headers = {
|
||||||
|
"x-user-token": "<你的用户JWT Token>",
|
||||||
|
}
|
||||||
|
querystring = {
|
||||||
|
"limit": "20",
|
||||||
|
"offset": "0",
|
||||||
|
}
|
||||||
|
response = requests.get(url, headers=headers, params=querystring)
|
||||||
|
print(response.json())
|
||||||
|
```
|
||||||
|
|
||||||
|
### 用户邮件列表
|
||||||
|
|
||||||
支持 `address` 过滤
|
支持 `address` 过滤
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Jwt } from 'hono/utils/jwt'
|
|||||||
import { isAddressCountLimitReached } from "../utils"
|
import { isAddressCountLimitReached } from "../utils"
|
||||||
import { unbindTelegramByAddress } from '../telegram_api/common';
|
import { unbindTelegramByAddress } from '../telegram_api/common';
|
||||||
import i18n from '../i18n';
|
import i18n from '../i18n';
|
||||||
import { updateAddressUpdatedAt, commonGetUserRole, hideObjectFields } from '../common';
|
import { updateAddressUpdatedAt, commonGetUserRole, handleListQuery, hideObjectFields } from '../common';
|
||||||
|
|
||||||
const UserBindAddressModule = {
|
const UserBindAddressModule = {
|
||||||
bind: async (c: Context<HonoCustomType>) => {
|
bind: async (c: Context<HonoCustomType>) => {
|
||||||
@@ -97,16 +97,24 @@ const UserBindAddressModule = {
|
|||||||
},
|
},
|
||||||
getBindedAddresses: async (c: Context<HonoCustomType>) => {
|
getBindedAddresses: async (c: Context<HonoCustomType>) => {
|
||||||
const { user_id } = c.get("userPayload");
|
const { user_id } = c.get("userPayload");
|
||||||
const results = await UserBindAddressModule.getBindedAddressesById(c, user_id);
|
const { limit, offset } = c.req.query();
|
||||||
return c.json({
|
const params = [String(user_id)];
|
||||||
results: results,
|
const fromQuery = ` FROM address a`
|
||||||
});
|
+ ` JOIN users_address ua ON ua.address_id = a.id`
|
||||||
},
|
+ ` WHERE ua.user_id = ?`;
|
||||||
getBindedAddressListById: async (
|
return await handleListQuery(
|
||||||
c: Context<HonoCustomType>, user_id: number | string
|
c,
|
||||||
): Promise<string[]> => {
|
`SELECT a.*,`
|
||||||
const bindedAddressList = await UserBindAddressModule.getBindedAddressesById(c, user_id);
|
+ ` (SELECT COUNT(*) FROM raw_mails WHERE address = a.name) AS mail_count,`
|
||||||
return bindedAddressList.map((item) => item.name);
|
+ ` (SELECT COUNT(*) FROM sendbox WHERE address = a.name) AS send_count`
|
||||||
|
+ fromQuery,
|
||||||
|
`SELECT COUNT(*) AS count${fromQuery}`,
|
||||||
|
params,
|
||||||
|
limit ?? 20,
|
||||||
|
offset ?? 0,
|
||||||
|
'a.id DESC',
|
||||||
|
['password'],
|
||||||
|
);
|
||||||
},
|
},
|
||||||
getBindedAddressesById: async (
|
getBindedAddressesById: async (
|
||||||
c: Context<HonoCustomType>, user_id: number | string
|
c: Context<HonoCustomType>, user_id: number | string
|
||||||
|
|||||||
@@ -1,30 +1,26 @@
|
|||||||
import { Context } from "hono";
|
import { Context } from "hono";
|
||||||
import i18n from "../i18n";
|
import i18n from "../i18n";
|
||||||
import { handleMailListQuery } from "../common";
|
import { handleMailListQuery } from "../common";
|
||||||
import UserBindAddressModule from "./bind_address";
|
|
||||||
import { getBooleanValue } from "../utils";
|
import { getBooleanValue } from "../utils";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
getMails: async (c: Context<HonoCustomType>) => {
|
getMails: async (c: Context<HonoCustomType>) => {
|
||||||
const { user_id } = c.get("userPayload");
|
const { user_id } = c.get("userPayload");
|
||||||
const { address, limit, offset } = c.req.query();
|
const { address, limit, offset } = c.req.query();
|
||||||
const bindedAddressList = await UserBindAddressModule.getBindedAddressListById(c, user_id);
|
const filterQuerys = [`ua.user_id = ?`];
|
||||||
const addressList = address ? bindedAddressList.filter((item) => item == address) : bindedAddressList;
|
const filterParams = [String(user_id)];
|
||||||
const addressQuery = `address IN (${addressList.map(() => "?").join(",")})`;
|
if (address) {
|
||||||
const addressParams = addressList;
|
filterQuerys.push(`rm.address = ?`);
|
||||||
|
filterParams.push(address);
|
||||||
// user must have at least one binded address to query mails
|
|
||||||
if (addressList.length <= 0) {
|
|
||||||
return c.json({ results: [], count: 0 });
|
|
||||||
}
|
}
|
||||||
|
const fromQuery = ` FROM users_address ua`
|
||||||
const filterQuerys = [addressQuery].filter((item) => item).join(" and ");
|
+ ` JOIN address a ON a.id = ua.address_id`
|
||||||
const finalQuery = filterQuerys.length > 0 ? `where ${filterQuerys}` : "";
|
+ ` JOIN raw_mails rm ON rm.address = a.name`
|
||||||
const filterParams = [...addressParams]
|
+ ` WHERE ${filterQuerys.join(" AND ")}`;
|
||||||
return await handleMailListQuery(c,
|
return await handleMailListQuery(c,
|
||||||
`SELECT * FROM raw_mails ${finalQuery}`,
|
`SELECT rm.*${fromQuery}`,
|
||||||
`SELECT count(*) as count FROM raw_mails ${finalQuery}`,
|
`SELECT count(*) as count${fromQuery}`,
|
||||||
filterParams, limit, offset
|
filterParams, limit, offset, 'rm.id desc'
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
deleteMail: async (c: Context<HonoCustomType>) => {
|
deleteMail: async (c: Context<HonoCustomType>) => {
|
||||||
@@ -34,11 +30,14 @@ export default {
|
|||||||
}
|
}
|
||||||
const { id } = c.req.param();
|
const { id } = c.req.param();
|
||||||
const { user_id } = c.get("userPayload");
|
const { user_id } = c.get("userPayload");
|
||||||
const bindedAddressList = await UserBindAddressModule.getBindedAddressListById(c, user_id);
|
|
||||||
const { success } = await c.env.DB.prepare(
|
const { success } = await c.env.DB.prepare(
|
||||||
`DELETE FROM raw_mails WHERE id = ?`
|
`DELETE FROM raw_mails WHERE id = ?`
|
||||||
+ ` and address IN (${bindedAddressList.map(() => "?").join(",")})`
|
+ ` AND EXISTS (`
|
||||||
).bind(id, ...bindedAddressList).run();
|
+ `SELECT 1 FROM users_address ua`
|
||||||
|
+ ` JOIN address a ON a.id = ua.address_id`
|
||||||
|
+ ` WHERE ua.user_id = ? AND a.name = raw_mails.address`
|
||||||
|
+ `)`
|
||||||
|
).bind(id, user_id).run();
|
||||||
return c.json({
|
return c.json({
|
||||||
success: success
|
success: success
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user