fix: bypass Corgi WMI in VIN decode chain + misc integrations
- vehicles.service.ts: remove Corgi from resolveVin chain; go straight to PartsCatalogs → PL24 → EMEX without offline WMI lookup - End condition simplified: return null only when EMEX also fails (no corgiKnown fallback) - Add scripts/test-emex-http.mjs: proxy timing test for EMEX HTTP decode Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -891,6 +891,8 @@ export class CatalogService {
|
||||
remark: p.remark || null,
|
||||
modelCodes: p.modelCodes || null,
|
||||
presel: p.presel || false,
|
||||
price: p.price != null ? String(p.price) : null,
|
||||
currency: p.price != null ? (p.currency ?? "EUR") : null,
|
||||
source: "pl24" as const,
|
||||
}));
|
||||
|
||||
|
||||
@@ -794,6 +794,8 @@ export class CategoriesService {
|
||||
remark: p.remark || null,
|
||||
modelCodes: p.modelCodes || null,
|
||||
presel: p.presel || false,
|
||||
price: p.price != null ? String(p.price) : null,
|
||||
currency: p.price != null ? (p.currency ?? "EUR") : null,
|
||||
source: "pl24" as const,
|
||||
}));
|
||||
|
||||
|
||||
@@ -35,6 +35,10 @@ export default () => ({
|
||||
companyCode: process.env.PL24_COMPANY_CODE,
|
||||
username: process.env.PL24_USERNAME,
|
||||
password: process.env.PL24_PASSWORD,
|
||||
companyCode2: process.env.PL24_COMPANY_CODE_2,
|
||||
username2: process.env.PL24_USERNAME_2,
|
||||
password2: process.env.PL24_PASSWORD_2,
|
||||
proxyDe: process.env.PL24_PROXY_DE,
|
||||
},
|
||||
emex: {
|
||||
username: process.env.EMEX_USERNAME,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
text,
|
||||
boolean,
|
||||
integer,
|
||||
numeric,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
@@ -351,6 +352,8 @@ export const parts = pgTable(
|
||||
remark: text("remark"),
|
||||
modelCodes: varchar("model_codes", { length: 500 }),
|
||||
presel: boolean("presel").default(false).notNull(),
|
||||
price: numeric("price", { precision: 10, scale: 2 }),
|
||||
currency: varchar("currency", { length: 3 }),
|
||||
source: varchar("source", { length: 20 }).default("pl24").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
|
||||
@@ -77,14 +77,14 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
||||
this.semaphore = new Semaphore(MAX_CONCURRENT_PAGES);
|
||||
|
||||
this.useProxy =
|
||||
this.configService.get<string>('EMEX_USE_PROXY', 'false') === 'true';
|
||||
this.configService.get<string>('EMEX_USE_PROXY', 'true') === 'true';
|
||||
this.proxyHost = this.configService.get<string>(
|
||||
'EMEX_PROXY_HOST',
|
||||
'74.81.81.81',
|
||||
);
|
||||
this.proxyPortStart = this.configService.get<number>(
|
||||
'EMEX_PROXY_PORT_START',
|
||||
10000,
|
||||
10001,
|
||||
);
|
||||
this.proxyPortEnd = this.configService.get<number>(
|
||||
'EMEX_PROXY_PORT_END',
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as path from 'path';
|
||||
|
||||
import { ProxyAgent } from 'undici';
|
||||
import {
|
||||
EmexScraperResponse,
|
||||
EmexCategoryTreeNode,
|
||||
@@ -99,6 +100,7 @@ export class EmexService {
|
||||
private readonly scraperPath: string;
|
||||
private readonly timeout: number;
|
||||
private readonly debug: boolean;
|
||||
private readonly proxyAgent: ProxyAgent | null;
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
@@ -117,6 +119,24 @@ export class EmexService {
|
||||
this.timeout = this.configService.get<number>('EMEX_TIMEOUT', 60000);
|
||||
this.debug = this.configService.get<boolean>('EMEX_DEBUG', false);
|
||||
|
||||
const useProxy = this.configService.get<string>('EMEX_USE_PROXY', 'true') === 'true';
|
||||
if (useProxy) {
|
||||
const host = this.configService.get<string>('EMEX_PROXY_HOST', '74.81.81.81');
|
||||
const portStart = this.configService.get<number>('EMEX_PROXY_PORT_START', 10001);
|
||||
const portEnd = this.configService.get<number>('EMEX_PROXY_PORT_END', 10099);
|
||||
const user = this.configService.get<string>('EMEX_PROXY_USER', '1726bbe361918676d44e');
|
||||
const pass = this.configService.get<string>('EMEX_PROXY_PASS', 'f11c7b6128cc86c6');
|
||||
const port = Math.floor(Math.random() * (portEnd - portStart + 1)) + portStart;
|
||||
this.proxyAgent = new ProxyAgent({
|
||||
uri: `http://${user}:${pass}@${host}:${port}`,
|
||||
connect: { timeout: 30000 },
|
||||
requestTls: { timeout: 30000 },
|
||||
});
|
||||
this.logger.log(`EMEX HTTP proxy enabled: ${host}:${port}`);
|
||||
} else {
|
||||
this.proxyAgent = null;
|
||||
}
|
||||
|
||||
this.logger.log(`EMEX Service initialized with scraper path: ${this.scraperPath}`);
|
||||
}
|
||||
|
||||
@@ -230,7 +250,8 @@ export class EmexService {
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': EMEX_UA, 'Accept': 'text/html,application/xhtml+xml' },
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
...(this.proxyAgent ? { dispatcher: this.proxyAgent } : {}),
|
||||
} as RequestInit);
|
||||
if (!res.ok) {
|
||||
throw new Error(`EMEX HTTP ${res.status} for ${url}`);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
* PartsLink24 Authentication Service
|
||||
*
|
||||
* Handles JWT authentication, token refresh, and session management
|
||||
* for the partslink24.com API. Tokens cached in-memory (short-lived).
|
||||
* for the partslink24.com API. Supports two accounts:
|
||||
* - 'tr' (tr-903645): direct connection, primary VAG account
|
||||
* - 'de' (de-708171): DataImpulse Germany proxy, Fiat + EUR prices
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, UnauthorizedException } from "@nestjs/common";
|
||||
@@ -20,144 +22,92 @@ import type {
|
||||
@Injectable()
|
||||
export class PL24AuthService {
|
||||
private readonly logger = new Logger(PL24AuthService.name);
|
||||
private tokenData: PL24TokenData | null = null;
|
||||
private serviceTokens = new Map<
|
||||
string,
|
||||
{ token: string; expiresAt: Date }
|
||||
>();
|
||||
|
||||
// ── Account 1 (tr-903645) ──────────────────────────────────────────────────
|
||||
private tokenData: PL24TokenData | null = null;
|
||||
private serviceTokens = new Map<string, { token: string; expiresAt: Date }>();
|
||||
|
||||
// ── Account 2 (de-708171) ──────────────────────────────────────────────────
|
||||
private tokenData2: PL24TokenData | null = null;
|
||||
private serviceTokens2 = new Map<string, { token: string; expiresAt: Date }>();
|
||||
private proxyAgent: any = null; // undici.ProxyAgent, lazy-init
|
||||
|
||||
// ── Config ─────────────────────────────────────────────────────────────────
|
||||
private readonly baseUrl: string;
|
||||
private readonly companyCode: string;
|
||||
private readonly username: string;
|
||||
private readonly password: string;
|
||||
private readonly companyCode2: string;
|
||||
private readonly username2: string;
|
||||
private readonly password2: string;
|
||||
private readonly proxyUrl: string | null;
|
||||
private readonly timeout: number;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.baseUrl = this.configService.get<string>(
|
||||
"pl24.baseUrl",
|
||||
"https://www.partslink24.com",
|
||||
);
|
||||
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", "");
|
||||
this.password = this.configService.get<string>("pl24.password", "");
|
||||
this.companyCode2 = this.configService.get<string>("pl24.companyCode2", "");
|
||||
this.username2 = this.configService.get<string>("pl24.username2", "");
|
||||
this.password2 = this.configService.get<string>("pl24.password2", "");
|
||||
this.proxyUrl = this.configService.get<string>("pl24.proxyDe", "") || null;
|
||||
this.timeout = 30000;
|
||||
|
||||
if (!this.companyCode || !this.username || !this.password) {
|
||||
this.logger.warn(
|
||||
"PL24 credentials not configured. Set PL24_BASE_URL, PL24_COMPANY_CODE, PL24_USERNAME, PL24_PASSWORD",
|
||||
"PL24 account 1 credentials not configured. Set PL24_COMPANY_CODE, PL24_USERNAME, PL24_PASSWORD",
|
||||
);
|
||||
}
|
||||
if (this.companyCode2 && !this.proxyUrl) {
|
||||
this.logger.warn("PL24 account 2 (de) configured but PL24_PROXY_DE not set");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login to PL24 and get access token.
|
||||
* Uses squeezeOut=true to force logout other sessions.
|
||||
*/
|
||||
async login(forceNew = false): Promise<PL24TokenData> {
|
||||
if (!forceNew && this.tokenData && this.isTokenValid(this.tokenData)) {
|
||||
return this.tokenData;
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// ── Public: per-account API ──────────────────────────────────────────────
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** Return access token for the given account. */
|
||||
async getAccessTokenForAccount(account: "tr" | "de"): Promise<string> {
|
||||
if (account === "de") {
|
||||
if (!this.tokenData2 || !this.isTokenValid(this.tokenData2)) {
|
||||
await this.login2();
|
||||
}
|
||||
return this.tokenData2!.accessToken;
|
||||
}
|
||||
|
||||
this.logger.log("Logging in to PL24...");
|
||||
|
||||
const loginRequest: PL24LoginRequest = {
|
||||
authentication: {
|
||||
account: this.companyCode,
|
||||
user: this.username,
|
||||
pwd: this.password,
|
||||
},
|
||||
device: {
|
||||
id: "0",
|
||||
os: "Windows 10",
|
||||
offset: "0",
|
||||
lang: "en-US",
|
||||
"os-version": "0",
|
||||
},
|
||||
"app-version": "",
|
||||
squeezeOut: true,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}${PL24_ENDPOINTS.LOGIN}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
},
|
||||
body: JSON.stringify(loginRequest),
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as PL24LoginResponse;
|
||||
|
||||
if (data.status === "USER_ALREADY_LOGGED_IN") {
|
||||
this.logger.warn("User already logged in, session squeezed out");
|
||||
}
|
||||
|
||||
if (!data.token?.access_token) {
|
||||
this.logger.error(
|
||||
`PL24 login failed: ${data.status} - ${data.message || "No token returned"}`,
|
||||
);
|
||||
throw new UnauthorizedException(
|
||||
`PL24 giris basarisiz: ${data.message || data.status || "Token alinamadi"}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Extract session cookie
|
||||
const setCookie = response.headers.get("set-cookie");
|
||||
const sessionCookie = this.extractSessionCookie(setCookie);
|
||||
|
||||
// Decode JWT for expiration + services
|
||||
const payload = this.decodeJWT(data.token.access_token);
|
||||
|
||||
this.tokenData = {
|
||||
accessToken: data.token.access_token,
|
||||
refreshToken: data.refreshToken || "",
|
||||
sessionCookie,
|
||||
expiresAt: new Date(payload.exp * 1000),
|
||||
services: payload.services || [],
|
||||
};
|
||||
|
||||
this.logger.log(
|
||||
`PL24 login successful. Token expires at ${this.tokenData.expiresAt.toISOString()}`,
|
||||
);
|
||||
this.logger.log(
|
||||
`Available services: ${this.tokenData.services.length}`,
|
||||
);
|
||||
|
||||
return this.tokenData;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
if (err.name === "TimeoutError") {
|
||||
throw new UnauthorizedException("PL24 giris zaman asimina ugradi");
|
||||
}
|
||||
this.logger.error(`PL24 login error: ${err.message}`);
|
||||
throw new UnauthorizedException(`PL24 giris hatasi: ${err.message}`);
|
||||
if (!this.tokenData || !this.isTokenValid(this.tokenData)) {
|
||||
await this.login();
|
||||
}
|
||||
return this.tokenData!.accessToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service-specific authorization token.
|
||||
* Required for accessing specific catalogs.
|
||||
*/
|
||||
async authorizeService(serviceName: string): Promise<string> {
|
||||
const cached = this.serviceTokens.get(serviceName);
|
||||
/** Return session cookie for the given account. */
|
||||
async getSessionCookieForAccount(account: "tr" | "de"): Promise<string> {
|
||||
if (account === "de") {
|
||||
if (!this.tokenData2 || !this.isTokenValid(this.tokenData2)) {
|
||||
await this.login2();
|
||||
}
|
||||
return this.tokenData2!.sessionCookie;
|
||||
}
|
||||
if (!this.tokenData || !this.isTokenValid(this.tokenData)) {
|
||||
await this.login();
|
||||
}
|
||||
return this.tokenData!.sessionCookie;
|
||||
}
|
||||
|
||||
/** Authorize a service catalog for the given account and return the service token. */
|
||||
async authorizeServiceForAccount(serviceName: string, account: "tr" | "de"): Promise<string> {
|
||||
const cache = account === "de" ? this.serviceTokens2 : this.serviceTokens;
|
||||
const cached = cache.get(serviceName);
|
||||
if (cached && cached.expiresAt > new Date()) {
|
||||
return cached.token;
|
||||
}
|
||||
|
||||
const mainToken = await this.getAccessToken();
|
||||
const mainToken = await this.getAccessTokenForAccount(account);
|
||||
const sessionCookie = await this.getSessionCookieForAccount(account);
|
||||
|
||||
this.logger.log(`Authorizing service: ${serviceName}`);
|
||||
this.logger.log(`Authorizing service ${serviceName} for account ${account}`);
|
||||
|
||||
const authorizeRequest: PL24AuthorizeRequest = {
|
||||
serviceNames: [
|
||||
@@ -177,61 +127,201 @@ export class PL24AuthService {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}${PL24_ENDPOINTS.AUTHORIZE}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${mainToken}`,
|
||||
Cookie: this.tokenData?.sessionCookie || "",
|
||||
},
|
||||
body: JSON.stringify(authorizeRequest),
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
const dispatcher = await this.getProxyAgent4Account(account);
|
||||
const fetchOpts: RequestInit & { dispatcher?: any } = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${mainToken}`,
|
||||
Cookie: sessionCookie,
|
||||
},
|
||||
);
|
||||
body: JSON.stringify(authorizeRequest),
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
};
|
||||
if (dispatcher) fetchOpts.dispatcher = dispatcher;
|
||||
|
||||
const response = await fetch(`${this.baseUrl}${PL24_ENDPOINTS.AUTHORIZE}`, fetchOpts);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as PL24AuthorizeResponse;
|
||||
|
||||
const accessToken = data.access_token || data.token?.access_token;
|
||||
if (!accessToken) {
|
||||
throw new Error("No service token in response");
|
||||
}
|
||||
|
||||
const payload = this.decodeJWT(accessToken);
|
||||
this.serviceTokens.set(serviceName, {
|
||||
cache.set(serviceName, {
|
||||
token: accessToken,
|
||||
expiresAt: new Date(payload.exp * 1000),
|
||||
});
|
||||
|
||||
this.logger.log(`Service ${serviceName} authorized successfully`);
|
||||
this.logger.log(`Service ${serviceName} authorized for account ${account}`);
|
||||
return accessToken;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error(`Service authorization error: ${err.message}`);
|
||||
throw new UnauthorizedException(
|
||||
`Servis yetkilendirme hatasi: ${err.message}`,
|
||||
);
|
||||
this.logger.error(`Service authorization error (${account}): ${err.message}`);
|
||||
throw new UnauthorizedException(`Servis yetkilendirme hatasi: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Build standard JSON API auth headers for the given account. */
|
||||
async buildAuthHeadersForAccount(
|
||||
account: "tr" | "de",
|
||||
serviceName?: string,
|
||||
includeContentType = false,
|
||||
): Promise<Record<string, string>> {
|
||||
const token = serviceName
|
||||
? await this.authorizeServiceForAccount(serviceName, account)
|
||||
: await this.getAccessTokenForAccount(account);
|
||||
|
||||
const sessionCookie = await this.getSessionCookieForAccount(account);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Cookie: sessionCookie,
|
||||
Accept: "application/json",
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
};
|
||||
|
||||
if (includeContentType) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/** Build Ford-legacy HTML page auth headers for the given account. */
|
||||
async buildFordLegacyHeadersForAccount(
|
||||
serviceName: string,
|
||||
account: "tr" | "de",
|
||||
): Promise<Record<string, string>> {
|
||||
const token = await this.authorizeServiceForAccount(serviceName, account);
|
||||
const sessionCookie = await this.getSessionCookieForAccount(account);
|
||||
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Cookie: sessionCookie,
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
};
|
||||
}
|
||||
|
||||
/** Return PL24TOKEN cookie value for the given account (used as Ford hintstoken param). */
|
||||
getPL24TokenValueForAccount(account: "tr" | "de"): string | null {
|
||||
const data = account === "de" ? this.tokenData2 : this.tokenData;
|
||||
if (!data?.sessionCookie) return null;
|
||||
const match = data.sessionCookie.match(/PL24TOKEN=([^;]+)/);
|
||||
return match?.[1] || null;
|
||||
}
|
||||
|
||||
/** Return ProxyAgent for account 'de', null for 'tr'. */
|
||||
async getProxyAgent4Account(account: "tr" | "de"): Promise<any | null> {
|
||||
if (account !== "de") return null;
|
||||
return this.getProxyAgent();
|
||||
}
|
||||
|
||||
/** Clear in-memory tokens for the given account. */
|
||||
clearTokensForAccount(account: "tr" | "de"): void {
|
||||
if (account === "de") {
|
||||
this.tokenData2 = null;
|
||||
this.serviceTokens2.clear();
|
||||
this.logger.log("PL24 account 2 (de) tokens cleared");
|
||||
} else {
|
||||
this.tokenData = null;
|
||||
this.serviceTokens.clear();
|
||||
this.logger.log("PL24 account 1 (tr) tokens cleared");
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// ── Public: legacy API (backwards-compatible, always 'tr') ──────────────
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async login(forceNew = false): Promise<PL24TokenData> {
|
||||
if (!forceNew && this.tokenData && this.isTokenValid(this.tokenData)) {
|
||||
return this.tokenData;
|
||||
}
|
||||
|
||||
this.logger.log("Logging in to PL24 (account 1 tr)...");
|
||||
|
||||
const loginRequest: PL24LoginRequest = {
|
||||
authentication: {
|
||||
account: this.companyCode,
|
||||
user: this.username,
|
||||
pwd: this.password,
|
||||
},
|
||||
device: { id: "0", os: "Windows 10", offset: "0", lang: "en-US", "os-version": "0" },
|
||||
"app-version": "",
|
||||
squeezeOut: true,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}${PL24_ENDPOINTS.LOGIN}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
},
|
||||
body: JSON.stringify(loginRequest),
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as PL24LoginResponse;
|
||||
|
||||
if (data.status === "USER_ALREADY_LOGGED_IN") {
|
||||
this.logger.warn("User already logged in, session squeezed out");
|
||||
}
|
||||
|
||||
if (!data.token?.access_token) {
|
||||
this.logger.error(`PL24 login failed: ${data.status} - ${data.message || "No token returned"}`);
|
||||
throw new UnauthorizedException(
|
||||
`PL24 giris basarisiz: ${data.message || data.status || "Token alinamadi"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const setCookie = response.headers.get("set-cookie");
|
||||
const sessionCookie = this.extractSessionCookie(setCookie);
|
||||
const payload = this.decodeJWT(data.token.access_token);
|
||||
|
||||
this.tokenData = {
|
||||
accessToken: data.token.access_token,
|
||||
refreshToken: data.refreshToken || "",
|
||||
sessionCookie,
|
||||
expiresAt: new Date(payload.exp * 1000),
|
||||
services: payload.services || [],
|
||||
};
|
||||
|
||||
this.logger.log(`PL24 login (tr) successful. Token expires at ${this.tokenData.expiresAt.toISOString()}`);
|
||||
return this.tokenData;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
if (err.name === "TimeoutError") {
|
||||
throw new UnauthorizedException("PL24 giris zaman asimina ugradi");
|
||||
}
|
||||
this.logger.error(`PL24 login error: ${err.message}`);
|
||||
throw new UnauthorizedException(`PL24 giris hatasi: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async authorizeService(serviceName: string): Promise<string> {
|
||||
return this.authorizeServiceForAccount(serviceName, "tr");
|
||||
}
|
||||
|
||||
async getAccessToken(): Promise<string> {
|
||||
if (!this.tokenData || !this.isTokenValid(this.tokenData)) {
|
||||
await this.login();
|
||||
}
|
||||
return this.tokenData!.accessToken;
|
||||
return this.getAccessTokenForAccount("tr");
|
||||
}
|
||||
|
||||
async getSessionCookie(): Promise<string> {
|
||||
if (!this.tokenData || !this.isTokenValid(this.tokenData)) {
|
||||
await this.login();
|
||||
}
|
||||
return this.tokenData!.sessionCookie;
|
||||
return this.getSessionCookieForAccount("tr");
|
||||
}
|
||||
|
||||
getAvailableServices(): string[] {
|
||||
@@ -243,65 +333,117 @@ export class PL24AuthService {
|
||||
}
|
||||
|
||||
clearTokens(): void {
|
||||
this.tokenData = null;
|
||||
this.serviceTokens.clear();
|
||||
this.logger.log("All PL24 tokens cleared");
|
||||
this.clearTokensForAccount("tr");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build authorization headers for API requests.
|
||||
*/
|
||||
async buildAuthHeaders(
|
||||
serviceName?: string,
|
||||
includeContentType = false,
|
||||
): Promise<Record<string, string>> {
|
||||
const token = serviceName
|
||||
? await this.authorizeService(serviceName)
|
||||
: await this.getAccessToken();
|
||||
return this.buildAuthHeadersForAccount("tr", serviceName, includeContentType);
|
||||
}
|
||||
|
||||
const sessionCookie = await this.getSessionCookie();
|
||||
async buildFordLegacyHeaders(serviceName: string): Promise<Record<string, string>> {
|
||||
return this.buildFordLegacyHeadersForAccount(serviceName, "tr");
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Cookie: sessionCookie,
|
||||
Accept: "application/json",
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
};
|
||||
getPL24TokenValue(): string | null {
|
||||
return this.getPL24TokenValueForAccount("tr");
|
||||
}
|
||||
|
||||
if (includeContentType) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// ── Private helpers ──────────────────────────────────────────────────────
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** Login with account 2 (de-708171) via DataImpulse DE proxy. */
|
||||
private async login2(forceNew = false): Promise<PL24TokenData> {
|
||||
if (!forceNew && this.tokenData2 && this.isTokenValid(this.tokenData2)) {
|
||||
return this.tokenData2;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
if (!this.companyCode2 || !this.username2 || !this.password2) {
|
||||
throw new UnauthorizedException("PL24 account 2 (de) credentials not configured");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build headers for Ford legacy HTML page requests.
|
||||
* Uses text/html Accept instead of application/json.
|
||||
*/
|
||||
async buildFordLegacyHeaders(
|
||||
serviceName: string,
|
||||
): Promise<Record<string, string>> {
|
||||
const token = await this.authorizeService(serviceName);
|
||||
const sessionCookie = await this.getSessionCookie();
|
||||
this.logger.log("Logging in to PL24 (account 2 de)...");
|
||||
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Cookie: sessionCookie,
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
const loginRequest: PL24LoginRequest = {
|
||||
authentication: {
|
||||
account: this.companyCode2,
|
||||
user: this.username2,
|
||||
pwd: this.password2,
|
||||
},
|
||||
device: { id: "0", os: "Windows 10", offset: "0", lang: "en-US", "os-version": "0" },
|
||||
"app-version": "",
|
||||
squeezeOut: true,
|
||||
};
|
||||
|
||||
try {
|
||||
const dispatcher = await this.getProxyAgent();
|
||||
const fetchOpts: RequestInit & { dispatcher?: any } = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
},
|
||||
body: JSON.stringify(loginRequest),
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
};
|
||||
if (dispatcher) fetchOpts.dispatcher = dispatcher;
|
||||
|
||||
const response = await fetch(`${this.baseUrl}${PL24_ENDPOINTS.LOGIN}`, fetchOpts);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as PL24LoginResponse;
|
||||
|
||||
if (!data.token?.access_token) {
|
||||
this.logger.error(`PL24 login (de) failed: ${data.status} - ${data.message || "No token"}`);
|
||||
throw new UnauthorizedException(
|
||||
`PL24 (de) giris basarisiz: ${data.message || data.status || "Token alinamadi"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const setCookie = response.headers.get("set-cookie");
|
||||
const sessionCookie = this.extractSessionCookie(setCookie);
|
||||
const payload = this.decodeJWT(data.token.access_token);
|
||||
|
||||
this.tokenData2 = {
|
||||
accessToken: data.token.access_token,
|
||||
refreshToken: data.refreshToken || "",
|
||||
sessionCookie,
|
||||
expiresAt: new Date(payload.exp * 1000),
|
||||
services: payload.services || [],
|
||||
};
|
||||
|
||||
this.logger.log(`PL24 login (de) successful. Token expires at ${this.tokenData2.expiresAt.toISOString()}`);
|
||||
return this.tokenData2;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
if (err.name === "TimeoutError") {
|
||||
throw new UnauthorizedException("PL24 (de) giris zaman asimina ugradi");
|
||||
}
|
||||
this.logger.error(`PL24 login (de) error: ${err.message}`);
|
||||
throw new UnauthorizedException(`PL24 (de) giris hatasi: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PL24TOKEN cookie value for Ford hintstoken parameter.
|
||||
*/
|
||||
getPL24TokenValue(): string | null {
|
||||
if (!this.tokenData?.sessionCookie) return null;
|
||||
const match = this.tokenData.sessionCookie.match(/PL24TOKEN=([^;]+)/);
|
||||
return match?.[1] || null;
|
||||
/** Lazy-init ProxyAgent for the de account. */
|
||||
private async getProxyAgent(): Promise<any> {
|
||||
if (this.proxyAgent) return this.proxyAgent;
|
||||
if (!this.proxyUrl) return null;
|
||||
try {
|
||||
const { ProxyAgent } = await import("undici");
|
||||
this.proxyAgent = new ProxyAgent(this.proxyUrl);
|
||||
this.logger.log("PL24 DE ProxyAgent initialized");
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to init ProxyAgent: ${(err as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
return this.proxyAgent;
|
||||
}
|
||||
|
||||
private isTokenValid(token: PL24TokenData): boolean {
|
||||
@@ -312,9 +454,7 @@ export class PL24AuthService {
|
||||
private decodeJWT(token: string): PL24JWTPayload {
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
throw new Error("Invalid JWT format");
|
||||
}
|
||||
if (parts.length !== 3) throw new Error("Invalid JWT format");
|
||||
const payload = Buffer.from(parts[1], "base64").toString("utf-8");
|
||||
return JSON.parse(payload);
|
||||
} catch {
|
||||
@@ -325,12 +465,8 @@ export class PL24AuthService {
|
||||
|
||||
private extractSessionCookie(setCookie: string | null): string {
|
||||
if (!setCookie) return "";
|
||||
|
||||
const match = setCookie.match(/PL24TOKEN=([^;]+)/);
|
||||
if (match) {
|
||||
return `PL24TOKEN=${match[1]}`;
|
||||
}
|
||||
|
||||
if (match) return `PL24TOKEN=${match[1]}`;
|
||||
return setCookie.split(";")[0];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,10 +56,14 @@ export class PL24FordLegacyService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a VIN for any P4 legacy service (Ford, PSA, Hyundai-Kia, Nissan, Opel, Volvo, etc.).
|
||||
* Decode a VIN for any P4 legacy service (Ford, PSA, Hyundai-Kia, Nissan, Opel, Volvo, Fiat, etc.).
|
||||
* All P4 catalogs share the same JSP/.action architecture.
|
||||
*/
|
||||
async decodeVinForService(vin: string, serviceName: string): Promise<PL24DecodedVehicle | null> {
|
||||
async decodeVinForService(
|
||||
vin: string,
|
||||
serviceName: string,
|
||||
userId?: string,
|
||||
): Promise<PL24DecodedVehicle | null> {
|
||||
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}vehicle:${vin}`;
|
||||
const cached = await this.redis.getJson<PL24DecodedVehicle>(cacheKey);
|
||||
if (cached) return cached;
|
||||
@@ -67,25 +71,33 @@ export class PL24FordLegacyService {
|
||||
this.logger.log(`P4 legacy: decoding VIN ${vin} with service ${serviceName}`);
|
||||
|
||||
try {
|
||||
// PSA servisleri için JSESSIONID session gerekiyor — ayrı akış
|
||||
// PSA servisleri için JSESSIONID session gerekiyor — ayrı akış (always tr account)
|
||||
const config = getServiceConfig(serviceName);
|
||||
if (config?.architecture === "LEGACY_PSA") {
|
||||
return this.decodeVinPsa(vin, serviceName, cacheKey);
|
||||
}
|
||||
|
||||
// Ford / Hyundai-Kia / Nissan / Opel / Volvo devam eder...
|
||||
await this.authService.authorizeService(serviceName);
|
||||
// Resolve account (Fiat → de, others → round-robin or tr)
|
||||
const account = await this.resolveAccount(userId, serviceName);
|
||||
|
||||
const html = await this.fetchVinGroupPage(vin, serviceName);
|
||||
// Fiat uses same P4 flow as Ford — dedicated method for clarity
|
||||
if (config?.architecture === "LEGACY_FIAT") {
|
||||
return this.decodeVinFiat(vin, serviceName, cacheKey, account);
|
||||
}
|
||||
|
||||
// Ford / Hyundai-Kia / Nissan / Opel / Volvo devam eder...
|
||||
await this.authService.authorizeServiceForAccount(serviceName, account);
|
||||
|
||||
const html = await this.fetchVinGroupPage(vin, serviceName, account);
|
||||
if (!html) return null;
|
||||
|
||||
const support = this.extractScriptVariable<FordPL24Support>(html, "PL24_SUPPORT");
|
||||
if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") {
|
||||
this.logger.warn(`P4 legacy: demo mode for ${serviceName}, retrying with fresh auth`);
|
||||
this.authService.clearTokens();
|
||||
await this.authService.authorizeService(serviceName);
|
||||
this.authService.clearTokensForAccount(account);
|
||||
await this.authService.authorizeServiceForAccount(serviceName, account);
|
||||
|
||||
const retryHtml = await this.fetchVinGroupPage(vin, serviceName);
|
||||
const retryHtml = await this.fetchVinGroupPage(vin, serviceName, account);
|
||||
if (!retryHtml) return null;
|
||||
|
||||
const retrySupport = this.extractScriptVariable<FordPL24Support>(retryHtml, "PL24_SUPPORT");
|
||||
@@ -105,11 +117,12 @@ export class PL24FordLegacyService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch sub-groups for a Ford legacy link path.
|
||||
* Fetch sub-groups for a Ford/Fiat legacy link path.
|
||||
*/
|
||||
async fetchSubGroupsByPath(
|
||||
linkPath: string,
|
||||
serviceName: string,
|
||||
userId?: string,
|
||||
): Promise<PL24MainGroup[]> {
|
||||
const pathHash = createHash("sha256").update(linkPath).digest("hex").substring(0, 16);
|
||||
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}ford:subgroups:${pathHash}`;
|
||||
@@ -119,7 +132,8 @@ export class PL24FordLegacyService {
|
||||
this.logger.log(`Ford legacy: fetching sub-groups from ${linkPath}`);
|
||||
|
||||
try {
|
||||
const html = await this.fetchP4Page(linkPath, serviceName);
|
||||
const account = await this.resolveAccount(userId, serviceName);
|
||||
const html = await this.fetchP4Page(linkPath, serviceName, false, account);
|
||||
if (!html) return [];
|
||||
|
||||
// Opel/Ford: json-main-group.action → { vCfgData: [...] } (Opel) OR { maingroups: [...] } (Ford)
|
||||
@@ -253,11 +267,12 @@ export class PL24FordLegacyService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch parts for a Ford legacy link path.
|
||||
* Fetch parts for a Ford/Fiat legacy link path.
|
||||
*/
|
||||
async fetchPartsByPath(
|
||||
linkPath: string,
|
||||
serviceName: string,
|
||||
userId?: string,
|
||||
): Promise<PL24PartsResponse> {
|
||||
const pathHash = createHash("sha256").update(linkPath).digest("hex").substring(0, 16);
|
||||
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}ford:parts:${pathHash}`;
|
||||
@@ -267,6 +282,7 @@ export class PL24FordLegacyService {
|
||||
this.logger.log(`Ford legacy: fetching parts from ${linkPath}`);
|
||||
|
||||
try {
|
||||
const account = await this.resolveAccount(userId, serviceName);
|
||||
const isImageBoard = linkPath.includes("image-board.action");
|
||||
|
||||
// For image-board pages, capture the JSESSIONID so ticket + ImageViewer requests
|
||||
@@ -274,11 +290,11 @@ export class PL24FordLegacyService {
|
||||
let html: string | null;
|
||||
let pageJsessionId = "";
|
||||
if (isImageBoard) {
|
||||
const result = await this.fetchP4PageWithSession(linkPath, serviceName);
|
||||
const result = await this.fetchP4PageWithSession(linkPath, serviceName, false, account);
|
||||
html = result.html;
|
||||
pageJsessionId = result.jsessionId;
|
||||
} else {
|
||||
html = await this.fetchP4Page(linkPath, serviceName);
|
||||
html = await this.fetchP4Page(linkPath, serviceName, false, account);
|
||||
}
|
||||
|
||||
if (!html) {
|
||||
@@ -1748,6 +1764,71 @@ export class PL24FordLegacyService {
|
||||
return scopes;
|
||||
}
|
||||
|
||||
// ==================== PRIVATE: Account routing ====================
|
||||
|
||||
/**
|
||||
* Resolve which PL24 account to use for a P4 legacy request.
|
||||
* Fiat services always use 'de'; others use round-robin via Redis.
|
||||
*/
|
||||
private async resolveAccount(userId?: string, serviceName?: string): Promise<"tr" | "de"> {
|
||||
// Fiat always needs de account
|
||||
if (serviceName && ["fiatp_parts", "fiatt_parts"].includes(serviceName)) {
|
||||
return "de";
|
||||
}
|
||||
|
||||
// No userId → tr (catalog browser / prefetch)
|
||||
if (!userId) return "tr";
|
||||
|
||||
// Cached account for this user
|
||||
const redisKey = `pl24:account:${userId}`;
|
||||
const cached = await this.redis.get(redisKey);
|
||||
if (cached === "tr" || cached === "de") return cached;
|
||||
|
||||
// Round-robin assignment
|
||||
const counter = await this.redis.incr("pl24:rr");
|
||||
const account: "tr" | "de" = counter % 2 !== 0 ? "tr" : "de";
|
||||
await this.redis.set(redisKey, account, 86400);
|
||||
return account;
|
||||
}
|
||||
|
||||
// ==================== PRIVATE: Fiat VIN decode ====================
|
||||
|
||||
/**
|
||||
* Decode a Fiat VIN via P4 legacy .action endpoints (same flow as Ford).
|
||||
* Requires de-708171 account.
|
||||
* NOTE: basePath (/fca) needs Playwright verification with de account.
|
||||
*/
|
||||
private async decodeVinFiat(
|
||||
vin: string,
|
||||
serviceName: string,
|
||||
cacheKey: string,
|
||||
account: "tr" | "de",
|
||||
): Promise<PL24DecodedVehicle | null> {
|
||||
await this.authService.authorizeServiceForAccount(serviceName, account);
|
||||
|
||||
const html = await this.fetchVinGroupPage(vin, serviceName, account);
|
||||
if (!html) return null;
|
||||
|
||||
const support = this.extractScriptVariable<FordPL24Support>(html, "PL24_SUPPORT");
|
||||
if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") {
|
||||
this.logger.warn(`Fiat: demo mode for ${serviceName}, retrying with fresh auth`);
|
||||
this.authService.clearTokensForAccount(account);
|
||||
await this.authService.authorizeServiceForAccount(serviceName, account);
|
||||
|
||||
const retryHtml = await this.fetchVinGroupPage(vin, serviceName, account);
|
||||
if (!retryHtml) return null;
|
||||
|
||||
const retrySupport = this.extractScriptVariable<FordPL24Support>(retryHtml, "PL24_SUPPORT");
|
||||
if (retrySupport?.demo || retrySupport?.role === "NOT_LOGGED_IN_DEMO") {
|
||||
this.logger.warn(`Fiat: still demo after retry for ${serviceName}`);
|
||||
return null;
|
||||
}
|
||||
return this.parseAndCacheVehicle(retryHtml, vin, serviceName, cacheKey);
|
||||
}
|
||||
|
||||
return this.parseAndCacheVehicle(html, vin, serviceName, cacheKey);
|
||||
}
|
||||
|
||||
// ==================== PRIVATE: PSA VIN decode ====================
|
||||
|
||||
/**
|
||||
@@ -2786,13 +2867,17 @@ export class PL24FordLegacyService {
|
||||
* Fetch the VIN group page for any P4 legacy service.
|
||||
* URL pattern: {basePath}/{serviceName}/vin-group.action?vin=...&lang=...
|
||||
*/
|
||||
private async fetchVinGroupPage(vin: string, serviceName: string): Promise<string | null> {
|
||||
private async fetchVinGroupPage(
|
||||
vin: string,
|
||||
serviceName: string,
|
||||
account: "tr" | "de" = "tr",
|
||||
): Promise<string | null> {
|
||||
const config = getServiceConfig(serviceName);
|
||||
const vinGroupPath = config
|
||||
? `${config.basePath}/${serviceName}/vin-group.action`
|
||||
: FORD_LEGACY_ENDPOINTS.VIN_GROUP;
|
||||
|
||||
const token = this.authService.getPL24TokenValue();
|
||||
const token = this.authService.getPL24TokenValueForAccount(account);
|
||||
const params = new URLSearchParams({
|
||||
vin,
|
||||
lang: this.language,
|
||||
@@ -2800,40 +2885,43 @@ export class PL24FordLegacyService {
|
||||
});
|
||||
|
||||
const url = `${this.baseUrl}${vinGroupPath}?${params}`;
|
||||
return this.fetchP4Page(url, serviceName, true);
|
||||
return this.fetchP4Page(url, serviceName, true, account);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a Ford legacy page (HTML). Handles 401 retry.
|
||||
* Fetch a Ford/Fiat legacy page (HTML). Handles 401 retry.
|
||||
* Uses DataImpulse DE proxy when account='de'.
|
||||
*/
|
||||
private async fetchP4Page(
|
||||
url: string,
|
||||
serviceName: string,
|
||||
isFullUrl = false,
|
||||
account: "tr" | "de" = "tr",
|
||||
): Promise<string | null> {
|
||||
const fullUrl = isFullUrl ? url : `${this.baseUrl}${url}`;
|
||||
const headers = await this.authService.buildFordLegacyHeaders(serviceName);
|
||||
const headers = await this.authService.buildFordLegacyHeadersForAccount(serviceName, account);
|
||||
const dispatcher = await this.authService.getProxyAgent4Account(account);
|
||||
|
||||
try {
|
||||
let response = await fetch(fullUrl, {
|
||||
const buildOpts = (hdrs: Record<string, string>): RequestInit & { dispatcher?: any } => {
|
||||
const opts: RequestInit & { dispatcher?: any } = {
|
||||
method: "GET",
|
||||
headers,
|
||||
headers: hdrs,
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
redirect: "follow",
|
||||
});
|
||||
};
|
||||
if (dispatcher) opts.dispatcher = dispatcher;
|
||||
return opts;
|
||||
};
|
||||
|
||||
try {
|
||||
let response = await fetch(fullUrl, buildOpts(headers));
|
||||
|
||||
if (response.status === 401) {
|
||||
this.logger.warn("Ford legacy: 401, refreshing auth");
|
||||
this.authService.clearTokens();
|
||||
await this.authService.authorizeService(serviceName);
|
||||
const newHeaders = await this.authService.buildFordLegacyHeaders(serviceName);
|
||||
|
||||
response = await fetch(fullUrl, {
|
||||
method: "GET",
|
||||
headers: newHeaders,
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
redirect: "follow",
|
||||
});
|
||||
this.logger.warn(`Ford legacy: 401, refreshing auth (account=${account})`);
|
||||
this.authService.clearTokensForAccount(account);
|
||||
await this.authService.authorizeServiceForAccount(serviceName, account);
|
||||
const newHeaders = await this.authService.buildFordLegacyHeadersForAccount(serviceName, account);
|
||||
response = await fetch(fullUrl, buildOpts(newHeaders));
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -2865,27 +2953,31 @@ export class PL24FordLegacyService {
|
||||
url: string,
|
||||
serviceName: string,
|
||||
isFullUrl = false,
|
||||
account: "tr" | "de" = "tr",
|
||||
): Promise<{ html: string | null; jsessionId: string }> {
|
||||
const fullUrl = isFullUrl ? url : `${this.baseUrl}${url}`;
|
||||
const headers = await this.authService.buildFordLegacyHeaders(serviceName);
|
||||
const headers = await this.authService.buildFordLegacyHeadersForAccount(serviceName, account);
|
||||
const dispatcher = await this.authService.getProxyAgent4Account(account);
|
||||
|
||||
const doFetch = async (hdrs: Record<string, string>) => {
|
||||
return fetch(fullUrl, {
|
||||
const opts: RequestInit & { dispatcher?: any } = {
|
||||
method: "GET",
|
||||
headers: hdrs,
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
redirect: "follow",
|
||||
});
|
||||
};
|
||||
if (dispatcher) opts.dispatcher = dispatcher;
|
||||
return fetch(fullUrl, opts);
|
||||
};
|
||||
|
||||
try {
|
||||
let response = await doFetch(headers);
|
||||
|
||||
if (response.status === 401) {
|
||||
this.logger.warn("Ford legacy: 401, refreshing auth");
|
||||
this.authService.clearTokens();
|
||||
await this.authService.authorizeService(serviceName);
|
||||
const newHeaders = await this.authService.buildFordLegacyHeaders(serviceName);
|
||||
this.logger.warn(`Ford legacy: 401, refreshing auth (account=${account})`);
|
||||
this.authService.clearTokensForAccount(account);
|
||||
await this.authService.authorizeServiceForAccount(serviceName, account);
|
||||
const newHeaders = await this.authService.buildFordLegacyHeadersForAccount(serviceName, account);
|
||||
response = await doFetch(newHeaders);
|
||||
}
|
||||
|
||||
|
||||
@@ -58,9 +58,9 @@ export class PL24Service {
|
||||
|
||||
/**
|
||||
* Decode VIN and get vehicle info with categories.
|
||||
* Only P5 Modern architecture is supported.
|
||||
* Only P5 Modern architecture is supported here; legacy is dispatched to fordLegacyService.
|
||||
*/
|
||||
async decodeVin(vin: string): Promise<PL24DecodedVehicle | null> {
|
||||
async decodeVin(vin: string, userId?: string): Promise<PL24DecodedVehicle | null> {
|
||||
const cleanVin = vin.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, "");
|
||||
this.validateVin(cleanVin);
|
||||
|
||||
@@ -80,7 +80,7 @@ export class PL24Service {
|
||||
// Dispatch P4 legacy architectures to the generic legacy service
|
||||
if (!isP5Modern(serviceName)) {
|
||||
if (isLegacyArchitecture(serviceName)) {
|
||||
return this.fordLegacyService.decodeVinForService(cleanVin, serviceName);
|
||||
return this.fordLegacyService.decodeVinForService(cleanVin, serviceName, userId);
|
||||
}
|
||||
this.logger.warn(`Unknown architecture for service: ${serviceName}`);
|
||||
return null;
|
||||
@@ -98,9 +98,10 @@ export class PL24Service {
|
||||
}
|
||||
|
||||
try {
|
||||
// Get service-specific authorization
|
||||
await this.authService.authorizeService(serviceName);
|
||||
const headers = await this.authService.buildAuthHeaders(serviceName);
|
||||
// Resolve account for this user (round-robin tr/de, or Fiat always de)
|
||||
const account = await this.resolveAccount(userId, serviceName);
|
||||
await this.authService.authorizeServiceForAccount(serviceName, account);
|
||||
const headers = await this.authService.buildAuthHeadersForAccount(account, serviceName);
|
||||
|
||||
const catalogBase = catalogConfig.apiPath;
|
||||
|
||||
@@ -116,6 +117,7 @@ export class PL24Service {
|
||||
`${directAccessUrl}?${params}`,
|
||||
headers,
|
||||
serviceName,
|
||||
account,
|
||||
);
|
||||
|
||||
const responseData = (await response.json()) as Record<string, any>;
|
||||
@@ -191,6 +193,7 @@ export class PL24Service {
|
||||
vin: string,
|
||||
illustrationId: string,
|
||||
mainGroup: string,
|
||||
userId?: string,
|
||||
): Promise<PL24PartsResponse> {
|
||||
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}parts:${vin}:${illustrationId}`;
|
||||
const cached = await this.redis.getJson<PL24PartsResponse>(cacheKey);
|
||||
@@ -201,8 +204,9 @@ export class PL24Service {
|
||||
);
|
||||
|
||||
try {
|
||||
await this.authService.authorizeService(serviceName);
|
||||
const headers = await this.authService.buildAuthHeaders(serviceName);
|
||||
const account = await this.resolveAccount(userId, serviceName);
|
||||
await this.authService.authorizeServiceForAccount(serviceName, account);
|
||||
const headers = await this.authService.buildAuthHeadersForAccount(account, serviceName);
|
||||
|
||||
const catalogBase = getServiceApiPath(serviceName);
|
||||
|
||||
@@ -218,6 +222,7 @@ export class PL24Service {
|
||||
`${this.baseUrl}${catalogBase}/extern/bom/vin?${params}`,
|
||||
headers,
|
||||
serviceName,
|
||||
account,
|
||||
);
|
||||
|
||||
const data = (await response.json()) as Record<string, any>;
|
||||
@@ -257,10 +262,11 @@ export class PL24Service {
|
||||
body?: string,
|
||||
engine?: string,
|
||||
gearbox?: string,
|
||||
userId?: string,
|
||||
): Promise<PL24PartsResponse> {
|
||||
// Ford legacy dispatch
|
||||
// Ford/Fiat legacy dispatch
|
||||
if (this.isP4LegacyPath(linkPath)) {
|
||||
return this.fordLegacyService.fetchPartsByPath(linkPath, serviceName);
|
||||
return this.fordLegacyService.fetchPartsByPath(linkPath, serviceName, userId);
|
||||
}
|
||||
// PSA image-board dispatch
|
||||
if (this.isPsaBoardPath(linkPath)) {
|
||||
@@ -291,13 +297,15 @@ export class PL24Service {
|
||||
const fetchPath = this.convertPartInfoToBom(linkPath);
|
||||
|
||||
try {
|
||||
await this.authService.authorizeService(serviceName);
|
||||
const headers = await this.authService.buildAuthHeaders(serviceName);
|
||||
const account = await this.resolveAccount(userId, serviceName);
|
||||
await this.authService.authorizeServiceForAccount(serviceName, account);
|
||||
const headers = await this.authService.buildAuthHeadersForAccount(account, serviceName);
|
||||
|
||||
const response = await this.fetchWithRetry(
|
||||
`${this.baseUrl}${fetchPath}`,
|
||||
headers,
|
||||
serviceName,
|
||||
account,
|
||||
);
|
||||
|
||||
const data = (await response.json()) as Record<string, any>;
|
||||
@@ -348,12 +356,14 @@ export class PL24Service {
|
||||
serviceName: string,
|
||||
vehicleId: string,
|
||||
mainGroupId: string,
|
||||
userId?: string,
|
||||
): Promise<PL24MainGroup[]> {
|
||||
this.logger.log(`Fetching sub-groups for mainGroup=${mainGroupId}`);
|
||||
|
||||
try {
|
||||
await this.authService.authorizeService(serviceName);
|
||||
const headers = await this.authService.buildAuthHeaders(serviceName);
|
||||
const account = await this.resolveAccount(userId, serviceName);
|
||||
await this.authService.authorizeServiceForAccount(serviceName, account);
|
||||
const headers = await this.authService.buildAuthHeadersForAccount(account, serviceName);
|
||||
|
||||
const catalogBase = getServiceApiPath(serviceName);
|
||||
const params = new URLSearchParams({
|
||||
@@ -367,6 +377,7 @@ export class PL24Service {
|
||||
`${this.baseUrl}${catalogBase}/extern/subgroups?${params}`,
|
||||
headers,
|
||||
serviceName,
|
||||
account,
|
||||
);
|
||||
|
||||
const data = (await response.json()) as Record<string, any>;
|
||||
@@ -387,10 +398,11 @@ export class PL24Service {
|
||||
body?: string,
|
||||
engine?: string,
|
||||
gearbox?: string,
|
||||
userId?: string,
|
||||
): Promise<PL24MainGroup[]> {
|
||||
// Ford legacy dispatch
|
||||
// Ford/Fiat legacy dispatch
|
||||
if (this.isP4LegacyPath(linkPath)) {
|
||||
return this.fordLegacyService.fetchSubGroupsByPath(linkPath, serviceName);
|
||||
return this.fordLegacyService.fetchSubGroupsByPath(linkPath, serviceName, userId);
|
||||
}
|
||||
// PSA scope dispatch ("psa::{svc}::scope=..." → main groups)
|
||||
if (this.isPsaPath(linkPath)) {
|
||||
@@ -405,13 +417,15 @@ export class PL24Service {
|
||||
await this.touchActivity();
|
||||
|
||||
try {
|
||||
await this.authService.authorizeService(serviceName);
|
||||
const headers = await this.authService.buildAuthHeaders(serviceName);
|
||||
const account = await this.resolveAccount(userId, serviceName);
|
||||
await this.authService.authorizeServiceForAccount(serviceName, account);
|
||||
const headers = await this.authService.buildAuthHeadersForAccount(account, serviceName);
|
||||
|
||||
const response = await this.fetchWithRetry(
|
||||
`${this.baseUrl}${linkPath}`,
|
||||
headers,
|
||||
serviceName,
|
||||
account,
|
||||
);
|
||||
|
||||
const data = (await response.json()) as Record<string, any>;
|
||||
@@ -746,30 +760,34 @@ export class PL24Service {
|
||||
|
||||
/**
|
||||
* Fetch with 401 retry (re-auth on token expiry).
|
||||
* Uses DataImpulse DE proxy for account 'de', direct connection for 'tr'.
|
||||
*/
|
||||
private async fetchWithRetry(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
serviceName: string,
|
||||
account: "tr" | "de" = "tr",
|
||||
): Promise<Response> {
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
const dispatcher = await this.authService.getProxyAgent4Account(account);
|
||||
const buildOpts = (hdrs: Record<string, string>): RequestInit & { dispatcher?: any } => {
|
||||
const opts: RequestInit & { dispatcher?: any } = {
|
||||
method: "GET",
|
||||
headers: hdrs,
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
};
|
||||
if (dispatcher) opts.dispatcher = dispatcher;
|
||||
return opts;
|
||||
};
|
||||
|
||||
const response = await fetch(url, buildOpts(headers));
|
||||
|
||||
if (response.status === 401) {
|
||||
this.logger.warn("Got 401, refreshing token...");
|
||||
this.authService.clearTokens();
|
||||
await this.authService.authorizeService(serviceName);
|
||||
const newHeaders =
|
||||
await this.authService.buildAuthHeaders(serviceName);
|
||||
this.logger.warn(`Got 401 (account=${account}), refreshing token...`);
|
||||
this.authService.clearTokensForAccount(account);
|
||||
await this.authService.authorizeServiceForAccount(serviceName, account);
|
||||
const newHeaders = await this.authService.buildAuthHeadersForAccount(account, serviceName);
|
||||
|
||||
const retry = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: newHeaders,
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
const retry = await fetch(url, buildOpts(newHeaders));
|
||||
|
||||
if (!retry.ok) {
|
||||
throw new Error(`HTTP ${retry.status}: ${retry.statusText}`);
|
||||
@@ -788,6 +806,38 @@ export class PL24Service {
|
||||
return response;
|
||||
}
|
||||
|
||||
// ==================== PRIVATE: Account routing ====================
|
||||
|
||||
/**
|
||||
* Resolve which PL24 account to use for a request.
|
||||
*
|
||||
* Rules:
|
||||
* 1. Fiat services (fiatp_parts, fiatt_parts) always use 'de'
|
||||
* 2. No userId → 'tr' (catalog browser, prefetch jobs)
|
||||
* 3. Redis key `pl24:account:{userId}` already set → return cached account
|
||||
* 4. Otherwise: round-robin via INCR pl24:rr — odd='tr', even='de' — cache 24h
|
||||
*/
|
||||
private async resolveAccount(userId?: string, serviceName?: string): Promise<"tr" | "de"> {
|
||||
// Rule 1: Fiat always needs de account
|
||||
if (serviceName && ["fiatp_parts", "fiatt_parts"].includes(serviceName)) {
|
||||
return "de";
|
||||
}
|
||||
|
||||
// Rule 2: no userId → tr (catalog browser / prefetch)
|
||||
if (!userId) return "tr";
|
||||
|
||||
// Rule 3: cached account for this user
|
||||
const redisKey = `pl24:account:${userId}`;
|
||||
const cached = await this.redis.get(redisKey);
|
||||
if (cached === "tr" || cached === "de") return cached;
|
||||
|
||||
// Rule 4: round-robin assignment
|
||||
const counter = await this.redis.incr("pl24:rr");
|
||||
const account: "tr" | "de" = counter % 2 !== 0 ? "tr" : "de";
|
||||
await this.redis.set(redisKey, account, 86400);
|
||||
return account;
|
||||
}
|
||||
|
||||
// ==================== PRIVATE: VIN helpers ====================
|
||||
|
||||
private validateVin(vin: string): void {
|
||||
@@ -1209,6 +1259,14 @@ export class PL24Service {
|
||||
};
|
||||
}
|
||||
|
||||
// Price extraction (de account with German market returns EUR prices in BOM)
|
||||
const priceRaw = String(
|
||||
values.listPrice || values.netPrice || values.price || values.grossPrice || values.retailPrice || "",
|
||||
).trim();
|
||||
const priceNum = priceRaw ? parseFloat(priceRaw.replace(",", ".")) : Number.NaN;
|
||||
const price = Number.isNaN(priceNum) ? undefined : priceNum;
|
||||
const currency = price !== undefined ? (values.currency || "EUR") : undefined;
|
||||
|
||||
return {
|
||||
id: String(part.id || ""),
|
||||
oemCode: cleanPartNo,
|
||||
@@ -1229,6 +1287,8 @@ export class PL24Service {
|
||||
superseded,
|
||||
hotspotId: (part.hotspotId as string) || undefined,
|
||||
linkPath: (part.link as Record<string, string>)?.path,
|
||||
price,
|
||||
currency,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -96,7 +96,8 @@ export type PL24ApiArchitecture =
|
||||
| "LEGACY_FORD"
|
||||
| "LEGACY_NISSAN"
|
||||
| "LEGACY_OPEL"
|
||||
| "LEGACY_VOLVO";
|
||||
| "LEGACY_VOLVO"
|
||||
| "LEGACY_FIAT";
|
||||
|
||||
export interface PL24CatalogConfig {
|
||||
basePath: string;
|
||||
@@ -365,6 +366,19 @@ export const PL24_SERVICE_CATALOGS: Record<string, PL24CatalogConfig> = {
|
||||
apiPath: "/volvo",
|
||||
architecture: "LEGACY_VOLVO",
|
||||
},
|
||||
|
||||
// Fiat Group (FCA/Stellantis) — P4 Legacy, requires de-708171 account
|
||||
// NOTE: basePath/apiPath require Playwright verification with de account
|
||||
fiatp_parts: {
|
||||
basePath: "/fca",
|
||||
apiPath: "/fca",
|
||||
architecture: "LEGACY_FIAT",
|
||||
},
|
||||
fiatt_parts: {
|
||||
basePath: "/fca",
|
||||
apiPath: "/fca",
|
||||
architecture: "LEGACY_FIAT",
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== HELPER FUNCTIONS ====================
|
||||
@@ -516,6 +530,16 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
|
||||
"1FA": "fordp_parts",
|
||||
"3FA": "fordp_parts",
|
||||
|
||||
// Fiat Passenger (fiatp_parts) — requires de-708171 account
|
||||
ZFA: "fiatp_parts", // Fiat SpA Italy (most common)
|
||||
ZCF: "fiatp_parts", // Tofaş Turkey (Linea, Fiorino, etc.)
|
||||
ZFF: "fiatp_parts", // Abarth / Fiat Sport
|
||||
ZAR: "fiatp_parts", // Alfa Romeo
|
||||
ZLA: "fiatp_parts", // Lancia
|
||||
|
||||
// Fiat Commercial (fiatt_parts)
|
||||
ZFC: "fiatt_parts", // Fiat Commercial
|
||||
|
||||
// Hyundai
|
||||
KMH: "hyundai_parts", // Hyundai Korea Motor House
|
||||
TMK: "hyundai_parts", // Hyundai (Turkey/other markets)
|
||||
@@ -605,6 +629,8 @@ export interface PL24Part {
|
||||
additionalInfo?: Record<string, string>;
|
||||
hotspotId?: string;
|
||||
linkPath?: string;
|
||||
price?: number;
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
export interface PL24HotspotArea {
|
||||
@@ -759,6 +785,9 @@ export const SERVICE_TO_BRAND: Record<string, string> = {
|
||||
// Volvo/Polestar
|
||||
volvo_parts: "Volvo",
|
||||
polestar_parts: "Polestar",
|
||||
// Fiat Group
|
||||
fiatp_parts: "Fiat",
|
||||
fiatt_parts: "Fiat",
|
||||
};
|
||||
|
||||
// Display names for services that share a brand (multi-catalog brands)
|
||||
@@ -792,4 +821,7 @@ export const SERVICE_DISPLAY_NAMES: Record<string, string> = {
|
||||
// Citroen
|
||||
citroen_parts: "Citroen",
|
||||
citroenDs_parts: "Citroen DS",
|
||||
// Fiat Group
|
||||
fiatp_parts: "Fiat",
|
||||
fiatt_parts: "Fiat Ticari",
|
||||
};
|
||||
|
||||
@@ -75,6 +75,8 @@ export class PartsService {
|
||||
remark: p.remark || null,
|
||||
modelCodes: p.modelCodes || null,
|
||||
presel: p.presel || false,
|
||||
price: p.price != null ? String(p.price) : null,
|
||||
currency: p.price != null ? (p.currency ?? "EUR") : null,
|
||||
source: "pl24" as const,
|
||||
}));
|
||||
|
||||
|
||||
@@ -78,16 +78,13 @@ export class VehiclesService {
|
||||
.limit(1);
|
||||
|
||||
if (existing && !pcatCarId) {
|
||||
const age = Date.now() - new Date(existing.updatedAt).getTime();
|
||||
if (age < 24 * 60 * 60 * 1000) {
|
||||
// Fresh cache (<24h) — check brand access, link user, return
|
||||
if (existing.brandId) {
|
||||
await this.checkBrandAccess(userId, existing.brandId);
|
||||
}
|
||||
await this.ensureUserVehicleLink(userId, existing.id);
|
||||
await this.logQuery(userId, vin, existing.brandId, "cache", true, Date.now() - startTime);
|
||||
return existing;
|
||||
// DB'de kayıtlı araç varsa doğrudan dön — yaş kontrolü yok
|
||||
if (existing.brandId) {
|
||||
await this.checkBrandAccess(userId, existing.brandId);
|
||||
}
|
||||
await this.ensureUserVehicleLink(userId, existing.id);
|
||||
await this.logQuery(userId, vin, existing.brandId, "cache", true, Date.now() - startTime);
|
||||
return existing;
|
||||
}
|
||||
|
||||
// 2. Resolve VIN via cached decode chain (Corgi → PartsCatalogs → PL24 → EMEX)
|
||||
@@ -233,10 +230,10 @@ export class VehiclesService {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// 1. Corgi decode (offline)
|
||||
const corgiResult = this.corgiService.decodeVin(vin);
|
||||
const corgiKnown = !!(corgiResult && corgiResult.isKnown);
|
||||
let brandName = corgiKnown ? corgiResult.brandName : null;
|
||||
// Corgi bypassed — go straight to external sources
|
||||
const corgiResult = null;
|
||||
const corgiKnown = false;
|
||||
let brandName: string | null = null;
|
||||
|
||||
// 2. PartsCatalogs (first external source)
|
||||
let pcatCandidates: PcatCar[] | null = null;
|
||||
@@ -361,7 +358,7 @@ export class VehiclesService {
|
||||
}
|
||||
|
||||
// Nothing recognized this VIN
|
||||
if (!emexVehicle && !corgiKnown) {
|
||||
if (!emexVehicle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -387,9 +384,9 @@ export class VehiclesService {
|
||||
* Resolve a specific PartsCatalogs car by ID (after user selects from candidates).
|
||||
*/
|
||||
private async resolvePcatCarById(vin: string, pcatCarId: string): Promise<VinResolveResult | null> {
|
||||
const corgiResult = this.corgiService.decodeVin(vin);
|
||||
const corgiKnown = !!(corgiResult && corgiResult.isKnown);
|
||||
let brandName = corgiKnown ? corgiResult.brandName : null;
|
||||
const corgiResult = null;
|
||||
const corgiKnown = false;
|
||||
let brandName: string | null = null;
|
||||
|
||||
// Re-decode VIN to get fresh car list, then find the selected car
|
||||
const pcatResult = await this.partsCatalogsService.decodeVin(vin);
|
||||
@@ -427,8 +424,8 @@ export class VehiclesService {
|
||||
* (after user selects from the multi-candidate modal).
|
||||
*/
|
||||
private async resolveEmexCarByIndex(vin: string, index: number): Promise<VinResolveResult | null> {
|
||||
const corgiResult = this.corgiService.decodeVin(vin);
|
||||
const corgiKnown = !!(corgiResult && corgiResult.isKnown);
|
||||
const corgiResult = null;
|
||||
const corgiKnown = false;
|
||||
|
||||
const decoded = await this.emexService.decodeVinByIndex(vin, index);
|
||||
if (!decoded) {
|
||||
|
||||
@@ -18,6 +18,8 @@ export function PartsPanel({ parts, vehicleId, categoryId }: PartsPanelProps) {
|
||||
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
const hasPrices = parts.some((p) => p.price != null);
|
||||
|
||||
// Map group → IDs of available (non-unavailable) parts
|
||||
const availableByGroup = useMemo(() => {
|
||||
const map = new Map<number, string[]>();
|
||||
@@ -81,6 +83,7 @@ export function PartsPanel({ parts, vehicleId, categoryId }: PartsPanelProps) {
|
||||
<th className="px-3 py-2">OEM Kodu</th>
|
||||
<th className="px-3 py-2 w-14 text-center">Adet</th>
|
||||
<th className="px-3 py-2">Pozisyon</th>
|
||||
{hasPrices && <th className="px-3 py-2 text-right">Fiyat</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -155,6 +158,16 @@ export function PartsPanel({ parts, vehicleId, categoryId }: PartsPanelProps) {
|
||||
<td className="px-3 py-2 text-muted-foreground">
|
||||
{part.position}
|
||||
</td>
|
||||
{hasPrices && (
|
||||
<td className="px-3 py-2 text-right text-xs">
|
||||
{part.price != null
|
||||
? new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency: part.currency ?? "EUR",
|
||||
}).format(part.price)
|
||||
: "—"}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -12,7 +12,8 @@ export interface Part {
|
||||
remark?: string;
|
||||
modelCodes?: string;
|
||||
presel?: boolean;
|
||||
price?: number;
|
||||
price?: number | null;
|
||||
currency?: string | null;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user