Files
NianAIGC/lib/server/usage-service.ts

282 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}