feat(billing): add usage cost aggregation report

This commit is contained in:
Jesse_Chen
2026-08-31 02:54:21 +08:00
parent 7db2dd2de0
commit da4c03a857
3 changed files with 164 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
# 计费闭环与功能级定价进度(2026-08-30)
## 任务 0 · 真实单位成本门控
已完成只读聚合端点:`frontend/src/app/api/admin/usage/aggregate/route.ts`
- 权限:`billing.orders.read`
- 窗口:最近 30 天
- 输出:每个预留功能的 `runs``avg``p50``p95``max`,覆盖 `cost_microusd``input_tokens``output_tokens``duration_ms`
- 无数据:`hasData=false`,统计值为 `null`,不会静默伪造为 0
- `report.full`:当前账本 0 行,这是报告接入计费前的已知空缺
### 当前可见数据库观测
本机 `jyotisha-local-preview-postgres-1`(不是 staging/production)截至 2026-08-30 的最近 30 天账本:
| feature_key | runs | cost p50 | cost p95 | input p50 | output p50 | duration p50 |
|---|---:|---:|---:|---:|---:|---:|
| `chat.standard` | 0 | 无实测数据 | 无实测数据 | 无实测数据 | 无实测数据 | 无实测数据 |
| `rectification` | 3 | 0 | 0 | 110566 | 5835 | 52879 |
| `report.full` | 0 | 无实测数据 | 无实测数据 | 无实测数据 | 无实测数据 | 无实测数据 |
上述 3 条校正账本行的 `request_id` 都是 `rectification:case:<caseId>`,每个 case 只有 1 行。代码链路确认 `runV9AgentTurn` 在一次 agent run 结束时调用 `billing.complete({ ...outcome.usage, durationMs })`,而 `outcome.usage` 来自该次 run 的 `result.totalUsage`;它不是跨 case 多轮累计值。当前本机发布模型的 input/output 单价也都是 0,因此这组本地 cost=0 不能作为生产定价依据。
**门控结论:未取得 staging/production 的非零真实单位成本,禁止写入任何新价格数字、会员公平使用数字或价格种子。**
## 尚未执行
任务 1–6 依赖任务 0 的真实成本口径;在取得可审计的 staging/production 聚合数据前,不接入会导致线上默认失败的空定价配置,也不修改商品售价或公平使用参数。
@@ -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);
}
}
@@ -0,0 +1,16 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const route = readFileSync(resolve("src/app/api/admin/usage/aggregate/route.ts"), "utf8");
test("admin usage aggregate is a read-only 30-day billing report", () => {
assert.match(route, /requirePermission\("billing\.orders\.read"\)/);
assert.match(route, /interval '30 days'/);
assert.match(route, /percentile_cont\(0\.50\)/);
assert.match(route, /percentile_cont\(0\.95\)/);
assert.match(route, /hasData/);
assert.match(route, /report\.full/);
assert.doesNotMatch(route, /export async function POST/);
});