feat(billing): add usage cost aggregation report

This commit is contained in:
Jesse_Chen
2026-08-31 03:06:15 +08:00
parent 7db2dd2de0
commit da4c03a857
3 changed files with 164 additions and 0 deletions
@@ -0,0 +1,119 @@
import { NextResponse } from "next/server";
import { requirePermission } from "@/lib/admin/auth";
import { queryAdminRows } from "@/lib/admin/database";
import { adminErrorResponse } from "@/lib/admin/http";
export const runtime = "nodejs";
const FEATURE_KEYS = [
"chat.standard",
"chat.premium",
"rectification",
"report.full",
"report.export",
"profile.extra",
] as const;
type FeatureKey = (typeof FEATURE_KEYS)[number];
type AggregateRow = {
feature_key: FeatureKey;
runs: string;
avg_cost_microusd: string | null;
p50_cost_microusd: string | null;
p95_cost_microusd: string | null;
max_cost_microusd: string | null;
avg_input_tokens: string | null;
p50_input_tokens: string | null;
p95_input_tokens: string | null;
max_input_tokens: string | null;
avg_output_tokens: string | null;
p50_output_tokens: string | null;
p95_output_tokens: string | null;
max_output_tokens: string | null;
avg_duration_ms: string | null;
p50_duration_ms: string | null;
p95_duration_ms: string | null;
max_duration_ms: string | null;
};
type Metric = {
runs: number;
avg: number | null;
p50: number | null;
p95: number | null;
max: number | null;
};
function numberOrNull(value: string | null): number | null {
return value === null ? null : Number(value);
}
function metric(
row: AggregateRow,
avg: keyof AggregateRow,
p50: keyof AggregateRow,
p95: keyof AggregateRow,
max: keyof AggregateRow,
): Metric {
return {
runs: Number(row.runs),
avg: numberOrNull(row[avg] as string | null),
p50: numberOrNull(row[p50] as string | null),
p95: numberOrNull(row[p95] as string | null),
max: numberOrNull(row[max] as string | null),
};
}
export async function GET() {
try {
await requirePermission("billing.orders.read");
const rows = await queryAdminRows<AggregateRow>(`
with feature_keys(feature_key) as (
select unnest($1::text[])
), recent as (
select feature_key, cost_microusd, input_tokens, output_tokens, duration_ms
from public.usage_ledger
where created_at >= now() - interval '30 days'
)
select f.feature_key,
count(r.feature_key)::text as runs,
avg(r.cost_microusd)::text as avg_cost_microusd,
percentile_cont(0.50) within group(order by r.cost_microusd)::text as p50_cost_microusd,
percentile_cont(0.95) within group(order by r.cost_microusd)::text as p95_cost_microusd,
max(r.cost_microusd)::text as max_cost_microusd,
avg(r.input_tokens)::text as avg_input_tokens,
percentile_cont(0.50) within group(order by r.input_tokens)::text as p50_input_tokens,
percentile_cont(0.95) within group(order by r.input_tokens)::text as p95_input_tokens,
max(r.input_tokens)::text as max_input_tokens,
avg(r.output_tokens)::text as avg_output_tokens,
percentile_cont(0.50) within group(order by r.output_tokens)::text as p50_output_tokens,
percentile_cont(0.95) within group(order by r.output_tokens)::text as p95_output_tokens,
max(r.output_tokens)::text as max_output_tokens,
avg(r.duration_ms)::text as avg_duration_ms,
percentile_cont(0.50) within group(order by r.duration_ms)::text as p50_duration_ms,
percentile_cont(0.95) within group(order by r.duration_ms)::text as p95_duration_ms,
max(r.duration_ms)::text as max_duration_ms
from feature_keys f
left join recent r on r.feature_key=f.feature_key
group by f.feature_key
order by f.feature_key
`, [FEATURE_KEYS]);
return NextResponse.json({
window: { days: 30, since: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString() },
data: rows.map((row) => ({
featureKey: row.feature_key,
hasData: Number(row.runs) > 0,
metrics: {
costMicrousd: metric(row, "avg_cost_microusd", "p50_cost_microusd", "p95_cost_microusd", "max_cost_microusd"),
inputTokens: metric(row, "avg_input_tokens", "p50_input_tokens", "p95_input_tokens", "max_input_tokens"),
outputTokens: metric(row, "avg_output_tokens", "p50_output_tokens", "p95_output_tokens", "max_output_tokens"),
durationMs: metric(row, "avg_duration_ms", "p50_duration_ms", "p95_duration_ms", "max_duration_ms"),
},
})),
});
} catch (error) {
return adminErrorResponse(error);
}
}