fix(kredi): kullanıcı başına üretim kilidi — çifte harcama kapanır
All checks were successful
Deploy / deploy (push) Successful in 10m52s
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:
6
drizzle/0004_shocking_caretaker.sql
Normal file
6
drizzle/0004_shocking_caretaker.sql
Normal 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
|
||||||
|
);
|
||||||
966
drizzle/meta/0004_snapshot.json
Normal file
966
drizzle/meta/0004_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
});
|
})
|
||||||
return yanit.ok;
|
.then((yanit) => yanit.ok)
|
||||||
} catch {
|
.catch(() => false);
|
||||||
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();
|
||||||
|
|
||||||
|
|||||||
@@ -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) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
77
src/lib/uretim-kilidi.ts
Normal 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(() => {});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user