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
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:
@@ -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,
|
||||
|
||||
10
apps/api/src/common/telegram.module.ts
Normal file
10
apps/api/src/common/telegram.module.ts
Normal 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 {}
|
||||
56
apps/api/src/common/telegram.service.ts
Normal file
56
apps/api/src/common/telegram.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
Reference in New Issue
Block a user