fix(kredi): kullanıcı başına üretim kilidi — çifte harcama kapanır
All checks were successful
Deploy / deploy (push) Successful in 10m52s

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>
This commit is contained in:
bilalgursen
2026-09-22 01:54:21 +03:00
parent bca1849eee
commit 9bd448bd16
8 changed files with 1202 additions and 19 deletions

View File

@@ -163,6 +163,24 @@ export const reports = sqliteTable("reports", {
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});
// Kullanıcı başına üretim kilidi. `credit_ledger.ref_id` benzersizliği yalnız
// AYNI denemenin tekrarını (ağda kaybolan cevap) koruyor; requestId istemcide
// her mount'ta yeniden üretildiği için "aynı kullanıcı üretimi iki kez
// başlattı" durumunu hiç yakalamıyordu: ikinci sekme ya da geri→ileri, ikinci
// bir ListeUretici monte edip 3 krediyi ikinci kez yakıyordu (tek rapor,
// çünkü reports.user_id benzersiz ve üstüne yazılıyor).
// userId birincil anahtar → ikinci INSERT benzersizlik ihlaliyle düşer, bu da
// "üretim sürüyor" demektir. startedAt bayat kilitleri (süreç öldü, konteyner
// yeniden başladı) devralmak için var.
export const reportLocks = sqliteTable("report_locks", {
userId: text("user_id")
.primaryKey()
.references(() => user.id),
/** Kilidi tutan deneme; salım yalnız sahibi tarafından yapılabilsin diye. */
requestId: text("request_id").notNull(),
startedAt: integer("started_at", { mode: "timestamp" }).notNull(),
});
// Anonim /sonuc tadımlığı: batch üretilmiş tek tercih satırları.
// Anahtar: sıra kovası × puan türü × kategori × üniversite tipi
// ("genel" = kategorisiz/tipsiz fallback). Anonim istek LLM'e asla gitmez;

View File

@@ -21,7 +21,7 @@ export const MAX_REVIZYON = 2;
* 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.
*/
function uniqueIhlaliMi(err: unknown): boolean {
export function uniqueIhlaliMi(err: unknown): boolean {
for (let e: unknown = err, i = 0; e != null && i < 5; i++) {
const h = e as {
message?: unknown;

77
src/lib/uretim-kilidi.ts Normal file
View File

@@ -0,0 +1,77 @@
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(() => {});
}