若干更新迭代

This commit is contained in:
andy committed 2026-10-02 19:56:32 +08:00
1 parent deb9cc5bcf
commit 4ac786aafb
64 files changed
+3251 -291

No files matched your search

+14
View File
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it } from "vitest";
import {
bailianResultUrls,
bailianOutputVideoSeconds,
bailianStatus,
buildBailianImagePayload,
buildBailianVideoPayload,
@@ -58,6 +59,19 @@ describe("Bailian client", () => {
expect(payload.parameters).not.toHaveProperty("size");
});
it("maps Wan 3.0 references while retaining optional first and last frames", () => {
const materials = Array.from({ length: 10 }, (_, index) => ({ type: "image" as const, url: `/image-${index}.png` }));
const reference = buildBailianVideoPayload({ model: "wan3.0-video", prompt: "combine", materials, origin: "https://app.test", settings: { duration: 30, resolution: "480P", ratio: "9:16", inputMode: "reference" } });
expect(reference.input.media).toHaveLength(10);
expect(reference.input.media?.every((item) => item.type === "reference_image")).toBe(true);
expect(reference.parameters.ratio).toBe("9:16");
const frames = buildBailianVideoPayload({ model: "wan3.0-video", prompt: "animate", materials: materials.slice(0, 2), origin: "https://app.test", settings: { inputMode: "frames" } });
expect(frames.input.media?.map((item) => item.type)).toEqual(["first_frame", "last_frame"]);
expect(buildBailianVideoPayload({ model: "wan3.0-video", prompt: "landscape", materials: [], origin: "https://app.test", settings: { inputMode: "reference" } }).input).toEqual({ prompt: "landscape" });
expect(() => buildBailianVideoPayload({ model: "wan2.7-i2v-2026-04-25", prompt: "x", materials, origin: "https://app.test", settings: { inputMode: "reference" } })).toThrow("请选择 Wan 3.0");
expect(bailianOutputVideoSeconds({ usage: { output_video_duration: 5.25, duration: 8 } })).toBe(5.25);
});
it("normalizes task state and result URLs", () => {
const response = { output: { task_status: "SUCCEEDED", results: [{ url: "https://example.com/result.png" }] } };
expect(bailianStatus(response)).toBe("succeeded");
+46 -1
View File
@@ -3,8 +3,9 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_BILLING_PRICE_RULES, ensureDefaultBillingPriceRules } from "@/lib/server/billing-catalog";
import { quoteGenerationCharge } from "@/lib/server/billing-service";
import { quoteGenerationCharge, settleMinimaxGenerationCharge } from "@/lib/server/billing-service";
import { createBillingPriceRule, getBillingPriceRule, updateBillingPriceTierMultiplier } from "@/lib/server/billing-store";
import { createGenerationJob } from "@/lib/server/data-store";
let runtimeDir = "";
@@ -44,6 +45,50 @@ describe("default billing catalog refresh", () => {
expect(tiers?.find((tier) => tier.value === "2K")?.markupMultiplier).toBe(1.2);
});
it("quotes MiniMax nine references and keeps Wan 3.0 resolution pricing independent", async () => {
const context = { source: "platform" as const, accountId: "user-1", displayName: "测试用户", organizationId: "org-1" };
const minimax = await quoteGenerationCharge({
provider: "minimax", capability: "video.generate", reqKey: "MiniMax-H3",
requestPayload: {
settings: { duration: 5, resolution: "2K" },
materials: Array.from({ length: 9 }, (_, index) => ({ type: "image", url: `https://cdn.test/${index}.png` }))
},
usageContext: context
});
expect(minimax).toMatchObject({ amountFen: 576, reservedAmountFen: 576, settlementStatus: "pending" });
expect(minimax?.parameters).toMatchObject({ inputImageCount: 9, inputImagePriceFen: 80 });
const wan3 = await quoteGenerationCharge({
provider: "bailian", capability: "video.generate", reqKey: "wan3.0-video",
requestPayload: { settings: { duration: 5, resolution: "1080P" } }, usageContext: context
});
expect(wan3).toMatchObject({
priceRuleId: "base-bailian-wan30-video-1080p", amountFen: 720,
reservedAmountFen: 720, settlementStatus: "pending"
});
});
it("retains quoted MiniMax image fees only when provider omits image usage", async () => {
const materials = Array.from({ length: 9 }, (_, index) => ({ type: "image", url: `https://cdn.test/${index}.png` }));
const makeJob = () => createGenerationJob({
ownerId: "user-1", capability: "video.generate", provider: "minimax", reqKey: "MiniMax-H3", status: "succeeded",
inputAssetIds: [], inputUrls: [], outputAssetIds: [], requestPayload: { materials },
usageContext: { source: "platform", accountId: "user-1", displayName: "管理员", role: "super_admin" },
billing: {
priceRuleId: "base-minimax-h3-2k", provider: "minimax", capability: "video.generate", reqKey: "MiniMax-H3",
unit: "video_second", quantity: 5, standardUnitPriceFen: 80, markupMultiplier: 1.2,
amountFen: 576, reservedAmountFen: 576, currency: "CNY", status: "not_charged", quotaExempt: true,
settlementStatus: "pending", parameters: { resolution: "2k", inputImageCount: 9 }
}
});
const missing = await settleMinimaxGenerationCharge(await makeJob(), { outputSeconds: 5, inputSeconds: 0 });
expect(missing?.billing).toMatchObject({ amountFen: 576, settlementStatus: "settled", providerUsage: { inputImageCount: 9 } });
const explicitZero = await settleMinimaxGenerationCharge(await makeJob(), { outputSeconds: 5, inputSeconds: 0, inputImageCount: 0 });
expect(explicitZero?.billing).toMatchObject({ amountFen: 480, settlementStatus: "settled", providerUsage: { inputImageCount: 0 } });
});
it("takes current default definitions while retaining valid matching tier markups", async () => {
const candidate = DEFAULT_BILLING_PRICE_RULES.find((rule) => rule.id === "base-seedream-5-0-pro")!;
const size = candidate.parameterDimensions!.find((dimension) => dimension.key === "size")!;
+1
View File
@@ -11,6 +11,7 @@ describe("billing HTTP compatibility fixture", () => {
});
expect(fixture.routes).toEqual(expect.arrayContaining([
expect.objectContaining({ method: "GET", path: "/api/billing", organizationSource: "refreshed_session" }),
expect.objectContaining({ method: "GET", path: "/api/billing/balance", requirement: "app", organizationSource: "refreshed_session", unboundStatus: 422, cacheControl: "no-store", responseFields: ["organization", "wallet"] }),
expect.objectContaining({ method: "PATCH", path: "/api/admin/billing/prices", requirement: "super_admin", allowedFields: ["ruleIds", "markupMultiplier"], atomic: true }),
expect.objectContaining({ method: "PATCH", path: "/api/admin/billing/prices/{id}", requirement: "super_admin" })
]));
+4 -3
View File
@@ -46,13 +46,14 @@ describe("create studio image paste wiring", () => {
.toBeLessThan(flow.indexOf("setMaterials(next)"));
});
it("routes special modes through existing source limits and adds references only for basic image or video", async () => {
it("validates video paste without replacing or truncating selected materials", async () => {
const { flow } = await pasteFlowSource();
expect(flow).toContain('imageCreationMode === "interactive" ? "append" : "single"');
expect(flow).toContain("isSeedreamLayerInputFile(files[0])");
expect(flow).toContain("if (selectedSeedreamSource && !window.confirm(");
expect(flow).toContain("options.maxFiles = 1");
expect(flow).toContain("options.replaceAll = true");
expect(flow).toContain("videoMaterialError(videoSelection, [...materials");
expect(flow).toContain("videoMaterialError(videoSelection, next)");
expect(flow).not.toContain("options.replaceAll = true");
expect(flow).toContain("options.maxFiles = remaining");
expect(flow).toContain("insertReferences: !specializedImageMode");
expect(flow).toContain('const labels = addedMaterials.map((material) => material.label || "");');
+99
View File
@@ -0,0 +1,99 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { GenerationError } from "@/components/generation-error";
import { generationErrorMessage, providerErrorDiagnostic } from "@/lib/client/provider-error";
import type { GenerationJob } from "@/lib/types";
function job(overrides: Partial<GenerationJob> = {}): GenerationJob {
return {
id: "job-test",
ownerId: "user-test",
capability: "video.generate",
provider: "seedance",
reqKey: "doubao-seedance-2-0-260128",
status: "failed",
inputAssetIds: [],
inputUrls: [],
outputAssetIds: [],
requestPayload: {},
error: { code: "InputImageSensitiveContentDetected.PrivacyInformation", message: "参考图片涉及真人肖像,未通过服务商审核。" },
...overrides
} as GenerationJob;
}
describe("generation task error presentation", () => {
it("shows the actionable reason without diagnostics to ordinary users", () => {
const result = renderToStaticMarkup(createElement(GenerationError, {
job: job({ responsePayload: { providerError: {
status: 400,
code: "InputImageSensitiveContentDetected.PrivacyInformation",
requestId: "request-secret",
detail: "provider-private-detail",
phase: "submit"
} } }),
isAdmin: false,
providerName: "Seedance"
}));
expect(result).toContain("失败原因");
expect(result).toContain("参考图片涉及真人肖像");
expect(result).not.toContain("错误详情");
expect(result).not.toContain("request-secret");
expect(result).not.toContain("provider-private-detail");
});
it("shows only available diagnostic fields to an administrator", () => {
const result = renderToStaticMarkup(createElement(GenerationError, {
job: job({ responsePayload: { providerError: {
status: 400,
code: "InputImageSensitiveContentDetected.PrivacyInformation",
requestId: "",
detail: "已脱敏的服务商说明",
phase: "submit"
} } }),
isAdmin: true,
providerName: "Seedance"
}));
expect(result).toContain("错误详情");
expect(result).toContain("HTTP 状态");
expect(result).toContain("400");
expect(result).toContain("错误码");
expect(result).toContain("已脱敏的服务商说明");
expect(result).not.toContain("请求 ID");
});
it("does not describe a queued retry as a final failure", () => {
const result = renderToStaticMarkup(createElement(GenerationError, {
job: job({ status: "queued", error: { message: "服务商暂时繁忙,系统稍后重试。" } }),
isAdmin: false,
providerName: "Seedance"
}));
expect(result).toContain("任务提示");
expect(result).not.toContain("失败原因");
});
it("translates old unknown-submission records without falsely claiming rejection", () => {
const legacy = job({ error: { message: "provider submission outcome is unknown; refusing duplicate submission" }, responsePayload: undefined });
expect(generationErrorMessage(legacy)).toContain("平台已停止自动重试");
expect(generationErrorMessage(legacy)).not.toContain("服务商拒绝");
expect(generationErrorMessage(legacy)).not.toContain("稍后刷新");
});
it("uses a known privacy code for an old task but ignores unrelated payload data", () => {
const legacy = job({
error: { code: "InputImageSensitiveContentDetected.PrivacyInformation", message: "provider submission outcome is unknown; refusing duplicate submission" },
responsePayload: { raw: { accessToken: "must-not-appear" } }
});
expect(generationErrorMessage(legacy)).toContain("真人肖像");
expect(providerErrorDiagnostic(legacy)).toEqual({ code: "InputImageSensitiveContentDetected.PrivacyInformation" });
});
it("does not infer a privacy review from a code with an extra suffix", () => {
const other = job({ error: {
code: "InputImageSensitiveContentDetected.PrivacyInformation.Unknown",
message: "provider submission outcome is unknown; refusing duplicate submission"
} });
expect(generationErrorMessage(other)).toContain("暂时无法确认服务商是否已接收");
expect(generationErrorMessage(other)).not.toContain("真人肖像");
});
});
+12
View File
@@ -27,6 +27,16 @@ describe("MiniMax H3 V2 client", () => {
expect(() => buildMinimaxH3Payload({ prompt: "bad", materials: [{ type: "video", url: "/a.mp4" }], settings: {}, origin: "https://app.test" })).toThrow("只上传 1 张图片");
});
it("sends every selected reference image and rejects overflow", () => {
const materials = Array.from({ length: 9 }, (_, index) => ({ type: "image" as const, url: `/image-${index}.png` }));
const payload = buildMinimaxH3Payload({ prompt: "combine", materials, settings: { inputMode: "reference", ratio: "adaptive" }, origin: "https://app.test" });
expect(payload.content).toHaveLength(10);
expect(payload.content.slice(1).every((item) => "role" in item && item.role === "reference_image")).toBe(true);
expect(buildMinimaxH3Payload({ prompt: "text only", materials: [], settings: { inputMode: "reference", ratio: "16:9" }, origin: "https://app.test" }).content).toHaveLength(1);
expect(() => buildMinimaxH3Payload({ prompt: "text only", materials: [], settings: { inputMode: "reference", ratio: "adaptive" }, origin: "https://app.test" })).toThrow("固定画面比例");
expect(() => buildMinimaxH3Payload({ prompt: "x", materials: [...materials, materials[0]], settings: { inputMode: "reference" }, origin: "https://app.test" })).toThrow("最多支持 9 张图片");
});
it("submits and queries the official asynchronous endpoints", async () => {
process.env.MINIMAX_API_KEY = "test-key";
process.env.MINIMAX_BASE_URL = "https://minimax.test";
@@ -45,6 +55,8 @@ describe("MiniMax H3 V2 client", () => {
it("extracts billable usage", () => {
expect(extractMinimaxH3Usage({ total_seconds: 8, input_seconds: 0, output_seconds: 8, input_image_count: 1 })).toEqual({ totalSeconds: 8, inputSeconds: 0, outputSeconds: 8, inputImageCount: 1 });
expect(extractMinimaxH3Usage({ output_seconds: 8 })).not.toHaveProperty("inputImageCount");
expect(extractMinimaxH3Usage({ output_seconds: 8, input_image_count: 0 })).toHaveProperty("inputImageCount", 0);
expect(extractMinimaxH3Usage({ output_seconds: 0 })).toBeUndefined();
});
});
+52
View File
@@ -0,0 +1,52 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchOrganizationBalance } from "@/lib/client/organization-balance";
const payload = (balanceFen = 12345) => ({
organization: { id: "org-current", name: "当前组织" },
wallet: { organizationId: "org-current", balanceFen, updatedAt: "2026-10-01T00:00:00Z" }
});
afterEach(() => vi.unstubAllGlobals());
describe("creation organization balance", () => {
it.each([12345, 0, -100])("preserves the available balance %i without substituting a fallback", async (amount) => {
const fetchMock = vi.fn().mockResolvedValue(Response.json(payload(amount)));
vi.stubGlobal("fetch", fetchMock);
const controller = new AbortController();
expect((await fetchOrganizationBalance("org-current", controller.signal)).wallet.balanceFen).toBe(amount);
expect(fetchMock).toHaveBeenCalledWith("/api/billing/balance", { cache: "no-store", signal: controller.signal });
});
it.each([
{ ...payload(), wallet: { ...payload().wallet, organizationId: "org-other" } },
{ ...payload(), wallet: { ...payload().wallet, balanceFen: "12345" } },
{ ...payload(), wallet: { ...payload().wallet, balanceFen: 1.5 } },
{ organization: payload().organization },
null
])("rejects malformed or other-organization responses instead of showing a balance", async (value) => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json(value)));
await expect(fetchOrganizationBalance("org-current")).rejects.toThrow("余额信息不完整");
});
it("explains when the server has refreshed the user into a different organization", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({
...payload(), organization: { id: "org-other", name: "新组织" }
})));
await expect(fetchOrganizationBalance("org-current")).rejects.toThrow("账号所属组织已变更,请刷新页面");
});
it.each([401, 403, 422])("explains expired or changed access for HTTP %i", async (status) => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({}, { status })));
await expect(fetchOrganizationBalance("org-current")).rejects.toThrow("登录状态或组织信息已变更,请刷新页面");
});
it("does not expose internal errors or turn failures into zero balance", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ error: "internal database details" }, { status: 500 })));
await expect(fetchOrganizationBalance("org-current")).rejects.toThrow("余额暂时无法读取,请重试。");
});
it("handles a non-JSON proxy response as an error", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("gateway unavailable", { status: 502 })));
await expect(fetchOrganizationBalance("org-current")).rejects.toThrow("余额暂时无法读取,请重试。");
});
});
+3 -3
View File
@@ -17,7 +17,7 @@ describe("Go provider adapter contract", () => {
submit: "/contents/generations/tasks",
query: "/contents/generations/tasks/{id}",
maxMaterials: {
"doubao-seedance-2-0-260128": 4,
"doubao-seedance-2-0-260128": 15,
"doubao-seedance-2-5-260628": 50
},
maxDurationSeconds: {
@@ -40,8 +40,8 @@ describe("Go provider adapter contract", () => {
model: "MiniMax-H3",
submit: "/v2/video_generation",
query: "/v2/query/video_generation/{id}",
modes: ["text-to-video", "first-frame-to-video"],
maxMaterials: 1,
modes: ["text-to-video", "first-frame-to-video", "reference-to-video"],
maxMaterials: 9,
durationSeconds: { min: 4, max: 15 },
resolutions: ["768P", "2K"]
});
+30 -11
View File
@@ -88,21 +88,40 @@ describe("Seedance usage extraction", () => {
expect(body?.content).toHaveLength(6);
});
it("rejects more materials than the Seedance 2.0 content limit before calling the provider", async () => {
it("accepts nine Seedance 2.0 reference images and rejects a tenth", async () => {
process.env.SEEDANCE_API_KEY = "test-key";
let body: Record<string, unknown> | undefined;
const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
body = JSON.parse(String(init?.body));
return new Response(JSON.stringify({ id: "task-20" }), { status: 200, headers: { "Content-Type": "application/json" } });
});
vi.stubGlobal("fetch", fetchMock);
const materials = Array.from({ length: 9 }, (_, index) => ({
type: "image" as const,
url: `/image-${index + 1}.png`,
label: `@图片${index + 1}`
}));
await createSeedanceTask({
prompt: "combine",
settings: {},
materials,
origin: "https://app.test"
});
expect(body?.content).toHaveLength(10);
expect(fetchMock).toHaveBeenCalledTimes(1);
await expect(createSeedanceTask({
prompt: "combine", settings: {}, materials: [...materials, { type: "image", url: "/image-10.png" }], origin: "https://app.test"
})).rejects.toThrow("最多支持 9 张图片");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("rejects Seedance 2.0 audio without an image or video", async () => {
process.env.SEEDANCE_API_KEY = "test-key";
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(createSeedanceTask({
prompt: "combine",
settings: {},
materials: Array.from({ length: 5 }, (_, index) => ({
type: "image" as const,
url: `/image-${index + 1}.png`,
label: `@图片${index + 1}`
})),
origin: "https://app.test"
})).rejects.toThrow("最多支持 4 个素材");
prompt: "music", settings: {}, materials: [{ type: "audio", url: "/music.mp3" }], origin: "https://app.test"
})).rejects.toThrow("音频不能单独使用");
expect(fetchMock).not.toHaveBeenCalled();
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { BAILIAN_27_VIDEO_MODEL, BAILIAN_30_VIDEO_MODEL, videoImageFileError, videoMaterialError } from "@/lib/video-input";
import { SEEDANCE_20_MODEL, SEEDANCE_25_MODEL } from "@/lib/video-settings";
const images = (count: number) => Array.from({ length: count }, () => ({ type: "image" as const }));
describe("video input mode limits", () => {
it("preserves two-frame mode while allowing ten Wan 3.0 reference images", () => {
expect(videoMaterialError({ engine: "bailian", model: BAILIAN_27_VIDEO_MODEL, inputMode: "frames" }, images(2))).toBeNull();
expect(videoMaterialError({ engine: "bailian", model: BAILIAN_27_VIDEO_MODEL, inputMode: "frames" }, images(3))).toMatch(/Wan 3.0/);
expect(videoMaterialError({ engine: "bailian", model: BAILIAN_30_VIDEO_MODEL, inputMode: "reference" }, images(10))).toBeNull();
expect(videoMaterialError({ engine: "bailian", model: BAILIAN_30_VIDEO_MODEL, inputMode: "reference" }, images(11))).toMatch(/10 张/);
expect(videoMaterialError({ engine: "bailian", model: BAILIAN_30_VIDEO_MODEL, inputMode: "frames" }, images(3))).toMatch(/首尾帧/);
});
it("explains MiniMax first-frame and reference alternatives", () => {
expect(videoMaterialError({ engine: "minimax", model: "MiniMax-H3", inputMode: "frames" }, images(2))).toMatch(/切换到「多图参考」/);
expect(videoMaterialError({ engine: "minimax", model: "MiniMax-H3", inputMode: "reference" }, images(9))).toBeNull();
expect(videoMaterialError({ engine: "minimax", model: "MiniMax-H3", inputMode: "reference" }, images(10))).toMatch(/9 张/);
});
it("allows Seedance 2.0 category maxima and rejects audio alone", () => {
const selection = { engine: "seedance" as const, model: SEEDANCE_20_MODEL, inputMode: "reference" as const };
expect(videoMaterialError(selection, [...images(9), ...Array.from({ length: 3 }, () => ({ type: "video" as const })), ...Array.from({ length: 3 }, () => ({ type: "audio" as const }))])).toBeNull();
expect(videoMaterialError(selection, [{ type: "audio" }])).toMatch(/不能单独/);
expect(videoMaterialError({ ...selection, model: SEEDANCE_25_MODEL }, [{ type: "audio" }])).toBeNull();
});
it("explains local reference-image format and size failures", () => {
const selection = { engine: "bailian" as const, model: BAILIAN_30_VIDEO_MODEL, inputMode: "reference" as const };
expect(videoImageFileError(selection, { name: "design.heic", size: 12, type: "image/heic" })).toMatch(/格式/);
expect(videoImageFileError(selection, { name: "design.png", size: 21 * 1024 * 1024, type: "image/png" })).toMatch(/20 MB/);
});
});