feat(billing): add usage cost aggregation report
This commit is contained in:
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user