282 lines
10 KiB
TypeScript
282 lines
10 KiB
TypeScript
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;
|
||
}
|