refactor: align provider and task contracts

This commit is contained in:
Qiufeng
2026-09-03 08:18:37 +08:00
parent b16390dd41
commit ca2e4b360e
13 changed files with 807 additions and 363 deletions
+136 -21
View File
@@ -1,6 +1,7 @@
export const CONTRACT_VERSION = "2026-08-20.v1" as const; export const CONTRACT_VERSION = "2026-08-20.v1" as const;
export const API_PREFIX = "/api/v1" as const; export const API_PREFIX = "/api/v1" as const;
export const WS_PATH = "/api/v1/ws/tasks" as const; export const WS_PATH = "/api/v1/ws/tasks" as const;
export const DOMAIN_SNAPSHOT_VERSION = 2 as const;
export const ErrorCodes = { export const ErrorCodes = {
Unauthorized: "UNAUTHORIZED", Unauthorized: "UNAUTHORIZED",
@@ -91,13 +92,16 @@ export type CapabilitySchema = {
async: boolean; async: boolean;
}; };
/** Values are stored by capability so management forms never need a raw JSON
* editor while a task can still snapshot the exact public contract. */
export type CapabilityParameters = Partial<Record<ModelCapability, Record<string, string | number | boolean | string[]>>>;
export type PublicResolution = { export type PublicResolution = {
id: string; id: string;
label: string; label: string;
width: number; width: number;
height: number; height: number;
priceMultiplier: number; /** Standard image ratios enabled for this public model; `auto` is implicit. */
/** Standard image ratios enabled for this billing preset; `auto` is implicit. */
ratios?: string[]; ratios?: string[];
}; };
@@ -130,19 +134,122 @@ export type PublicModelProduct = {
name: string; name: string;
tier: "basic" | "advanced" | "flagship"; tier: "basic" | "advanced" | "flagship";
capabilities: ModelCapability[]; capabilities: ModelCapability[];
capabilityParameters?: CapabilityParameters;
resolutions: PublicResolution[]; resolutions: PublicResolution[];
basePrice: number;
currencyUnit: string;
enabled: boolean; enabled: boolean;
version?: number; version?: number;
capabilitiesSchema?: CapabilitySchema[]; capabilitiesSchema?: CapabilitySchema[];
publicErrorMessages?: Partial<Record<ErrorCode, string>>; publicErrorMessages?: Partial<Record<ErrorCode, string>>;
maxCount?: number;
maxConcurrent?: number;
}; };
export type InternalModelProduct = PublicModelProduct & { export type InternalModelProduct = PublicModelProduct;
channelGroupId?: string;
export type PricingRuleTier = {
minQuantity: number;
maxQuantity?: number;
unitPrice: number;
};
export type PricingRule = {
id: string;
publicModelId: string;
resolutionId?: string;
billingUnit: "task" | "output";
tiers: PricingRuleTier[];
multiplier: number;
balanceUnitVersion: number;
effectiveFrom?: string;
effectiveTo?: string;
enabled: boolean;
version: number;
};
export type ChannelModel = {
requestModelId: string;
source: "discovered" | "manual";
enabled: boolean;
lastSeenAt?: string;
};
export type ModelRouteChannel = {
channelId: string;
priorityTier: number;
orderWithinTier: number;
retryBudget: number;
};
export type ModelRoute = {
publicModelId: string;
channels: ModelRouteChannel[];
strategy: "strict" | "smart-within-priority";
retryBudget: number;
version: number;
};
export type ChannelHealthStatus = "healthy" | "degraded" | "open" | "half-open";
export type ChannelHealthSampleStatus = "success" | "slow" | "network_error" | "http_error" | "provider_error" | "empty" | "probing";
export type ChannelHealthSample = {
id: string;
channelId: string;
source: "probe" | "task";
status: ChannelHealthSampleStatus;
latencyMs?: number;
httpStatus?: number;
requestPath?: string;
requestAttempted: boolean;
errorCode?: string;
createdAt: string;
};
export type CapacitySnapshot = {
current: number;
limit: number;
available: number;
};
export type RouteSnapshotCandidate = ModelRouteChannel & {
requestModelId: string;
};
export type RouteSnapshot = {
groupId: string;
groupVersion: number;
publicModelId: string;
routeVersion: number;
orderedCandidates: RouteSnapshotCandidate[];
strategy: ModelRoute["strategy"];
totalRetryBudget: number;
};
export type PricingSnapshot = {
ruleId: string;
ruleVersion: number;
publicModelId: string;
resolutionId?: string;
unitPrice: number;
quantity: number;
billingUnit: PricingRule["billingUnit"];
multiplier: number;
balanceUnitVersion: number;
};
export type ModelSnapshot = {
publicModelId: string;
modelVersion: number;
name: string;
tier: PublicModelProduct["tier"];
capabilities: ModelCapability[];
capabilityParameters?: CapabilityParameters;
resolutionId?: string;
resolution?: PublicResolution;
};
export type PlanSnapshot = {
planId?: string;
version?: number;
queuePriority: number;
maxConcurrent: number;
}; };
export type BalanceSummary = { export type BalanceSummary = {
@@ -242,18 +349,16 @@ export type GenerationTask = OrganizationScope & {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
eventSequence: number; eventSequence: number;
channelGroupId: string;
routeSnapshotVersion: number;
attempts: GenerationAttempt[]; attempts: GenerationAttempt[];
outputs: Array<{ id: string; mimeType: string; url?: string; status: "succeeded" | "failed"; unitPriceSnapshot?: number; chargedAmount?: number; metadata?: ImageOutputMetadata }>; outputs: Array<{ id: string; mimeType: string; url?: string; status: "succeeded" | "failed"; unitPriceSnapshot?: number; chargedAmount?: number; metadata?: ImageOutputMetadata }>;
publicErrorCode?: ErrorCode; publicErrorCode?: ErrorCode;
prompt?: string; prompt?: string;
resolutionPresetId?: string; resolutionPresetId?: string;
count?: number; count?: number;
pricingSnapshot?: { basePrice: number; multiplier: number; unitVersion: number }; pricingSnapshot: PricingSnapshot;
modelSnapshot?: { publicModelId: string; version: number }; modelSnapshot: ModelSnapshot;
routeSnapshot?: { groupId: string; version: number; channelIds: string[]; retryBudget?: number; channelRetryBudgets?: Record<string, number> }; routeSnapshot: RouteSnapshot;
planSnapshot?: { planId?: string; version?: number; queuePriority: number; maxConcurrent: number }; planSnapshot: PlanSnapshot;
leaseExpiresAt?: string; leaseExpiresAt?: string;
/** Fences a late worker/provider response after a lease is recovered. */ /** Fences a late worker/provider response after a lease is recovered. */
leaseToken?: string; leaseToken?: string;
@@ -405,7 +510,9 @@ export type AdminSessionResponse = {
export type AdminPage<T> = { export type AdminPage<T> = {
items: T[]; items: T[];
total?: number; total: number;
page?: number;
pageSize?: number;
nextCursor?: string; nextCursor?: string;
}; };
@@ -413,19 +520,27 @@ export type AdminChannelSummary = {
id: string; id: string;
label: string; label: string;
enabled: boolean; enabled: boolean;
priority: number; health: ChannelHealthStatus | "disabled";
health: "healthy" | "degraded" | "open";
version: number; version: number;
retryBudget: number;
consecutiveFailures: number; consecutiveFailures: number;
providerType?: "generic" | "openai-images"; providerType?: "generic" | "openai-images";
providerName?: string; providerName?: string;
providerModelId?: string; models?: ChannelModel[];
enabledModelIds?: string[];
modelMappings?: Array<{ displayModelId: string; requestModelId: string }>; modelMappings?: Array<{ displayModelId: string; requestModelId: string }>;
resolutionModelMap?: Record<string, string>;
baseUrl?: string; baseUrl?: string;
secretConfigured?: boolean; secretConfigured?: boolean;
capacity?: CapacitySnapshot;
circuitResetAt?: string;
groupIds?: string[];
samples?: Array<ChannelHealthSample | null>;
};
export type AdminChannelGroupSummary = {
id: string;
name?: string;
enabled: boolean;
version: number;
routes: ModelRoute[];
}; };
export type AdminTaskSummary = { export type AdminTaskSummary = {
+18 -13
View File
@@ -8,16 +8,20 @@ import { isPublicAddress, resolvePublicHttpsUrl, safeOutboundFetch } from "../in
export type ProviderOutput = { mimeType: string; data: string; metadata?: { width?: number; height?: number; format?: string; size?: string; revisedPrompt?: string; usage?: Record<string, number>; source?: "base64" | "url" } }; export type ProviderOutput = { mimeType: string; data: string; metadata?: { width?: number; height?: number; format?: string; size?: string; revisedPrompt?: string; usage?: Record<string, number>; source?: "base64" | "url" } };
export type ProviderResult = { status: "succeeded" | "unknown" | "failed"; providerRequestId?: string; outputs?: ProviderOutput[]; errorCode?: string; retryable?: boolean }; export type ProviderResult = { status: "succeeded" | "unknown" | "failed"; providerRequestId?: string; outputs?: ProviderOutput[]; errorCode?: string; retryable?: boolean };
export type ProviderInput = { taskId: string; taskType: TaskType; publicModelId?: string; prompt?: string; count?: number; references?: string[]; referenceImages?: Array<{ objectId: string; seq: number; name: string }>; maskObjectId?: string; params?: Record<string, unknown>; platformIdempotencyKey?: string; onRequestAttempted?: () => void }; export type ProviderInput = { taskId: string; taskType: TaskType; publicModelId?: string; requestModelId?: string; prompt?: string; count?: number; references?: string[]; referenceImages?: Array<{ objectId: string; seq: number; name: string }>; maskObjectId?: string; params?: Record<string, unknown>; platformIdempotencyKey?: string; onRequestAttempted?: () => void };
export type ProviderCancelResult = "confirmed" | "pending" | "unsupported"; export type ProviderCancelResult = "confirmed" | "pending" | "unsupported";
function resolveChannelModel(channel: ProviderChannel, publicModelId?: string) { export function resolveChannelModel(channel: ProviderChannel, publicModelId?: string, requestModelId?: string) {
const mappedModel = channel.modelMappings?.find((mapping) => mapping.displayModelId === publicModelId)?.requestModelId; const models = channel.models || [];
const directModel = publicModelId && (channel.enabledModelIds?.includes(publicModelId) || channel.providerModelId === publicModelId) ? publicModelId : undefined; const requested = requestModelId?.trim();
if (channel.providerType === "openai-images" && publicModelId && !mappedModel && !directModel) { if (requested) {
throw Object.assign(new Error("public model is not mapped to this channel"), { errorCode: "PROVIDER_MODEL_NOT_CONFIGURED", retryable: false }); if (!models.some((model) => model.enabled && model.requestModelId === requested)) throw Object.assign(new Error("request model is not registered for this channel"), { errorCode: "PROVIDER_MODEL_NOT_CONFIGURED", retryable: false });
return { model: requested, exact: true };
} }
return { model: mappedModel || directModel || channel.enabledModelIds?.[0] || channel.providerModelId, exact: Boolean(mappedModel || directModel) }; const mapped = publicModelId ? channel.modelMappings.find((mapping) => mapping.displayModelId === publicModelId)?.requestModelId : undefined;
if (mapped && models.some((model) => model.enabled && model.requestModelId === mapped)) return { model: mapped, exact: true };
if (publicModelId && models.some((model) => model.enabled && model.requestModelId === publicModelId)) return { model: publicModelId, exact: true };
throw Object.assign(new Error("public model is not mapped to this channel"), { errorCode: "PROVIDER_MODEL_NOT_CONFIGURED", retryable: false });
} }
const chineseImageNumbers = ["一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "十一", "十二", "十三", "十四", "十五", "十六"]; const chineseImageNumbers = ["一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "十一", "十二", "十三", "十四", "十五", "十六"];
@@ -176,8 +180,9 @@ async function invokeImage(store: Store, base: URL, secret: string, channel: Pro
// The channel owns the upstream model identity. Do not let a public task // The channel owns the upstream model identity. Do not let a public task
// parameter override the configured resolution mapping. // parameter override the configured resolution mapping.
delete params.model; delete params.model;
const channelModel = resolveChannelModel(channel, input.publicModelId); const channelModel = resolveChannelModel(channel, input.publicModelId, input.requestModelId);
const model = channel.providerType === "openai-images" ? imageModelFor(channelModel.model, rawParams.resolution, channelModel.exact ? undefined : channel.resolutionModelMap) : channelModel.model || "configured-model"; const model = channel.providerType === "openai-images" ? imageModelFor(channelModel.model, rawParams.resolution) : channelModel.model;
const timeoutMs = Math.max(1_000, Math.min(120_000, Number(channel.timeoutMs) || 60_000));
let rewrittenPrompt = [...effectiveReferences].sort((left, right) => right.name.length - left.name.length).reduce((value, reference) => value.split("@" + reference.name).join("第" + (chineseImageNumbers[reference.seq - 1] || reference.seq) + "张图"), prompt); let rewrittenPrompt = [...effectiveReferences].sort((left, right) => right.name.length - left.name.length).reduce((value, reference) => value.split("@" + reference.name).join("第" + (chineseImageNumbers[reference.seq - 1] || reference.seq) + "张图"), prompt);
if (hasReferences && mentionedNames.size === 0) rewrittenPrompt = rewrittenPrompt ? `${rewrittenPrompt}\n基于以下参考图生成。` : "基于以下参考图生成。"; if (hasReferences && mentionedNames.size === 0) rewrittenPrompt = rewrittenPrompt ? `${rewrittenPrompt}\n基于以下参考图生成。` : "基于以下参考图生成。";
let response: Response; let response: Response;
@@ -193,9 +198,9 @@ async function invokeImage(store: Store, base: URL, secret: string, channel: Pro
} }
if (input.maskObjectId) form.append("mask", await referenceBlob(store, input.maskObjectId), "mask.png"); if (input.maskObjectId) form.append("mask", await referenceBlob(store, input.maskObjectId), "mask.png");
for (const [key, value] of Object.entries(params)) if (typeof value === "string" || typeof value === "number") form.set(key, String(value)); for (const [key, value] of Object.entries(params)) if (typeof value === "string" || typeof value === "number") form.set(key, String(value));
response = await providerFetch(providerEndpoint(base, "v1/images/edits"), secret, { method: "POST", headers: { "Idempotency-Key": input.platformIdempotencyKey || input.taskId }, body: form }, 60_000, input.onRequestAttempted); response = await providerFetch(providerEndpoint(base, "v1/images/edits"), secret, { method: "POST", headers: { "Idempotency-Key": input.platformIdempotencyKey || input.taskId }, body: form }, timeoutMs, input.onRequestAttempted);
} else { } else {
response = await providerFetch(providerEndpoint(base, "v1/images/generations"), secret, { method: "POST", headers: { "content-type": "application/json", "Idempotency-Key": input.platformIdempotencyKey || input.taskId }, body: JSON.stringify({ model, prompt: rewrittenPrompt, n: count, ...params }) }, 60_000, input.onRequestAttempted); response = await providerFetch(providerEndpoint(base, "v1/images/generations"), secret, { method: "POST", headers: { "content-type": "application/json", "Idempotency-Key": input.platformIdempotencyKey || input.taskId }, body: JSON.stringify({ model, prompt: rewrittenPrompt, n: count, ...params }) }, timeoutMs, input.onRequestAttempted);
} }
const providerRequestIdHeader = providerRequestId(response); const providerRequestIdHeader = providerRequestId(response);
const data = await jsonResponse(response); const data = await jsonResponse(response);
@@ -214,8 +219,8 @@ async function invokeImage(store: Store, base: URL, secret: string, channel: Pro
return { id: typeof data.id === "string" && data.id.trim() ? data.id : providerRequestIdHeader, outputs }; return { id: typeof data.id === "string" && data.id.trim() ? data.id : providerRequestIdHeader, outputs };
} }
async function invokeText(base: URL, secret: string, channel: ProviderChannel, input: ProviderInput) { const model = resolveChannelModel(channel, input.publicModelId).model || "configured-model"; const response = await providerFetch(providerEndpoint(base, "v1/responses"), secret, { method: "POST", headers: { "content-type": "application/json", "Idempotency-Key": input.platformIdempotencyKey || input.taskId }, body: JSON.stringify({ ...input.params, model, input: input.prompt || "" }) }, 60_000, input.onRequestAttempted); const data = await jsonResponse(response); const nested = Array.isArray(data.output) ? (data.output as Array<{ content?: Array<{ text?: string }> }>).flatMap((item) => item.content || []).map((item) => item.text || "").join("") : ""; const text = typeof data.output_text === "string" ? data.output_text : nested; if (!text) throw Object.assign(new Error("provider returned no text"), { retryable: false, errorCode: "PROVIDER_EMPTY_RESULT" }); return { id: typeof data.id === "string" && data.id.trim() ? data.id : providerRequestId(response), outputs: [{ mimeType: "text/plain", data: Buffer.from(text).toString("base64") }] }; } async function invokeText(base: URL, secret: string, channel: ProviderChannel, input: ProviderInput) { const model = resolveChannelModel(channel, input.publicModelId, input.requestModelId).model; const response = await providerFetch(providerEndpoint(base, "v1/responses"), secret, { method: "POST", headers: { "content-type": "application/json", "Idempotency-Key": input.platformIdempotencyKey || input.taskId }, body: JSON.stringify({ ...input.params, model, input: input.prompt || "" }) }, Math.max(1_000, Math.min(120_000, Number(channel.timeoutMs) || 60_000)), input.onRequestAttempted); const data = await jsonResponse(response); const nested = Array.isArray(data.output) ? (data.output as Array<{ content?: Array<{ text?: string }> }>).flatMap((item) => item.content || []).map((item) => item.text || "").join("") : ""; const text = typeof data.output_text === "string" ? data.output_text : nested; if (!text) throw Object.assign(new Error("provider returned no text"), { retryable: false, errorCode: "PROVIDER_EMPTY_RESULT" }); return { id: typeof data.id === "string" && data.id.trim() ? data.id : providerRequestId(response), outputs: [{ mimeType: "text/plain", data: Buffer.from(text).toString("base64") }] }; }
async function invokeAudio(base: URL, secret: string, channel: ProviderChannel, input: ProviderInput) { const model = resolveChannelModel(channel, input.publicModelId).model || "configured-model"; const response = await providerFetch(providerEndpoint(base, "v1/audio/speech"), secret, { method: "POST", headers: { "content-type": "application/json", "Idempotency-Key": input.platformIdempotencyKey || input.taskId }, body: JSON.stringify({ ...input.params, model, input: input.prompt || "" }) }, 60_000, input.onRequestAttempted); if (!response.ok) throw providerHttpError(response.status); const bytes = Buffer.from(await response.arrayBuffer()); if (!bytes.length || bytes.byteLength > 50 * 1024 * 1024) throw new Error("provider audio response invalid"); const mimeType = (response.headers.get("content-type") || "audio/mpeg").split(";")[0].toLowerCase(); if (!/^audio\/(mpeg|wav|ogg|mp4)$/.test(mimeType)) throw new Error("provider audio MIME is not allowed"); return { id: providerRequestId(response), outputs: [{ mimeType, data: bytes.toString("base64") }] }; } async function invokeAudio(base: URL, secret: string, channel: ProviderChannel, input: ProviderInput) { const model = resolveChannelModel(channel, input.publicModelId, input.requestModelId).model; const response = await providerFetch(providerEndpoint(base, "v1/audio/speech"), secret, { method: "POST", headers: { "content-type": "application/json", "Idempotency-Key": input.platformIdempotencyKey || input.taskId }, body: JSON.stringify({ ...input.params, model, input: input.prompt || "" }) }, Math.max(1_000, Math.min(120_000, Number(channel.timeoutMs) || 60_000)), input.onRequestAttempted); if (!response.ok) throw providerHttpError(response.status); const bytes = Buffer.from(await response.arrayBuffer()); if (!bytes.length || bytes.byteLength > 50 * 1024 * 1024) throw new Error("provider audio response invalid"); const mimeType = (response.headers.get("content-type") || "audio/mpeg").split(";")[0].toLowerCase(); if (!/^audio\/(mpeg|wav|ogg|mp4)$/.test(mimeType)) throw new Error("provider audio MIME is not allowed"); return { id: providerRequestId(response), outputs: [{ mimeType, data: bytes.toString("base64") }] }; }
export async function invokeProvider(store: Store, channel: ProviderChannel, input: ProviderInput): Promise<ProviderResult> { export async function invokeProvider(store: Store, channel: ProviderChannel, input: ProviderInput): Promise<ProviderResult> {
if (!channel.baseUrl || !channel.secretRef) return { status: "failed", errorCode: channel.fixtureFailure === "unknown" ? "PROVIDER_TIMEOUT_UNKNOWN" : "CHANNEL_UNAVAILABLE", retryable: true }; if (!channel.baseUrl || !channel.secretRef) return { status: "failed", errorCode: channel.fixtureFailure === "unknown" ? "PROVIDER_TIMEOUT_UNKNOWN" : "CHANNEL_UNAVAILABLE", retryable: true };
+410 -187
View File
@@ -1,7 +1,7 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { parse as parseUrl } from "node:url"; import { parse as parseUrl } from "node:url";
import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto"; import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
import { ErrorCodes, type ApiEnvelope, type CreateTaskRequest, type GenerationTask, type HealthResponse, type InternalModelProduct, type PublicGenerationTask, type PublicModelProduct, type ReferenceImageGroup, type TaskType, type UploadObject } from "@miragenflow/contracts"; import { ErrorCodes, type ApiEnvelope, type ChannelModel, type CreateTaskRequest, type GenerationTask, type HealthResponse, type InternalModelProduct, type ModelRoute, type PricingRule, type PublicGenerationTask, type PublicModelProduct, type ReferenceImageGroup, type TaskType, type UploadObject } from "@miragenflow/contracts";
import { loadConfig, type ServerConfig } from "../config.ts"; import { loadConfig, type ServerConfig } from "../config.ts";
import { DomainError, apiError } from "../shared/errors.ts"; import { DomainError, apiError } from "../shared/errors.ts";
import { requireAdmin, requireUser, issueAccessToken, publicUser, hashPassword, verifyPassword, hashRefreshToken, hashChallenge, createNumericCode, createRecoveryCodes, createTotpSecret, decryptSecret, encryptSecret, totpProvisioningUri, verifyTotpCode, verifyTotpCodeWithCounter, bearer, revokeAccessToken, verifyAccessToken } from "../shared/auth.ts"; import { requireAdmin, requireUser, issueAccessToken, publicUser, hashPassword, verifyPassword, hashRefreshToken, hashChallenge, createNumericCode, createRecoveryCodes, createTotpSecret, decryptSecret, encryptSecret, totpProvisioningUri, verifyTotpCode, verifyTotpCodeWithCounter, bearer, revokeAccessToken, verifyAccessToken } from "../shared/auth.ts";
@@ -196,16 +196,25 @@ function createUserSessionResponse(store: Store, user: Store["users"] extends Ma
} }
function publicProduct(product: InternalModelProduct): PublicModelProduct { function publicProduct(product: InternalModelProduct): PublicModelProduct {
const { channelGroupId: _channelGroupId, ...safe } = product; return { ...product, id: product.publicModelId };
return { ...safe, id: product.publicModelId }; }
function channelRequestModel(channel: Store["channels"][number], publicModelId: string) {
const models = channel.models || [];
const mapping = (channel.modelMappings || []).find((item) => item.displayModelId === publicModelId);
if (mapping) return models.some((item) => item.enabled && item.requestModelId === mapping.requestModelId) ? mapping.requestModelId : undefined;
return models.some((item) => item.enabled && item.requestModelId === publicModelId) ? publicModelId : undefined;
}
function groupChannelIds(group: Store["channelGroups"][number]) {
return [...new Set(group.routes.flatMap((route) => route.channels.map((candidate) => candidate.channelId)))];
} }
function productAvailableThroughChannel(store: Store, product: InternalModelProduct) { function productAvailableThroughChannel(store: Store, product: InternalModelProduct) {
const group = store.channelGroups.find((item) => item.id === (product.channelGroupId || "image-default") && item.enabled); return store.channelGroups.some((group) => group.enabled && (group.routes || []).some((route) => route.publicModelId === product.publicModelId && route.channels.some((candidate) => {
return Boolean(group?.channelIds.some((channelId) => { const channel = store.channels.find((item) => item.id === candidate.channelId);
const channel = store.channels.find((item) => item.id === channelId); return channel?.enabled === true && channel.health !== "open" && Boolean(channelRequestModel(channel, product.publicModelId));
return channel?.enabled === true && channel.health !== "open"; })));
}));
} }
function webdavConnection(record: Store["webdav"] extends Map<string, infer V> ? V : never, secret: string) { function webdavConnection(record: Store["webdav"] extends Map<string, infer V> ? V : never, secret: string) {
@@ -443,48 +452,102 @@ function normalizeChannelGroupName(value: unknown) {
return name || undefined; return name || undefined;
} }
function normalizeChannelGroupProductIds(store: Store, value: unknown) { function normalizePublicModelIds(store: Store, value: unknown) {
if (value === undefined) return undefined; if (value === undefined) return undefined;
if (!Array.isArray(value)) throw new DomainError(ErrorCodes.ValidationFailed, "关联模型产品必须是数组", undefined, 422); if (!Array.isArray(value)) throw new DomainError(ErrorCodes.ValidationFailed, "公开模型列表必须是数组", undefined, 422);
const productIds = value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean); const publicModelIds = value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean).map((value) => store.products.find((product) => product.publicModelId === value || product.id === value)?.publicModelId || value);
if (new Set(productIds).size !== productIds.length || productIds.some((productId) => !store.products.some((product) => product.id === productId))) throw new DomainError(ErrorCodes.ValidationFailed, "关联模型产品无效", undefined, 422); if (new Set(publicModelIds).size !== publicModelIds.length || publicModelIds.some((publicModelId) => !store.products.some((product) => product.publicModelId === publicModelId))) throw new DomainError(ErrorCodes.ValidationFailed, "公开模型列表无效", undefined, 422);
return productIds; return publicModelIds;
} }
function validateChannelGroupProductModes(store: Store, capabilities: string[] | undefined, productIds: string[]) { function validateChannelGroupProductModes(store: Store, capabilities: string[] | undefined, publicModelIds: string[]) {
if (!capabilities?.length) return; if (!capabilities?.length) return;
const supported = new Set(capabilities); const supported = new Set(capabilities);
if (productIds.some((productId) => { if (publicModelIds.some((publicModelId) => {
const product = store.products.find((item) => item.id === productId); const product = store.products.find((item) => item.publicModelId === publicModelId);
return product && !product.capabilities.some((capability) => supported.has(capability)); return product && !product.capabilities.some((capability) => supported.has(capability));
})) throw new DomainError(ErrorCodes.ValidationFailed, "模型产品与分组适用模式不匹配", undefined, 422); })) throw new DomainError(ErrorCodes.ValidationFailed, "模型产品与分组适用模式不匹配", undefined, 422);
} }
function validateChannelGroupProductOwnership(store: Store, groupId: string, productIds: string[]) { function validateChannelGroupProductOwnership(store: Store, groupId: string, publicModelIds: string[], enabled: boolean) {
const conflicts = productIds if (!enabled) return;
.map((productId) => store.products.find((product) => product.id === productId)) const conflicts = publicModelIds
.filter((product): product is InternalModelProduct => Boolean(product?.channelGroupId && product.channelGroupId !== groupId)); .map((publicModelId) => store.products.find((product) => product.publicModelId === publicModelId))
.filter((product): product is InternalModelProduct => Boolean(product && store.channelGroups.some((group) => group.id !== groupId && group.enabled && group.routes.some((route) => route.publicModelId === product.publicModelId))));
if (conflicts.length) { if (conflicts.length) {
const labels = conflicts.map((product) => product.publicModelId || product.name || product.id).join("、"); const labels = conflicts.map((product) => product.publicModelId || product.name || product.id).join("、");
throw new DomainError(ErrorCodes.Conflict, `模型产品已关联其他渠道分组:${labels},请先解除原分组关联`, undefined, 409); throw new DomainError(ErrorCodes.Conflict, `模型产品已关联其他渠道分组:${labels},请先解除原分组关联`, undefined, 409);
} }
} }
function bindChannelGroupProducts(store: Store, groupId: string, productIds: string[]) { function normalizeChannelModels(value: unknown, fallback: ChannelModel[] = []) {
const selected = new Set(productIds); if (value === undefined) return fallback.map((model) => ({ ...model }));
for (const product of store.products) { if (!Array.isArray(value)) throw new DomainError(ErrorCodes.ValidationFailed, "渠道模型列表必须是数组", undefined, 422);
if (selected.has(product.id)) { const seen = new Set<string>();
if (product.channelGroupId !== groupId) { const models = value.map((item) => {
product.channelGroupId = groupId; const record = typeof item === "string" ? { requestModelId: item } : item && typeof item === "object" && !Array.isArray(item) ? item as Record<string, unknown> : {};
product.version = (product.version || 1) + 1; const requestModelId = typeof record.requestModelId === "string" ? record.requestModelId.trim().slice(0, 200) : "";
if (!requestModelId || seen.has(requestModelId)) throw new DomainError(ErrorCodes.ValidationFailed, "渠道请求模型 ID 必须填写且不能重复", undefined, 422);
seen.add(requestModelId);
return { requestModelId, source: record.source === "discovered" ? "discovered" as const : "manual" as const, enabled: record.enabled !== false, lastSeenAt: typeof record.lastSeenAt === "string" ? record.lastSeenAt : undefined };
});
return models;
}
function normalizeCapabilityParameters(value: unknown): InternalModelProduct["capabilityParameters"] {
if (value === undefined) return undefined;
if (!value || typeof value !== "object" || Array.isArray(value)) throw new DomainError(ErrorCodes.ValidationFailed, "能力参数必须是结构化对象", undefined, 422);
const output: NonNullable<InternalModelProduct["capabilityParameters"]> = {};
for (const [capability, raw] of Object.entries(value as Record<string, unknown>)) {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new DomainError(ErrorCodes.ValidationFailed, "能力参数必须按能力分组填写", { capability }, 422);
const fields: Record<string, string | number | boolean | string[]> = {};
for (const [key, field] of Object.entries(raw as Record<string, unknown>)) {
if (typeof field === "string" || typeof field === "number" || typeof field === "boolean" || (Array.isArray(field) && field.every((item) => typeof item === "string"))) fields[key] = Array.isArray(field) ? [...field] : field;
else throw new DomainError(ErrorCodes.ValidationFailed, "能力参数包含不支持的值", { capability, field: key }, 422);
} }
continue; output[capability as InternalModelProduct["capabilities"][number]] = fields;
} }
if (product.channelGroupId !== groupId) continue; return output;
product.channelGroupId = undefined; }
product.enabled = false;
product.version = (product.version || 1) + 1; function validateRoute(store: Store, route: ModelRoute, requireEnabledChannels = false) {
if (!store.products.some((product) => product.publicModelId === route.publicModelId)) throw new DomainError(ErrorCodes.ValidationFailed, "路由中的公开模型不存在", { publicModelId: route.publicModelId }, 422);
if (!Number.isSafeInteger(route.retryBudget) || route.retryBudget < 0 || !Number.isSafeInteger(route.version) || route.version < 1) throw new DomainError(ErrorCodes.ValidationFailed, "模型路由重试预算或版本无效", undefined, 422);
if (route.strategy !== "strict" && route.strategy !== "smart-within-priority") throw new DomainError(ErrorCodes.ValidationFailed, "模型路由策略无效", undefined, 422);
const seenChannels = new Set<string>();
const tierOrders = new Map<number, Set<number>>();
for (const candidate of route.channels) {
const channel = store.channels.find((item) => item.id === candidate.channelId);
if (!channel || seenChannels.has(candidate.channelId)) throw new DomainError(ErrorCodes.ValidationFailed, "模型路由渠道成员无效或重复", { publicModelId: route.publicModelId, channelId: candidate.channelId }, 422);
if (!Number.isSafeInteger(candidate.priorityTier) || candidate.priorityTier < 1 || !Number.isSafeInteger(candidate.orderWithinTier) || candidate.orderWithinTier < 1 || !Number.isSafeInteger(candidate.retryBudget) || candidate.retryBudget < 0) throw new DomainError(ErrorCodes.ValidationFailed, "模型路由优先级或失败预算无效", { publicModelId: route.publicModelId, channelId: candidate.channelId }, 422);
if (requireEnabledChannels && !channel.enabled) throw new DomainError(ErrorCodes.ValidationFailed, "停用渠道不能加入启用分组路由", { channelId: candidate.channelId }, 422);
if (!channelRequestModel(channel, route.publicModelId)) throw new DomainError(ErrorCodes.ValidationFailed, "渠道缺少该公开模型的精确请求映射", { channelId: candidate.channelId, publicModelId: route.publicModelId }, 422);
const orders = tierOrders.get(candidate.priorityTier) || new Set<number>();
if (orders.has(candidate.orderWithinTier)) throw new DomainError(ErrorCodes.ValidationFailed, "同一优先级层内顺序不能重复", { publicModelId: route.publicModelId, priorityTier: candidate.priorityTier }, 422);
orders.add(candidate.orderWithinTier); tierOrders.set(candidate.priorityTier, orders); seenChannels.add(candidate.channelId);
} }
if (!route.channels.length) throw new DomainError(ErrorCodes.ValidationFailed, "每个模型路由至少需要一个渠道", { publicModelId: route.publicModelId }, 422);
}
function normalizeRoutes(store: Store, value: unknown, fallback: ModelRoute[] = [], requireEnabledChannels = false) {
if (value === undefined) return fallback.map((route) => ({ ...route, channels: route.channels.map((channel) => ({ ...channel })) }));
if (!Array.isArray(value)) throw new DomainError(ErrorCodes.ValidationFailed, "模型路由必须是数组", undefined, 422);
const seenModels = new Set<string>();
const routes = value.map((item) => {
if (!item || typeof item !== "object" || Array.isArray(item)) throw new DomainError(ErrorCodes.ValidationFailed, "模型路由格式无效", undefined, 422);
const record = item as Record<string, unknown>;
const publicModelId = typeof record.publicModelId === "string" ? record.publicModelId.trim() : "";
const rawChannels = Array.isArray(record.channels) ? record.channels : [];
const channels = rawChannels.map((candidate) => {
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) throw new DomainError(ErrorCodes.ValidationFailed, "模型路由渠道格式无效", undefined, 422);
const value = candidate as Record<string, unknown>;
return { channelId: typeof value.channelId === "string" ? value.channelId.trim() : "", priorityTier: Number(value.priorityTier), orderWithinTier: Number(value.orderWithinTier), retryBudget: Number(value.retryBudget ?? 0) };
});
const route: ModelRoute = { publicModelId, channels, strategy: record.strategy === "smart-within-priority" ? "smart-within-priority" : "strict", retryBudget: Number(record.retryBudget ?? 0), version: Number(record.version ?? 1) };
if (!publicModelId || seenModels.has(publicModelId)) throw new DomainError(ErrorCodes.ValidationFailed, "模型路由中的公开模型必须唯一", undefined, 422);
seenModels.add(publicModelId); validateRoute(store, route, requireEnabledChannels); return route;
});
return routes;
} }
function normalizeResolutionList(value: unknown) { function normalizeResolutionList(value: unknown) {
@@ -495,12 +558,12 @@ function normalizeResolutionList(value: unknown) {
const record = item as Record<string, unknown>; const record = item as Record<string, unknown>;
const id = typeof record.id === "string" ? record.id.trim() : ""; const id = typeof record.id === "string" ? record.id.trim() : "";
const label = typeof record.label === "string" ? record.label.trim() : ""; const label = typeof record.label === "string" ? record.label.trim() : "";
const width = Number(record.width); const height = Number(record.height); const priceMultiplier = Number(record.priceMultiplier); const width = Number(record.width); const height = Number(record.height);
const requestedRatios = record.ratios === undefined ? OPENAI_IMAGE_RATIOS.filter((ratio) => ratio !== "auto") : record.ratios; const requestedRatios = record.ratios === undefined ? OPENAI_IMAGE_RATIOS.filter((ratio) => ratio !== "auto") : record.ratios;
if (!Array.isArray(requestedRatios) || requestedRatios.some((ratio) => typeof ratio !== "string" || ratio === "auto" || !OPENAI_IMAGE_RATIOS.includes(ratio as typeof OPENAI_IMAGE_RATIOS[number])) || new Set(requestedRatios).size !== requestedRatios.length) throw new DomainError(ErrorCodes.ValidationFailed, "分辨率支持的比例无效", undefined, 422); if (!Array.isArray(requestedRatios) || requestedRatios.some((ratio) => typeof ratio !== "string" || ratio === "auto" || !OPENAI_IMAGE_RATIOS.includes(ratio as typeof OPENAI_IMAGE_RATIOS[number])) || new Set(requestedRatios).size !== requestedRatios.length) throw new DomainError(ErrorCodes.ValidationFailed, "分辨率支持的比例无效", undefined, 422);
if (!id || !label || seen.has(id) || !Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width < 16 || height < 16 || width > 8192 || height > 8192 || !Number.isFinite(priceMultiplier) || priceMultiplier <= 0) throw new DomainError(ErrorCodes.ValidationFailed, "分辨率必须包含唯一标识、正整数尺寸和正价格倍率", undefined, 422); if (!id || !label || seen.has(id) || !Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width < 16 || height < 16 || width > 8192 || height > 8192) throw new DomainError(ErrorCodes.ValidationFailed, "分辨率必须包含唯一标识和正整数尺寸", undefined, 422);
seen.add(id); seen.add(id);
return { id, label, width, height, priceMultiplier, ratios: [...requestedRatios] }; return { id, label, width, height, ratios: [...requestedRatios] };
}); });
if (!resolutions.length) throw new DomainError(ErrorCodes.ValidationFailed, "至少需要一个有效分辨率", undefined, 422); if (!resolutions.length) throw new DomainError(ErrorCodes.ValidationFailed, "至少需要一个有效分辨率", undefined, 422);
return resolutions; return resolutions;
@@ -530,8 +593,21 @@ function normalizeEnabledModelIds(value: unknown) {
return modelIds; return modelIds;
} }
function validateMappingTargets(enabledModelIds: string[], modelMappings: ReturnType<typeof normalizeModelMappings>) { function normalizeChannelTimeout(value: unknown, fallback = 60_000) {
if (modelMappings?.some((mapping) => !enabledModelIds.includes(mapping.requestModelId))) throw new DomainError(ErrorCodes.ValidationFailed, "模型映射中的请求模型必须先添加到渠道模型列表", undefined, 422); if (value === undefined) return fallback;
const timeoutMs = Number(value);
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1_000 || timeoutMs > 120_000) throw new DomainError(ErrorCodes.ValidationFailed, "请求超时必须是 1000 到 120000 毫秒的整数", undefined, 422);
return timeoutMs;
}
function rejectLegacyFields(body: Record<string, unknown>, fields: string[]) {
const field = fields.find((name) => Object.prototype.hasOwnProperty.call(body, name));
if (field) throw new DomainError(ErrorCodes.ValidationFailed, `字段 ${field} 已移至独立配置页面`, { field }, 422);
}
function validateMappingTargets(models: ChannelModel[], modelMappings: ReturnType<typeof normalizeModelMappings>) {
const requestModelIds = new Set(models.filter((model) => model.enabled).map((model) => model.requestModelId));
if (modelMappings?.some((mapping) => !requestModelIds.has(mapping.requestModelId))) throw new DomainError(ErrorCodes.ValidationFailed, "模型映射中的请求模型必须先添加到渠道模型列表", undefined, 422);
} }
type ProviderProbeError = Error & { errorCode?: string; providerStatusCode?: number; requestAttempted?: boolean }; type ProviderProbeError = Error & { errorCode?: string; providerStatusCode?: number; requestAttempted?: boolean };
@@ -789,10 +865,12 @@ function decodeBase64(value: string) {
function reconciliationUnitPrice(task: GenerationTask, outputIndex = 0) { function reconciliationUnitPrice(task: GenerationTask, outputIndex = 0) {
const requestedCount = Math.max(1, task.count || 1); const requestedCount = Math.max(1, task.count || 1);
const pricing = task.pricingSnapshot; const pricing = task.pricingSnapshot;
const snapshotUnit = pricing && Number.isFinite(pricing.basePrice) && Number.isFinite(pricing.multiplier) const billedUnits = pricing?.billingUnit === "task" ? 1 : requestedCount;
? pricing.basePrice * pricing.multiplier const snapshotUnit = pricing && Number.isFinite(pricing.unitPrice) && Number.isFinite(pricing.multiplier)
: task.estimatedCost / requestedCount; ? Number(pricing.unitPrice) * Number(pricing.multiplier)
const total = Number.isSafeInteger(task.estimatedCost) ? task.estimatedCost : Math.ceil(snapshotUnit * requestedCount); : task.estimatedCost / billedUnits;
const total = Number.isSafeInteger(task.estimatedCost) ? task.estimatedCost : Math.ceil(snapshotUnit * billedUnits);
if (pricing?.billingUnit === "task") return outputIndex === 0 ? total : 0;
const base = Math.floor(total / requestedCount); const base = Math.floor(total / requestedCount);
const remainder = total - base * requestedCount; const remainder = total - base * requestedCount;
return base + (outputIndex < remainder ? 1 : 0); return base + (outputIndex < remainder ? 1 : 0);
@@ -855,6 +933,114 @@ function allowsLegacyCsrfBootstrap(pathname: string) {
|| pathname === "/api/v1/admin/auth/refresh"; || pathname === "/api/v1/admin/auth/refresh";
} }
type ResolvedRoute = {
group: Store["channelGroups"][number];
route: ModelRoute;
candidates: Array<{ channelId: string; priorityTier: number; orderWithinTier: number; retryBudget: number; requestModelId: string }>;
};
export function resolveModelRoute(store: Store, publicModelId: string): ResolvedRoute {
const matches = store.channelGroups.flatMap((group) => group.enabled ? (group.routes || []).filter((route) => route.publicModelId === publicModelId).map((route) => ({ group, route })) : []);
if (!matches.length) throw new DomainError(ErrorCodes.ModelGroupUnavailable, "当前模型未配置启用路由分组", { publicModelId }, 503);
if (matches.length > 1) throw new DomainError(ErrorCodes.Conflict, "同一公开模型不能同时归属多个启用分组", { publicModelId, groupIds: matches.map((item) => item.group.id) }, 409);
const { group, route } = matches[0];
const seen = new Set<string>();
const candidates = route.channels.map((candidate) => {
if (!candidate.channelId || seen.has(candidate.channelId) || !Number.isSafeInteger(candidate.priorityTier) || candidate.priorityTier < 1 || !Number.isSafeInteger(candidate.orderWithinTier) || candidate.orderWithinTier < 1 || !Number.isSafeInteger(candidate.retryBudget) || candidate.retryBudget < 0) {
throw new DomainError(ErrorCodes.ValidationFailed, "模型路由配置无效", { publicModelId, groupId: group.id }, 422);
}
seen.add(candidate.channelId);
const channel = store.channels.find((item) => item.id === candidate.channelId);
const requestModelId = channel ? channelRequestModel(channel, publicModelId) : undefined;
if (!requestModelId) throw new DomainError(ErrorCodes.ValidationFailed, "模型路由渠道缺少有效请求模型映射", { publicModelId, groupId: group.id, channelId: candidate.channelId }, 422);
return { ...candidate, requestModelId };
}).sort((left, right) => left.priorityTier - right.priorityTier || left.orderWithinTier - right.orderWithinTier || left.channelId.localeCompare(right.channelId));
if (!candidates.some((candidate) => store.channels.find((channel) => channel.id === candidate.channelId)?.enabled)) {
throw new DomainError(ErrorCodes.ModelGroupUnavailable, "当前模型尚未接入可用渠道或渠道模型映射", { publicModelId, groupId: group.id }, 503);
}
return { group, route, candidates };
}
export function resolvePricingRule(store: Store, publicModelId: string, resolutionId: string | undefined, count: number) {
const now = Date.now();
const inEffect = (value: { effectiveFrom?: string; effectiveTo?: string }) => (!value.effectiveFrom || Date.parse(value.effectiveFrom) <= now) && (!value.effectiveTo || Date.parse(value.effectiveTo) > now);
const rules = store.pricingRules.filter((rule) => rule.enabled && rule.publicModelId === publicModelId && inEffect(rule));
const exact = rules.filter((rule) => rule.resolutionId === resolutionId);
const candidates = exact.length ? exact : rules.filter((rule) => !rule.resolutionId);
if (!candidates.length) throw new DomainError(ErrorCodes.ModelGroupUnavailable, "当前模型没有生效价格规则", { publicModelId, resolutionId }, 503);
if (candidates.length > 1) throw new DomainError(ErrorCodes.Conflict, "当前模型存在重叠价格规则", { publicModelId, resolutionId, ruleIds: candidates.map((rule) => rule.id) }, 409);
const rule = candidates[0];
const tier = rule.tiers.find((item) => Number.isSafeInteger(item.minQuantity) && item.minQuantity >= 1 && count >= item.minQuantity && (item.maxQuantity === undefined || (Number.isSafeInteger(item.maxQuantity) && count <= item.maxQuantity)));
if (!tier || !Number.isFinite(tier.unitPrice) || tier.unitPrice < 0 || !Number.isFinite(rule.multiplier) || rule.multiplier <= 0) {
throw new DomainError(ErrorCodes.ValidationFailed, "当前数量没有匹配的有效价格阶梯", { publicModelId, resolutionId, count }, 422);
}
const billedUnits = rule.billingUnit === "task" ? 1 : count;
return { rule, tier, estimatedCost: Math.ceil(tier.unitPrice * rule.multiplier * billedUnits) };
}
function normalizePricingTiers(value: unknown): PricingRule["tiers"] {
if (!Array.isArray(value) || !value.length) throw new DomainError(ErrorCodes.ValidationFailed, "价格阶梯至少需要一项", undefined, 422);
const tiers = value.map((item) => {
if (!item || typeof item !== "object" || Array.isArray(item)) throw new DomainError(ErrorCodes.ValidationFailed, "价格阶梯格式无效", undefined, 422);
const record = item as Record<string, unknown>;
const minQuantity = Number(record.minQuantity);
const maxQuantity = record.maxQuantity === undefined || record.maxQuantity === null || record.maxQuantity === "" ? undefined : Number(record.maxQuantity);
const unitPrice = Number(record.unitPrice);
if (!Number.isSafeInteger(minQuantity) || minQuantity < 1 || (maxQuantity !== undefined && (!Number.isSafeInteger(maxQuantity) || maxQuantity < minQuantity)) || !Number.isFinite(unitPrice) || unitPrice < 0) throw new DomainError(ErrorCodes.ValidationFailed, "价格阶梯数量或单价无效", undefined, 422);
return { minQuantity, ...(maxQuantity === undefined ? {} : { maxQuantity }), unitPrice };
}).sort((left, right) => left.minQuantity - right.minQuantity);
for (let index = 1; index < tiers.length; index += 1) {
const previous = tiers[index - 1];
if (previous.maxQuantity === undefined || previous.maxQuantity >= tiers[index].minQuantity) throw new DomainError(ErrorCodes.ValidationFailed, "价格阶梯不能重叠", undefined, 422);
}
return tiers;
}
function normalizePricingRule(store: Store, body: Record<string, unknown>, existing?: PricingRule): PricingRule {
const publicModelId = typeof body.publicModelId === "string" ? body.publicModelId.trim() : existing?.publicModelId || "";
if (!publicModelId || !store.products.some((product) => product.publicModelId === publicModelId)) throw new DomainError(ErrorCodes.ValidationFailed, "价格规则对应的公开模型不存在", undefined, 422);
const resolutionId = body.resolutionId === undefined ? existing?.resolutionId : typeof body.resolutionId === "string" && body.resolutionId.trim() ? body.resolutionId.trim() : undefined;
const product = store.products.find((item) => item.publicModelId === publicModelId);
if (resolutionId !== undefined && !product?.resolutions.some((resolution) => resolution.id === resolutionId)) throw new DomainError(ErrorCodes.ValidationFailed, "价格规则对应的分辨率不存在", undefined, 422);
const billingUnit = body.billingUnit === undefined ? existing?.billingUnit || "output" : body.billingUnit;
if (billingUnit !== "task" && billingUnit !== "output") throw new DomainError(ErrorCodes.ValidationFailed, "计费单位无效", undefined, 422);
const tiers = body.tiers === undefined ? existing?.tiers || [] : normalizePricingTiers(body.tiers);
if (!tiers.length) throw new DomainError(ErrorCodes.ValidationFailed, "价格阶梯至少需要一项", undefined, 422);
const multiplier = body.multiplier === undefined ? existing?.multiplier ?? 1 : Number(body.multiplier);
if (!Number.isFinite(multiplier) || multiplier <= 0) throw new DomainError(ErrorCodes.ValidationFailed, "价格倍率必须为正数", undefined, 422);
const effectiveFrom = body.effectiveFrom === undefined ? existing?.effectiveFrom : typeof body.effectiveFrom === "string" && body.effectiveFrom.trim() ? body.effectiveFrom.trim() : undefined;
const effectiveTo = body.effectiveTo === undefined ? existing?.effectiveTo : typeof body.effectiveTo === "string" && body.effectiveTo.trim() ? body.effectiveTo.trim() : undefined;
if (effectiveFrom && Number.isNaN(Date.parse(effectiveFrom))) throw new DomainError(ErrorCodes.ValidationFailed, "价格规则生效时间无效", undefined, 422);
if (effectiveTo && Number.isNaN(Date.parse(effectiveTo))) throw new DomainError(ErrorCodes.ValidationFailed, "价格规则失效时间无效", undefined, 422);
if (effectiveFrom && effectiveTo && Date.parse(effectiveFrom) >= Date.parse(effectiveTo)) throw new DomainError(ErrorCodes.ValidationFailed, "价格规则生效区间无效", undefined, 422);
return { id: existing?.id || (typeof body.id === "string" && body.id.trim() ? body.id.trim() : randomUUID()), publicModelId, resolutionId, billingUnit, tiers, multiplier, balanceUnitVersion: existing?.balanceUnitVersion || store.balanceUnit.version, effectiveFrom, effectiveTo, enabled: body.enabled === undefined ? existing?.enabled !== false : body.enabled === true, version: existing?.version || 1 };
}
function pricingIntervalsOverlap(left: PricingRule, right: PricingRule) {
if (left.publicModelId !== right.publicModelId || left.resolutionId !== right.resolutionId || left.billingUnit !== right.billingUnit) return false;
const leftStart = left.effectiveFrom ? Date.parse(left.effectiveFrom) : -Infinity;
const leftEnd = left.effectiveTo ? Date.parse(left.effectiveTo) : Infinity;
const rightStart = right.effectiveFrom ? Date.parse(right.effectiveFrom) : -Infinity;
const rightEnd = right.effectiveTo ? Date.parse(right.effectiveTo) : Infinity;
return leftStart < rightEnd && rightStart < leftEnd;
}
function publicPricingRule(rule: PricingRule, store: Store) {
const product = store.products.find((item) => item.publicModelId === rule.publicModelId);
return { ...rule, modelName: product?.name, resolutionName: product?.resolutions.find((resolution) => resolution.id === rule.resolutionId)?.label };
}
function resolvePlanSnapshot(store: Store, userId: string, product: InternalModelProduct, groupId: string) {
const entitlement = [...store.entitlements.values()].find((item) => {
const modelAllowed = !item.allowedModelProductIds.length || item.allowedModelProductIds.includes(product.id) || item.allowedModelProductIds.includes(product.publicModelId);
return item.userId === userId && Date.parse(item.expiresAt) > Date.now() && modelAllowed && (!item.allowedChannelGroupIds.length || item.allowedChannelGroupIds.includes(groupId));
});
const planSnapshot = entitlement
? { planId: entitlement.planId, version: entitlement.planVersion, queuePriority: entitlement.queuePriority, maxConcurrent: entitlement.maxConcurrent }
: { queuePriority: 0, maxConcurrent: 1 };
return { entitlement, planSnapshot };
}
function createTask(store: Store, userId: string, taskType: TaskType, body: CreateTaskRequest, idempotencyKey: string, options: { deferCommit?: boolean } = {}): TaskDraft { function createTask(store: Store, userId: string, taskType: TaskType, body: CreateTaskRequest, idempotencyKey: string, options: { deferCommit?: boolean } = {}): TaskDraft {
const idempotencyRef = `${userId}:${idempotencyKey}`; const idempotencyRef = `${userId}:${idempotencyKey}`;
const existing = store.idempotency.get(idempotencyRef); const existing = store.idempotency.get(idempotencyRef);
@@ -870,15 +1056,14 @@ function createTask(store: Store, userId: string, taskType: TaskType, body: Crea
if (!product.capabilities.includes(capability as typeof product.capabilities[number])) throw new DomainError(ErrorCodes.ValidationFailed, "该模型不支持当前任务能力", { capability }, 422); if (!product.capabilities.includes(capability as typeof product.capabilities[number])) throw new DomainError(ErrorCodes.ValidationFailed, "该模型不支持当前任务能力", { capability }, 422);
const referenceInput = normalizeReferenceGroup(body, userId, store); const referenceInput = normalizeReferenceGroup(body, userId, store);
if (!["reverse-prompt", "matting", "split", "upscale", "expand"].includes(taskType) && (!referenceInput.prompt || referenceInput.prompt.length === 0) && referenceInput.references.length === 0) throw new DomainError(ErrorCodes.ValidationFailed, "提示词或参考图至少需要一项"); if (!["reverse-prompt", "matting", "split", "upscale", "expand"].includes(taskType) && (!referenceInput.prompt || referenceInput.prompt.length === 0) && referenceInput.references.length === 0) throw new DomainError(ErrorCodes.ValidationFailed, "提示词或参考图至少需要一项");
const maxCount = product.maxCount || 8; const maxCount = Math.max(1, Number(store.systemSettings.generationMaxCount) || 1);
const rawCount = Number(body.count || 1); const rawCount = Number(body.count || 1);
if (!Number.isInteger(rawCount) || rawCount < 1 || rawCount > maxCount) throw new DomainError(ErrorCodes.ValidationFailed, "生成数量超出模型限制", { maxCount }); if (!Number.isInteger(rawCount) || rawCount < 1 || rawCount > maxCount) throw new DomainError(ErrorCodes.ValidationFailed, "生成数量超出模型限制", { maxCount });
const count = rawCount; const count = rawCount;
let resolution = body.resolutionPresetId ? product.resolutions.find((item) => item.id === body.resolutionPresetId) : product.resolutions[0]; let resolution = body.resolutionPresetId ? product.resolutions.find((item) => item.id === body.resolutionPresetId) : product.resolutions[0];
if (body.resolutionPresetId && !resolution) throw new DomainError(ErrorCodes.ValidationFailed, "分辨率不支持该模型", { resolutionPresetId: body.resolutionPresetId }); if (body.resolutionPresetId && !resolution) throw new DomainError(ErrorCodes.ValidationFailed, "分辨率不支持该模型", { resolutionPresetId: body.resolutionPresetId });
const group = store.channelGroups.find((item) => item.id === (product.channelGroupId || "image-default") && item.enabled); const resolvedRoute = resolveModelRoute(store, product.publicModelId);
if (!group) throw new DomainError(ErrorCodes.ModelGroupUnavailable, "当前模型暂不可用,请切换到其他同档位模型", undefined, 503); const { group, route, candidates: routeCandidates } = resolvedRoute;
if (!group.channelIds.some((channelId) => store.channels.some((channel) => channel.id === channelId && channel.enabled))) throw new DomainError(ErrorCodes.ModelGroupUnavailable, "当前模型尚未接入可用渠道,请稍后再试", undefined, 503);
if (body.maskObjectId) { if (body.maskObjectId) {
const upload = store.uploads.get(body.maskObjectId); const upload = store.uploads.get(body.maskObjectId);
if (!upload || upload.ownerId !== userId || upload.status !== "ready" || Date.parse(upload.expiresAt) <= Date.now()) throw new DomainError(ErrorCodes.ObjectOwnershipDenied, "蒙版对象不存在或无权使用", undefined, 403); if (!upload || upload.ownerId !== userId || upload.status !== "ready" || Date.parse(upload.expiresAt) <= Date.now()) throw new DomainError(ErrorCodes.ObjectOwnershipDenied, "蒙版对象不存在或无权使用", undefined, 403);
@@ -886,7 +1071,7 @@ function createTask(store: Store, userId: string, taskType: TaskType, body: Crea
} }
if (body.maskObjectId && referenceInput.references.length === 0) throw new DomainError(ErrorCodes.ValidationFailed, "蒙版必须和参考图一起使用", undefined, 422); if (body.maskObjectId && referenceInput.references.length === 0) throw new DomainError(ErrorCodes.ValidationFailed, "蒙版必须和参考图一起使用", undefined, 422);
const params = sanitizeTaskParams(body.params); const params = sanitizeTaskParams(body.params);
const openAiGroup = taskType === "image" && group.channelIds.some((channelId) => store.channels.find((channel) => channel.id === channelId)?.providerType === "openai-images"); const openAiGroup = taskType === "image" && routeCandidates.some((candidate) => store.channels.find((channel) => channel.id === candidate.channelId)?.providerType === "openai-images");
if (taskType === "image") { if (taskType === "image") {
if (params.ratio !== undefined && !OPENAI_IMAGE_RATIOS.includes(params.ratio as typeof OPENAI_IMAGE_RATIOS[number])) throw new DomainError(ErrorCodes.ValidationFailed, "图片比例无效", undefined, 422); if (params.ratio !== undefined && !OPENAI_IMAGE_RATIOS.includes(params.ratio as typeof OPENAI_IMAGE_RATIOS[number])) throw new DomainError(ErrorCodes.ValidationFailed, "图片比例无效", undefined, 422);
if (params.resolution !== undefined && !OPENAI_IMAGE_RESOLUTIONS.includes(params.resolution as typeof OPENAI_IMAGE_RESOLUTIONS[number])) throw new DomainError(ErrorCodes.ValidationFailed, "图片分辨率无效", undefined, 422); if (params.resolution !== undefined && !OPENAI_IMAGE_RESOLUTIONS.includes(params.resolution as typeof OPENAI_IMAGE_RESOLUTIONS[number])) throw new DomainError(ErrorCodes.ValidationFailed, "图片分辨率无效", undefined, 422);
@@ -912,10 +1097,9 @@ function createTask(store: Store, userId: string, taskType: TaskType, body: Crea
validateCapabilityParams(product, taskType, params); validateCapabilityParams(product, taskType, params);
const taskId = randomUUID(); const taskId = randomUUID();
const now = new Date().toISOString(); const now = new Date().toISOString();
const routeChannelIds = [...group.channelIds].sort((left, right) => { const leftPriority = group.channelPriorities?.[left] ?? store.channels.find((channel) => channel.id === left)?.priority ?? Number.MAX_SAFE_INTEGER; const rightPriority = group.channelPriorities?.[right] ?? store.channels.find((channel) => channel.id === right)?.priority ?? Number.MAX_SAFE_INTEGER; return leftPriority - rightPriority || group.channelIds.indexOf(left) - group.channelIds.indexOf(right); }); const pricing = resolvePricingRule(store, product.publicModelId, resolution?.id, count);
const estimatedCost = Math.ceil(product.basePrice * (resolution?.priceMultiplier || 1) * count); const estimatedCost = pricing.estimatedCost;
const entitlement = [...store.entitlements.values()].find((item) => { const modelAllowed = !item.allowedModelProductIds.length || item.allowedModelProductIds.includes(product.id) || item.allowedModelProductIds.includes(product.publicModelId); return item.userId === userId && Date.parse(item.expiresAt) > Date.now() && modelAllowed && (!item.allowedChannelGroupIds.length || item.allowedChannelGroupIds.includes(group.id)); }); const { entitlement, planSnapshot } = resolvePlanSnapshot(store, userId, product, group.id);
const planSnapshot = entitlement ? { planId: entitlement.planId, version: entitlement.planVersion, queuePriority: entitlement.queuePriority, maxConcurrent: entitlement.maxConcurrent } : { queuePriority: 0, maxConcurrent: product.maxConcurrent || 1 };
const activeTasks = [...store.tasks.values()].filter((item) => item.ownerId === userId && (item.status === "queued" || item.status === "running")).length; const activeTasks = [...store.tasks.values()].filter((item) => item.ownerId === userId && (item.status === "queued" || item.status === "running")).length;
if (activeTasks >= planSnapshot.maxConcurrent) throw new DomainError(ErrorCodes.RateLimited, "已达到当前套餐并发上限,请等待已有任务完成", { maxConcurrent: planSnapshot.maxConcurrent }, 429); if (activeTasks >= planSnapshot.maxConcurrent) throw new DomainError(ErrorCodes.RateLimited, "已达到当前套餐并发上限,请等待已有任务完成", { maxConcurrent: planSnapshot.maxConcurrent }, 429);
if (!options.deferCommit && !reserveBalance(store, userId, estimatedCost, taskId, entitlement?.creditBucketId ? { preferredBucketIds: new Set([entitlement.creditBucketId]) } : {})) { if (!options.deferCommit && !reserveBalance(store, userId, estimatedCost, taskId, entitlement?.creditBucketId ? { preferredBucketIds: new Set([entitlement.creditBucketId]) } : {})) {
@@ -923,7 +1107,35 @@ function createTask(store: Store, userId: string, taskType: TaskType, body: Crea
throw new DomainError(ErrorCodes.InsufficientBalance, "金币余额不足", { required: estimatedCost, available: balance.available }, 409); throw new DomainError(ErrorCodes.InsufficientBalance, "金币余额不足", { required: estimatedCost, available: balance.available }, 409);
} }
const taskRetentionDays = entitlement?.retentionDays || store.storagePolicy.retentionDays; const taskRetentionDays = entitlement?.retentionDays || store.storagePolicy.retentionDays;
const task: TaskDraft = { id: taskId, ownerId: userId, taskType, modelProductId: product.publicModelId, prompt: referenceInput.prompt, resolutionPresetId: resolution?.id, count, status: "queued" as const, estimatedCost, reservedCost: estimatedCost, reserveExpiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), retentionExpiresAt: new Date(Date.now() + taskRetentionDays * 86_400_000).toISOString(), retentionState: "active" as const, retentionExtensionDays: 0, createdAt: now, updatedAt: now, eventSequence: 0, channelGroupId: group.id, routeSnapshotVersion: group.version, routeSnapshot: { groupId: group.id, version: group.version, channelIds: routeChannelIds, retryBudget: group.retryBudget, channelRetryBudgets: Object.fromEntries(routeChannelIds.map((channelId) => [channelId, store.channels.find((channel) => channel.id === channelId)?.retryBudget || 0])) }, modelSnapshot: { publicModelId: product.publicModelId, version: product.version || 1 }, pricingSnapshot: { basePrice: product.basePrice, multiplier: resolution?.priceMultiplier || 1, unitVersion: store.balanceUnit.version }, planSnapshot, inputSnapshot: { references: referenceInput.references, referenceGroup: referenceInput.group, maskObjectId: body.maskObjectId, params, originalPrompt: body.prompt }, idempotencyKey, requestFingerprint: bodyFingerprint(body), attempts: [], outputs: [], ...(options.deferCommit && entitlement?.creditBucketId ? { preferredReserveBucketId: entitlement.creditBucketId } : {}) }; const task: TaskDraft = {
id: taskId,
ownerId: userId,
taskType,
modelProductId: product.publicModelId,
prompt: referenceInput.prompt,
resolutionPresetId: resolution?.id,
count,
status: "queued" as const,
estimatedCost,
reservedCost: estimatedCost,
reserveExpiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
retentionExpiresAt: new Date(Date.now() + taskRetentionDays * 86_400_000).toISOString(),
retentionState: "active" as const,
retentionExtensionDays: 0,
createdAt: now,
updatedAt: now,
eventSequence: 0,
routeSnapshot: { groupId: group.id, groupVersion: group.version, publicModelId: product.publicModelId, routeVersion: route.version, orderedCandidates: routeCandidates, strategy: route.strategy, totalRetryBudget: route.retryBudget },
modelSnapshot: { publicModelId: product.publicModelId, modelVersion: product.version || 1, name: product.name, tier: product.tier, capabilities: [...product.capabilities], capabilityParameters: product.capabilityParameters, resolutionId: resolution?.id, resolution: resolution ? { id: resolution.id, label: resolution.label, width: resolution.width, height: resolution.height, ratios: resolution.ratios ? [...resolution.ratios] : undefined } : undefined },
pricingSnapshot: { ruleId: pricing.rule.id, ruleVersion: pricing.rule.version, publicModelId: product.publicModelId, resolutionId: resolution?.id, unitPrice: pricing.tier.unitPrice, quantity: count, billingUnit: pricing.rule.billingUnit, multiplier: pricing.rule.multiplier, balanceUnitVersion: pricing.rule.balanceUnitVersion },
planSnapshot,
inputSnapshot: { references: referenceInput.references, referenceGroup: referenceInput.group, maskObjectId: body.maskObjectId, params, originalPrompt: body.prompt },
idempotencyKey,
requestFingerprint: bodyFingerprint(body),
attempts: [],
outputs: [],
...(options.deferCommit && entitlement?.creditBucketId ? { preferredReserveBucketId: entitlement.creditBucketId } : {}),
};
if (!options.deferCommit) { if (!options.deferCommit) {
store.tasks.set(task.id, task); store.tasks.set(task.id, task);
store.idempotency.set(idempotencyRef, task); store.idempotency.set(idempotencyRef, task);
@@ -2091,7 +2303,7 @@ export function createHttpServer(store: Store, config: ServerConfig = loadConfig
let task: GenerationTask; let created: boolean; let task: GenerationTask; let created: boolean;
if (store.repository.adapter === "postgres" && store.repository.createTaskAtomic) { if (store.repository.adapter === "postgres" && store.repository.createTaskAtomic) {
const draft = createTask(store, auth.sub, taskType, body, key, { deferCommit: true }); const draft = createTask(store, auth.sub, taskType, body, key, { deferCommit: true });
const atomic = await store.repository.createTaskAtomic({ userId: auth.sub, task: draft, estimatedCost: draft.estimatedCost, unitVersion: store.balanceUnit.version, maxConcurrent: draft.planSnapshot?.maxConcurrent || 1, preferredBucketId: draft.preferredReserveBucketId, requestFingerprint: draft.requestFingerprint || bodyFingerprint(body) }); const atomic = await store.repository.createTaskAtomic({ userId: auth.sub, task: draft, estimatedCost: draft.estimatedCost, unitVersion: draft.pricingSnapshot?.balanceUnitVersion || store.balanceUnit.version, maxConcurrent: draft.planSnapshot?.maxConcurrent || 1, preferredBucketId: draft.preferredReserveBucketId, requestFingerprint: draft.requestFingerprint || bodyFingerprint(body) });
await store.loadPersisted(); await store.loadPersisted();
task = store.tasks.get(atomic.taskId) || atomic.task || draft; task = store.tasks.get(atomic.taskId) || atomic.task || draft;
created = atomic.created; created = atomic.created;
@@ -2309,6 +2521,7 @@ export function createHttpServer(store: Store, config: ServerConfig = loadConfig
} }
if (request.method === "POST" && resource === "channels" && !resourceId) { if (request.method === "POST" && resource === "channels" && !resourceId) {
const body = await readJson(request); const body = await readJson(request);
rejectLegacyFields(body, ["priority", "retryBudget", "providerModelId", "enabledModelIds", "resolutionModelMap"]);
const requestedId = typeof body.id === "string" ? body.id.trim() : ""; const requestedId = typeof body.id === "string" ? body.id.trim() : "";
const channelId = requestedId || randomUUID(); const channelId = requestedId || randomUUID();
if (store.channels.some((channel) => channel.id === channelId)) throw new DomainError(ErrorCodes.Conflict, "渠道 ID 已存在", undefined, 409); if (store.channels.some((channel) => channel.id === channelId)) throw new DomainError(ErrorCodes.Conflict, "渠道 ID 已存在", undefined, 409);
@@ -2318,18 +2531,14 @@ export function createHttpServer(store: Store, config: ServerConfig = loadConfig
catch (error) { throw new DomainError(ErrorCodes.ValidationFailed, error instanceof Error ? error.message : "渠道地址无效", undefined, 422); } catch (error) { throw new DomainError(ErrorCodes.ValidationFailed, error instanceof Error ? error.message : "渠道地址无效", undefined, 422); }
} }
const providerType = body.providerType === "openai-images" ? "openai-images" as const : "generic" as const; const providerType = body.providerType === "openai-images" ? "openai-images" as const : "generic" as const;
const enabledModelIds = normalizeEnabledModelIds(body.enabledModelIds) || []; const models = normalizeChannelModels(body.models);
const legacyProviderModelId = typeof body.providerModelId === "string" ? body.providerModelId.trim().slice(0, 200) : "";
const legacyResolutionModels = body.resolutionModelMap && typeof body.resolutionModelMap === "object" && !Array.isArray(body.resolutionModelMap) ? Object.values(body.resolutionModelMap).filter((value): value is string => typeof value === "string" && Boolean(value.trim())).map((value) => value.trim().slice(0, 200)) : [];
const normalizedEnabledModelIds = providerType === "openai-images" ? Array.from(new Set([...enabledModelIds, ...(legacyProviderModelId ? [legacyProviderModelId] : []), ...legacyResolutionModels])) : enabledModelIds;
const modelMappings = normalizeModelMappings(body.modelMappings); const modelMappings = normalizeModelMappings(body.modelMappings);
if (providerType === "openai-images") validateMappingTargets(normalizedEnabledModelIds, modelMappings); validateMappingTargets(models, modelMappings);
const providerModelId = providerType === "openai-images" ? normalizedEnabledModelIds[0] : legacyProviderModelId || undefined;
const secret = typeof body.secretRef === "string" ? body.secretRef.trim() : ""; const secret = typeof body.secretRef === "string" ? body.secretRef.trim() : "";
if (providerType === "openai-images" && body.enabled !== false && (!baseUrl || !normalizedEnabledModelIds.length || !secret)) throw new DomainError(ErrorCodes.ValidationFailed, "启用 OpenAI 图片渠道必须填写地址、至少添加一个模型并配置 API Key", undefined, 422); if (providerType === "openai-images" && body.enabled !== false && (!baseUrl || !models.some((model) => model.enabled) || !secret)) throw new DomainError(ErrorCodes.ValidationFailed, "启用 OpenAI 图片渠道必须填写地址、至少添加一个模型并配置 API Key", undefined, 422);
const resolutionModelMap = body.resolutionModelMap && typeof body.resolutionModelMap === "object" && !Array.isArray(body.resolutionModelMap) ? Object.fromEntries(Object.entries(body.resolutionModelMap as Record<string, unknown>).filter(([key, value]) => (key === "1K" || key === "2K" || key === "4K") && typeof value === "string" && value.trim()).map(([key, value]) => [key, String(value).trim().slice(0, 200)])) : undefined;
const health: "healthy" | "degraded" = "degraded"; const health: "healthy" | "degraded" = "degraded";
const channel = { id: channelId, label: typeof body.label === "string" ? body.label.trim().slice(0, 80) : channelId, enabled: body.enabled !== false, priority: Number.isInteger(body.priority) ? Number(body.priority) : store.channels.length, health, providerType, providerName: typeof body.providerName === "string" ? body.providerName.trim().slice(0, 80) : providerType === "openai-images" ? "OpenAI 图片端点" : undefined, providerModelId, enabledModelIds: normalizedEnabledModelIds, modelMappings, resolutionModelMap, baseUrl, secretRef: secret ? encryptSecret(secret, store.channelEncryptionKey) : undefined, version: 1, retryBudget: Number.isInteger(body.retryBudget) && Number(body.retryBudget) >= 0 ? Number(body.retryBudget) : 0, consecutiveFailures: 0 }; const timeoutMs = normalizeChannelTimeout(body.timeoutMs);
const channel: Store["channels"][number] = { id: channelId, label: typeof body.label === "string" ? body.label.trim().slice(0, 80) : channelId, enabled: body.enabled !== false, health, providerType, providerName: typeof body.providerName === "string" ? body.providerName.trim().slice(0, 80) : providerType === "openai-images" ? "OpenAI 图片端点" : undefined, models, timeoutMs, modelMappings: modelMappings || [], baseUrl, secretRef: secret ? encryptSecret(secret, store.channelEncryptionKey) : undefined, version: 1, consecutiveFailures: 0 };
store.channels.push(channel); store.channels.push(channel);
audit(store, "channel.create", "provider_channel", channel.id, id, admin.sub, undefined, { ...channel, secretRef: channel.secretRef ? "configured" : undefined }, clientAddress(request)); audit(store, "channel.create", "provider_channel", channel.id, id, admin.sub, undefined, { ...channel, secretRef: channel.secretRef ? "configured" : undefined }, clientAddress(request));
store.persist(); store.persist();
@@ -2338,95 +2547,81 @@ export function createHttpServer(store: Store, config: ServerConfig = loadConfig
} }
if (request.method === "POST" && resource === "channel-groups") { if (request.method === "POST" && resource === "channel-groups") {
const body = await readJson(request); const body = await readJson(request);
const channelIds = Array.isArray(body.channelIds) ? body.channelIds.filter((value): value is string => typeof value === "string") : []; rejectLegacyFields(body, ["channelIds", "channelPriorities", "retryBudget", "productIds"]);
if (!channelIds.length || new Set(channelIds).size !== channelIds.length || channelIds.some((channelId) => !store.channels.some((channel) => channel.id === channelId))) throw new DomainError(ErrorCodes.ValidationFailed, "渠道分组成员无效", undefined, 422); const routes = normalizeRoutes(store, body.routes, [], true);
validateChannelProtocols(store, channelIds); for (const route of routes) validateChannelProtocols(store, route.channels.map((candidate) => candidate.channelId));
const retryBudget = body.retryBudget === undefined ? channelIds.length : Number(body.retryBudget); const enabled = body.enabled !== false;
if (!Number.isSafeInteger(retryBudget) || retryBudget < 0) throw new DomainError(ErrorCodes.ValidationFailed, "分组重试预算无效", undefined, 422);
const requestedId = typeof body.id === "string" ? body.id.trim() : ""; const requestedId = typeof body.id === "string" ? body.id.trim() : "";
const group = { const group: Store["channelGroups"][number] = {
id: requestedId || randomUUID(), id: requestedId || randomUUID(),
version: 1, version: 1,
name: normalizeChannelGroupName(body.name), name: normalizeChannelGroupName(body.name),
capabilities: normalizeChannelGroupCapabilities(body.capabilities), capabilities: normalizeChannelGroupCapabilities(body.capabilities),
channelIds, routes,
channelPriorities: Object.fromEntries(channelIds.map((channelId, index) => [channelId, index])), enabled,
enabled: body.enabled !== false,
retryBudget,
}; };
if (store.channelGroups.some((item) => item.id === group.id)) throw new DomainError(ErrorCodes.Conflict, "渠道分组 ID 已存在", undefined, 409); if (store.channelGroups.some((item) => item.id === group.id)) throw new DomainError(ErrorCodes.Conflict, "渠道分组 ID 已存在", undefined, 409);
const productIds = normalizeChannelGroupProductIds(store, body.productIds) || []; validateChannelGroupProductModes(store, group.capabilities, routes.map((route) => route.publicModelId));
validateChannelGroupProductModes(store, group.capabilities, productIds); validateChannelGroupProductOwnership(store, group.id, routes.map((route) => route.publicModelId), enabled);
validateChannelGroupProductOwnership(store, group.id, productIds);
store.channelGroups.push(group); store.channelGroups.push(group);
bindChannelGroupProducts(store, group.id, productIds);
audit(store, "channel_group.create", "channel_group", group.id, id, admin.sub, undefined, group, clientAddress(request)); audit(store, "channel_group.create", "channel_group", group.id, id, admin.sub, undefined, group, clientAddress(request));
store.persist(); store.persist();
ok(response, id, { item: { ...group, productIds } }, 201); ok(response, id, { item: group }, 201);
return; return;
} }
if (request.method === "POST" && resource === "model-products") { if (request.method === "POST" && resource === "model-products") {
const body = await readJson(request); const body = await readJson(request);
rejectLegacyFields(body, ["basePrice", "maxCount", "maxConcurrent", "currencyUnit", "channelGroupId"]);
if (Array.isArray(body.capabilities) && body.capabilities.some((value) => value === "3d")) throw new DomainError(ErrorCodes.CapabilityNotEnabled, "V1 不允许发布 3D 模型能力", { capability: "3d" }, 422); if (Array.isArray(body.capabilities) && body.capabilities.some((value) => value === "3d")) throw new DomainError(ErrorCodes.CapabilityNotEnabled, "V1 不允许发布 3D 模型能力", { capability: "3d" }, 422);
if (typeof body.publicModelId !== "string" || typeof body.name !== "string") throw new DomainError(ErrorCodes.ValidationFailed, "公开模型 ID 和名称必填", undefined, 422); if (typeof body.publicModelId !== "string" || typeof body.name !== "string" || !body.name.trim()) throw new DomainError(ErrorCodes.ValidationFailed, "公开模型 ID 和名称必填", undefined, 422);
const requestedProductId = typeof body.id === "string" ? body.id.trim() : ""; const requestedProductId = typeof body.id === "string" ? body.id.trim() : "";
const productId = requestedProductId || randomUUID(); const productId = requestedProductId || randomUUID();
const publicModelId = body.publicModelId.trim(); const publicModelId = body.publicModelId.trim();
if (!/^[a-z0-9][a-z0-9._-]{1,127}$/.test(publicModelId)) throw new DomainError(ErrorCodes.ValidationFailed, "公开模型 ID 必须是安全的小写标识", undefined, 422); if (!/^[a-z0-9][a-z0-9._-]{1,127}$/.test(publicModelId)) throw new DomainError(ErrorCodes.ValidationFailed, "公开模型 ID 必须是安全的小写标识", undefined, 422);
if (store.products.some((item) => item.id === productId || item.publicModelId === publicModelId)) throw new DomainError(ErrorCodes.Conflict, "模型产品或公开模型 ID 已存在", undefined, 409); if (store.products.some((item) => item.id === productId || item.publicModelId === publicModelId)) throw new DomainError(ErrorCodes.Conflict, "模型产品或公开模型 ID 已存在", undefined, 409);
const basePrice = body.basePrice === undefined ? 0 : Number(body.basePrice); const enabled = body.enabled === true;
const maxCount = body.maxCount === undefined ? 1 : Number(body.maxCount);
const maxConcurrent = body.maxConcurrent === undefined ? 1 : Number(body.maxConcurrent);
if (!Number.isSafeInteger(basePrice) || basePrice < 0 || !Number.isSafeInteger(maxCount) || maxCount < 1 || !Number.isSafeInteger(maxConcurrent) || maxConcurrent < 1) throw new DomainError(ErrorCodes.ValidationFailed, "模型价格、数量和并发必须是有效整数", undefined, 422);
const enabled = body.enabled !== false;
const channelGroupId = typeof body.channelGroupId === "string" ? body.channelGroupId.trim() : "";
if (enabled && (!channelGroupId || !store.channelGroups.some((group) => group.id === channelGroupId && group.enabled))) throw new DomainError(ErrorCodes.ValidationFailed, "已上架模型必须绑定已启用的渠道分组", undefined, 422);
const capabilityValues = Array.isArray(body.capabilities) ? body.capabilities.filter((value): value is string => typeof value === "string").map((value) => value.trim()).filter(Boolean) : ["image"]; const capabilityValues = Array.isArray(body.capabilities) ? body.capabilities.filter((value): value is string => typeof value === "string").map((value) => value.trim()).filter(Boolean) : ["image"];
const allowedCapabilities = new Set<InternalModelProduct["capabilities"][number]>(["image", "text", "audio", "utility", "reverse-prompt", "multi-angle", "matting", "split", "upscale", "expand", "3d"]); const allowedCapabilities = new Set<InternalModelProduct["capabilities"][number]>(["image", "text", "audio", "utility", "reverse-prompt", "multi-angle", "matting", "split", "upscale", "expand", "3d"]);
if (!capabilityValues.length || capabilityValues.some((value) => !allowedCapabilities.has(value as InternalModelProduct["capabilities"][number]))) throw new DomainError(ErrorCodes.ValidationFailed, "模型能力包含不支持的类型", undefined, 422); if (!capabilityValues.length || capabilityValues.some((value) => !allowedCapabilities.has(value as InternalModelProduct["capabilities"][number]))) throw new DomainError(ErrorCodes.ValidationFailed, "模型能力包含不支持的类型", undefined, 422);
const capabilities = capabilityValues as InternalModelProduct["capabilities"]; const capabilities = capabilityValues as InternalModelProduct["capabilities"];
const resolutions = body.resolutions === undefined ? [] : normalizeResolutionList(body.resolutions); const resolutions = body.resolutions === undefined ? [] : normalizeResolutionList(body.resolutions);
if (enabled && capabilities.includes("image" as never) && !resolutions.length) throw new DomainError(ErrorCodes.ValidationFailed, "已上架图片模型必须配置分辨率矩阵", undefined, 422); if (enabled && capabilities.includes("image" as never) && !resolutions.length) throw new DomainError(ErrorCodes.ValidationFailed, "已上架图片模型必须配置分辨率矩阵", undefined, 422);
const product: InternalModelProduct = { id: productId, publicModelId, name: body.name.trim(), tier: body.tier === "flagship" || body.tier === "advanced" ? body.tier : "basic", capabilities, capabilitiesSchema: capabilities.map(capabilitySchema), resolutions, basePrice, currencyUnit: store.balanceUnit.name, enabled, version: 1, maxCount, maxConcurrent, channelGroupId: channelGroupId || undefined }; if (enabled && !productAvailableThroughChannel(store, { id: productId, publicModelId, name: body.name.trim(), tier: body.tier === "flagship" || body.tier === "advanced" ? body.tier : "basic", capabilities, resolutions, enabled, version: 1 })) throw new DomainError(ErrorCodes.ValidationFailed, "已上架模型必须先配置可用分组路由", undefined, 422);
const product: InternalModelProduct = { id: productId, publicModelId, name: body.name.trim(), tier: body.tier === "flagship" || body.tier === "advanced" ? body.tier : "basic", capabilities, capabilityParameters: normalizeCapabilityParameters(body.capabilityParameters), capabilitiesSchema: capabilities.map(capabilitySchema), resolutions, enabled, version: 1 };
store.products.push(product); audit(store, "model_product.create", "model_product", product.id, id, admin.sub, undefined, product, clientAddress(request)); store.persist(); ok(response, id, { item: product }, 201); return; store.products.push(product); audit(store, "model_product.create", "model_product", product.id, id, admin.sub, undefined, product, clientAddress(request)); store.persist(); ok(response, id, { item: product }, 201); return;
} }
if (request.method === "PATCH" && resource === "channels" && resourceId) { if (request.method === "PATCH" && resource === "channels" && resourceId) {
const body = await readJson(request); const body = await readJson(request);
rejectLegacyFields(body, ["priority", "retryBudget", "providerModelId", "enabledModelIds", "resolutionModelMap"]);
const existingChannel = store.channels.find((item) => item.id === resourceId); const existingChannel = store.channels.find((item) => item.id === resourceId);
if (!existingChannel) throw new DomainError(ErrorCodes.NotFound, "渠道不存在", undefined, 404); if (!existingChannel) throw new DomainError(ErrorCodes.NotFound, "渠道不存在", undefined, 404);
requireIfMatch(request, existingChannel.version); requireIfMatch(request, existingChannel.version);
const channel = { ...existingChannel, enabledModelIds: existingChannel.enabledModelIds ? [...existingChannel.enabledModelIds] : undefined, modelMappings: existingChannel.modelMappings ? existingChannel.modelMappings.map((item) => ({ ...item })) : undefined, resolutionModelMap: existingChannel.resolutionModelMap ? { ...existingChannel.resolutionModelMap } : undefined }; const channel: Store["channels"][number] = { ...existingChannel, models: existingChannel.models.map((item) => ({ ...item })), modelMappings: existingChannel.modelMappings.map((item) => ({ ...item })) };
const before = { ...existingChannel }; const before = { ...existingChannel };
if (typeof body.label === "string" && body.label.trim()) channel.label = body.label.trim().slice(0, 80); if (typeof body.label === "string" && body.label.trim()) channel.label = body.label.trim().slice(0, 80);
if (typeof body.providerName === "string") channel.providerName = body.providerName.trim().slice(0, 80) || undefined; if (typeof body.providerName === "string") channel.providerName = body.providerName.trim().slice(0, 80) || undefined;
if (body.providerType === "generic" || body.providerType === "openai-images") { if (body.providerType === "generic" || body.providerType === "openai-images") {
for (const group of store.channelGroups.filter((item) => item.channelIds.includes(channel.id))) { for (const group of store.channelGroups) for (const route of group.routes) if (route.channels.some((candidate) => candidate.channelId === channel.id)) {
const protocols = new Set(group.channelIds.map((id) => id === channel.id ? body.providerType : store.channels.find((item) => item.id === id)?.providerType || "generic")); const protocols = new Set(route.channels.map((candidate) => candidate.channelId === channel.id ? body.providerType : store.channels.find((item) => item.id === candidate.channelId)?.providerType || "generic"));
if (protocols.size > 1) throw new DomainError(ErrorCodes.ValidationFailed, "同一渠道分组不能混用不同供应商协议", undefined, 422); if (protocols.size > 1) throw new DomainError(ErrorCodes.ValidationFailed, "同一模型路由不能混用不同供应商协议", undefined, 422);
} }
channel.providerType = body.providerType; channel.providerType = body.providerType;
} }
if (body.priority !== undefined) { const priority = Number(body.priority); if (!Number.isSafeInteger(priority) || priority < 0) throw new DomainError(ErrorCodes.ValidationFailed, "渠道优先级无效", undefined, 422); channel.priority = priority; }
if (typeof body.enabled === "boolean") channel.enabled = body.enabled; if (typeof body.enabled === "boolean") channel.enabled = body.enabled;
if (body.health === "healthy" || body.health === "degraded" || body.health === "open") channel.health = body.health;
if (typeof body.baseUrl === "string") { try { channel.baseUrl = validateProviderUrl(body.baseUrl.trim()).toString().replace(/\/$/, ""); } catch (error) { throw new DomainError(ErrorCodes.ValidationFailed, error instanceof Error ? error.message : "渠道地址无效", undefined, 422); } } if (typeof body.baseUrl === "string") { try { channel.baseUrl = validateProviderUrl(body.baseUrl.trim()).toString().replace(/\/$/, ""); } catch (error) { throw new DomainError(ErrorCodes.ValidationFailed, error instanceof Error ? error.message : "渠道地址无效", undefined, 422); } }
if (body.enabledModelIds !== undefined) channel.enabledModelIds = normalizeEnabledModelIds(body.enabledModelIds); if (body.models !== undefined) channel.models = normalizeChannelModels(body.models, channel.models);
if (body.modelMappings !== undefined) channel.modelMappings = normalizeModelMappings(body.modelMappings); if (body.modelMappings !== undefined) channel.modelMappings = normalizeModelMappings(body.modelMappings) || [];
if (typeof body.providerModelId === "string") channel.providerModelId = body.providerModelId.trim().slice(0, 200);
else if (body.enabledModelIds !== undefined) channel.providerModelId = channel.enabledModelIds?.[0];
if (body.resolutionModelMap && typeof body.resolutionModelMap === "object" && !Array.isArray(body.resolutionModelMap)) channel.resolutionModelMap = Object.fromEntries(Object.entries(body.resolutionModelMap as Record<string, unknown>).filter(([key, value]) => (key === "1K" || key === "2K" || key === "4K") && typeof value === "string" && value.trim()).map(([key, value]) => [key, String(value).trim().slice(0, 200)]));
if (typeof body.secretRef === "string") { if (typeof body.secretRef === "string") {
const secret = body.secretRef.trim(); const secret = body.secretRef.trim();
if (secret) channel.secretRef = encryptSecret(secret, store.channelEncryptionKey); if (secret) channel.secretRef = encryptSecret(secret, store.channelEncryptionKey);
} }
if (channel.providerType === "generic") channel.resolutionModelMap = undefined; if (body.timeoutMs !== undefined) channel.timeoutMs = normalizeChannelTimeout(body.timeoutMs, channel.timeoutMs);
if (channel.providerType === "openai-images") validateMappingTargets(channel.enabledModelIds || [], channel.modelMappings); validateMappingTargets(channel.models, channel.modelMappings || []);
if (channel.providerType === "openai-images" && channel.enabled && (!channel.baseUrl || !channel.enabledModelIds?.length || !decryptSecret(channel.secretRef, store.channelEncryptionKey))) throw new DomainError(ErrorCodes.ValidationFailed, "启用 OpenAI 图片渠道必须填写地址、至少添加一个模型并配置 API Key", undefined, 422); if (channel.providerType === "openai-images" && channel.enabled && (!channel.baseUrl || !channel.models.some((model) => model.enabled) || !decryptSecret(channel.secretRef, store.channelEncryptionKey))) throw new DomainError(ErrorCodes.ValidationFailed, "启用 OpenAI 图片渠道必须填写地址、至少添加一个模型并配置 API Key", undefined, 422);
const openAiConfigChanged = channel.providerType === "openai-images" && (body.providerType !== undefined || body.baseUrl !== undefined || body.providerModelId !== undefined || body.enabledModelIds !== undefined || body.modelMappings !== undefined || body.secretRef !== undefined || existingChannel.providerType !== "openai-images"); const providerConfigChanged = body.providerType !== undefined || body.baseUrl !== undefined || body.models !== undefined || body.modelMappings !== undefined || body.secretRef !== undefined || existingChannel.providerType !== channel.providerType;
if (openAiConfigChanged) { channel.health = "degraded"; channel.consecutiveFailures = 0; } if (providerConfigChanged) { channel.health = "degraded"; channel.consecutiveFailures = 0; channel.circuitOpenedAt = undefined; channel.circuitResetAt = undefined; }
channel.version = Number(existingChannel.version || 1) + 1; channel.version = Number(existingChannel.version || 1) + 1;
Object.assign(existingChannel, channel); Object.assign(existingChannel, channel);
if (body.priority !== undefined) for (const group of store.channelGroups) if (group.channelIds.includes(channel.id)) group.channelPriorities = { ...(group.channelPriorities || {}), [channel.id]: channel.priority };
audit(store, "channel.update", "provider_channel", channel.id, id, admin.sub, { ...before, secretRef: before.secretRef ? "configured" : undefined }, { ...channel, secretRef: channel.secretRef ? "configured" : undefined, fixtureFailure: undefined }, clientAddress(request)); audit(store, "channel.update", "provider_channel", channel.id, id, admin.sub, { ...before, secretRef: before.secretRef ? "configured" : undefined }, { ...channel, secretRef: channel.secretRef ? "configured" : undefined, fixtureFailure: undefined }, clientAddress(request));
store.persist(); store.persist();
ok(response, id, { item: { ...channel, secretRef: channel.secretRef ? "configured" : undefined, fixtureFailure: undefined } }); ok(response, id, { item: { ...channel, secretRef: channel.secretRef ? "configured" : undefined, fixtureFailure: undefined } });
@@ -2473,7 +2668,7 @@ export function createHttpServer(store: Store, config: ServerConfig = loadConfig
const background = typeof body.background === "string" ? body.background : "auto"; const background = typeof body.background === "string" ? body.background : "auto";
const moderation = typeof body.moderation === "string" ? body.moderation : "auto"; const moderation = typeof body.moderation === "string" ? body.moderation : "auto";
const outputFormat = typeof body.output_format === "string" ? body.output_format : "png"; const outputFormat = typeof body.output_format === "string" ? body.output_format : "png";
const enabledModels = channel.enabledModelIds || []; const enabledModels = channel.models.filter((item) => item.enabled).map((item) => item.requestModelId);
const mappedModels = (channel.modelMappings || []).map((item) => item.displayModelId); const mappedModels = (channel.modelMappings || []).map((item) => item.displayModelId);
if (!model || (!enabledModels.includes(model) && !mappedModels.includes(model))) throw new DomainError(ErrorCodes.ValidationFailed, "测试模型不在该渠道模型列表中", undefined, 422); if (!model || (!enabledModels.includes(model) && !mappedModels.includes(model))) throw new DomainError(ErrorCodes.ValidationFailed, "测试模型不在该渠道模型列表中", undefined, 422);
if (!prompt) throw new DomainError(ErrorCodes.ValidationFailed, "测试提示词不能为空", undefined, 422); if (!prompt) throw new DomainError(ErrorCodes.ValidationFailed, "测试提示词不能为空", undefined, 422);
@@ -2505,30 +2700,30 @@ export function createHttpServer(store: Store, config: ServerConfig = loadConfig
requireIfMatch(request, product.version || 1); requireIfMatch(request, product.version || 1);
const before = { ...product }; const before = { ...product };
const body = await readJson(request); const body = await readJson(request);
rejectLegacyFields(body, ["basePrice", "maxCount", "maxConcurrent", "currencyUnit", "channelGroupId"]);
const nextName = typeof body.name === "string" ? body.name.trim() : product.name; const nextName = typeof body.name === "string" ? body.name.trim() : product.name;
const nextEnabled = typeof body.enabled === "boolean" ? body.enabled : product.enabled; const nextEnabled = typeof body.enabled === "boolean" ? body.enabled : product.enabled;
let nextChannelGroupId = product.channelGroupId; if (!nextName) throw new DomainError(ErrorCodes.ValidationFailed, "模型名称不能为空", undefined, 422);
if (typeof body.channelGroupId === "string") { if (body.tier !== undefined && body.tier !== "basic" && body.tier !== "advanced" && body.tier !== "flagship") throw new DomainError(ErrorCodes.ValidationFailed, "模型档位无效", undefined, 422);
const channelGroupId = body.channelGroupId.trim(); let nextCapabilities = product.capabilities;
if (channelGroupId) { if (body.capabilities !== undefined) {
const group = store.channelGroups.find((item) => item.id === channelGroupId); if (!Array.isArray(body.capabilities)) throw new DomainError(ErrorCodes.ValidationFailed, "模型能力必须是数组", undefined, 422);
if (!group) throw new DomainError(ErrorCodes.ValidationFailed, "渠道分组不存在", undefined, 422); const allowedCapabilities = new Set<InternalModelProduct["capabilities"][number]>(["image", "text", "audio", "utility", "reverse-prompt", "multi-angle", "matting", "split", "upscale", "expand"]);
validateChannelGroupProductModes(store, group.capabilities, [product.id]); const values = [...new Set(body.capabilities.filter((value): value is string => typeof value === "string").map((value) => value.trim()).filter(Boolean))];
validateChannelGroupProductOwnership(store, group.id, [product.id]); if (!values.length || values.some((value) => !allowedCapabilities.has(value as InternalModelProduct["capabilities"][number]))) throw new DomainError(ErrorCodes.ValidationFailed, "模型能力包含不支持的类型", undefined, 422);
nextChannelGroupId = group.id; nextCapabilities = values as InternalModelProduct["capabilities"];
} else nextChannelGroupId = undefined;
} }
if (nextEnabled && (!nextChannelGroupId || !store.channelGroups.some((group) => group.id === nextChannelGroupId && group.enabled))) throw new DomainError(ErrorCodes.ValidationFailed, "已上架模型必须绑定已启用的渠道分组", undefined, 422); const nextResolutions = body.resolutions === undefined ? product.resolutions : normalizeResolutionList(body.resolutions);
let nextBasePrice = product.basePrice; if (nextEnabled && nextCapabilities.includes("image") && !nextResolutions.length) throw new DomainError(ErrorCodes.ValidationFailed, "已上架图片模型必须配置分辨率矩阵", undefined, 422);
if (body.basePrice !== undefined) { const nextProduct = { ...product, name: nextName, tier: body.tier === "basic" || body.tier === "advanced" || body.tier === "flagship" ? body.tier : product.tier, capabilities: nextCapabilities, capabilityParameters: body.capabilityParameters === undefined ? product.capabilityParameters : normalizeCapabilityParameters(body.capabilityParameters), resolutions: nextResolutions, enabled: nextEnabled };
const basePrice = Number(body.basePrice); if (nextEnabled && !productAvailableThroughChannel(store, nextProduct)) throw new DomainError(ErrorCodes.ValidationFailed, "已上架模型必须配置可用分组路由", undefined, 422);
if (!Number.isSafeInteger(basePrice) || basePrice < 0) throw new DomainError(ErrorCodes.ValidationFailed, "价格必须是非负整数", undefined, 422); product.name = nextProduct.name;
nextBasePrice = basePrice; product.tier = nextProduct.tier;
} product.capabilities = nextProduct.capabilities;
product.name = nextName; product.capabilityParameters = nextProduct.capabilityParameters;
product.resolutions = nextProduct.resolutions;
product.enabled = nextEnabled; product.enabled = nextEnabled;
product.channelGroupId = nextChannelGroupId; product.capabilitiesSchema = nextCapabilities.map(capabilitySchema);
product.basePrice = nextBasePrice;
product.version = (product.version || 1) + 1; product.version = (product.version || 1) + 1;
audit(store, "model_product.update", "model_product", product.id, id, admin.sub, before, product, clientAddress(request)); audit(store, "model_product.update", "model_product", product.id, id, admin.sub, before, product, clientAddress(request));
store.persist(); store.persist();
@@ -2536,27 +2731,48 @@ export function createHttpServer(store: Store, config: ServerConfig = loadConfig
return; return;
} }
if (request.method === "PATCH" && resource === "pricing-rules" && resourceId) { if (request.method === "PATCH" && resource === "pricing-rules" && resourceId) {
const product = store.products.find((item) => item.id === resourceId); if (!product) throw new DomainError(ErrorCodes.NotFound, "价格规则不存在", undefined, 404); requireIfMatch(request, product.version || 1); const body = await readJson(request); const amount = Number(body.basePrice); if (!Number.isSafeInteger(amount) || amount < 0) throw new DomainError(ErrorCodes.ValidationFailed, "价格必须是非负整数", undefined, 422); const before = product.basePrice; product.basePrice = amount; product.version = (product.version || 1) + 1; audit(store, "pricing.update", "pricing_rule", product.id, id, admin.sub, { basePrice: before }, { basePrice: amount }, clientAddress(request)); store.persist(); ok(response, id, { item: { modelProductId: product.id, basePrice: product.basePrice, version: product.version } }); return; const rule = store.pricingRules.find((item) => item.id === resourceId);
if (!rule) throw new DomainError(ErrorCodes.NotFound, "价格规则不存在", undefined, 404);
requireIfMatch(request, rule.version);
const body = await readJson(request);
rejectLegacyFields(body, ["basePrice", "modelProductId"]);
const next = normalizePricingRule(store, body, rule);
if (store.pricingRules.some((item) => item.id !== rule.id && item.enabled && next.enabled && pricingIntervalsOverlap(item, next))) throw new DomainError(ErrorCodes.Conflict, "价格规则生效区间与现有规则重叠", undefined, 409);
const before = { ...rule };
Object.assign(rule, next, { version: rule.version + 1 });
audit(store, "pricing.update", "pricing_rule", rule.id, id, admin.sub, before, rule, clientAddress(request));
store.persist();
ok(response, id, { item: publicPricingRule(rule, store) });
return;
} }
if (request.method === "POST" && resource === "pricing-rules") { if (request.method === "POST" && resource === "pricing-rules") {
const body = await readJson(request); const productId = typeof body.modelProductId === "string" ? body.modelProductId : typeof body.id === "string" ? body.id : ""; const body = await readJson(request);
const product = store.products.find((item) => item.id === productId || item.publicModelId === productId); if (!product) throw new DomainError(ErrorCodes.NotFound, "价格规则对应的模型不存在", undefined, 404); rejectLegacyFields(body, ["basePrice", "modelProductId"]);
const amount = Number(body.basePrice); if (!Number.isSafeInteger(amount) || amount < 0) throw new DomainError(ErrorCodes.ValidationFailed, "价格必须是非负整数", undefined, 422); const rule = normalizePricingRule(store, body);
const before = product.basePrice; product.basePrice = amount; product.version = (product.version || 1) + 1; if (store.pricingRules.some((item) => item.enabled && rule.enabled && pricingIntervalsOverlap(item, rule))) throw new DomainError(ErrorCodes.Conflict, "价格规则生效区间与现有规则重叠", undefined, 409);
audit(store, "pricing.create", "pricing_rule", product.id, id, admin.sub, { basePrice: before }, { basePrice: amount }, clientAddress(request)); store.persist(); ok(response, id, { item: { modelProductId: product.id, publicModelId: product.publicModelId, basePrice: amount, version: product.version } }, 201); return; store.pricingRules.push(rule);
audit(store, "pricing.create", "pricing_rule", rule.id, id, admin.sub, undefined, rule, clientAddress(request));
store.persist();
ok(response, id, { item: publicPricingRule(rule, store) }, 201);
return;
} }
if (request.method === "PATCH" && resource === "channel-groups" && resourceId && action === "order") { if (request.method === "PATCH" && resource === "channel-groups" && resourceId && action === "order") {
const body = await readJson(request); const body = await readJson(request);
const group = store.channelGroups.find((item) => item.id === resourceId); const group = store.channelGroups.find((item) => item.id === resourceId);
const channelIds = Array.isArray(body.channelIds) ? body.channelIds.filter((value): value is string => typeof value === "string") : [];
if (!group) throw new DomainError(ErrorCodes.NotFound, "渠道分组不存在", undefined, 404); if (!group) throw new DomainError(ErrorCodes.NotFound, "渠道分组不存在", undefined, 404);
requireIfMatch(request, group.version); requireIfMatch(request, group.version);
if (channelIds.length !== group.channelIds.length || new Set(channelIds).size !== channelIds.length || channelIds.some((channelId) => !store.channels.some((channel) => channel.id === channelId))) throw new DomainError(ErrorCodes.ValidationFailed, "渠道顺序无效"); const publicModelId = typeof body.publicModelId === "string" ? body.publicModelId.trim() : "";
validateChannelProtocols(store, channelIds); if (!publicModelId || !Array.isArray(body.channels)) throw new DomainError(ErrorCodes.ValidationFailed, "模型路由排序必须包含公开模型和渠道顺序", undefined, 422);
group.channelIds = channelIds; const currentRoute = group.routes.find((route) => route.publicModelId === publicModelId);
group.channelPriorities = Object.fromEntries(channelIds.map((channelId, index) => [channelId, index])); if (!currentRoute) throw new DomainError(ErrorCodes.NotFound, "模型路由不存在", undefined, 404);
const route = normalizeRoutes(store, [{ publicModelId: currentRoute.publicModelId, channels: body.channels, strategy: body.strategy ?? currentRoute.strategy, retryBudget: body.retryBudget ?? currentRoute.retryBudget, version: currentRoute.version }], [], group.enabled)[0];
if (!route) throw new DomainError(ErrorCodes.ValidationFailed, "模型路由无效", undefined, 422);
if (new Set(route.channels.map((candidate) => candidate.channelId)).size !== new Set(currentRoute.channels.map((candidate) => candidate.channelId)).size || route.channels.some((candidate) => !currentRoute.channels.some((current) => current.channelId === candidate.channelId))) throw new DomainError(ErrorCodes.ValidationFailed, "排序不能增删模型路由渠道成员", undefined, 422);
validateChannelProtocols(store, route.channels.map((candidate) => candidate.channelId));
route.version = currentRoute.version + 1;
group.routes = group.routes.map((item) => item.publicModelId === currentRoute.publicModelId ? route : item);
group.version += 1; group.version += 1;
audit(store, "channel_group.order", "channel_group", group.id, id, admin.sub, undefined, group, clientAddress(request)); audit(store, "channel_group.route.update", "channel_group", group.id, id, admin.sub, { publicModelId: currentRoute.publicModelId, route: currentRoute }, { publicModelId: route.publicModelId, route }, clientAddress(request));
store.persist(); store.persist();
ok(response, id, { item: group }); ok(response, id, { item: group });
return; return;
@@ -2566,44 +2782,23 @@ export function createHttpServer(store: Store, config: ServerConfig = loadConfig
if (!group) throw new DomainError(ErrorCodes.NotFound, "渠道分组不存在", undefined, 404); if (!group) throw new DomainError(ErrorCodes.NotFound, "渠道分组不存在", undefined, 404);
requireIfMatch(request, group.version); requireIfMatch(request, group.version);
const body = await readJson(request); const body = await readJson(request);
rejectLegacyFields(body, ["channelIds", "channelPriorities", "retryBudget", "productIds"]);
const nextName = body.name !== undefined ? normalizeChannelGroupName(body.name) : group.name; const nextName = body.name !== undefined ? normalizeChannelGroupName(body.name) : group.name;
const nextCapabilities = body.capabilities !== undefined ? normalizeChannelGroupCapabilities(body.capabilities) : group.capabilities; const nextCapabilities = body.capabilities !== undefined ? normalizeChannelGroupCapabilities(body.capabilities) : group.capabilities;
let nextChannelIds = group.channelIds; const nextEnabled = typeof body.enabled === "boolean" ? body.enabled : group.enabled;
let nextChannelPriorities = group.channelPriorities; const nextRoutes = normalizeRoutes(store, body.routes, group.routes, nextEnabled);
if (Array.isArray(body.channelIds)) { for (const route of nextRoutes) { validateRoute(store, route, nextEnabled); validateChannelProtocols(store, route.channels.map((candidate) => candidate.channelId)); }
const channelIds = body.channelIds.filter((value): value is string => typeof value === "string"); validateChannelGroupProductModes(store, nextCapabilities, nextRoutes.map((route) => route.publicModelId));
if (!channelIds.length || new Set(channelIds).size !== channelIds.length || channelIds.some((channelId) => !store.channels.some((channel) => channel.id === channelId))) throw new DomainError(ErrorCodes.ValidationFailed, "渠道分组成员无效", undefined, 422); validateChannelGroupProductOwnership(store, group.id, nextRoutes.map((route) => route.publicModelId), nextEnabled);
validateChannelProtocols(store, channelIds);
nextChannelIds = channelIds;
nextChannelPriorities = Object.fromEntries(channelIds.map((channelId, index) => [channelId, index]));
}
let nextRetryBudget = group.retryBudget;
if (body.retryBudget !== undefined) {
const retryBudget = Number(body.retryBudget);
if (!Number.isSafeInteger(retryBudget) || retryBudget < 0) throw new DomainError(ErrorCodes.ValidationFailed, "分组重试预算无效", undefined, 422);
nextRetryBudget = retryBudget;
}
const hasProductIds = body.productIds !== undefined;
const productIds = hasProductIds
? normalizeChannelGroupProductIds(store, body.productIds) || []
: store.products.filter((product) => product.channelGroupId === group.id).map((product) => product.id);
// Always validate an explicitly supplied product list. When only the
// mode changes, validate the existing bindings before mutating the
// group so an incompatible legacy relation cannot remain hidden.
validateChannelGroupProductModes(store, nextCapabilities, productIds);
if (hasProductIds) validateChannelGroupProductOwnership(store, group.id, productIds);
const before = { ...group }; const before = { ...group };
group.name = nextName; group.name = nextName;
group.capabilities = nextCapabilities; group.capabilities = nextCapabilities;
group.channelIds = nextChannelIds; group.routes = nextRoutes;
group.channelPriorities = nextChannelPriorities; group.enabled = nextEnabled;
group.retryBudget = nextRetryBudget;
if (typeof body.enabled === "boolean") group.enabled = body.enabled;
if (hasProductIds) bindChannelGroupProducts(store, group.id, productIds);
group.version += 1; group.version += 1;
audit(store, "channel_group.update", "channel_group", group.id, id, admin.sub, before, group, clientAddress(request)); audit(store, "channel_group.update", "channel_group", group.id, id, admin.sub, before, group, clientAddress(request));
store.persist(); store.persist();
ok(response, id, { item: { ...group, productIds } }); ok(response, id, { item: group });
return; return;
} }
if ((request.method === "GET" || request.method === "PATCH") && resource === "model-products" && resourceId && action === "resolutions") { if ((request.method === "GET" || request.method === "PATCH") && resource === "model-products" && resourceId && action === "resolutions") {
@@ -2771,25 +2966,44 @@ export function createHttpServer(store: Store, config: ServerConfig = loadConfig
} }
if (request.method === "GET" && resource === "channels") { if (request.method === "GET" && resource === "channels") {
const support = (admin.roles || []).includes("support") && !(admin.roles || []).includes("super_admin"); const support = (admin.roles || []).includes("support") && !(admin.roles || []).includes("super_admin");
ok(response, id, { items: store.channels.map(({ id: channelId, label, enabled, priority, health, version, retryBudget, consecutiveFailures, lastTestedAt, lastTestHealthy, lastTestRequestPath, lastTestRequestAttempted, lastTestError, providerType, providerName, providerModelId, enabledModelIds, modelMappings, resolutionModelMap, baseUrl, secretRef }) => ({ id: channelId, label, enabled, priority, health, version, retryBudget, consecutiveFailures, lastTestedAt, lastTestHealthy, lastTestRequestPath, lastTestRequestAttempted, lastTestError, providerType: support ? undefined : providerType, providerName: support ? undefined : providerName, providerModelId: support ? undefined : providerModelId, enabledModelIds: support ? undefined : enabledModelIds, modelMappings: support ? undefined : modelMappings, resolutionModelMap: support ? undefined : resolutionModelMap, baseUrl: support ? undefined : baseUrl, secretConfigured: Boolean(secretRef) })) }); ok(response, id, { items: store.channels.map((channel) => ({
id: channel.id,
label: channel.label,
enabled: channel.enabled,
health: channel.enabled ? channel.health : "disabled",
version: channel.version,
consecutiveFailures: channel.consecutiveFailures,
lastTestedAt: channel.lastTestedAt,
lastTestHealthy: channel.lastTestHealthy,
lastTestRequestPath: channel.lastTestRequestPath,
lastTestRequestAttempted: channel.lastTestRequestAttempted,
lastTestError: channel.lastTestError,
providerType: support ? undefined : channel.providerType,
providerName: support ? undefined : channel.providerName,
models: support ? undefined : channel.models,
modelMappings: support ? undefined : channel.modelMappings,
baseUrl: support ? undefined : channel.baseUrl,
secretConfigured: Boolean(channel.secretRef),
groupIds: store.channelGroups.filter((group) => group.routes.some((route) => route.channels.some((candidate) => candidate.channelId === channel.id))).map((group) => group.id),
})) });
return; return;
} }
if (request.method === "GET" && resource === "channel-groups") { if (request.method === "GET" && resource === "channel-groups") {
ok(response, id, { ok(response, id, {
items: store.channelGroups.map((group) => { items: store.channelGroups.map((group) => {
const products = store.products const products = group.routes
.filter((product) => product.channelGroupId === group.id) .map((route) => store.products.find((product) => product.publicModelId === route.publicModelId))
.map(({ id: productId, publicModelId, name, basePrice, currencyUnit, enabled, capabilities, resolutions }) => ({ productId, publicModelId, name, basePrice, currencyUnit, enabled, capabilities, resolutions })); .filter((product): product is InternalModelProduct => Boolean(product))
.map(({ id: productId, publicModelId, name, enabled, capabilities, resolutions }) => ({ productId, publicModelId, name, enabled, capabilities, resolutions }));
const capabilities = group.capabilities?.length ? group.capabilities : [...new Set(products.flatMap((product) => product.capabilities || []))]; const capabilities = group.capabilities?.length ? group.capabilities : [...new Set(products.flatMap((product) => product.capabilities || []))];
const channelDetails = group.channelIds.map((channelId) => { const channelDetails = groupChannelIds(group).map((channelId) => {
const channel = store.channels.find((item) => item.id === channelId); const channel = store.channels.find((item) => item.id === channelId);
return channel ? { id: channel.id, label: channel.label, providerName: channel.providerName, providerType: channel.providerType, enabled: channel.enabled, health: channel.health, enabledModelIds: channel.enabledModelIds || [] } : { id: channelId, label: channelId, enabled: false, health: "open" as const, enabledModelIds: [] }; return channel ? { id: channel.id, label: channel.label, providerName: channel.providerName, providerType: channel.providerType, enabled: channel.enabled, health: channel.enabled ? channel.health : "disabled" as const, models: channel.models } : { id: channelId, label: channelId, enabled: false, health: "open" as const, models: [] };
}); });
return { return {
...group, ...group,
name: group.name || (group.id === "image-default" ? "图片生成渠道组" : undefined), name: group.name || (group.id === "image-default" ? "图片生成渠道组" : undefined),
capabilities, capabilities,
productIds: products.map((product) => product.productId),
products, products,
channels: channelDetails.map((channel) => channel.label), channels: channelDetails.map((channel) => channel.label),
channelDetails, channelDetails,
@@ -2800,9 +3014,12 @@ export function createHttpServer(store: Store, config: ServerConfig = loadConfig
} }
if (request.method === "GET" && resource === "model-products") { if (request.method === "GET" && resource === "model-products") {
ok(response, id, { items: store.products.map((product) => { ok(response, id, { items: store.products.map((product) => {
const group = store.channelGroups.find((item) => item.id === (product.channelGroupId || "image-default")); const routes = store.channelGroups.flatMap((group) => group.enabled ? group.routes.filter((route) => route.publicModelId === product.publicModelId).map((route) => ({ group, route })) : []);
const availableChannelCount = group?.channelIds.filter((channelId) => store.channels.some((channel) => channel.id === channelId && channel.enabled && channel.health !== "open")).length || 0; const availableChannelCount = routes.flatMap(({ route }) => route.channels).filter((candidate) => {
return { ...product, availableChannelCount, availability: product.enabled && group?.enabled && availableChannelCount > 0 ? "available" : product.enabled ? "unavailable" : "disabled" }; const channel = store.channels.find((item) => item.id === candidate.channelId);
return channel?.enabled && channel.health !== "open" && Boolean(channelRequestModel(channel, product.publicModelId));
}).length;
return { ...product, availableChannelCount, availability: product.enabled && availableChannelCount > 0 ? "available" : product.enabled ? "unavailable" : "disabled" };
}) }); }) });
return; return;
} }
@@ -2879,6 +3096,12 @@ export function createHttpServer(store: Store, config: ServerConfig = loadConfig
if (task.status !== "unknown") throw new DomainError(ErrorCodes.Conflict, "仅未知结果任务需要对账", undefined, 409); if (task.status !== "unknown") throw new DomainError(ErrorCodes.Conflict, "仅未知结果任务需要对账", undefined, 409);
const body = await readJson(request); const outcome = body.outcome === "success" ? "success" : body.outcome === "failure" ? "failure" : undefined; const body = await readJson(request); const outcome = body.outcome === "success" ? "success" : body.outcome === "failure" ? "failure" : undefined;
if (!outcome) throw new DomainError(ErrorCodes.ValidationFailed, "对账结果必须是 success 或 failure", undefined, 422); if (!outcome) throw new DomainError(ErrorCodes.ValidationFailed, "对账结果必须是 success 或 failure", undefined, 422);
let requestedCharge: number | undefined;
if (outcome === "success") {
requestedCharge = body.chargedAmount === undefined ? task.estimatedCost : Number(body.chargedAmount);
if (!Number.isSafeInteger(requestedCharge) || requestedCharge < 0) throw new DomainError(ErrorCodes.ValidationFailed, "实际扣除金币必须是非负整数", undefined, 422);
if (requestedCharge > task.reservedCost) throw new DomainError(ErrorCodes.ValidationFailed, "实际扣除金币不能超过任务预留金币", { reservedCost: task.reservedCost }, 422);
}
const attempt = [...task.attempts].reverse().find((item) => item.status === "unknown"); const attempt = [...task.attempts].reverse().find((item) => item.status === "unknown");
if (!attempt) throw new DomainError(ErrorCodes.Conflict, "任务缺少未知 attempt", undefined, 409); if (!attempt) throw new DomainError(ErrorCodes.Conflict, "任务缺少未知 attempt", undefined, 409);
let reconciledOutput: { id: string; mimeType: string; stagingKey: string; bytes: Buffer; metadata?: Record<string, unknown> } | undefined; let reconciledOutput: { id: string; mimeType: string; stagingKey: string; bytes: Buffer; metadata?: Record<string, unknown> } | undefined;
@@ -2908,9 +3131,8 @@ export function createHttpServer(store: Store, config: ServerConfig = loadConfig
currentTask.publicErrorCode = ErrorCodes.ModelGroupUnavailable; currentTask.publicErrorCode = ErrorCodes.ModelGroupUnavailable;
currentTask.reservedCost = 0; currentTask.reservedCost = 0;
} else { } else {
if (body.chargedAmount !== undefined && (!Number.isSafeInteger(Number(body.chargedAmount)) || Number(body.chargedAmount) < 0)) throw new DomainError(ErrorCodes.ValidationFailed, "实际扣除金币必须是非负整数", undefined, 422); const chargedAmount = requestedCharge ?? currentTask.estimatedCost;
const requestedCharge = body.chargedAmount === undefined ? currentTask.estimatedCost : Number(body.chargedAmount); if (!Number.isSafeInteger(chargedAmount) || chargedAmount < 0 || chargedAmount > currentTask.reservedCost) throw new DomainError(ErrorCodes.ValidationFailed, "实际扣除金币不能超过任务预留金币", { reservedCost: currentTask.reservedCost }, 422);
const chargedAmount = Math.max(0, Math.min(currentTask.reservedCost, requestedCharge));
if (currentTask.reservedCost > 0 && !settleBalance(atomicStore, currentTask.ownerId, currentTask.reservedCost, chargedAmount, currentTask.id)) return false; if (currentTask.reservedCost > 0 && !settleBalance(atomicStore, currentTask.ownerId, currentTask.reservedCost, chargedAmount, currentTask.id)) return false;
if (currentAttempt) currentAttempt.reconciliationStatus = "confirmed_success"; if (currentAttempt) currentAttempt.reconciliationStatus = "confirmed_success";
if (!currentTask.outputs.length && !reconciledOutput) return false; if (!currentTask.outputs.length && !reconciledOutput) return false;
@@ -3040,7 +3262,7 @@ export function createHttpServer(store: Store, config: ServerConfig = loadConfig
if (request.method === "GET" && resource === "recharge-orders") { ok(response, id, { items: [...store.rechargeOrders.values()].map((order) => ({ id: order.id, version: rechargeOrderVersion(order), userId: order.userId, provider: order.provider, status: order.status, fiatAmount: order.fiatAmount, balanceAmount: order.balanceAmount, unitVersion: order.unitVersion, createdAt: order.createdAt, paidAt: order.paidAt })) }); return; } if (request.method === "GET" && resource === "recharge-orders") { ok(response, id, { items: [...store.rechargeOrders.values()].map((order) => ({ id: order.id, version: rechargeOrderVersion(order), userId: order.userId, provider: order.provider, status: order.status, fiatAmount: order.fiatAmount, balanceAmount: order.balanceAmount, unitVersion: order.unitVersion, createdAt: order.createdAt, paidAt: order.paidAt })) }); return; }
if (request.method === "GET" && resource === "payment-events") { ok(response, id, { items: [...store.paymentEvents.values()] }); return; } if (request.method === "GET" && resource === "payment-events") { ok(response, id, { items: [...store.paymentEvents.values()] }); return; }
if (request.method === "GET" && resource === "pricing-rules") { if (request.method === "GET" && resource === "pricing-rules") {
ok(response, id, { items: store.products.map((product) => ({ id: product.id, modelProductId: product.id, publicModelId: product.publicModelId, name: product.name, basePrice: product.basePrice, currencyUnit: product.currencyUnit, version: product.version || 1 })) }); return; ok(response, id, { items: store.pricingRules.map((rule) => publicPricingRule(rule, store)) }); return;
} }
if (request.method === "GET" && resource === "billing" && resourceId === "units") { ok(response, id, { items: [store.balanceUnit] }); return; } if (request.method === "GET" && resource === "billing" && resourceId === "units") { ok(response, id, { items: [store.balanceUnit] }); return; }
if ((request.method === "GET" || request.method === "POST" || request.method === "PATCH") && resource === "billing" && resourceId === "conversion-rules") { if ((request.method === "GET" || request.method === "POST" || request.method === "PATCH") && resource === "billing" && resourceId === "conversion-rules") {
@@ -3051,16 +3273,17 @@ export function createHttpServer(store: Store, config: ServerConfig = loadConfig
if (request.method === "POST" && resource === "payment-providers") { const body = await readJson(request); const suppliedSecret = typeof body.secret === "string" ? body.secret : typeof body.apiKey === "string" ? body.apiKey : typeof body.password === "string" ? body.password : undefined; const requestedId = typeof body.id === "string" ? body.id.trim() : ""; const provider = { id: requestedId || randomUUID(), name: typeof body.name === "string" ? body.name.trim() : "支付适配器", enabled: body.enabled !== false, mode: body.mode === "live" ? "live" : "test", secretConfigured: Boolean(suppliedSecret), secretRef: suppliedSecret ? encryptSecret(suppliedSecret, store.channelEncryptionKey) : undefined, version: 1 }; if (store.paymentProviders.has(provider.id)) throw new DomainError(ErrorCodes.Conflict, "支付适配器 ID 已存在", undefined, 409); store.paymentProviders.set(provider.id, provider); audit(store, "payment_provider.create", "payment_provider", provider.id, id, admin.sub, undefined, { ...provider, secretRef: provider.secretRef ? "configured" : undefined }, clientAddress(request)); const { secretRef: _secretRef, ...safeProvider } = provider; store.persist(); ok(response, id, { item: safeProvider }, 201); return; } if (request.method === "POST" && resource === "payment-providers") { const body = await readJson(request); const suppliedSecret = typeof body.secret === "string" ? body.secret : typeof body.apiKey === "string" ? body.apiKey : typeof body.password === "string" ? body.password : undefined; const requestedId = typeof body.id === "string" ? body.id.trim() : ""; const provider = { id: requestedId || randomUUID(), name: typeof body.name === "string" ? body.name.trim() : "支付适配器", enabled: body.enabled !== false, mode: body.mode === "live" ? "live" : "test", secretConfigured: Boolean(suppliedSecret), secretRef: suppliedSecret ? encryptSecret(suppliedSecret, store.channelEncryptionKey) : undefined, version: 1 }; if (store.paymentProviders.has(provider.id)) throw new DomainError(ErrorCodes.Conflict, "支付适配器 ID 已存在", undefined, 409); store.paymentProviders.set(provider.id, provider); audit(store, "payment_provider.create", "payment_provider", provider.id, id, admin.sub, undefined, { ...provider, secretRef: provider.secretRef ? "configured" : undefined }, clientAddress(request)); const { secretRef: _secretRef, ...safeProvider } = provider; store.persist(); ok(response, id, { item: safeProvider }, 201); return; }
if (request.method === "PATCH" && resource === "payment-providers" && resourceId) { const provider = store.paymentProviders.get(resourceId); if (!provider) throw new DomainError(ErrorCodes.NotFound, "支付适配器不存在", undefined, 404); requireIfMatch(request, Number(provider.version || 1)); const body = await readJson(request); const before = { ...provider }; if (typeof body.enabled === "boolean") provider.enabled = body.enabled; const suppliedSecret = typeof body.secret === "string" ? body.secret : typeof body.apiKey === "string" ? body.apiKey : typeof body.password === "string" ? body.password : undefined; if (suppliedSecret) { provider.secretConfigured = true; provider.secretRef = encryptSecret(suppliedSecret, store.channelEncryptionKey); } provider.version = Number(provider.version || 1) + 1; audit(store, "payment_provider.update", "payment_provider", resourceId, id, admin.sub, before, { ...provider, secretRef: provider.secretRef ? "configured" : undefined }, clientAddress(request)); const { secretRef: _secretRef, ...safeProvider } = provider; store.persist(); ok(response, id, { item: safeProvider }); return; } if (request.method === "PATCH" && resource === "payment-providers" && resourceId) { const provider = store.paymentProviders.get(resourceId); if (!provider) throw new DomainError(ErrorCodes.NotFound, "支付适配器不存在", undefined, 404); requireIfMatch(request, Number(provider.version || 1)); const body = await readJson(request); const before = { ...provider }; if (typeof body.enabled === "boolean") provider.enabled = body.enabled; const suppliedSecret = typeof body.secret === "string" ? body.secret : typeof body.apiKey === "string" ? body.apiKey : typeof body.password === "string" ? body.password : undefined; if (suppliedSecret) { provider.secretConfigured = true; provider.secretRef = encryptSecret(suppliedSecret, store.channelEncryptionKey); } provider.version = Number(provider.version || 1) + 1; audit(store, "payment_provider.update", "payment_provider", resourceId, id, admin.sub, before, { ...provider, secretRef: provider.secretRef ? "configured" : undefined }, clientAddress(request)); const { secretRef: _secretRef, ...safeProvider } = provider; store.persist(); ok(response, id, { item: safeProvider }); return; }
if (request.method === "GET" && resource === "channel-health") { if (request.method === "GET" && resource === "channel-health") {
const samples = [...store.tasks.values()].flatMap((task) => task.attempts.map((attempt) => ({ channelId: attempt.channelId, status: attempt.status, finishedAt: attempt.finishedAt, durationMs: attempt.finishedAt ? Math.max(0, Date.parse(attempt.finishedAt) - Date.parse(attempt.startedAt)) : undefined })));
const percentile = (values: number[], ratio: number) => { if (!values.length) return undefined; const sorted = [...values].sort((a, b) => a - b); return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * ratio) - 1))]; }; const percentile = (values: number[], ratio: number) => { if (!values.length) return undefined; const sorted = [...values].sort((a, b) => a - b); return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * ratio) - 1))]; };
ok(response, id, { items: store.channels.map((channel) => { ok(response, id, { items: store.channels.map((channel) => {
const channelSamples = samples.filter((sample) => sample.channelId === channel.id); const channelSamples = store.healthSamples.filter((sample) => sample.channelId === channel.id).sort((left, right) => left.createdAt.localeCompare(right.createdAt));
const completed = channelSamples.filter((sample) => sample.status !== "started"); const completed = channelSamples.filter((sample) => sample.status !== "probing");
const durations = channelSamples.flatMap((sample) => sample.durationMs === undefined ? [] : [sample.durationMs]); const durations = channelSamples.flatMap((sample) => sample.latencyMs === undefined ? [] : [sample.latencyMs]);
const succeeded = completed.filter((sample) => sample.status === "succeeded").length; const succeeded = completed.filter((sample) => sample.status === "success").length;
const lastCheckedAt = channel.lastTestedAt || channelSamples.flatMap((sample) => sample.finishedAt ? [sample.finishedAt] : []).sort().at(-1); const trend = Array.from({ length: 12 }, (_, index) => channelSamples.slice(-12)[index] || null);
const groupId = store.channelGroups.find((group) => group.channelIds.includes(channel.id))?.id; const current = [...store.tasks.values()].reduce((count, task) => count + (task.status === "running" ? task.attempts.filter((attempt) => attempt.channelId === channel.id && attempt.status === "started").length : 0), 0);
return { id: channel.id, label: channel.label, groupId, health: channel.enabled ? channel.health : "disabled", enabled: channel.enabled, priority: channel.priority, consecutiveFailures: channel.consecutiveFailures, circuitResetAt: channel.circuitResetAt ? new Date(channel.circuitResetAt).toISOString() : undefined, version: channel.version, attempts: channelSamples.length, succeeded, failed: completed.length - succeeded, successRate: completed.length ? succeeded / completed.length : 0, latencyP95Ms: percentile(durations, 0.95), lastCheckedAt, lastTestedAt: channel.lastTestedAt, lastTestHealthy: channel.lastTestHealthy, lastTestRequestPath: channel.lastTestRequestPath, lastTestRequestAttempted: channel.lastTestRequestAttempted, lastTestError: channel.lastTestError }; const limit = Math.max(0, channel.capacityLimit || 0);
const lastCheckedAt = channel.lastTestedAt || channelSamples.at(-1)?.createdAt;
return { id: channel.id, label: channel.label, groupIds: store.channelGroups.filter((group) => group.routes.some((route) => route.channels.some((candidate) => candidate.channelId === channel.id))).map((group) => group.id), health: channel.enabled ? channel.health : "disabled", enabled: channel.enabled, consecutiveFailures: channel.consecutiveFailures, circuitResetAt: channel.circuitResetAt ? new Date(channel.circuitResetAt).toISOString() : undefined, version: channel.version, attempts: channelSamples.length, succeeded, failed: completed.length - succeeded, successRate: completed.length ? succeeded / completed.length : 0, latencyP95Ms: percentile(durations, 0.95), lastCheckedAt, lastTestedAt: channel.lastTestedAt, lastTestHealthy: channel.lastTestHealthy, lastTestRequestPath: channel.lastTestRequestPath, lastTestRequestAttempted: channel.lastTestRequestAttempted, lastTestError: channel.lastTestError, capacity: { current, limit, available: Math.max(0, limit - current) }, samples: trend };
}) }); return; }) }); return;
} }
if (request.method === "GET" && resource === "ws" && resourceId === "metrics") { const subscriptions = [...store.taskSubscribers.values()].reduce((total, listeners) => total + listeners.size, 0); ok(response, id, { items: [{ connections: new Set([...store.taskSubscribers.values()].flatMap((listeners) => [...listeners])).size, subscriptions, taskQueues: store.queue.length, eventBacklog: [...store.events.values()].reduce((total, events) => total + events.length, 0) }] }); return; } if (request.method === "GET" && resource === "ws" && resourceId === "metrics") { const subscriptions = [...store.taskSubscribers.values()].reduce((total, listeners) => total + listeners.size, 0); ok(response, id, { items: [{ connections: new Set([...store.taskSubscribers.values()].flatMap((listeners) => [...listeners])).size, subscriptions, taskQueues: store.queue.length, eventBacklog: [...store.events.values()].reduce((total, events) => total + events.length, 0) }] }); return; }
+9 -7
View File
@@ -1,4 +1,4 @@
import type { BalanceLedgerEntry, GenerationTask, TaskEvent } from "@miragenflow/contracts"; import type { BalanceLedgerEntry, GenerationTask, ModelRoute, PricingRule, TaskEvent } from "@miragenflow/contracts";
import type { AdminApprovalRecord, MessageOutboxRecord, PaymentEventRecord, RechargeOrder, Store } from "../store.ts"; import type { AdminApprovalRecord, MessageOutboxRecord, PaymentEventRecord, RechargeOrder, Store } from "../store.ts";
const DAY = 24 * 60 * 60 * 1000; const DAY = 24 * 60 * 60 * 1000;
@@ -52,6 +52,10 @@ export function ensureAdminDemoData(store: Store) {
const imagePublicModelId = imageProduct?.publicModelId || "basic-image-v1"; const imagePublicModelId = imageProduct?.publicModelId || "basic-image-v1";
const textModelId = textProduct?.id || "basic-text"; const textModelId = textProduct?.id || "basic-text";
const textPublicModelId = textProduct?.publicModelId || "basic-text-v1"; const textPublicModelId = textProduct?.publicModelId || "basic-text-v1";
const imageRoute = store.channelGroups.find((group) => group.id === channelGroupId)?.routes.find((route) => route.publicModelId === imagePublicModelId);
const imagePricing = store.pricingRules.find((rule) => rule.publicModelId === imagePublicModelId) || ({ id: `price-${imageModelId}`, version: 1, publicModelId: imagePublicModelId, resolutionId: imageProduct?.resolutions[0]?.id, billingUnit: "output", tiers: [{ minQuantity: 1, unitPrice: 10 }], multiplier: 1, balanceUnitVersion: store.balanceUnit.version, enabled: true } satisfies PricingRule);
const imageSnapshotRoute: GenerationTask["routeSnapshot"] = { groupId: channelGroupId, groupVersion: store.channelGroups.find((group) => group.id === channelGroupId)?.version || 1, publicModelId: imagePublicModelId, routeVersion: imageRoute?.version || 1, orderedCandidates: imageRoute?.channels.map((candidate) => ({ ...candidate, requestModelId: store.channels.find((channel) => channel.id === candidate.channelId)?.models.find((model) => model.enabled)?.requestModelId || imagePublicModelId })) || (store.channels.length ? [{ channelId, priorityTier: 1, orderWithinTier: 1, retryBudget: 0, requestModelId: imagePublicModelId }] : []), strategy: imageRoute?.strategy || "strict", totalRetryBudget: imageRoute?.retryBudget || 0 };
const imageModelSnapshot: GenerationTask["modelSnapshot"] = { publicModelId: imagePublicModelId, modelVersion: imageProduct?.version || 1, name: imageProduct?.name || imagePublicModelId, tier: imageProduct?.tier || "basic", capabilities: [...(imageProduct?.capabilities || ["image"])], capabilityParameters: imageProduct?.capabilityParameters, resolutionId: imageProduct?.resolutions[0]?.id, resolution: imageProduct?.resolutions[0] };
const successStarted = timestamp(1, 9, 12); const successStarted = timestamp(1, 9, 12);
const successFinished = timestamp(1, 9, 13); const successFinished = timestamp(1, 9, 13);
@@ -66,16 +70,14 @@ export function ensureAdminDemoData(store: Store) {
createdAt: timestamp(1, 9), createdAt: timestamp(1, 9),
updatedAt: successFinished, updatedAt: successFinished,
eventSequence: 3, eventSequence: 3,
channelGroupId,
routeSnapshotVersion: 1,
attempts: [addChannelAwareAttempt(channelId, "succeeded", successStarted, successFinished, 1)], attempts: [addChannelAwareAttempt(channelId, "succeeded", successStarted, successFinished, 1)],
outputs: [{ id: "demo-output-success", mimeType: "image/png", status: "succeeded", unitPriceSnapshot: 10, chargedAmount: 10, metadata: { width: 1024, height: 1024, format: "png", resolution: "1K", source: "url" } }], outputs: [{ id: "demo-output-success", mimeType: "image/png", status: "succeeded", unitPriceSnapshot: 10, chargedAmount: 10, metadata: { width: 1024, height: 1024, format: "png", resolution: "1K", source: "url" } }],
prompt: "产品演示插画:明亮的工作室和一张白色桌面", prompt: "产品演示插画:明亮的工作室和一张白色桌面",
resolutionPresetId: "low", resolutionPresetId: "low",
count: 1, count: 1,
pricingSnapshot: { basePrice: 10, multiplier: 1, unitVersion: store.balanceUnit.version }, pricingSnapshot: { ruleId: imagePricing.id, ruleVersion: imagePricing.version, publicModelId: imagePublicModelId, resolutionId: imageProduct?.resolutions[0]?.id, unitPrice: imagePricing.tiers[0]?.unitPrice || 10, quantity: 1, billingUnit: imagePricing.billingUnit, multiplier: imagePricing.multiplier, balanceUnitVersion: imagePricing.balanceUnitVersion },
modelSnapshot: { publicModelId: imagePublicModelId, version: imageProduct?.version || 1 }, modelSnapshot: imageModelSnapshot,
routeSnapshot: { groupId: channelGroupId, version: 1, channelIds: store.channels.length ? [channelId] : [] }, routeSnapshot: imageSnapshotRoute,
planSnapshot: { planId: store.plans[0]?.id, version: store.plans[0]?.version, queuePriority: 0, maxConcurrent: 2 }, planSnapshot: { planId: store.plans[0]?.id, version: store.plans[0]?.version, queuePriority: 0, maxConcurrent: 2 },
}; };
addTask(store, successTask, taskEvents(successTask, "task.succeeded", successFinished)); addTask(store, successTask, taskEvents(successTask, "task.succeeded", successFinished));
@@ -124,7 +126,7 @@ export function ensureAdminDemoData(store: Store) {
attempts: [], attempts: [],
outputs: [], outputs: [],
prompt: "已取消示例:产品说明摘要", prompt: "已取消示例:产品说明摘要",
modelSnapshot: { publicModelId: textPublicModelId, version: textProduct?.version || 1 }, modelSnapshot: { publicModelId: textPublicModelId, modelVersion: textProduct?.version || 1, name: textProduct?.name || textPublicModelId, tier: textProduct?.tier || "basic", capabilities: [...(textProduct?.capabilities || ["text"])], capabilityParameters: textProduct?.capabilityParameters, resolutionId: textProduct?.resolutions[0]?.id, resolution: textProduct?.resolutions[0] },
}; };
addTask(store, canceledTask, taskEvents(canceledTask, "task.canceled", canceledFinished)); addTask(store, canceledTask, taskEvents(canceledTask, "task.canceled", canceledFinished));
+9 -9
View File
@@ -2,7 +2,7 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { dirname } from "node:path"; import { dirname } from "node:path";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { GenerationTask } from "@miragenflow/contracts"; import { DOMAIN_SNAPSHOT_VERSION, type GenerationTask } from "@miragenflow/contracts";
import type { WebDavFileRecord, WebDavRecord, WebDavSyncJob } from "../store.ts"; import type { WebDavFileRecord, WebDavRecord, WebDavSyncJob } from "../store.ts";
import { ErrorCodes } from "@miragenflow/contracts"; import { ErrorCodes } from "@miragenflow/contracts";
import { DomainError } from "../shared/errors.ts"; import { DomainError } from "../shared/errors.ts";
@@ -433,7 +433,7 @@ export class PostgresRepository<T> implements PersistenceRepository<T> {
if (!Number.isSafeInteger(input.maxConcurrent) || input.maxConcurrent < 1) throw new DomainError(ErrorCodes.ValidationFailed, "任务并发上限无效", undefined, 422); if (!Number.isSafeInteger(input.maxConcurrent) || input.maxConcurrent < 1) throw new DomainError(ErrorCodes.ValidationFailed, "任务并发上限无效", undefined, 422);
const pool = this.client || (this.client = await this.connect()); const pool = this.client || (this.client = await this.connect());
if (!pool) throw new Error("PostgreSQL adapter unavailable: install pg and verify DATABASE_URL"); if (!pool) throw new Error("PostgreSQL adapter unavailable: install pg and verify DATABASE_URL");
const client = await pool.connect(); const task = input.task; const taskSnapshot = { modelProductId: task.modelProductId, prompt: task.prompt, resolutionPresetId: task.resolutionPresetId, count: task.count, estimatedCost: input.estimatedCost, channelGroupId: task.channelGroupId, routeSnapshotVersion: task.routeSnapshot?.version, retentionExpiresAt: task.retentionExpiresAt, retentionState: task.retentionState, retentionExtensionDays: task.retentionExtensionDays, reserveExpiresAt: task.reserveExpiresAt, inputSnapshot: task.inputSnapshot, planSnapshot: task.planSnapshot, requestFingerprint: input.requestFingerprint, providerSubmitted: task.providerSubmitted, publicErrorCode: task.publicErrorCode }; const client = await pool.connect(); const task = input.task; const taskSnapshot = { ...task, estimatedCost: input.estimatedCost };
try { try {
await client.query("BEGIN"); await client.query("BEGIN");
await client.query("SELECT pg_advisory_xact_lock_shared(hashtext($1))", [this.key]); await client.query("SELECT pg_advisory_xact_lock_shared(hashtext($1))", [this.key]);
@@ -559,10 +559,10 @@ async function persistCanonicalSnapshot(client: PgClient, value: unknown) {
for (const [, raw] of mapEntries(source.buckets)) for (const bucket of arrayEntries(raw)) { const item = record(bucket); const id = uuid(item.id); const userId = uuid(item.userId); if (!id || !userId) continue; await client.query("INSERT INTO balance_buckets(id, user_id, source, remaining, priority, expires_at) VALUES ($1, $2, $3, $4, $5, $6)", [id, userId, String(item.source || "recharge"), numberValue(item.remaining), numberValue(item.priority), item.expiresAt ? iso(item.expiresAt) : null]); } for (const [, raw] of mapEntries(source.buckets)) for (const bucket of arrayEntries(raw)) { const item = record(bucket); const id = uuid(item.id); const userId = uuid(item.userId); if (!id || !userId) continue; await client.query("INSERT INTO balance_buckets(id, user_id, source, remaining, priority, expires_at) VALUES ($1, $2, $3, $4, $5, $6)", [id, userId, String(item.source || "recharge"), numberValue(item.remaining), numberValue(item.priority), item.expiresAt ? iso(item.expiresAt) : null]); }
const unit = record(source.balanceUnit); if (unit.shortCode) await client.query("INSERT INTO balance_units(id, version, name, short_code, icon, precision, currency_code, conversion_rate, rounding, published_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now())", [String(unit.shortCode), numberValue(unit.version, 1), String(unit.name || "金币"), String(unit.shortCode), String(unit.icon || "coins"), numberValue(unit.precision), String(unit.currencyCode || "CNY"), numberValue(unit.conversionRate, 1), String(unit.rounding || "round")]); const unit = record(source.balanceUnit); if (unit.shortCode) await client.query("INSERT INTO balance_units(id, version, name, short_code, icon, precision, currency_code, conversion_rate, rounding, published_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now())", [String(unit.shortCode), numberValue(unit.version, 1), String(unit.name || "金币"), String(unit.shortCode), String(unit.icon || "coins"), numberValue(unit.precision), String(unit.currencyCode || "CNY"), numberValue(unit.conversionRate, 1), String(unit.rounding || "round")]);
for (const raw of arrayEntries(source.products)) { const item = record(raw); if (!item.id || !item.publicModelId) continue; await client.query("INSERT INTO model_products(id, public_model_id, tier, enabled, version, snapshot) VALUES ($1, $2, $3, $4, $5, $6)", [String(item.id), String(item.publicModelId), String(item.tier || "basic"), item.enabled !== false, numberValue(item.version, 1), jsonValue(item)]); } for (const raw of arrayEntries(source.products)) { const item = record(raw); if (!item.id || !item.publicModelId) continue; await client.query("INSERT INTO model_products(id, public_model_id, tier, enabled, version, snapshot) VALUES ($1, $2, $3, $4, $5, $6)", [String(item.id), String(item.publicModelId), String(item.tier || "basic"), item.enabled !== false, numberValue(item.version, 1), jsonValue(item)]); }
for (const raw of arrayEntries(source.channels)) { const item = record(raw); if (!item.id) continue; const health = { label: item.label, enabled: item.enabled, priority: item.priority, providerType: item.providerType, providerName: item.providerName, providerModelId: item.providerModelId, enabledModelIds: item.enabledModelIds, modelMappings: item.modelMappings, resolutionModelMap: item.resolutionModelMap, retryBudget: item.retryBudget, fixtureFailure: item.fixtureFailure, health: item.health, consecutiveFailures: item.consecutiveFailures, circuitOpenedAt: item.circuitOpenedAt, circuitResetAt: item.circuitResetAt }; await client.query("INSERT INTO provider_channels(id, status, encrypted_secret_ref, base_url, health, version) VALUES ($1, $2, $3, $4, $5, $6)", [String(item.id), String(item.health || "healthy"), typeof item.secretRef === "string" ? item.secretRef : "", typeof item.baseUrl === "string" ? item.baseUrl : "", jsonValue(health), numberValue(item.version, 1)]); } for (const raw of arrayEntries(source.channels)) { const item = record(raw); if (!item.id) continue; const { id: _id, baseUrl, secretRef, health: healthStatus, version, ...projection } = item; await client.query("INSERT INTO provider_channels(id, status, encrypted_secret_ref, base_url, health, version) VALUES ($1, $2, $3, $4, $5, $6)", [String(item.id), String(healthStatus || "healthy"), typeof secretRef === "string" ? secretRef : "", typeof baseUrl === "string" ? baseUrl : "", jsonValue(projection), numberValue(version, 1)]); }
for (const raw of arrayEntries(source.channelGroups)) { const item = record(raw); if (!item.id) continue; await client.query("INSERT INTO channel_groups(id, version, enabled, channel_order) VALUES ($1, $2, $3, $4)", [String(item.id), numberValue(item.version, 1), item.enabled !== false, jsonValue({ name: item.name, capabilities: item.capabilities || [], channelIds: item.channelIds || [], channelPriorities: item.channelPriorities || {}, retryBudget: item.retryBudget })]); } for (const raw of arrayEntries(source.channelGroups)) { const item = record(raw); if (!item.id) continue; const { id: _id, version, enabled, ...projection } = item; await client.query("INSERT INTO channel_groups(id, version, enabled, channel_order) VALUES ($1, $2, $3, $4)", [String(item.id), numberValue(version, 1), enabled !== false, jsonValue(projection)]); }
const taskEntries = mapEntries(source.tasks); const taskIds = new Set<string>(); const taskEntries = mapEntries(source.tasks); const taskIds = new Set<string>();
for (const [key, raw] of taskEntries) { const item = record(raw); const id = uuid(key) || uuid(item.id); const userId = uuid(item.ownerId); if (!id || !userId) continue; taskIds.add(id); await client.query("INSERT INTO generation_tasks(id, user_id, task_type, status, idempotency_key, model_snapshot, route_snapshot, pricing_snapshot, task_snapshot, reserved_amount, state_version, event_sequence, reserve_expires_at, lease_expires_at, lease_token, updated_at, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11, $12, $13, $14, $15, $16)", [id, userId, String(item.taskType || "image"), String(item.status || "queued"), String(item.idempotencyKey || id), jsonValue(item.modelSnapshot), jsonValue(item.routeSnapshot), jsonValue(item.pricingSnapshot), jsonValue({ modelProductId: item.modelProductId, prompt: item.prompt, resolutionPresetId: item.resolutionPresetId, count: item.count, estimatedCost: item.estimatedCost, channelGroupId: item.channelGroupId, routeSnapshotVersion: record(item.routeSnapshot).version, retentionExpiresAt: item.retentionExpiresAt, retentionState: item.retentionState, retentionExtensionDays: item.retentionExtensionDays, reserveExpiresAt: item.reserveExpiresAt, inputSnapshot: item.inputSnapshot, planSnapshot: item.planSnapshot, requestFingerprint: item.requestFingerprint, providerSubmitted: item.providerSubmitted, publicErrorCode: item.publicErrorCode }), numberValue(item.reservedCost), numberValue(item.eventSequence, 0), item.reserveExpiresAt ? iso(item.reserveExpiresAt) : null, item.leaseExpiresAt ? iso(item.leaseExpiresAt) : null, item.leaseToken || null, iso(item.updatedAt, iso(item.createdAt)), iso(item.createdAt)]); for (const rawAttempt of arrayEntries(item.attempts)) { const attempt = record(rawAttempt); const attemptId = uuid(attempt.id); if (!attemptId || !attempt.channelId) continue; await client.query("INSERT INTO generation_attempts(id, task_id, channel_id, sequence, status, provider_request_id, error_code, platform_idempotency_key, reconciliation_status, lease_expires_at, started_at, finished_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", [attemptId, id, String(attempt.channelId), numberValue(attempt.sequence, 1), String(attempt.status || "started"), typeof attempt.providerRequestId === "string" ? attempt.providerRequestId : null, typeof attempt.errorCode === "string" ? attempt.errorCode : null, attempt.platformIdempotencyKey || null, attempt.reconciliationStatus || null, attempt.leaseExpiresAt ? iso(attempt.leaseExpiresAt) : null, iso(attempt.startedAt), attempt.finishedAt ? iso(attempt.finishedAt) : null]); } for (const rawOutput of arrayEntries(item.outputs)) { const output = record(rawOutput); const outputId = uuid(output.id); if (!outputId) continue; await client.query("INSERT INTO generation_outputs(id, task_id, owner_id, object_key, mime_type, status, expires_at, revoked_at, unit_price_snapshot, charged_amount, metadata, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", [outputId, id, userId, String(output.url || output.id), String(output.mimeType || "application/octet-stream"), String(output.status || "succeeded"), iso(item.retentionExpiresAt, new Date(Date.now() + 86_400_000).toISOString()), null, output.unitPriceSnapshot === undefined ? null : numberValue(output.unitPriceSnapshot), output.chargedAmount === undefined ? null : numberValue(output.chargedAmount), jsonValue(output.metadata), iso(item.createdAt)]); } } for (const [key, raw] of taskEntries) { const item = record(raw); const id = uuid(key) || uuid(item.id); const userId = uuid(item.ownerId); if (!id || !userId) continue; taskIds.add(id); const taskSnapshot = { ...item }; await client.query("INSERT INTO generation_tasks(id, user_id, task_type, status, idempotency_key, model_snapshot, route_snapshot, pricing_snapshot, task_snapshot, reserved_amount, state_version, event_sequence, reserve_expires_at, lease_expires_at, lease_token, updated_at, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11, $12, $13, $14, $15, $16)", [id, userId, String(item.taskType || "image"), String(item.status || "queued"), String(item.idempotencyKey || id), jsonValue(item.modelSnapshot), jsonValue(item.routeSnapshot), jsonValue(item.pricingSnapshot), jsonValue(taskSnapshot), numberValue(item.reservedCost), numberValue(item.eventSequence, 0), item.reserveExpiresAt ? iso(item.reserveExpiresAt) : null, item.leaseExpiresAt ? iso(item.leaseExpiresAt) : null, item.leaseToken || null, iso(item.updatedAt, iso(item.createdAt)), iso(item.createdAt)]); for (const rawAttempt of arrayEntries(item.attempts)) { const attempt = record(rawAttempt); const attemptId = uuid(attempt.id); if (!attemptId || !attempt.channelId) continue; await client.query("INSERT INTO generation_attempts(id, task_id, channel_id, sequence, status, provider_request_id, error_code, platform_idempotency_key, reconciliation_status, lease_expires_at, started_at, finished_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", [attemptId, id, String(attempt.channelId), numberValue(attempt.sequence, 1), String(attempt.status || "started"), typeof attempt.providerRequestId === "string" ? attempt.providerRequestId : null, typeof attempt.errorCode === "string" ? attempt.errorCode : null, attempt.platformIdempotencyKey || null, attempt.reconciliationStatus || null, attempt.leaseExpiresAt ? iso(attempt.leaseExpiresAt) : null, iso(attempt.startedAt), attempt.finishedAt ? iso(attempt.finishedAt) : null]); } for (const rawOutput of arrayEntries(item.outputs)) { const output = record(rawOutput); const outputId = uuid(output.id); if (!outputId || !userId) continue; await client.query("INSERT INTO generation_outputs(id, task_id, owner_id, object_key, mime_type, status, expires_at, revoked_at, unit_price_snapshot, charged_amount, metadata, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", [outputId, id, userId, String(output.url || output.id), String(output.mimeType || "application/octet-stream"), String(output.status || "succeeded"), iso(item.retentionExpiresAt, new Date(Date.now() + 86_400_000).toISOString()), null, output.unitPriceSnapshot === undefined ? null : numberValue(output.unitPriceSnapshot), output.chargedAmount === undefined ? null : numberValue(output.chargedAmount), jsonValue(output.metadata), iso(item.createdAt)]); } }
for (const [key, raw] of mapEntries(source.taskDispatchOutbox)) { const item = record(raw); const taskId = uuid(key) || uuid(item.taskId); if (!taskId || !taskIds.has(taskId)) continue; await client.query("INSERT INTO task_dispatch_outbox(task_id, status, available_at, attempts, lease_owner, lease_expires_at, last_error, dispatched_at, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (task_id) DO UPDATE SET status = EXCLUDED.status, available_at = EXCLUDED.available_at, attempts = EXCLUDED.attempts, lease_owner = EXCLUDED.lease_owner, lease_expires_at = EXCLUDED.lease_expires_at, last_error = EXCLUDED.last_error, dispatched_at = EXCLUDED.dispatched_at", [taskId, String(item.status || "pending"), iso(item.availableAt), numberValue(item.attempts), item.leaseOwner || null, item.leaseExpiresAt ? iso(item.leaseExpiresAt) : null, item.lastError || null, item.dispatchedAt ? iso(item.dispatchedAt) : null, iso(item.createdAt)]); } for (const [key, raw] of mapEntries(source.taskDispatchOutbox)) { const item = record(raw); const taskId = uuid(key) || uuid(item.taskId); if (!taskId || !taskIds.has(taskId)) continue; await client.query("INSERT INTO task_dispatch_outbox(task_id, status, available_at, attempts, lease_owner, lease_expires_at, last_error, dispatched_at, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (task_id) DO UPDATE SET status = EXCLUDED.status, available_at = EXCLUDED.available_at, attempts = EXCLUDED.attempts, lease_owner = EXCLUDED.lease_owner, lease_expires_at = EXCLUDED.lease_expires_at, last_error = EXCLUDED.last_error, dispatched_at = EXCLUDED.dispatched_at", [taskId, String(item.status || "pending"), iso(item.availableAt), numberValue(item.attempts), item.leaseOwner || null, item.leaseExpiresAt ? iso(item.leaseExpiresAt) : null, item.lastError || null, item.dispatchedAt ? iso(item.dispatchedAt) : null, iso(item.createdAt)]); }
const events = mapEntries(source.events); for (const [taskKey, rawEvents] of events) { const taskId = uuid(taskKey); if (!taskId || !taskIds.has(taskId)) continue; for (const rawEvent of arrayEntries(rawEvents)) { const event = record(rawEvent); const eventId = uuid(event.eventId); if (!eventId) continue; await client.query("INSERT INTO task_event_log(task_id, sequence, event_id, event_type, payload, occurred_at) VALUES ($1, $2, $3, $4, $5, $6)", [taskId, numberValue(event.sequence, 1), eventId, String(event.type || "task.progress"), jsonValue(event.payload), iso(event.occurredAt)]); } } const events = mapEntries(source.events); for (const [taskKey, rawEvents] of events) { const taskId = uuid(taskKey); if (!taskId || !taskIds.has(taskId)) continue; for (const rawEvent of arrayEntries(rawEvents)) { const event = record(rawEvent); const eventId = uuid(event.eventId); if (!eventId) continue; await client.query("INSERT INTO task_event_log(task_id, sequence, event_id, event_type, payload, occurred_at) VALUES ($1, $2, $3, $4, $5, $6)", [taskId, numberValue(event.sequence, 1), eventId, String(event.type || "task.progress"), jsonValue(event.payload), iso(event.occurredAt)]); } }
const plans = arrayEntries(source.plans); for (const raw of plans) { const item = record(raw); if (!item.id || !item.code) continue; await client.query("INSERT INTO membership_plans(id, code, version, snapshot, published) VALUES ($1, $2, $3, $4, $5)", [String(item.id), String(item.code), numberValue(item.version, 1), jsonValue(item), item.published === true]); } const plans = arrayEntries(source.plans); for (const raw of plans) { const item = record(raw); if (!item.id || !item.code) continue; await client.query("INSERT INTO membership_plans(id, code, version, snapshot, published) VALUES ($1, $2, $3, $4, $5)", [String(item.id), String(item.code), numberValue(item.version, 1), jsonValue(item), item.published === true]); }
@@ -612,7 +612,7 @@ function mergeSnapshots(base: Record<string, unknown> | undefined, canonical: Sn
for (const root of ["users", "sessions", "balances", "buckets", "tasks", "planPurchases", "entitlements", "reserveAllocations", "rechargeOrders", "paymentEvents", "uploads", "assets", "messageOutbox", "webdav", "webdavFiles", "webdavJobs", "captchaChallenges", "adminAccounts"]) mergeMapRoot(base, output, root); for (const root of ["users", "sessions", "balances", "buckets", "tasks", "planPurchases", "entitlements", "reserveAllocations", "rechargeOrders", "paymentEvents", "uploads", "assets", "messageOutbox", "webdav", "webdavFiles", "webdavJobs", "captchaChallenges", "adminAccounts"]) mergeMapRoot(base, output, root);
for (const root of ["products", "channels", "channelGroups", "plans", "ledger", "audit"]) mergeArrayRoot(base, output, root); for (const root of ["products", "channels", "channelGroups", "plans", "ledger", "audit"]) mergeArrayRoot(base, output, root);
for (const [key, value] of Object.entries(base || {})) if (output[key] === undefined) output[key] = value; for (const [key, value] of Object.entries(base || {})) if (output[key] === undefined) output[key] = value;
output.snapshotVersion = 1; output.snapshotVersion = DOMAIN_SNAPSHOT_VERSION;
return output; return output;
} }
@@ -637,8 +637,8 @@ async function loadCanonicalSnapshot(client: PgClient): Promise<SnapshotRecord |
const buckets = await client.query("SELECT id, user_id, source, remaining, priority, expires_at FROM balance_buckets"); if (buckets.rows.length) { const grouped = new Map<string, unknown[]>(); for (const row of buckets.rows) { const key = String(row.user_id); const list = grouped.get(key) || []; list.push({ id: String(row.id), userId: key, source: row.source, remaining: Number(row.remaining), priority: Number(row.priority), expiresAt: row.expires_at ? new Date(String(row.expires_at)).toISOString() : undefined }); grouped.set(key, list); } output.buckets = [...grouped.entries()]; } const buckets = await client.query("SELECT id, user_id, source, remaining, priority, expires_at FROM balance_buckets"); if (buckets.rows.length) { const grouped = new Map<string, unknown[]>(); for (const row of buckets.rows) { const key = String(row.user_id); const list = grouped.get(key) || []; list.push({ id: String(row.id), userId: key, source: row.source, remaining: Number(row.remaining), priority: Number(row.priority), expiresAt: row.expires_at ? new Date(String(row.expires_at)).toISOString() : undefined }); grouped.set(key, list); } output.buckets = [...grouped.entries()]; }
const ledger = await client.query("SELECT id, user_id, type, amount, unit_version, idempotency_key, reference_id, task_id, output_id, bucket_id, expires_at, status, created_at FROM balance_ledger ORDER BY created_at, id"); if (ledger.rows.length) output.ledger = ledger.rows.map((row) => ({ id: String(row.id), userId: String(row.user_id), type: row.type, amount: Number(row.amount), balanceUnitVersion: Number(row.unit_version), idempotencyKey: row.idempotency_key, referenceId: row.reference_id || undefined, taskId: row.task_id || undefined, outputId: row.output_id || undefined, bucketId: row.bucket_id || undefined, expiresAt: row.expires_at ? new Date(String(row.expires_at)).toISOString() : undefined, createdAt: new Date(String(row.created_at)).toISOString(), status: row.status || "posted" })); const ledger = await client.query("SELECT id, user_id, type, amount, unit_version, idempotency_key, reference_id, task_id, output_id, bucket_id, expires_at, status, created_at FROM balance_ledger ORDER BY created_at, id"); if (ledger.rows.length) output.ledger = ledger.rows.map((row) => ({ id: String(row.id), userId: String(row.user_id), type: row.type, amount: Number(row.amount), balanceUnitVersion: Number(row.unit_version), idempotencyKey: row.idempotency_key, referenceId: row.reference_id || undefined, taskId: row.task_id || undefined, outputId: row.output_id || undefined, bucketId: row.bucket_id || undefined, expiresAt: row.expires_at ? new Date(String(row.expires_at)).toISOString() : undefined, createdAt: new Date(String(row.created_at)).toISOString(), status: row.status || "posted" }));
const products = await client.query("SELECT id, snapshot FROM model_products ORDER BY id"); if (products.rows.length) output.products = products.rows.map((row) => parseJson(row.snapshot, { id: String(row.id) })); const products = await client.query("SELECT id, snapshot FROM model_products ORDER BY id"); if (products.rows.length) output.products = products.rows.map((row) => parseJson(row.snapshot, { id: String(row.id) }));
const channels = await client.query("SELECT id, status, encrypted_secret_ref, base_url, health, version FROM provider_channels ORDER BY id"); if (channels.rows.length) output.channels = channels.rows.map((row) => { const health = record(parseJson(row.health)); return { id: String(row.id), label: String(health.label || row.id), enabled: health.enabled !== false, priority: Number(health.priority || 0), health: row.status, providerType: health.providerType, providerName: health.providerName, providerModelId: health.providerModelId, enabledModelIds: Array.isArray(health.enabledModelIds) ? health.enabledModelIds : undefined, modelMappings: Array.isArray(health.modelMappings) ? health.modelMappings : undefined, resolutionModelMap: health.resolutionModelMap, baseUrl: row.base_url || undefined, secretRef: row.encrypted_secret_ref || undefined, version: Number(row.version), retryBudget: Number(health.retryBudget || 0), fixtureFailure: health.fixtureFailure, consecutiveFailures: Number(health.consecutiveFailures || 0), circuitOpenedAt: health.circuitOpenedAt, circuitResetAt: health.circuitResetAt }; }); const channels = await client.query("SELECT id, status, encrypted_secret_ref, base_url, health, version FROM provider_channels ORDER BY id"); if (channels.rows.length) output.channels = channels.rows.map((row) => ({ ...record(parseJson(row.health)), id: String(row.id), health: row.status, baseUrl: row.base_url || undefined, secretRef: row.encrypted_secret_ref || undefined, version: Number(row.version) }));
const groups = await client.query("SELECT id, version, enabled, channel_order FROM channel_groups ORDER BY id"); if (groups.rows.length) output.channelGroups = groups.rows.map((row) => { const data = record(parseJson(row.channel_order, {})); return { id: String(row.id), version: Number(row.version), name: typeof data.name === "string" && data.name ? data.name : undefined, capabilities: arrayEntries(data.capabilities), enabled: row.enabled !== false, channelIds: arrayEntries(data.channelIds), channelPriorities: record(data.channelPriorities) as Record<string, number>, retryBudget: Number(data.retryBudget || 0) }; }); const groups = await client.query("SELECT id, version, enabled, channel_order FROM channel_groups ORDER BY id"); if (groups.rows.length) output.channelGroups = groups.rows.map((row) => ({ ...record(parseJson(row.channel_order, {})), id: String(row.id), version: Number(row.version), enabled: row.enabled !== false }));
const plans = await client.query("SELECT id, snapshot FROM membership_plans ORDER BY id"); if (plans.rows.length) output.plans = plans.rows.map((row) => parseJson(row.snapshot, { id: String(row.id) })); const plans = await client.query("SELECT id, snapshot FROM membership_plans ORDER BY id"); if (plans.rows.length) output.plans = plans.rows.map((row) => parseJson(row.snapshot, { id: String(row.id) }));
const tasks = await client.query("SELECT id, user_id, task_type, status, idempotency_key, model_snapshot, route_snapshot, pricing_snapshot, task_snapshot, reserved_amount, state_version, event_sequence, reserve_expires_at, lease_expires_at, lease_token, updated_at, created_at FROM generation_tasks ORDER BY created_at, id"); if (tasks.rows.length) { const attempts = await client.query("SELECT task_id, id, channel_id, sequence, status, provider_request_id, error_code, platform_idempotency_key, reconciliation_status, lease_expires_at, started_at, finished_at FROM generation_attempts ORDER BY task_id, sequence"); const outputs = await client.query("SELECT id, task_id, owner_id, object_key, mime_type, status, expires_at, revoked_at, unit_price_snapshot, charged_amount FROM generation_outputs ORDER BY task_id, created_at, id"); const events = await client.query("SELECT task_id, sequence, event_id, event_type, payload, occurred_at FROM task_event_log ORDER BY task_id, sequence"); const attemptMap = new Map<string, unknown[]>(); for (const row of attempts.rows) { const key = String(row.task_id); const list = attemptMap.get(key) || []; list.push({ id: String(row.id), channelId: row.channel_id, sequence: Number(row.sequence), status: row.status, providerRequestId: row.provider_request_id || undefined, platformIdempotencyKey: row.platform_idempotency_key || undefined, reconciliationStatus: row.reconciliation_status || undefined, leaseExpiresAt: row.lease_expires_at ? new Date(String(row.lease_expires_at)).toISOString() : undefined, errorCode: row.error_code || undefined, startedAt: new Date(String(row.started_at)).toISOString(), finishedAt: row.finished_at ? new Date(String(row.finished_at)).toISOString() : undefined }); attemptMap.set(key, list); } const outputMap = new Map<string, unknown[]>(); for (const row of outputs.rows) { const key = String(row.task_id); const list = outputMap.get(key) || []; list.push({ id: String(row.id), mimeType: row.mime_type, status: row.status, url: row.object_key, unitPriceSnapshot: row.unit_price_snapshot === null ? undefined : Number(row.unit_price_snapshot), chargedAmount: row.charged_amount === null ? undefined : Number(row.charged_amount) }); outputMap.set(key, list); } const eventMap = new Map<string, unknown[]>(); for (const row of events.rows) { const key = String(row.task_id); const list = eventMap.get(key) || []; list.push({ eventId: String(row.event_id), taskId: key, sequence: Number(row.sequence), type: row.event_type, payload: parseJson(row.payload), occurredAt: new Date(String(row.occurred_at)).toISOString() }); eventMap.set(key, list); } output.tasks = tasks.rows.map((row) => { const id = String(row.id); const taskSnapshot = record(parseJson(row.task_snapshot, {})); return [id, { ...taskSnapshot, id, ownerId: String(row.user_id), taskType: row.task_type, status: row.status, idempotencyKey: row.idempotency_key, reservedCost: Number(row.reserved_amount), eventSequence: Number(row.event_sequence ?? row.state_version), modelSnapshot: parseJson(row.model_snapshot), routeSnapshot: parseJson(row.route_snapshot), pricingSnapshot: parseJson(row.pricing_snapshot), reserveExpiresAt: row.reserve_expires_at ? new Date(String(row.reserve_expires_at)).toISOString() : taskSnapshot.reserveExpiresAt, leaseExpiresAt: row.lease_expires_at ? new Date(String(row.lease_expires_at)).toISOString() : undefined, leaseToken: row.lease_token || undefined, attempts: attemptMap.get(id) || [], outputs: outputMap.get(id) || [], createdAt: new Date(String(row.created_at)).toISOString(), updatedAt: new Date(String(row.updated_at || row.created_at)).toISOString() }]; }); output.events = [...eventMap.entries()]; const taskEntries = mapEntries(output.tasks); output.idempotency = taskEntries.map(([, raw]) => { const item = record(raw); return [`${item.ownerId}:${item.idempotencyKey}`, raw]; }); output.idempotencyBodies = taskEntries.flatMap(([, raw]) => { const item = record(raw); return item.idempotencyKey && item.requestFingerprint ? [[`${item.ownerId}:${item.idempotencyKey}`, item.requestFingerprint]] : []; }); } const tasks = await client.query("SELECT id, user_id, task_type, status, idempotency_key, model_snapshot, route_snapshot, pricing_snapshot, task_snapshot, reserved_amount, state_version, event_sequence, reserve_expires_at, lease_expires_at, lease_token, updated_at, created_at FROM generation_tasks ORDER BY created_at, id"); if (tasks.rows.length) { const attempts = await client.query("SELECT task_id, id, channel_id, sequence, status, provider_request_id, error_code, platform_idempotency_key, reconciliation_status, lease_expires_at, started_at, finished_at FROM generation_attempts ORDER BY task_id, sequence"); const outputs = await client.query("SELECT id, task_id, owner_id, object_key, mime_type, status, expires_at, revoked_at, unit_price_snapshot, charged_amount FROM generation_outputs ORDER BY task_id, created_at, id"); const events = await client.query("SELECT task_id, sequence, event_id, event_type, payload, occurred_at FROM task_event_log ORDER BY task_id, sequence"); const attemptMap = new Map<string, unknown[]>(); for (const row of attempts.rows) { const key = String(row.task_id); const list = attemptMap.get(key) || []; list.push({ id: String(row.id), channelId: row.channel_id, sequence: Number(row.sequence), status: row.status, providerRequestId: row.provider_request_id || undefined, platformIdempotencyKey: row.platform_idempotency_key || undefined, reconciliationStatus: row.reconciliation_status || undefined, leaseExpiresAt: row.lease_expires_at ? new Date(String(row.lease_expires_at)).toISOString() : undefined, errorCode: row.error_code || undefined, startedAt: new Date(String(row.started_at)).toISOString(), finishedAt: row.finished_at ? new Date(String(row.finished_at)).toISOString() : undefined }); attemptMap.set(key, list); } const outputMap = new Map<string, unknown[]>(); for (const row of outputs.rows) { const key = String(row.task_id); const list = outputMap.get(key) || []; list.push({ id: String(row.id), mimeType: row.mime_type, status: row.status, url: row.object_key, unitPriceSnapshot: row.unit_price_snapshot === null ? undefined : Number(row.unit_price_snapshot), chargedAmount: row.charged_amount === null ? undefined : Number(row.charged_amount) }); outputMap.set(key, list); } const eventMap = new Map<string, unknown[]>(); for (const row of events.rows) { const key = String(row.task_id); const list = eventMap.get(key) || []; list.push({ eventId: String(row.event_id), taskId: key, sequence: Number(row.sequence), type: row.event_type, payload: parseJson(row.payload), occurredAt: new Date(String(row.occurred_at)).toISOString() }); eventMap.set(key, list); } output.tasks = tasks.rows.map((row) => { const id = String(row.id); const taskSnapshot = record(parseJson(row.task_snapshot, {})); return [id, { ...taskSnapshot, id, ownerId: String(row.user_id), taskType: row.task_type, status: row.status, idempotencyKey: row.idempotency_key, reservedCost: Number(row.reserved_amount), eventSequence: Number(row.event_sequence ?? row.state_version), modelSnapshot: parseJson(row.model_snapshot), routeSnapshot: parseJson(row.route_snapshot), pricingSnapshot: parseJson(row.pricing_snapshot), reserveExpiresAt: row.reserve_expires_at ? new Date(String(row.reserve_expires_at)).toISOString() : taskSnapshot.reserveExpiresAt, leaseExpiresAt: row.lease_expires_at ? new Date(String(row.lease_expires_at)).toISOString() : undefined, leaseToken: row.lease_token || undefined, attempts: attemptMap.get(id) || [], outputs: outputMap.get(id) || [], createdAt: new Date(String(row.created_at)).toISOString(), updatedAt: new Date(String(row.updated_at || row.created_at)).toISOString() }]; }); output.events = [...eventMap.entries()]; const taskEntries = mapEntries(output.tasks); output.idempotency = taskEntries.map(([, raw]) => { const item = record(raw); return [`${item.ownerId}:${item.idempotencyKey}`, raw]; }); output.idempotencyBodies = taskEntries.flatMap(([, raw]) => { const item = record(raw); return item.idempotencyKey && item.requestFingerprint ? [[`${item.ownerId}:${item.idempotencyKey}`, item.requestFingerprint]] : []; }); }
const dispatch = await client.query("SELECT task_id, status, available_at, attempts, lease_owner, lease_expires_at, last_error, dispatched_at, created_at FROM task_dispatch_outbox ORDER BY created_at, task_id"); if (dispatch.rows.length) output.taskDispatchOutbox = dispatch.rows.map((row) => [String(row.task_id), { taskId: String(row.task_id), status: row.status, availableAt: new Date(String(row.available_at)).toISOString(), attempts: Number(row.attempts || 0), leaseOwner: row.lease_owner || undefined, leaseExpiresAt: row.lease_expires_at ? new Date(String(row.lease_expires_at)).toISOString() : undefined, lastError: row.last_error || undefined, dispatchedAt: row.dispatched_at ? new Date(String(row.dispatched_at)).toISOString() : undefined, createdAt: new Date(String(row.created_at)).toISOString() }]); const dispatch = await client.query("SELECT task_id, status, available_at, attempts, lease_owner, lease_expires_at, last_error, dispatched_at, created_at FROM task_dispatch_outbox ORDER BY created_at, task_id"); if (dispatch.rows.length) output.taskDispatchOutbox = dispatch.rows.map((row) => [String(row.task_id), { taskId: String(row.task_id), status: row.status, availableAt: new Date(String(row.available_at)).toISOString(), attempts: Number(row.attempts || 0), leaseOwner: row.lease_owner || undefined, leaseExpiresAt: row.lease_expires_at ? new Date(String(row.lease_expires_at)).toISOString() : undefined, lastError: row.last_error || undefined, dispatchedAt: row.dispatched_at ? new Date(String(row.dispatched_at)).toISOString() : undefined, createdAt: new Date(String(row.created_at)).toISOString() }]);
@@ -699,7 +699,7 @@ function inflateDomainRows(rows: Array<Record<string, unknown>>): Record<string,
else if (shape === "array") output[root] = values[0].entity_id === "__empty__" ? [] : values.filter((row) => row.entity_id !== "__empty__").sort((a, b) => String(a.entity_id).localeCompare(String(b.entity_id))).map((row) => row.payload); else if (shape === "array") output[root] = values[0].entity_id === "__empty__" ? [] : values.filter((row) => row.entity_id !== "__empty__").sort((a, b) => String(a.entity_id).localeCompare(String(b.entity_id))).map((row) => row.payload);
else output[root] = values.find((row) => row.entity_id === "__value__")?.payload; else output[root] = values.find((row) => row.entity_id === "__value__")?.payload;
} }
output.snapshotVersion = 1; output.snapshotVersion = DOMAIN_SNAPSHOT_VERSION;
return output; return output;
} }
+40 -30
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { ErrorCodes, type ErrorCode, type GenerationAttempt, type GenerationTask, type TaskType } from "@miragenflow/contracts"; import { ErrorCodes, type ErrorCode, type GenerationAttempt, type GenerationTask, type RouteSnapshotCandidate, type TaskType } from "@miragenflow/contracts";
import { appendEvent, releaseBalance, settleBalance, transactTask, type Store } from "../store.ts"; import { appendEvent, releaseBalance, settleBalance, transactTask, type Store } from "../store.ts";
import { invokeProvider, queryProvider } from "../adapters/provider.ts"; import { invokeProvider, queryProvider } from "../adapters/provider.ts";
import { putStagingObject } from "../infra/staging.ts"; import { putStagingObject } from "../infra/staging.ts";
@@ -13,6 +13,11 @@ function workerLeaseMs() {
return Number(process.env.MIRAGENFLOW_WORKER_LEASE_MS) > 0 ? Number(process.env.MIRAGENFLOW_WORKER_LEASE_MS) : DEFAULT_LEASE_MS; return Number(process.env.MIRAGENFLOW_WORKER_LEASE_MS) > 0 ? Number(process.env.MIRAGENFLOW_WORKER_LEASE_MS) : DEFAULT_LEASE_MS;
} }
function queueAttemptLimit(task: GenerationTask | undefined) {
const route = task?.routeSnapshot;
return Math.max(1, (route?.orderedCandidates.length || 0) + Math.max(0, route?.totalRetryBudget || 0));
}
async function taskForWorker(store: Store, taskId: string) { async function taskForWorker(store: Store, taskId: string) {
const local = store.tasks.get(taskId); const local = store.tasks.get(taskId);
if (local) return local; if (local) return local;
@@ -134,7 +139,7 @@ export function scheduleTask(store: Store, taskId: string) {
if (!store.queue.includes(taskId)) store.queue.push(taskId); if (!store.queue.includes(taskId)) store.queue.push(taskId);
const task = store.tasks.get(taskId); const task = store.tasks.get(taskId);
if (store.repository.adapter === "postgres" && store.repository.claimTaskDispatchOutbox) { void dispatchTaskOutbox(store); return; } if (store.repository.adapter === "postgres" && store.repository.claimTaskDispatchOutbox) { void dispatchTaskOutbox(store); return; }
void store.queueAdapter.enqueue(taskId, { maxAttempts: Math.max(1, task?.routeSnapshot?.channelIds.length || 4), priority: task?.planSnapshot?.queuePriority || 0 }).then(async () => { void store.queueAdapter.enqueue(taskId, { maxAttempts: queueAttemptLimit(task), priority: task?.planSnapshot?.queuePriority || 0 }).then(async () => {
const outbox = store.taskDispatchOutbox.get(taskId); const outbox = store.taskDispatchOutbox.get(taskId);
if (outbox) { outbox.status = "sent"; outbox.dispatchedAt = new Date().toISOString(); outbox.leaseOwner = undefined; outbox.leaseExpiresAt = undefined; store.persist(); } if (outbox) { outbox.status = "sent"; outbox.dispatchedAt = new Date().toISOString(); outbox.leaseOwner = undefined; outbox.leaseExpiresAt = undefined; store.persist(); }
const lease = await store.queueAdapter.claim(workerId, workerLeaseMs()); const lease = await store.queueAdapter.claim(workerId, workerLeaseMs());
@@ -142,7 +147,7 @@ export function scheduleTask(store: Store, taskId: string) {
// A schedule call may race another worker that already has a queue lease. // A schedule call may race another worker that already has a queue lease.
// Never execute or ack an unrelated task under this task's outbox context. // Never execute or ack an unrelated task under this task's outbox context.
if (lease.taskId !== taskId) { if (lease.taskId !== taskId) {
if (store.queueAdapter.nack) await store.queueAdapter.nack(lease.taskId, lease.leaseToken, { retry: true, maxAttempts: Math.max(1, store.tasks.get(lease.taskId)?.routeSnapshot?.channelIds.length || 4) }); if (store.queueAdapter.nack) await store.queueAdapter.nack(lease.taskId, lease.leaseToken, { retry: true, maxAttempts: queueAttemptLimit(store.tasks.get(lease.taskId)) });
return; return;
} }
try { try {
@@ -151,7 +156,7 @@ export function scheduleTask(store: Store, taskId: string) {
await store.queueAdapter.ack(lease.taskId, lease.leaseToken); await store.queueAdapter.ack(lease.taskId, lease.leaseToken);
} catch (error) { } catch (error) {
console.error("task execution failed", { taskId: lease.taskId, error: error instanceof Error ? error.message : String(error) }); console.error("task execution failed", { taskId: lease.taskId, error: error instanceof Error ? error.message : String(error) });
if (store.queueAdapter.nack) await store.queueAdapter.nack(lease.taskId, lease.leaseToken, { retry: true, maxAttempts: Math.max(1, task?.routeSnapshot?.channelIds.length || 4) }); if (store.queueAdapter.nack) await store.queueAdapter.nack(lease.taskId, lease.leaseToken, { retry: true, maxAttempts: queueAttemptLimit(task) });
else await store.queueAdapter.ack(lease.taskId, lease.leaseToken); else await store.queueAdapter.ack(lease.taskId, lease.leaseToken);
} }
}).catch((error) => { }).catch((error) => {
@@ -190,7 +195,7 @@ export async function dispatchTaskOutbox(store: Store) {
// The outbox dispatcher only publishes a uniquely identified task. It // The outbox dispatcher only publishes a uniquely identified task. It
// must not claim an arbitrary queue member here: queue execution is a // must not claim an arbitrary queue member here: queue execution is a
// separate worker concern and cannot be paired with this outbox row. // separate worker concern and cannot be paired with this outbox row.
await store.queueAdapter.enqueue(taskId, { maxAttempts: Math.max(1, task.routeSnapshot?.channelIds.length || 4), priority: task.planSnapshot?.queuePriority || 0 }); await store.queueAdapter.enqueue(taskId, { maxAttempts: queueAttemptLimit(task), priority: task.planSnapshot?.queuePriority || 0 });
const completed = await complete.call(store.repository, taskId, worker, true); const completed = await complete.call(store.repository, taskId, worker, true);
if (!completed) continue; if (!completed) continue;
const outbox = store.taskDispatchOutbox.get(taskId); const outbox = store.taskDispatchOutbox.get(taskId);
@@ -222,7 +227,7 @@ export async function dispatchTaskOutbox(store: Store) {
await runTask(store, lease.taskId, lease.leaseToken); await runTask(store, lease.taskId, lease.leaseToken);
await store.queueAdapter.ack(lease.taskId, lease.leaseToken); await store.queueAdapter.ack(lease.taskId, lease.leaseToken);
} catch { } catch {
if (store.queueAdapter.nack) await store.queueAdapter.nack(lease.taskId, lease.leaseToken, { retry: true, maxAttempts: Math.max(1, store.tasks.get(lease.taskId)?.routeSnapshot?.channelIds.length || 4) }); if (store.queueAdapter.nack) await store.queueAdapter.nack(lease.taskId, lease.leaseToken, { retry: true, maxAttempts: queueAttemptLimit(store.tasks.get(lease.taskId)) });
else await store.queueAdapter.ack(lease.taskId, lease.leaseToken); else await store.queueAdapter.ack(lease.taskId, lease.leaseToken);
} }
})); }));
@@ -236,13 +241,13 @@ export async function resumeQueuedTasks(store: Store) {
for (const taskId of queued) { for (const taskId of queued) {
if (!store.queue.includes(taskId)) store.queue.push(taskId); if (!store.queue.includes(taskId)) store.queue.push(taskId);
try { try {
await store.queueAdapter.enqueue(taskId, { maxAttempts: Math.max(1, store.tasks.get(taskId)?.routeSnapshot?.channelIds.length || 4), priority: store.tasks.get(taskId)?.planSnapshot?.queuePriority || 0 }); await store.queueAdapter.enqueue(taskId, { maxAttempts: queueAttemptLimit(store.tasks.get(taskId)), priority: store.tasks.get(taskId)?.planSnapshot?.queuePriority || 0 });
const outbox = store.taskDispatchOutbox.get(taskId); const outbox = store.taskDispatchOutbox.get(taskId);
if (outbox) { outbox.status = "sent"; outbox.dispatchedAt = new Date().toISOString(); store.persist(); } if (outbox) { outbox.status = "sent"; outbox.dispatchedAt = new Date().toISOString(); store.persist(); }
const lease = await store.queueAdapter.claim(workerId, workerLeaseMs()); const lease = await store.queueAdapter.claim(workerId, workerLeaseMs());
if (!lease) continue; if (!lease) continue;
if (lease.taskId !== taskId) { if (lease.taskId !== taskId) {
if (store.queueAdapter.nack) await store.queueAdapter.nack(lease.taskId, lease.leaseToken, { retry: true, maxAttempts: Math.max(1, store.tasks.get(lease.taskId)?.routeSnapshot?.channelIds.length || 4) }); if (store.queueAdapter.nack) await store.queueAdapter.nack(lease.taskId, lease.leaseToken, { retry: true, maxAttempts: queueAttemptLimit(store.tasks.get(lease.taskId)) });
continue; continue;
} }
try { try {
@@ -251,7 +256,7 @@ export async function resumeQueuedTasks(store: Store) {
await store.queueAdapter.ack(lease.taskId, lease.leaseToken); await store.queueAdapter.ack(lease.taskId, lease.leaseToken);
} catch (error) { } catch (error) {
console.error("queued task execution failed", { taskId: lease.taskId, error: error instanceof Error ? error.message : String(error) }); console.error("queued task execution failed", { taskId: lease.taskId, error: error instanceof Error ? error.message : String(error) });
if (store.queueAdapter.nack) await store.queueAdapter.nack(lease.taskId, lease.leaseToken, { retry: true, maxAttempts: Math.max(1, store.tasks.get(taskId)?.routeSnapshot?.channelIds.length || 4) }); if (store.queueAdapter.nack) await store.queueAdapter.nack(lease.taskId, lease.leaseToken, { retry: true, maxAttempts: queueAttemptLimit(store.tasks.get(taskId)) });
else await store.queueAdapter.ack(lease.taskId, lease.leaseToken); else await store.queueAdapter.ack(lease.taskId, lease.leaseToken);
} }
} catch (error) { } catch (error) {
@@ -417,27 +422,32 @@ async function runTaskWithLease(store: Store, taskId: string, leaseToken: string
current.status = "running"; current.status = "running";
current.leaseToken = leaseToken; current.leaseToken = leaseToken;
current.leaseExpiresAt = new Date(Date.now() + leaseMs).toISOString(); current.leaseExpiresAt = new Date(Date.now() + leaseMs).toISOString();
appendEvent(atomicStore, current.id, { taskId: current.id, type: "task.running", payload: { channelGroupId: current.channelGroupId, workerId, leaseExpiresAt: current.leaseExpiresAt } }); appendEvent(atomicStore, current.id, { taskId: current.id, type: "task.running", payload: { groupId: current.routeSnapshot?.groupId, workerId, leaseExpiresAt: current.leaseExpiresAt } });
return true; return true;
}); });
if (!started) return; if (!started) return;
const task = store.tasks.get(taskId); const task = store.tasks.get(taskId);
if (!task) return; if (!task) return;
const group = store.channelGroups.find((item) => item.id === task.channelGroupId); const routeCandidates = task.routeSnapshot?.orderedCandidates || [];
const routeIds = task.routeSnapshot?.channelIds || group?.channelIds || [];
const now = Date.now(); const now = Date.now();
const channels = routeIds.map((id) => store.channels.find((channel) => channel.id === id)).filter((channel): channel is NonNullable<typeof channel> => { const channels: Array<{ candidate: RouteSnapshotCandidate; channel: Store["channels"][number] }> = [];
if (!channel?.enabled) return false; for (const candidate of routeCandidates) {
if (channel.health !== "open") return true; const channel = store.channels.find((item) => item.id === candidate.channelId);
if (channel.circuitResetAt && channel.circuitResetAt <= now) { channel.health = "degraded"; channel.circuitOpenedAt = undefined; channel.circuitResetAt = undefined; return true; } if (!channel?.enabled) continue;
return false; if (channel.health === "open") {
}); if (!channel.circuitResetAt || channel.circuitResetAt > now) continue;
channel.health = "degraded";
channel.circuitOpenedAt = undefined;
channel.circuitResetAt = undefined;
}
channels.push({ candidate, channel });
}
const attemptPlan: typeof channels = []; const attemptPlan: typeof channels = [];
let groupRetries = Math.max(0, task.routeSnapshot?.retryBudget ?? group?.retryBudget ?? 0); let groupRetries = Math.max(0, task.routeSnapshot?.totalRetryBudget ?? 0);
for (const channel of channels) { for (const item of channels) {
attemptPlan.push(channel); attemptPlan.push(item);
const retries = Math.min(Math.max(0, task.routeSnapshot?.channelRetryBudgets?.[channel.id] ?? channel.retryBudget), groupRetries); const retries = Math.min(Math.max(0, item.candidate.retryBudget), groupRetries);
for (let retry = 0; retry < retries; retry += 1) attemptPlan.push(channel); for (let retry = 0; retry < retries; retry += 1) attemptPlan.push(item);
groupRetries -= retries; groupRetries -= retries;
} }
const maxAttempts = attemptPlan.length; const maxAttempts = attemptPlan.length;
@@ -448,10 +458,10 @@ async function runTaskWithLease(store: Store, taskId: string, leaseToken: string
const failures = fixtureFailures(); const failures = fixtureFailures();
for (let index = 0; index < maxAttempts; index += 1) { for (let index = 0; index < maxAttempts; index += 1) {
if (!ownsLease()) return; if (!ownsLease()) return;
const channel = attemptPlan[index]; const { channel, candidate } = attemptPlan[index];
if (channel.health === "open" && (!channel.circuitResetAt || channel.circuitResetAt > Date.now())) continue; if (channel.health === "open" && (!channel.circuitResetAt || channel.circuitResetAt > Date.now())) continue;
const attemptStartedAt = new Date().toISOString(); const attemptStartedAt = new Date().toISOString();
if (index > 0 && attemptPlan[index - 1]?.id === channel.id) await new Promise((resolve) => setTimeout(resolve, Math.min(2_000, 100 * 2 ** Math.min(4, index - 1)) + Math.floor(Math.random() * 50))); if (index > 0 && attemptPlan[index - 1]?.channel.id === channel.id) await new Promise((resolve) => setTimeout(resolve, Math.min(2_000, 100 * 2 ** Math.min(4, index - 1)) + Math.floor(Math.random() * 50)));
const attempt: GenerationAttempt = { id: randomUUID(), channelId: channel.id, sequence: index + 1, status: "started", platformIdempotencyKey: `${task.id}:${index + 1}`, startedAt: attemptStartedAt, leaseExpiresAt: new Date(Date.now() + leaseMs).toISOString() }; const attempt: GenerationAttempt = { id: randomUUID(), channelId: channel.id, sequence: index + 1, status: "started", platformIdempotencyKey: `${task.id}:${index + 1}`, startedAt: attemptStartedAt, leaseExpiresAt: new Date(Date.now() + leaseMs).toISOString() };
task.attempts.push(attempt); task.attempts.push(attempt);
appendEvent(store, task.id, { taskId: task.id, attemptId: attempt.id, type: "task.attempt", payload: { channelId: channel.id, sequence: attempt.sequence, status: attempt.status } }); appendEvent(store, task.id, { taskId: task.id, attemptId: attempt.id, type: "task.attempt", payload: { channelId: channel.id, sequence: attempt.sequence, status: attempt.status } });
@@ -459,7 +469,7 @@ async function runTaskWithLease(store: Store, taskId: string, leaseToken: string
if (!ownsLease()) return; if (!ownsLease()) return;
if (channel.baseUrl) { if (channel.baseUrl) {
task.providerSubmitted = true; task.providerSubmitted = true;
const provider = await invokeProvider(store, channel, { taskId: task.id, platformIdempotencyKey: attempt.platformIdempotencyKey, taskType: task.taskType, publicModelId: task.modelSnapshot?.publicModelId || task.modelProductId, prompt: task.prompt, count: task.count, references: task.inputSnapshot?.references, referenceImages: task.inputSnapshot?.referenceGroup?.images, maskObjectId: task.inputSnapshot?.maskObjectId, params: task.inputSnapshot?.params }); const provider = await invokeProvider(store, channel, { taskId: task.id, platformIdempotencyKey: attempt.platformIdempotencyKey, taskType: task.taskType, publicModelId: task.modelSnapshot?.publicModelId || task.modelProductId, requestModelId: candidate.requestModelId, prompt: task.prompt, count: task.count, references: task.inputSnapshot?.references, referenceImages: task.inputSnapshot?.referenceGroup?.images, maskObjectId: task.inputSnapshot?.maskObjectId, params: task.inputSnapshot?.params });
if (!ownsLease()) return; if (!ownsLease()) return;
if (provider.status === "unknown") { if (provider.status === "unknown") {
attempt.status = "unknown"; attempt.providerRequestId = provider.providerRequestId; attempt.errorCode = provider.errorCode; attempt.finishedAt = new Date().toISOString(); attempt.reconciliationStatus = "pending"; task.status = "unknown"; task.publicErrorCode = ErrorCodes.ModelGroupUnavailable; task.leaseExpiresAt = undefined; task.leaseToken = undefined; appendEvent(store, task.id, { taskId: task.id, attemptId: attempt.id, type: "task.unknown", payload: { channelId: channel.id, reconciliationRequired: true, providerRequestIdAvailable: Boolean(provider.providerRequestId) } }); return; attempt.status = "unknown"; attempt.providerRequestId = provider.providerRequestId; attempt.errorCode = provider.errorCode; attempt.finishedAt = new Date().toISOString(); attempt.reconciliationStatus = "pending"; task.status = "unknown"; task.publicErrorCode = ErrorCodes.ModelGroupUnavailable; task.leaseExpiresAt = undefined; task.leaseToken = undefined; appendEvent(store, task.id, { taskId: task.id, attemptId: attempt.id, type: "task.unknown", payload: { channelId: channel.id, reconciliationRequired: true, providerRequestIdAvailable: Boolean(provider.providerRequestId) } }); return;
@@ -468,7 +478,7 @@ async function runTaskWithLease(store: Store, taskId: string, leaseToken: string
attempt.status = "failed"; attempt.errorCode = "CANCELED_AFTER_PROVIDER_SUBMIT"; attempt.finishedAt = new Date().toISOString(); attempt.reconciliationStatus = "confirmed_failure"; task.leaseExpiresAt = undefined; task.leaseToken = undefined; return; attempt.status = "failed"; attempt.errorCode = "CANCELED_AFTER_PROVIDER_SUBMIT"; attempt.finishedAt = new Date().toISOString(); attempt.reconciliationStatus = "confirmed_failure"; task.leaseExpiresAt = undefined; task.leaseToken = undefined; return;
} }
if (provider.status !== "succeeded") { if (provider.status !== "succeeded") {
attempt.status = "failed"; attempt.errorCode = provider.errorCode; attempt.retryable = provider.retryable === true; attempt.finishedAt = new Date().toISOString(); channel.consecutiveFailures += 1; if (channel.consecutiveFailures >= 3) { channel.health = "open"; channel.circuitOpenedAt = Date.now(); channel.circuitResetAt = Date.now() + 30_000; } else channel.health = provider.retryable || ["PROVIDER_AUTH", "PROVIDER_SECRET_UNAVAILABLE"].includes(provider.errorCode || "") ? "degraded" : channel.health; appendEvent(store, task.id, { taskId: task.id, attemptId: attempt.id, type: "task.progress", payload: { channelId: channel.id, status: attempt.status, errorCode: attempt.errorCode, nextChannel: attemptPlan[index + 1]?.id } }); if (provider.retryable !== true && !["PROVIDER_AUTH", "PROVIDER_SECRET_UNAVAILABLE"].includes(provider.errorCode || "")) break; continue; attempt.status = "failed"; attempt.errorCode = provider.errorCode; attempt.retryable = provider.retryable === true; attempt.finishedAt = new Date().toISOString(); channel.consecutiveFailures += 1; if (channel.consecutiveFailures >= 3) { channel.health = "open"; channel.circuitOpenedAt = Date.now(); channel.circuitResetAt = Date.now() + 60_000; } else channel.health = provider.retryable ? "degraded" : channel.health; appendEvent(store, task.id, { taskId: task.id, attemptId: attempt.id, type: "task.progress", payload: { channelId: channel.id, status: attempt.status, errorCode: attempt.errorCode, nextChannel: attemptPlan[index + 1]?.channel.id } }); if (provider.retryable !== true) break; continue;
} }
attempt.status = "succeeded"; channel.consecutiveFailures = 0; channel.health = "healthy"; attempt.providerRequestId = provider.providerRequestId; attempt.finishedAt = new Date().toISOString(); task.outputs = []; for (const providerOutput of (provider.outputs || []).slice(0, Math.max(1, task.count || 1))) { const outputId = randomUUID(); const staged = await putStagingObject(store.stagingDir, task.ownerId, Buffer.from(providerOutput.data, "base64"), providerOutput.mimeType.split("/")[1] || "bin"); if (!ownsLease()) return; const expiresAt = retentionExpiry(store, task); store.objects.set(outputId, { id: outputId, ownerId: task.ownerId, mimeType: providerOutput.mimeType, stagingKey: staged.key, taskId: task.id, expiresAt }); store.assets.set(outputId, { id: outputId, ownerId: task.ownerId, type: task.taskType === "audio" ? "audio" : task.taskType === "text" || task.taskType === "reverse-prompt" ? "text" : "image", objectId: outputId, taskId: task.id, name: `${task.taskType}-${outputId.slice(0, 8)}`, tags: [], retentionExpiresAt: expiresAt, pinned: false }); task.outputs.push({ id: outputId, mimeType: providerOutput.mimeType, url: `/api/v1/objects/${outputId}`, status: "succeeded", metadata: outputMetadataForTask(task, providerOutput.metadata) as GenerationTask["outputs"][number]["metadata"] }); } if (!task.outputs.length) { attempt.status = "failed"; attempt.errorCode = "PROVIDER_EMPTY_RESULT"; const released = await releaseTaskReservation(store, task.id, task.reservedCost, "failed", { code: "PROVIDER_EMPTY_RESULT" }, attempt.id); if (!released) markSettlementFailure(store, task, attempt.id); return; } const settlement = await settleOutputs(store, task, attempt.id); if (settlement.failed) { markSettlementFailure(store, task, attempt.id); return; } return; attempt.status = "succeeded"; channel.consecutiveFailures = 0; channel.health = "healthy"; attempt.providerRequestId = provider.providerRequestId; attempt.finishedAt = new Date().toISOString(); task.outputs = []; for (const providerOutput of (provider.outputs || []).slice(0, Math.max(1, task.count || 1))) { const outputId = randomUUID(); const staged = await putStagingObject(store.stagingDir, task.ownerId, Buffer.from(providerOutput.data, "base64"), providerOutput.mimeType.split("/")[1] || "bin"); if (!ownsLease()) return; const expiresAt = retentionExpiry(store, task); store.objects.set(outputId, { id: outputId, ownerId: task.ownerId, mimeType: providerOutput.mimeType, stagingKey: staged.key, taskId: task.id, expiresAt }); store.assets.set(outputId, { id: outputId, ownerId: task.ownerId, type: task.taskType === "audio" ? "audio" : task.taskType === "text" || task.taskType === "reverse-prompt" ? "text" : "image", objectId: outputId, taskId: task.id, name: `${task.taskType}-${outputId.slice(0, 8)}`, tags: [], retentionExpiresAt: expiresAt, pinned: false }); task.outputs.push({ id: outputId, mimeType: providerOutput.mimeType, url: `/api/v1/objects/${outputId}`, status: "succeeded", metadata: outputMetadataForTask(task, providerOutput.metadata) as GenerationTask["outputs"][number]["metadata"] }); } if (!task.outputs.length) { attempt.status = "failed"; attempt.errorCode = "PROVIDER_EMPTY_RESULT"; const released = await releaseTaskReservation(store, task.id, task.reservedCost, "failed", { code: "PROVIDER_EMPTY_RESULT" }, attempt.id); if (!released) markSettlementFailure(store, task, attempt.id); return; } const settlement = await settleOutputs(store, task, attempt.id); if (settlement.failed) { markSettlementFailure(store, task, attempt.id); return; } return;
} }
@@ -476,7 +486,7 @@ async function runTaskWithLease(store: Store, taskId: string, leaseToken: string
attempt.status = "failed"; attempt.status = "failed";
attempt.errorCode = "CHANNEL_NOT_CONFIGURED"; attempt.errorCode = "CHANNEL_NOT_CONFIGURED";
attempt.finishedAt = new Date().toISOString(); attempt.finishedAt = new Date().toISOString();
appendEvent(store, task.id, { taskId: task.id, attemptId: attempt.id, type: "task.progress", payload: { channelId: channel.id, status: attempt.status, errorCode: attempt.errorCode, nextChannel: attemptPlan[index + 1]?.id } }); appendEvent(store, task.id, { taskId: task.id, attemptId: attempt.id, type: "task.progress", payload: { channelId: channel.id, status: attempt.status, errorCode: attempt.errorCode, nextChannel: attemptPlan[index + 1]?.channel.id } });
continue; continue;
} }
if (failures.has(channel.id) || channel.fixtureFailure === "retryable") { if (failures.has(channel.id) || channel.fixtureFailure === "retryable") {
@@ -484,8 +494,8 @@ async function runTaskWithLease(store: Store, taskId: string, leaseToken: string
attempt.errorCode = "CHANNEL_UNAVAILABLE"; attempt.errorCode = "CHANNEL_UNAVAILABLE";
attempt.finishedAt = new Date().toISOString(); attempt.finishedAt = new Date().toISOString();
channel.consecutiveFailures += 1; channel.consecutiveFailures += 1;
if (channel.consecutiveFailures >= 3) { channel.health = "open"; channel.circuitOpenedAt = Date.now(); channel.circuitResetAt = Date.now() + 30_000; } else channel.health = "degraded"; if (channel.consecutiveFailures >= 3) { channel.health = "open"; channel.circuitOpenedAt = Date.now(); channel.circuitResetAt = Date.now() + 60_000; } else channel.health = "degraded";
appendEvent(store, task.id, { taskId: task.id, attemptId: attempt.id, type: "task.progress", payload: { channelId: channel.id, status: attempt.status, errorCode: attempt.errorCode, nextChannel: attemptPlan[index + 1]?.id } }); appendEvent(store, task.id, { taskId: task.id, attemptId: attempt.id, type: "task.progress", payload: { channelId: channel.id, status: attempt.status, errorCode: attempt.errorCode, nextChannel: attemptPlan[index + 1]?.channel.id } });
continue; continue;
} }
if (channel.fixtureFailure === "unknown") { if (channel.fixtureFailure === "unknown") {
+91 -23
View File
@@ -1,23 +1,46 @@
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname } from "node:path"; import { dirname } from "node:path";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { AssetRecord, BalanceBucket, BalanceLedgerEntry, BalanceSummary, BalanceUnitConfig, CapabilitySchema, GenerationTask, InternalModelProduct, MembershipPlan, TaskEvent, UploadObject } from "@miragenflow/contracts"; import { DOMAIN_SNAPSHOT_VERSION, type AssetRecord, type BalanceBucket, type BalanceLedgerEntry, type BalanceSummary, type BalanceUnitConfig, type CapabilitySchema, type ChannelHealthSample, type ChannelHealthStatus, type ChannelModel, type GenerationTask, type InternalModelProduct, type MembershipPlan, type ModelRoute, type PricingRule, type TaskEvent, type UploadObject } from "@miragenflow/contracts";
import { openAiImageCapabilityParameters } from "./image-options.ts"; import { openAiImageCapabilityParameters } from "./image-options.ts";
import { createPersistenceRepository, type PersistenceRepository, type RepositoryHealth } from "./infra/repository.ts"; import { createPersistenceRepository, type PersistenceRepository, type RepositoryHealth } from "./infra/repository.ts";
import { createTaskQueue, type QueueHealth, type TaskQueue } from "./infra/queue.ts"; import { createTaskQueue, type QueueHealth, type TaskQueue } from "./infra/queue.ts";
export type UserRecord = { id: string; version: number; username?: string; email?: string; phone?: string; displayName?: string; password: string; verified: boolean; status: "pending_verification" | "active" | "frozen" | "closing"; planId?: string; maxConcurrent?: number; dailyTaskLimit?: number; allowedModelProductIds?: string[]; passwordChangedAt?: string; mfaRequired: boolean; mfaEnabled: boolean; mfaSecretEncrypted?: string; mfaRecoveryCodeHashes?: string[]; mfaLastTotpCounter?: number; roles: string[]; failedLoginCount: number; lockedUntil?: number; createdAt: string }; export type UserRecord = { id: string; version: number; username?: string; email?: string; phone?: string; displayName?: string; password: string; verified: boolean; status: "pending_verification" | "active" | "frozen" | "closing"; planId?: string; maxConcurrent?: number; dailyTaskLimit?: number; allowedModelProductIds?: string[]; passwordChangedAt?: string; mfaRequired: boolean; mfaEnabled: boolean; mfaSecretEncrypted?: string; mfaRecoveryCodeHashes?: string[]; mfaLastTotpCounter?: number; roles: string[]; failedLoginCount: number; lockedUntil?: number; createdAt: string };
export type ProviderModelMapping = { displayModelId: string; requestModelId: string }; export type ProviderModelMapping = { displayModelId: string; requestModelId: string };
export type ProviderChannel = { id: string; label: string; enabled: boolean; priority: number; health: "healthy" | "degraded" | "open"; fixtureFailure?: "retryable" | "unknown"; providerType?: "generic" | "openai-images"; providerName?: string; providerModelId?: string; enabledModelIds?: string[]; modelMappings?: ProviderModelMapping[]; resolutionModelMap?: Record<string, string>; baseUrl?: string; secretRef?: string; version: number; retryBudget: number; consecutiveFailures: number; circuitOpenedAt?: number; circuitResetAt?: number; lastTestedAt?: string; lastTestHealthy?: boolean; lastTestRequestPath?: string; lastTestRequestAttempted?: boolean; lastTestError?: string }; export type ProviderChannel = {
id: string;
label: string;
enabled: boolean;
health: ChannelHealthStatus;
fixtureFailure?: "retryable" | "unknown";
providerType?: "generic" | "openai-images";
providerName?: string;
models: ChannelModel[];
modelMappings: ProviderModelMapping[];
timeoutMs: number;
baseUrl?: string;
secretRef?: string;
version: number;
consecutiveFailures: number;
circuitOpenedAt?: number;
circuitResetAt?: number;
halfOpenLeaseOwner?: string;
halfOpenLeaseExpiresAt?: string;
capacityLimit?: number;
lastTestedAt?: string;
lastTestHealthy?: boolean;
lastTestRequestPath?: string;
lastTestRequestAttempted?: boolean;
lastTestError?: string;
};
export type ChannelGroup = { export type ChannelGroup = {
id: string; id: string;
version: number; version: number;
name?: string; name?: string;
capabilities?: string[]; capabilities?: string[];
channelIds: string[]; routes: ModelRoute[];
channelPriorities?: Record<string, number>;
enabled: boolean; enabled: boolean;
retryBudget: number;
}; };
export type SessionRecord = { userId: string; scope: "user" | "admin"; expiresAt: number; absoluteExpiresAt: number; createdAt: number; rotationId: string; familyId: string; revokedAt?: number; replacedBy?: string; deviceId?: string; roles?: string[]; scopes?: string[] }; export type SessionRecord = { userId: string; scope: "user" | "admin"; expiresAt: number; absoluteExpiresAt: number; createdAt: number; rotationId: string; familyId: string; revokedAt?: number; replacedBy?: string; deviceId?: string; roles?: string[]; scopes?: string[] };
export type VerificationRecord = { codeHash: string; expiresAt: number; attempts: number; maxAttempts: number; consumedAt?: number; targetHash?: string; purpose: "register" | "login" | "reset" | "mfa"; ip?: string; deviceId?: string }; export type VerificationRecord = { codeHash: string; expiresAt: number; attempts: number; maxAttempts: number; consumedAt?: number; targetHash?: string; purpose: "register" | "login" | "reset" | "mfa"; ip?: string; deviceId?: string };
@@ -38,7 +61,7 @@ export type ReserveAllocation = { bucketId: string; amount: number };
export type PlanPurchase = { id: string; userId: string; planId: string; planVersion: number; provider: "mock" | "adapter"; status: "pending" | "paid" | "failed" | "refunded"; purchaseAmount: number; includedBalance: number; idempotencyKey: string; requestFingerprint?: string; createdAt: string; paidAt?: string; creditBucketId?: string; entitlementId?: string; planSnapshot?: MembershipPlan }; export type PlanPurchase = { id: string; userId: string; planId: string; planVersion: number; provider: "mock" | "adapter"; status: "pending" | "paid" | "failed" | "refunded"; purchaseAmount: number; includedBalance: number; idempotencyKey: string; requestFingerprint?: string; createdAt: string; paidAt?: string; creditBucketId?: string; entitlementId?: string; planSnapshot?: MembershipPlan };
export type PlanEntitlement = { id: string; userId: string; planId: string; planVersion: number; expiresAt: string; includedBalance: number; remainingBalance: number; creditBucketId?: string; allowedModelProductIds: string[]; allowedChannelGroupIds: string[]; maxConcurrent: number; queuePriority: number; retentionDays: number; maxRetentionExtensionDays: number }; export type PlanEntitlement = { id: string; userId: string; planId: string; planVersion: number; expiresAt: string; includedBalance: number; remainingBalance: number; creditBucketId?: string; allowedModelProductIds: string[]; allowedChannelGroupIds: string[]; maxConcurrent: number; queuePriority: number; retentionDays: number; maxRetentionExtensionDays: number };
export type StoragePolicy = { id: string; version: number; stagingAdapter: "local-private" | "object-storage"; retentionDays: number; maxRetentionExtensionDays: number; archiveMode: "webdav-first" | "disabled"; updatedAt: string }; export type StoragePolicy = { id: string; version: number; stagingAdapter: "local-private" | "object-storage"; retentionDays: number; maxRetentionExtensionDays: number; archiveMode: "webdav-first" | "disabled"; updatedAt: string };
export type SystemSettings = { id: string; version: number; adminMfaRequired: boolean; adminCaptchaRequired: boolean; userMfaRequired: boolean; userVerificationRequired: boolean; emailRegistrationVerificationRequired: boolean; phoneRegistrationVerificationRequired: boolean; captchaRequired: boolean; rateLimitWindowMs: number; rateLimitMax: number; queueMaxConcurrent: number; auditRetentionDays: number; auditMaxEntries: number; updatedAt: string }; export type SystemSettings = { id: string; version: number; adminMfaRequired: boolean; adminCaptchaRequired: boolean; userMfaRequired: boolean; userVerificationRequired: boolean; emailRegistrationVerificationRequired: boolean; phoneRegistrationVerificationRequired: boolean; captchaRequired: boolean; rateLimitWindowMs: number; rateLimitMax: number; queueMaxConcurrent: number; generationMaxCount: number; auditRetentionDays: number; auditMaxEntries: number; updatedAt: string };
export type StoreTransactionOptions = { reload?: boolean }; export type StoreTransactionOptions = { reload?: boolean };
export type Store = { export type Store = {
@@ -57,6 +80,8 @@ export type Store = {
buckets: Map<string, BalanceBucket[]>; buckets: Map<string, BalanceBucket[]>;
reserveAllocations: Map<string, ReserveAllocation[]>; reserveAllocations: Map<string, ReserveAllocation[]>;
products: InternalModelProduct[]; products: InternalModelProduct[];
pricingRules: PricingRule[];
healthSamples: ChannelHealthSample[];
balanceUnit: BalanceUnitConfig; balanceUnit: BalanceUnitConfig;
plans: MembershipPlan[]; plans: MembershipPlan[];
verificationCodes: Map<string, VerificationRecord>; verificationCodes: Map<string, VerificationRecord>;
@@ -121,14 +146,38 @@ function defaultProducts(): InternalModelProduct[] {
const imageCapabilities = ["image", "utility", "reverse-prompt", "multi-angle", "matting", "split", "upscale", "expand"] as const; const imageCapabilities = ["image", "utility", "reverse-prompt", "multi-angle", "matting", "split", "upscale", "expand"] as const;
const imageRatios = ["1:1", "16:9", "9:16", "3:4", "4:5", "3:2", "2:3", "4:3", "1.91:1", "2.35:1", "21:9"]; const imageRatios = ["1:1", "16:9", "9:16", "3:4", "4:5", "3:2", "2:3", "4:3", "1.91:1", "2.35:1", "21:9"];
return [ return [
{ id: "basic-image", publicModelId: "basic-image-v1", name: "基础模型", tier: "basic", capabilities: [...imageCapabilities], basePrice: 10, currencyUnit: "金币", enabled: true, version: 1, maxCount: 8, maxConcurrent: 2, channelGroupId: "image-default", capabilitiesSchema: imageCapabilities.map(capabilitySchema), resolutions: [{ id: "low", label: "低", width: 1024, height: 1024, priceMultiplier: 1, ratios: imageRatios }] }, { id: "basic-image", publicModelId: "basic-image-v1", name: "基础模型", tier: "basic", capabilities: [...imageCapabilities], enabled: true, version: 1, capabilitiesSchema: imageCapabilities.map(capabilitySchema), resolutions: [{ id: "low", label: "低", width: 1024, height: 1024, ratios: imageRatios }] },
{ id: "advanced-image", publicModelId: "advanced-image-v1", name: "高级模型", tier: "advanced", capabilities: [...imageCapabilities], basePrice: 25, currencyUnit: "金币", enabled: true, version: 1, maxCount: 8, maxConcurrent: 3, channelGroupId: "image-default", capabilitiesSchema: imageCapabilities.map(capabilitySchema), resolutions: [{ id: "medium", label: "中", width: 2048, height: 2048, priceMultiplier: 1.5, ratios: imageRatios }] }, { id: "advanced-image", publicModelId: "advanced-image-v1", name: "高级模型", tier: "advanced", capabilities: [...imageCapabilities], enabled: true, version: 1, capabilitiesSchema: imageCapabilities.map(capabilitySchema), resolutions: [{ id: "medium", label: "中", width: 2048, height: 2048, ratios: imageRatios }] },
{ id: "flagship-image", publicModelId: "flagship-image-v1", name: "旗舰模型", tier: "flagship", capabilities: [...imageCapabilities], basePrice: 60, currencyUnit: "金币", enabled: true, version: 1, maxCount: 4, maxConcurrent: 1, channelGroupId: "image-default", capabilitiesSchema: imageCapabilities.map(capabilitySchema), resolutions: [{ id: "high", label: "高", width: 2880, height: 2880, priceMultiplier: 2, ratios: imageRatios }] }, { id: "flagship-image", publicModelId: "flagship-image-v1", name: "旗舰模型", tier: "flagship", capabilities: [...imageCapabilities], enabled: true, version: 1, capabilitiesSchema: imageCapabilities.map(capabilitySchema), resolutions: [{ id: "high", label: "高", width: 2880, height: 2880, ratios: imageRatios }] },
{ id: "basic-text", publicModelId: "basic-text-v1", name: "文字理解", tier: "basic", capabilities: ["text"], basePrice: 2, currencyUnit: "金币", enabled: true, version: 1, maxCount: 1, maxConcurrent: 4, channelGroupId: "image-default", capabilitiesSchema: [capabilitySchema("text")], resolutions: [] }, { id: "basic-text", publicModelId: "basic-text-v1", name: "文字理解", tier: "basic", capabilities: ["text"], enabled: true, version: 1, capabilitiesSchema: [capabilitySchema("text")], resolutions: [] },
{ id: "basic-audio", publicModelId: "basic-audio-v1", name: "音频生成", tier: "basic", capabilities: ["audio"], basePrice: 8, currencyUnit: "金币", enabled: true, version: 1, maxCount: 1, maxConcurrent: 2, channelGroupId: "image-default", capabilitiesSchema: [capabilitySchema("audio")], resolutions: [] }, { id: "basic-audio", publicModelId: "basic-audio-v1", name: "音频生成", tier: "basic", capabilities: ["audio"], enabled: true, version: 1, capabilitiesSchema: [capabilitySchema("audio")], resolutions: [] },
]; ];
} }
function defaultPricingRules(products: InternalModelProduct[], balanceUnitVersion: number): PricingRule[] {
const unitPrices: Record<string, { unitPrice: number; multiplier: number }> = {
"basic-image-v1": { unitPrice: 10, multiplier: 1 },
"advanced-image-v1": { unitPrice: 25, multiplier: 1.5 },
"flagship-image-v1": { unitPrice: 60, multiplier: 2 },
"basic-text-v1": { unitPrice: 2, multiplier: 1 },
"basic-audio-v1": { unitPrice: 8, multiplier: 1 },
};
return products.map((product) => {
const price = unitPrices[product.publicModelId] || { unitPrice: 1, multiplier: 1 };
return {
id: `price-${product.id}`,
publicModelId: product.publicModelId,
resolutionId: product.resolutions[0]?.id,
billingUnit: "output" as const,
tiers: [{ minQuantity: 1, unitPrice: price.unitPrice }],
multiplier: price.multiplier,
balanceUnitVersion,
enabled: true,
version: 1,
};
});
}
type StoreOptions = { persistenceAdapter?: "memory" | "file" | "postgres"; queueAdapter?: "memory" | "redis"; databaseUrl?: string; redisUrl?: string; stagingDir?: string; channelEncryptionKey?: string }; type StoreOptions = { persistenceAdapter?: "memory" | "file" | "postgres"; queueAdapter?: "memory" | "redis"; databaseUrl?: string; redisUrl?: string; stagingDir?: string; channelEncryptionKey?: string };
function baseStore(path: string | undefined, options: StoreOptions): Store { function baseStore(path: string | undefined, options: StoreOptions): Store {
@@ -137,27 +186,30 @@ function baseStore(path: string | undefined, options: StoreOptions): Store {
const channelFixtureMode = process.env.NODE_ENV === "test" || process.env.MIRAGENFLOW_TEST_MODE === "true"; const channelFixtureMode = process.env.NODE_ENV === "test" || process.env.MIRAGENFLOW_TEST_MODE === "true";
const paymentFixtureMode = process.env.NODE_ENV === "test" || process.env.MIRAGENFLOW_TEST_MODE === "true"; const paymentFixtureMode = process.env.NODE_ENV === "test" || process.env.MIRAGENFLOW_TEST_MODE === "true";
const products = fixtureMode ? defaultProducts() : []; const products = fixtureMode ? defaultProducts() : [];
const pricingRules = fixtureMode ? defaultPricingRules(products, unit.version) : [];
const plans = fixtureMode ? [{ id: "starter", code: "starter", name: "基础套餐", icon: "coins", description: "用于开始创作", durationDays: 30, purchaseAmount: 0, includedBalance: 100, published: true, allowedModelProductIds: ["basic-image", "basic-text", "basic-audio"], allowedChannelGroupIds: ["image-default"], maxConcurrent: 2, queuePriority: 0, retentionDays: 30, maxRetentionExtensionDays: 30, version: 1 }] : []; const plans = fixtureMode ? [{ id: "starter", code: "starter", name: "基础套餐", icon: "coins", description: "用于开始创作", durationDays: 30, purchaseAmount: 0, includedBalance: 100, published: true, allowedModelProductIds: ["basic-image", "basic-text", "basic-audio"], allowedChannelGroupIds: ["image-default"], maxConcurrent: 2, queuePriority: 0, retentionDays: 30, maxRetentionExtensionDays: 30, version: 1 }] : [];
const channels: ProviderChannel[] = channelFixtureMode ? ["channel-a", "channel-b", "channel-c", "channel-d"].map((id, index) => ({ id, label: `渠道 ${index + 1}`, enabled: true, priority: index, health: "healthy" as const, version: 1, retryBudget: 0, consecutiveFailures: 0 })) : []; const channelModels: ChannelModel[] = products.map((product) => ({ requestModelId: product.publicModelId, source: "manual", enabled: true }));
const channelGroups = channelFixtureMode ? [{ id: "image-default", version: 1, name: "图片生成渠道组", capabilities: ["image"], channelIds: ["channel-a", "channel-b", "channel-c", "channel-d"], channelPriorities: { "channel-a": 0, "channel-b": 1, "channel-c": 2, "channel-d": 3 }, enabled: true, retryBudget: 4 }] : fixtureMode ? [{ id: "image-default", version: 1, name: "图片生成渠道组", capabilities: ["image"], channelIds: [], channelPriorities: {}, enabled: true, retryBudget: 0 }] : []; const channels: ProviderChannel[] = channelFixtureMode ? ["channel-a", "channel-b", "channel-c", "channel-d"].map((id, index) => ({ id, label: `渠道 ${index + 1}`, enabled: true, health: "healthy" as const, version: 1, models: channelModels.map((model) => ({ ...model })), modelMappings: [], timeoutMs: 60_000, consecutiveFailures: 0, capacityLimit: 4 + index })) : [];
const routes: ModelRoute[] = products.map((product) => ({ publicModelId: product.publicModelId, channels: channels.map((channel, index) => ({ channelId: channel.id, priorityTier: 1, orderWithinTier: index + 1, retryBudget: 0 })), strategy: "strict", retryBudget: 4, version: 1 }));
const channelGroups: ChannelGroup[] = fixtureMode ? [{ id: "image-default", version: 1, name: "图片生成渠道组", capabilities: ["image"], routes, enabled: true }] : [];
const paymentProviders = paymentFixtureMode ? new Map([["mock", { id: "mock", name: "模拟支付适配器", enabled: true, mode: "test", secretConfigured: false, version: 1 }]]) : new Map<string, Record<string, unknown>>(); const paymentProviders = paymentFixtureMode ? new Map([["mock", { id: "mock", name: "模拟支付适配器", enabled: true, mode: "test", secretConfigured: false, version: 1 }]]) : new Map<string, Record<string, unknown>>();
const repository = createPersistenceRepository({ adapter: options.persistenceAdapter || (path ? "file" : "memory"), filePath: path, databaseUrl: options.databaseUrl, key: "store" }); const repository = createPersistenceRepository({ adapter: options.persistenceAdapter || (path ? "file" : "memory"), filePath: path, databaseUrl: options.databaseUrl, key: "store" });
const queueAdapter = createTaskQueue({ adapter: options.queueAdapter, redisUrl: options.redisUrl }); const queueAdapter = createTaskQueue({ adapter: options.queueAdapter, redisUrl: options.redisUrl });
return { return {
users: new Map(), sessions: new Map(), sessionHashes: new Map(), sessionFamilies: new Map(), refreshReuse: new Map(), revokedAccessBefore: new Map(), idempotency: new Map(), idempotencyBodies: new Map(), retentionIdempotency: new Map(), tasks: new Map(), events: new Map(), balances: new Map(), buckets: new Map(), reserveAllocations: new Map(), products, balanceUnit: unit, users: new Map(), sessions: new Map(), sessionHashes: new Map(), sessionFamilies: new Map(), refreshReuse: new Map(), revokedAccessBefore: new Map(), idempotency: new Map(), idempotencyBodies: new Map(), retentionIdempotency: new Map(), tasks: new Map(), events: new Map(), balances: new Map(), buckets: new Map(), reserveAllocations: new Map(), products, pricingRules, healthSamples: [], balanceUnit: unit,
plans, plans,
verificationCodes: new Map(), passwordResetCodes: new Map(), passwordResetRequests: new Map(), mfaChallenges: new Map(), captchaChallenges: new Map(), rateLimits: new Map(), ledger: [], rechargeOrders: new Map(), rechargeIdempotency: new Map(), paymentEvents: new Map(), adminIdempotency: new Map(), adminApprovals: new Map(), adminAccounts: new Map(), planPurchases: new Map(), entitlements: new Map(), verificationCodes: new Map(), passwordResetCodes: new Map(), passwordResetRequests: new Map(), mfaChallenges: new Map(), captchaChallenges: new Map(), rateLimits: new Map(), ledger: [], rechargeOrders: new Map(), rechargeIdempotency: new Map(), paymentEvents: new Map(), adminIdempotency: new Map(), adminApprovals: new Map(), adminAccounts: new Map(), planPurchases: new Map(), entitlements: new Map(),
channels, channelGroups, taskSubscribers: new Map(), audit: [], uploads: new Map(), assets: new Map(), webdav: new Map(), webdavFiles: new Map(), webdavJobs: new Map(), taskDispatchOutbox: new Map(), messageOutbox: new Map(), messageProviders: new Map(), messageTemplates: new Map(), paymentProviders, storagePolicy: { id: "default", version: 1, stagingAdapter: "local-private", retentionDays: 30, maxRetentionExtensionDays: 30, archiveMode: "webdav-first", updatedAt: new Date().toISOString() }, systemSettings: { id: "default", version: 1, adminMfaRequired: process.env.MIRAGENFLOW_ADMIN_MFA_REQUIRED === "true", adminCaptchaRequired: process.env.MIRAGENFLOW_ADMIN_CAPTCHA_REQUIRED === "true", userMfaRequired: process.env.MIRAGENFLOW_USER_MFA_REQUIRED === "true", userVerificationRequired: process.env.MIRAGENFLOW_USER_VERIFICATION_REQUIRED === "true", emailRegistrationVerificationRequired: process.env.MIRAGENFLOW_EMAIL_REGISTRATION_VERIFICATION_REQUIRED === "true" || process.env.MIRAGENFLOW_USER_VERIFICATION_REQUIRED === "true", phoneRegistrationVerificationRequired: process.env.MIRAGENFLOW_PHONE_REGISTRATION_VERIFICATION_REQUIRED === "true" || process.env.MIRAGENFLOW_USER_VERIFICATION_REQUIRED === "true", captchaRequired: process.env.MIRAGENFLOW_CAPTCHA_REQUIRED === "true", rateLimitWindowMs: Number(process.env.MIRAGENFLOW_RATE_LIMIT_WINDOW_MS) || 60_000, rateLimitMax: Number(process.env.MIRAGENFLOW_RATE_LIMIT_MAX) || 12, queueMaxConcurrent: 4, auditRetentionDays: 365, auditMaxEntries: 100_000, updatedAt: new Date().toISOString() }, systemSettingsConfigured: false, systemSettingsRegistrationConfigured: Boolean(process.env.MIRAGENFLOW_EMAIL_REGISTRATION_VERIFICATION_REQUIRED !== undefined || process.env.MIRAGENFLOW_PHONE_REGISTRATION_VERIFICATION_REQUIRED !== undefined), objects: new Map(), queue: [], transactionDepth: 0, transactionEvents: [], transact: async () => { throw new Error("store transaction not initialized"); }, persistenceFailure: undefined, persistencePath: path, persist: () => undefined, persistAsync: async () => undefined, loadPersisted: async () => undefined, repository, queueAdapter, stagingDir: options.stagingDir || process.env.MIRAGENFLOW_STAGING_DIR?.trim() || "/tmp/miragenflow-staging", health: async () => ({ persistence: await repository.health(), queue: await queueAdapter.health() }), channelEncryptionKey: options.channelEncryptionKey || process.env.MIRAGENFLOW_CHANNEL_ENCRYPTION_KEY || process.env.MIRAGENFLOW_ACCESS_TOKEN_SECRET || "local-development-secret-change-me", channels, channelGroups, taskSubscribers: new Map(), audit: [], uploads: new Map(), assets: new Map(), webdav: new Map(), webdavFiles: new Map(), webdavJobs: new Map(), taskDispatchOutbox: new Map(), messageOutbox: new Map(), messageProviders: new Map(), messageTemplates: new Map(), paymentProviders, storagePolicy: { id: "default", version: 1, stagingAdapter: "local-private", retentionDays: 30, maxRetentionExtensionDays: 30, archiveMode: "webdav-first", updatedAt: new Date().toISOString() }, systemSettings: { id: "default", version: 1, adminMfaRequired: process.env.MIRAGENFLOW_ADMIN_MFA_REQUIRED === "true", adminCaptchaRequired: process.env.MIRAGENFLOW_ADMIN_CAPTCHA_REQUIRED === "true", userMfaRequired: process.env.MIRAGENFLOW_USER_MFA_REQUIRED === "true", userVerificationRequired: process.env.MIRAGENFLOW_USER_VERIFICATION_REQUIRED === "true", emailRegistrationVerificationRequired: process.env.MIRAGENFLOW_EMAIL_REGISTRATION_VERIFICATION_REQUIRED === "true" || process.env.MIRAGENFLOW_USER_VERIFICATION_REQUIRED === "true", phoneRegistrationVerificationRequired: process.env.MIRAGENFLOW_PHONE_REGISTRATION_VERIFICATION_REQUIRED === "true" || process.env.MIRAGENFLOW_USER_VERIFICATION_REQUIRED === "true", captchaRequired: process.env.MIRAGENFLOW_CAPTCHA_REQUIRED === "true", rateLimitWindowMs: Number(process.env.MIRAGENFLOW_RATE_LIMIT_WINDOW_MS) || 60_000, rateLimitMax: Number(process.env.MIRAGENFLOW_RATE_LIMIT_MAX) || 12, queueMaxConcurrent: 4, generationMaxCount: Number(process.env.MIRAGENFLOW_GENERATION_MAX_COUNT) || 8, auditRetentionDays: 365, auditMaxEntries: 100_000, updatedAt: new Date().toISOString() }, systemSettingsConfigured: false, systemSettingsRegistrationConfigured: Boolean(process.env.MIRAGENFLOW_EMAIL_REGISTRATION_VERIFICATION_REQUIRED !== undefined || process.env.MIRAGENFLOW_PHONE_REGISTRATION_VERIFICATION_REQUIRED !== undefined), objects: new Map(), queue: [], transactionDepth: 0, transactionEvents: [], transact: async () => { throw new Error("store transaction not initialized"); }, persistenceFailure: undefined, persistencePath: path, persist: () => undefined, persistAsync: async () => undefined, loadPersisted: async () => undefined, repository, queueAdapter, stagingDir: options.stagingDir || process.env.MIRAGENFLOW_STAGING_DIR?.trim() || "/tmp/miragenflow-staging", health: async () => ({ persistence: await repository.health(), queue: await queueAdapter.health() }), channelEncryptionKey: options.channelEncryptionKey || process.env.MIRAGENFLOW_CHANNEL_ENCRYPTION_KEY || process.env.MIRAGENFLOW_ACCESS_TOKEN_SECRET || "local-development-secret-change-me",
}; };
} }
type Persisted = { users: [string, UserRecord][]; sessions: [string, SessionRecord][]; sessionHashes: [string, string][]; sessionFamilies: [string, Store["sessionFamilies"] extends Map<string, infer V> ? V : never][]; refreshReuse: [string, Store["refreshReuse"] extends Map<string, infer V> ? V : never][]; revokedAccessBefore: [string, number][]; idempotency: [string, GenerationTask][]; idempotencyBodies: [string, string][]; retentionIdempotency: [string, { fingerprint: string; taskId: string }][]; tasks: [string, GenerationTask][]; events: [string, TaskEvent[]][]; balances: [string, { available: number; reserved: number; version?: number }][]; buckets: [string, BalanceBucket[]][]; reserveAllocations: [string, ReserveAllocation[]][]; products: InternalModelProduct[]; balanceUnit: BalanceUnitConfig; plans: MembershipPlan[]; planPurchases: [string, PlanPurchase][]; entitlements: [string, PlanEntitlement][]; verificationCodes: [string, VerificationRecord][]; passwordResetCodes: [string, VerificationRecord][]; passwordResetRequests: [string, { userId: string; expiresAt: number }][]; mfaChallenges: [string, MfaChallengeRecord][]; captchaChallenges: [string, CaptchaChallengeRecord][]; rateLimits: [string, { count: number; resetAt: number }][]; ledger: BalanceLedgerEntry[]; rechargeOrders: [string, RechargeOrder][]; rechargeIdempotency: [string, RechargeOrder][]; paymentEvents: [string, PaymentEventRecord][]; adminIdempotency: [string, AdminIdempotencyRecord][]; adminApprovals: [string, AdminApprovalRecord][]; adminAccounts: [string, AdminAccountRecord][]; channels: ProviderChannel[]; channelGroups: ChannelGroup[]; audit: Store["audit"]; uploads: [string, UploadObject][]; assets: [string, AssetRecord][]; webdav: [string, WebDavRecord][]; webdavFiles: [string, WebDavFileRecord][]; webdavJobs: [string, WebDavSyncJob][]; taskDispatchOutbox: [string, TaskDispatchOutboxRecord][]; messageOutbox: [string, MessageOutboxRecord][]; messageProviders: [string, Record<string, unknown>][]; messageTemplates: [string, Record<string, unknown>][]; paymentProviders: [string, Record<string, unknown>][]; storagePolicy: StoragePolicy; systemSettings?: SystemSettings; systemSettingsRegistrationConfigured?: boolean; objects: [string, { id: string; ownerId: string; mimeType: string; data?: string; stagingKey?: string; expiresAt: string; taskId?: string; revoked?: boolean }][]; queue: string[]; snapshotVersion?: number }; type Persisted = { users: [string, UserRecord][]; sessions: [string, SessionRecord][]; sessionHashes: [string, string][]; sessionFamilies: [string, Store["sessionFamilies"] extends Map<string, infer V> ? V : never][]; refreshReuse: [string, Store["refreshReuse"] extends Map<string, infer V> ? V : never][]; revokedAccessBefore: [string, number][]; idempotency: [string, GenerationTask][]; idempotencyBodies: [string, string][]; retentionIdempotency: [string, { fingerprint: string; taskId: string }][]; tasks: [string, GenerationTask][]; events: [string, TaskEvent[]][]; balances: [string, { available: number; reserved: number; version?: number }][]; buckets: [string, BalanceBucket[]][]; reserveAllocations: [string, ReserveAllocation[]][]; products: InternalModelProduct[]; pricingRules: PricingRule[]; healthSamples: ChannelHealthSample[]; balanceUnit: BalanceUnitConfig; plans: MembershipPlan[]; planPurchases: [string, PlanPurchase][]; entitlements: [string, PlanEntitlement][]; verificationCodes: [string, VerificationRecord][]; passwordResetCodes: [string, VerificationRecord][]; passwordResetRequests: [string, { userId: string; expiresAt: number }][]; mfaChallenges: [string, MfaChallengeRecord][]; captchaChallenges: [string, CaptchaChallengeRecord][]; rateLimits: [string, { count: number; resetAt: number }][]; ledger: BalanceLedgerEntry[]; rechargeOrders: [string, RechargeOrder][]; rechargeIdempotency: [string, RechargeOrder][]; paymentEvents: [string, PaymentEventRecord][]; adminIdempotency: [string, AdminIdempotencyRecord][]; adminApprovals: [string, AdminApprovalRecord][]; adminAccounts: [string, AdminAccountRecord][]; channels: ProviderChannel[]; channelGroups: ChannelGroup[]; audit: Store["audit"]; uploads: [string, UploadObject][]; assets: [string, AssetRecord][]; webdav: [string, WebDavRecord][]; webdavFiles: [string, WebDavFileRecord][]; webdavJobs: [string, WebDavSyncJob][]; taskDispatchOutbox: [string, TaskDispatchOutboxRecord][]; messageOutbox: [string, MessageOutboxRecord][]; messageProviders: [string, Record<string, unknown>][]; messageTemplates: [string, Record<string, unknown>][]; paymentProviders: [string, Record<string, unknown>][]; storagePolicy: StoragePolicy; systemSettings?: SystemSettings; systemSettingsRegistrationConfigured?: boolean; objects: [string, { id: string; ownerId: string; mimeType: string; data?: string; stagingKey?: string; expiresAt: string; taskId?: string; revoked?: boolean }][]; queue: string[]; snapshotVersion: typeof DOMAIN_SNAPSHOT_VERSION };
function restore(store: Store, path: string) { function restore(store: Store, path: string) {
if (!existsSync(path)) return; if (!existsSync(path)) return;
try { try {
const raw = JSON.parse(readFileSync(path, "utf8")) as Partial<Persisted> & { snapshotVersion?: unknown }; const raw = JSON.parse(readFileSync(path, "utf8")) as Partial<Persisted> & { snapshotVersion?: unknown };
if (raw.snapshotVersion !== 1) throw new Error("unsupported or missing persistence snapshot version"); if (raw.snapshotVersion !== DOMAIN_SNAPSHOT_VERSION) throw new Error(`unsupported persistence snapshot version; expected ${DOMAIN_SNAPSHOT_VERSION}`);
applySnapshot(store, raw, true); applySnapshot(store, raw, true);
} catch (error) { } catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return; if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
@@ -165,11 +217,17 @@ function restore(store: Store, path: string) {
} }
} }
function assertSnapshotVersion(raw: unknown): asserts raw is Partial<Persisted> {
if (!raw || typeof raw !== "object" || (raw as { snapshotVersion?: unknown }).snapshotVersion !== DOMAIN_SNAPSHOT_VERSION) {
throw new Error(`unsupported persistence snapshot version; expected ${DOMAIN_SNAPSHOT_VERSION}`);
}
}
function applySnapshot(store: Store, raw: Partial<Persisted>, replace = false) { function applySnapshot(store: Store, raw: Partial<Persisted>, replace = false) {
const entries: Array<[keyof Persisted, keyof Store]> = [["users", "users"], ["sessions", "sessions"], ["sessionHashes", "sessionHashes"], ["sessionFamilies", "sessionFamilies"], ["refreshReuse", "refreshReuse"], ["revokedAccessBefore", "revokedAccessBefore"], ["idempotency", "idempotency"], ["idempotencyBodies", "idempotencyBodies"], ["retentionIdempotency", "retentionIdempotency"], ["tasks", "tasks"], ["events", "events"], ["balances", "balances"], ["buckets", "buckets"], ["reserveAllocations", "reserveAllocations"], ["verificationCodes", "verificationCodes"], ["passwordResetCodes", "passwordResetCodes"], ["passwordResetRequests", "passwordResetRequests"], ["mfaChallenges", "mfaChallenges"], ["captchaChallenges", "captchaChallenges"], ["rateLimits", "rateLimits"], ["rechargeOrders", "rechargeOrders"], ["rechargeIdempotency", "rechargeIdempotency"], ["paymentEvents", "paymentEvents"], ["adminIdempotency", "adminIdempotency"], ["adminApprovals", "adminApprovals"], ["adminAccounts", "adminAccounts"], ["planPurchases", "planPurchases"], ["entitlements", "entitlements"], ["uploads", "uploads"], ["assets", "assets"], ["webdav", "webdav"], ["webdavFiles", "webdavFiles"], ["webdavJobs", "webdavJobs"], ["taskDispatchOutbox", "taskDispatchOutbox"], ["messageOutbox", "messageOutbox"], ["messageProviders", "messageProviders"], ["messageTemplates", "messageTemplates"], ["paymentProviders", "paymentProviders"], ["objects", "objects"]]; const entries: Array<[keyof Persisted, keyof Store]> = [["users", "users"], ["sessions", "sessions"], ["sessionHashes", "sessionHashes"], ["sessionFamilies", "sessionFamilies"], ["refreshReuse", "refreshReuse"], ["revokedAccessBefore", "revokedAccessBefore"], ["idempotency", "idempotency"], ["idempotencyBodies", "idempotencyBodies"], ["retentionIdempotency", "retentionIdempotency"], ["tasks", "tasks"], ["events", "events"], ["balances", "balances"], ["buckets", "buckets"], ["reserveAllocations", "reserveAllocations"], ["verificationCodes", "verificationCodes"], ["passwordResetCodes", "passwordResetCodes"], ["passwordResetRequests", "passwordResetRequests"], ["mfaChallenges", "mfaChallenges"], ["captchaChallenges", "captchaChallenges"], ["rateLimits", "rateLimits"], ["rechargeOrders", "rechargeOrders"], ["rechargeIdempotency", "rechargeIdempotency"], ["paymentEvents", "paymentEvents"], ["adminIdempotency", "adminIdempotency"], ["adminApprovals", "adminApprovals"], ["adminAccounts", "adminAccounts"], ["planPurchases", "planPurchases"], ["entitlements", "entitlements"], ["uploads", "uploads"], ["assets", "assets"], ["webdav", "webdav"], ["webdavFiles", "webdavFiles"], ["webdavJobs", "webdavJobs"], ["taskDispatchOutbox", "taskDispatchOutbox"], ["messageOutbox", "messageOutbox"], ["messageProviders", "messageProviders"], ["messageTemplates", "messageTemplates"], ["paymentProviders", "paymentProviders"], ["objects", "objects"]];
if (replace) { if (replace) {
for (const [, target] of entries) (store[target] as Map<unknown, unknown>).clear(); for (const [, target] of entries) (store[target] as Map<unknown, unknown>).clear();
store.ledger = []; store.products = []; store.plans = []; store.channels = []; store.channelGroups = []; store.audit = []; store.queue = []; store.ledger = []; store.products = []; store.pricingRules = []; store.healthSamples = []; store.plans = []; store.channels = []; store.channelGroups = []; store.audit = []; store.queue = [];
} }
for (const [source, target] of entries) { for (const [source, target] of entries) {
const value = raw[source]; const value = raw[source];
@@ -187,6 +245,8 @@ function applySnapshot(store: Store, raw: Partial<Persisted>, replace = false) {
} }
if (Array.isArray(raw.ledger)) store.ledger = raw.ledger; if (Array.isArray(raw.ledger)) store.ledger = raw.ledger;
if (Array.isArray(raw.products)) store.products = raw.products; if (Array.isArray(raw.products)) store.products = raw.products;
if (Array.isArray(raw.pricingRules)) store.pricingRules = raw.pricingRules;
if (Array.isArray(raw.healthSamples)) store.healthSamples = raw.healthSamples;
if (raw.balanceUnit) store.balanceUnit = raw.balanceUnit; if (raw.balanceUnit) store.balanceUnit = raw.balanceUnit;
if (Array.isArray(raw.plans)) store.plans = raw.plans; if (Array.isArray(raw.plans)) store.plans = raw.plans;
if (Array.isArray(raw.channels)) store.channels = raw.channels; if (Array.isArray(raw.channels)) store.channels = raw.channels;
@@ -202,7 +262,7 @@ function applySnapshot(store: Store, raw: Partial<Persisted>, replace = false) {
} }
function snapshot(store: Store): Persisted { function snapshot(store: Store): Persisted {
return { users: [...store.users], sessions: [...store.sessions], sessionHashes: [...store.sessionHashes], sessionFamilies: [...store.sessionFamilies], refreshReuse: [...store.refreshReuse], revokedAccessBefore: [...store.revokedAccessBefore], idempotency: [...store.idempotency], idempotencyBodies: [...store.idempotencyBodies], retentionIdempotency: [...store.retentionIdempotency], tasks: [...store.tasks], events: [...store.events], balances: [...store.balances], buckets: [...store.buckets], reserveAllocations: [...store.reserveAllocations], products: store.products, balanceUnit: store.balanceUnit, plans: store.plans, planPurchases: [...store.planPurchases], entitlements: [...store.entitlements], verificationCodes: [...store.verificationCodes], passwordResetCodes: [...store.passwordResetCodes], passwordResetRequests: [...store.passwordResetRequests], mfaChallenges: [...store.mfaChallenges], captchaChallenges: [...store.captchaChallenges], rateLimits: [...store.rateLimits], ledger: store.ledger, rechargeOrders: [...store.rechargeOrders], rechargeIdempotency: [...store.rechargeIdempotency], paymentEvents: [...store.paymentEvents], adminIdempotency: [...store.adminIdempotency], adminApprovals: [...store.adminApprovals], adminAccounts: [...store.adminAccounts], channels: store.channels, channelGroups: store.channelGroups, audit: store.audit, uploads: [...store.uploads], assets: [...store.assets], webdav: [...store.webdav], webdavJobs: [...store.webdavJobs], webdavFiles: [...store.webdavFiles], taskDispatchOutbox: [...store.taskDispatchOutbox], messageOutbox: [...store.messageOutbox], messageProviders: [...store.messageProviders], messageTemplates: [...store.messageTemplates], paymentProviders: [...store.paymentProviders], storagePolicy: store.storagePolicy, systemSettings: store.systemSettings, systemSettingsRegistrationConfigured: store.systemSettingsRegistrationConfigured, objects: [...store.objects], queue: store.queue, snapshotVersion: 1 }; return { users: [...store.users], sessions: [...store.sessions], sessionHashes: [...store.sessionHashes], sessionFamilies: [...store.sessionFamilies], refreshReuse: [...store.refreshReuse], revokedAccessBefore: [...store.revokedAccessBefore], idempotency: [...store.idempotency], idempotencyBodies: [...store.idempotencyBodies], retentionIdempotency: [...store.retentionIdempotency], tasks: [...store.tasks], events: [...store.events], balances: [...store.balances], buckets: [...store.buckets], reserveAllocations: [...store.reserveAllocations], products: store.products, pricingRules: store.pricingRules, healthSamples: store.healthSamples, balanceUnit: store.balanceUnit, plans: store.plans, planPurchases: [...store.planPurchases], entitlements: [...store.entitlements], verificationCodes: [...store.verificationCodes], passwordResetCodes: [...store.passwordResetCodes], passwordResetRequests: [...store.passwordResetRequests], mfaChallenges: [...store.mfaChallenges], captchaChallenges: [...store.captchaChallenges], rateLimits: [...store.rateLimits], ledger: store.ledger, rechargeOrders: [...store.rechargeOrders], rechargeIdempotency: [...store.rechargeIdempotency], paymentEvents: [...store.paymentEvents], adminIdempotency: [...store.adminIdempotency], adminApprovals: [...store.adminApprovals], adminAccounts: [...store.adminAccounts], channels: store.channels, channelGroups: store.channelGroups, audit: store.audit, uploads: [...store.uploads], assets: [...store.assets], webdav: [...store.webdav], webdavJobs: [...store.webdavJobs], webdavFiles: [...store.webdavFiles], taskDispatchOutbox: [...store.taskDispatchOutbox], messageOutbox: [...store.messageOutbox], messageProviders: [...store.messageProviders], messageTemplates: [...store.messageTemplates], paymentProviders: [...store.paymentProviders], storagePolicy: store.storagePolicy, systemSettings: store.systemSettings, systemSettingsRegistrationConfigured: store.systemSettingsRegistrationConfigured, objects: [...store.objects], queue: store.queue, snapshotVersion: DOMAIN_SNAPSHOT_VERSION };
} }
function frozenSnapshot(store: Store) { return JSON.parse(JSON.stringify(snapshot(store))) as Persisted; } function frozenSnapshot(store: Store) { return JSON.parse(JSON.stringify(snapshot(store))) as Persisted; }
@@ -269,7 +329,10 @@ export function createStore(path = process.env.MIRAGENFLOW_STORE_FILE?.trim() ||
const epoch = persistenceEpoch; const epoch = persistenceEpoch;
const raw = await store.repository.load(); const raw = await store.repository.load();
if (epoch !== persistenceEpoch) continue; if (epoch !== persistenceEpoch) continue;
if (raw && typeof raw === "object") applySnapshot(store, raw as Partial<Persisted>, true); if (raw && typeof raw === "object") {
assertSnapshotVersion(raw);
applySnapshot(store, raw, true);
}
store.persistenceFailure = undefined; store.persistenceFailure = undefined;
return; return;
} }
@@ -288,21 +351,26 @@ export function createStore(path = process.env.MIRAGENFLOW_STORE_FILE?.trim() ||
// reload and commit the already prepared task graph atomically. // reload and commit the already prepared task graph atomically.
if (store.repository.adapter === "postgres" && options.reload !== false) { await persistChain.catch(() => undefined); await store.loadPersisted(); } if (store.repository.adapter === "postgres" && options.reload !== false) { await persistChain.catch(() => undefined); await store.loadPersisted(); }
const before = JSON.parse(JSON.stringify(snapshot(store))) as Persisted; const before = JSON.parse(JSON.stringify(snapshot(store))) as Persisted;
store.transactionDepth += 1; store.transactionEvents = []; const eventStart = store.transactionEvents.length;
if (store.transactionDepth === 0) store.transactionEvents = [];
store.transactionDepth += 1;
try { try {
const result = await mutator(store); const result = await mutator(store);
store.transactionDepth -= 1; store.transactionDepth = Math.max(0, store.transactionDepth - 1);
if (store.transactionDepth === 0) {
await store.persistAsync(); await store.persistAsync();
const events = store.transactionEvents.splice(0); const events = store.transactionEvents.splice(0);
for (const event of events) for (const subscriber of store.taskSubscribers.get(event.taskId) || []) subscriber(event); for (const event of events) for (const subscriber of store.taskSubscribers.get(event.taskId) || []) subscriber(event);
}
return result; return result;
} catch (error) { } catch (error) {
store.transactionDepth = Math.max(0, store.transactionDepth - 1); store.transactionDepth = Math.max(0, store.transactionDepth - 1);
store.transactionEvents = []; store.transactionEvents.splice(eventStart);
// Validation can fail before the mutator changes anything. Avoid // Validation can fail before the mutator changes anything. Avoid
// rebuilding the graph in that case so callers holding a reference // rebuilding the graph in that case so callers holding a reference
// to an untouched record keep observing the same object. // to an untouched record keep observing the same object.
if (JSON.stringify(snapshot(store)) !== JSON.stringify(before)) applySnapshot(store, before, true); if (JSON.stringify(snapshot(store)) !== JSON.stringify(before)) applySnapshot(store, before, true);
if (store.transactionDepth === 0) store.transactionEvents = [];
const conflict = /persistence conflict|revision changed/i.test(error instanceof Error ? error.message : String(error)); const conflict = /persistence conflict|revision changed/i.test(error instanceof Error ? error.message : String(error));
if (conflict && store.repository.adapter === "postgres" && options.reload !== false && attempt + 1 < Math.max(1, retries)) continue; if (conflict && store.repository.adapter === "postgres" && options.reload !== false && attempt + 1 < Math.max(1, retries)) continue;
throw error; throw error;
+2 -2
View File
@@ -186,11 +186,11 @@ test("public task responses do not expose provider route fields and 3D remains d
const session = (await login.json()) as { data: { accessToken: string } }; const auth = { Authorization: `Bearer ${session.data.accessToken}` }; const session = (await login.json()) as { data: { accessToken: string } }; const auth = { Authorization: `Bearer ${session.data.accessToken}` };
const catalog = await fetch(`${base}/api/v1/catalog/models`, { headers: auth }); const catalogPayload = await catalog.json() as { data: { items: Array<Record<string, unknown>> } }; const catalog = await fetch(`${base}/api/v1/catalog/models`, { headers: auth }); const catalogPayload = await catalog.json() as { data: { items: Array<Record<string, unknown>> } };
assert.equal(Object.prototype.hasOwnProperty.call(catalogPayload.data.items[0], "channelGroupId"), false); assert.equal(Object.prototype.hasOwnProperty.call(catalogPayload.data.items[0], "channelGroupId"), false);
const task = "provider-task"; const now = new Date().toISOString(); store.tasks.set(task, { id: task, ownerId: registered.data.userId, taskType: "image", modelProductId: "basic-image", status: "queued", estimatedCost: 1, reservedCost: 0, createdAt: now, updatedAt: now, eventSequence: 0, channelGroupId: "image-default", routeSnapshotVersion: 1, routeSnapshot: { groupId: "image-default", version: 1, channelIds: ["channel-a"] }, pricingSnapshot: { basePrice: 1, multiplier: 1, unitVersion: 1 }, planSnapshot: { queuePriority: 0, maxConcurrent: 1 }, reserveExpiresAt: now, idempotencyKey: "private-task-key", requestFingerprint: "private-fingerprint", attempts: [{ id: "attempt", channelId: "channel-a", sequence: 1, status: "started", providerRequestId: "provider-secret", platformIdempotencyKey: "provider-idempotency", leaseExpiresAt: now, startedAt: now }], outputs: [] }); const task = "provider-task"; const now = new Date().toISOString(); store.tasks.set(task, { id: task, ownerId: registered.data.userId, taskType: "image", modelProductId: "basic-image", status: "queued", estimatedCost: 1, reservedCost: 0, createdAt: now, updatedAt: now, eventSequence: 0, routeSnapshot: { groupId: "image-default", groupVersion: 1, publicModelId: "basic-image-v1", routeVersion: 1, orderedCandidates: [{ channelId: "channel-a", priorityTier: 1, orderWithinTier: 1, retryBudget: 0, requestModelId: "basic-image-v1" }], strategy: "strict", totalRetryBudget: 0 }, modelSnapshot: { publicModelId: "basic-image-v1", modelVersion: 1, name: "基础模型", tier: "basic", capabilities: ["image"] }, pricingSnapshot: { ruleId: "price-basic-image", ruleVersion: 1, publicModelId: "basic-image-v1", resolutionId: "low", unitPrice: 1, quantity: 1, billingUnit: "output", multiplier: 1, balanceUnitVersion: 1 }, planSnapshot: { queuePriority: 0, maxConcurrent: 1 }, reserveExpiresAt: now, idempotencyKey: "private-task-key", requestFingerprint: "private-fingerprint", attempts: [{ id: "attempt", channelId: "channel-a", sequence: 1, status: "started", providerRequestId: "provider-secret", platformIdempotencyKey: "provider-idempotency", leaseExpiresAt: now, startedAt: now }], outputs: [] });
const hidden = JSON.stringify(store.tasks.get(task)); assert.equal(hidden.includes("provider-secret"), true); const hidden = JSON.stringify(store.tasks.get(task)); assert.equal(hidden.includes("provider-secret"), true);
const taskResponse = await fetch(`${base}/api/v1/tasks/${task}`, { headers: auth }); const taskPayload = await taskResponse.json() as { data: Record<string, unknown> & { attempts?: Array<Record<string, unknown>> } }; const taskResponse = await fetch(`${base}/api/v1/tasks/${task}`, { headers: auth }); const taskPayload = await taskResponse.json() as { data: Record<string, unknown> & { attempts?: Array<Record<string, unknown>> } };
assert.equal(taskResponse.status, 200); assert.equal(taskResponse.status, 200);
for (const field of ["ownerId", "channelGroupId", "routeSnapshotVersion", "routeSnapshot", "pricingSnapshot", "planSnapshot", "leaseExpiresAt", "leaseToken", "providerSubmitted", "preferredReserveBucketId", "idempotencyKey", "requestFingerprint", "reserveExpiresAt"]) assert.equal(Object.prototype.hasOwnProperty.call(taskPayload.data, field), false); for (const field of ["ownerId", "routeSnapshot", "pricingSnapshot", "planSnapshot", "leaseExpiresAt", "leaseToken", "providerSubmitted", "preferredReserveBucketId", "idempotencyKey", "requestFingerprint", "reserveExpiresAt"]) assert.equal(Object.prototype.hasOwnProperty.call(taskPayload.data, field), false);
for (const field of ["channelId", "providerRequestId", "errorCode", "platformIdempotencyKey", "leaseExpiresAt"]) assert.equal(Object.prototype.hasOwnProperty.call(taskPayload.data.attempts?.[0] || {}, field), false); for (const field of ["channelId", "providerRequestId", "errorCode", "platformIdempotencyKey", "leaseExpiresAt"]) assert.equal(Object.prototype.hasOwnProperty.call(taskPayload.data.attempts?.[0] || {}, field), false);
const disabled3d = await fetch(`${base}/api/v1/tasks/3d`, { method: "POST", headers: { ...auth, "content-type": "application/json" }, body: JSON.stringify({ modelProductId: "basic-image", prompt: "fixture" }) }); const disabledPayload = await disabled3d.json() as { error?: { code?: string } }; assert.ok([400, 422].includes(disabled3d.status)); assert.equal(disabledPayload.error?.code, "CAPABILITY_NOT_ENABLED"); const disabled3d = await fetch(`${base}/api/v1/tasks/3d`, { method: "POST", headers: { ...auth, "content-type": "application/json" }, body: JSON.stringify({ modelProductId: "basic-image", prompt: "fixture" }) }); const disabledPayload = await disabled3d.json() as { error?: { code?: string } }; assert.ok([400, 422].includes(disabled3d.status)); assert.equal(disabledPayload.error?.code, "CAPABILITY_NOT_ENABLED");
} finally { } finally {
+4 -4
View File
@@ -107,7 +107,7 @@ test("postgres reload retries when a write starts during the database read", asy
test("store transaction rolls back in-memory mutations and defers events until commit", async () => { test("store transaction rolls back in-memory mutations and defers events until commit", async () => {
const store = createStore(); const store = createStore();
const taskId = "transaction-task"; const taskId = "transaction-task";
store.tasks.set(taskId, { id: taskId, ownerId: "transaction-user", taskType: "image", modelProductId: "basic-image-v1", status: "queued", estimatedCost: 1, reservedCost: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), eventSequence: 0, channelGroupId: "image-default", routeSnapshotVersion: 1, attempts: [], outputs: [] }); store.tasks.set(taskId, { id: taskId, ownerId: "transaction-user", taskType: "image", modelProductId: "basic-image-v1", status: "queued", estimatedCost: 1, reservedCost: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), eventSequence: 0, routeSnapshot: { groupId: "image-default", groupVersion: 1, publicModelId: "basic-image-v1", routeVersion: 1, orderedCandidates: [], strategy: "strict", totalRetryBudget: 0 }, modelSnapshot: { publicModelId: "basic-image-v1", modelVersion: 1, name: "基础模型", tier: "basic", capabilities: ["image"] }, pricingSnapshot: { ruleId: "price-basic-image", ruleVersion: 1, publicModelId: "basic-image-v1", unitPrice: 1, quantity: 1, billingUnit: "output", multiplier: 1, balanceUnitVersion: 1 }, planSnapshot: { queuePriority: 0, maxConcurrent: 1 }, attempts: [], outputs: [] });
let observed = 0; let observed = 0;
store.taskSubscribers.set(taskId, new Set([() => { observed += 1; }])); store.taskSubscribers.set(taskId, new Set([() => { observed += 1; }]));
await assert.rejects(store.transact(() => { const task = store.tasks.get(taskId)!; task.status = "running"; appendEvent(store, taskId, { taskId, type: "task.running", payload: {} }); throw new Error("rollback"); }), /rollback/); await assert.rejects(store.transact(() => { const task = store.tasks.get(taskId)!; task.status = "running"; appendEvent(store, taskId, { taskId, type: "task.running", payload: {} }); throw new Error("rollback"); }), /rollback/);
@@ -145,7 +145,7 @@ test("staging objects stay below the private root and expired files are collecte
test("expired worker lease moves a running task to unknown without releasing reserve", () => { test("expired worker lease moves a running task to unknown without releasing reserve", () => {
const store = createStore(); const store = createStore();
const now = new Date(Date.now() - 10_000).toISOString(); const now = new Date(Date.now() - 10_000).toISOString();
store.tasks.set("lease-task", { id: "lease-task", ownerId: "lease-user", taskType: "image", modelProductId: "basic-image", status: "running", estimatedCost: 10, reservedCost: 10, createdAt: now, updatedAt: now, eventSequence: 0, channelGroupId: "image-default", routeSnapshotVersion: 1, leaseExpiresAt: now, attempts: [{ id: "attempt-1", channelId: "channel-a", sequence: 1, status: "started", startedAt: now, leaseExpiresAt: now }], outputs: [] }); store.tasks.set("lease-task", { id: "lease-task", ownerId: "lease-user", taskType: "image", modelProductId: "basic-image", status: "running", estimatedCost: 10, reservedCost: 10, createdAt: now, updatedAt: now, eventSequence: 0, routeSnapshot: { groupId: "image-default", groupVersion: 1, publicModelId: "basic-image-v1", routeVersion: 1, orderedCandidates: [], strategy: "strict", totalRetryBudget: 0 }, modelSnapshot: { publicModelId: "basic-image-v1", modelVersion: 1, name: "基础模型", tier: "basic", capabilities: ["image"] }, pricingSnapshot: { ruleId: "price-basic-image", ruleVersion: 1, publicModelId: "basic-image-v1", unitPrice: 1, quantity: 1, billingUnit: "output", multiplier: 1, balanceUnitVersion: 1 }, planSnapshot: { queuePriority: 0, maxConcurrent: 1 }, leaseExpiresAt: now, attempts: [{ id: "attempt-1", channelId: "channel-a", sequence: 1, status: "started", startedAt: now, leaseExpiresAt: now }], outputs: [] });
store.balances.set("lease-user", { available: 0, reserved: 10 }); store.balances.set("lease-user", { available: 0, reserved: 10 });
assert.equal(recoverExpiredTaskLeases(store), 1); assert.equal(recoverExpiredTaskLeases(store), 1);
const task = store.tasks.get("lease-task")!; const task = store.tasks.get("lease-task")!;
@@ -293,7 +293,7 @@ test("expired unknown tasks without a provider request id remain held for reconc
store.buckets.set(userId, [{ id: "bucket", userId, source: "recharge", remaining: 10, priority: 0 }]); store.buckets.set(userId, [{ id: "bucket", userId, source: "recharge", remaining: 10, priority: 0 }]);
assert.equal(reserveBalance(store, userId, 10, "unknown-expiry-task"), true); assert.equal(reserveBalance(store, userId, 10, "unknown-expiry-task"), true);
const expired = new Date(Date.now() - 1_000).toISOString(); const expired = new Date(Date.now() - 1_000).toISOString();
store.tasks.set("unknown-expiry-task", { id: "unknown-expiry-task", ownerId: userId, taskType: "image", modelProductId: "basic-image", status: "unknown", estimatedCost: 10, reservedCost: 10, reserveExpiresAt: expired, createdAt: expired, updatedAt: expired, eventSequence: 0, channelGroupId: "image-default", routeSnapshotVersion: 1, attempts: [{ id: "unknown-attempt", channelId: "channel-a", sequence: 1, status: "unknown", startedAt: expired, finishedAt: expired, reconciliationStatus: "pending" }], outputs: [] }); store.tasks.set("unknown-expiry-task", { id: "unknown-expiry-task", ownerId: userId, taskType: "image", modelProductId: "basic-image", status: "unknown", estimatedCost: 10, reservedCost: 10, reserveExpiresAt: expired, createdAt: expired, updatedAt: expired, eventSequence: 0, routeSnapshot: { groupId: "image-default", groupVersion: 1, publicModelId: "basic-image-v1", routeVersion: 1, orderedCandidates: [], strategy: "strict", totalRetryBudget: 0 }, modelSnapshot: { publicModelId: "basic-image-v1", modelVersion: 1, name: "基础模型", tier: "basic", capabilities: ["image"] }, pricingSnapshot: { ruleId: "price-basic-image", ruleVersion: 1, publicModelId: "basic-image-v1", unitPrice: 1, quantity: 1, billingUnit: "output", multiplier: 1, balanceUnitVersion: 1 }, planSnapshot: { queuePriority: 0, maxConcurrent: 1 }, attempts: [{ id: "unknown-attempt", channelId: "channel-a", sequence: 1, status: "unknown", startedAt: expired, finishedAt: expired, reconciliationStatus: "pending" }], outputs: [] });
assert.equal(recoverExpiredReservations(store), 0); assert.equal(recoverExpiredReservations(store), 0);
assert.equal(store.tasks.get("unknown-expiry-task")?.status, "unknown"); assert.equal(store.tasks.get("unknown-expiry-task")?.status, "unknown");
assert.equal(store.tasks.get("unknown-expiry-task")?.reservedCost, 10); assert.equal(store.tasks.get("unknown-expiry-task")?.reservedCost, 10);
@@ -307,7 +307,7 @@ test("file snapshots use a version marker and reject corrupt state", async () =>
const store = createStore(file); const store = createStore(file);
store.persist(); store.persist();
const persisted = JSON.parse(await readFile(file, "utf8")) as { snapshotVersion?: number }; const persisted = JSON.parse(await readFile(file, "utf8")) as { snapshotVersion?: number };
assert.equal(persisted.snapshotVersion, 1); assert.equal(persisted.snapshotVersion, 2);
await writeFile(file, "{not-json", "utf8"); await writeFile(file, "{not-json", "utf8");
assert.throws(() => createStore(file), /persistence snapshot rejected/); assert.throws(() => createStore(file), /persistence snapshot rejected/);
} finally { await rm(root, { recursive: true, force: true }); } } finally { await rm(root, { recursive: true, force: true }); }
+26 -22
View File
@@ -11,8 +11,12 @@ function channel(store: ReturnType<typeof createStore>) {
...store.channels[0], ...store.channels[0],
providerType: "openai-images" as const, providerType: "openai-images" as const,
baseUrl: "https://1.1.1.1/v1", baseUrl: "https://1.1.1.1/v1",
providerModelId: "base-model", models: [
resolutionModelMap: { "2K": "mapped-2k" }, { requestModelId: "base-model", source: "manual" as const, enabled: true },
{ requestModelId: "mapped-2k", source: "manual" as const, enabled: true },
{ requestModelId: "供应商模型", source: "manual" as const, enabled: true },
],
modelMappings: [],
secretRef: encryptSecret("1234567890123456", store.channelEncryptionKey), secretRef: encryptSecret("1234567890123456", store.channelEncryptionKey),
}; };
} }
@@ -26,14 +30,14 @@ test("OpenAI image generation uses the mapped model and normalized v1 endpoint",
return new Response(JSON.stringify({ data: [{ b64_json: png.toString("base64") }] }), { status: 200, headers: { "content-type": "application/json", "x-request-id": "request-123" } }); return new Response(JSON.stringify({ data: [{ b64_json: png.toString("base64") }] }), { status: 200, headers: { "content-type": "application/json", "x-request-id": "request-123" } });
}; };
try { try {
const result = await invokeProvider(store, channel(store), { taskId: "task-1", taskType: "image", prompt: "一只猫", count: 1, params: { resolution: "2K" }, platformIdempotencyKey: "idem-1" }); const result = await invokeProvider(store, channel(store), { taskId: "task-1", taskType: "image", requestModelId: "base-model", prompt: "一只猫", count: 1, params: { resolution: "2K" }, platformIdempotencyKey: "idem-1" });
assert.equal(result.status, "succeeded"); assert.equal(result.status, "succeeded");
assert.equal(result.providerRequestId, "request-123"); assert.equal(result.providerRequestId, "request-123");
assert.equal(result.outputs?.[0]?.mimeType, "image/png"); assert.equal(result.outputs?.[0]?.mimeType, "image/png");
assert.equal(requests[0]?.url, "https://1.1.1.1/v1/images/generations"); assert.equal(requests[0]?.url, "https://1.1.1.1/v1/images/generations");
assert.equal(requests[0]?.headers.get("authorization"), "Bearer 1234567890123456"); assert.equal(requests[0]?.headers.get("authorization"), "Bearer 1234567890123456");
assert.equal(requests[0]?.headers.get("idempotency-key"), "idem-1"); assert.equal(requests[0]?.headers.get("idempotency-key"), "idem-1");
assert.equal((JSON.parse(String(requests[0]?.body)) as Record<string, unknown>).model, "mapped-2k"); assert.equal((JSON.parse(String(requests[0]?.body)) as Record<string, unknown>).model, "base-model");
} finally { } finally {
globalThis.fetch = previousFetch; globalThis.fetch = previousFetch;
} }
@@ -72,7 +76,7 @@ test("OpenAI image edits preserve reference order with image[] fields", async ()
return new Response(JSON.stringify({ data: [{ b64_json: png.toString("base64") }] }), { status: 200, headers: { "content-type": "application/json" } }); return new Response(JSON.stringify({ data: [{ b64_json: png.toString("base64") }] }), { status: 200, headers: { "content-type": "application/json" } });
}; };
try { try {
const result = await invokeProvider(store, channel(store), { taskId: "task-2", taskType: "image", prompt: "@图片一和@图片二", count: 1, referenceImages: [{ objectId: "ref-b", seq: 2, name: "图片二" }, { objectId: "ref-a", seq: 1, name: "图片一" }], platformIdempotencyKey: "idem-2" }); const result = await invokeProvider(store, channel(store), { taskId: "task-2", taskType: "image", requestModelId: "base-model", prompt: "@图片一和@图片二", count: 1, referenceImages: [{ objectId: "ref-b", seq: 2, name: "图片二" }, { objectId: "ref-a", seq: 1, name: "图片一" }], platformIdempotencyKey: "idem-2" });
assert.equal(result.status, "succeeded"); assert.equal(result.status, "succeeded");
} finally { } finally {
globalThis.fetch = previousFetch; globalThis.fetch = previousFetch;
@@ -87,7 +91,7 @@ test("invalid image base64 and empty model probes fail closed", async () => {
return new Response(JSON.stringify({ data: [{ b64_json: "not-base64" }] }), { status: 200, headers: { "content-type": "application/json" } }); return new Response(JSON.stringify({ data: [{ b64_json: "not-base64" }] }), { status: 200, headers: { "content-type": "application/json" } });
}; };
try { try {
const result = await invokeProvider(store, channel(store), { taskId: "task-3", taskType: "image", prompt: "测试", count: 1, platformIdempotencyKey: "idem-3" }); const result = await invokeProvider(store, channel(store), { taskId: "task-3", taskType: "image", requestModelId: "base-model", prompt: "测试", count: 1, platformIdempotencyKey: "idem-3" });
assert.equal(result.status, "failed"); assert.equal(result.status, "failed");
assert.equal(result.errorCode, "PROVIDER_INVALID_OUTPUT"); assert.equal(result.errorCode, "PROVIDER_INVALID_OUTPUT");
await assert.rejects(() => probeProviderModels("https://1.1.1.1/v1", "1234567890123456")); await assert.rejects(() => probeProviderModels("https://1.1.1.1/v1", "1234567890123456"));
@@ -113,7 +117,7 @@ test("duplicate references are uploaded once and URL outputs are validated", asy
}; };
try { try {
const result = await invokeProvider(store, channel(store), { const result = await invokeProvider(store, channel(store), {
taskId: "task-4", taskId: "task-4", requestModelId: "base-model",
taskType: "image", taskType: "image",
prompt: "使用@图片一", prompt: "使用@图片一",
count: 1, count: 1,
@@ -141,7 +145,7 @@ test("unavailable reference objects fail without retrying the provider", async (
}; };
try { try {
const result = await invokeProvider(store, channel(store), { const result = await invokeProvider(store, channel(store), {
taskId: "task-5", taskId: "task-5", requestModelId: "base-model",
taskType: "image", taskType: "image",
prompt: "使用第一张图", prompt: "使用第一张图",
count: 1, count: 1,
@@ -166,7 +170,7 @@ test("expired, revoked, and missing staging references fail deterministically",
globalThis.fetch = async () => { called = true; return new Response(JSON.stringify({ data: [] }), { status: 200 }); }; globalThis.fetch = async () => { called = true; return new Response(JSON.stringify({ data: [] }), { status: 200 }); };
try { try {
for (const [index, objectId] of ["expired", "revoked", "staging-missing"].entries()) { for (const [index, objectId] of ["expired", "revoked", "staging-missing"].entries()) {
const result = await invokeProvider(store, channel(store), { taskId: `reference-unavailable-${index}`, taskType: "image", prompt: "使用第一张图", count: 1, referenceImages: [{ objectId, seq: 1, name: "图片一" }], platformIdempotencyKey: `reference-unavailable-${index}` }); const result = await invokeProvider(store, channel(store), { taskId: `reference-unavailable-${index}`, taskType: "image", requestModelId: "base-model", prompt: "使用第一张图", count: 1, referenceImages: [{ objectId, seq: 1, name: "图片一" }], platformIdempotencyKey: `reference-unavailable-${index}` });
assert.equal(result.status, "failed"); assert.equal(result.status, "failed");
assert.equal(result.errorCode, "PROVIDER_REFERENCE_UNAVAILABLE"); assert.equal(result.errorCode, "PROVIDER_REFERENCE_UNAVAILABLE");
assert.equal(result.retryable, false); assert.equal(result.retryable, false);
@@ -191,7 +195,7 @@ test("invalid image output URLs are non-retryable provider failures", async () =
if (String(input).endsWith("/images/generations")) return new Response(JSON.stringify({ data: [{ url: outputUrl }] }), { status: 200, headers: { "content-type": "application/json" } }); if (String(input).endsWith("/images/generations")) return new Response(JSON.stringify({ data: [{ url: outputUrl }] }), { status: 200, headers: { "content-type": "application/json" } });
return new Response(index === 2 ? png : "not an image", { status: index === 2 ? 404 : 200, headers: { "content-type": "image/png" } }); return new Response(index === 2 ? png : "not an image", { status: index === 2 ? 404 : 200, headers: { "content-type": "image/png" } });
}; };
const result = await invokeProvider(store, channel(store), { taskId: `invalid-output-${index}`, taskType: "image", prompt: "测试", count: 1, platformIdempotencyKey: `invalid-output-${index}` }); const result = await invokeProvider(store, channel(store), { taskId: `invalid-output-${index}`, taskType: "image", requestModelId: "base-model", prompt: "测试", count: 1, platformIdempotencyKey: `invalid-output-${index}` });
assert.equal(result.status, "failed"); assert.equal(result.status, "failed");
assert.equal(result.errorCode, "PROVIDER_INVALID_OUTPUT"); assert.equal(result.errorCode, "PROVIDER_INVALID_OUTPUT");
assert.equal(result.retryable, false); assert.equal(result.retryable, false);
@@ -211,7 +215,7 @@ test("redirected image output URLs are rejected as invalid output", async () =>
return new Response(null, { status: 302, headers: { location: "https://example.com/private.png" } }); return new Response(null, { status: 302, headers: { location: "https://example.com/private.png" } });
}; };
try { try {
const result = await invokeProvider(store, channel(store), { taskId: "redirect-output", taskType: "image", prompt: "测试", count: 1, platformIdempotencyKey: "redirect-output" }); const result = await invokeProvider(store, channel(store), { taskId: "redirect-output", taskType: "image", requestModelId: "base-model", prompt: "测试", count: 1, platformIdempotencyKey: "redirect-output" });
assert.equal(result.status, "failed"); assert.equal(result.status, "failed");
assert.equal(result.errorCode, "PROVIDER_INVALID_OUTPUT"); assert.equal(result.errorCode, "PROVIDER_INVALID_OUTPUT");
assert.equal(result.retryable, false); assert.equal(result.retryable, false);
@@ -229,7 +233,7 @@ test("image output metadata prefers dimensions decoded from the returned bytes",
return new Response(null, { status: 500 }); return new Response(null, { status: 500 });
}; };
try { try {
const result = await invokeProvider(store, channel(store), { taskId: "metadata-bytes", taskType: "image", prompt: "测试", count: 1, platformIdempotencyKey: "metadata-bytes" }); const result = await invokeProvider(store, channel(store), { taskId: "metadata-bytes", taskType: "image", requestModelId: "base-model", prompt: "测试", count: 1, platformIdempotencyKey: "metadata-bytes" });
assert.equal(result.status, "succeeded"); assert.equal(result.status, "succeeded");
assert.deepEqual(result.outputs?.[0]?.metadata, { width: 1, height: 1, format: "png", size: "1x1", revisedPrompt: undefined, usage: undefined, source: "base64" }); assert.deepEqual(result.outputs?.[0]?.metadata, { width: 1, height: 1, format: "png", size: "1x1", revisedPrompt: undefined, usage: undefined, source: "base64" });
} finally { } finally {
@@ -248,10 +252,10 @@ test("image references validate mentions and add a default reference instruction
return new Response(JSON.stringify({ data: [{ b64_json: png.toString("base64") }] }), { status: 200, headers: { "content-type": "application/json" } }); return new Response(JSON.stringify({ data: [{ b64_json: png.toString("base64") }] }), { status: 200, headers: { "content-type": "application/json" } });
}; };
try { try {
const result = await invokeProvider(store, channel(store), { taskId: "task-6", taskType: "image", prompt: "请保持主体", count: 1, referenceImages: [{ objectId: "ref-a", seq: 1, name: "图片一" }], platformIdempotencyKey: "idem-6" }); const result = await invokeProvider(store, channel(store), { taskId: "task-6", taskType: "image", requestModelId: "base-model", prompt: "请保持主体", count: 1, referenceImages: [{ objectId: "ref-a", seq: 1, name: "图片一" }], platformIdempotencyKey: "idem-6" });
assert.equal(result.status, "succeeded"); assert.equal(result.status, "succeeded");
assert.equal(prompts[0], "请保持主体\n基于以下参考图生成。"); assert.equal(prompts[0], "请保持主体\n基于以下参考图生成。");
const missing = await invokeProvider(store, channel(store), { taskId: "task-7", taskType: "image", prompt: "使用@图片二", count: 1, referenceImages: [{ objectId: "ref-a", seq: 1, name: "图片一" }], platformIdempotencyKey: "idem-7" }); const missing = await invokeProvider(store, channel(store), { taskId: "task-7", taskType: "image", requestModelId: "base-model", prompt: "使用@图片二", count: 1, referenceImages: [{ objectId: "ref-a", seq: 1, name: "图片一" }], platformIdempotencyKey: "idem-7" });
assert.equal(missing.status, "failed"); assert.equal(missing.status, "failed");
assert.equal(missing.errorCode, "PROVIDER_REFERENCE_UNAVAILABLE"); assert.equal(missing.errorCode, "PROVIDER_REFERENCE_UNAVAILABLE");
} finally { } finally {
@@ -261,7 +265,7 @@ test("image references validate mentions and add a default reference instruction
test("invalid provider reference sequences fail closed", async () => { test("invalid provider reference sequences fail closed", async () => {
const store = createStore(); const store = createStore();
const result = await invokeProvider(store, channel(store), { taskId: "task-8", taskType: "image", prompt: "测试", count: 1, referenceImages: [{ objectId: "ref-a", seq: 17, name: "图片十七" }], platformIdempotencyKey: "idem-8" }); const result = await invokeProvider(store, channel(store), { taskId: "task-8", taskType: "image", requestModelId: "base-model", prompt: "测试", count: 1, referenceImages: [{ objectId: "ref-a", seq: 17, name: "图片十七" }], platformIdempotencyKey: "idem-8" });
assert.equal(result.status, "failed"); assert.equal(result.status, "failed");
assert.equal(result.errorCode, "PROVIDER_REFERENCE_INVALID"); assert.equal(result.errorCode, "PROVIDER_REFERENCE_INVALID");
}); });
@@ -269,7 +273,7 @@ test("invalid provider reference sequences fail closed", async () => {
test("duplicate reference sequences are rejected instead of producing ambiguous 图片 names", async () => { test("duplicate reference sequences are rejected instead of producing ambiguous 图片 names", async () => {
const store = createStore(); const store = createStore();
for (const objectId of ["duplicate-seq-a", "duplicate-seq-b"]) store.objects.set(objectId, { id: objectId, ownerId: "user-1", mimeType: "image/png", data: png.toString("base64"), expiresAt: new Date(Date.now() + 60_000).toISOString() }); for (const objectId of ["duplicate-seq-a", "duplicate-seq-b"]) store.objects.set(objectId, { id: objectId, ownerId: "user-1", mimeType: "image/png", data: png.toString("base64"), expiresAt: new Date(Date.now() + 60_000).toISOString() });
const result = await invokeProvider(store, channel(store), { taskId: "duplicate-seq", taskType: "image", prompt: "测试", count: 1, referenceImages: [{ objectId: "duplicate-seq-a", seq: 1, name: "图片一" }, { objectId: "duplicate-seq-b", seq: 1, name: "图片一" }], platformIdempotencyKey: "duplicate-seq" }); const result = await invokeProvider(store, channel(store), { taskId: "duplicate-seq", taskType: "image", requestModelId: "base-model", prompt: "测试", count: 1, referenceImages: [{ objectId: "duplicate-seq-a", seq: 1, name: "图片一" }, { objectId: "duplicate-seq-b", seq: 1, name: "图片一" }], platformIdempotencyKey: "duplicate-seq" });
assert.equal(result.status, "failed"); assert.equal(result.status, "failed");
assert.equal(result.errorCode, "PROVIDER_REFERENCE_INVALID"); assert.equal(result.errorCode, "PROVIDER_REFERENCE_INVALID");
}); });
@@ -281,7 +285,7 @@ test("provider image requests reject non-positive or fractional counts", async (
globalThis.fetch = async () => { called = true; return new Response(JSON.stringify({ data: [] }), { status: 200 }); }; globalThis.fetch = async () => { called = true; return new Response(JSON.stringify({ data: [] }), { status: 200 }); };
try { try {
for (const count of [0, -1, 1.5]) { for (const count of [0, -1, 1.5]) {
const result = await invokeProvider(store, channel(store), { taskId: `invalid-count-${count}`, taskType: "image", prompt: "测试", count, platformIdempotencyKey: `invalid-count-${String(count).replace(".", "-")}` }); const result = await invokeProvider(store, channel(store), { taskId: `invalid-count-${count}`, taskType: "image", requestModelId: "base-model", prompt: "测试", count, platformIdempotencyKey: `invalid-count-${String(count).replace(".", "-")}` });
assert.equal(result.status, "failed"); assert.equal(result.status, "failed");
assert.equal(result.errorCode, "PROVIDER_INVALID_REQUEST"); assert.equal(result.errorCode, "PROVIDER_INVALID_REQUEST");
} }
@@ -305,9 +309,9 @@ test("OpenAI image edits accept the full sixteen-reference boundary and reject t
return new Response(JSON.stringify({ data: [{ b64_json: png.toString("base64") }] }), { status: 200, headers: { "content-type": "application/json" } }); return new Response(JSON.stringify({ data: [{ b64_json: png.toString("base64") }] }), { status: 200, headers: { "content-type": "application/json" } });
}; };
try { try {
const accepted = await invokeProvider(store, channel(store), { taskId: "sixteen-references", taskType: "image", prompt: "组合参考图", count: 1, referenceImages: references, platformIdempotencyKey: "sixteen-references" }); const accepted = await invokeProvider(store, channel(store), { taskId: "sixteen-references", taskType: "image", requestModelId: "base-model", prompt: "组合参考图", count: 1, referenceImages: references, platformIdempotencyKey: "sixteen-references" });
assert.equal(accepted.status, "succeeded"); assert.equal(accepted.status, "succeeded");
const rejected = await invokeProvider(store, channel(store), { taskId: "seventeen-references", taskType: "image", prompt: "组合参考图", count: 1, referenceImages: [...references, { objectId: "boundary-ref-1", seq: 17, name: "图片十七" }], platformIdempotencyKey: "seventeen-references" }); const rejected = await invokeProvider(store, channel(store), { taskId: "seventeen-references", taskType: "image", requestModelId: "base-model", prompt: "组合参考图", count: 1, referenceImages: [...references, { objectId: "boundary-ref-1", seq: 17, name: "图片十七" }], platformIdempotencyKey: "seventeen-references" });
assert.equal(rejected.status, "failed"); assert.equal(rejected.status, "failed");
assert.equal(rejected.errorCode, "PROVIDER_REFERENCE_LIMIT"); assert.equal(rejected.errorCode, "PROVIDER_REFERENCE_LIMIT");
} finally { } finally {
@@ -331,7 +335,7 @@ test("provider output metadata detects JPEG and WebP bytes instead of trusting a
try { try {
for (const [index, suffix] of ["jpeg", "webp"].entries()) { for (const [index, suffix] of ["jpeg", "webp"].entries()) {
requestedFormat = suffix; requestedFormat = suffix;
const result = await invokeProvider(store, channel(store), { taskId: `format-${suffix}`, taskType: "image", prompt: "格式测试", count: 1, params: { output_format: suffix }, platformIdempotencyKey: `format-${index}` }); const result = await invokeProvider(store, channel(store), { taskId: `format-${suffix}`, taskType: "image", requestModelId: "base-model", prompt: "格式测试", count: 1, params: { output_format: suffix }, platformIdempotencyKey: `format-${index}` });
assert.equal(result.status, "succeeded"); assert.equal(result.status, "succeeded");
assert.equal(result.outputs?.[0]?.mimeType, `image/${suffix}`); assert.equal(result.outputs?.[0]?.mimeType, `image/${suffix}`);
assert.deepEqual(result.outputs?.[0]?.metadata && { width: result.outputs[0].metadata.width, height: result.outputs[0].metadata.height, format: result.outputs[0].metadata.format }, { width: 1, height: 1, format: suffix }); assert.deepEqual(result.outputs?.[0]?.metadata && { width: result.outputs[0].metadata.width, height: result.outputs[0].metadata.height, format: result.outputs[0].metadata.format }, { width: 1, height: 1, format: suffix });
@@ -356,12 +360,12 @@ test("provider probes use GET and Bearer authentication, and transport failures
const probe = await probeProviderModels("https://1.1.1.1/v1", "1234567890123456"); const probe = await probeProviderModels("https://1.1.1.1/v1", "1234567890123456");
assert.deepEqual(probe, { healthy: true, models: ["gpt-image-2"] }); assert.deepEqual(probe, { healthy: true, models: ["gpt-image-2"] });
assert.deepEqual(probeRequests, [{ method: "GET", authorization: "Bearer 1234567890123456" }]); assert.deepEqual(probeRequests, [{ method: "GET", authorization: "Bearer 1234567890123456" }]);
const unknown = await invokeProvider(store, channel(store), { taskId: "transport-unknown", taskType: "image", prompt: "网络中断", count: 1, platformIdempotencyKey: "transport-unknown" }); const unknown = await invokeProvider(store, channel(store), { taskId: "transport-unknown", taskType: "image", requestModelId: "base-model", prompt: "网络中断", count: 1, platformIdempotencyKey: "transport-unknown" });
assert.equal(unknown.status, "unknown"); assert.equal(unknown.status, "unknown");
assert.equal(unknown.errorCode, "PROVIDER_TRANSPORT_UNKNOWN"); assert.equal(unknown.errorCode, "PROVIDER_TRANSPORT_UNKNOWN");
const abortedFetch = globalThis.fetch; const abortedFetch = globalThis.fetch;
globalThis.fetch = async () => { throw Object.assign(new Error("timeout"), { name: "AbortError" }); }; globalThis.fetch = async () => { throw Object.assign(new Error("timeout"), { name: "AbortError" }); };
const timeout = await invokeProvider(store, channel(store), { taskId: "timeout-unknown", taskType: "image", prompt: "超时", count: 1, platformIdempotencyKey: "timeout-unknown" }); const timeout = await invokeProvider(store, channel(store), { taskId: "timeout-unknown", taskType: "image", requestModelId: "base-model", prompt: "超时", count: 1, platformIdempotencyKey: "timeout-unknown" });
assert.equal(timeout.status, "unknown"); assert.equal(timeout.status, "unknown");
assert.equal(timeout.errorCode, "PROVIDER_TIMEOUT_UNKNOWN"); assert.equal(timeout.errorCode, "PROVIDER_TIMEOUT_UNKNOWN");
globalThis.fetch = async () => { throw new Error("probe transport failed"); }; globalThis.fetch = async () => { throw new Error("probe transport failed"); };
+4 -4
View File
@@ -119,7 +119,7 @@ test("admin RBAC rejects finance access and redacts support channel internals",
const { store, server, base } = await start("support"); const { store, server, base } = await start("support");
try { try {
store.channels[0].providerName = "internal-provider"; store.channels[0].providerName = "internal-provider";
store.channels[0].providerModelId = "internal-model"; store.channels[0].models[0].requestModelId = "internal-model";
store.channels[0].baseUrl = "https://provider.example.com"; store.channels[0].baseUrl = "https://provider.example.com";
const login = await fetch(`${base}/api/v1/admin/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "admin@example.com", password: "change-me-now" }) }); const login = await fetch(`${base}/api/v1/admin/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "admin@example.com", password: "change-me-now" }) });
const loginPayload = await login.json() as { data: { accessToken: string } }; const loginPayload = await login.json() as { data: { accessToken: string } };
@@ -165,12 +165,12 @@ test("OpenAI image channel creation does not probe implicitly and explicit probi
return previousFetch(input, init); return previousFetch(input, init);
}; };
const auth = { authorization: `Bearer ${loginPayload.data.accessToken}`, "content-type": "application/json" }; const auth = { authorization: `Bearer ${loginPayload.data.accessToken}`, "content-type": "application/json" };
const created = await fetch(`${base}/api/v1/admin/channels`, { method: "POST", headers: { ...auth, "idempotency-key": "openai-channel-create" }, body: JSON.stringify({ label: "OpenAI 图片渠道", providerType: "openai-images", providerModelId: "gpt-image-2", baseUrl: "https://1.1.1.1/v1", secretRef: "k", resolutionModelMap: { "1K": "gpt-image-2", "2K": "gpt-image-2-2k", invalid: "should-drop" } }) }); const created = await fetch(`${base}/api/v1/admin/channels`, { method: "POST", headers: { ...auth, "idempotency-key": "openai-channel-create" }, body: JSON.stringify({ label: "OpenAI 图片渠道", providerType: "openai-images", models: [{ requestModelId: "gpt-image-2", source: "manual", enabled: true }, { requestModelId: "gpt-image-2-2k", source: "manual", enabled: true }], baseUrl: "https://1.1.1.1/v1", secretRef: "k" }) });
assert.equal(created.status, 201); assert.equal(created.status, 201);
const createdPayload = await created.json() as { data: { item: Record<string, unknown> } }; const createdPayload = await created.json() as { data: { item: Record<string, unknown> } };
assert.equal(createdPayload.data.item.secretRef, "configured"); assert.equal(createdPayload.data.item.secretRef, "configured");
assert.equal(createdPayload.data.item.baseUrl, "https://1.1.1.1/v1"); assert.equal(createdPayload.data.item.baseUrl, "https://1.1.1.1/v1");
assert.deepEqual(createdPayload.data.item.resolutionModelMap, { "1K": "gpt-image-2", "2K": "gpt-image-2-2k" }); assert.deepEqual(createdPayload.data.item.models, [{ requestModelId: "gpt-image-2", source: "manual", enabled: true }, { requestModelId: "gpt-image-2-2k", source: "manual", enabled: true }]);
const channel = store.channels.find((item) => item.id === createdPayload.data.item.id); const channel = store.channels.find((item) => item.id === createdPayload.data.item.id);
assert.ok(channel); assert.ok(channel);
assert.notEqual(channel.secretRef, "k"); assert.notEqual(channel.secretRef, "k");
@@ -182,7 +182,7 @@ test("OpenAI image channel creation does not probe implicitly and explicit probi
assert.equal(probeRequests[0]?.method, "GET"); assert.equal(probeRequests[0]?.method, "GET");
assert.equal(probeRequests[0]?.authorization, "Bearer k"); assert.equal(probeRequests[0]?.authorization, "Bearer k");
const invalid = await fetch(`${base}/api/v1/admin/channels`, { method: "POST", headers: { ...auth, "idempotency-key": "openai-channel-http" }, body: JSON.stringify({ label: "不安全渠道", providerType: "openai-images", providerModelId: "gpt-image-2", baseUrl: "http://1.1.1.1/v1", secretRef: "1234567890123456" }) }); const invalid = await fetch(`${base}/api/v1/admin/channels`, { method: "POST", headers: { ...auth, "idempotency-key": "openai-channel-http" }, body: JSON.stringify({ label: "不安全渠道", providerType: "openai-images", models: [{ requestModelId: "gpt-image-2", source: "manual", enabled: true }], baseUrl: "http://1.1.1.1/v1", secretRef: "1234567890123456" }) });
assert.equal(invalid.status, 201); assert.equal(invalid.status, 201);
assert.equal(store.channels.some((item) => item.label === "不安全渠道"), true); assert.equal(store.channels.some((item) => item.label === "不安全渠道"), true);
} finally { } finally {
+52 -35
View File
@@ -143,7 +143,7 @@ test("admin image model products publish capability parameters and require a res
const login = await fetch(`${base}/api/v1/admin/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "admin@example.com", password: "change-me-now" }) }); const login = await fetch(`${base}/api/v1/admin/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "admin@example.com", password: "change-me-now" }) });
const loginPayload = await login.json() as { data: { accessToken: string } }; const loginPayload = await login.json() as { data: { accessToken: string } };
const headers = { authorization: `Bearer ${loginPayload.data.accessToken}`, "content-type": "application/json" }; const headers = { authorization: `Bearer ${loginPayload.data.accessToken}`, "content-type": "application/json" };
const created = await fetch(`${base}/api/v1/admin/model-products`, { method: "POST", headers: { ...headers, "idempotency-key": "model-capability-create" }, body: JSON.stringify({ id: "openai-image-product", publicModelId: "gpt-image-2", name: "OpenAI 图片模型", capabilities: ["image"], channelGroupId: "image-default", basePrice: 20, resolutions: [{ id: "1K", label: "1K", width: 1024, height: 1024, priceMultiplier: 1, ratios: ["1:1", "16:9"] }] }) }); const created = await fetch(`${base}/api/v1/admin/model-products`, { method: "POST", headers: { ...headers, "idempotency-key": "model-capability-create" }, body: JSON.stringify({ id: "openai-image-product", publicModelId: "gpt-image-2", name: "OpenAI 图片模型", capabilities: ["image"], enabled: false, resolutions: [{ id: "1K", label: "1K", width: 1024, height: 1024, ratios: ["1:1", "16:9"] }] }) });
assert.equal(created.status, 201); assert.equal(created.status, 201);
const createdPayload = await created.json() as { data: { item: { capabilitiesSchema?: Array<{ capability: string; parameters?: Record<string, { enum?: string[] }> }> } } }; const createdPayload = await created.json() as { data: { item: { capabilitiesSchema?: Array<{ capability: string; parameters?: Record<string, { enum?: string[] }> }> } } };
const imageSchema = createdPayload.data.item.capabilitiesSchema?.find((item) => item.capability === "image"); const imageSchema = createdPayload.data.item.capabilitiesSchema?.find((item) => item.capability === "image");
@@ -152,69 +152,78 @@ test("admin image model products publish capability parameters and require a res
assert.deepEqual(imageSchema.parameters?.resolution.enum, ["1K", "2K", "4K"]); assert.deepEqual(imageSchema.parameters?.resolution.enum, ["1K", "2K", "4K"]);
assert.deepEqual(imageSchema.parameters?.input_fidelity.enum, ["low", "high"]); assert.deepEqual(imageSchema.parameters?.input_fidelity.enum, ["low", "high"]);
const invalid = await fetch(`${base}/api/v1/admin/model-products`, { method: "POST", headers: { ...headers, "idempotency-key": "model-capability-invalid" }, body: JSON.stringify({ id: "openai-image-invalid", publicModelId: "gpt-image-invalid", name: "无分辨率图片模型", capabilities: ["image"], channelGroupId: "image-default", basePrice: 20, resolutions: [] }) }); const invalid = await fetch(`${base}/api/v1/admin/model-products`, { method: "POST", headers: { ...headers, "idempotency-key": "model-capability-invalid" }, body: JSON.stringify({ id: "openai-image-invalid", publicModelId: "gpt-image-invalid", name: "无分辨率图片模型", capabilities: ["image"], enabled: true, resolutions: [] }) });
assert.equal(invalid.status, 422); assert.equal(invalid.status, 422);
} finally { } finally {
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
} }
}); });
test("admin channel groups bind model products separately from request channels", async () => { test("admin channel groups keep model routes separate from public model records", async () => {
const { server, base, store } = await start(); const { server, base, store } = await start();
try { try {
const login = await fetch(`${base}/api/v1/admin/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "admin@example.com", password: "change-me-now" }) }); const login = await fetch(`${base}/api/v1/admin/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "admin@example.com", password: "change-me-now" }) });
const loginPayload = await login.json() as { data: { accessToken: string } }; const loginPayload = await login.json() as { data: { accessToken: string } };
const headers = { authorization: `Bearer ${loginPayload.data.accessToken}`, "content-type": "application/json" }; const headers = { authorization: `Bearer ${loginPayload.data.accessToken}`, "content-type": "application/json" };
const firstProduct = await fetch(`${base}/api/v1/admin/model-products`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-product-first" }, body: JSON.stringify({ id: "group-basic-image", publicModelId: "group-basic-image-v1", name: "分组基础图片", capabilities: ["image"], enabled: false, basePrice: 10, resolutions: [{ id: "1K", label: "1K", width: 1024, height: 1024, priceMultiplier: 1, ratios: ["1:1"] }] }) }); const firstProduct = await fetch(`${base}/api/v1/admin/model-products`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-product-first" }, body: JSON.stringify({ id: "group-basic-image", publicModelId: "group-basic-image-v1", name: "分组基础图片", capabilities: ["image"], enabled: false, resolutions: [{ id: "1K", label: "1K", width: 1024, height: 1024, ratios: ["1:1"] }] }) });
assert.equal(firstProduct.status, 201); assert.equal(firstProduct.status, 201);
const secondProduct = await fetch(`${base}/api/v1/admin/model-products`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-product-second" }, body: JSON.stringify({ id: "group-advanced-image", publicModelId: "group-advanced-image-v1", name: "分组高级图片", capabilities: ["image"], enabled: false, basePrice: 25, resolutions: [{ id: "2K", label: "2K", width: 2048, height: 2048, priceMultiplier: 1.5, ratios: ["1:1"] }] }) }); const secondProduct = await fetch(`${base}/api/v1/admin/model-products`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-product-second" }, body: JSON.stringify({ id: "group-advanced-image", publicModelId: "group-advanced-image-v1", name: "分组高级图片", capabilities: ["image"], enabled: false, resolutions: [{ id: "2K", label: "2K", width: 2048, height: 2048, ratios: ["1:1"] }] }) });
assert.equal(secondProduct.status, 201); assert.equal(secondProduct.status, 201);
const channel = store.channels.find((item) => item.id === "channel-a")!;
const channelUpdate = await fetch(`${base}/api/v1/admin/channels/channel-a`, { method: "PATCH", headers: { ...headers, "If-Match": String(channel.version), "idempotency-key": "channel-group-product-models" }, body: JSON.stringify({ models: [...channel.models, { requestModelId: "group-basic-image-v1", source: "manual", enabled: true }, { requestModelId: "group-advanced-image-v1", source: "manual", enabled: true }] }) });
assert.equal(channelUpdate.status, 200);
const created = await fetch(`${base}/api/v1/admin/channel-groups`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-product-bind" }, body: JSON.stringify({ id: "image-product-group", name: "图片产品组", capabilities: ["image"], productIds: ["group-basic-image"], channelIds: ["channel-a"], retryBudget: 2 }) }); const created = await fetch(`${base}/api/v1/admin/channel-groups`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-product-bind" }, body: JSON.stringify({ id: "image-product-group", name: "图片产品组", capabilities: ["image"], productIds: ["group-basic-image"], channelIds: ["channel-a"], retryBudget: 2 }) });
assert.equal(created.status, 201); assert.equal(created.status, 422);
const createdPayload = await created.json() as { data: { item: { productIds?: string[] } } }; const createdWithRoutes = await fetch(`${base}/api/v1/admin/channel-groups`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-product-bind-routes" }, body: JSON.stringify({ id: "image-product-group", name: "图片产品组", capabilities: ["image"], routes: [{ publicModelId: "group-basic-image-v1", channels: [{ channelId: "channel-a", priorityTier: 1, orderWithinTier: 1, retryBudget: 1 }], strategy: "strict", retryBudget: 2, version: 1 }] }) });
assert.deepEqual(createdPayload.data.item.productIds, ["group-basic-image"]); assert.equal(createdWithRoutes.status, 201);
assert.equal(store.products.find((product) => product.id === "group-basic-image")?.channelGroupId, "image-product-group"); const createdPayload = await createdWithRoutes.json() as { data: { item: { routes: Array<{ publicModelId: string }> } } };
assert.deepEqual(createdPayload.data.item.routes.map((route) => route.publicModelId), ["group-basic-image-v1"]);
assert.equal(store.products.find((product) => product.id === "group-basic-image")?.enabled, false);
const listed = await fetch(`${base}/api/v1/admin/channel-groups`, { headers }); const listed = await fetch(`${base}/api/v1/admin/channel-groups`, { headers });
assert.equal(listed.status, 200); assert.equal(listed.status, 200);
const listedPayload = await listed.json() as { data: { items: Array<{ id: string; productIds?: string[]; products?: Array<{ productId: string }> }> } }; const listedPayload = await listed.json() as { data: { items: Array<{ id: string; routes?: Array<{ publicModelId: string }>; products?: Array<{ productId: string }> }> } };
const listedGroup = listedPayload.data.items.find((item) => item.id === "image-product-group"); const listedGroup = listedPayload.data.items.find((item) => item.id === "image-product-group");
assert.deepEqual(listedGroup?.productIds, ["group-basic-image"]); assert.deepEqual(listedGroup?.routes?.map((route) => route.publicModelId), ["group-basic-image-v1"]);
assert.deepEqual(listedGroup?.products?.map((product) => product.productId), ["group-basic-image"]); assert.deepEqual(listedGroup?.products?.map((product) => product.productId), ["group-basic-image"]);
const edited = await fetch(`${base}/api/v1/admin/channel-groups/image-product-group`, { method: "PATCH", headers: { ...headers, "If-Match": "1", "idempotency-key": "channel-group-product-rebind" }, body: JSON.stringify({ productIds: ["group-advanced-image"], capabilities: ["image"], channelIds: ["channel-a"], retryBudget: 2, enabled: true }) }); const edited = await fetch(`${base}/api/v1/admin/channel-groups/image-product-group`, { method: "PATCH", headers: { ...headers, "If-Match": "1", "idempotency-key": "channel-group-product-rebind" }, body: JSON.stringify({ productIds: ["group-advanced-image"], capabilities: ["image"], channelIds: ["channel-a"], retryBudget: 2, enabled: true }) });
assert.equal(edited.status, 200); assert.equal(edited.status, 422);
assert.equal(store.products.find((product) => product.id === "group-basic-image")?.channelGroupId, undefined); const editedWithRoutes = await fetch(`${base}/api/v1/admin/channel-groups/image-product-group`, { method: "PATCH", headers: { ...headers, "If-Match": "1", "idempotency-key": "channel-group-product-rebind-routes" }, body: JSON.stringify({ routes: [{ publicModelId: "group-advanced-image-v1", channels: [{ channelId: "channel-a", priorityTier: 1, orderWithinTier: 1, retryBudget: 1 }], strategy: "strict", retryBudget: 2, version: 1 }], capabilities: ["image"], enabled: true }) });
assert.equal(editedWithRoutes.status, 200);
assert.deepEqual(store.channelGroups.find((group) => group.id === "image-product-group")?.routes.map((route) => route.publicModelId), ["group-advanced-image-v1"]);
assert.equal(store.products.find((product) => product.id === "group-basic-image")?.enabled, false); assert.equal(store.products.find((product) => product.id === "group-basic-image")?.enabled, false);
assert.equal(store.products.find((product) => product.id === "group-advanced-image")?.channelGroupId, "image-product-group");
} finally { } finally {
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
} }
}); });
test("admin channel groups reject incompatible or silently migrated model products", async () => { test("admin channel groups reject incompatible routes and legacy model binding fields", async () => {
const { server, base, store } = await start(); const { server, base, store } = await start();
try { try {
const login = await fetch(`${base}/api/v1/admin/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "admin@example.com", password: "change-me-now" }) }); const login = await fetch(`${base}/api/v1/admin/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "admin@example.com", password: "change-me-now" }) });
const loginPayload = await login.json() as { data: { accessToken: string } }; const loginPayload = await login.json() as { data: { accessToken: string } };
const headers = { authorization: `Bearer ${loginPayload.data.accessToken}`, "content-type": "application/json" }; const headers = { authorization: `Bearer ${loginPayload.data.accessToken}`, "content-type": "application/json" };
const imageGroup = await fetch(`${base}/api/v1/admin/channel-groups`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-capability-image" }, body: JSON.stringify({ id: "capability-image-group", name: "图片能力组", capabilities: ["image"], productIds: [], channelIds: ["channel-a"], retryBudget: 1 }) }); const channel = store.channels.find((item) => item.id === "channel-a")!;
assert.equal(imageGroup.status, 201); const channelUpdate = await fetch(`${base}/api/v1/admin/channels/channel-a`, { method: "PATCH", headers: { ...headers, "If-Match": String(channel.version), "idempotency-key": "channel-group-capability-model" }, body: JSON.stringify({ models: [...channel.models, { requestModelId: "capability-image-v1", source: "manual", enabled: true }] }) });
const product = await fetch(`${base}/api/v1/admin/model-products`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-capability-product" }, body: JSON.stringify({ id: "capability-image-product", publicModelId: "capability-image-v1", name: "能力校验图片产品", capabilities: ["image"], channelGroupId: "capability-image-group", basePrice: 5, resolutions: [{ id: "1K", label: "1K", width: 1024, height: 1024, priceMultiplier: 1, ratios: ["1:1"] }] }) }); assert.equal(channelUpdate.status, 200);
const product = await fetch(`${base}/api/v1/admin/model-products`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-capability-product" }, body: JSON.stringify({ id: "capability-image-product", publicModelId: "capability-image-v1", name: "能力校验图片产品", capabilities: ["image"], enabled: false, resolutions: [{ id: "1K", label: "1K", width: 1024, height: 1024, ratios: ["1:1"] }] }) });
assert.equal(product.status, 201); assert.equal(product.status, 201);
const textGroup = await fetch(`${base}/api/v1/admin/channel-groups`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-capability-text" }, body: JSON.stringify({ id: "capability-text-group", name: "文字能力组", capabilities: ["text"], productIds: [], channelIds: ["channel-a"], retryBudget: 1 }) }); const imageGroup = await fetch(`${base}/api/v1/admin/channel-groups`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-capability-image" }, body: JSON.stringify({ id: "capability-image-group", name: "图片能力组", capabilities: ["image"], routes: [{ publicModelId: "capability-image-v1", channels: [{ channelId: "channel-a", priorityTier: 1, orderWithinTier: 1, retryBudget: 1 }], strategy: "strict", retryBudget: 1, version: 1 }] }) });
assert.equal(imageGroup.status, 201);
const textGroup = await fetch(`${base}/api/v1/admin/channel-groups`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-capability-text" }, body: JSON.stringify({ id: "capability-text-group", name: "文字能力组", capabilities: ["text"], routes: [] }) });
assert.equal(textGroup.status, 201); assert.equal(textGroup.status, 201);
const incompatibleMode = await fetch(`${base}/api/v1/admin/channel-groups/capability-image-group`, { method: "PATCH", headers: { ...headers, "If-Match": "1", "idempotency-key": "channel-group-capability-change" }, body: JSON.stringify({ capabilities: ["text"] }) }); const incompatibleMode = await fetch(`${base}/api/v1/admin/channel-groups/capability-image-group`, { method: "PATCH", headers: { ...headers, "If-Match": "1", "idempotency-key": "channel-group-capability-change" }, body: JSON.stringify({ capabilities: ["text"] }) });
assert.equal(incompatibleMode.status, 422); assert.equal(incompatibleMode.status, 422);
assert.deepEqual(store.channelGroups.find((group) => group.id === "capability-image-group")?.capabilities, ["image"]); assert.deepEqual(store.channelGroups.find((group) => group.id === "capability-image-group")?.capabilities, ["image"]);
assert.equal(store.products.find((item) => item.id === "capability-image-product")?.channelGroupId, "capability-image-group"); assert.deepEqual(store.channelGroups.find((group) => group.id === "capability-image-group")?.routes.map((route) => route.publicModelId), ["capability-image-v1"]);
const incompatibleProduct = await fetch(`${base}/api/v1/admin/model-products/capability-image-product`, { method: "PATCH", headers: { ...headers, "If-Match": "1", "idempotency-key": "channel-group-capability-product-change" }, body: JSON.stringify({ channelGroupId: "capability-text-group" }) }); const incompatibleProduct = await fetch(`${base}/api/v1/admin/model-products/capability-image-product`, { method: "PATCH", headers: { ...headers, "If-Match": "1", "idempotency-key": "channel-group-capability-product-change" }, body: JSON.stringify({ channelGroupId: "capability-text-group" }) });
assert.equal(incompatibleProduct.status, 422); assert.equal(incompatibleProduct.status, 422);
assert.equal(store.products.find((item) => item.id === "capability-image-product")?.channelGroupId, "capability-image-group"); assert.equal(store.products.find((item) => item.id === "capability-image-product")?.enabled, false);
const duplicateBinding = await fetch(`${base}/api/v1/admin/channel-groups`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-ownership-conflict" }, body: JSON.stringify({ id: "capability-other-group", name: "重复绑定组", capabilities: ["image"], productIds: ["capability-image-product"], channelIds: ["channel-a"], retryBudget: 1 }) }); const duplicateBinding = await fetch(`${base}/api/v1/admin/channel-groups`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-ownership-conflict" }, body: JSON.stringify({ id: "capability-other-group", name: "重复绑定组", capabilities: ["image"], routes: [{ publicModelId: "capability-image-v1", channels: [{ channelId: "channel-a", priorityTier: 1, orderWithinTier: 1, retryBudget: 1 }], strategy: "strict", retryBudget: 1, version: 1 }] }) });
assert.equal(duplicateBinding.status, 409); assert.equal(duplicateBinding.status, 409);
assert.equal(store.channelGroups.some((group) => group.id === "capability-other-group"), false); assert.equal(store.channelGroups.some((group) => group.id === "capability-other-group"), false);
} finally { } finally {
@@ -251,7 +260,7 @@ test("message provider and template writes survive a file-store restart", async
} }
}); });
test("admin unknown-task reconciliation stores real image metadata and clamps the charge", async () => { test("admin unknown-task reconciliation rejects overcharge and stores real image metadata", async () => {
const { store, server, base } = await start(); const { store, server, base } = await start();
const taskId = "reconcile-image-task"; const taskId = "reconcile-image-task";
const ownerId = "reconcile-user"; const ownerId = "reconcile-user";
@@ -267,13 +276,14 @@ test("admin unknown-task reconciliation stores real image metadata and clamps th
status: "unknown", status: "unknown",
estimatedCost: 10, estimatedCost: 10,
reservedCost: 10, reservedCost: 10,
count: 2, count: 1,
pricingSnapshot: { basePrice: 5, multiplier: 1, unitVersion: 1 }, pricingSnapshot: { ruleId: "price-basic-image", ruleVersion: 1, publicModelId: "basic-image-v1", resolutionId: "low", unitPrice: 10, quantity: 1, billingUnit: "output", multiplier: 1, balanceUnitVersion: 1 },
modelSnapshot: { publicModelId: "basic-image-v1", modelVersion: 1, name: "基础模型", tier: "basic", capabilities: ["image"], resolutionId: "low", resolution: { id: "low", label: "低", width: 1024, height: 1024, ratios: ["1:1"] } },
routeSnapshot: { groupId: "image-default", groupVersion: 1, publicModelId: "basic-image-v1", routeVersion: 1, orderedCandidates: [{ channelId: "channel-a", priorityTier: 1, orderWithinTier: 1, retryBudget: 0, requestModelId: "basic-image-v1" }], strategy: "strict", totalRetryBudget: 0 },
planSnapshot: { queuePriority: 0, maxConcurrent: 1 },
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
eventSequence: 0, eventSequence: 0,
channelGroupId: "image-default",
routeSnapshotVersion: 1,
attempts: [{ id: "reconcile-attempt", channelId: "channel-a", sequence: 1, status: "unknown", startedAt: now, reconciliationStatus: "pending" }], attempts: [{ id: "reconcile-attempt", channelId: "channel-a", sequence: 1, status: "unknown", startedAt: now, reconciliationStatus: "pending" }],
outputs: [], outputs: [],
inputSnapshot: { references: [], params: { resolution: "2K" } }, inputSnapshot: { references: [], params: { resolution: "2K" } },
@@ -287,11 +297,15 @@ test("admin unknown-task reconciliation stores real image metadata and clamps th
assert.equal(store.tasks.get(taskId)?.status, "unknown"); assert.equal(store.tasks.get(taskId)?.status, "unknown");
assert.equal(store.balances.get(ownerId)?.reserved, 10); assert.equal(store.balances.get(ownerId)?.reserved, 10);
const form = new FormData(); form.set("outcome", "success"); form.set("chargedAmount", "99"); form.append("file", new Blob([Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAF/gL+XwAAAABJRU5ErkJggg==", "base64")], { type: "image/png" }), "result.png"); const form = new FormData(); form.set("outcome", "success"); form.set("chargedAmount", "99"); form.append("file", new Blob([Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAF/gL+XwAAAABJRU5ErkJggg==", "base64")], { type: "image/png" }), "result.png");
const overcharged = await fetch(`${base}/api/v1/admin/tasks/${taskId}/reconcile`, { method: "POST", headers: { authorization: `Bearer ${loginPayload.data.accessToken}`, "idempotency-key": "reconcile-image-overcharge", "if-match": "0" }, body: form });
assert.equal(overcharged.status, 422);
assert.equal(store.tasks.get(taskId)?.status, "unknown");
form.set("chargedAmount", "10");
const response = await fetch(`${base}/api/v1/admin/tasks/${taskId}/reconcile`, { method: "POST", headers: { authorization: `Bearer ${loginPayload.data.accessToken}`, "idempotency-key": "reconcile-image-success", "if-match": "0" }, body: form }); const response = await fetch(`${base}/api/v1/admin/tasks/${taskId}/reconcile`, { method: "POST", headers: { authorization: `Bearer ${loginPayload.data.accessToken}`, "idempotency-key": "reconcile-image-success", "if-match": "0" }, body: form });
assert.equal(response.status, 200); assert.equal(response.status, 200);
const payload = await response.json() as { data: { item: GenerationTask } }; const payload = await response.json() as { data: { item: GenerationTask } };
const settledEntries = store.ledger.filter((entry) => entry.type === "settle" && entry.referenceId === taskId).length; const settledEntries = store.ledger.filter((entry) => entry.type === "settle" && entry.referenceId === taskId).length;
const repeatedForm = new FormData(); repeatedForm.set("outcome", "success"); repeatedForm.set("chargedAmount", "99"); repeatedForm.append("file", new Blob([Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAF/gL+XwAAAABJRU5ErkJggg==", "base64")], { type: "image/png" }), "result.png"); const repeatedForm = new FormData(); repeatedForm.set("outcome", "success"); repeatedForm.set("chargedAmount", "10"); repeatedForm.append("file", new Blob([Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAF/gL+XwAAAABJRU5ErkJggg==", "base64")], { type: "image/png" }), "result.png");
const repeated = await fetch(`${base}/api/v1/admin/tasks/${taskId}/reconcile`, { method: "POST", headers: { authorization: `Bearer ${loginPayload.data.accessToken}`, "idempotency-key": "reconcile-image-success", "if-match": "0" }, body: repeatedForm }); const repeated = await fetch(`${base}/api/v1/admin/tasks/${taskId}/reconcile`, { method: "POST", headers: { authorization: `Bearer ${loginPayload.data.accessToken}`, "idempotency-key": "reconcile-image-success", "if-match": "0" }, body: repeatedForm });
assert.equal(repeated.status, 200); assert.equal(repeated.status, 200);
assert.equal(store.ledger.filter((entry) => entry.type === "settle" && entry.referenceId === taskId).length, settledEntries); assert.equal(store.ledger.filter((entry) => entry.type === "settle" && entry.referenceId === taskId).length, settledEntries);
@@ -306,7 +320,7 @@ test("admin unknown-task reconciliation stores real image metadata and clamps th
assert.equal(output.metadata?.format, "png"); assert.equal(output.metadata?.format, "png");
assert.equal(output.metadata?.source, "base64"); assert.equal(output.metadata?.source, "base64");
assert.equal(output.metadata?.resolution, "2K"); assert.equal(output.metadata?.resolution, "2K");
assert.equal(output.unitPriceSnapshot, 5); assert.equal(output.unitPriceSnapshot, 10);
assert.equal(output.chargedAmount, 10); assert.equal(output.chargedAmount, 10);
assert.equal(store.balances.get(ownerId)?.reserved, 0); assert.equal(store.balances.get(ownerId)?.reserved, 0);
assert.equal(store.ledger.some((entry) => entry.type === "settle" && entry.referenceId === taskId), true); assert.equal(store.ledger.some((entry) => entry.type === "settle" && entry.referenceId === taskId), true);
@@ -333,8 +347,10 @@ test("admin unknown-task failure reconciliation releases the full reservation",
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
eventSequence: 0, eventSequence: 0,
channelGroupId: "image-default", pricingSnapshot: { ruleId: "price-basic-image", ruleVersion: 1, publicModelId: "basic-image-v1", resolutionId: "low", unitPrice: 10, quantity: 1, billingUnit: "output", multiplier: 1, balanceUnitVersion: 1 },
routeSnapshotVersion: 1, modelSnapshot: { publicModelId: "basic-image-v1", modelVersion: 1, name: "基础模型", tier: "basic", capabilities: ["image"], resolutionId: "low", resolution: { id: "low", label: "低", width: 1024, height: 1024, ratios: ["1:1"] } },
routeSnapshot: { groupId: "image-default", groupVersion: 1, publicModelId: "basic-image-v1", routeVersion: 1, orderedCandidates: [{ channelId: "channel-a", priorityTier: 1, orderWithinTier: 1, retryBudget: 0, requestModelId: "basic-image-v1" }], strategy: "strict", totalRetryBudget: 0 },
planSnapshot: { queuePriority: 0, maxConcurrent: 1 },
attempts: [{ id: "reconcile-failure-attempt", channelId: "channel-a", sequence: 1, status: "unknown", startedAt: now, reconciliationStatus: "pending" }], attempts: [{ id: "reconcile-failure-attempt", channelId: "channel-a", sequence: 1, status: "unknown", startedAt: now, reconciliationStatus: "pending" }],
outputs: [], outputs: [],
} satisfies GenerationTask); } satisfies GenerationTask);
@@ -566,12 +582,13 @@ test("task route snapshots follow channel-group priority order", async () => {
store.balances.set(user.registered.userId, { available: 100, reserved: 0 }); store.balances.set(user.registered.userId, { available: 100, reserved: 0 });
const group = store.channelGroups.find((item) => item.id === "image-default"); const group = store.channelGroups.find((item) => item.id === "image-default");
assert.ok(group); assert.ok(group);
group.channelIds = ["channel-c", "channel-a", "channel-d", "channel-b"]; const route = group.routes.find((item) => item.publicModelId === "basic-image-v1");
group.channelPriorities = { "channel-c": 0, "channel-a": 1, "channel-d": 2, "channel-b": 3 }; assert.ok(route);
route.channels = ["channel-c", "channel-a", "channel-d", "channel-b"].map((channelId, index) => ({ channelId, priorityTier: 1, orderWithinTier: index + 1, retryBudget: 0 }));
const response = await fetch(`${base}/api/v1/tasks/image`, { method: "POST", headers: { authorization: `Bearer ${user.payload.data.accessToken!}`, "content-type": "application/json", "idempotency-key": "channel-order-task" }, body: JSON.stringify({ modelProductId: "basic-image-v1", prompt: "route snapshot" }) }); const response = await fetch(`${base}/api/v1/tasks/image`, { method: "POST", headers: { authorization: `Bearer ${user.payload.data.accessToken!}`, "content-type": "application/json", "idempotency-key": "channel-order-task" }, body: JSON.stringify({ modelProductId: "basic-image-v1", prompt: "route snapshot" }) });
assert.equal(response.status, 202); assert.equal(response.status, 202);
const task = [...store.tasks.values()].find((item) => item.ownerId === user.registered.userId); const task = [...store.tasks.values()].find((item) => item.ownerId === user.registered.userId);
assert.deepEqual(task?.routeSnapshot?.channelIds, ["channel-c", "channel-a", "channel-d", "channel-b"]); assert.deepEqual(task?.routeSnapshot?.orderedCandidates.map((candidate) => candidate.channelId), ["channel-c", "channel-a", "channel-d", "channel-b"]);
} finally { } finally {
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
} }
@@ -586,7 +603,7 @@ test("documented admin singleton and detail method aliases remain callable", asy
assert.equal((await fetch(`${base}/api/v1/admin/billing/units`, { headers: headers("alias-units-get") })).status, 200); assert.equal((await fetch(`${base}/api/v1/admin/billing/units`, { headers: headers("alias-units-get") })).status, 200);
assert.equal((await fetch(`${base}/api/v1/admin/billing/units`, { method: "POST", headers: headers("alias-units-post"), body: JSON.stringify({ name: "金币" }) })).status, 201); assert.equal((await fetch(`${base}/api/v1/admin/billing/units`, { method: "POST", headers: headers("alias-units-post"), body: JSON.stringify({ name: "金币" }) })).status, 201);
assert.equal((await fetch(`${base}/api/v1/admin/billing/conversion-rules`, { method: "POST", headers: headers("alias-conversion-post"), body: JSON.stringify({ conversionRate: 1, rounding: "round" }) })).status, 201); assert.equal((await fetch(`${base}/api/v1/admin/billing/conversion-rules`, { method: "POST", headers: headers("alias-conversion-post"), body: JSON.stringify({ conversionRate: 1, rounding: "round" }) })).status, 201);
assert.equal((await fetch(`${base}/api/v1/admin/pricing-rules`, { method: "POST", headers: headers("alias-pricing-post"), body: JSON.stringify({ modelProductId: "basic-image", basePrice: 10 }) })).status, 201); assert.equal((await fetch(`${base}/api/v1/admin/pricing-rules`, { method: "POST", headers: headers("alias-pricing-post"), body: JSON.stringify({ publicModelId: "basic-image-v1", resolutionId: "low", billingUnit: "output", tiers: [{ minQuantity: 1, unitPrice: 10 }], multiplier: 1, enabled: false }) })).status, 201);
assert.equal((await fetch(`${base}/api/v1/admin/storage-policies`, { method: "POST", headers: headers("alias-storage-post"), body: JSON.stringify({ retentionDays: 30 }) })).status, 201); assert.equal((await fetch(`${base}/api/v1/admin/storage-policies`, { method: "POST", headers: headers("alias-storage-post"), body: JSON.stringify({ retentionDays: 30 }) })).status, 201);
assert.equal((await fetch(`${base}/api/v1/admin/recharge-orders/unknown-order`, { headers: headers("alias-order-detail") })).status, 404); assert.equal((await fetch(`${base}/api/v1/admin/recharge-orders/unknown-order`, { headers: headers("alias-order-detail") })).status, 404);
} finally { } finally {
+1 -1
View File
@@ -17,7 +17,7 @@ test("task websocket authenticates and replays a subscribed task cursor", async
assert.ok(address && typeof address !== "string"); assert.ok(address && typeof address !== "string");
const taskId = "ws-task"; const taskId = "ws-task";
store.users.set("ws-user", { id: "ws-user", version: 1, password: "", verified: true, status: "active", mfaRequired: false, mfaEnabled: false, roles: ["user"], failedLoginCount: 0, createdAt: new Date().toISOString() }); store.users.set("ws-user", { id: "ws-user", version: 1, password: "", verified: true, status: "active", mfaRequired: false, mfaEnabled: false, roles: ["user"], failedLoginCount: 0, createdAt: new Date().toISOString() });
store.tasks.set(taskId, { id: taskId, ownerId: "ws-user", taskType: "image", modelProductId: "basic-image", status: "queued", estimatedCost: 1, reservedCost: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), eventSequence: 0, channelGroupId: "image-default", routeSnapshotVersion: 1, attempts: [], outputs: [] }); store.tasks.set(taskId, { id: taskId, ownerId: "ws-user", taskType: "image", modelProductId: "basic-image", status: "queued", estimatedCost: 1, reservedCost: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), eventSequence: 0, routeSnapshot: { groupId: "image-default", groupVersion: 1, publicModelId: "basic-image-v1", routeVersion: 1, orderedCandidates: [], strategy: "strict", totalRetryBudget: 0 }, modelSnapshot: { publicModelId: "basic-image-v1", modelVersion: 1, name: "基础模型", tier: "basic", capabilities: ["image"] }, pricingSnapshot: { ruleId: "price-basic-image", ruleVersion: 1, publicModelId: "basic-image-v1", unitPrice: 1, quantity: 1, billingUnit: "output", multiplier: 1, balanceUnitVersion: 1 }, planSnapshot: { queuePriority: 0, maxConcurrent: 1 }, attempts: [], outputs: [] });
const token = issueAccessToken("ws-user", "user", secret, 900); const token = issueAccessToken("ws-user", "user", secret, 900);
const socket = new WebSocket(`ws://127.0.0.1:${address.port}/api/v1/ws/tasks?access_token=${encodeURIComponent(token)}`); const socket = new WebSocket(`ws://127.0.0.1:${address.port}/api/v1/ws/tasks?access_token=${encodeURIComponent(token)}`);
const messages: Array<{ type: string; event?: { taskId: string } }> = []; const messages: Array<{ type: string; event?: { taskId: string } }> = [];