|
|
import type { QuoteContractRates } from "@/workers/rpa/quote-capture/quote-contract-guard";
|
|
|
|
|
|
/** 归一化后的报价 rates(契约校验唯一输入) */
|
|
|
export type CanonicalQuote = {
|
|
|
rates: QuoteContractRates;
|
|
|
/** 解析来源路径(debug) */
|
|
|
sourcePath: string;
|
|
|
};
|
|
|
|
|
|
function isRatesObject(value: unknown): value is QuoteContractRates {
|
|
|
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
|
}
|
|
|
|
|
|
function hasRatesBranchShape(rates: QuoteContractRates): boolean {
|
|
|
return Object.keys(rates).some((key) => {
|
|
|
if (key === "fastest") {
|
|
|
return true;
|
|
|
}
|
|
|
const val = rates[key];
|
|
|
return val != null && typeof val === "object" && !Array.isArray(val);
|
|
|
});
|
|
|
}
|
|
|
|
|
|
function readPath(obj: unknown, ...keys: string[]): unknown {
|
|
|
let cur = obj;
|
|
|
for (const key of keys) {
|
|
|
if (cur == null || typeof cur !== "object" || Array.isArray(cur)) {
|
|
|
return undefined;
|
|
|
}
|
|
|
cur = (cur as Record<string, unknown>)[key];
|
|
|
}
|
|
|
return cur;
|
|
|
}
|
|
|
|
|
|
type RatesCandidate = { rates: QuoteContractRates; sourcePath: string };
|
|
|
|
|
|
function tryRatesAt(
|
|
|
rates: unknown,
|
|
|
sourcePath: string,
|
|
|
): RatesCandidate | null {
|
|
|
if (!isRatesObject(rates) || !hasRatesBranchShape(rates)) {
|
|
|
return null;
|
|
|
}
|
|
|
return { rates, sourcePath };
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 从 API body 解析 rates(支持常见嵌套变体)。
|
|
|
* body.rates | body.data.rates | body.result.rates | array[0].rates
|
|
|
*/
|
|
|
export function resolveRatesFromBody(body: unknown): RatesCandidate | null {
|
|
|
if (body == null) {
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
if (Array.isArray(body)) {
|
|
|
if (body.length > 0) {
|
|
|
const fromFirst = tryRatesAt(
|
|
|
readPath(body[0], "rates"),
|
|
|
"[0].rates",
|
|
|
);
|
|
|
if (fromFirst) {
|
|
|
return fromFirst;
|
|
|
}
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
if (typeof body !== "object") {
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
const candidates: RatesCandidate[] = [];
|
|
|
const direct = tryRatesAt(readPath(body, "rates"), "rates");
|
|
|
if (direct) {
|
|
|
candidates.push(direct);
|
|
|
}
|
|
|
const fromData = tryRatesAt(readPath(body, "data", "rates"), "data.rates");
|
|
|
if (fromData) {
|
|
|
candidates.push(fromData);
|
|
|
}
|
|
|
const fromResult = tryRatesAt(
|
|
|
readPath(body, "result", "rates"),
|
|
|
"result.rates",
|
|
|
);
|
|
|
if (fromResult) {
|
|
|
candidates.push(fromResult);
|
|
|
}
|
|
|
|
|
|
return candidates[0] ?? null;
|
|
|
}
|
|
|
|
|
|
/** body → CanonicalQuote;无法解析 rates 时返回 null */
|
|
|
export function normalizeQuote(body: unknown): CanonicalQuote | null {
|
|
|
const resolved = resolveRatesFromBody(body);
|
|
|
if (!resolved) {
|
|
|
return null;
|
|
|
}
|
|
|
return {
|
|
|
rates: resolved.rates,
|
|
|
sourcePath: resolved.sourcePath,
|
|
|
};
|
|
|
}
|