diff --git a/PROGRESS-billing-pricing-20260830.md b/PROGRESS-billing-pricing-20260830.md new file mode 100644 index 00000000..9f60fd09 --- /dev/null +++ b/PROGRESS-billing-pricing-20260830.md @@ -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:`,每个 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 聚合数据前,不接入会导致线上默认失败的空定价配置,也不修改商品售价或公平使用参数。 diff --git a/frontend/src/app/api/admin/usage/aggregate/route.ts b/frontend/src/app/api/admin/usage/aggregate/route.ts new file mode 100644 index 00000000..26e567b8 --- /dev/null +++ b/frontend/src/app/api/admin/usage/aggregate/route.ts @@ -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(` + 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); + } +} diff --git a/frontend/tests/admin-usage-aggregate-contract.test.ts b/frontend/tests/admin-usage-aggregate-contract.test.ts new file mode 100644 index 00000000..f88433e7 --- /dev/null +++ b/frontend/tests/admin-usage-aggregate-contract.test.ts @@ -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/); +});