fix(pl24): tr hesabı kapalıyken de köprüsü (PL24_TR_DISABLED) + login devre kesici
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

- tr-903645 PL24 tarafında pasif (07-24, "The account is not active"): flag
  açıkken tüm tr auth/proxy/egress şeffaf biçimde de-708171'e maplenir
  (PSA dahil — legacy authorizeService yolu tr'ye hardcode'du)
- PSA fetch'lerine dispatcher eklendi: de oturumu DE proxy'den çıkar
- resolveAccount flag altında herkese de döner (fetch/cache/etiket tutarlı)
- login devre kesici: 3 ardışık hata → 5 dk soğuma (haftalardır süren
  ~6 boş login/dk fırtınasını bitirir)
- prefetch: flag açıkken pl24 backfill üretimi durur + kuyruktaki pl24
  main-lane işleri 15 dk defer'la park edilir (fast/user lane akmaya devam
  eder) — hayatta kalan tek hesabı arka plan yükü yakmasın
- compose: PL24_TR_DISABLED env injection (api+worker)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 14:47:53 +03:00
parent 181bdbe971
commit 5d9da95830
6 changed files with 285 additions and 32 deletions

View File

@@ -0,0 +1,127 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PL24AuthService } from "./pl24-auth.service";
// PL24_TR_DISABLED köprüsü + login devre kesici: ağ yok, login yolları global
// fetch stub'ıyla gözlenir; private durum repo genelindeki `as unknown as` kalıbıyla okunur.
const makeService = (cfgOverrides: Record<string, string> = {}) => {
const cfg: Record<string, string> = {
"pl24.companyCode": "tr-903645",
"pl24.username": "admin",
"pl24.password": "pw-tr",
"pl24.companyCode2": "de-708171",
"pl24.username2": "admin",
"pl24.password2": "pw-de",
"pl24.proxyDe": "http://u:p@127.0.0.1:9",
...cfgOverrides,
};
const configService = { get: (k: string, d?: unknown) => cfg[k] ?? d } as never;
const redis = { getJson: async () => null, setJson: async () => {} } as never;
return new PL24AuthService(configService, redis);
};
type Exposed = {
effectiveAccount(account: "tr" | "de"): "tr" | "de";
login(forceNew?: boolean): Promise<unknown>;
login2(forceNew?: boolean): Promise<unknown>;
tokenData2: unknown;
};
const savedFlag = process.env.PL24_TR_DISABLED;
beforeEach(() => {
// "" ≠ "true" → bayrak kapalı; delete yerine atama (biome noDelete).
process.env.PL24_TR_DISABLED = "";
});
afterEach(() => {
process.env.PL24_TR_DISABLED = savedFlag ?? "";
vi.unstubAllGlobals();
});
describe("PL24AuthService — PL24_TR_DISABLED tr→de köprüsü", () => {
it("bayrak kapalıyken hesaplar olduğu gibi kalır", () => {
const p = makeService() as unknown as Exposed;
expect(p.effectiveAccount("tr")).toBe("tr");
expect(p.effectiveAccount("de")).toBe("de");
});
it("bayrak açıkken tr → de'ye maplenir, de değişmez", () => {
process.env.PL24_TR_DISABLED = "true";
const p = makeService() as unknown as Exposed;
expect(p.effectiveAccount("tr")).toBe("de");
expect(p.effectiveAccount("de")).toBe("de");
});
it("de hesabı tanımlı değilse mapleme yapılmaz (duvara yönlendirme yok)", () => {
process.env.PL24_TR_DISABLED = "true";
const p = makeService({ "pl24.companyCode2": "" }) as unknown as Exposed;
expect(p.effectiveAccount("tr")).toBe("tr");
});
it("bayrak açıkken tr login'i ağa çıkmadan reddedilir", async () => {
process.env.PL24_TR_DISABLED = "true";
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const p = makeService() as unknown as Exposed;
await expect(p.login()).rejects.toThrow(/PL24_TR_DISABLED/);
expect(fetchSpy).not.toHaveBeenCalled();
});
it("legacy clearTokens (tr) bayrak altında de token'larını temizler", () => {
process.env.PL24_TR_DISABLED = "true";
const svc = makeService();
const p = svc as unknown as Exposed;
p.tokenData2 = { accessToken: "x", sessionCookie: "c", expiresAt: new Date(), services: [] };
svc.clearTokens();
expect(p.tokenData2).toBeNull();
});
});
describe("PL24AuthService — login devre kesici", () => {
it("3 ardışık başarısız login'den sonra açılır ve yeni ağ denemesini keser", async () => {
const fetchSpy = vi.fn().mockRejectedValue(new Error("network down"));
vi.stubGlobal("fetch", fetchSpy);
const p = makeService() as unknown as Exposed;
for (let i = 0; i < 3; i++) {
await expect(p.login2()).rejects.toThrow(/giris hatasi/);
}
expect(fetchSpy).toHaveBeenCalledTimes(3);
// 4. deneme: kesici açık — fetch'e hiç gidilmez.
await expect(p.login2()).rejects.toThrow(/breaker open/);
expect(fetchSpy).toHaveBeenCalledTimes(3);
});
it("başarılı login kesici sayacını sıfırlar", async () => {
let failFirst = 2;
const okResponse = {
ok: true,
headers: { get: () => null },
json: async () => ({
token: {
// exp uzak gelecekte olan imzasız-görünümlü JWT payload'ı
access_token: `h.${Buffer.from(
JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600, services: ["x"] }),
).toString("base64url")}.s`,
},
}),
};
const fetchSpy = vi.fn().mockImplementation(async () => {
if (failFirst > 0) {
failFirst -= 1;
throw new Error("network down");
}
return okResponse;
});
vi.stubGlobal("fetch", fetchSpy);
const p = makeService() as unknown as Exposed;
await expect(p.login2()).rejects.toThrow();
await expect(p.login2()).rejects.toThrow();
await expect(p.login2(true)).resolves.toBeTruthy(); // 3. deneme başarılı → sayaç sıfır
await expect(p.login2(true)).resolves.toBeTruthy(); // kesici açılmadı, akış normal
expect(fetchSpy).toHaveBeenCalledTimes(4);
});
});

View File

@@ -33,6 +33,18 @@ export class PL24AuthService implements OnModuleInit {
private serviceTokens2 = new Map<string, { token: string; expiresAt: Date }>();
private proxyAgent: any = null; // undici.ProxyAgent, lazy-init
// ── tr-outage bridge + login circuit breaker ───────────────────────────────
// PL24_TR_DISABLED=true: the tr account is inactive on PL24's side ("The
// account is not active", 2026-07-24) — every tr request is transparently
// served by the de account until the flag is removed. The breaker stops the
// pre-outage failure mode where a dead account was retried ~6x/min for weeks.
private static readonly BREAKER_THRESHOLD = 3;
private static readonly BREAKER_COOLDOWN_MS = 5 * 60_000;
private readonly loginBreaker = {
tr: { fails: 0, openUntil: 0 },
de: { fails: 0, openUntil: 0 },
};
// ── Config ─────────────────────────────────────────────────────────────────
private readonly baseUrl: string;
private readonly companyCode: string;
@@ -69,6 +81,11 @@ export class PL24AuthService implements OnModuleInit {
}
onModuleInit(): void {
if (this.trDisabled()) {
this.logger.warn(
"PL24_TR_DISABLED=true — routing ALL PL24 traffic (auth + proxy egress) to the de account",
);
}
// In-memory auth tokens are cleared on every (re)deploy, so the first VIN
// decode afterwards otherwise pays the ~10s PL24 login + service-authorize
// handshake on the request path. Warm it in the background so users never do.
@@ -127,8 +144,49 @@ export class PL24AuthService implements OnModuleInit {
// ── Public: per-account API ──────────────────────────────────────────────
// ═══════════════════════════════════════════════════════════════════════════
private trDisabled(): boolean {
return process.env.PL24_TR_DISABLED === "true";
}
/** Map tr → de while PL24_TR_DISABLED is set (and de is configured). */
private effectiveAccount(account: "tr" | "de"): "tr" | "de" {
return account === "tr" && this.trDisabled() && this.companyCode2 ? "de" : account;
}
private breakerGate(account: "tr" | "de"): void {
const b = this.loginBreaker[account];
if (Date.now() < b.openUntil) {
throw new UnauthorizedException(
`PL24 ${account} login breaker open (${b.fails} consecutive failures) — ` +
`retrying after ${new Date(b.openUntil).toISOString()}`,
);
}
}
private breakerFail(account: "tr" | "de"): void {
const b = this.loginBreaker[account];
b.fails += 1;
if (b.fails >= PL24AuthService.BREAKER_THRESHOLD && Date.now() >= b.openUntil) {
b.openUntil = Date.now() + PL24AuthService.BREAKER_COOLDOWN_MS;
this.logger.warn(
`PL24 ${account} login breaker OPEN after ${b.fails} consecutive failures — ` +
`pausing login attempts for ${PL24AuthService.BREAKER_COOLDOWN_MS / 60_000}m`,
);
}
}
private breakerOk(account: "tr" | "de"): void {
const b = this.loginBreaker[account];
if (b.fails >= PL24AuthService.BREAKER_THRESHOLD) {
this.logger.log(`PL24 ${account} login breaker reset (login succeeded)`);
}
b.fails = 0;
b.openUntil = 0;
}
/** Return access token for the given account. */
async getAccessTokenForAccount(account: "tr" | "de"): Promise<string> {
async getAccessTokenForAccount(requestedAccount: "tr" | "de"): Promise<string> {
const account = this.effectiveAccount(requestedAccount);
if (account === "de") {
if (!this.tokenData2 || !this.isTokenValid(this.tokenData2)) {
await this.login2();
@@ -144,7 +202,8 @@ export class PL24AuthService implements OnModuleInit {
}
/** Return session cookie for the given account. */
async getSessionCookieForAccount(account: "tr" | "de"): Promise<string> {
async getSessionCookieForAccount(requestedAccount: "tr" | "de"): Promise<string> {
const account = this.effectiveAccount(requestedAccount);
if (account === "de") {
if (!this.tokenData2 || !this.isTokenValid(this.tokenData2)) {
await this.login2();
@@ -160,7 +219,11 @@ export class PL24AuthService implements OnModuleInit {
}
/** Authorize a service catalog for the given account and return the service token. */
async authorizeServiceForAccount(serviceName: string, account: "tr" | "de"): Promise<string> {
async authorizeServiceForAccount(
serviceName: string,
requestedAccount: "tr" | "de",
): Promise<string> {
const account = this.effectiveAccount(requestedAccount);
const cache = account === "de" ? this.serviceTokens2 : this.serviceTokens;
const cached = cache.get(serviceName);
if (cached && cached.expiresAt > new Date()) {
@@ -274,7 +337,8 @@ export class PL24AuthService implements OnModuleInit {
}
/** Return PL24TOKEN cookie value for the given account (used as Ford hintstoken param). */
getPL24TokenValueForAccount(account: "tr" | "de"): string | null {
getPL24TokenValueForAccount(requestedAccount: "tr" | "de"): string | null {
const account = this.effectiveAccount(requestedAccount);
const data = account === "de" ? this.tokenData2 : this.tokenData;
if (!data?.sessionCookie) return null;
const match = data.sessionCookie.match(/PL24TOKEN=([^;]+)/);
@@ -283,12 +347,13 @@ export class PL24AuthService implements OnModuleInit {
/** Return ProxyAgent for account 'de', null for 'tr'. */
async getProxyAgent4Account(account: "tr" | "de"): Promise<any | null> {
if (account !== "de") return null;
if (this.effectiveAccount(account) !== "de") return null;
return this.getProxyAgent();
}
/** Clear in-memory tokens for the given account. */
clearTokensForAccount(account: "tr" | "de"): void {
clearTokensForAccount(requestedAccount: "tr" | "de"): void {
const account = this.effectiveAccount(requestedAccount);
if (account === "de") {
this.tokenData2 = null;
this.serviceTokens2.clear();
@@ -316,6 +381,15 @@ export class PL24AuthService implements OnModuleInit {
}
}
if (this.trDisabled()) {
// Callers normally never reach here (effectiveAccount maps tr → de); this
// guards stray/legacy paths so the dead account is never hit while flagged.
throw new UnauthorizedException(
"PL24 tr login skipped: PL24_TR_DISABLED=true (account inactive on PL24 side)",
);
}
this.breakerGate("tr");
this.logger.log("Logging in to PL24 (account 1 tr)...");
const loginRequest: PL24LoginRequest = {
@@ -376,8 +450,10 @@ export class PL24AuthService implements OnModuleInit {
this.logger.log(
`PL24 login (tr) successful. Token expires at ${this.tokenData.expiresAt.toISOString()}`,
);
this.breakerOk("tr");
return this.tokenData;
} catch (error) {
this.breakerFail("tr");
const err = error as Error;
if (err.name === "TimeoutError") {
throw new UnauthorizedException("PL24 giris zaman asimina ugradi");
@@ -447,6 +523,8 @@ export class PL24AuthService implements OnModuleInit {
throw new UnauthorizedException("PL24 account 2 (de) credentials not configured");
}
this.breakerGate("de");
this.logger.log("Logging in to PL24 (account 2 de)...");
const loginRequest: PL24LoginRequest = {
@@ -505,8 +583,10 @@ export class PL24AuthService implements OnModuleInit {
this.logger.log(
`PL24 login (de) successful. Token expires at ${this.tokenData2.expiresAt.toISOString()}`,
);
this.breakerOk("de");
return this.tokenData2;
} catch (error) {
this.breakerFail("de");
const err = error as Error;
if (err.name === "TimeoutError") {
throw new UnauthorizedException("PL24 (de) giris zaman asimina ugradi");

View File

@@ -1917,6 +1917,10 @@ export class PL24FordLegacyService {
* Fiat services always use 'de'; others use round-robin via Redis.
*/
private async resolveAccount(userId?: string, serviceName?: string): Promise<"tr" | "de"> {
// PL24_TR_DISABLED=true → the tr account is inactive on PL24's side; route
// everyone to de so page fetches, proxies and cache labels all agree with the
// auth layer's tr→de mapping (PL24AuthService.effectiveAccount).
if (process.env.PL24_TR_DISABLED === "true") return "de";
// Fiat always needs de account; Hyundai/Kia/Nissan parts are licensed only on de.
if (serviceName && ["fiatp_parts", "fiatt_parts"].includes(serviceName)) {
return "de";

View File

@@ -442,31 +442,49 @@ export class PL24PsaService {
// ==================== PRIVATE: session + HTTP ====================
/**
* PSA requests must leave from the same egress as their auth account: under
* PL24_TR_DISABLED the legacy "tr" auth maps to de, whose session lives behind
* the DE proxy — the auth service picks the dispatcher (null for real tr).
*/
private async psaDispatcher(): Promise<any | null> {
return this.authService.getProxyAgent4Account("tr");
}
/**
* entry.action → startup=true (302) → 302 with mode+upds. Returns JSESSIONID/mode/upds.
*/
private async initPsaSession(serviceName: string): Promise<PsaSession | null> {
await this.authService.authorizeService(serviceName);
const headers = await this.authService.buildFordLegacyHeaders(serviceName);
const dispatcher = await this.psaDispatcher();
const withDispatcher = (o: RequestInit): RequestInit & { dispatcher?: any } =>
dispatcher ? { ...o, dispatcher } : o;
try {
const entryRes = await fetch(`${this.baseUrl}/psa/pl24-entry.action?service=${serviceName}`, {
method: "GET",
headers,
redirect: "manual",
signal: AbortSignal.timeout(this.timeout),
});
const entryRes = await fetch(
`${this.baseUrl}/psa/pl24-entry.action?service=${serviceName}`,
withDispatcher({
method: "GET",
headers,
redirect: "manual",
signal: AbortSignal.timeout(this.timeout),
}),
);
const jsessionId = entryRes.headers.get("set-cookie")?.match(/JSESSIONID=([^;]+)/)?.[1] || "";
const loc1 = entryRes.headers.get("location");
if (!loc1) return null;
const hdrs2 = jsessionId
? { ...headers, Cookie: `${headers.Cookie}; JSESSIONID=${jsessionId}` }
: headers;
const startupRes = await fetch(loc1.startsWith("http") ? loc1 : `${this.baseUrl}${loc1}`, {
method: "GET",
headers: hdrs2,
redirect: "manual",
signal: AbortSignal.timeout(this.timeout),
});
const startupRes = await fetch(
loc1.startsWith("http") ? loc1 : `${this.baseUrl}${loc1}`,
withDispatcher({
method: "GET",
headers: hdrs2,
redirect: "manual",
signal: AbortSignal.timeout(this.timeout),
}),
);
const loc2 = startupRes.headers.get("location") || loc1;
const mode = loc2.match(/[?&]mode=([^&]+)/)?.[1] || "";
// Keep upds URL-encoded exactly as returned (e.g. "2024.02.13+09%3A27%3A21+CET");
@@ -494,12 +512,15 @@ export class PL24PsaService {
Accept: opts.json ? "application/json,*/*" : "text/html,application/xhtml+xml,*/*;q=0.9",
};
try {
const res = await fetch(url, {
const dispatcher = await this.psaDispatcher();
const fetchOpts: RequestInit & { dispatcher?: any } = {
method: "GET",
headers,
redirect: opts.manual ? "manual" : "follow",
signal: AbortSignal.timeout(this.timeout),
});
};
if (dispatcher) fetchOpts.dispatcher = dispatcher;
const res = await fetch(url, fetchOpts);
const location = res.headers.get("location");
if (opts.manual && res.status >= 300 && res.status < 400) {
return { status: res.status, text: null, location };
@@ -543,13 +564,19 @@ export class PL24PsaService {
Accept: "application/json,image/*,*/*;q=0.9",
Referer: `${this.baseUrl}/psa/${serviceName}/vin-image-board.action`,
};
const dispatcher = await this.psaDispatcher();
const withDispatcher = (o: RequestInit): RequestInit & { dispatcher?: any } =>
dispatcher ? { ...o, dispatcher } : o;
try {
const infoRes = await fetch(`${imageUrl}&request=GetImageInfo&cv=1`, {
method: "GET",
headers,
redirect: "follow",
signal: AbortSignal.timeout(this.timeout),
});
const infoRes = await fetch(
`${imageUrl}&request=GetImageInfo&cv=1`,
withDispatcher({
method: "GET",
headers,
redirect: "follow",
signal: AbortSignal.timeout(this.timeout),
}),
);
if (!infoRes.ok) return null;
const info = (await infoRes.json()) as {
imageWidth: number;
@@ -570,12 +597,15 @@ export class PL24PsaService {
const getImgUrl =
`${imageUrl}&request=GetImage&format=image%2Fpng` +
`&bbox=${encodeURIComponent(`0,0,${w},${h}`)}&width=${w}&height=${h}&scalefac=1.0&cv=1&rnd=${rnd}`;
const imgRes = await fetch(getImgUrl, {
method: "GET",
headers: { ...headers, Accept: "image/png,image/*,*/*;q=0.9" },
redirect: "follow",
signal: AbortSignal.timeout(this.timeout),
});
const imgRes = await fetch(
getImgUrl,
withDispatcher({
method: "GET",
headers: { ...headers, Accept: "image/png,image/*,*/*;q=0.9" },
redirect: "follow",
signal: AbortSignal.timeout(this.timeout),
}),
);
if (!imgRes.ok) return null;
const contentType = imgRes.headers.get("content-type") || "image/png";
const buffer = Buffer.from(await imgRes.arrayBuffer());