All checks were successful
Deploy / deploy (push) Successful in 9m30s
- Tüm AI akışı OpenRouter üzerinden (openai SDK, varsayılan model deepseek/deepseek-v4-flash, OPENROUTER_MODEL ile değiştirilebilir); Anthropic/Gemini/lokal sağlayıcı seçimi ve SDK'ları kaldırıldı. - Rapor: aday havuzu dilim başına 20 (60 program, ~8k token prompt); AI şeması sabit uzunluklu üç liste (hayal 5 / dengeli 13 / garanti 6 — dağılım şema düzeyinde zorlanır), sıra + dilim + riskNotu + trendOzeti gerçek sıra geçmişinden deterministik üretilir; 184 sn → ~12 sn. - Sohbet: akış ortası zaman aşımı artık sessizce yutulmaz (kesik cevap tam cevap gibi kaydedilmiyordu, kredi iadesi çalışmıyordu); istemci hata anında durumu sunucudan senkronlar, boş balon bırakmaz. - aiHataMesaji, openai SDK'nın .name'i "Error" kalan abort/timeout hatalarını da yakalar; meraklisina şeması yeni akışla senkronlandı. - CI: OPENROUTER_API_KEY / OPENROUTER_MODEL secret'ları; eski AI secret'ları kaldırıldı. next.config serverExternalPackages sadeleşti. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
329 lines
9.6 KiB
TypeScript
329 lines
9.6 KiB
TypeScript
"use server";
|
||
|
||
import { and, eq, sql } from "drizzle-orm";
|
||
import { getSession, getCurrentUser } from "@/lib/session";
|
||
import { appDb, schema } from "@/lib/appdb";
|
||
import { PUAN_TURLERI, type PuanTuruKey } from "@/lib/db";
|
||
import {
|
||
spendCredits,
|
||
grantCredits,
|
||
krediBittiIsaretle,
|
||
RAPOR_KREDI,
|
||
MAX_REVIZYON,
|
||
} from "@/lib/credits";
|
||
import {
|
||
raporUret,
|
||
listeOzetiCikar,
|
||
RaporUretimHatasi,
|
||
type RaporSonuc,
|
||
} from "@/lib/ai/rapor";
|
||
import type { RaporParams } from "@/lib/rapor-havuzu";
|
||
import { sihirbazDogrula, type SihirbazSecimleri } from "@/lib/sihirbaz";
|
||
import { raporMaskele } from "@/lib/rapor-maske";
|
||
|
||
|
||
// Modal yerinde render ettiği için action redirect etmez; sonucu döner.
|
||
export type ListeSonuc =
|
||
| {
|
||
ok: true;
|
||
rapor: RaporSonuc;
|
||
params: RaporParams;
|
||
revisionCount: number;
|
||
kredi: number;
|
||
hasPaket: boolean;
|
||
}
|
||
| { ok: false; error: string; code: "AUTH" | "PAKET" | "KREDI" | "HATA" };
|
||
|
||
function siraTurGecerli(sira: number, tur: string): tur is PuanTuruKey {
|
||
return (
|
||
Number.isFinite(sira) && sira >= 1 && sira <= 4_000_000 && tur in PUAN_TURLERI
|
||
);
|
||
}
|
||
|
||
function aiHataMesaji(err: unknown): string {
|
||
if (err instanceof RaporUretimHatasi) return err.message;
|
||
// AbortSignal.timeout: DOMException "TimeoutError"; SDK'lar abort'u kendi
|
||
// hata sınıflarıyla sarabiliyor (ör. APIUserAbortError) ve openai/anthropic
|
||
// bu sınıflarda .name'i "Error" bırakıyor — ada ek olarak sınıf adına ve
|
||
// mesaja da bak ("Request was aborted." / "Request timed out.").
|
||
if (
|
||
err instanceof Error &&
|
||
/abort|timeout|timed out/i.test(
|
||
`${err.name} ${err.constructor.name} ${err.message}`,
|
||
)
|
||
) {
|
||
return "Liste üretimi bu sefer çok uzun sürdü ve durduruldu; harcanan kredin otomatik iade edildi. Tekrar dener misin?";
|
||
}
|
||
if (err instanceof Error && err.message === "AI_KEY_MISSING") {
|
||
// Yapılandırma detayı log'a; kullanıcıya altyapı sızdırmayan mesaj
|
||
console.error("[liste] Yapay Zeka anahtarı eksik — üretim başlatılamadı");
|
||
return "Liste üretimi şu anda başlatılamıyor. Birazdan tekrar dener misin? Harcanan kredin otomatik iade edilir.";
|
||
}
|
||
console.error("[liste] üretim hatası:", err);
|
||
return "Liste üretilirken bir sorun oluştu. Birazdan tekrar dener misin?";
|
||
}
|
||
|
||
function paramsBirlestir(
|
||
sira: number,
|
||
tur: PuanTuruKey,
|
||
secimler: SihirbazSecimleri,
|
||
): RaporParams {
|
||
return {
|
||
sira: Math.round(sira),
|
||
tur,
|
||
kategoriler: secimler.kategoriler,
|
||
iller: secimler.iller,
|
||
universiteTipi: secimler.universiteTipi,
|
||
oncelikler: secimler.oncelikler,
|
||
};
|
||
}
|
||
|
||
export async function listeOlustur(input: {
|
||
sira: number;
|
||
tur: string;
|
||
secimler: unknown;
|
||
requestId: string;
|
||
}): Promise<ListeSonuc> {
|
||
const session = await getSession();
|
||
if (!session) {
|
||
return { ok: false, code: "AUTH", error: "Giriş yapmalısın." };
|
||
}
|
||
const user = await getCurrentUser();
|
||
if (!user) {
|
||
return { ok: false, code: "AUTH", error: "Giriş yapmalısın." };
|
||
}
|
||
// Paket şartı yok: üretim kredi bazlı (yeni kullanıcının 5 deneme kredisi
|
||
// burada yanmaya başlar). Paketsize dönen rapor maskelenir.
|
||
|
||
if (!siraTurGecerli(input.sira, input.tur)) {
|
||
return { ok: false, code: "HATA", error: "Geçerli bir sıralama gerekli." };
|
||
}
|
||
const secimler = sihirbazDogrula(input.secimler);
|
||
if (!secimler) {
|
||
return {
|
||
ok: false,
|
||
code: "HATA",
|
||
error: "En az bir ilgi alanı seçmelisin.",
|
||
};
|
||
}
|
||
if (!input.requestId || typeof input.requestId !== "string") {
|
||
return { ok: false, code: "HATA", error: "Geçersiz istek." };
|
||
}
|
||
|
||
const params = paramsBirlestir(input.sira, input.tur, secimler);
|
||
|
||
// 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,
|
||
});
|
||
if (!harcama.ok) {
|
||
if (harcama.error === "INSUFFICIENT") {
|
||
await krediBittiIsaretle(user.id);
|
||
return {
|
||
ok: false,
|
||
code: "KREDI",
|
||
error: `Liste oluşturmak için ${RAPOR_KREDI} kredi gerekli.`,
|
||
};
|
||
}
|
||
// DUPLICATE: bu requestId zaten işlendi. Önceki deneme başarısız olup
|
||
// iade edildiyse mevcut (eski) raporu yeniymiş gibi döndürme; taze bir
|
||
// requestId ile yeniden denemesini iste.
|
||
const iade = await appDb.query.creditLedger.findFirst({
|
||
where: and(
|
||
eq(schema.creditLedger.reason, "refund"),
|
||
eq(schema.creditLedger.refId, input.requestId),
|
||
),
|
||
});
|
||
if (iade) {
|
||
return {
|
||
ok: false,
|
||
code: "HATA",
|
||
error:
|
||
"Önceki denemen başarısız olduğu için kredin iade edilmişti. Tekrar dener misin?",
|
||
};
|
||
}
|
||
const mevcut = await appDb.query.reports.findFirst({
|
||
where: eq(schema.reports.userId, user.id),
|
||
});
|
||
if (mevcut?.result) {
|
||
const rapor = mevcut.result as RaporSonuc;
|
||
return {
|
||
ok: true,
|
||
rapor: user.hasPaket ? rapor : raporMaskele(rapor),
|
||
params: mevcut.params as RaporParams,
|
||
revisionCount: mevcut.revisionCount,
|
||
kredi: user.creditBalance,
|
||
hasPaket: user.hasPaket,
|
||
};
|
||
}
|
||
return { ok: false, code: "HATA", error: "İstek tekrarlandı." };
|
||
}
|
||
|
||
let sonuc: RaporSonuc;
|
||
try {
|
||
sonuc = await raporUret(params);
|
||
} catch (err) {
|
||
// Üretim başarısız → krediyi iade et
|
||
await grantCredits({
|
||
userId: user.id,
|
||
delta: RAPOR_KREDI,
|
||
reason: "refund",
|
||
refId: input.requestId,
|
||
}).catch(() => {});
|
||
return { ok: false, code: "HATA", error: aiHataMesaji(err) };
|
||
}
|
||
|
||
const now = new Date();
|
||
await appDb
|
||
.insert(schema.reports)
|
||
.values({
|
||
id: crypto.randomUUID(),
|
||
userId: user.id,
|
||
params,
|
||
result: sonuc,
|
||
revisionCount: 0,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
})
|
||
.onConflictDoUpdate({
|
||
target: schema.reports.userId,
|
||
set: { params, result: sonuc, revisionCount: 0, updatedAt: now },
|
||
});
|
||
|
||
return {
|
||
ok: true,
|
||
rapor: user.hasPaket ? sonuc : raporMaskele(sonuc),
|
||
params,
|
||
revisionCount: 0,
|
||
kredi: user.creditBalance - RAPOR_KREDI,
|
||
hasPaket: user.hasPaket,
|
||
};
|
||
}
|
||
|
||
export async function listeRevize(input: {
|
||
feedback: string;
|
||
requestId: string;
|
||
}): Promise<ListeSonuc> {
|
||
const session = await getSession();
|
||
if (!session) return { ok: false, code: "AUTH", error: "Giriş yapmalısın." };
|
||
const user = await getCurrentUser();
|
||
if (!user) return { ok: false, code: "AUTH", error: "Giriş yapmalısın." };
|
||
if (!user.hasPaket) return { ok: false, code: "PAKET", error: "Paket gerekli." };
|
||
|
||
const feedback = (input.feedback ?? "").trim();
|
||
if (feedback.length < 5) {
|
||
return {
|
||
ok: false,
|
||
code: "HATA",
|
||
error: "Ne değişsin istediğini kısaca yazmalısın.",
|
||
};
|
||
}
|
||
if (!input.requestId) {
|
||
return { ok: false, code: "HATA", error: "Geçersiz istek." };
|
||
}
|
||
|
||
const rapor = await appDb.query.reports.findFirst({
|
||
where: eq(schema.reports.userId, user.id),
|
||
});
|
||
if (!rapor?.result) {
|
||
return { ok: false, code: "HATA", error: "Önce bir liste oluşturmalısın." };
|
||
}
|
||
if (rapor.revisionCount >= MAX_REVIZYON) {
|
||
return {
|
||
ok: false,
|
||
code: "HATA",
|
||
error: `Revizyon hakkın doldu (en fazla ${MAX_REVIZYON}).`,
|
||
};
|
||
}
|
||
|
||
const params = rapor.params as RaporParams;
|
||
const onceki = rapor.result as RaporSonuc;
|
||
|
||
const harcama = await spendCredits({
|
||
userId: user.id,
|
||
amount: RAPOR_KREDI,
|
||
reason: "report_revision",
|
||
refId: input.requestId,
|
||
});
|
||
if (!harcama.ok) {
|
||
if (harcama.error === "INSUFFICIENT") {
|
||
await krediBittiIsaretle(user.id);
|
||
return {
|
||
ok: false,
|
||
code: "KREDI",
|
||
error: `Revizyon için ${RAPOR_KREDI} kredi gerekli.`,
|
||
};
|
||
}
|
||
// DUPLICATE: bu requestId zaten işlendi. İade varsa önceki deneme
|
||
// başarısızdı → taze bir denemeye yönlendir. Yoksa revizyon tamamlanmış
|
||
// ama cevap istemciye ulaşmamıştır → güncel raporu döndür.
|
||
const iade = await appDb.query.creditLedger.findFirst({
|
||
where: and(
|
||
eq(schema.creditLedger.reason, "refund"),
|
||
eq(schema.creditLedger.refId, input.requestId),
|
||
),
|
||
});
|
||
if (iade) {
|
||
return {
|
||
ok: false,
|
||
code: "HATA",
|
||
error:
|
||
"Önceki denemen başarısız olduğu için kredin iade edilmişti. Tekrar dener misin?",
|
||
};
|
||
}
|
||
const guncel = await appDb.query.reports.findFirst({
|
||
where: eq(schema.reports.userId, user.id),
|
||
});
|
||
if (guncel?.result) {
|
||
return {
|
||
ok: true,
|
||
rapor: guncel.result as RaporSonuc,
|
||
params: guncel.params as RaporParams,
|
||
revisionCount: guncel.revisionCount,
|
||
kredi: user.creditBalance,
|
||
hasPaket: true,
|
||
};
|
||
}
|
||
return { ok: false, code: "HATA", error: "İstek tekrarlandı." };
|
||
}
|
||
|
||
let sonuc: RaporSonuc;
|
||
try {
|
||
sonuc = await raporUret(
|
||
params,
|
||
feedback.slice(0, 1000),
|
||
listeOzetiCikar(onceki, params.sira),
|
||
);
|
||
} catch (err) {
|
||
await grantCredits({
|
||
userId: user.id,
|
||
delta: RAPOR_KREDI,
|
||
reason: "refund",
|
||
refId: input.requestId,
|
||
}).catch(() => {});
|
||
return { ok: false, code: "HATA", error: aiHataMesaji(err) };
|
||
}
|
||
|
||
// Sayaç yalnızca başarılı üretimde artar
|
||
await appDb
|
||
.update(schema.reports)
|
||
.set({
|
||
result: sonuc,
|
||
revisionCount: sql`${schema.reports.revisionCount} + 1`,
|
||
updatedAt: new Date(),
|
||
})
|
||
.where(eq(schema.reports.id, rapor.id));
|
||
|
||
return {
|
||
ok: true,
|
||
rapor: sonuc,
|
||
params,
|
||
revisionCount: rapor.revisionCount + 1,
|
||
kredi: user.creditBalance - RAPOR_KREDI,
|
||
hasPaket: true,
|
||
};
|
||
}
|