feat: migrate send logs and discord links to D1

This commit is contained in:
RhenCloud 2026-08-03 03:57:58 +08:00
parent 5a98e45544
commit 3a6a2cbbc5
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
16 changed files with 139 additions and 79 deletions

View file

@ -12,6 +12,7 @@ function createEnv(overrides: Partial<Env> = {}): Env {
return {
GITHUB_WEBHOOK_SECRET: "secret",
KV: {} as KVNamespace,
DB: {} as D1Database,
...overrides,
};
}
@ -91,6 +92,17 @@ describe("dispatchEvent fallback routing", () => {
} as unknown as KVNamespace;
}
function createMockDB(): D1Database {
return {
prepare: () => ({
bind: () => ({
run: async () => ({ success: true }),
all: async () => ({ results: [] }),
}),
}),
} as unknown as D1Database;
}
const baseConfig = {
baseUrl: "https://example.com",
github: {
@ -110,7 +122,7 @@ describe("dispatchEvent fallback routing", () => {
sent.push(url);
return new Response("{}", { status: 200 });
});
const env = createEnv({ KV: createMockKV() });
const env = createEnv({ KV: createMockKV(), DB: createMockDB() });
const routes: Route[] = [
{
id: "regular-push",

View file

@ -1,39 +1,46 @@
import { describe, it, expect } from "bun:test";
import { recordSend, getSendLog } from "../lib/send-log";
function createMockKV(): KVNamespace {
const store = new Map<string, { value: string; expiration?: number }>();
function createMockDB(): D1Database {
const rows: Array<Record<string, unknown>> = [];
return {
get: async (key: string, type?: string) => {
const entry = store.get(key);
if (!entry) return null;
if (entry.expiration && Date.now() / 1000 > entry.expiration) {
store.delete(key);
return null;
}
if (type === "json") return JSON.parse(entry.value);
return entry.value;
},
put: async (key: string, value: string, opts?: { expirationTtl?: number }) => {
const expiration = opts?.expirationTtl ? Date.now() / 1000 + opts.expirationTtl : undefined;
store.set(key, { value, expiration });
},
delete: async (key: string) => {
store.delete(key);
},
list: async () => ({
keys: [...store.keys()].map((k) => ({ name: k })),
list_complete: true,
cacheStatus: null,
prepare: (sql: string) => ({
bind: (..._args: unknown[]) => ({
run: async (): Promise<{ success: boolean }> => {
if (sql.startsWith("INSERT")) {
const args = _args as unknown[];
rows.push({
ts: args[0],
route_id: args[1],
event: args[2],
repo: args[3],
target: args[4],
ok: args[5],
error: args[6],
});
}
return { success: true };
},
all: async (): Promise<{ results: Array<Record<string, unknown>> }> => {
const args = _args as unknown[];
const limit = (args[0] as number) ?? 50;
return {
results: rows
.slice()
.sort((a, b) => (b.ts as number) - (a.ts as number))
.slice(0, limit),
};
},
}),
}),
} as unknown as KVNamespace;
} as unknown as D1Database;
}
describe("send-log", () => {
it("records and returns logs sorted newest first", async () => {
const kv = createMockKV();
await recordSend(kv, { ts: 1000, routeId: "a", event: "push", target: "111", ok: true });
await recordSend(kv, {
const db = createMockDB();
await recordSend(db, { ts: 1000, routeId: "a", event: "push", target: "111", ok: true });
await recordSend(db, {
ts: 2000,
routeId: "b",
event: "issues",
@ -41,7 +48,7 @@ describe("send-log", () => {
ok: false,
error: "Missing Permissions",
});
const logs = await getSendLog(kv);
const logs = await getSendLog(db);
expect(logs).toHaveLength(2);
expect(logs[0]!.routeId).toBe("b");
expect(logs[0]!.ok).toBe(false);
@ -50,7 +57,7 @@ describe("send-log", () => {
});
it("returns empty when no logs", async () => {
const kv = createMockKV();
expect(await getSendLog(kv)).toEqual([]);
const db = createMockDB();
expect(await getSendLog(db)).toEqual([]);
});
});

View file

@ -48,7 +48,7 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
const message = formatEvent(route, event, tr, showEmoji);
const result = await getDriver(route.target).send(message, route.target, env);
if (!result.ok) throw new Error(result.error ?? "Send failed");
await recordSend(env.KV, {
await recordSend(env.DB, {
ts: Date.now(),
routeId: route.id,
event: event.event,
@ -57,7 +57,7 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
ok: true,
});
} catch (err) {
await recordSend(env.KV, {
await recordSend(env.DB, {
ts: Date.now(),
routeId: route.id,
event: event.event,

View file

@ -270,7 +270,7 @@ async function handleButton(
customId: string | undefined,
): Promise<void> {
if (!userId) return respond(env, id, token, "无法识别你的 Discord 账号。");
const githubUserId = await getDiscordLink(env.KV, userId);
const githubUserId = await getDiscordLink(env.DB, userId);
if (!githubUserId) {
return respond(env, id, token, "你还没有绑定 GitHub 账号,请先使用 `/gh login`。");
}
@ -348,7 +348,7 @@ async function cmdLogout(
userId: string | null,
): Promise<void> {
if (!userId) return respond(env, id, token, "无法识别你的 Discord 账号。");
await removeDiscordLink(env.KV, userId);
await removeDiscordLink(env.DB, userId);
await respond(env, id, token, "已解绑你的 GitHub 账号。");
}
@ -374,7 +374,7 @@ async function commentOp(
source: string,
): Promise<void> {
if (!userId) return respond(env, id, token, "无法识别你的 Discord 账号。");
const githubUserId = await getDiscordLink(env.KV, userId);
const githubUserId = await getDiscordLink(env.DB, userId);
if (!githubUserId) {
return respond(env, id, token, "你还没有绑定 GitHub 账号,请先使用 `/gh login`。");
}
@ -487,7 +487,7 @@ async function modalSubmit(
const body = d.components?.[0]?.components?.find((c) => c.custom_id === "body")?.value?.trim();
if (!body) return respond(env, id, token, "评论内容不能为空。");
const githubUserId = await getDiscordLink(env.KV, userId);
const githubUserId = await getDiscordLink(env.DB, userId);
if (!githubUserId) {
return respond(env, id, token, "你还没有绑定 GitHub 账号,请先使用 `/gh login`。");
}

View file

@ -73,20 +73,27 @@ export async function findUserIdByToken(
* as that GitHub account. The actual OAuth token lives under `token:{githubUserId}`.
*/
export async function saveDiscordLink(
kv: KVNamespace,
db: D1Database,
discordUserId: string,
githubUserId: string,
): Promise<void> {
await kv.put(`discord-link:${discordUserId}`, githubUserId);
await db
.prepare("INSERT OR REPLACE INTO discord_links (discord_user_id, github_user_id) VALUES (?, ?)")
.bind(discordUserId, githubUserId)
.run();
}
export async function getDiscordLink(
kv: KVNamespace,
db: D1Database,
discordUserId: string,
): Promise<string | null> {
return await kv.get(`discord-link:${discordUserId}`, "text");
const { results } = await db
.prepare("SELECT github_user_id FROM discord_links WHERE discord_user_id = ?")
.bind(discordUserId)
.all<{ github_user_id: string }>();
return results[0]?.github_user_id ?? null;
}
export async function removeDiscordLink(kv: KVNamespace, discordUserId: string): Promise<void> {
await kv.delete(`discord-link:${discordUserId}`);
export async function removeDiscordLink(db: D1Database, discordUserId: string): Promise<void> {
await db.prepare("DELETE FROM discord_links WHERE discord_user_id = ?").bind(discordUserId).run();
}

View file

@ -10,36 +10,42 @@ export interface SendRecord {
error?: string;
}
const KEY_PREFIX = "logs:send:";
const RETENTION_TTL = 3600;
const MAX_READ = 200;
function randomHex(): string {
const bytes = new Uint8Array(8);
crypto.getRandomValues(bytes);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
export async function recordSend(kv: KVNamespace, record: SendRecord): Promise<void> {
export async function recordSend(db: D1Database, record: SendRecord): Promise<void> {
try {
await kv.put(`${KEY_PREFIX}${record.ts}-${randomHex()}`, JSON.stringify(record), {
expirationTtl: RETENTION_TTL,
});
await db
.prepare(
"INSERT INTO send_logs (ts, route_id, event, repo, target, ok, error) VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(
record.ts,
record.routeId,
record.event,
record.repo ?? null,
record.target,
record.ok ? 1 : 0,
record.error ?? null,
)
.run();
} catch (err) {
log.warn({ err }, "Failed to record send log");
}
}
export async function getSendLog(kv: KVNamespace, limit = 50): Promise<SendRecord[]> {
export async function getSendLog(db: D1Database, limit = 50): Promise<SendRecord[]> {
try {
const list = await kv.list({ prefix: KEY_PREFIX, limit: MAX_READ });
const records = await Promise.all(list.keys.map((k) => kv.get<SendRecord>(k.name, "json")));
return records
.filter((r): r is SendRecord => r != null)
.sort((a, b) => b.ts - a.ts)
.slice(0, limit);
const { results } = await db
.prepare("SELECT * FROM send_logs ORDER BY ts DESC LIMIT ?")
.bind(limit)
.all<{ ts: number; route_id: string; event: string; repo: string | null; target: string; ok: number; error: string | null }>();
return results.map((r) => ({
ts: r.ts,
routeId: r.route_id,
event: r.event,
repo: r.repo ?? undefined,
target: r.target,
ok: r.ok === 1,
error: r.error ?? undefined,
}));
} catch (err) {
log.warn({ err }, "Failed to load send log");
return [];

View file

@ -15,6 +15,7 @@ export interface Env {
DISCORD_APPLICATION_ID?: string;
ASSETS?: Fetcher;
KV: KVNamespace;
DB: D1Database;
}
export interface Config {

View file

@ -227,13 +227,13 @@ export function createAdminRoutes(): Hono<{ Bindings: Env }> {
if (!s) return c.json({ error: "Unauthorized" }, 401);
const limit = Math.min(Math.max(Number(c.req.query("limit") ?? 50), 1), 100);
if (s.scope.isSuper) {
return c.json({ logs: await getSendLog(c.env.KV, limit) });
return c.json({ logs: await getSendLog(c.env.DB, limit) });
}
const all = await loadRoutes(c.env.KV);
const allowed = new Set(
all.filter((r) => r.groupId != null && s.scope.groupIds.has(r.groupId)).map((r) => r.id),
);
const logs = (await getSendLog(c.env.KV, 200))
const logs = (await getSendLog(c.env.DB, 200))
.filter((l) => allowed.has(l.routeId))
.slice(0, limit);
return c.json({ logs });

View file

@ -83,7 +83,7 @@ export function createOAuthRoutes(): Hono<{ Bindings: Env }> {
// Discord account-linking flow: bind the Discord user to this GitHub account.
if (pending.discordUserId) {
await saveDiscordLink(c.env.KV, pending.discordUserId, result.userId);
await saveDiscordLink(c.env.DB, pending.discordUserId, result.userId);
const isBrowserLink = (c.req.header("accept") ?? "").includes("text/html");
if (isBrowserLink) {
return c.html(linkedPage(result.login));