Files
kolaytercih/src/lib/uretim-kilidi.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

78 lines
2.6 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, lt } from "drizzle-orm";
import { appDb, schema } from "./appdb";
import { uniqueIhlaliMi } from "./credits";
const { reportLocks } = schema;
/**
* Bayat kilit eşiği. Tek Yapay Zeka çağrısının sunucu sınırı 60 sn ve üretim
* iki denemeye kadar gidebiliyor (bkz. lib/ai/cagri.ts), üstüne havuz SQL'i ve
* DB yazımı biniyor. 180 sn bu üst sınırın rahatça üstünde: bu kadar eski bir
* kilit ancak süreç öldüyse (OOM, deploy) kalmıştır ve devralınabilir.
*/
const BAYAT_MS = 180_000;
export type KilitSonuc =
| { ok: true }
/** Aynı kullanıcı için taze bir üretim zaten koşuyor. */
| { ok: false; error: "SURUYOR" };
/**
* Kullanıcı başına üretim kilidini alır.
*
* Neden var: `credit_ledger.ref_id` benzersizliği yalnız AYNI denemenin
* tekrarını koruyor (ağda kaybolan cevap → aynı requestId ile retry). Oysa
* `requestId` istemcide her mount'ta yeniden üretiliyor, yani ikinci sekmede
* açılan `/listem?uret=1` ya da geri→ileri ikinci bir üretim başlatıp 3 krediyi
* ikinci kez yakıyordu — tek rapor karşılığında (reports.user_id benzersiz).
*
* Kilit krediden ÖNCE alınır: harcama yapılmadan reddedilir.
*/
export async function uretimKilidiAl(
userId: string,
requestId: string,
): Promise<KilitSonuc> {
const simdi = new Date();
try {
await appDb
.insert(reportLocks)
.values({ userId, requestId, startedAt: simdi });
return { ok: true };
} catch (err) {
if (!uniqueIhlaliMi(err)) throw err;
}
// Kilit var. Yalnız bayatsa devral — koşullu UPDATE olduğu için iki eşzamanlı
// devralma denemesinden yalnız biri satırı günceller (rowsAffected ile ayırt
// edilir), yani yarış çifte harcamaya dönüşemez.
const devir = await appDb
.update(reportLocks)
.set({ requestId, startedAt: simdi })
.where(
and(
eq(reportLocks.userId, userId),
lt(reportLocks.startedAt, new Date(simdi.getTime() - BAYAT_MS)),
),
);
if (devir.rowsAffected > 0) return { ok: true };
return { ok: false, error: "SURUYOR" };
}
/**
* Kilidi bırakır. `requestId` koşulu şart: bayat kilidi devralan başka bir
* deneme varken, geç uyanan eski sahip onun kilidini silmemeli.
*/
export async function uretimKilidiBirak(
userId: string,
requestId: string,
): Promise<void> {
await appDb
.delete(reportLocks)
.where(
and(eq(reportLocks.userId, userId), eq(reportLocks.requestId, requestId)),
)
// Salım hiçbir zaman asıl akışı kırmamalı: kilit en kötü ihtimalle
// BAYAT_MS sonra kendiliğinden devralınabilir hâle gelir.
.catch(() => {});
}