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

@@ -44,22 +44,33 @@ const GECIS_SINIRI_MS = 6000;
// kesilmez, cevap gelirse akış normal devam eder.
const UZUN_SURE_MS = 90_000;
const SAGLIK_YOKLAMA_MS = 5000;
/** Sunucu ayakta mı (bkz. app/api/saglik). Ağ hatası ve 5xx "hayır" sayılır.
* AbortSignal.timeout Safari 16'da geldi; eski iPad'lerde yokluğu yoklamayı
* "sunucu ölü" sanmaya çevirmesin diye varlığı denetlenir. */
* "sunucu ölü" sanmaya çevirmesin diye varlığı denetlenir — ama o cihazlarda
* istek süresiz asılabilir ve yoklama artık MUTLU YOLDA (gezinmeden önce)
* koştuğu için asılması her başarılı üretimi kilitlerdi; zamanaşımı bu yüzden
* yarışla da kurulur. Kaybeden fetch'in sonradan reddetmesi `catch` ile
* yutulur, aksi hâlde sahipsiz reddetme olurdu. */
async function sunucuAyaktaMi(): Promise<boolean> {
try {
const yanit = await fetch("/api/saglik", {
cache: "no-store",
signal:
typeof AbortSignal.timeout === "function"
? AbortSignal.timeout(5000)
: undefined,
});
return yanit.ok;
} catch {
return false;
}
const zamanasimiDestekli = typeof AbortSignal.timeout === "function";
const istek = fetch("/api/saglik", {
cache: "no-store",
signal: zamanasimiDestekli
? AbortSignal.timeout(SAGLIK_YOKLAMA_MS)
: undefined,
})
.then((yanit) => yanit.ok)
.catch(() => false);
if (zamanasimiDestekli) return istek;
return Promise.race([
istek,
new Promise<boolean>((coz) =>
setTimeout(() => coz(false), SAGLIK_YOKLAMA_MS),
),
]);
}
type Durum =
@@ -70,6 +81,8 @@ type Durum =
| { ad: "geciliyor" }
// Liste kuruldu ve kaydedildi ama sunucu yanıt vermiyor (ters vekil 502)
| { ad: "sunucu-yok" }
// Aynı kullanıcı için üretim başka bir sekmede/denemede zaten koşuyor
| { ad: "suruyor" }
| { ad: "kredi-yetersiz" }
| { ad: "hata"; mesaj: string }
| { ad: "profil-yok" };
@@ -128,6 +141,27 @@ export function ListeUretici({
tur: profil.tur,
sira_kovasi: siraKovasi(profil.sira),
});
// BİRİNCİL YOL KORUMASI (22 Eyl 2026). Emniyet supabındaki yoklama tek
// başına yetmiyor: router.replace'in RSC isteği ölü sunucuda 502'ye
// düşerse tam-sayfa gezinmeyi Next'in KENDİSİ yapıyor ve supap hiç
// çalışmıyor. Zincir (next 16.2.10):
// fetch-server-response.js — cevap flight değilse ya da !res.ok ise
// doMpaNavigation(url) → string döner
// segment-cache/navigation.js — typeof result === "string" →
// completeHardNavigation → pushRef.mpaNavigation = true
// app-router.js — mpaNavigation'da location.replace(canonicalUrl)
// Sonuç ters vekilin düz metin "Bad Gateway" gövdesinin tarayıcıya
// inmesi, WebKit'te de listem.txt olarak indirilmesiydi.
//
// Yoklama BİLEREK "geciliyor"dan önce: o durum 6 sn'lik supap
// zamanlayıcısını kuruyor, yoklamayı sonrasına koymak iki yolu
// yarıştırıp aynı ekranı iki kez yazdırırdı. Üretim ekranı birkaç
// milisaniye daha durur, kullanıcı için fark yok.
if (!(await sunucuAyaktaMi())) {
olay("liste_uretim_hata", { kod: "SUNUCUYA_ULASILAMIYOR" });
setDurum({ ad: "sunucu-yok" });
return;
}
setDurum({ ad: "geciliyor" });
// Next'in kendi gezinmesi: URL (?uret=1 düşer) ve sayfa ağacı birlikte
// güncellenir. Burada ikinci bir yönlendirici aksiyonu (refresh) YOK —
@@ -142,6 +176,13 @@ export function ListeUretici({
if (sonuc.code === "KREDI" || sonuc.code === "PAKET") {
olay("kredi_bitti");
setDurum({ ad: "kredi-yetersiz" });
} else if (sonuc.code === "SURUYOR") {
// Kredi HARCANMADI: sunucu kilidi krediden önce kontrol ediyor. Bu dalda
// yeniden denetmiyoruz — "Tekrar dene" taze bir requestId üretir ve
// kilit düştüğü anda ikinci bir üretim başlatır, yani tam kaçındığımız
// çifte harcama. Kullanıcıyı listeye yönlendiriyoruz; asıl üretim orada.
olay("liste_uretim_hata", { kod: "SURUYOR" });
setDurum({ ad: "suruyor" });
} else if (sonuc.code === "AUTH") {
router.push(`/giris?callback=${encodeURIComponent("/listem?uret=1")}`);
} else {
@@ -249,6 +290,8 @@ export function ListeUretici({
if (durum.ad === "sunucu-yok") return <SunucuYokEkrani />;
if (durum.ad === "suruyor") return <SuruyorEkrani />;
if (durum.ad === "hata") {
return (
<section className="mx-auto w-full max-w-2xl rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
@@ -327,6 +370,31 @@ function SunucuYokEkrani() {
);
}
/** Üretim başka bir sekmede/denemede sürüyor. Kredi burada HARCANMADI (kilit
* kredi harcamasından önce alınıyor, bkz. lib/uretim-kilidi.ts). Bilerek
* "Tekrar dene" yok: tekrar taze requestId üretip ikinci üretimi başlatırdı. */
function SuruyorEkrani() {
return (
<section className="mx-auto w-full max-w-2xl rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
<h1 className="font-heading text-xl font-bold">Listen zaten hazırlanıyor</h1>
<p className="mx-auto mt-2 max-w-md text-sm text-slate-600">
Bunu başka bir sekmede başlatmışsın. İki kez kurmayalım — kredin bu
denemede harcanmadı. Hazır olunca listende görünecek.
</p>
<Button
asChild
size="lg"
className="mt-6 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href="/listem">
Listeme git
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
</section>
);
}
function UretimGovdesi({ uzunSurdu }: { uzunSurdu: boolean }) {
const mesaj = useUretimMesaji();

View File

@@ -1,6 +1,10 @@
"use server";
import { and, eq, sql } from "drizzle-orm";
import {
uretimKilidiAl,
uretimKilidiBirak,
} from "@/lib/uretim-kilidi";
import { getSession, getCurrentUser } from "@/lib/session";
import { appDb, schema } from "@/lib/appdb";
import { kullanicininRaporu } from "@/features/rapor/rapor-queries";
@@ -35,7 +39,14 @@ export type ListeSonuc =
kredi: number;
hasPaket: boolean;
}
| { ok: false; error: string; code: "AUTH" | "PAKET" | "KREDI" | "HATA" };
| {
ok: false;
error: string;
// SURUYOR: aynı kullanıcı için üretim hâlihazırda koşuyor (ikinci sekme,
// geri→ileri). Ayrı kod, çünkü istemci bunu "hata" sayıp taze bir
// requestId ile yeniden denerse çifte harcama kapısı yeniden açılır.
code: "AUTH" | "PAKET" | "KREDI" | "SURUYOR" | "HATA";
};
function siraTurGecerli(sira: number, tur: string): tur is PuanTuruKey {
return (
@@ -115,12 +126,35 @@ export async function listeOlustur(input: {
const params = paramsBirlestir(input.sira, input.tur, secimler);
// Kullanıcı başına üretim kilidi — kredi harcamasından ÖNCE. requestId
// idempotansı yalnız aynı denemenin tekrarını koruyor; ikinci sekmede açılan
// /listem?uret=1 taze bir requestId üretip 3 krediyi ikinci kez yakıyordu.
const kilit = await uretimKilidiAl(user.id, input.requestId);
if (!kilit.ok) {
return {
ok: false,
code: "SURUYOR",
error: "Listen zaten hazırlanıyor. Bu sekmeyi kapatabilirsin.",
};
}
try {
return await listeOlusturKilitli(user, params, input.requestId);
} finally {
await uretimKilidiBirak(user.id, input.requestId);
}
}
async function listeOlusturKilitli(
user: NonNullable<Awaited<ReturnType<typeof getCurrentUser>>>,
params: RaporParams,
requestId: string,
): Promise<ListeSonuc> {
// 3 kredi düş (idempotent: aynı requestId ile retry çifte harcamaz)
const harcama = await spendCredits({
userId: user.id,
amount: RAPOR_KREDI,
reason: "report_generate",
refId: input.requestId,
refId: requestId,
});
if (!harcama.ok) {
if (harcama.error === "INSUFFICIENT") {
@@ -137,7 +171,7 @@ export async function listeOlustur(input: {
const iade = await appDb.query.creditLedger.findFirst({
where: and(
eq(schema.creditLedger.reason, "refund"),
eq(schema.creditLedger.refId, input.requestId),
eq(schema.creditLedger.refId, requestId),
),
});
if (iade) {
@@ -160,7 +194,14 @@ export async function listeOlustur(input: {
hasPaket: user.hasPaket,
};
}
return { ok: false, code: "HATA", error: "İstek tekrarlandı." };
// Aynı requestId harcanmış, iade yok, rapor da henüz yazılmamış → o deneme
// hâlâ uçuşta. Genel HATA dönmek istemciyi taze bir requestId üretip
// yeniden denemeye itiyordu (BACKLOG #1'deki ikinci çifte harcama yolu).
return {
ok: false,
code: "SURUYOR",
error: "Listen zaten hazırlanıyor. Bu sekmeyi kapatabilirsin.",
};
}
let sonuc: RaporSonuc;
@@ -172,7 +213,7 @@ export async function listeOlustur(input: {
userId: user.id,
delta: RAPOR_KREDI,
reason: "refund",
refId: input.requestId,
refId: requestId,
}).catch(() => {});
return { ok: false, code: "HATA", error: aiHataMesaji(err) };
}