Merge pull request 'dev' (#45) from dev into main

Reviewed-on: #45
This commit was merged in pull request #45.
This commit is contained in:
2026-05-24 23:40:27 +00:00
4 changed files with 123 additions and 10 deletions

View File

@@ -24,6 +24,13 @@ const MAX_RETRIES = Number(process.env.PCAT_MAX_RETRIES) || 2;
// timeout before the retry rotates to a fresh port — three of those blow the
// caller's 25s decode budget. Fail fast so retries reach a live port in time.
const PROXY_CONNECT_TIMEOUT = Number(process.env.PCAT_PROXY_CONNECT_TIMEOUT_MS) || 6_000;
// VIN decode (/car/info) answers in <1s on a healthy proxy, so a long timeout only
// prolongs dead-port connects. undici's ProxyAgent connect.timeout does NOT bound the
// connection to the proxy itself (it stays at undici's 10s default), so we bound it at
// the fetch level: a short per-call timeout + extra retries makes a stuck DataImpulse
// port abort fast and rotate to a live one within the caller's 25s decode budget.
const DECODE_REQUEST_TIMEOUT = Number(process.env.PCAT_DECODE_TIMEOUT_MS) || 6_000;
const DECODE_MAX_RETRIES = Number(process.env.PCAT_DECODE_MAX_RETRIES) || 4;
@Injectable()
export class PartsCatalogsService {
@@ -52,7 +59,10 @@ export class PartsCatalogsService {
outcome?: { transient: boolean },
): Promise<PcatVinResult | null> {
try {
const data = await this.fetchWithAuth("/car/info", { q: vin }, signal);
const data = await this.fetchWithAuth("/car/info", { q: vin }, signal, {
timeoutMs: DECODE_REQUEST_TIMEOUT,
maxRetries: DECODE_MAX_RETRIES,
});
if (!data || typeof data !== "object") {
return null;
@@ -192,8 +202,10 @@ export class PartsCatalogsService {
endpoint: string,
params?: Record<string, string>,
externalSignal?: AbortSignal,
opts?: { timeoutMs?: number; maxRetries?: number },
): Promise<any> {
const maxRetries = MAX_RETRIES;
const maxRetries = opts?.maxRetries ?? MAX_RETRIES;
const timeoutMs = opts?.timeoutMs ?? REQUEST_TIMEOUT;
let session: PcatSession | null = null;
@@ -211,7 +223,7 @@ export class PartsCatalogsService {
}
try {
const signals = [AbortSignal.timeout(REQUEST_TIMEOUT)];
const signals = [AbortSignal.timeout(timeoutMs)];
if (externalSignal) signals.push(externalSignal);
const fetchOptions: RequestInit & { dispatcher?: any } = {
method: "GET",

View File

@@ -9,6 +9,7 @@
import { Injectable, Logger, type OnModuleInit, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { RedisService } from "../../redis/redis.service";
import { PL24_ENDPOINTS } from "./pl24.constants";
import {
PL24AuthorizeRequest,
@@ -43,7 +44,10 @@ export class PL24AuthService implements OnModuleInit {
private readonly proxyUrl: string | null;
private readonly timeout: number;
constructor(private configService: ConfigService) {
constructor(
private configService: ConfigService,
private readonly redis: RedisService,
) {
this.baseUrl = this.configService.get<string>("pl24.baseUrl", "https://www.partslink24.com");
this.companyCode = this.configService.get<string>("pl24.companyCode", "");
this.username = this.configService.get<string>("pl24.username", "");
@@ -304,6 +308,13 @@ export class PL24AuthService implements OnModuleInit {
if (!forceNew && this.tokenData && this.isTokenValid(this.tokenData)) {
return this.tokenData;
}
if (!forceNew) {
const cached = await this.hydrateAccountFromRedis("tr");
if (cached) {
this.tokenData = cached;
return this.tokenData;
}
}
this.logger.log("Logging in to PL24 (account 1 tr)...");
@@ -360,6 +371,7 @@ export class PL24AuthService implements OnModuleInit {
expiresAt: new Date(payload.exp * 1000),
services: payload.services || [],
};
await this.persistAccountToRedis("tr", this.tokenData);
this.logger.log(
`PL24 login (tr) successful. Token expires at ${this.tokenData.expiresAt.toISOString()}`,
@@ -423,6 +435,13 @@ export class PL24AuthService implements OnModuleInit {
if (!forceNew && this.tokenData2 && this.isTokenValid(this.tokenData2)) {
return this.tokenData2;
}
if (!forceNew) {
const cached = await this.hydrateAccountFromRedis("de");
if (cached) {
this.tokenData2 = cached;
return this.tokenData2;
}
}
if (!this.companyCode2 || !this.username2 || !this.password2) {
throw new UnauthorizedException("PL24 account 2 (de) credentials not configured");
@@ -481,6 +500,7 @@ export class PL24AuthService implements OnModuleInit {
expiresAt: new Date(payload.exp * 1000),
services: payload.services || [],
};
await this.persistAccountToRedis("de", this.tokenData2);
this.logger.log(
`PL24 login (de) successful. Token expires at ${this.tokenData2.expiresAt.toISOString()}`,
@@ -511,6 +531,33 @@ export class PL24AuthService implements OnModuleInit {
return this.proxyAgent;
}
// ── Redis-backed account-token cache (survives restarts; shared api+worker) ──
// Best-effort: any Redis error falls through to a normal login, so PL24 auth
// never depends on Redis being up. Sharing the token across processes also
// avoids the squeezeOut session-fight (PL24 allows one session per account).
private acctKey(account: "tr" | "de"): string {
return `pl24:auth:acct:${account}`;
}
private async hydrateAccountFromRedis(account: "tr" | "de"): Promise<PL24TokenData | null> {
try {
const raw = await this.redis.getJson<PL24TokenData>(this.acctKey(account));
if (!raw) return null;
const data: PL24TokenData = { ...raw, expiresAt: new Date(raw.expiresAt) };
if (!this.isTokenValid(data)) return null;
this.logger.log(`PL24 ${account} token hydrated from Redis (login skipped)`);
return data;
} catch {
return null;
}
}
private async persistAccountToRedis(account: "tr" | "de", data: PL24TokenData): Promise<void> {
const ttlSeconds = Math.floor((data.expiresAt.getTime() - Date.now()) / 1000);
if (ttlSeconds <= 0) return;
await this.redis.setJson(this.acctKey(account), data, ttlSeconds).catch(() => {});
}
private isTokenValid(token: PL24TokenData): boolean {
const bufferMs = 60 * 1000;
return token.expiresAt.getTime() - bufferMs > Date.now();

View File

@@ -480,10 +480,15 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
JTJ: "lexus_parts",
"2T2": "lexus_parts",
// Renault
VF1: "renault_parts",
VF6: "renault_parts",
VNE: "renault_parts",
// Renault — DISABLED: PL24 has suspended Renault VIN identification ("Bu marka
// için şasi numarası tanımlamasının belirsiz bir süre için mevcut olmayacağını
// üzülerek bildiririz."). renault_parts authorizes fine but every decode throws
// that message → wasted ~1s call AND it trips the PL24 circuit breaker, which
// then skips PL24 for ALL brands. PCAT + EMEX cover Renault. Re-enable when PL24
// restores Renault VIN decode.
// VF1: "renault_parts",
// VF6: "renault_parts",
// VNE: "renault_parts",
// Dacia
UU1: "dacia_parts",

View File

@@ -121,8 +121,32 @@ export class VehiclesService {
const resolved = await this.resolveVin(vin, pcatCarId, emexCarIndex, userId, ctx);
if (!resolved) {
const finalSource = ctx.timings.aborted ? "aborted" : "none";
const errMsg = ctx.timings.aborted
const aborted = !!ctx.timings.aborted;
// No catalog source has this VIN. Best-effort identification (offline Corgi
// WMI + NHTSA) so we don't dead-end with a bare "unsupported" — at least tell
// the dealer what car it is. The miss is logged (with wmi) for the coverage
// backlog either way. Skip on budget-abort (already a timeout, don't pile on).
if (!aborted) {
const basic = await this.identifyBasic(vin);
if (basic) {
ctx.timings.identified_no_catalog = 1;
await this.logQuery(
userId,
vin,
null,
"none",
false,
Date.now() - startTime,
`No catalog — identified as ${basic}`,
ctx.timings,
);
throw new BadRequestException(
`Bu araç ${basic} olarak tanındı, ancak bu şase için parça kataloğu henüz mevcut değil. Talebiniz kaydedildi.`,
);
}
}
const finalSource = aborted ? "aborted" : "none";
const errMsg = aborted
? `Decode budget exceeded (${VehiclesService.RESOLVE_BUDGET_MS}ms)`
: "Unknown VIN/brand";
await this.logQuery(
@@ -500,6 +524,31 @@ export class VehiclesService {
return "timeout";
}
/**
* Best-effort identification for VINs no catalog could decode: offline Corgi WMI
* → brand, NHTSA → model/year. Returns a display string ("Renault Clio 2018") or
* null if even the brand is unknown. Only used to give the user a meaningful
* "identified but no catalog yet" message instead of a bare "unsupported".
*/
private async identifyBasic(vin: string): Promise<string | null> {
const corgi = this.corgiService.decodeVin(vin);
let brand: string | null = corgi?.isKnown ? corgi.brandName : null;
let model: string | null = null;
let year: string | number | null = corgi?.modelYear ?? null;
try {
const nhtsa = await this.vinApiService.decodeVin(vin);
if (nhtsa) {
brand = brand || nhtsa.make || null;
model = nhtsa.model || null;
year = year || nhtsa.modelYear || null;
}
} catch {
// NHTSA is best-effort; a brand from Corgi alone is still useful.
}
if (!brand && !model) return null;
return [brand, model, year].filter(Boolean).join(" ").trim() || null;
}
/** Actual decode chain — does NOT touch the cache or lock. */
private async doResolveVin(
vin: string,