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>
236 lines
7.5 KiB
TypeScript
236 lines
7.5 KiB
TypeScript
import { and, eq, gte, isNull, sql } from "drizzle-orm";
|
||
import { appDb, schema } from "./appdb";
|
||
|
||
const { user, creditLedger, chatMessages } = schema;
|
||
|
||
export const URUNLER = {
|
||
paket: { amountKurus: 29900, credits: 60, label: "Tercih Dönemi Paketi" },
|
||
topup: { amountKurus: 12900, credits: 30, label: "+30 Kredi" },
|
||
} as const;
|
||
|
||
export const DENEME_KREDISI = 5;
|
||
|
||
// Rapor üretimi/revizyonu başına düşen kredi
|
||
export const RAPOR_KREDI = 3;
|
||
|
||
/** Paketle gelen geri bildirimli liste revizyonu hakkı */
|
||
export const MAX_REVIZYON = 2;
|
||
|
||
/**
|
||
* UNIQUE ihlali mi? drizzle sürücü hatasını DrizzleQueryError'a sarar: üst
|
||
* 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.
|
||
*/
|
||
export function uniqueIhlaliMi(err: unknown): boolean {
|
||
for (let e: unknown = err, i = 0; e != null && i < 5; i++) {
|
||
const h = e as {
|
||
message?: unknown;
|
||
code?: unknown;
|
||
extendedCode?: unknown;
|
||
cause?: unknown;
|
||
};
|
||
if (
|
||
String(h.message ?? "").includes("UNIQUE") ||
|
||
String(h.extendedCode ?? h.code ?? "").includes("CONSTRAINT_UNIQUE")
|
||
) {
|
||
return true;
|
||
}
|
||
e = h.cause;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
export type SpendResult =
|
||
| { ok: true }
|
||
| { ok: false; error: "INSUFFICIENT" | "DUPLICATE" };
|
||
|
||
function newId() {
|
||
return crypto.randomUUID();
|
||
}
|
||
|
||
/**
|
||
* Sabit tutarda kredi düşer + ledger kaydı — tek transaction, iade edilebilir.
|
||
* Rapor üretimi ve revizyonu için (3 kredi). refId idempotency anahtarıdır:
|
||
* aynı requestId ile ikinci çağrı DUPLICATE döner (çifte harcama olmaz).
|
||
* Bakiye yetersizse hiçbir şey yazılmaz.
|
||
*/
|
||
export async function spendCredits(opts: {
|
||
userId: string;
|
||
amount: number;
|
||
reason: "report_generate" | "report_revision";
|
||
refId: string;
|
||
}): Promise<SpendResult> {
|
||
try {
|
||
await appDb.transaction(async (tx) => {
|
||
const res = await tx
|
||
.update(user)
|
||
.set({
|
||
creditBalance: sql`${user.creditBalance} - ${opts.amount}`,
|
||
updatedAt: new Date(),
|
||
})
|
||
.where(
|
||
and(eq(user.id, opts.userId), gte(user.creditBalance, opts.amount)),
|
||
);
|
||
if (res.rowsAffected === 0) throw new Error("INSUFFICIENT");
|
||
await tx.insert(creditLedger).values({
|
||
id: newId(),
|
||
userId: opts.userId,
|
||
delta: -opts.amount,
|
||
reason: opts.reason,
|
||
refId: opts.refId,
|
||
createdAt: new Date(),
|
||
});
|
||
});
|
||
return { ok: true };
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
if (msg === "INSUFFICIENT") return { ok: false, error: "INSUFFICIENT" };
|
||
if (uniqueIhlaliMi(err)) return { ok: false, error: "DUPLICATE" };
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 1 kredi düşer + ledger kaydı + kullanıcı mesajını persist eder — tek transaction.
|
||
* Bakiye yetersizse hiçbir şey yazılmaz. clientMessageId unique → retry'da çifte
|
||
* harcama yerine DUPLICATE döner.
|
||
*/
|
||
export async function spendCreditForMessage(opts: {
|
||
userId: string;
|
||
content: string;
|
||
clientMessageId: string;
|
||
}): Promise<SpendResult & { messageId?: string }> {
|
||
const messageId = newId();
|
||
try {
|
||
await appDb.transaction(async (tx) => {
|
||
const res = await tx
|
||
.update(user)
|
||
.set({
|
||
creditBalance: sql`${user.creditBalance} - 1`,
|
||
updatedAt: new Date(),
|
||
})
|
||
.where(and(eq(user.id, opts.userId), gte(user.creditBalance, 1)));
|
||
if (res.rowsAffected === 0) throw new Error("INSUFFICIENT");
|
||
await tx.insert(chatMessages).values({
|
||
id: messageId,
|
||
userId: opts.userId,
|
||
role: "user",
|
||
content: opts.content,
|
||
clientMessageId: opts.clientMessageId,
|
||
createdAt: new Date(),
|
||
});
|
||
await tx.insert(creditLedger).values({
|
||
id: newId(),
|
||
userId: opts.userId,
|
||
delta: -1,
|
||
reason: "chat_message",
|
||
refId: messageId,
|
||
createdAt: new Date(),
|
||
});
|
||
});
|
||
return { ok: true, messageId };
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
if (msg === "INSUFFICIENT") return { ok: false, error: "INSUFFICIENT" };
|
||
if (uniqueIhlaliMi(err)) return { ok: false, error: "DUPLICATE" };
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/** `paketTekil` ile çağrılan tanımlamada kullanıcı zaten paketliyse fırlatılır. */
|
||
class ZatenPaketli extends Error {}
|
||
|
||
/**
|
||
* Kredi tanımlama (deneme, satın alma, top-up, iade). UNIQUE(reason, refId)
|
||
* sayesinde aynı referansla ikinci çağrı sessizce no-op olur → idempotent.
|
||
*
|
||
* `paketTekil`: paket tek seferliktir. Kullanıcı başka bir siparişle zaten
|
||
* paketliyse kredi YAZILMAZ (`neden: "zaten-paketli"`); çağıran bu ödemeyi
|
||
* iade edilecek diye işaretler. Kontrol, bakiye güncellemesinin WHERE'inde
|
||
* yapılır ki eşzamanlı iki ödeme de tek paket yazabilsin.
|
||
*/
|
||
export async function grantCredits(opts: {
|
||
userId: string;
|
||
delta: number;
|
||
reason: "trial_grant" | "purchase" | "topup" | "refund";
|
||
refId: string;
|
||
setHasPaket?: boolean;
|
||
paketTekil?: boolean;
|
||
}): Promise<{ granted: boolean; neden?: "tekrar" | "zaten-paketli" }> {
|
||
try {
|
||
await appDb.transaction(async (tx) => {
|
||
await tx.insert(creditLedger).values({
|
||
id: newId(),
|
||
userId: opts.userId,
|
||
delta: opts.delta,
|
||
reason: opts.reason,
|
||
refId: opts.refId,
|
||
createdAt: new Date(),
|
||
});
|
||
const res = await tx
|
||
.update(user)
|
||
.set({
|
||
creditBalance: sql`${user.creditBalance} + ${opts.delta}`,
|
||
...(opts.setHasPaket ? { hasPaket: true } : {}),
|
||
// Kredi gelince bekleyen kredi-bitti hatırlatması iptal olur
|
||
krediBittiAt: null,
|
||
updatedAt: new Date(),
|
||
})
|
||
.where(
|
||
opts.paketTekil
|
||
? and(eq(user.id, opts.userId), eq(user.hasPaket, false))
|
||
: eq(user.id, opts.userId),
|
||
);
|
||
// 0 satır: kullanıcı zaten paketli → defter kaydını da geri al
|
||
if (opts.paketTekil && res.rowsAffected === 0) throw new ZatenPaketli();
|
||
});
|
||
return { granted: true };
|
||
} catch (err) {
|
||
if (err instanceof ZatenPaketli) {
|
||
return { granted: false, neden: "zaten-paketli" };
|
||
}
|
||
if (uniqueIhlaliMi(err)) return { granted: false, neden: "tekrar" };
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Bu siparişin kredisi deftere düşmüş mü? `reason` şart: UNIQUE(reason, ref_id)
|
||
* indeksi kullanılsın (yalnız ref_id tam tablo tarar) ve aynı refId'li bir
|
||
* `refund` satırı "kredi yazılmış" sayılmasın.
|
||
*/
|
||
export async function krediKaydiVarMi(
|
||
refId: string,
|
||
reason: "purchase" | "topup",
|
||
): Promise<boolean> {
|
||
const rows = await appDb
|
||
.select({ id: creditLedger.id })
|
||
.from(creditLedger)
|
||
.where(and(eq(creditLedger.reason, reason), eq(creditLedger.refId, refId)))
|
||
.limit(1);
|
||
return rows.length > 0;
|
||
}
|
||
|
||
/**
|
||
* Kredi-bitti anını işaretler (48 saatlik hatırlatma e-postasının saati).
|
||
* Yalnızca ilk bitişte yazar (kolon doluysa no-op) ve hatırlatma zaten
|
||
* gönderildiyse yeniden kurmaz — e-posta ömür boyu 1 kez. Hata yolunda
|
||
* çağrıldığı için asla throw etmez.
|
||
*/
|
||
export async function krediBittiIsaretle(userId: string): Promise<void> {
|
||
try {
|
||
await appDb
|
||
.update(user)
|
||
.set({ krediBittiAt: new Date() })
|
||
.where(
|
||
and(
|
||
eq(user.id, userId),
|
||
isNull(user.krediBittiAt),
|
||
isNull(user.krediHatirlatmaGonderildiAt),
|
||
),
|
||
);
|
||
} catch (err) {
|
||
console.error(`[kredi] kredi-bitti işaretlenemedi (${userId}):`, err);
|
||
}
|
||
}
|