Files
kolaytercih/src/lib/credits.ts
bilalgursen 9bd448bd16
All checks were successful
Deploy / deploy (push) Successful in 10m52s
fix(kredi): kullanıcı başına üretim kilidi — çifte harcama kapanır
credit_ledger.ref_id benzersizliği yalnız AYNI denemenin tekrarını koruyordu
(ağda kaybolan cevap → aynı requestId ile retry). requestId istemcide her
mount'ta yeniden üretildiği için "aynı kullanıcı üretimi iki kez başlattı"
durumu hiç yakalanmıyordu: üretim sürerken (20-60 sn) ikinci sekmede açılan
/listem?uret=1 ya da geri→ileri ikinci bir ListeUretici monte edip 3 krediyi
ikinci kez yakıyordu. Karşılığında tek rapor çıkıyordu, çünkü reports.user_id
benzersiz ve ikinci üretim birincinin üstüne yazıyor. Bakiyesi yetmeyen
deneme kullanıcısı ise akışın ortasında "kredi yetersiz" ekranı görüyordu.

- report_locks (user_id birincil anahtar): ikinci INSERT benzersizlik
  ihlaliyle düşer, bu da "üretim sürüyor" demektir. Kilit kredi
  harcamasından ÖNCE alınır, finally'de bırakılır.
- Bayat kilit (süreç OOM'la öldü, deploy) 180 sn sonra devralınabilir;
  devir koşullu UPDATE olduğu için iki eşzamanlı devralmadan yalnız biri
  kazanır. Salım requestId koşullu: geç uyanan eski sahip devralanın
  kilidini silemez.
- Yeni SURUYOR kodu: istemci bunu "hata" sayıp taze requestId ile yeniden
  denerse çifte harcama kapısı yeniden açılırdı. Ekranda "Tekrar dene"
  bilerek yok; kullanıcı listeye yönlendiriliyor, kredi harcanmadı.
- DUPLICATE dalının "İstek tekrarlandı" HATA'sı da SURUYOR'a çevrildi
  (BACKLOG #1'deki ikinci çifte harcama yolu). Revizyon dalı değişmedi.

BACKLOG #1 · güvenlik denetimi G5

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 01:54:21 +03:00

236 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { and, eq, gte, isNull, sql } from "drizzle-orm";
import { appDb, schema } from "./appdb";
const { user, creditLedger, chatMessages } = schema;
export const URUNLER = {
paket: { amountKurus: 29900, credits: 60, label: "Tercih Dönemi Paketi" },
topup: { amountKurus: 12900, credits: 30, label: "+30 Kredi" },
} as const;
export const DENEME_KREDISI = 5;
// Rapor üretimi/revizyonu başına düşen kredi
export const RAPOR_KREDI = 3;
/** Paketle gelen geri bildirimli liste revizyonu hakkı */
export const MAX_REVIZYON = 2;
/**
* UNIQUE ihlali mi? drizzle sürücü hatasını DrizzleQueryError'a sarar: üst
* mesaj "Failed query: …" olur, "UNIQUE" yalnız `cause` zincirinde kalır. Yalnız
* üst mesaja bakmak idempotent no-op yerine hatayı yukarı fırlatıyordu.
*/
export function uniqueIhlaliMi(err: unknown): boolean {
for (let e: unknown = err, i = 0; e != null && i < 5; i++) {
const h = e as {
message?: unknown;
code?: unknown;
extendedCode?: unknown;
cause?: unknown;
};
if (
String(h.message ?? "").includes("UNIQUE") ||
String(h.extendedCode ?? h.code ?? "").includes("CONSTRAINT_UNIQUE")
) {
return true;
}
e = h.cause;
}
return false;
}
export type SpendResult =
| { ok: true }
| { ok: false; error: "INSUFFICIENT" | "DUPLICATE" };
function newId() {
return crypto.randomUUID();
}
/**
* Sabit tutarda kredi düşer + ledger kaydı — tek transaction, iade edilebilir.
* Rapor üretimi ve revizyonu için (3 kredi). refId idempotency anahtarıdır:
* aynı requestId ile ikinci çağrı DUPLICATE döner (çifte harcama olmaz).
* Bakiye yetersizse hiçbir şey yazılmaz.
*/
export async function spendCredits(opts: {
userId: string;
amount: number;
reason: "report_generate" | "report_revision";
refId: string;
}): Promise<SpendResult> {
try {
await appDb.transaction(async (tx) => {
const res = await tx
.update(user)
.set({
creditBalance: sql`${user.creditBalance} - ${opts.amount}`,
updatedAt: new Date(),
})
.where(
and(eq(user.id, opts.userId), gte(user.creditBalance, opts.amount)),
);
if (res.rowsAffected === 0) throw new Error("INSUFFICIENT");
await tx.insert(creditLedger).values({
id: newId(),
userId: opts.userId,
delta: -opts.amount,
reason: opts.reason,
refId: opts.refId,
createdAt: new Date(),
});
});
return { ok: true };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg === "INSUFFICIENT") return { ok: false, error: "INSUFFICIENT" };
if (uniqueIhlaliMi(err)) return { ok: false, error: "DUPLICATE" };
throw err;
}
}
/**
* 1 kredi düşer + ledger kaydı + kullanıcı mesajını persist eder — tek transaction.
* Bakiye yetersizse hiçbir şey yazılmaz. clientMessageId unique → retry'da çifte
* harcama yerine DUPLICATE döner.
*/
export async function spendCreditForMessage(opts: {
userId: string;
content: string;
clientMessageId: string;
}): Promise<SpendResult & { messageId?: string }> {
const messageId = newId();
try {
await appDb.transaction(async (tx) => {
const res = await tx
.update(user)
.set({
creditBalance: sql`${user.creditBalance} - 1`,
updatedAt: new Date(),
})
.where(and(eq(user.id, opts.userId), gte(user.creditBalance, 1)));
if (res.rowsAffected === 0) throw new Error("INSUFFICIENT");
await tx.insert(chatMessages).values({
id: messageId,
userId: opts.userId,
role: "user",
content: opts.content,
clientMessageId: opts.clientMessageId,
createdAt: new Date(),
});
await tx.insert(creditLedger).values({
id: newId(),
userId: opts.userId,
delta: -1,
reason: "chat_message",
refId: messageId,
createdAt: new Date(),
});
});
return { ok: true, messageId };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg === "INSUFFICIENT") return { ok: false, error: "INSUFFICIENT" };
if (uniqueIhlaliMi(err)) return { ok: false, error: "DUPLICATE" };
throw err;
}
}
/** `paketTekil` ile çağrılan tanımlamada kullanıcı zaten paketliyse fırlatılır. */
class ZatenPaketli extends Error {}
/**
* Kredi tanımlama (deneme, satın alma, top-up, iade). UNIQUE(reason, refId)
* sayesinde aynı referansla ikinci çağrı sessizce no-op olur → idempotent.
*
* `paketTekil`: paket tek seferliktir. Kullanıcı başka bir siparişle zaten
* paketliyse kredi YAZILMAZ (`neden: "zaten-paketli"`); çağıran bu ödemeyi
* iade edilecek diye işaretler. Kontrol, bakiye güncellemesinin WHERE'inde
* yapılır ki eşzamanlı iki ödeme de tek paket yazabilsin.
*/
export async function grantCredits(opts: {
userId: string;
delta: number;
reason: "trial_grant" | "purchase" | "topup" | "refund";
refId: string;
setHasPaket?: boolean;
paketTekil?: boolean;
}): Promise<{ granted: boolean; neden?: "tekrar" | "zaten-paketli" }> {
try {
await appDb.transaction(async (tx) => {
await tx.insert(creditLedger).values({
id: newId(),
userId: opts.userId,
delta: opts.delta,
reason: opts.reason,
refId: opts.refId,
createdAt: new Date(),
});
const res = await tx
.update(user)
.set({
creditBalance: sql`${user.creditBalance} + ${opts.delta}`,
...(opts.setHasPaket ? { hasPaket: true } : {}),
// Kredi gelince bekleyen kredi-bitti hatırlatması iptal olur
krediBittiAt: null,
updatedAt: new Date(),
})
.where(
opts.paketTekil
? and(eq(user.id, opts.userId), eq(user.hasPaket, false))
: eq(user.id, opts.userId),
);
// 0 satır: kullanıcı zaten paketli → defter kaydını da geri al
if (opts.paketTekil && res.rowsAffected === 0) throw new ZatenPaketli();
});
return { granted: true };
} catch (err) {
if (err instanceof ZatenPaketli) {
return { granted: false, neden: "zaten-paketli" };
}
if (uniqueIhlaliMi(err)) return { granted: false, neden: "tekrar" };
throw err;
}
}
/**
* Bu siparişin kredisi deftere düşmüş mü? `reason` şart: UNIQUE(reason, ref_id)
* indeksi kullanılsın (yalnız ref_id tam tablo tarar) ve aynı refId'li bir
* `refund` satırı "kredi yazılmış" sayılmasın.
*/
export async function krediKaydiVarMi(
refId: string,
reason: "purchase" | "topup",
): Promise<boolean> {
const rows = await appDb
.select({ id: creditLedger.id })
.from(creditLedger)
.where(and(eq(creditLedger.reason, reason), eq(creditLedger.refId, refId)))
.limit(1);
return rows.length > 0;
}
/**
* Kredi-bitti anını işaretler (48 saatlik hatırlatma e-postasının saati).
* Yalnızca ilk bitişte yazar (kolon doluysa no-op) ve hatırlatma zaten
* gönderildiyse yeniden kurmaz — e-posta ömür boyu 1 kez. Hata yolunda
* çağrıldığı için asla throw etmez.
*/
export async function krediBittiIsaretle(userId: string): Promise<void> {
try {
await appDb
.update(user)
.set({ krediBittiAt: new Date() })
.where(
and(
eq(user.id, userId),
isNull(user.krediBittiAt),
isNull(user.krediHatirlatmaGonderildiAt),
),
);
} catch (err) {
console.error(`[kredi] kredi-bitti işaretlenemedi (${userId}):`, err);
}
}