feat
CI / Verify (push) Successful in 1m47s
CI / Build immutable images and deploy (push) Successful in 2m51s

This commit is contained in:
2026-10-01 22:53:48 +07:00 Unverified
parent d85687c332
commit bdce288951
10 changed files with 102 additions and 371 deletions
+43
View File
@@ -0,0 +1,43 @@
import type { SQL } from "drizzle-orm";
import { PgDialect } from "drizzle-orm/pg-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const candidates = vi.fn();
const returning = vi.fn();
const deleteWhere = vi.fn((condition: SQL) => { void condition; return { returning }; });
const deleteRows = vi.fn(() => ({ where: deleteWhere }));
const select = vi.fn(() => ({
from: () => ({
leftJoin: () => ({ where: () => ({ orderBy: () => ({ limit: candidates }) }) }),
where: () => ({}),
}),
}));
const set = vi.fn();
const evalRedis = vi.fn().mockResolvedValue(1);
vi.mock("server-only", () => ({}));
vi.mock("@/db/client", () => ({ getDb: () => ({ select, delete: deleteRows }) }));
vi.mock("@/lib/redis/client", () => ({ getRedisClient: async () => ({ set, eval: evalRedis }) }));
const { removeExpiredUnpaidCheckouts } = await import("./cleanup");
describe("expired checkout cleanup", () => {
beforeEach(() => {
vi.clearAllMocks();
candidates.mockResolvedValue([{ id: "checkout-1" }, { id: "checkout-2" }]);
set.mockResolvedValueOnce("OK").mockResolvedValueOnce(null);
returning.mockResolvedValue([{ id: "checkout-1" }]);
});
it("deletes only an unlocked expired checkout with no ticket", async () => {
expect(await removeExpiredUnpaidCheckouts()).toBe(1);
expect(deleteRows).toHaveBeenCalledOnce();
expect(set).toHaveBeenCalledWith(expect.stringContaining("checkout-1"), expect.any(String), "EX", 60, "NX");
expect(evalRedis).toHaveBeenCalledOnce();
const query = new PgDialect().sqlToQuery(deleteWhere.mock.calls[0][0]);
expect(query.sql).toContain("not exists");
expect(query.sql).toContain('"created_at"');
expect(query.params).toContain("checkout-1");
});
});
+40
View File
@@ -0,0 +1,40 @@
import { and, asc, eq, isNull, lte, notExists } from "drizzle-orm";
import { getDb } from "@/db/client";
import { commissionCheckouts, commissionTickets } from "@/db/schema";
import { CHECKOUT_DURATION_MS } from "@/lib/commission/checkout-expiration";
import { commissionSlipLockKey } from "@/lib/commission/slip-lock";
import { getRedisClient } from "@/lib/redis/client";
const BATCH_SIZE = 100;
export async function removeExpiredUnpaidCheckouts(): Promise<number> {
const db = getDb();
const redis = await getRedisClient();
const cutoff = new Date(Date.now() - CHECKOUT_DURATION_MS);
const candidates = await db.select({ id: commissionCheckouts.id })
.from(commissionCheckouts)
.leftJoin(commissionTickets, eq(commissionTickets.checkoutId, commissionCheckouts.id))
.where(and(lte(commissionCheckouts.createdAt, cutoff), isNull(commissionTickets.id)))
.orderBy(asc(commissionCheckouts.createdAt))
.limit(BATCH_SIZE);
let removed = 0;
for (const { id } of candidates) {
const lockKey = commissionSlipLockKey(id);
const lockId = crypto.randomUUID();
if (await redis.set(lockKey, lockId, "EX", 60, "NX") !== "OK") continue;
try {
const deleted = await db.delete(commissionCheckouts).where(and(
eq(commissionCheckouts.id, id),
lte(commissionCheckouts.createdAt, cutoff),
notExists(db.select({ id: commissionTickets.id }).from(commissionTickets)
.where(eq(commissionTickets.checkoutId, id))),
)).returning({ id: commissionCheckouts.id });
removed += deleted.length;
} finally {
await redis.eval("if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end return 0", 1,
lockKey, lockId).catch(() => undefined);
}
}
return removed;
}
+3
View File
@@ -0,0 +1,3 @@
export function commissionSlipLockKey(checkoutId: string): string {
return `${process.env.REDIS_SECURITY_PREFIX || "buzz:security"}:commission-slip-lock:${checkoutId}`;
}
+2 -1
View File
@@ -7,6 +7,7 @@ import { notifyPaidTicketDiscord } from "@/lib/commission/discord";
import { verifyCommissionSlip } from "@/lib/commission/payment";
import { notifyCommission } from "@/lib/commission/server";
import { checkoutExpired } from "@/lib/commission/checkout-expiration";
import { commissionSlipLockKey } from "@/lib/commission/slip-lock";
import { getMediaStorage } from "@/lib/media/storage";
import { inspectImage } from "@/lib/media/inspect";
import { getRedisClient } from "@/lib/redis/client";
@@ -21,7 +22,7 @@ export async function verifyAndCreateTicket(checkout: Checkout, file: FormDataEn
throw new HttpError(415, "invalid-slip-image");
const redis = await getRedisClient();
const lockKey = `${process.env.REDIS_SECURITY_PREFIX || "buzz:security"}:commission-slip-lock:${checkout.id}`;
const lockKey = commissionSlipLockKey(checkout.id);
const lockId = crypto.randomUUID();
if (await redis.set(lockKey, lockId, "EX", 60, "NX") !== "OK")
throw new HttpError(409, "slip-verification-in-progress");