feat: add local auth billing and usage management

This commit is contained in:
inman committed 2026-08-12 12:13:06 +08:00
1 parent f642b5e71f
commit 196fdde83f
119 files changed
+15695 -2650

No files matched your search

+281
View File
@@ -0,0 +1,281 @@
import { createHash } from "node:crypto";
import { listUsageEvents } from "@/lib/server/data-store";
import type { OrganizationInfo } from "@/lib/server/organization-client";
import type { GenerationCapability, GenerationProvider, UsageEvent } from "@/lib/types";
import {
CAPABILITY_OPTIONS,
UNASSIGNED_ORGANIZATION_ID,
capabilityLabel,
providerLabel,
shiftDateKey,
usageDateKey,
usageDateRange,
usagePresetRange,
type AdminUsageReport,
type PersonalUsageReport,
type UsageAccountRow,
type UsageCountItem,
type UsageOrganizationRow,
type UsagePreset,
type UsageRecordView,
type UsageTrendPoint
} from "@/lib/usage";
export type AdminUsageFilters = {
startDate?: string;
endDate?: string;
organizationId?: string;
ownerId?: string;
capability?: GenerationCapability;
provider?: GenerationProvider;
};
export async function getPersonalUsageReport(
ownerId: string,
preset: UsagePreset,
now = new Date()
): Promise<PersonalUsageReport> {
const range = usagePresetRange(preset, now);
const events = eligibleEvents(await listUsageEvents({
ownerId,
source: "platform",
from: range.from,
to: range.to
}));
return {
preset,
range,
total: events.length,
byCapability: capabilityBreakdown(events),
recent: events.slice(0, 5).map((event) => usageRecordView(event))
};
}
export async function getAdminUsageReport(
filters: AdminUsageFilters,
organizations: OrganizationInfo[] = [],
now = new Date()
): Promise<AdminUsageReport> {
const defaultRange = usagePresetRange("month", now);
const range = filters.startDate || filters.endDate
? usageDateRange(filters.startDate || defaultRange.startDate, filters.endDate || defaultRange.endDate)
: defaultRange;
const baseEvents = eligibleEvents(await listUsageEvents({
source: "platform",
from: range.from,
to: range.to
}));
const organizationNames = new Map(organizations.map((organization) => [
organization.organizationId,
organization.organizationName || organization.organizationId
]));
const baseRecords = baseEvents.map((event) => usageRecordView(event, organizationNames));
const records = baseRecords.filter((record) => {
if (filters.organizationId && record.organizationId !== filters.organizationId) return false;
if (filters.ownerId && record.ownerId !== filters.ownerId) return false;
if (filters.capability && record.capability !== filters.capability) return false;
if (filters.provider && record.provider !== filters.provider) return false;
return true;
});
const filteredEventsById = new Map(baseEvents.map((event) => [event.id, event]));
const filteredEvents = records.map((record) => filteredEventsById.get(record.id)).filter((event): event is UsageEvent => Boolean(event));
const organizationRows = organizationBreakdown(records);
const accountRows = accountBreakdown(records);
const optionRecords = filters.organizationId
? baseRecords.filter((record) => record.organizationId === filters.organizationId)
: baseRecords;
return {
range,
summary: {
total: records.length,
activeAccounts: new Set(records.map((record) => record.ownerId)).size,
activeOrganizations: new Set(records
.map((record) => record.organizationId)
.filter((id) => id !== UNASSIGNED_ORGANIZATION_ID)).size,
averagePerDay: Number((records.length / range.dayCount).toFixed(1))
},
trend: trendBreakdown(records, range.startDate, range.endDate, range.dayCount),
byCapability: capabilityBreakdown(filteredEvents),
byProvider: providerBreakdown(filteredEvents),
organizations: organizationRows,
accounts: accountRows,
recent: records.slice(0, 100),
options: {
organizations: organizationOptions(organizations, optionRecords),
accounts: accountOptions(optionRecords),
capabilities: CAPABILITY_OPTIONS.map((item) => ({ value: item.value, label: item.label })),
providers: providerOptions(baseRecords)
}
};
}
function eligibleEvents(events: UsageEvent[]): UsageEvent[] {
return events
.filter((event) => event.source !== "api" && event.provider !== "mock")
.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
}
function usageRecordView(event: UsageEvent, organizationNames = new Map<string, string>()): UsageRecordView {
const organizationId = event.organizationId || UNASSIGNED_ORGANIZATION_ID;
return {
id: event.id,
jobId: event.jobId,
ownerId: event.ownerId,
accountName: event.accountDisplayName || event.accountUsername || historicalAccountName(event.ownerId),
accountUsername: event.accountUsername,
organizationId,
organizationName: event.organizationName || organizationNames.get(organizationId) || "未归属组织",
capability: event.capability,
capabilityLabel: capabilityLabel(event.capability),
provider: event.provider,
providerLabel: providerLabel(event.provider),
reqKey: event.reqKey,
createdAt: event.createdAt
};
}
function capabilityBreakdown(events: UsageEvent[]): UsageCountItem[] {
const counts = countBy(events, (event) => event.capability);
return CAPABILITY_OPTIONS.map((item) => ({
key: item.value,
label: item.label,
count: counts.get(item.value) || 0
}));
}
function providerBreakdown(events: UsageEvent[]): UsageCountItem[] {
const counts = countBy(events, (event) => event.provider || "unknown");
return [...counts.entries()]
.map(([key, count]) => ({ key, label: providerLabel(key === "unknown" ? undefined : key as GenerationProvider), count }))
.sort(sortCountRows);
}
function organizationBreakdown(records: UsageRecordView[]): UsageOrganizationRow[] {
const rows = new Map<string, UsageOrganizationRow & { accounts: Set<string> }>();
for (const record of records) {
const existing = rows.get(record.organizationId) || {
organizationId: record.organizationId,
organizationName: record.organizationName,
count: 0,
accountCount: 0,
accounts: new Set<string>(),
lastUsedAt: undefined
};
existing.count += 1;
existing.accounts.add(record.ownerId);
existing.accountCount = existing.accounts.size;
existing.lastUsedAt = latestTime(existing.lastUsedAt, record.createdAt);
rows.set(record.organizationId, existing);
}
return [...rows.values()]
.map(({ accounts: _accounts, ...row }) => row)
.sort((left, right) => {
const count = sortCountRows(left, right);
if (count) return count;
if (left.organizationId === UNASSIGNED_ORGANIZATION_ID) return 1;
if (right.organizationId === UNASSIGNED_ORGANIZATION_ID) return -1;
return left.organizationName.localeCompare(right.organizationName, "zh-CN");
});
}
function accountBreakdown(records: UsageRecordView[]): UsageAccountRow[] {
const rows = new Map<string, UsageAccountRow>();
for (const record of records) {
const existing = rows.get(record.ownerId) || {
ownerId: record.ownerId,
accountName: record.accountName,
accountUsername: record.accountUsername,
organizationId: record.organizationId,
organizationName: record.organizationName,
count: 0,
lastUsedAt: undefined
};
existing.count += 1;
existing.lastUsedAt = latestTime(existing.lastUsedAt, record.createdAt);
rows.set(record.ownerId, existing);
}
return [...rows.values()].sort(sortCountRows);
}
function trendBreakdown(
records: UsageRecordView[],
startDate: string,
endDate: string,
dayCount: number
): UsageTrendPoint[] {
if (dayCount > 62) {
const monthly = countBy(records, (record) => usageDateKey(record.createdAt).slice(0, 7));
return [...monthly.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([date, count]) => ({
date,
label: date,
count
}));
}
const daily = countBy(records, (record) => usageDateKey(record.createdAt));
const points: UsageTrendPoint[] = [];
for (let date = startDate; date <= endDate; date = shiftDateKey(date, 1)) {
points.push({ date, label: date.slice(5), count: daily.get(date) || 0 });
}
return points;
}
function organizationOptions(organizations: OrganizationInfo[], records: UsageRecordView[]) {
const options = new Map<string, string>();
for (const organization of organizations) {
options.set(organization.organizationId, organization.organizationName || organization.organizationId);
}
for (const record of records) options.set(record.organizationId, record.organizationName);
return [...options.entries()]
.map(([value, label]) => ({ value, label }))
.sort((left, right) => {
if (left.value === UNASSIGNED_ORGANIZATION_ID) return 1;
if (right.value === UNASSIGNED_ORGANIZATION_ID) return -1;
return left.label.localeCompare(right.label, "zh-CN");
});
}
function accountOptions(records: UsageRecordView[]) {
const options = new Map<string, string>();
for (const record of records) {
if (!options.has(record.ownerId)) {
const username = record.accountUsername ? `(${record.accountUsername})` : "";
options.set(record.ownerId, `${record.accountName}${username}`);
}
}
return [...options.entries()]
.map(([value, label]) => ({ value, label }))
.sort((left, right) => left.label.localeCompare(right.label, "zh-CN"));
}
function providerOptions(records: UsageRecordView[]) {
const options = new Map<string, string>();
for (const record of records) {
if (record.provider) options.set(record.provider, record.providerLabel);
}
return [...options.entries()]
.map(([value, label]) => ({ value, label }))
.sort((left, right) => left.label.localeCompare(right.label, "zh-CN"));
}
function historicalAccountName(ownerId: string): string {
const id = createHash("sha256").update(ownerId).digest("hex").slice(0, 8).toUpperCase();
return `历史账号 ${id}`;
}
function countBy<T>(items: T[], keyFor: (item: T) => string): Map<string, number> {
const counts = new Map<string, number>();
for (const item of items) {
const key = keyFor(item);
counts.set(key, (counts.get(key) || 0) + 1);
}
return counts;
}
function latestTime(current: string | undefined, candidate: string): string {
return !current || candidate > current ? candidate : current;
}
function sortCountRows(left: { count: number }, right: { count: number }): number {
return right.count - left.count;
}