feat(pl24): PL24 girişi 1 saat başarısızsa Telegram uyarısı
Some checks are pending
QA Gate (P0/P1) / Test affected app (pull_request) Waiting to run

İki hesap banı da günler sonra, DB satır sayılarından fark edildi (tr 07-24 →
08-18'de, de 09-04 → 09-16'da). Üçüncüsünde bir saat içinde haber olsun.

- Yeni global `TelegramService` (aynı bot: TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID;
  yapılandırılmamışsa sessizce devre dışı, istek yolunu asla etkilemez).
- `PL24AuthService` kesinti saatini Redis'te tutuyor (`pl24:auth:fail-since:<hesap>`),
  böylece api+worker ve yeniden başlatmalar aynı başlangıcı görür — crash-loop
  saati sıfırlayamaz. Başarılı login saati siler.
- Kesinti 60 dakikayı geçince tek sefer uyarı (`pl24:auth:alerted:<hesap>` NX,
  6 saat) — hesap kodu, süre, son hata ve "banlanmış olabilir" notu ile.
  Düzelince sessiz "🟢 düzeldi" mesajı.
- Devre kesici açıkken de saat işler: kesici login denemesini bastırdığı için
  aksi halde hesap en ölü olduğu anda saat duruyordu.
- compose'a eksik env referansları: TELEGRAM_*, PL24_HTTP_DAILY_MAX,
  PL24_HTTP_USER_RESERVE, PREFETCH_PL24_FAST_DEPTH, PREFETCH_PL24_DELAY_MS
  (Coolify env'i ancak compose'da referanslıysa container'a ulaşıyor).

Test: 3 yeni test (1 saat dolmadan susar, dolunca tek sefer uyarır, düzelince
kurtarma mesajı + saat sıfırlanır). Redis stub'ı gerçek SET NX semantiğine
çekildi (alarm tekrarının bastırılması buna dayanıyor). 80 PL24 testi geçti.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
semih
2026-09-17 13:53:15 +03:00
parent 02ec3856d5
commit 3cfa3d588a
6 changed files with 246 additions and 2 deletions

View File

@@ -21,6 +21,7 @@ import { RolesGuard } from "./common/guards/roles.guard";
import { LoggingInterceptor } from "./common/interceptors/logging.interceptor";
import { TimeoutInterceptor } from "./common/interceptors/timeout.interceptor";
import { TransformInterceptor } from "./common/interceptors/transform.interceptor";
import { TelegramModule } from "./common/telegram.module";
import configuration from "./config/configuration";
import { validate } from "./config/env.validation";
import { ContactModule } from "./contact/contact.module";
@@ -79,6 +80,7 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
]),
DatabaseModule,
RedisModule,
TelegramModule,
AuthModule,
UsersModule,
EmailModule,

View File

@@ -0,0 +1,10 @@
import { Global, Module } from "@nestjs/common";
import { TelegramService } from "./telegram.service";
/** Global so any module can raise an operational alert without extra wiring. */
@Global()
@Module({
providers: [TelegramService],
exports: [TelegramService],
})
export class TelegramModule {}

View File

@@ -0,0 +1,56 @@
import { Injectable, Logger } from "@nestjs/common";
/**
* Minimal Telegram alerting for operational failures that need a human now
* (currently: PL24 auth down for an hour — i.e. the account may be banned
* again). Fire-and-forget: a failed alert must never affect a request.
*
* Uses the same bot as the Süper Panel — set TELEGRAM_BOT_TOKEN and
* TELEGRAM_CHAT_ID. Unconfigured = silently disabled (local/dev).
*/
@Injectable()
export class TelegramService {
private readonly logger = new Logger(TelegramService.name);
private get token(): string {
return process.env.TELEGRAM_BOT_TOKEN ?? "";
}
private get chatId(): string {
return process.env.TELEGRAM_CHAT_ID ?? "";
}
isConfigured(): boolean {
return Boolean(this.token && this.chatId);
}
/** Send a message. Returns false when disabled or the API refused. */
async send(text: string, opts: { silent?: boolean } = {}): Promise<boolean> {
if (!this.isConfigured()) {
this.logger.warn(`Telegram not configured — alert dropped: ${text.slice(0, 120)}`);
return false;
}
try {
const res = await fetch(`https://api.telegram.org/bot${this.token}/sendMessage`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chat_id: this.chatId,
text,
parse_mode: "HTML",
disable_notification: opts.silent ?? false,
disable_web_page_preview: true,
}),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
this.logger.warn(`Telegram sendMessage failed: HTTP ${res.status}`);
return false;
}
return true;
} catch (err) {
this.logger.warn(`Telegram sendMessage error: ${(err as Error).message}`);
return false;
}
}
}

View File

@@ -7,6 +7,9 @@ import { PL24AuthService } from "./pl24-auth.service";
type RedisStub = {
store: Map<string, unknown>;
get: (k: string) => Promise<string | null>;
set: (k: string, v: string, ttl?: number) => Promise<void>;
exists: (k: string) => Promise<boolean>;
getJson: (k: string) => Promise<unknown>;
setJson: (k: string, v: unknown) => Promise<void>;
del: (k: string) => Promise<void>;
@@ -20,6 +23,11 @@ const makeRedis = (opts: { lockTaken?: boolean } = {}): RedisStub => {
const counters = new Map<string, number>();
return {
store,
get: async (k: string) => (store.has(k) ? String(store.get(k)) : null),
set: async (k: string, v: string) => {
store.set(k, v);
},
exists: async (k: string) => store.has(k),
getJson: async (k: string) => store.get(k) ?? null,
setJson: async (k: string, v: unknown) => {
store.set(k, v);
@@ -27,7 +35,14 @@ const makeRedis = (opts: { lockTaken?: boolean } = {}): RedisStub => {
del: async (k: string) => {
store.delete(k);
},
setNx: async () => !opts.lockTaken,
// Gerçek SET NX semantiği: var olan anahtarı ikinci kez yazmaz. Alarm
// tekrarının bastırılması buna dayanıyor.
setNx: async (k: string, v: string) => {
if (opts.lockTaken && k.includes("login-lock")) return false;
if (store.has(k)) return false;
store.set(k, v);
return true;
},
incr: async (k: string) => {
const n = (counters.get(k) ?? 0) + 1;
counters.set(k, n);
@@ -63,10 +78,19 @@ const makeService = (cfgOverrides: Record<string, string> = {}, redis = makeRedi
};
const configService = { get: (k: string, d?: unknown) => cfg[k] ?? d } as never;
const budget = makeBudget();
const telegram = {
sent: [] as string[],
isConfigured: () => true,
send: async function (t: string) {
this.sent.push(t);
return true;
},
};
return {
svc: new PL24AuthService(configService, redis as never, budget as never),
svc: new PL24AuthService(configService, redis as never, budget as never, telegram as never),
redis,
budget,
telegram,
};
};
@@ -384,3 +408,54 @@ describe("PL24AuthService — login devre kesici", () => {
expect(fetchSpy).toHaveBeenCalledTimes(6);
});
});
describe("PL24AuthService — 1 saatlik kesinti alarmı (Telegram)", () => {
const loginFails = () => ({
ok: false,
status: 400,
statusText: "Bad Request",
headers: { get: () => null, getSetCookie: () => [] },
json: async () => ({ type: "urn:login:account-not-active", detail: "account not active" }),
});
it("ilk saat içinde alarm göndermez", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(loginFails()));
const { svc, telegram } = makeService();
await expect((svc as unknown as Exposed).login("de")).rejects.toThrow();
expect(telegram.sent).toHaveLength(0);
});
it("kesinti 1 saati geçince tek sefer alarm gönderir", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(loginFails()));
const redis = makeRedis();
// Kesinti 70 dakika önce başlamış gibi davran (paylaşılan Redis saati).
redis.store.set("pl24:auth:fail-since:de", String(Date.now() - 70 * 60_000));
const { svc, telegram } = makeService({}, redis);
await expect((svc as unknown as Exposed).login("de")).rejects.toThrow();
await new Promise((r) => setTimeout(r, 10)); // void alarm promise'i
expect(telegram.sent).toHaveLength(1);
expect(telegram.sent[0]).toContain("PL24 giriş yapılamıyor");
expect(telegram.sent[0]).toContain("70 dakika");
// Kesici açık olsa bile ikinci kez sızlanmaz (alerted anahtarı).
await expect((svc as unknown as Exposed).login("de")).rejects.toThrow();
await new Promise((r) => setTimeout(r, 10));
expect(telegram.sent).toHaveLength(1);
});
it("giriş düzelince kurtarma mesajı gönderir ve saati sıfırlar", async () => {
const redis = makeRedis();
redis.store.set("pl24:auth:fail-since:de", String(Date.now() - 90 * 60_000));
redis.store.set("pl24:auth:alerted:de", "1");
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(loginOk()));
const { svc, telegram } = makeService({}, redis);
await svc.getSessionCookieForAccount("de");
await new Promise((r) => setTimeout(r, 10));
expect(telegram.sent.some((t) => t.includes("PL24 girişi düzeldi"))).toBe(true);
expect(redis.store.has("pl24:auth:fail-since:de")).toBe(false);
});
});

View File

@@ -24,6 +24,7 @@
import { Injectable, Logger, type OnModuleInit, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { TelegramService } from "../../common/telegram.service";
import { RedisService } from "../../redis/redis.service";
import { PL24BudgetService } from "./pl24-budget.service";
import { PL24_DEFAULTS, PL24_ENDPOINTS, PL24_USER_AGENT } from "./pl24.constants";
@@ -82,6 +83,13 @@ export class PL24AuthService implements OnModuleInit {
};
/** Hard ceiling on logins per account per hour (PL24 counts sessions, not requests). */
private static readonly MAX_LOGINS_PER_HOUR = 6;
/**
* How long PL24 auth may stay broken before a human is paged on Telegram.
* Both previous bans were only noticed days later, from DB row counts.
*/
private static readonly ALERT_AFTER_MS = 60 * 60_000;
/** Do not re-page more often than this while the outage continues. */
private static readonly ALERT_REPEAT_MS = 6 * 60 * 60_000;
// ── Config ─────────────────────────────────────────────────────────────────
private readonly baseUrl: string;
@@ -98,6 +106,7 @@ export class PL24AuthService implements OnModuleInit {
private configService: ConfigService,
private readonly redis: RedisService,
private readonly budget: PL24BudgetService,
private readonly telegram: TelegramService,
) {
this.baseUrl = this.configService.get<string>("pl24.baseUrl", "https://www.partslink24.com");
this.companyCode = this.configService.get<string>("pl24.companyCode", "");
@@ -350,6 +359,7 @@ export class PL24AuthService implements OnModuleInit {
if (!result.sessionToken) {
this.breakerFail(account, result.permanent);
const detail = result.error || "Token alinamadi";
void this.noteLoginFailure(account, detail);
this.logger.error(`PL24 login (${account}) failed: ${detail}`);
throw new UnauthorizedException(`PL24 (${account}) giris hatasi: ${detail}`);
}
@@ -362,6 +372,7 @@ export class PL24AuthService implements OnModuleInit {
this.sessions[account] = session;
await this.persistSession(account, session);
this.breakerOk(account);
void this.noteLoginSuccess(account);
this.logger.log(`PL24 login (${account}) successful — session established`);
return session;
}
@@ -712,6 +723,9 @@ export class PL24AuthService implements OnModuleInit {
private breakerGate(account: PL24Account): void {
const b = this.loginBreaker[account];
if (Date.now() < b.openUntil) {
// The breaker suppresses login attempts, so without this the outage clock
// would stop ticking exactly when the account is most likely dead.
void this.noteLoginFailure(account, `login breaker open (${b.fails} failures)`);
throw new UnauthorizedException(
`PL24 ${account} login breaker open (${b.fails} consecutive failures) — ` +
`retrying after ${new Date(b.openUntil).toISOString()}`,
@@ -736,6 +750,87 @@ export class PL24AuthService implements OnModuleInit {
}
}
// ── Outage tracking → Telegram ────────────────────────────────────────────
private failSinceKey(account: PL24Account): string {
return `${PL24_DEFAULTS.CACHE_PREFIX}auth:fail-since:${account}`;
}
private alertedKey(account: PL24Account): string {
return `${PL24_DEFAULTS.CACHE_PREFIX}auth:alerted:${account}`;
}
/**
* Record a login failure and page a human once the account has been unusable
* for an hour. Shared via Redis so api+worker (and restarts) agree on when the
* outage started — a crash-looping process must not reset the clock.
*/
private async noteLoginFailure(account: PL24Account, reason: string): Promise<void> {
try {
const key = this.failSinceKey(account);
const existing = await this.redis.get(key);
const since = existing ? Number(existing) : Date.now();
if (!existing) await this.redis.set(key, String(since), 3 * 86_400);
const downForMs = Date.now() - since;
if (downForMs < PL24AuthService.ALERT_AFTER_MS) return;
if (
!(await this.redis.setNx(
this.alertedKey(account),
"1",
Math.floor(PL24AuthService.ALERT_REPEAT_MS / 1000),
))
) {
return; // already paged recently
}
const minutes = Math.round(downForMs / 60_000);
const company = account === "de" ? this.companyCode2 : this.companyCode;
await this.telegram.send(
[
"🔴 <b>PL24 giriş yapılamıyor</b>",
"",
`Hesap: <code>${company || account}</code>`,
`Süre: <b>${minutes} dakikadır</b> başarısız`,
`Son hata: <code>${reason.slice(0, 200)}</code>`,
"",
"Hesap banlanmış olabilir (önceki iki ban: tr 2026-07-24, de 2026-09-04).",
"Katalog decode'ları PL24 olmadan pcat/emex'e düşüyor.",
].join("\n"),
);
this.logger.error(`PL24 ${account}: auth down for ${minutes}m — Telegram alert sent`);
} catch (err) {
this.logger.warn(`PL24 outage alert failed: ${(err as Error).message}`);
}
}
/** Clear the outage clock and, if we had paged, say it recovered. */
private async noteLoginSuccess(account: PL24Account): Promise<void> {
try {
const key = this.failSinceKey(account);
const since = await this.redis.get(key);
if (!since) return;
await this.redis.del(key);
const wasAlerted = await this.redis.exists(this.alertedKey(account));
await this.redis.del(this.alertedKey(account));
if (wasAlerted) {
const minutes = Math.round((Date.now() - Number(since)) / 60_000);
const company = account === "de" ? this.companyCode2 : this.companyCode;
await this.telegram.send(
[
"🟢 <b>PL24 girişi düzeldi</b>",
"",
`Hesap: <code>${company || account}</code>`,
`Kesinti: ~${minutes} dakika`,
].join("\n"),
{ silent: true },
);
}
} catch {
// never block auth on alerting
}
}
private breakerOk(account: PL24Account): void {
const b = this.loginBreaker[account];
if (b.fails >= PL24AuthService.BREAKER_THRESHOLD) {

View File

@@ -144,6 +144,12 @@ services:
- PREFETCH_MAX_DEPTH=${PREFETCH_MAX_DEPTH:-}
- PREFETCH_RATE_PL24=${PREFETCH_RATE_PL24:-}
- PREFETCH_DAILY_PL24=${PREFETCH_DAILY_PL24:-}
- PREFETCH_PL24_FAST_DEPTH=${PREFETCH_PL24_FAST_DEPTH:-}
- PREFETCH_PL24_DELAY_MS=${PREFETCH_PL24_DELAY_MS:-}
- PL24_HTTP_DAILY_MAX=${PL24_HTTP_DAILY_MAX:-}
- PL24_HTTP_USER_RESERVE=${PL24_HTTP_USER_RESERVE:-}
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
- PREFETCH_RATE_EMEX=${PREFETCH_RATE_EMEX:-}
- PREFETCH_RATE_PCAT=${PREFETCH_RATE_PCAT:-}
- PREFETCH_PCAT_DELAY_MS=${PREFETCH_PCAT_DELAY_MS:-}