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

@@ -0,0 +1,6 @@
CREATE TABLE `report_locks` (
`user_id` text PRIMARY KEY NOT NULL,
`request_id` text NOT NULL,
`started_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
);

View File

@@ -0,0 +1,966 @@
{
"version": "6",
"dialect": "sqlite",
"id": "79c73a04-96a6-4849-adf6-63a502c314d0",
"prevId": "5fbf03eb-6857-4ba1-b111-819e8aabe877",
"tables": {
"account": {
"name": "account",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"chat_messages": {
"name": "chat_messages",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"client_message_id": {
"name": "client_message_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"chat_messages_client_message_id_unique": {
"name": "chat_messages_client_message_id_unique",
"columns": [
"client_message_id"
],
"isUnique": true
},
"chat_user_created": {
"name": "chat_user_created",
"columns": [
"user_id",
"created_at"
],
"isUnique": false
}
},
"foreignKeys": {
"chat_messages_user_id_user_id_fk": {
"name": "chat_messages_user_id_user_id_fk",
"tableFrom": "chat_messages",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"credit_ledger": {
"name": "credit_ledger",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"delta": {
"name": "delta",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ref_id": {
"name": "ref_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"ledger_reason_ref": {
"name": "ledger_reason_ref",
"columns": [
"reason",
"ref_id"
],
"isUnique": true
},
"ledger_user": {
"name": "ledger_user",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"credit_ledger_user_id_user_id_fk": {
"name": "credit_ledger_user_id_user_id_fk",
"tableFrom": "credit_ledger",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"orders": {
"name": "orders",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"product": {
"name": "product",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"amount_kurus": {
"name": "amount_kurus",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"credits": {
"name": "credits",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"iyzico_token": {
"name": "iyzico_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"iyzico_form_content": {
"name": "iyzico_form_content",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"iyzico_payment_id": {
"name": "iyzico_payment_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"paid_at": {
"name": "paid_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"orders_user": {
"name": "orders_user",
"columns": [
"user_id"
],
"isUnique": false
},
"orders_iyzico_token": {
"name": "orders_iyzico_token",
"columns": [
"iyzico_token"
],
"isUnique": true
}
},
"foreignKeys": {
"orders_user_id_user_id_fk": {
"name": "orders_user_id_user_id_fk",
"tableFrom": "orders",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"report_locks": {
"name": "report_locks",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"request_id": {
"name": "request_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"started_at": {
"name": "started_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"report_locks_user_id_user_id_fk": {
"name": "report_locks_user_id_user_id_fk",
"tableFrom": "report_locks",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"reports": {
"name": "reports",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"params": {
"name": "params",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"result": {
"name": "result",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"revision_count": {
"name": "revision_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"reports_user_id_unique": {
"name": "reports_user_id_unique",
"columns": [
"user_id"
],
"isUnique": true
}
},
"foreignKeys": {
"reports_user_id_user_id_fk": {
"name": "reports_user_id_user_id_fk",
"tableFrom": "reports",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"saved_lists": {
"name": "saved_lists",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"items": {
"name": "items",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"profil": {
"name": "profil",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"saved_lists_user_id_unique": {
"name": "saved_lists_user_id_unique",
"columns": [
"user_id"
],
"isUnique": true
}
},
"foreignKeys": {
"saved_lists_user_id_user_id_fk": {
"name": "saved_lists_user_id_user_id_fk",
"tableFrom": "saved_lists",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session": {
"name": "session",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"session_token_unique": {
"name": "session_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tadimlik_havuzu": {
"name": "tadimlik_havuzu",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"kova_slug": {
"name": "kova_slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tur": {
"name": "tur",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"kategori_slug": {
"name": "kategori_slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tip": {
"name": "tip",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'genel'"
},
"program_id": {
"name": "program_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dilim": {
"name": "dilim",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"gerekce": {
"name": "gerekce",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"risk_notu": {
"name": "risk_notu",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"trend_ozeti": {
"name": "trend_ozeti",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"program": {
"name": "program",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ornek_sira": {
"name": "ornek_sira",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"uretim_at": {
"name": "uretim_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"tadimlik_anahtar": {
"name": "tadimlik_anahtar",
"columns": [
"kova_slug",
"tur",
"kategori_slug",
"tip"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email_verified": {
"name": "email_verified",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"credit_balance": {
"name": "credit_balance",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"has_paket": {
"name": "has_paket",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"kredi_bitti_at": {
"name": "kredi_bitti_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"kredi_hatirlatma_gonderildi_at": {
"name": "kredi_hatirlatma_gonderildi_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"verification": {
"name": "verification",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -29,6 +29,13 @@
"when": 1786397951019, "when": 1786397951019,
"tag": "0003_furry_nekra", "tag": "0003_furry_nekra",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1790031029618,
"tag": "0004_shocking_caretaker",
"breakpoints": true
} }
] ]
} }

View File

@@ -44,22 +44,33 @@ const GECIS_SINIRI_MS = 6000;
// kesilmez, cevap gelirse akış normal devam eder. // kesilmez, cevap gelirse akış normal devam eder.
const UZUN_SURE_MS = 90_000; 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. /** 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ı * 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> { async function sunucuAyaktaMi(): Promise<boolean> {
try { const zamanasimiDestekli = typeof AbortSignal.timeout === "function";
const yanit = await fetch("/api/saglik", { const istek = fetch("/api/saglik", {
cache: "no-store", cache: "no-store",
signal: signal: zamanasimiDestekli
typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(SAGLIK_YOKLAMA_MS)
? AbortSignal.timeout(5000) : undefined,
: undefined, })
}); .then((yanit) => yanit.ok)
return yanit.ok; .catch(() => false);
} catch {
return false; if (zamanasimiDestekli) return istek;
} return Promise.race([
istek,
new Promise<boolean>((coz) =>
setTimeout(() => coz(false), SAGLIK_YOKLAMA_MS),
),
]);
} }
type Durum = type Durum =
@@ -70,6 +81,8 @@ type Durum =
| { ad: "geciliyor" } | { ad: "geciliyor" }
// Liste kuruldu ve kaydedildi ama sunucu yanıt vermiyor (ters vekil 502) // Liste kuruldu ve kaydedildi ama sunucu yanıt vermiyor (ters vekil 502)
| { ad: "sunucu-yok" } | { ad: "sunucu-yok" }
// Aynı kullanıcı için üretim başka bir sekmede/denemede zaten koşuyor
| { ad: "suruyor" }
| { ad: "kredi-yetersiz" } | { ad: "kredi-yetersiz" }
| { ad: "hata"; mesaj: string } | { ad: "hata"; mesaj: string }
| { ad: "profil-yok" }; | { ad: "profil-yok" };
@@ -128,6 +141,27 @@ export function ListeUretici({
tur: profil.tur, tur: profil.tur,
sira_kovasi: siraKovasi(profil.sira), 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" }); setDurum({ ad: "geciliyor" });
// Next'in kendi gezinmesi: URL (?uret=1 düşer) ve sayfa ağacı birlikte // Next'in kendi gezinmesi: URL (?uret=1 düşer) ve sayfa ağacı birlikte
// güncellenir. Burada ikinci bir yönlendirici aksiyonu (refresh) YOK — // güncellenir. Burada ikinci bir yönlendirici aksiyonu (refresh) YOK —
@@ -142,6 +176,13 @@ export function ListeUretici({
if (sonuc.code === "KREDI" || sonuc.code === "PAKET") { if (sonuc.code === "KREDI" || sonuc.code === "PAKET") {
olay("kredi_bitti"); olay("kredi_bitti");
setDurum({ ad: "kredi-yetersiz" }); 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") { } else if (sonuc.code === "AUTH") {
router.push(`/giris?callback=${encodeURIComponent("/listem?uret=1")}`); router.push(`/giris?callback=${encodeURIComponent("/listem?uret=1")}`);
} else { } else {
@@ -249,6 +290,8 @@ export function ListeUretici({
if (durum.ad === "sunucu-yok") return <SunucuYokEkrani />; if (durum.ad === "sunucu-yok") return <SunucuYokEkrani />;
if (durum.ad === "suruyor") return <SuruyorEkrani />;
if (durum.ad === "hata") { if (durum.ad === "hata") {
return ( 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"> <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 }) { function UretimGovdesi({ uzunSurdu }: { uzunSurdu: boolean }) {
const mesaj = useUretimMesaji(); const mesaj = useUretimMesaji();

View File

@@ -1,6 +1,10 @@
"use server"; "use server";
import { and, eq, sql } from "drizzle-orm"; import { and, eq, sql } from "drizzle-orm";
import {
uretimKilidiAl,
uretimKilidiBirak,
} from "@/lib/uretim-kilidi";
import { getSession, getCurrentUser } from "@/lib/session"; import { getSession, getCurrentUser } from "@/lib/session";
import { appDb, schema } from "@/lib/appdb"; import { appDb, schema } from "@/lib/appdb";
import { kullanicininRaporu } from "@/features/rapor/rapor-queries"; import { kullanicininRaporu } from "@/features/rapor/rapor-queries";
@@ -35,7 +39,14 @@ export type ListeSonuc =
kredi: number; kredi: number;
hasPaket: boolean; 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 { function siraTurGecerli(sira: number, tur: string): tur is PuanTuruKey {
return ( return (
@@ -115,12 +126,35 @@ export async function listeOlustur(input: {
const params = paramsBirlestir(input.sira, input.tur, secimler); 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) // 3 kredi düş (idempotent: aynı requestId ile retry çifte harcamaz)
const harcama = await spendCredits({ const harcama = await spendCredits({
userId: user.id, userId: user.id,
amount: RAPOR_KREDI, amount: RAPOR_KREDI,
reason: "report_generate", reason: "report_generate",
refId: input.requestId, refId: requestId,
}); });
if (!harcama.ok) { if (!harcama.ok) {
if (harcama.error === "INSUFFICIENT") { if (harcama.error === "INSUFFICIENT") {
@@ -137,7 +171,7 @@ export async function listeOlustur(input: {
const iade = await appDb.query.creditLedger.findFirst({ const iade = await appDb.query.creditLedger.findFirst({
where: and( where: and(
eq(schema.creditLedger.reason, "refund"), eq(schema.creditLedger.reason, "refund"),
eq(schema.creditLedger.refId, input.requestId), eq(schema.creditLedger.refId, requestId),
), ),
}); });
if (iade) { if (iade) {
@@ -160,7 +194,14 @@ export async function listeOlustur(input: {
hasPaket: user.hasPaket, 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; let sonuc: RaporSonuc;
@@ -172,7 +213,7 @@ export async function listeOlustur(input: {
userId: user.id, userId: user.id,
delta: RAPOR_KREDI, delta: RAPOR_KREDI,
reason: "refund", reason: "refund",
refId: input.requestId, refId: requestId,
}).catch(() => {}); }).catch(() => {});
return { ok: false, code: "HATA", error: aiHataMesaji(err) }; return { ok: false, code: "HATA", error: aiHataMesaji(err) };
} }

View File

@@ -163,6 +163,24 @@ export const reports = sqliteTable("reports", {
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(), 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ı. // Anonim /sonuc tadımlığı: batch üretilmiş tek tercih satırları.
// Anahtar: sıra kovası × puan türü × kategori × üniversite tipi // Anahtar: sıra kovası × puan türü × kategori × üniversite tipi
// ("genel" = kategorisiz/tipsiz fallback). Anonim istek LLM'e asla gitmez; // ("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 * 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. * ü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++) { for (let e: unknown = err, i = 0; e != null && i < 5; i++) {
const h = e as { const h = e as {
message?: unknown; 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(() => {});
}