refactor(decode): keep decode-source details server-side, opaque candidate keys

The multi-candidate decode response leaked provider internals (source name,
pcat car ids, EMEX _ssd/_vid/_quickGroupsUrl/catalogId) and made the client
carry them between requests: the frontend stored candidateSource and echoed
pcatCarId/emexCarIndex back on selection.

Now the candidate list returned to the client carries only display fields
(name, description, parameters) plus an opaque key, and the provider mapping
is stashed in Redis (vin:candidates:*, 30m TTL, resolve-cache fallback). The
pick request sends just { vin, candidate }. Legacy pcatCarId/emexCarIndex
body params still work for already-loaded bundles.

Also drops `source` from the public /vehicles/preview response — no consumer
used it, and provider names must never be public (same policy as
teaser-stats).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 02:37:02 +03:00
parent 43dd4d38ac
commit 2077a9724a
6 changed files with 149 additions and 43 deletions

View File

@@ -37,10 +37,14 @@ export class VehiclesController {
async decode(
@CurrentUser("id") userId: string,
@Body("vin", VinValidationPipe) vin: string,
// Deprecated: provider-specific picks from pre-stash frontend bundles.
// New clients send only the opaque `candidate` key; the provider mapping
// lives in the server-side stash.
@Body("pcatCarId") pcatCarId?: string,
@Body("emexCarIndex") emexCarIndex?: number,
@Body("candidate") candidate?: string,
) {
return this.vehiclesService.decodeVin(vin, userId, pcatCarId, emexCarIndex);
return this.vehiclesService.decodeVin(vin, userId, pcatCarId, emexCarIndex, candidate);
}
@Get("history")

View File

@@ -620,8 +620,9 @@ describe("VehiclesService", () => {
const res = await service.previewVin("NM435600006H43436");
expect(res.source).toBe("parts-catalogs");
expect(res.brandName).toBe("Fiat"); // was null before the fix
// Provider/source adı public preview cevabına asla sızmamalı.
expect("source" in res).toBe(false);
});
});

View File

@@ -35,7 +35,7 @@ import { VinApiService } from "../integrations/vin-api/vin-api.service";
import { PrefetchSource } from "../jobs/prefetch.types";
import { CATALOG_PREFETCH_QUEUE } from "../jobs/queues/catalog-prefetch.queue";
import { RedisService } from "../redis/redis.service";
import { vinResolveCacheKeys } from "./vin-cache-keys";
import { vinCandidateStashKey, vinResolveCacheKeys } from "./vin-cache-keys";
/**
* Per-decode metadata, persisted to `query_logs.timings` jsonb. Despite the
@@ -66,6 +66,26 @@ interface VinResolveResult {
emexCandidates?: EmexCandidate[];
}
/**
* Server-side record of a pending multi-candidate selection. The client only
* ever sees the opaque per-candidate key (the index, as a string); which decode
* source produced the list and how to re-address the chosen car never leave
* the API.
*/
interface CandidateStash {
source: "parts-catalogs" | "emex";
/** Indexed by candidate key — provider-specific selector for each entry. */
picks: Array<{ pcatCarId?: string; emexCarIndex?: number }>;
}
/** Display-only candidate shape returned to the client. */
export interface PublicCandidate {
id: string;
name: string;
description?: string;
parameters?: PcatCar["parameters"];
}
@Injectable()
export class VehiclesService {
private readonly logger = new Logger(VehiclesService.name);
@@ -81,8 +101,31 @@ export class VehiclesService {
private redis: RedisService,
) {}
async decodeVin(vin: string, userId: string, pcatCarId?: string, emexCarIndex?: number) {
async decodeVin(
vin: string,
userId: string,
legacyPcatCarId?: string,
legacyEmexCarIndex?: number,
candidateKey?: string,
) {
const startTime = Date.now();
if (!isValidVin(vin)) {
throw new BadRequestException("Geçersiz şase numarası");
}
// Opaque candidate pick → provider-specific selection, restored from the
// server-side stash so the client never round-trips decode-source details
// (source name, pcat car ids, EMEX indexes) between requests. The legacy
// pcatCarId/emexCarIndex body params still work for old bundles.
let pcatCarId = legacyPcatCarId;
let emexCarIndex = legacyEmexCarIndex;
if (candidateKey !== undefined && pcatCarId === undefined && emexCarIndex === undefined) {
const pick = await this.resolveCandidateKey(vin, candidateKey);
pcatCarId = pick.pcatCarId;
emexCarIndex = pick.emexCarIndex;
}
const ctx: ResolveContext = {
timings: {
wmi: vin.length >= 3 ? vin.substring(0, 3).toUpperCase() : "",
@@ -90,10 +133,6 @@ export class VehiclesService {
},
};
if (!isValidVin(vin)) {
throw new BadRequestException("Geçersiz şase numarası");
}
// 1. Check for shared vehicle config by VIN (no userId filter)
const [existing] = await this.db.select().from(vehicles).where(eq(vehicles.vin, vin)).limit(1);
@@ -163,32 +202,32 @@ export class VehiclesService {
throw new BadRequestException("Şase numarası tanınamadı. Marka desteklenmiyor.");
}
// 2b. If resolveVin returned multiple candidates, return them for frontend selection
if (resolved.pcatCandidates && resolved.pcatCandidates.length > 1) {
// 2b. If resolveVin returned multiple candidates, return them for frontend
// selection. Provider details (source name, car ids, EMEX internals) are
// stashed server-side; the client only echoes back the opaque candidate key.
const hasPcatCandidates = !!resolved.pcatCandidates && resolved.pcatCandidates.length > 1;
const hasEmexCandidates = !!resolved.emexCandidates && resolved.emexCandidates.length > 1;
if (hasPcatCandidates || hasEmexCandidates) {
const stash = this.buildCandidateStash(resolved);
await this.redis.setJson(
vinCandidateStashKey(vin),
stash,
VehiclesService.CANDIDATE_STASH_TTL_S,
);
await this.logQuery(
userId,
vin,
null,
"parts-catalogs",
stash.source,
true,
Date.now() - startTime,
undefined,
ctx.timings,
);
return { candidates: resolved.pcatCandidates, vin, source: "parts-catalogs" };
}
if (resolved.emexCandidates && resolved.emexCandidates.length > 1) {
await this.logQuery(
userId,
vin,
null,
"emex",
true,
Date.now() - startTime,
undefined,
ctx.timings,
);
return { candidates: resolved.emexCandidates, vin, source: "emex" };
const raw = hasPcatCandidates
? (resolved.pcatCandidates as PcatCar[])
: (resolved.emexCandidates as EmexCandidate[]);
return { candidates: this.toPublicCandidates(raw), vin };
}
// 3. Brand access check
@@ -346,14 +385,14 @@ export class VehiclesService {
throw new BadRequestException("Geçersiz şase numarası");
}
// DB'de varsa direkt dön — dış API çağrısına gerek yok
// DB'de varsa direkt dön — dış API çağrısına gerek yok.
// Source/provider adı public preview'a asla dahil edilmez.
const [existing] = await this.db
.select({
brandName: vehicles.brandName,
model: vehicles.model,
year: vehicles.year,
engine: vehicles.engine,
source: vehicles.source,
})
.from(vehicles)
.where(eq(vehicles.vin, vin))
@@ -378,7 +417,6 @@ export class VehiclesService {
model: resolved.model,
year: resolved.year,
engine: resolved.engine,
source: resolved.source,
};
}
@@ -398,6 +436,10 @@ export class VehiclesService {
// PL24 decode (responseTimeMs=1006123) that ran long after the request was
// already aborted by TimeoutInterceptor. Anything past this is wasted work.
private static readonly RESOLVE_BUDGET_MS = 25_000;
// How long a multi-candidate selection stays valid. Picks normally happen
// within seconds; if the stash expires we fall back to the resolve cache
// (24h TTL) before giving up.
private static readonly CANDIDATE_STASH_TTL_S = 1_800; // 30m
// ─── PL24 circuit breaker ──────────────────────────────
// PL24 had p95=12min in production. When it goes bad it stays bad — every
@@ -869,6 +911,60 @@ export class VehiclesService {
return null;
}
// ─── Candidate stash (opaque pick keys) ────────────────
/** Build the server-side selector list for a multi-candidate resolve. */
private buildCandidateStash(resolved: VinResolveResult): CandidateStash {
if (resolved.pcatCandidates && resolved.pcatCandidates.length > 0) {
return {
source: "parts-catalogs",
picks: resolved.pcatCandidates.map((c) => ({ pcatCarId: c.id })),
};
}
return {
source: "emex",
picks: (resolved.emexCandidates ?? []).map((c) => ({
emexCarIndex: Number.isInteger(c._index) ? c._index : Number.parseInt(c.id, 10),
})),
};
}
/** Strip provider internals before the candidate list leaves the API. */
private toPublicCandidates(candidates: Array<PcatCar | EmexCandidate>): PublicCandidate[] {
return candidates.map((c, i) => ({
id: String(i),
name: c.name,
description: "description" in c ? c.description : undefined,
parameters: c.parameters,
}));
}
/**
* Map an opaque candidate key back to the provider-specific selection.
* Falls back to re-resolving the VIN (normally a Redis resolve-cache hit)
* when the stash has expired.
*/
private async resolveCandidateKey(
vin: string,
candidateKey: string,
): Promise<{ pcatCarId?: string; emexCarIndex?: number }> {
let stash = await this.redis.getJson<CandidateStash>(vinCandidateStashKey(vin));
if (!stash) {
const resolved = await this.resolveVin(vin);
if (resolved?.pcatCandidates?.length || resolved?.emexCandidates?.length) {
stash = this.buildCandidateStash(resolved);
}
}
const idx = Number(candidateKey);
const pick = stash && Number.isInteger(idx) ? stash.picks[idx] : undefined;
if (!pick) {
throw new BadRequestException(
"Araç seçimi zaman aşımına uğradı. Lütfen şase numarasını tekrar sorgulayın.",
);
}
return pick;
}
/**
* Resolve a specific PartsCatalogs car by ID (after user selects from candidates).
*/

View File

@@ -29,3 +29,14 @@ export function vinResolveCacheKeys(vin: string): {
lockKey: `vin:lock:${vin}`,
};
}
/**
* Server-side stash for a multi-candidate decode: maps the opaque candidate
* keys returned to the client back to the provider-specific selection
* (parts-catalogs car id / EMEX list index). Exists so the client never has to
* carry decode-source details between the decode and the candidate-pick
* requests.
*/
export function vinCandidateStashKey(vin: string): string {
return `vin:candidates:${DECODE_CHAIN_VERSION}:${vin}`;
}