From 9bd448bd167906e7c69159e40546e5e6c40ec27d Mon Sep 17 00:00:00 2001 From: bilalgursen Date: Tue, 22 Sep 2026 01:54:21 +0300 Subject: [PATCH] =?UTF-8?q?fix(kredi):=20kullan=C4=B1c=C4=B1=20ba=C5=9F?= =?UTF-8?q?=C4=B1na=20=C3=BCretim=20kilidi=20=E2=80=94=20=C3=A7ifte=20harc?= =?UTF-8?q?ama=20kapan=C4=B1r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- drizzle/0004_shocking_caretaker.sql | 6 + drizzle/meta/0004_snapshot.json | 966 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + .../rapor/components/liste-uretici.tsx | 94 +- src/features/rapor/rapor-actions.ts | 51 +- src/lib/appdb/schema.ts | 18 + src/lib/credits.ts | 2 +- src/lib/uretim-kilidi.ts | 77 ++ 8 files changed, 1202 insertions(+), 19 deletions(-) create mode 100644 drizzle/0004_shocking_caretaker.sql create mode 100644 drizzle/meta/0004_snapshot.json create mode 100644 src/lib/uretim-kilidi.ts diff --git a/drizzle/0004_shocking_caretaker.sql b/drizzle/0004_shocking_caretaker.sql new file mode 100644 index 0000000..9047091 --- /dev/null +++ b/drizzle/0004_shocking_caretaker.sql @@ -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 +); diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..c3c28e6 --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 772c6a4..838c808 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1786397951019, "tag": "0003_furry_nekra", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1790031029618, + "tag": "0004_shocking_caretaker", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/features/rapor/components/liste-uretici.tsx b/src/features/rapor/components/liste-uretici.tsx index 0ac335d..39f6bef 100644 --- a/src/features/rapor/components/liste-uretici.tsx +++ b/src/features/rapor/components/liste-uretici.tsx @@ -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 { - 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((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 ; + if (durum.ad === "suruyor") return ; + if (durum.ad === "hata") { return (
@@ -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 ( +
+

Listen zaten hazırlanıyor

+

+ Bunu başka bir sekmede başlatmışsın. İki kez kurmayalım — kredin bu + denemede harcanmadı. Hazır olunca listende görünecek. +

+ +
+ ); +} + function UretimGovdesi({ uzunSurdu }: { uzunSurdu: boolean }) { const mesaj = useUretimMesaji(); diff --git a/src/features/rapor/rapor-actions.ts b/src/features/rapor/rapor-actions.ts index 741f113..66c6acd 100644 --- a/src/features/rapor/rapor-actions.ts +++ b/src/features/rapor/rapor-actions.ts @@ -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>>, + params: RaporParams, + requestId: string, +): Promise { // 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) }; } diff --git a/src/lib/appdb/schema.ts b/src/lib/appdb/schema.ts index c990123..19042bd 100644 --- a/src/lib/appdb/schema.ts +++ b/src/lib/appdb/schema.ts @@ -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; diff --git a/src/lib/credits.ts b/src/lib/credits.ts index 280a238..2683b5c 100644 --- a/src/lib/credits.ts +++ b/src/lib/credits.ts @@ -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; diff --git a/src/lib/uretim-kilidi.ts b/src/lib/uretim-kilidi.ts new file mode 100644 index 0000000..2ef3157 --- /dev/null +++ b/src/lib/uretim-kilidi.ts @@ -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 { + 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 { + 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(() => {}); +}