倍率问题和图片问题调整

This commit is contained in:
andy committed 2026-09-23 13:33:47 +08:00
1 parent f1993eb388
commit 3f29ba716a
20 files changed
+1210 -24

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
import { mkdtemp, rm } from "node:fs/promises";
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 { createBillingPriceRule, getBillingPriceRule, updateBillingPriceTierMultiplier } from "@/lib/server/billing-store";
let runtimeDir = "";
describe("default billing catalog refresh", () => {
beforeEach(async () => {
runtimeDir = await mkdtemp(join(tmpdir(), "zhinian-billing-seed-"));
vi.stubEnv("ZHINIAN_RUNTIME_DIR", runtimeDir);
vi.stubEnv("ZHINIAN_DATA_BACKEND", "local");
});
afterEach(async () => {
vi.unstubAllEnvs();
await rm(runtimeDir, { recursive: true, force: true });
});
it("keeps a saved tier multiplier across catalog refresh and quotation", async () => {
await ensureDefaultBillingPriceRules();
await updateBillingPriceTierMultiplier({
ruleId: "base-seedream-5-0-pro",
dimensionKey: "size",
tierValue: "1.5K",
markupMultiplier: 1.3
});
const quote = await quoteGenerationCharge({
provider: "seedream",
capability: "image.generate",
reqKey: "doubao-seedream-5-0-pro-260628",
requestPayload: { settings: { size: "1.5K" } },
usageContext: { source: "platform", accountId: "user-1", displayName: "测试用户", organizationId: "org-1" }
});
expect(quote).toMatchObject({ standardUnitPriceFen: 30, markupMultiplier: 1.3, amountFen: 39 });
const saved = await getBillingPriceRule("base-seedream-5-0-pro");
const tiers = saved?.parameterDimensions?.find((dimension) => dimension.key === "size")?.tiers;
expect(tiers?.find((tier) => tier.value === "1.5K")?.markupMultiplier).toBe(1.3);
expect(tiers?.find((tier) => tier.value === "2K")?.markupMultiplier).toBe(1.2);
});
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")!;
await createBillingPriceRule({
...candidate,
standardUnitPriceFen: 5,
markupMultiplier: 1.7,
parameterDimensions: [{
...size,
label: "旧分辨率",
tiers: [
{ ...size.tiers[0], standardFactor: 99, markupMultiplier: 0 },
{ ...size.tiers[1], standardFactor: 99, markupMultiplier: 1.4 },
{ value: "3K", label: "旧档位", standardFactor: 9, markupMultiplier: 3, enabled: true }
]
}]
});
await ensureDefaultBillingPriceRules();
const saved = await getBillingPriceRule(candidate.id!);
expect(saved?.standardUnitPriceFen).toBe(30);
expect(saved?.markupMultiplier).toBe(1.7);
const dimension = saved?.parameterDimensions?.find((item) => item.key === "size");
expect(dimension?.label).toBe("分辨率");
expect(dimension?.tiers.map((tier) => tier.value)).toEqual(["1K", "1.5K", "2K"]);
expect(dimension?.tiers.map((tier) => [tier.standardFactor, tier.markupMultiplier])).toEqual([
[1, 1.2],
[1, 1.4],
[2, 1.2]
]);
});
});
+113
View File
@@ -0,0 +1,113 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { PriceCatalog } from "@/components/billing-manager";
import { groupedPriceRuleSections, priceTierEditTarget } from "@/lib/client/billing-price-catalog";
import type { BillingParameterDimension, BillingPriceRule } from "@/lib/types";
const size: BillingParameterDimension = {
key: "size",
label: "分辨率",
baselineValue: "1.5K",
tiers: [
{ value: "1.5K", label: "1.5K", standardFactor: 1, markupMultiplier: 1.2, enabled: true },
{ value: "2K", label: "2K", standardFactor: 2, markupMultiplier: 1.3, enabled: true }
]
};
function rule(id: string, overrides: Partial<BillingPriceRule> = {}): BillingPriceRule {
return {
id,
provider: "seedream",
capability: "image.generate",
reqKey: "doubao-seedream-5-0-pro-260628",
unit: "image",
standardUnitPriceFen: 30,
markupMultiplier: 1.2,
enabled: true,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides
};
}
describe("billing price catalog grouped rules", () => {
it("keeps separate Seedream businesses and their resolution edit targets", () => {
const basic = rule("basic", { parameterDimensions: [size] });
const layers = rule("layers", {
standardUnitPriceFen: 15,
conditions: { layerDecomposition: true },
parameterDimensions: [size]
});
const sections = groupedPriceRuleSections([basic, layers]);
expect(sections.map(({ label, dimensions }) => [label, dimensions[0]?.key])).toEqual([
["基础生图", "size"],
["图层拆分", "size"]
]);
expect(priceTierEditTarget(sections[1].dimensions[0], size.tiers[1], sections[1].label)).toEqual({
dimensionKey: "size",
tierValue: "2K",
label: "图层拆分 · 分辨率 · 2K",
markupMultiplier: 1.3
});
expect(sections[0].rule.id).toBe("basic");
expect(sections[1].rule.id).toBe("layers");
expect(sections.map((section) => section.dimensions[0].tiers.map((tier) => tier.value))).toEqual([
["1.5K", "2K"],
["1.5K", "2K"]
]);
const html = renderToStaticMarkup(createElement(PriceCatalog, { rules: [basic, layers], onEdit: () => {}, disabled: false }));
expect(html).toContain("基础生图 · 分辨率");
expect(html).toContain("图层拆分 · 分辨率");
expect(html.match(/>调整倍率<\/button>/g)).toHaveLength(4);
expect(html).toContain("¥0.15");
expect(html).toContain("¥0.30");
});
it("keeps grouped legacy video rules as independent flat rows", () => {
const rules = [
rule("720p", { provider: "seedance", capability: "video.generate", reqKey: "seedance-2.0", variantKey: "resolution=720p", unit: "video_second", standardUnitPriceFen: 99 }),
rule("1080p", { provider: "seedance", capability: "video.generate", reqKey: "seedance-2.0", variantKey: "resolution=1080p", unit: "video_second", standardUnitPriceFen: 248 })
];
expect(groupedPriceRuleSections(rules).every((section) => section.dimensions.length === 0)).toBe(true);
expect(groupedPriceRuleSections(rules).map((section) => section.label)).toEqual(["resolution=720p", "resolution=1080p"]);
const html = renderToStaticMarkup(createElement(PriceCatalog, { rules, onEdit: () => {}, disabled: false }));
expect(html).toContain("resolution=720p");
expect(html).toContain("resolution=1080p");
expect(html.match(/>调整倍率<\/button>/g)).toHaveLength(2);
});
it("retains unnamed dimension and tier identifiers in edit labels", () => {
const dimension: BillingParameterDimension = {
key: "quality",
label: "",
baselineValue: "medium",
tiers: [{ value: "high", label: "", standardFactor: 1, markupMultiplier: 1.25, enabled: true }]
};
expect(priceTierEditTarget(dimension, dimension.tiers[0])).toEqual({
dimensionKey: "quality",
tierValue: "high",
label: "quality · high",
markupMultiplier: 1.25
});
const single = rule("evolink", {
provider: "evolink",
reqKey: "gpt-image-2",
parameterDimensions: [dimension]
});
const singleHtml = renderToStaticMarkup(createElement(PriceCatalog, { rules: [single], onEdit: () => {}, disabled: false }));
expect(singleHtml).toContain(">quality</strong>");
expect(singleHtml).toContain(">high</strong>");
expect(singleHtml.match(/>调整倍率<\/button>/g)).toHaveLength(1);
const mixedHtml = renderToStaticMarkup(createElement(PriceCatalog, {
rules: [single, rule("legacy", { provider: "evolink", reqKey: "gpt-image-2", variantKey: "legacy" })],
onEdit: () => {},
disabled: false
}));
expect(mixedHtml).toContain("规则 1 · quality</strong>");
expect(mixedHtml).toContain("legacy");
expect(mixedHtml.match(/>调整倍率<\/button>/g)).toHaveLength(2);
});
});
+81
View File
@@ -0,0 +1,81 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { downloadFilesAsZip } from "@/lib/client/zip-download";
const png = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2]);
const jpeg = Uint8Array.from([0xff, 0xd8, 0xff, 0xe0, 3, 4]);
const webp = Uint8Array.from([0x52, 0x49, 0x46, 0x46, 4, 0, 0, 0, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x20, 5]);
const gif = Uint8Array.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 6]);
const bmp = Uint8Array.from([0x42, 0x4d, 7]);
const ico = Uint8Array.from([0, 0, 1, 0, 8]);
function zipEntries(data: Uint8Array) {
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
const entries: { name: string; bytes: Uint8Array }[] = [];
let offset = 0;
while (view.getUint32(offset, true) === 0x04034b50) {
const size = view.getUint32(offset + 18, true);
const nameSize = view.getUint16(offset + 26, true);
const extraSize = view.getUint16(offset + 28, true);
const nameStart = offset + 30;
const bytesStart = nameStart + nameSize + extraSize;
entries.push({
name: new TextDecoder().decode(data.subarray(nameStart, nameStart + nameSize)),
bytes: data.slice(bytesStart, bytesStart + size)
});
offset = bytesStart + size;
}
return entries;
}
describe("ZIP layer downloads", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("uses image signatures for entry extensions and keeps the original bytes", async () => {
const files = [
{ name: "portrait-face.image", url: "/png" },
{ name: "portrait-face.image", url: "/png-copy" },
{ name: "sketch.image", url: "/jpeg" },
{ name: "original.JPEG", url: "/jpeg-original" },
{ name: "another.JPE", url: "/jpeg-jpe" },
{ name: "mask.image", url: "/webp" },
{ name: "animated.image", url: "/gif" },
{ name: "bitmap.image", url: "/bmp" },
{ name: "icon.image", url: "/ico" },
{ name: "invalid-webp.image", url: "/invalid-webp" },
{ name: "unrecognized.image", url: "/unknown" }
];
const bytesByUrl = new Map([
["/png", png], ["/png-copy", png], ["/jpeg", jpeg],
["/jpeg-original", jpeg], ["/jpeg-jpe", jpeg], ["/webp", webp],
["/gif", gif], ["/bmp", bmp], ["/ico", ico],
["/invalid-webp", Uint8Array.from([0x52, 0x49, 0x46, 0x46, 4, 0, 0, 0, 0x57, 0x45, 0x42, 0x50, 5])],
["/unknown", Uint8Array.from([1, 2, 3])]
]);
let archive: Blob | undefined;
const anchor = { href: "", download: "", click: vi.fn(), remove: vi.fn() };
vi.stubGlobal("fetch", vi.fn(async (url: string) => new Response(bytesByUrl.get(url), { status: 200 })));
vi.stubGlobal("document", { createElement: () => anchor, body: { appendChild: vi.fn() } });
vi.stubGlobal("window", { setTimeout: vi.fn() });
vi.stubGlobal("URL", { createObjectURL: (blob: Blob) => { archive = blob; return "blob:layers"; }, revokeObjectURL: vi.fn() });
await downloadFilesAsZip(files, "layers.zip");
expect(anchor.download).toBe("layers.zip");
expect(anchor.click).toHaveBeenCalledOnce();
expect(archive).toBeDefined();
const entries = zipEntries(new Uint8Array(await archive!.arrayBuffer()));
expect(entries.map((entry) => entry.name)).toEqual([
"portrait-face.png", "portrait-face-2.png", "sketch.jpg", "original.JPEG", "another.JPE",
"mask.webp", "animated.gif", "bitmap.bmp", "icon.ico", "invalid-webp.image", "unrecognized.image"
]);
expect(entries.map((entry) => entry.bytes)).toEqual([
png, png, jpeg, jpeg, jpeg, webp, gif, bmp, ico,
Uint8Array.from([0x52, 0x49, 0x46, 0x46, 4, 0, 0, 0, 0x57, 0x45, 0x42, 0x50, 5]),
Uint8Array.from([1, 2, 3])
]);
});
});