refactor(api): 收口 server/client 数据解析层,消除 aiGenerationClient 的 as T 断言
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
// DTO 解析层:把后端返回的 unknown 安全地解析成强类型 view model。
|
||||
// 所有从 serverRequest / SSE / localStorage 进入前端状态的 DTO 都应经过这里的 parser,
|
||||
// 避免 as unknown as / as T 这类静默断言在后端变形时把 undefined/错误类型传到 UI。
|
||||
//
|
||||
// helper 与 keyServerClient 里的 toNumber/toStringValue 同构,为避免改动 keyServerClient
|
||||
// 暂在此自带一份;后续可统一到共享 dtoHelpers 模块。
|
||||
|
||||
import { isRecord } from "./serverConnection";
|
||||
import type { AiTaskStatus, ImageTaskCreateResponse, ImageProviderDebug } from "./aiGenerationClient";
|
||||
|
||||
function toNumber(value: unknown, fallback = 0): number {
|
||||
const numberValue = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(numberValue) ? numberValue : fallback;
|
||||
}
|
||||
|
||||
function toNullableString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
const TASK_STATUS_VALUES: ReadonlySet<string> = new Set(["pending", "running", "completed", "failed", "cancelled"]);
|
||||
const TASK_TYPE_VALUES: ReadonlySet<string> = new Set(["image", "video"]);
|
||||
|
||||
function normalizeTaskStatusValue(value: unknown): AiTaskStatus["status"] {
|
||||
return typeof value === "string" && TASK_STATUS_VALUES.has(value)
|
||||
? (value as AiTaskStatus["status"])
|
||||
: "failed";
|
||||
}
|
||||
|
||||
function normalizeTaskTypeValue(value: unknown): AiTaskStatus["type"] {
|
||||
return typeof value === "string" && TASK_TYPE_VALUES.has(value) ? (value as AiTaskStatus["type"]) : "image";
|
||||
}
|
||||
|
||||
interface ProviderDebugCandidate {
|
||||
provider?: string;
|
||||
transport?: string;
|
||||
model?: string;
|
||||
requestedModel?: string;
|
||||
billingProvider?: string;
|
||||
fallbackOf?: string;
|
||||
}
|
||||
|
||||
function normalizeProviderDebugCandidate(raw: unknown): ProviderDebugCandidate {
|
||||
if (!isRecord(raw)) return {};
|
||||
return {
|
||||
provider: toNullableString(raw.provider) ?? undefined,
|
||||
transport: toNullableString(raw.transport) ?? undefined,
|
||||
model: toNullableString(raw.model) ?? undefined,
|
||||
requestedModel: toNullableString(raw.requestedModel ?? raw.requested_model) ?? undefined,
|
||||
billingProvider: toNullableString(raw.billingProvider ?? raw.billing_provider) ?? undefined,
|
||||
fallbackOf: toNullableString(raw.fallbackOf ?? raw.fallback_of) ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function toStringArray(raw: unknown): string[] | undefined {
|
||||
if (!Array.isArray(raw)) return undefined;
|
||||
return (raw as unknown[])
|
||||
.map((item) => toNullableString(item))
|
||||
.filter((item): item is string => item !== null);
|
||||
}
|
||||
|
||||
function normalizeProviderDebug(raw: unknown): ImageProviderDebug | undefined {
|
||||
if (!isRecord(raw)) return undefined;
|
||||
const hasAny =
|
||||
(raw.requestedModel ?? raw.requested_model) !== undefined ||
|
||||
(raw.effectiveModel ?? raw.effective_model) !== undefined ||
|
||||
(raw.primaryProvider ?? raw.primary_provider) !== undefined ||
|
||||
(raw.fallbackProviders ?? raw.fallback_providers) !== undefined ||
|
||||
raw.route !== undefined ||
|
||||
raw.candidates !== undefined;
|
||||
if (!hasAny) return undefined;
|
||||
const fallbackProviders = toStringArray(raw.fallbackProviders ?? raw.fallback_providers);
|
||||
const route = toStringArray(raw.route);
|
||||
const candidates = Array.isArray(raw.candidates)
|
||||
? (raw.candidates as unknown[]).map(normalizeProviderDebugCandidate)
|
||||
: undefined;
|
||||
return {
|
||||
requestedModel: toNullableString(raw.requestedModel ?? raw.requested_model) ?? undefined,
|
||||
effectiveModel: toNullableString(raw.effectiveModel ?? raw.effective_model) ?? undefined,
|
||||
primaryProvider: toNullableString(raw.primaryProvider ?? raw.primary_provider) ?? undefined,
|
||||
fallbackProviders,
|
||||
route,
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single task status DTO. Returns a well-formed AiTaskStatus with safe
|
||||
* defaults for any missing/malformed field, so downstream code never sees
|
||||
* undefined where it expects a value.
|
||||
*/
|
||||
export function parseAiTaskStatus(payload: unknown): AiTaskStatus {
|
||||
const task = isRecord(payload) ? payload : {};
|
||||
return {
|
||||
taskId: toNullableString(task.taskId ?? task.task_id ?? task.id) ?? "",
|
||||
projectId: toNullableString(task.projectId ?? task.project_id) ?? undefined,
|
||||
conversationId: typeof task.conversationId === "number" || typeof task.conversation_id === "number"
|
||||
? ((task.conversationId ?? task.conversation_id) as number)
|
||||
: null,
|
||||
clientQueueId: toNullableString(task.clientQueueId ?? task.client_queue_id),
|
||||
type: normalizeTaskTypeValue(task.type),
|
||||
status: normalizeTaskStatusValue(task.status),
|
||||
progress: Math.max(0, Math.min(100, toNumber(task.progress))),
|
||||
resultUrl: toNullableString(task.resultUrl ?? task.result_url),
|
||||
error: toNullableString(task.error),
|
||||
params: isRecord(task.params) ? task.params : undefined,
|
||||
createdAt: toNullableString(task.createdAt ?? task.created_at) ?? "",
|
||||
updatedAt: toNullableString(task.updatedAt ?? task.updated_at) ?? "",
|
||||
completedAt: toNullableString(task.completedAt ?? task.completed_at),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a task-create response ({ taskId }). Throws if taskId is missing,
|
||||
* rather than silently returning { taskId: undefined }.
|
||||
*/
|
||||
export function parseTaskCreateResponse(payload: unknown): { taskId: string } {
|
||||
const body = isRecord(payload) ? payload : {};
|
||||
const taskId = toNullableString(body.taskId ?? body.task_id ?? body.id);
|
||||
if (!taskId) {
|
||||
throw new Error("任务创建失败:服务端未返回任务 ID");
|
||||
}
|
||||
return { taskId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an image task-create response, including optional provider debug info.
|
||||
*/
|
||||
export function parseImageTaskCreateResponse(payload: unknown): ImageTaskCreateResponse {
|
||||
const base = parseTaskCreateResponse(payload);
|
||||
const body = isRecord(payload) ? payload : {};
|
||||
const providerDebug = normalizeProviderDebug(body.providerDebug ?? body.provider_debug);
|
||||
return providerDebug ? { ...base, providerDebug } : base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a task list payload that may be a bare array or an envelope
|
||||
* ({ tasks | items: [...] }). Malformed elements are dropped, not coerced,
|
||||
* because a single bad row should not corrupt the whole history list.
|
||||
*/
|
||||
export function parseAiTaskStatusList(payload: unknown): AiTaskStatus[] {
|
||||
let rows: unknown[];
|
||||
if (Array.isArray(payload)) {
|
||||
rows = payload;
|
||||
} else if (isRecord(payload)) {
|
||||
const nested = payload.tasks ?? payload.items;
|
||||
rows = Array.isArray(nested) ? nested : [];
|
||||
} else {
|
||||
rows = [];
|
||||
}
|
||||
// Keep only rows that have a non-empty taskId — empty-id rows are useless
|
||||
// to the UI and indicate a malformed DTO.
|
||||
return rows.map(parseAiTaskStatus).filter((task) => task.taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an SSE task frame. SSE data is untyped JSON from the server stream;
|
||||
* this validates the subset of fields that subscribeTaskStatus forwards.
|
||||
*/
|
||||
export function parseSseTaskFrame(payload: unknown): Pick<
|
||||
AiTaskStatus,
|
||||
"taskId" | "status" | "progress" | "resultUrl" | "error"
|
||||
> {
|
||||
const frame = isRecord(payload) ? payload : {};
|
||||
return {
|
||||
taskId: toNullableString(frame.taskId ?? frame.task_id) ?? "",
|
||||
status: normalizeTaskStatusValue(frame.status),
|
||||
progress: Math.max(0, Math.min(100, toNumber(frame.progress))),
|
||||
resultUrl: toNullableString(frame.resultUrl ?? frame.result_url),
|
||||
error: toNullableString(frame.error),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user