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 { 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 { 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()): 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 }>(); for (const record of records) { const existing = rows.get(record.organizationId) || { organizationId: record.organizationId, organizationName: record.organizationName, count: 0, accountCount: 0, accounts: new Set(), 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(); 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(); 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(); 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(); 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(items: T[], keyFor: (item: T) => string): Map { const counts = new Map(); 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; }