refactor: align provider and task contracts
This commit is contained in:
+136
-21
@@ -1,6 +1,7 @@
|
||||
export const CONTRACT_VERSION = "2026-08-20.v1" as const;
|
||||
export const API_PREFIX = "/api/v1" as const;
|
||||
export const WS_PATH = "/api/v1/ws/tasks" as const;
|
||||
export const DOMAIN_SNAPSHOT_VERSION = 2 as const;
|
||||
|
||||
export const ErrorCodes = {
|
||||
Unauthorized: "UNAUTHORIZED",
|
||||
@@ -91,13 +92,16 @@ export type CapabilitySchema = {
|
||||
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 = {
|
||||
id: string;
|
||||
label: string;
|
||||
width: number;
|
||||
height: number;
|
||||
priceMultiplier: number;
|
||||
/** Standard image ratios enabled for this billing preset; `auto` is implicit. */
|
||||
/** Standard image ratios enabled for this public model; `auto` is implicit. */
|
||||
ratios?: string[];
|
||||
};
|
||||
|
||||
@@ -130,19 +134,122 @@ export type PublicModelProduct = {
|
||||
name: string;
|
||||
tier: "basic" | "advanced" | "flagship";
|
||||
capabilities: ModelCapability[];
|
||||
capabilityParameters?: CapabilityParameters;
|
||||
resolutions: PublicResolution[];
|
||||
basePrice: number;
|
||||
currencyUnit: string;
|
||||
enabled: boolean;
|
||||
version?: number;
|
||||
capabilitiesSchema?: CapabilitySchema[];
|
||||
publicErrorMessages?: Partial<Record<ErrorCode, string>>;
|
||||
maxCount?: number;
|
||||
maxConcurrent?: number;
|
||||
};
|
||||
|
||||
export type InternalModelProduct = PublicModelProduct & {
|
||||
channelGroupId?: string;
|
||||
export type InternalModelProduct = PublicModelProduct;
|
||||
|
||||
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 = {
|
||||
@@ -242,18 +349,16 @@ export type GenerationTask = OrganizationScope & {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
eventSequence: number;
|
||||
channelGroupId: string;
|
||||
routeSnapshotVersion: number;
|
||||
attempts: GenerationAttempt[];
|
||||
outputs: Array<{ id: string; mimeType: string; url?: string; status: "succeeded" | "failed"; unitPriceSnapshot?: number; chargedAmount?: number; metadata?: ImageOutputMetadata }>;
|
||||
publicErrorCode?: ErrorCode;
|
||||
prompt?: string;
|
||||
resolutionPresetId?: string;
|
||||
count?: number;
|
||||
pricingSnapshot?: { basePrice: number; multiplier: number; unitVersion: number };
|
||||
modelSnapshot?: { publicModelId: string; version: number };
|
||||
routeSnapshot?: { groupId: string; version: number; channelIds: string[]; retryBudget?: number; channelRetryBudgets?: Record<string, number> };
|
||||
planSnapshot?: { planId?: string; version?: number; queuePriority: number; maxConcurrent: number };
|
||||
pricingSnapshot: PricingSnapshot;
|
||||
modelSnapshot: ModelSnapshot;
|
||||
routeSnapshot: RouteSnapshot;
|
||||
planSnapshot: PlanSnapshot;
|
||||
leaseExpiresAt?: string;
|
||||
/** Fences a late worker/provider response after a lease is recovered. */
|
||||
leaseToken?: string;
|
||||
@@ -405,7 +510,9 @@ export type AdminSessionResponse = {
|
||||
|
||||
export type AdminPage<T> = {
|
||||
items: T[];
|
||||
total?: number;
|
||||
total: number;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
nextCursor?: string;
|
||||
};
|
||||
|
||||
@@ -413,19 +520,27 @@ export type AdminChannelSummary = {
|
||||
id: string;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
health: "healthy" | "degraded" | "open";
|
||||
health: ChannelHealthStatus | "disabled";
|
||||
version: number;
|
||||
retryBudget: number;
|
||||
consecutiveFailures: number;
|
||||
providerType?: "generic" | "openai-images";
|
||||
providerName?: string;
|
||||
providerModelId?: string;
|
||||
enabledModelIds?: string[];
|
||||
models?: ChannelModel[];
|
||||
modelMappings?: Array<{ displayModelId: string; requestModelId: string }>;
|
||||
resolutionModelMap?: Record<string, string>;
|
||||
baseUrl?: string;
|
||||
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 = {
|
||||
|
||||
@@ -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 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";
|
||||
|
||||
function resolveChannelModel(channel: ProviderChannel, publicModelId?: string) {
|
||||
const mappedModel = channel.modelMappings?.find((mapping) => mapping.displayModelId === publicModelId)?.requestModelId;
|
||||
const directModel = publicModelId && (channel.enabledModelIds?.includes(publicModelId) || channel.providerModelId === publicModelId) ? publicModelId : undefined;
|
||||
if (channel.providerType === "openai-images" && publicModelId && !mappedModel && !directModel) {
|
||||
throw Object.assign(new Error("public model is not mapped to this channel"), { errorCode: "PROVIDER_MODEL_NOT_CONFIGURED", retryable: false });
|
||||
export function resolveChannelModel(channel: ProviderChannel, publicModelId?: string, requestModelId?: string) {
|
||||
const models = channel.models || [];
|
||||
const requested = requestModelId?.trim();
|
||||
if (requested) {
|
||||
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 = ["一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "十一", "十二", "十三", "十四", "十五", "十六"];
|
||||
@@ -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
|
||||
// parameter override the configured resolution mapping.
|
||||
delete params.model;
|
||||
const channelModel = resolveChannelModel(channel, input.publicModelId);
|
||||
const model = channel.providerType === "openai-images" ? imageModelFor(channelModel.model, rawParams.resolution, channelModel.exact ? undefined : channel.resolutionModelMap) : channelModel.model || "configured-model";
|
||||
const channelModel = resolveChannelModel(channel, input.publicModelId, input.requestModelId);
|
||||
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);
|
||||
if (hasReferences && mentionedNames.size === 0) rewrittenPrompt = rewrittenPrompt ? `${rewrittenPrompt}\n基于以下参考图生成。` : "基于以下参考图生成。";
|
||||
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");
|
||||
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 {
|
||||
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 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 };
|
||||
}
|
||||
|
||||
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 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 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, 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> {
|
||||
if (!channel.baseUrl || !channel.secretRef) return { status: "failed", errorCode: channel.fixtureFailure === "unknown" ? "PROVIDER_TIMEOUT_UNKNOWN" : "CHANNEL_UNAVAILABLE", retryable: true };
|
||||
|
||||
+410
-187
@@ -1,7 +1,7 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { parse as parseUrl } from "node:url";
|
||||
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 { 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";
|
||||
@@ -196,16 +196,25 @@ function createUserSessionResponse(store: Store, user: Store["users"] extends Ma
|
||||
}
|
||||
|
||||
function publicProduct(product: InternalModelProduct): PublicModelProduct {
|
||||
const { channelGroupId: _channelGroupId, ...safe } = product;
|
||||
return { ...safe, id: product.publicModelId };
|
||||
return { ...product, 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) {
|
||||
const group = store.channelGroups.find((item) => item.id === (product.channelGroupId || "image-default") && item.enabled);
|
||||
return Boolean(group?.channelIds.some((channelId) => {
|
||||
const channel = store.channels.find((item) => item.id === channelId);
|
||||
return channel?.enabled === true && channel.health !== "open";
|
||||
}));
|
||||
return store.channelGroups.some((group) => group.enabled && (group.routes || []).some((route) => route.publicModelId === product.publicModelId && route.channels.some((candidate) => {
|
||||
const channel = store.channels.find((item) => item.id === candidate.channelId);
|
||||
return channel?.enabled === true && channel.health !== "open" && Boolean(channelRequestModel(channel, product.publicModelId));
|
||||
})));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function normalizeChannelGroupProductIds(store: Store, value: unknown) {
|
||||
function normalizePublicModelIds(store: Store, value: unknown) {
|
||||
if (value === undefined) return undefined;
|
||||
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);
|
||||
if (new Set(productIds).size !== productIds.length || productIds.some((productId) => !store.products.some((product) => product.id === productId))) throw new DomainError(ErrorCodes.ValidationFailed, "关联模型产品无效", undefined, 422);
|
||||
return productIds;
|
||||
if (!Array.isArray(value)) throw new DomainError(ErrorCodes.ValidationFailed, "公开模型列表必须是数组", undefined, 422);
|
||||
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(publicModelIds).size !== publicModelIds.length || publicModelIds.some((publicModelId) => !store.products.some((product) => product.publicModelId === publicModelId))) throw new DomainError(ErrorCodes.ValidationFailed, "公开模型列表无效", undefined, 422);
|
||||
return publicModelIds;
|
||||
}
|
||||
|
||||
function validateChannelGroupProductModes(store: Store, capabilities: string[] | undefined, productIds: string[]) {
|
||||
function validateChannelGroupProductModes(store: Store, capabilities: string[] | undefined, publicModelIds: string[]) {
|
||||
if (!capabilities?.length) return;
|
||||
const supported = new Set(capabilities);
|
||||
if (productIds.some((productId) => {
|
||||
const product = store.products.find((item) => item.id === productId);
|
||||
if (publicModelIds.some((publicModelId) => {
|
||||
const product = store.products.find((item) => item.publicModelId === publicModelId);
|
||||
return product && !product.capabilities.some((capability) => supported.has(capability));
|
||||
})) throw new DomainError(ErrorCodes.ValidationFailed, "模型产品与分组适用模式不匹配", undefined, 422);
|
||||
}
|
||||
|
||||
function validateChannelGroupProductOwnership(store: Store, groupId: string, productIds: string[]) {
|
||||
const conflicts = productIds
|
||||
.map((productId) => store.products.find((product) => product.id === productId))
|
||||
.filter((product): product is InternalModelProduct => Boolean(product?.channelGroupId && product.channelGroupId !== groupId));
|
||||
function validateChannelGroupProductOwnership(store: Store, groupId: string, publicModelIds: string[], enabled: boolean) {
|
||||
if (!enabled) return;
|
||||
const conflicts = publicModelIds
|
||||
.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) {
|
||||
const labels = conflicts.map((product) => product.publicModelId || product.name || product.id).join("、");
|
||||
throw new DomainError(ErrorCodes.Conflict, `模型产品已关联其他渠道分组:${labels},请先解除原分组关联`, undefined, 409);
|
||||
}
|
||||
}
|
||||
|
||||
function bindChannelGroupProducts(store: Store, groupId: string, productIds: string[]) {
|
||||
const selected = new Set(productIds);
|
||||
for (const product of store.products) {
|
||||
if (selected.has(product.id)) {
|
||||
if (product.channelGroupId !== groupId) {
|
||||
product.channelGroupId = groupId;
|
||||
product.version = (product.version || 1) + 1;
|
||||
function normalizeChannelModels(value: unknown, fallback: ChannelModel[] = []) {
|
||||
if (value === undefined) return fallback.map((model) => ({ ...model }));
|
||||
if (!Array.isArray(value)) throw new DomainError(ErrorCodes.ValidationFailed, "渠道模型列表必须是数组", undefined, 422);
|
||||
const seen = new Set<string>();
|
||||
const models = value.map((item) => {
|
||||
const record = typeof item === "string" ? { requestModelId: item } : item && typeof item === "object" && !Array.isArray(item) ? item as Record<string, unknown> : {};
|
||||
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;
|
||||
}
|
||||
continue;
|
||||
|
||||
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);
|
||||
}
|
||||
if (product.channelGroupId !== groupId) continue;
|
||||
product.channelGroupId = undefined;
|
||||
product.enabled = false;
|
||||
product.version = (product.version || 1) + 1;
|
||||
output[capability as InternalModelProduct["capabilities"][number]] = fields;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -495,12 +558,12 @@ function normalizeResolutionList(value: unknown) {
|
||||
const record = item as Record<string, unknown>;
|
||||
const id = typeof record.id === "string" ? record.id.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;
|
||||
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);
|
||||
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);
|
||||
return resolutions;
|
||||
@@ -530,8 +593,21 @@ function normalizeEnabledModelIds(value: unknown) {
|
||||
return modelIds;
|
||||
}
|
||||
|
||||
function validateMappingTargets(enabledModelIds: string[], modelMappings: ReturnType<typeof normalizeModelMappings>) {
|
||||
if (modelMappings?.some((mapping) => !enabledModelIds.includes(mapping.requestModelId))) throw new DomainError(ErrorCodes.ValidationFailed, "模型映射中的请求模型必须先添加到渠道模型列表", undefined, 422);
|
||||
function normalizeChannelTimeout(value: unknown, fallback = 60_000) {
|
||||
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 };
|
||||
@@ -789,10 +865,12 @@ function decodeBase64(value: string) {
|
||||
function reconciliationUnitPrice(task: GenerationTask, outputIndex = 0) {
|
||||
const requestedCount = Math.max(1, task.count || 1);
|
||||
const pricing = task.pricingSnapshot;
|
||||
const snapshotUnit = pricing && Number.isFinite(pricing.basePrice) && Number.isFinite(pricing.multiplier)
|
||||
? pricing.basePrice * pricing.multiplier
|
||||
: task.estimatedCost / requestedCount;
|
||||
const total = Number.isSafeInteger(task.estimatedCost) ? task.estimatedCost : Math.ceil(snapshotUnit * requestedCount);
|
||||
const billedUnits = pricing?.billingUnit === "task" ? 1 : requestedCount;
|
||||
const snapshotUnit = pricing && Number.isFinite(pricing.unitPrice) && Number.isFinite(pricing.multiplier)
|
||||
? Number(pricing.unitPrice) * Number(pricing.multiplier)
|
||||
: 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 remainder = total - base * requestedCount;
|
||||
return base + (outputIndex < remainder ? 1 : 0);
|
||||
@@ -855,6 +933,114 @@ function allowsLegacyCsrfBootstrap(pathname: string) {
|
||||
|| 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 {
|
||||
const idempotencyRef = `${userId}:${idempotencyKey}`;
|
||||
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);
|
||||
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, "提示词或参考图至少需要一项");
|
||||
const maxCount = product.maxCount || 8;
|
||||
const maxCount = Math.max(1, Number(store.systemSettings.generationMaxCount) || 1);
|
||||
const rawCount = Number(body.count || 1);
|
||||
if (!Number.isInteger(rawCount) || rawCount < 1 || rawCount > maxCount) throw new DomainError(ErrorCodes.ValidationFailed, "生成数量超出模型限制", { maxCount });
|
||||
const count = rawCount;
|
||||
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 });
|
||||
const group = store.channelGroups.find((item) => item.id === (product.channelGroupId || "image-default") && item.enabled);
|
||||
if (!group) throw new DomainError(ErrorCodes.ModelGroupUnavailable, "当前模型暂不可用,请切换到其他同档位模型", undefined, 503);
|
||||
if (!group.channelIds.some((channelId) => store.channels.some((channel) => channel.id === channelId && channel.enabled))) throw new DomainError(ErrorCodes.ModelGroupUnavailable, "当前模型尚未接入可用渠道,请稍后再试", undefined, 503);
|
||||
const resolvedRoute = resolveModelRoute(store, product.publicModelId);
|
||||
const { group, route, candidates: routeCandidates } = resolvedRoute;
|
||||
if (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);
|
||||
@@ -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);
|
||||
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 (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);
|
||||
@@ -912,10 +1097,9 @@ function createTask(store: Store, userId: string, taskType: TaskType, body: Crea
|
||||
validateCapabilityParams(product, taskType, params);
|
||||
const taskId = randomUUID();
|
||||
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 estimatedCost = Math.ceil(product.basePrice * (resolution?.priceMultiplier || 1) * count);
|
||||
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 planSnapshot = entitlement ? { planId: entitlement.planId, version: entitlement.planVersion, queuePriority: entitlement.queuePriority, maxConcurrent: entitlement.maxConcurrent } : { queuePriority: 0, maxConcurrent: product.maxConcurrent || 1 };
|
||||
const pricing = resolvePricingRule(store, product.publicModelId, resolution?.id, count);
|
||||
const estimatedCost = pricing.estimatedCost;
|
||||
const { entitlement, planSnapshot } = resolvePlanSnapshot(store, userId, product, group.id);
|
||||
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 (!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);
|
||||
}
|
||||
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) {
|
||||
store.tasks.set(task.id, task);
|
||||
store.idempotency.set(idempotencyRef, task);
|
||||
@@ -2091,7 +2303,7 @@ export function createHttpServer(store: Store, config: ServerConfig = loadConfig
|
||||
let task: GenerationTask; let created: boolean;
|
||||
if (store.repository.adapter === "postgres" && store.repository.createTaskAtomic) {
|
||||
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();
|
||||
task = store.tasks.get(atomic.taskId) || atomic.task || draft;
|
||||
created = atomic.created;
|
||||
@@ -2309,6 +2521,7 @@ export function createHttpServer(store: Store, config: ServerConfig = loadConfig
|
||||
}
|
||||
if (request.method === "POST" && resource === "channels" && !resourceId) {
|
||||
const body = await readJson(request);
|
||||
rejectLegacyFields(body, ["priority", "retryBudget", "providerModelId", "enabledModelIds", "resolutionModelMap"]);
|
||||
const requestedId = typeof body.id === "string" ? body.id.trim() : "";
|
||||
const channelId = requestedId || randomUUID();
|
||||
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); }
|
||||
}
|
||||
const providerType = body.providerType === "openai-images" ? "openai-images" as const : "generic" as const;
|
||||
const enabledModelIds = normalizeEnabledModelIds(body.enabledModelIds) || [];
|
||||
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 models = normalizeChannelModels(body.models);
|
||||
const modelMappings = normalizeModelMappings(body.modelMappings);
|
||||
if (providerType === "openai-images") validateMappingTargets(normalizedEnabledModelIds, modelMappings);
|
||||
const providerModelId = providerType === "openai-images" ? normalizedEnabledModelIds[0] : legacyProviderModelId || undefined;
|
||||
validateMappingTargets(models, modelMappings);
|
||||
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);
|
||||
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;
|
||||
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 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);
|
||||
audit(store, "channel.create", "provider_channel", channel.id, id, admin.sub, undefined, { ...channel, secretRef: channel.secretRef ? "configured" : undefined }, clientAddress(request));
|
||||
store.persist();
|
||||
@@ -2338,95 +2547,81 @@ export function createHttpServer(store: Store, config: ServerConfig = loadConfig
|
||||
}
|
||||
if (request.method === "POST" && resource === "channel-groups") {
|
||||
const body = await readJson(request);
|
||||
const channelIds = Array.isArray(body.channelIds) ? body.channelIds.filter((value): value is string => typeof value === "string") : [];
|
||||
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);
|
||||
validateChannelProtocols(store, channelIds);
|
||||
const retryBudget = body.retryBudget === undefined ? channelIds.length : Number(body.retryBudget);
|
||||
if (!Number.isSafeInteger(retryBudget) || retryBudget < 0) throw new DomainError(ErrorCodes.ValidationFailed, "分组重试预算无效", undefined, 422);
|
||||
rejectLegacyFields(body, ["channelIds", "channelPriorities", "retryBudget", "productIds"]);
|
||||
const routes = normalizeRoutes(store, body.routes, [], true);
|
||||
for (const route of routes) validateChannelProtocols(store, route.channels.map((candidate) => candidate.channelId));
|
||||
const enabled = body.enabled !== false;
|
||||
const requestedId = typeof body.id === "string" ? body.id.trim() : "";
|
||||
const group = {
|
||||
const group: Store["channelGroups"][number] = {
|
||||
id: requestedId || randomUUID(),
|
||||
version: 1,
|
||||
name: normalizeChannelGroupName(body.name),
|
||||
capabilities: normalizeChannelGroupCapabilities(body.capabilities),
|
||||
channelIds,
|
||||
channelPriorities: Object.fromEntries(channelIds.map((channelId, index) => [channelId, index])),
|
||||
enabled: body.enabled !== false,
|
||||
retryBudget,
|
||||
routes,
|
||||
enabled,
|
||||
};
|
||||
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, productIds);
|
||||
validateChannelGroupProductOwnership(store, group.id, productIds);
|
||||
validateChannelGroupProductModes(store, group.capabilities, routes.map((route) => route.publicModelId));
|
||||
validateChannelGroupProductOwnership(store, group.id, routes.map((route) => route.publicModelId), enabled);
|
||||
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));
|
||||
store.persist();
|
||||
ok(response, id, { item: { ...group, productIds } }, 201);
|
||||
ok(response, id, { item: group }, 201);
|
||||
return;
|
||||
}
|
||||
if (request.method === "POST" && resource === "model-products") {
|
||||
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 (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 productId = requestedProductId || randomUUID();
|
||||
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 (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 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 enabled = body.enabled === true;
|
||||
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"]);
|
||||
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 resolutions = body.resolutions === undefined ? [] : normalizeResolutionList(body.resolutions);
|
||||
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;
|
||||
}
|
||||
if (request.method === "PATCH" && resource === "channels" && resourceId) {
|
||||
const body = await readJson(request);
|
||||
rejectLegacyFields(body, ["priority", "retryBudget", "providerModelId", "enabledModelIds", "resolutionModelMap"]);
|
||||
const existingChannel = store.channels.find((item) => item.id === resourceId);
|
||||
if (!existingChannel) throw new DomainError(ErrorCodes.NotFound, "渠道不存在", undefined, 404);
|
||||
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 };
|
||||
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 (body.providerType === "generic" || body.providerType === "openai-images") {
|
||||
for (const group of store.channelGroups.filter((item) => item.channelIds.includes(channel.id))) {
|
||||
const protocols = new Set(group.channelIds.map((id) => id === channel.id ? body.providerType : store.channels.find((item) => item.id === id)?.providerType || "generic"));
|
||||
if (protocols.size > 1) throw new DomainError(ErrorCodes.ValidationFailed, "同一渠道分组不能混用不同供应商协议", undefined, 422);
|
||||
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(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);
|
||||
}
|
||||
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 (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 (body.enabledModelIds !== undefined) channel.enabledModelIds = normalizeEnabledModelIds(body.enabledModelIds);
|
||||
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 (body.models !== undefined) channel.models = normalizeChannelModels(body.models, channel.models);
|
||||
if (body.modelMappings !== undefined) channel.modelMappings = normalizeModelMappings(body.modelMappings) || [];
|
||||
if (typeof body.secretRef === "string") {
|
||||
const secret = body.secretRef.trim();
|
||||
if (secret) channel.secretRef = encryptSecret(secret, store.channelEncryptionKey);
|
||||
}
|
||||
if (channel.providerType === "generic") channel.resolutionModelMap = undefined;
|
||||
if (channel.providerType === "openai-images") validateMappingTargets(channel.enabledModelIds || [], 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);
|
||||
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");
|
||||
if (openAiConfigChanged) { channel.health = "degraded"; channel.consecutiveFailures = 0; }
|
||||
if (body.timeoutMs !== undefined) channel.timeoutMs = normalizeChannelTimeout(body.timeoutMs, channel.timeoutMs);
|
||||
validateMappingTargets(channel.models, channel.modelMappings || []);
|
||||
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 providerConfigChanged = body.providerType !== undefined || body.baseUrl !== undefined || body.models !== undefined || body.modelMappings !== undefined || body.secretRef !== undefined || existingChannel.providerType !== channel.providerType;
|
||||
if (providerConfigChanged) { channel.health = "degraded"; channel.consecutiveFailures = 0; channel.circuitOpenedAt = undefined; channel.circuitResetAt = undefined; }
|
||||
channel.version = Number(existingChannel.version || 1) + 1;
|
||||
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));
|
||||
store.persist();
|
||||
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 moderation = typeof body.moderation === "string" ? body.moderation : "auto";
|
||||
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);
|
||||
if (!model || (!enabledModels.includes(model) && !mappedModels.includes(model))) 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);
|
||||
const before = { ...product };
|
||||
const body = await readJson(request);
|
||||
rejectLegacyFields(body, ["basePrice", "maxCount", "maxConcurrent", "currencyUnit", "channelGroupId"]);
|
||||
const nextName = typeof body.name === "string" ? body.name.trim() : product.name;
|
||||
const nextEnabled = typeof body.enabled === "boolean" ? body.enabled : product.enabled;
|
||||
let nextChannelGroupId = product.channelGroupId;
|
||||
if (typeof body.channelGroupId === "string") {
|
||||
const channelGroupId = body.channelGroupId.trim();
|
||||
if (channelGroupId) {
|
||||
const group = store.channelGroups.find((item) => item.id === channelGroupId);
|
||||
if (!group) throw new DomainError(ErrorCodes.ValidationFailed, "渠道分组不存在", undefined, 422);
|
||||
validateChannelGroupProductModes(store, group.capabilities, [product.id]);
|
||||
validateChannelGroupProductOwnership(store, group.id, [product.id]);
|
||||
nextChannelGroupId = group.id;
|
||||
} else nextChannelGroupId = undefined;
|
||||
if (!nextName) throw new DomainError(ErrorCodes.ValidationFailed, "模型名称不能为空", undefined, 422);
|
||||
if (body.tier !== undefined && body.tier !== "basic" && body.tier !== "advanced" && body.tier !== "flagship") throw new DomainError(ErrorCodes.ValidationFailed, "模型档位无效", undefined, 422);
|
||||
let nextCapabilities = product.capabilities;
|
||||
if (body.capabilities !== undefined) {
|
||||
if (!Array.isArray(body.capabilities)) 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"]);
|
||||
const values = [...new Set(body.capabilities.filter((value): value is string => typeof value === "string").map((value) => value.trim()).filter(Boolean))];
|
||||
if (!values.length || values.some((value) => !allowedCapabilities.has(value as InternalModelProduct["capabilities"][number]))) throw new DomainError(ErrorCodes.ValidationFailed, "模型能力包含不支持的类型", undefined, 422);
|
||||
nextCapabilities = values as InternalModelProduct["capabilities"];
|
||||
}
|
||||
if (nextEnabled && (!nextChannelGroupId || !store.channelGroups.some((group) => group.id === nextChannelGroupId && group.enabled))) throw new DomainError(ErrorCodes.ValidationFailed, "已上架模型必须绑定已启用的渠道分组", undefined, 422);
|
||||
let nextBasePrice = product.basePrice;
|
||||
if (body.basePrice !== undefined) {
|
||||
const basePrice = Number(body.basePrice);
|
||||
if (!Number.isSafeInteger(basePrice) || basePrice < 0) throw new DomainError(ErrorCodes.ValidationFailed, "价格必须是非负整数", undefined, 422);
|
||||
nextBasePrice = basePrice;
|
||||
}
|
||||
product.name = nextName;
|
||||
const nextResolutions = body.resolutions === undefined ? product.resolutions : normalizeResolutionList(body.resolutions);
|
||||
if (nextEnabled && nextCapabilities.includes("image") && !nextResolutions.length) throw new DomainError(ErrorCodes.ValidationFailed, "已上架图片模型必须配置分辨率矩阵", undefined, 422);
|
||||
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 };
|
||||
if (nextEnabled && !productAvailableThroughChannel(store, nextProduct)) throw new DomainError(ErrorCodes.ValidationFailed, "已上架模型必须配置可用分组路由", undefined, 422);
|
||||
product.name = nextProduct.name;
|
||||
product.tier = nextProduct.tier;
|
||||
product.capabilities = nextProduct.capabilities;
|
||||
product.capabilityParameters = nextProduct.capabilityParameters;
|
||||
product.resolutions = nextProduct.resolutions;
|
||||
product.enabled = nextEnabled;
|
||||
product.channelGroupId = nextChannelGroupId;
|
||||
product.basePrice = nextBasePrice;
|
||||
product.capabilitiesSchema = nextCapabilities.map(capabilitySchema);
|
||||
product.version = (product.version || 1) + 1;
|
||||
audit(store, "model_product.update", "model_product", product.id, id, admin.sub, before, product, clientAddress(request));
|
||||
store.persist();
|
||||
@@ -2536,27 +2731,48 @@ export function createHttpServer(store: Store, config: ServerConfig = loadConfig
|
||||
return;
|
||||
}
|
||||
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") {
|
||||
const body = await readJson(request); const productId = typeof body.modelProductId === "string" ? body.modelProductId : typeof body.id === "string" ? body.id : "";
|
||||
const product = store.products.find((item) => item.id === productId || item.publicModelId === productId); if (!product) throw new DomainError(ErrorCodes.NotFound, "价格规则对应的模型不存在", undefined, 404);
|
||||
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.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;
|
||||
const body = await readJson(request);
|
||||
rejectLegacyFields(body, ["basePrice", "modelProductId"]);
|
||||
const rule = normalizePricingRule(store, body);
|
||||
if (store.pricingRules.some((item) => item.enabled && rule.enabled && pricingIntervalsOverlap(item, rule))) throw new DomainError(ErrorCodes.Conflict, "价格规则生效区间与现有规则重叠", undefined, 409);
|
||||
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") {
|
||||
const body = await readJson(request);
|
||||
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);
|
||||
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, "渠道顺序无效");
|
||||
validateChannelProtocols(store, channelIds);
|
||||
group.channelIds = channelIds;
|
||||
group.channelPriorities = Object.fromEntries(channelIds.map((channelId, index) => [channelId, index]));
|
||||
const publicModelId = typeof body.publicModelId === "string" ? body.publicModelId.trim() : "";
|
||||
if (!publicModelId || !Array.isArray(body.channels)) throw new DomainError(ErrorCodes.ValidationFailed, "模型路由排序必须包含公开模型和渠道顺序", undefined, 422);
|
||||
const currentRoute = group.routes.find((route) => route.publicModelId === publicModelId);
|
||||
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;
|
||||
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();
|
||||
ok(response, id, { item: group });
|
||||
return;
|
||||
@@ -2566,44 +2782,23 @@ export function createHttpServer(store: Store, config: ServerConfig = loadConfig
|
||||
if (!group) throw new DomainError(ErrorCodes.NotFound, "渠道分组不存在", undefined, 404);
|
||||
requireIfMatch(request, group.version);
|
||||
const body = await readJson(request);
|
||||
rejectLegacyFields(body, ["channelIds", "channelPriorities", "retryBudget", "productIds"]);
|
||||
const nextName = body.name !== undefined ? normalizeChannelGroupName(body.name) : group.name;
|
||||
const nextCapabilities = body.capabilities !== undefined ? normalizeChannelGroupCapabilities(body.capabilities) : group.capabilities;
|
||||
let nextChannelIds = group.channelIds;
|
||||
let nextChannelPriorities = group.channelPriorities;
|
||||
if (Array.isArray(body.channelIds)) {
|
||||
const channelIds = body.channelIds.filter((value): value is string => typeof value === "string");
|
||||
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);
|
||||
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 nextEnabled = typeof body.enabled === "boolean" ? body.enabled : group.enabled;
|
||||
const nextRoutes = normalizeRoutes(store, body.routes, group.routes, nextEnabled);
|
||||
for (const route of nextRoutes) { validateRoute(store, route, nextEnabled); validateChannelProtocols(store, route.channels.map((candidate) => candidate.channelId)); }
|
||||
validateChannelGroupProductModes(store, nextCapabilities, nextRoutes.map((route) => route.publicModelId));
|
||||
validateChannelGroupProductOwnership(store, group.id, nextRoutes.map((route) => route.publicModelId), nextEnabled);
|
||||
const before = { ...group };
|
||||
group.name = nextName;
|
||||
group.capabilities = nextCapabilities;
|
||||
group.channelIds = nextChannelIds;
|
||||
group.channelPriorities = nextChannelPriorities;
|
||||
group.retryBudget = nextRetryBudget;
|
||||
if (typeof body.enabled === "boolean") group.enabled = body.enabled;
|
||||
if (hasProductIds) bindChannelGroupProducts(store, group.id, productIds);
|
||||
group.routes = nextRoutes;
|
||||
group.enabled = nextEnabled;
|
||||
group.version += 1;
|
||||
audit(store, "channel_group.update", "channel_group", group.id, id, admin.sub, before, group, clientAddress(request));
|
||||
store.persist();
|
||||
ok(response, id, { item: { ...group, productIds } });
|
||||
ok(response, id, { item: group });
|
||||
return;
|
||||
}
|
||||
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") {
|
||||
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;
|
||||
}
|
||||
if (request.method === "GET" && resource === "channel-groups") {
|
||||
ok(response, id, {
|
||||
items: store.channelGroups.map((group) => {
|
||||
const products = store.products
|
||||
.filter((product) => product.channelGroupId === group.id)
|
||||
.map(({ id: productId, publicModelId, name, basePrice, currencyUnit, enabled, capabilities, resolutions }) => ({ productId, publicModelId, name, basePrice, currencyUnit, enabled, capabilities, resolutions }));
|
||||
const products = group.routes
|
||||
.map((route) => store.products.find((product) => product.publicModelId === route.publicModelId))
|
||||
.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 channelDetails = group.channelIds.map((channelId) => {
|
||||
const channelDetails = groupChannelIds(group).map((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 {
|
||||
...group,
|
||||
name: group.name || (group.id === "image-default" ? "图片生成渠道组" : undefined),
|
||||
capabilities,
|
||||
productIds: products.map((product) => product.productId),
|
||||
products,
|
||||
channels: channelDetails.map((channel) => channel.label),
|
||||
channelDetails,
|
||||
@@ -2800,9 +3014,12 @@ export function createHttpServer(store: Store, config: ServerConfig = loadConfig
|
||||
}
|
||||
if (request.method === "GET" && resource === "model-products") {
|
||||
ok(response, id, { items: store.products.map((product) => {
|
||||
const group = store.channelGroups.find((item) => item.id === (product.channelGroupId || "image-default"));
|
||||
const availableChannelCount = group?.channelIds.filter((channelId) => store.channels.some((channel) => channel.id === channelId && channel.enabled && channel.health !== "open")).length || 0;
|
||||
return { ...product, availableChannelCount, availability: product.enabled && group?.enabled && availableChannelCount > 0 ? "available" : product.enabled ? "unavailable" : "disabled" };
|
||||
const routes = store.channelGroups.flatMap((group) => group.enabled ? group.routes.filter((route) => route.publicModelId === product.publicModelId).map((route) => ({ group, route })) : []);
|
||||
const availableChannelCount = routes.flatMap(({ route }) => route.channels).filter((candidate) => {
|
||||
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;
|
||||
}
|
||||
@@ -2879,6 +3096,12 @@ export function createHttpServer(store: Store, config: ServerConfig = loadConfig
|
||||
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;
|
||||
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");
|
||||
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;
|
||||
@@ -2908,9 +3131,8 @@ export function createHttpServer(store: Store, config: ServerConfig = loadConfig
|
||||
currentTask.publicErrorCode = ErrorCodes.ModelGroupUnavailable;
|
||||
currentTask.reservedCost = 0;
|
||||
} else {
|
||||
if (body.chargedAmount !== undefined && (!Number.isSafeInteger(Number(body.chargedAmount)) || Number(body.chargedAmount) < 0)) throw new DomainError(ErrorCodes.ValidationFailed, "实际扣除金币必须是非负整数", undefined, 422);
|
||||
const requestedCharge = body.chargedAmount === undefined ? currentTask.estimatedCost : Number(body.chargedAmount);
|
||||
const chargedAmount = Math.max(0, Math.min(currentTask.reservedCost, requestedCharge));
|
||||
const chargedAmount = requestedCharge ?? currentTask.estimatedCost;
|
||||
if (!Number.isSafeInteger(chargedAmount) || chargedAmount < 0 || chargedAmount > currentTask.reservedCost) throw new DomainError(ErrorCodes.ValidationFailed, "实际扣除金币不能超过任务预留金币", { reservedCost: currentTask.reservedCost }, 422);
|
||||
if (currentTask.reservedCost > 0 && !settleBalance(atomicStore, currentTask.ownerId, currentTask.reservedCost, chargedAmount, currentTask.id)) return false;
|
||||
if (currentAttempt) currentAttempt.reconciliationStatus = "confirmed_success";
|
||||
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 === "payment-events") { ok(response, id, { items: [...store.paymentEvents.values()] }); return; }
|
||||
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" || 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 === "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") {
|
||||
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))]; };
|
||||
ok(response, id, { items: store.channels.map((channel) => {
|
||||
const channelSamples = samples.filter((sample) => sample.channelId === channel.id);
|
||||
const completed = channelSamples.filter((sample) => sample.status !== "started");
|
||||
const durations = channelSamples.flatMap((sample) => sample.durationMs === undefined ? [] : [sample.durationMs]);
|
||||
const succeeded = completed.filter((sample) => sample.status === "succeeded").length;
|
||||
const lastCheckedAt = channel.lastTestedAt || channelSamples.flatMap((sample) => sample.finishedAt ? [sample.finishedAt] : []).sort().at(-1);
|
||||
const groupId = store.channelGroups.find((group) => group.channelIds.includes(channel.id))?.id;
|
||||
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 channelSamples = store.healthSamples.filter((sample) => sample.channelId === channel.id).sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
||||
const completed = channelSamples.filter((sample) => sample.status !== "probing");
|
||||
const durations = channelSamples.flatMap((sample) => sample.latencyMs === undefined ? [] : [sample.latencyMs]);
|
||||
const succeeded = completed.filter((sample) => sample.status === "success").length;
|
||||
const trend = Array.from({ length: 12 }, (_, index) => channelSamples.slice(-12)[index] || null);
|
||||
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);
|
||||
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;
|
||||
}
|
||||
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; }
|
||||
|
||||
@@ -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";
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
@@ -52,6 +52,10 @@ export function ensureAdminDemoData(store: Store) {
|
||||
const imagePublicModelId = imageProduct?.publicModelId || "basic-image-v1";
|
||||
const textModelId = textProduct?.id || "basic-text";
|
||||
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 successFinished = timestamp(1, 9, 13);
|
||||
@@ -66,16 +70,14 @@ export function ensureAdminDemoData(store: Store) {
|
||||
createdAt: timestamp(1, 9),
|
||||
updatedAt: successFinished,
|
||||
eventSequence: 3,
|
||||
channelGroupId,
|
||||
routeSnapshotVersion: 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" } }],
|
||||
prompt: "产品演示插画:明亮的工作室和一张白色桌面",
|
||||
resolutionPresetId: "low",
|
||||
count: 1,
|
||||
pricingSnapshot: { basePrice: 10, multiplier: 1, unitVersion: store.balanceUnit.version },
|
||||
modelSnapshot: { publicModelId: imagePublicModelId, version: imageProduct?.version || 1 },
|
||||
routeSnapshot: { groupId: channelGroupId, version: 1, channelIds: store.channels.length ? [channelId] : [] },
|
||||
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: imageModelSnapshot,
|
||||
routeSnapshot: imageSnapshotRoute,
|
||||
planSnapshot: { planId: store.plans[0]?.id, version: store.plans[0]?.version, queuePriority: 0, maxConcurrent: 2 },
|
||||
};
|
||||
addTask(store, successTask, taskEvents(successTask, "task.succeeded", successFinished));
|
||||
@@ -124,7 +126,7 @@ export function ensureAdminDemoData(store: Store) {
|
||||
attempts: [],
|
||||
outputs: [],
|
||||
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));
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import { createHash } 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 { ErrorCodes } from "@miragenflow/contracts";
|
||||
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);
|
||||
const pool = this.client || (this.client = await this.connect());
|
||||
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 {
|
||||
await client.query("BEGIN");
|
||||
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]); }
|
||||
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.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.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.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; 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>();
|
||||
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)]); }
|
||||
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]); }
|
||||
@@ -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 ["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;
|
||||
output.snapshotVersion = 1;
|
||||
output.snapshotVersion = DOMAIN_SNAPSHOT_VERSION;
|
||||
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 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 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 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 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) => ({ ...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 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() }]);
|
||||
@@ -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 output[root] = values.find((row) => row.entity_id === "__value__")?.payload;
|
||||
}
|
||||
output.snapshotVersion = 1;
|
||||
output.snapshotVersion = DOMAIN_SNAPSHOT_VERSION;
|
||||
return output;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { invokeProvider, queryProvider } from "../adapters/provider.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;
|
||||
}
|
||||
|
||||
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) {
|
||||
const local = store.tasks.get(taskId);
|
||||
if (local) return local;
|
||||
@@ -134,7 +139,7 @@ export function scheduleTask(store: Store, taskId: string) {
|
||||
if (!store.queue.includes(taskId)) store.queue.push(taskId);
|
||||
const task = store.tasks.get(taskId);
|
||||
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);
|
||||
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());
|
||||
@@ -142,7 +147,7 @@ export function scheduleTask(store: Store, taskId: string) {
|
||||
// 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.
|
||||
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;
|
||||
}
|
||||
try {
|
||||
@@ -151,7 +156,7 @@ export function scheduleTask(store: Store, taskId: string) {
|
||||
await store.queueAdapter.ack(lease.taskId, lease.leaseToken);
|
||||
} catch (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);
|
||||
}
|
||||
}).catch((error) => {
|
||||
@@ -190,7 +195,7 @@ export async function dispatchTaskOutbox(store: Store) {
|
||||
// The outbox dispatcher only publishes a uniquely identified task. It
|
||||
// must not claim an arbitrary queue member here: queue execution is a
|
||||
// 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);
|
||||
if (!completed) continue;
|
||||
const outbox = store.taskDispatchOutbox.get(taskId);
|
||||
@@ -222,7 +227,7 @@ export async function dispatchTaskOutbox(store: Store) {
|
||||
await runTask(store, lease.taskId, lease.leaseToken);
|
||||
await store.queueAdapter.ack(lease.taskId, lease.leaseToken);
|
||||
} 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);
|
||||
}
|
||||
}));
|
||||
@@ -236,13 +241,13 @@ export async function resumeQueuedTasks(store: Store) {
|
||||
for (const taskId of queued) {
|
||||
if (!store.queue.includes(taskId)) store.queue.push(taskId);
|
||||
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);
|
||||
if (outbox) { outbox.status = "sent"; outbox.dispatchedAt = new Date().toISOString(); store.persist(); }
|
||||
const lease = await store.queueAdapter.claim(workerId, workerLeaseMs());
|
||||
if (!lease) continue;
|
||||
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;
|
||||
}
|
||||
try {
|
||||
@@ -251,7 +256,7 @@ export async function resumeQueuedTasks(store: Store) {
|
||||
await store.queueAdapter.ack(lease.taskId, lease.leaseToken);
|
||||
} catch (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);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -417,27 +422,32 @@ async function runTaskWithLease(store: Store, taskId: string, leaseToken: string
|
||||
current.status = "running";
|
||||
current.leaseToken = leaseToken;
|
||||
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;
|
||||
});
|
||||
if (!started) return;
|
||||
const task = store.tasks.get(taskId);
|
||||
if (!task) return;
|
||||
const group = store.channelGroups.find((item) => item.id === task.channelGroupId);
|
||||
const routeIds = task.routeSnapshot?.channelIds || group?.channelIds || [];
|
||||
const routeCandidates = task.routeSnapshot?.orderedCandidates || [];
|
||||
const now = Date.now();
|
||||
const channels = routeIds.map((id) => store.channels.find((channel) => channel.id === id)).filter((channel): channel is NonNullable<typeof channel> => {
|
||||
if (!channel?.enabled) return false;
|
||||
if (channel.health !== "open") return true;
|
||||
if (channel.circuitResetAt && channel.circuitResetAt <= now) { channel.health = "degraded"; channel.circuitOpenedAt = undefined; channel.circuitResetAt = undefined; return true; }
|
||||
return false;
|
||||
});
|
||||
const channels: Array<{ candidate: RouteSnapshotCandidate; channel: Store["channels"][number] }> = [];
|
||||
for (const candidate of routeCandidates) {
|
||||
const channel = store.channels.find((item) => item.id === candidate.channelId);
|
||||
if (!channel?.enabled) continue;
|
||||
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 = [];
|
||||
let groupRetries = Math.max(0, task.routeSnapshot?.retryBudget ?? group?.retryBudget ?? 0);
|
||||
for (const channel of channels) {
|
||||
attemptPlan.push(channel);
|
||||
const retries = Math.min(Math.max(0, task.routeSnapshot?.channelRetryBudgets?.[channel.id] ?? channel.retryBudget), groupRetries);
|
||||
for (let retry = 0; retry < retries; retry += 1) attemptPlan.push(channel);
|
||||
let groupRetries = Math.max(0, task.routeSnapshot?.totalRetryBudget ?? 0);
|
||||
for (const item of channels) {
|
||||
attemptPlan.push(item);
|
||||
const retries = Math.min(Math.max(0, item.candidate.retryBudget), groupRetries);
|
||||
for (let retry = 0; retry < retries; retry += 1) attemptPlan.push(item);
|
||||
groupRetries -= retries;
|
||||
}
|
||||
const maxAttempts = attemptPlan.length;
|
||||
@@ -448,10 +458,10 @@ async function runTaskWithLease(store: Store, taskId: string, leaseToken: string
|
||||
const failures = fixtureFailures();
|
||||
for (let index = 0; index < maxAttempts; index += 1) {
|
||||
if (!ownsLease()) return;
|
||||
const channel = attemptPlan[index];
|
||||
const { channel, candidate } = attemptPlan[index];
|
||||
if (channel.health === "open" && (!channel.circuitResetAt || channel.circuitResetAt > Date.now())) continue;
|
||||
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() };
|
||||
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 } });
|
||||
@@ -459,7 +469,7 @@ async function runTaskWithLease(store: Store, taskId: string, leaseToken: string
|
||||
if (!ownsLease()) return;
|
||||
if (channel.baseUrl) {
|
||||
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 (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;
|
||||
@@ -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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -476,7 +486,7 @@ async function runTaskWithLease(store: Store, taskId: string, leaseToken: string
|
||||
attempt.status = "failed";
|
||||
attempt.errorCode = "CHANNEL_NOT_CONFIGURED";
|
||||
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;
|
||||
}
|
||||
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.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 = "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 } });
|
||||
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]?.channel.id } });
|
||||
continue;
|
||||
}
|
||||
if (channel.fixtureFailure === "unknown") {
|
||||
|
||||
+91
-23
@@ -1,23 +1,46 @@
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
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 { createPersistenceRepository, type PersistenceRepository, type RepositoryHealth } from "./infra/repository.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 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 = {
|
||||
id: string;
|
||||
version: number;
|
||||
name?: string;
|
||||
capabilities?: string[];
|
||||
channelIds: string[];
|
||||
channelPriorities?: Record<string, number>;
|
||||
routes: ModelRoute[];
|
||||
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 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 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 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 Store = {
|
||||
@@ -57,6 +80,8 @@ export type Store = {
|
||||
buckets: Map<string, BalanceBucket[]>;
|
||||
reserveAllocations: Map<string, ReserveAllocation[]>;
|
||||
products: InternalModelProduct[];
|
||||
pricingRules: PricingRule[];
|
||||
healthSamples: ChannelHealthSample[];
|
||||
balanceUnit: BalanceUnitConfig;
|
||||
plans: MembershipPlan[];
|
||||
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 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 [
|
||||
{ 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: "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: "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: "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-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-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], 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], 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"], enabled: true, version: 1, capabilitiesSchema: [capabilitySchema("text")], 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 };
|
||||
|
||||
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 paymentFixtureMode = process.env.NODE_ENV === "test" || process.env.MIRAGENFLOW_TEST_MODE === "true";
|
||||
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 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 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 channelModels: ChannelModel[] = products.map((product) => ({ requestModelId: product.publicModelId, source: "manual", enabled: true }));
|
||||
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 repository = createPersistenceRepository({ adapter: options.persistenceAdapter || (path ? "file" : "memory"), filePath: path, databaseUrl: options.databaseUrl, key: "store" });
|
||||
const queueAdapter = createTaskQueue({ adapter: options.queueAdapter, redisUrl: options.redisUrl });
|
||||
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,
|
||||
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) {
|
||||
if (!existsSync(path)) return;
|
||||
try {
|
||||
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);
|
||||
} catch (error) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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.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 (Array.isArray(raw.plans)) store.plans = raw.plans;
|
||||
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 {
|
||||
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; }
|
||||
@@ -269,7 +329,10 @@ export function createStore(path = process.env.MIRAGENFLOW_STORE_FILE?.trim() ||
|
||||
const epoch = persistenceEpoch;
|
||||
const raw = await store.repository.load();
|
||||
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;
|
||||
return;
|
||||
}
|
||||
@@ -288,21 +351,26 @@ export function createStore(path = process.env.MIRAGENFLOW_STORE_FILE?.trim() ||
|
||||
// reload and commit the already prepared task graph atomically.
|
||||
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;
|
||||
store.transactionDepth += 1; store.transactionEvents = [];
|
||||
const eventStart = store.transactionEvents.length;
|
||||
if (store.transactionDepth === 0) store.transactionEvents = [];
|
||||
store.transactionDepth += 1;
|
||||
try {
|
||||
const result = await mutator(store);
|
||||
store.transactionDepth -= 1;
|
||||
store.transactionDepth = Math.max(0, store.transactionDepth - 1);
|
||||
if (store.transactionDepth === 0) {
|
||||
await store.persistAsync();
|
||||
const events = store.transactionEvents.splice(0);
|
||||
for (const event of events) for (const subscriber of store.taskSubscribers.get(event.taskId) || []) subscriber(event);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
store.transactionDepth = Math.max(0, store.transactionDepth - 1);
|
||||
store.transactionEvents = [];
|
||||
store.transactionEvents.splice(eventStart);
|
||||
// Validation can fail before the mutator changes anything. Avoid
|
||||
// rebuilding the graph in that case so callers holding a reference
|
||||
// to an untouched record keep observing the same object.
|
||||
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));
|
||||
if (conflict && store.repository.adapter === "postgres" && options.reload !== false && attempt + 1 < Math.max(1, retries)) continue;
|
||||
throw error;
|
||||
|
||||
@@ -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 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);
|
||||
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 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);
|
||||
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);
|
||||
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 {
|
||||
|
||||
@@ -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 () => {
|
||||
const store = createStore();
|
||||
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;
|
||||
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/);
|
||||
@@ -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", () => {
|
||||
const store = createStore();
|
||||
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 });
|
||||
assert.equal(recoverExpiredTaskLeases(store), 1);
|
||||
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 }]);
|
||||
assert.equal(reserveBalance(store, userId, 10, "unknown-expiry-task"), true);
|
||||
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(store.tasks.get("unknown-expiry-task")?.status, "unknown");
|
||||
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);
|
||||
store.persist();
|
||||
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");
|
||||
assert.throws(() => createStore(file), /persistence snapshot rejected/);
|
||||
} finally { await rm(root, { recursive: true, force: true }); }
|
||||
|
||||
@@ -11,8 +11,12 @@ function channel(store: ReturnType<typeof createStore>) {
|
||||
...store.channels[0],
|
||||
providerType: "openai-images" as const,
|
||||
baseUrl: "https://1.1.1.1/v1",
|
||||
providerModelId: "base-model",
|
||||
resolutionModelMap: { "2K": "mapped-2k" },
|
||||
models: [
|
||||
{ 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),
|
||||
};
|
||||
}
|
||||
@@ -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" } });
|
||||
};
|
||||
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.providerRequestId, "request-123");
|
||||
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]?.headers.get("authorization"), "Bearer 1234567890123456");
|
||||
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 {
|
||||
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" } });
|
||||
};
|
||||
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");
|
||||
} finally {
|
||||
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" } });
|
||||
};
|
||||
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.errorCode, "PROVIDER_INVALID_OUTPUT");
|
||||
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 {
|
||||
const result = await invokeProvider(store, channel(store), {
|
||||
taskId: "task-4",
|
||||
taskId: "task-4", requestModelId: "base-model",
|
||||
taskType: "image",
|
||||
prompt: "使用@图片一",
|
||||
count: 1,
|
||||
@@ -141,7 +145,7 @@ test("unavailable reference objects fail without retrying the provider", async (
|
||||
};
|
||||
try {
|
||||
const result = await invokeProvider(store, channel(store), {
|
||||
taskId: "task-5",
|
||||
taskId: "task-5", requestModelId: "base-model",
|
||||
taskType: "image",
|
||||
prompt: "使用第一张图",
|
||||
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 }); };
|
||||
try {
|
||||
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.errorCode, "PROVIDER_REFERENCE_UNAVAILABLE");
|
||||
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" } });
|
||||
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.errorCode, "PROVIDER_INVALID_OUTPUT");
|
||||
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" } });
|
||||
};
|
||||
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.errorCode, "PROVIDER_INVALID_OUTPUT");
|
||||
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 });
|
||||
};
|
||||
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.deepEqual(result.outputs?.[0]?.metadata, { width: 1, height: 1, format: "png", size: "1x1", revisedPrompt: undefined, usage: undefined, source: "base64" });
|
||||
} 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" } });
|
||||
};
|
||||
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(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.errorCode, "PROVIDER_REFERENCE_UNAVAILABLE");
|
||||
} finally {
|
||||
@@ -261,7 +265,7 @@ test("image references validate mentions and add a default reference instruction
|
||||
|
||||
test("invalid provider reference sequences fail closed", async () => {
|
||||
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.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 () => {
|
||||
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() });
|
||||
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.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 }); };
|
||||
try {
|
||||
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.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" } });
|
||||
};
|
||||
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");
|
||||
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.errorCode, "PROVIDER_REFERENCE_LIMIT");
|
||||
} finally {
|
||||
@@ -331,7 +335,7 @@ test("provider output metadata detects JPEG and WebP bytes instead of trusting a
|
||||
try {
|
||||
for (const [index, suffix] of ["jpeg", "webp"].entries()) {
|
||||
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.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 });
|
||||
@@ -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");
|
||||
assert.deepEqual(probe, { healthy: true, models: ["gpt-image-2"] });
|
||||
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.errorCode, "PROVIDER_TRANSPORT_UNKNOWN");
|
||||
const abortedFetch = globalThis.fetch;
|
||||
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.errorCode, "PROVIDER_TIMEOUT_UNKNOWN");
|
||||
globalThis.fetch = async () => { throw new Error("probe transport failed"); };
|
||||
|
||||
@@ -119,7 +119,7 @@ test("admin RBAC rejects finance access and redacts support channel internals",
|
||||
const { store, server, base } = await start("support");
|
||||
try {
|
||||
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";
|
||||
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 } };
|
||||
@@ -165,12 +165,12 @@ test("OpenAI image channel creation does not probe implicitly and explicit probi
|
||||
return previousFetch(input, init);
|
||||
};
|
||||
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);
|
||||
const createdPayload = await created.json() as { data: { item: Record<string, unknown> } };
|
||||
assert.equal(createdPayload.data.item.secretRef, "configured");
|
||||
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);
|
||||
assert.ok(channel);
|
||||
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]?.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(store.channels.some((item) => item.label === "不安全渠道"), true);
|
||||
} finally {
|
||||
|
||||
@@ -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 loginPayload = await login.json() as { data: { accessToken: string } };
|
||||
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);
|
||||
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");
|
||||
@@ -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?.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);
|
||||
} finally {
|
||||
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();
|
||||
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 loginPayload = await login.json() as { data: { accessToken: string } };
|
||||
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);
|
||||
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);
|
||||
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 }) });
|
||||
assert.equal(created.status, 201);
|
||||
const createdPayload = await created.json() as { data: { item: { productIds?: string[] } } };
|
||||
assert.deepEqual(createdPayload.data.item.productIds, ["group-basic-image"]);
|
||||
assert.equal(store.products.find((product) => product.id === "group-basic-image")?.channelGroupId, "image-product-group");
|
||||
assert.equal(created.status, 422);
|
||||
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.equal(createdWithRoutes.status, 201);
|
||||
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 });
|
||||
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");
|
||||
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"]);
|
||||
|
||||
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(store.products.find((product) => product.id === "group-basic-image")?.channelGroupId, undefined);
|
||||
assert.equal(edited.status, 422);
|
||||
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-advanced-image")?.channelGroupId, "image-product-group");
|
||||
} finally {
|
||||
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();
|
||||
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 loginPayload = await login.json() as { data: { accessToken: string } };
|
||||
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 }) });
|
||||
assert.equal(imageGroup.status, 201);
|
||||
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"] }] }) });
|
||||
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-capability-model" }, body: JSON.stringify({ models: [...channel.models, { requestModelId: "capability-image-v1", source: "manual", enabled: true }] }) });
|
||||
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);
|
||||
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);
|
||||
|
||||
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.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" }) });
|
||||
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(store.channelGroups.some((group) => group.id === "capability-other-group"), false);
|
||||
} 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 taskId = "reconcile-image-task";
|
||||
const ownerId = "reconcile-user";
|
||||
@@ -267,13 +276,14 @@ test("admin unknown-task reconciliation stores real image metadata and clamps th
|
||||
status: "unknown",
|
||||
estimatedCost: 10,
|
||||
reservedCost: 10,
|
||||
count: 2,
|
||||
pricingSnapshot: { basePrice: 5, multiplier: 1, unitVersion: 1 },
|
||||
count: 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,
|
||||
updatedAt: now,
|
||||
eventSequence: 0,
|
||||
channelGroupId: "image-default",
|
||||
routeSnapshotVersion: 1,
|
||||
attempts: [{ id: "reconcile-attempt", channelId: "channel-a", sequence: 1, status: "unknown", startedAt: now, reconciliationStatus: "pending" }],
|
||||
outputs: [],
|
||||
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.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 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 });
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json() as { data: { item: GenerationTask } };
|
||||
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 });
|
||||
assert.equal(repeated.status, 200);
|
||||
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?.source, "base64");
|
||||
assert.equal(output.metadata?.resolution, "2K");
|
||||
assert.equal(output.unitPriceSnapshot, 5);
|
||||
assert.equal(output.unitPriceSnapshot, 10);
|
||||
assert.equal(output.chargedAmount, 10);
|
||||
assert.equal(store.balances.get(ownerId)?.reserved, 0);
|
||||
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,
|
||||
updatedAt: now,
|
||||
eventSequence: 0,
|
||||
channelGroupId: "image-default",
|
||||
routeSnapshotVersion: 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 },
|
||||
attempts: [{ id: "reconcile-failure-attempt", channelId: "channel-a", sequence: 1, status: "unknown", startedAt: now, reconciliationStatus: "pending" }],
|
||||
outputs: [],
|
||||
} 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 });
|
||||
const group = store.channelGroups.find((item) => item.id === "image-default");
|
||||
assert.ok(group);
|
||||
group.channelIds = ["channel-c", "channel-a", "channel-d", "channel-b"];
|
||||
group.channelPriorities = { "channel-c": 0, "channel-a": 1, "channel-d": 2, "channel-b": 3 };
|
||||
const route = group.routes.find((item) => item.publicModelId === "basic-image-v1");
|
||||
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" }) });
|
||||
assert.equal(response.status, 202);
|
||||
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 {
|
||||
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`, { 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/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/recharge-orders/unknown-order`, { headers: headers("alias-order-detail") })).status, 404);
|
||||
} finally {
|
||||
|
||||
@@ -17,7 +17,7 @@ test("task websocket authenticates and replays a subscribed task cursor", async
|
||||
assert.ok(address && typeof address !== "string");
|
||||
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.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 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 } }> = [];
|
||||
|
||||
Reference in New Issue
Block a user