feat: add local auth billing and usage management
This commit is contained in:
1 parent
f642b5e71f
commit
196fdde83f
119 files changed
+15695
-2650
No files matched your search
@@ -0,0 +1,179 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createGenerationJob,
|
||||
deleteGenerationJob,
|
||||
listUsageEvents,
|
||||
recordUsageEvent,
|
||||
recordUsageForJob
|
||||
} from "@/lib/server/data-store";
|
||||
import { getAdminUsageReport, getPersonalUsageReport } from "@/lib/server/usage-service";
|
||||
import { usagePresetRange } from "@/lib/usage";
|
||||
import type { GenerationJob, UsageContext } from "@/lib/types";
|
||||
|
||||
let runtimeDir = "";
|
||||
const previousEnv = new Map<string, string | undefined>();
|
||||
const envNames = ["ZHINIAN_RUNTIME_DIR", "NEXT_PUBLIC_SUPABASE_URL", "SUPABASE_SERVICE_ROLE_KEY"];
|
||||
|
||||
describe("usage metering and reports", () => {
|
||||
beforeEach(async () => {
|
||||
runtimeDir = await mkdtemp(join(tmpdir(), "zhinian-usage-"));
|
||||
for (const name of envNames) previousEnv.set(name, process.env[name]);
|
||||
process.env.ZHINIAN_RUNTIME_DIR = runtimeDir;
|
||||
delete process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
delete process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const name of envNames) restoreEnv(name, previousEnv.get(name));
|
||||
previousEnv.clear();
|
||||
await rm(runtimeDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
it("uses China Standard Time for preset boundaries", () => {
|
||||
const range = usagePresetRange("month", new Date("2026-07-27T16:30:00.000Z"));
|
||||
expect(range).toMatchObject({
|
||||
startDate: "2026-07-01",
|
||||
endDate: "2026-07-28",
|
||||
from: "2026-07-01T00:00:00+08:00",
|
||||
to: "2026-07-29T00:00:00+08:00",
|
||||
dayCount: 28
|
||||
});
|
||||
});
|
||||
|
||||
it("records one immutable event per real platform job", async () => {
|
||||
const job = await createUsageJob("job-platform", platformContext("owner-a", "tenant-a", "org-a"));
|
||||
const first = await recordUsageForJob(job);
|
||||
const second = await recordUsageForJob(job);
|
||||
expect(second?.id).toBe(first?.id);
|
||||
expect(await listUsageEvents({ source: "platform" })).toHaveLength(1);
|
||||
|
||||
await deleteGenerationJob(job.id);
|
||||
const retained = await listUsageEvents({ source: "platform" });
|
||||
expect(retained).toHaveLength(1);
|
||||
expect(retained[0]).toMatchObject({
|
||||
jobId: job.id,
|
||||
quantity: 1,
|
||||
estimatedUnit: "job",
|
||||
organizationId: "org-a"
|
||||
});
|
||||
});
|
||||
|
||||
it("does not meter mock jobs or public API clients", async () => {
|
||||
const mock = await createUsageJob("job-mock", platformContext("owner-a"), { provider: "mock" });
|
||||
const api = await createUsageJob("job-api", undefined, { externalClientId: "partner-a" });
|
||||
expect(await recordUsageForJob(mock)).toBeNull();
|
||||
expect(await recordUsageForJob(api)).toBeNull();
|
||||
expect(await listUsageEvents()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("builds personal and administrator reports from distinct successful jobs", async () => {
|
||||
await recordUsageEvent({
|
||||
ownerId: "owner-a",
|
||||
jobId: "job-a",
|
||||
source: "platform",
|
||||
capability: "image.generate",
|
||||
provider: "bailian",
|
||||
reqKey: "wan2.2-t2i-plus",
|
||||
accountDisplayName: "账号 A",
|
||||
tenantId: "tenant-a",
|
||||
organizationId: "org-a",
|
||||
organizationName: "组织 A",
|
||||
quantity: 4,
|
||||
estimatedUnit: "image",
|
||||
createdAt: "2026-07-03T01:00:00.000Z"
|
||||
});
|
||||
await recordUsageEvent({
|
||||
ownerId: "owner-b",
|
||||
jobId: "job-b",
|
||||
source: "platform",
|
||||
capability: "video.generate",
|
||||
provider: "seedance",
|
||||
accountDisplayName: "账号 B",
|
||||
quantity: 1,
|
||||
estimatedUnit: "job",
|
||||
createdAt: "2026-07-04T01:00:00.000Z"
|
||||
});
|
||||
await recordUsageEvent({
|
||||
ownerId: "api:partner-a",
|
||||
jobId: "job-api",
|
||||
source: "api",
|
||||
capability: "image.generate",
|
||||
provider: "bailian",
|
||||
quantity: 1,
|
||||
estimatedUnit: "job",
|
||||
createdAt: "2026-07-05T01:00:00.000Z"
|
||||
});
|
||||
await recordUsageEvent({
|
||||
ownerId: "owner-a",
|
||||
jobId: "job-mock",
|
||||
source: "platform",
|
||||
capability: "image.generate",
|
||||
provider: "mock",
|
||||
quantity: 1,
|
||||
estimatedUnit: "job",
|
||||
createdAt: "2026-07-06T01:00:00.000Z"
|
||||
});
|
||||
|
||||
const now = new Date("2026-07-28T04:00:00.000Z");
|
||||
const personal = await getPersonalUsageReport("owner-a", "month", now);
|
||||
expect(personal.total).toBe(1);
|
||||
expect(personal.byCapability.find((item) => item.key === "image.generate")?.count).toBe(1);
|
||||
|
||||
const report = await getAdminUsageReport({}, [{
|
||||
organizationId: "org-a",
|
||||
organizationName: "组织 A",
|
||||
organizationBindTenantId: 1
|
||||
}], now);
|
||||
expect(report.summary).toMatchObject({ total: 2, activeAccounts: 2, activeOrganizations: 1 });
|
||||
expect(report.organizations.map((row) => row.organizationName)).toEqual(["组织 A", "未归属组织"]);
|
||||
|
||||
const filtered = await getAdminUsageReport({ organizationId: "org-a" }, [], now);
|
||||
expect(filtered.summary.total).toBe(1);
|
||||
expect(filtered.accounts[0]?.accountName).toBe("账号 A");
|
||||
expect(filtered.options.accounts.map((option) => option.value)).toEqual(["owner-a"]);
|
||||
});
|
||||
});
|
||||
|
||||
async function createUsageJob(
|
||||
id: string,
|
||||
usageContext?: UsageContext,
|
||||
overrides: Partial<GenerationJob> = {}
|
||||
): Promise<GenerationJob> {
|
||||
return createGenerationJob({
|
||||
id,
|
||||
ownerId: usageContext?.accountId || "api:partner-a",
|
||||
externalClientId: overrides.externalClientId,
|
||||
capability: "image.generate",
|
||||
provider: overrides.provider || "bailian",
|
||||
reqKey: "wan2.2-t2i-plus",
|
||||
status: "running",
|
||||
inputAssetIds: [],
|
||||
inputUrls: [],
|
||||
outputAssetIds: [],
|
||||
requestPayload: {},
|
||||
usageContext
|
||||
});
|
||||
}
|
||||
|
||||
function platformContext(accountId: string, tenantId?: string, organizationId?: string): UsageContext {
|
||||
return {
|
||||
source: "platform",
|
||||
accountId,
|
||||
username: accountId,
|
||||
displayName: accountId,
|
||||
tenantId,
|
||||
organizationId,
|
||||
organizationName: organizationId ? "组织 A" : undefined
|
||||
};
|
||||
}
|
||||
|
||||
function restoreEnv(name: string, value: string | undefined) {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
return;
|
||||
}
|
||||
process.env[name] = value;
|
||||
}
|
||||
Reference in new issue
Block a user