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:
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user