v2.5.0:操作日志 + 邮件保留 + 账号弹窗精简 + 复制按钮推广

- 操作日志:operation_logs 表(0004 迁移)记录 账号/域名/邮箱/Webhook 全部写操作,
  含操作账号(Console 登录名,系统动作为 system);/logs 日志页(类型筛选+搜索+分页);
  上限 10000 条,Cron housekeeping 超限删最旧
- 邮件保留:emails.retained;POST /api/emails/retain(单条/批量);
  TTL 清理与 Webhook 投递过期判定均跳过已保留;/inboxs 操作列图钉按钮 + 批量保留
- 添加账号弹窗:OAuth 与 API Token 同窗按钮组切换,去掉说明文字与账户 ID 字段
  (账户 ID 以 Token 实际所属账户为准);账号列表去掉 Email Worker 列
- 复制按钮推广:CopyText 组件;/domains 域名列、/inboxs 发件人与收件人(原「邮箱」改名)列
- 测试:104 bun tests(+10 日志/保留)、ui-smoke 35 项(+logs 页/新弹窗/复制/保留)
This commit is contained in:
bigcode
2026-10-01 00:25:38 +08:00
parent 8c5ed7fc8b
commit 8b255ef2e0
22 changed files with 1240 additions and 228 deletions
+276
View File
@@ -0,0 +1,276 @@
/**
* 操作日志 + 邮件保留(v2.5.0):
* - 资源路由的写操作落 operation_logs(操作账号 / 动作 / 对象)
* - POST /api/emails/retain 保留邮件;Cron TTL 清理跳过已保留
* - Webhook 投递终局写日志;日志表最多保留 10000 条(housekeeping 裁剪)
*/
import { describe, expect, test } from "bun:test";
import { cleanupExpiredEmails, housekeeping } from "../console/src/services/cleanup.ts";
import { ingestEmail } from "../console/src/services/email_ingest.ts";
import { processDueDeliveries } from "../console/src/services/webhook_delivery.ts";
import { authedFetch, loginCookie, makeTestEnv, TEST_MAIL, type TestEnv } from "./helpers.ts";
/** # 成员变量: 日志行结构(直接查库断言用) **/
interface LogRow {
action: string;
target_type: string;
target_id: string | null;
target_name: string | null;
operator: string;
}
/** # 函数
* 查最新一条指定动作的日志
*/
async function latestLog(t: TestEnv, action: string): Promise<LogRow | null> {
return await t.env.DB.prepare(
"SELECT action, target_type, target_id, target_name, operator FROM operation_logs WHERE action = ? ORDER BY created_at DESC, id DESC LIMIT 1",
)
.bind(action)
.first<LogRow>();
}
describe("操作日志(账号/域名/邮箱/Webhook)", () => {
test("创建邮箱写日志:operator=登录名,对象为邮箱地址", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const resp = await authedFetch(t, cookie, "http://localhost/api/mailboxes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ domain_id: t.domainId, address: "logger" }),
});
expect(resp.status).toBe(201);
const row = await latestLog(t, "mailbox.create");
expect(row?.operator).toBe("admin");
expect(row?.target_type).toBe("mailbox");
expect(row?.target_name).toBe("logger@example.test");
});
test("删除邮箱写日志;API 层无对应操作时不产生日志", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
// 纯读操作不写日志
await authedFetch(t, cookie, "http://localhost/api/mailboxes");
expect(await latestLog(t, "mailbox.delete")).toBeNull();
const del = await authedFetch(t, cookie, `http://localhost/api/mailboxes/${t.mailboxId}`, { method: "DELETE" });
expect(del.status).toBe(200);
const row = await latestLog(t, "mailbox.delete");
expect(row?.target_name).toBe("*@example.test");
expect(row?.operator).toBe("admin");
});
test("删除账号(软删)写日志;批量删除记一条汇总(含数量)", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
await authedFetch(t, cookie, `http://localhost/api/accounts/${t.accountId}`, { method: "DELETE" });
const row = await latestLog(t, "account.delete");
expect(row?.target_name).toBe("test-account");
expect(row?.target_type).toBe("account");
const batch = await authedFetch(t, cookie, "http://localhost/api/accounts/batch-delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: ["acct_no_such"] }),
});
expect(batch.status).toBe(200);
// 全部失败(deleted=0)不写日志
expect(await latestLog(t, "account.batch_delete")).toBeNull();
});
test("域名级 Webhook 保存/清除写 Webhook 记录", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const save = await authedFetch(t, cookie, `http://localhost/api/domains/${t.domainId}/webhook`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: "https://hooks.example.com/wmh" }),
});
expect(save.status).toBe(200);
const saved = await latestLog(t, "webhook.save");
expect(saved?.target_name).toBe("https://hooks.example.com/wmh");
expect(saved?.target_type).toBe("webhook");
const clear = await authedFetch(t, cookie, `http://localhost/api/domains/${t.domainId}/webhook`, { method: "DELETE" });
expect(clear.status).toBe(200);
expect((await latestLog(t, "webhook.clear"))?.target_name).toBe("example.test");
});
test("Webhook 投递终局写日志:成功记 deliver,重试耗尽记 deliver_failed", async () => {
const t = await makeTestEnv();
const recv = Bun.serve({
port: 0,
fetch: () => new Response("no", { status: 500 }),
});
const cookie = await loginCookie(t);
await authedFetch(t, cookie, "http://localhost/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ webhook: { url: `http://127.0.0.1:${recv.port}/hook` } }),
});
await ingestEmail(t.env, {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
accountId: t.accountId,
});
// 重试上限 5 次(指数退避):每轮把 next_retry_at 拨回过去,立即触发下一轮
for (let round = 0; round < 5; round++) {
if (round > 0) {
await t.env.DB.prepare("UPDATE webhook_deliveries SET next_retry_at = ?").bind(new Date(Date.now() - 1000).toISOString()).run();
}
await processDueDeliveries(t.env, undefined, 20, true);
}
const failed = await latestLog(t, "webhook.deliver_failed");
expect(failed?.target_type).toBe("webhook");
expect(failed?.operator).toBe("system");
expect(await latestLog(t, "webhook.deliver")).toBeNull();
recv.stop(true);
});
test("GET /api/logs:时间倒序 + target_type 筛选 + 分页", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
// 造两类日志:1 条邮箱 + 1 条邮件删除
await authedFetch(t, cookie, "http://localhost/api/mailboxes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ domain_id: t.domainId, address: "logfilter" }),
});
const email = await ingestEmail(t.env, {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
accountId: t.accountId,
});
await authedFetch(t, cookie, `http://localhost/api/emails/${email.id}`, { method: "DELETE" });
const all = await authedFetch(t, cookie, "http://localhost/api/logs?limit=10");
expect(all.status).toBe(200);
const allBody = (await all.json()) as { items: LogRow[]; total: number };
expect(allBody.total).toBe(2);
expect(allBody.items[0]?.action).toBe("email.delete");
const filtered = await authedFetch(t, cookie, "http://localhost/api/logs?target_type=mailbox");
const filteredBody = (await filtered.json()) as { items: LogRow[]; total: number };
expect(filteredBody.total).toBe(1);
expect(filteredBody.items[0]?.action).toBe("mailbox.create");
});
});
describe("邮件保留(retained,永不过期)", () => {
test("保留接口置位 retained;Cron TTL 清理跳过已保留,未保留的照删", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const first = await ingestEmail(t.env, {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
accountId: t.accountId,
});
const second = await ingestEmail(t.env, {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
accountId: t.accountId,
});
const retain = await authedFetch(t, cookie, "http://localhost/api/emails/retain", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: [first.id] }),
});
expect(retain.status).toBe(200);
expect(((await retain.json()) as { retained: number }).retained).toBe(1);
// 重复保留幂等:不再计数
const again = await authedFetch(t, cookie, "http://localhost/api/emails/retain", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: [first.id] }),
});
expect(((await again.json()) as { retained: number }).retained).toBe(0);
// 两封都改为已过期,跑清理
const past = new Date(Date.now() - 60_000).toISOString();
await t.env.DB.prepare("UPDATE emails SET expires_at = ?").bind(past).run();
const stats = { expiredEmails: 0, deferredEmails: 0, orphanAttachments: 0, orphanDeliveries: 0, housekeeping: 0, errors: [] };
await cleanupExpiredEmails(t.env, stats);
expect(stats.expiredEmails).toBe(1);
const kept = await t.env.DB.prepare("SELECT id FROM emails WHERE id = ?").bind(first.id).first();
expect(kept).not.toBeNull();
const gone = await t.env.DB.prepare("SELECT id FROM emails WHERE id = ?").bind(second.id).first();
expect(gone).toBeNull();
// 保留动作写日志
expect((await latestLog(t, "email.retain"))?.target_type).toBe("email");
});
test("列表接口带出 retained;已保留的过期邮件不再被标记 muted 判定依据(字段可判)", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const email = await ingestEmail(t.env, {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
accountId: t.accountId,
});
await authedFetch(t, cookie, "http://localhost/api/emails/retain", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: [email.id] }),
});
const list = (await (await authedFetch(t, cookie, "http://localhost/api/emails")).json()) as {
items: { id: string; retained: number }[];
};
expect(list.items.find((e) => e.id === email.id)?.retained).toBe(1);
});
test("已保留邮件的 Webhook 投递不因 expires_at 过去而放弃", async () => {
const t = await makeTestEnv();
let hits = 0;
const recv = Bun.serve({
port: 0,
fetch: () => {
hits++;
return Response.json({ ok: true });
},
});
await t.env.DB.prepare(
"INSERT INTO settings (key, value) VALUES ('global_webhook', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
)
.bind(JSON.stringify({ url: `http://127.0.0.1:${recv.port}/hook`, headers: {} }))
.run();
await ingestEmail(t.env, {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
accountId: t.accountId,
});
// 邮件已过期 + 已保留
await t.env.DB.prepare("UPDATE emails SET expires_at = ?, retained = 1").bind(new Date(Date.now() - 60_000).toISOString()).run();
const stats = await processDueDeliveries(t.env, undefined, 20, true);
expect(stats.success).toBe(1);
expect(hits).toBe(1);
recv.stop(true);
});
});
describe("日志上限(10000 条)", () => {
test("housekeeping 裁剪:只留最新 10000 条", async () => {
const t = await makeTestEnv();
const now = new Date().toISOString();
const rows: string[] = [];
for (let i = 0; i < 10020; i++) {
rows.push(`('oplog_${String(i).padStart(6, "0")}', 'mailbox.create', 'mailbox', NULL, 'x', NULL, 'admin', '${now}')`);
}
t.env.DB.exec(`INSERT INTO operation_logs (id, action, target_type, target_id, target_name, detail, operator, created_at) VALUES ${rows.join(",")}`);
const before = await t.env.DB.prepare("SELECT COUNT(*) AS n FROM operation_logs").first<{ n: number }>();
expect(before?.n).toBe(10020);
const stats = { expiredEmails: 0, deferredEmails: 0, orphanAttachments: 0, orphanDeliveries: 0, housekeeping: 0, errors: [] };
await housekeeping(t.env, stats);
expect(stats.housekeeping).toBe(20);
const after = await t.env.DB.prepare("SELECT COUNT(*) AS n FROM operation_logs").first<{ n: number }>();
expect(after?.n).toBe(10000);
// 留下的是最新的(前 20 条被裁掉)
const oldest = await t.env.DB.prepare("SELECT id FROM operation_logs ORDER BY created_at ASC, id ASC LIMIT 1").first<{ id: string }>();
expect(oldest?.id).toBe("oplog_000020");
});
});
+10
View File
@@ -704,6 +704,16 @@ describe("API Token 方式添加账号(与 OAuth 并列的第二种接入方式)
expect(conn?.provider).toBe("cloudflare-token");
expect(conn!.encrypted_access_token).not.toContain("cf-user-api-token-abc");
expect(conn!.expires_at).toBeNull(); // API Token 长期有效
// 操作日志:v2.5.0 起账号接入记 operation_logs(操作账号/动作/对象)
const log = await t.env.DB.prepare(
"SELECT action, target_id, target_name, operator FROM operation_logs WHERE target_id = ?",
)
.bind(body.account_id)
.first<{ action: string; target_id: string; target_name: string; operator: string }>();
expect(log?.action).toBe("account.create");
expect(log?.target_name).toBe("我的主账号");
expect(log?.operator).toBe("admin");
});
test("Token 无效 / 账户 ID 对不上:当场报错,不留下僵尸账号", async () => {
+55 -15
View File
@@ -55,6 +55,7 @@ const pages = [
{ path: "/domains", expect: "域名", shot: "04-domains.png" },
{ path: "/mailboxes", expect: "邮箱", shot: "05-mailboxes.png" },
{ path: "/inboxs", expect: "收件箱", shot: "06-inboxs.png" },
{ path: "/logs", expect: "日志", shot: "06b-logs.png" },
];
for (const p of pages) {
await page.goto(`${BASE}${p.path}`, { waitUntil: "domcontentloaded" });
@@ -143,32 +144,39 @@ for (const path of ["/accounts", "/domains", "/mailboxes", "/inboxs"]) {
if (path === "/mailboxes") await page.screenshot({ path: `${SHOT_DIR}/11-batch-selected.png` });
}
// 6b. 添加账号模态窗口:OAuth / API Token 两种授权方式 + 各自说明
// 6b. 添加账号模态窗口:OAuth / API Token 同窗(Radio 切换),只留必要字段
await page.goto(`${BASE}/accounts`, { waitUntil: "domcontentloaded" });
await page.waitForSelector(".ant-table", { timeout: 8000 });
check(
"账号列表不再有「Email Worker」列(统一命名,无需展示)",
(await page.locator(".ant-table-thead th:has-text('Email Worker')").count()) === 0,
);
await page.click("button:has-text('添加账号')");
await page.waitForSelector(".ant-modal:has-text('添加 Cloudflare 账号')", { timeout: 8000 });
const addTabs = await page.locator(".ant-modal .ant-tabs-tab").allInnerTexts();
const modeLabels = await page.locator(".ant-modal .ant-radio-button-wrapper").allInnerTexts();
check(
"添加账号模态窗口含 OAuth / API Token 两种授权方式",
addTabs.some((t) => t.includes("OAuth")) && addTabs.some((t) => t.includes("API Token")),
addTabs.join(" | "),
"添加账号模态窗口含 OAuth / API Token 两种授权方式(同窗按钮组)",
modeLabels.some((t) => t.includes("OAuth")) && modeLabels.some((t) => t.includes("API Token")),
modeLabels.join(" | "),
);
check(
"OAuth 方式带说明(不需在本系统保存密钥)",
(await page.locator(".ant-modal :text('不需要在本机保存任何密钥')").count()) > 0,
"窗口内不再有说明文字(Alert)与账户 ID 字段",
(await page.locator(".ant-modal .ant-alert").count()) === 0 &&
(await page.locator(".ant-modal :text('账户 ID')").count()) === 0,
);
// OAuth 模式:只有「前往 Cloudflare 授权」按钮
check(
"OAuth 模式默认展示「前往 Cloudflare 授权」按钮",
(await page.locator(".ant-modal button:has-text('前往 Cloudflare 授权')").count()) === 1,
);
await page.screenshot({ path: `${SHOT_DIR}/12a-add-account-oauth.png` });
await page.click(".ant-modal .ant-tabs-tab:has-text('API Token')");
// 切到 API Token:账户名称 + API Token 两个字段
await page.click(".ant-modal .ant-radio-button-wrapper:has-text('API Token')");
await page.waitForSelector(".ant-modal :text('账户名称')", { timeout: 8000 });
check(
"API Token 方式含 账户名称 / 账户 ID / API Token 三个字段",
(await page.locator(".ant-modal :text('账户 ID')").count()) > 0 &&
(await page.locator(".ant-modal input[type=password]").count()) > 0,
);
check(
"API Token 方式带说明(会先校验再接入)",
(await page.locator(".ant-modal :text('确认可用后才建账号')").count()) > 0,
"API Token 方式只含 账户名称 / API Token 两个字段",
(await page.locator(".ant-modal input[type=password]").count()) === 1 &&
(await page.locator(".ant-modal :text('账户 ID')").count()) === 0,
);
await page.screenshot({ path: `${SHOT_DIR}/12b-add-account-token.png` });
await page.keyboard.press("Escape");
@@ -239,6 +247,38 @@ if ((await setupBtn.count()) > 0) {
check("点「设置 Email Workers」先弹确认框(说明会改 MX 与路由)", false, "找不到配置按钮");
}
// 6e. 域名列表复制按钮
await page.goto(`${BASE}/domains`, { waitUntil: "domcontentloaded" });
await page.waitForSelector(".ant-table-tbody", { timeout: 8000 });
check(
"域名列表域名右侧带复制按钮",
(await page.locator(".ant-table-tbody button.copy-btn").count()) > 0,
);
await page.waitForTimeout(400);
await page.screenshot({ path: `${SHOT_DIR}/15-domains-copy.png` });
// 6f. 收件箱:发件人/收件人复制按钮 + 「操作」列保留按钮 + 批量保留
await page.goto(`${BASE}/inboxs`, { waitUntil: "domcontentloaded" });
await page.waitForSelector(".ant-table-tbody", { timeout: 8000 });
check(
"列表表头是「收件人」(不再叫「邮箱」)",
(await page.locator(".ant-table-thead th:has-text('收件人')").count()) === 1 &&
(await page.locator(".ant-table-thead th:has-text('邮箱')").count()) === 0,
);
const inboxCopyBtns = await page.locator(".ant-table-tbody button.copy-btn").count();
check("发件人与收件人右侧都有复制按钮", inboxCopyBtns >= 2, `copy-btn x${inboxCopyBtns}`);
check(
"「操作」列有保留按钮(图钉,无边框)",
(await page.locator(".ant-table-thead th:has-text('操作')").count()) === 1 &&
(await page.locator(".ant-table-tbody button.pin-btn").count()) > 0,
);
// 勾选一行 → 工具条出现「保留选中」
await page.locator(".ant-table-tbody .ant-checkbox-input").first().click({ force: true });
await page.waitForSelector("button:has-text('保留选中')", { timeout: 6000 });
check("勾选后工具条出现「保留选中(批量保留)」按钮", true);
await page.waitForTimeout(400);
await page.screenshot({ path: `${SHOT_DIR}/16-inbox-retain.png` });
await browser.close();
const failed = results.filter((r) => !r.ok);