diff --git a/PROGRESS-billing-pricing-20260830.md b/PROGRESS-billing-pricing-20260830.md index 32b3e43a..4609b7b6 100644 --- a/PROGRESS-billing-pricing-20260830.md +++ b/PROGRESS-billing-pricing-20260830.md @@ -47,3 +47,56 @@ - `npm run test:db`:34 通过,0 失败;Docker 中 migration apply 与业务 schema 检查通过 价格门控仍生效:没有 staging/production 非零真实成本前,migration 未写入任何价格 seed。 + +## 任务 1 · 报告生成计费 + +已接入 `report.full` 的完整 `reserve → complete/release` 闭环: + +- 日限检查之后、创建报告之前 reserve;创建或入队失败立即 release。 +- inline 成功 complete;durable worker 成功 complete,终态失败 release,retryable failure 在重试耗尽前不 release。 +- request id 使用既有 `payload.requestId`,保持幂等。 +- token 按 plan、章节、summary、repair 的实际调用累计后 complete;不使用伪造的固定 token 数。 + +## 任务 2 · 校正按功能定价 + +已接入 `resolveFeaturePricing(accounting, "rectification", selectedModel.id)`,不再把模型 `creditCost` 当作校正价格。继续使用既有 case 级幂等键 `rectification:case:`,未修改公平使用、配额或 `model_not_included` SQL 校验。错误响应区分点数不足、分钟/日/月公平使用限制与 billing denied。 + +## 任务 3 · 对话按功能定价 + +已接入 `resolveFeaturePricing(accounting, "chat.standard", model.id)`,仍使用既有 `reserve_consultation_usage` RPC,仅替换 `p_credit_cost` 的来源为服务端已发布功能价格。 + +## 任务 4 · 上下文缓存 + +Blocked/skipped:当前没有 staging/production 的真实成本与 provider 缓存能力证据;本轮不接缓存,也不引入 provider 特殊降级逻辑。待有真实成本与 provider 级缓存计价/兼容性数据后再单独评估。 + +## 任务 5 · 会员档参数与权益 + +Blocked:未取得 staging/production 非零真实单位成本,不能重设 `minuteLimit`、`dayLimit`、`billingLimit`,不能移除 `rectification` / `report.full` 现有权益行,也不修改商品售价。保持现有公平使用与配额配置不变。 + +## 任务 6 · 管理端定价测算页 + +已完成只读 `/admin/pricing-simulator`: + +- 数据来自模型价格、功能定价、商品/权益与账本聚合端点;页面仅调用 GET,未提供保存价格入口。 +- 单功能、会员平均/打满、盈亏平衡与 21 秒串行日吞吐/会员承载量均由纯函数实时计算。 +- 权重不为 1 时自动归一化;`billingLimit=null` 显示不限;账本无数据显示“无实测数据”,不转成 0。 +- 模型价格为 0、账本无数据、权重归一化与不限配额均有单测。 +- 空数据、分布权重、汇率固定规则之外的固定成本与用户画像均保持“假设/待填”语义;未写入任何价格 seed。固定换算规则为 1 元人民币 = 10 积分。 + +## 验证与剩余环境缺口 + +已通过: + +- `./node_modules/.bin/tsc --noEmit` +- `./node_modules/.bin/tsx --test tests/personal-report-api.test.ts tests/personal-report-worker.test.ts tests/feature-pricing-contract.test.ts tests/application-billing-contract.test.ts tests/pricing-simulation.test.ts`(72 passed) +- `npm run lint`(0 errors,23 个既有 warnings) +- `npm run test:db`(34 passed,0 failed,Docker migration/schema tests) +- `npm run build`(成功;仅既有 Turbopack dynamic filesystem tracing warnings) + +`npm run db:migrate:check` 尚未能执行有效检查:环境未提供 `SCHEMA_DATABASE_URL`,命令会 fail-closed 为 `SCHEMA_DATABASE_URL is required`。补充 schema database 连接串后需连续运行三次;当前不因该环境缺口修改迁移内容。 + +本轮不 push、不 merge、不 rebase;主工作树保持不变。 + +### 全量测试补充 + +`./node_modules/.bin/tsx --test tests/*.test.ts`:2369 passed,1 failed。唯一失败为既有的 `tests/staging-backend-workflows.test.ts` YAML 语法检查,失败原因是运行环境的 Python 缺少 `yaml` 模块(`ModuleNotFoundError: No module named 'yaml'`);本轮未修改 `.gitea/workflows/**`,因此不是本轮回归。该环境缺口未通过新增依赖绕过。 diff --git a/frontend/src/app/admin/pricing-simulator/page.tsx b/frontend/src/app/admin/pricing-simulator/page.tsx new file mode 100644 index 00000000..a4eab955 --- /dev/null +++ b/frontend/src/app/admin/pricing-simulator/page.tsx @@ -0,0 +1,5 @@ +import { PricingSimulator } from "@/components/admin/pricing-simulator"; + +export default function PricingSimulatorPage() { + return ; +} diff --git a/frontend/src/components/admin/pricing-simulator.tsx b/frontend/src/components/admin/pricing-simulator.tsx new file mode 100644 index 00000000..3d4d9eb8 --- /dev/null +++ b/frontend/src/components/admin/pricing-simulator.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { Alert, Button, Card, Col, Divider, InputNumber, Row, Space, Statistic, Table, Tag, Typography } from "antd"; +import { useEffect, useMemo, useState } from "react"; + +import { adminRequestJson } from "@/lib/admin/providers"; +import { + CONSULTATION_DOMAIN_DURATION_MS, + estimateFeature, + memberCapacity, + membershipCost, + type DistributionBand, + type Metric, + type ModelPrice, + type UsageAggregate, +} from "@/lib/pricing-simulation"; + +const FEATURE_KEYS = ["chat.standard", "chat.premium", "rectification", "report.full"] as const; +const BANDS: DistributionBand[] = [ + { weight: 0, monthlyMessages: 0 }, + { weight: 0, monthlyMessages: 0 }, + { weight: 0, monthlyMessages: 0 }, + { weight: 0, monthlyMessages: 0 }, +]; + +type ModelRow = ModelPrice & { modelId: string; status: string; enabled: boolean }; +type PricingRow = { featureKey: string; modelTier: string; creditCost: number; status: string; enabled: boolean }; +type Product = { code: string; priceCents: number; enabled: boolean; status: string; entitlements: Array<{ featureKey: string; metadata?: { minuteLimit?: number | null; dayLimit?: number | null; billingLimit?: number | null } }> }; +type UsagePayload = { data: Array<{ featureKey: string; hasData: boolean; metrics: { costMicrousd: Metric; inputTokens: Metric; outputTokens: Metric; durationMs: Metric } }> }; + +type LoadState = { models?: ModelRow[]; pricing?: PricingRow[]; products?: Product[]; usage?: UsagePayload; errors: string[] }; + +function metric(value: unknown): Metric { + const data = value as Partial | null; + return { runs: Number(data?.runs ?? 0), avg: data?.avg ?? null, p50: data?.p50 ?? null, p95: data?.p95 ?? null, max: data?.max ?? null }; +} + +function formatNumber(value: number | null, suffix = "") { + return value === null || !Number.isFinite(value) ? "—" : `${value.toLocaleString("zh-CN", { maximumFractionDigits: 2 })}${suffix}`; +} + +function formatCny(value: number | null) { + return value === null ? "—" : `¥${value.toFixed(2)}`; +} + +function errorText(error: unknown, fallback: string) { + return error instanceof Error ? error.message : fallback; +} + +export function PricingSimulator() { + const [state, setState] = useState({ errors: [] }); + const [usdToCny, setUsdToCny] = useState(null); + const [fixedCostCny, setFixedCostCny] = useState(null); + const [distribution, setDistribution] = useState(BANDS); + + useEffect(() => { + let active = true; + void Promise.allSettled([ + adminRequestJson<{ data: ModelRow[] }>("/api/admin/models?page=1&pageSize=100&status=published"), + adminRequestJson<{ data: PricingRow[] }>("/api/admin/feature-pricing"), + adminRequestJson<{ data: Product[] }>("/api/admin/products?page=1&pageSize=100&status=published"), + adminRequestJson("/api/admin/usage/aggregate"), + ]).then((results) => { + if (!active) return; + const [models, pricing, products, usage] = results; + setState({ + models: models.status === "fulfilled" ? models.value.data : undefined, + pricing: pricing.status === "fulfilled" ? pricing.value.data : undefined, + products: products.status === "fulfilled" ? products.value.data : undefined, + usage: usage.status === "fulfilled" ? usage.value : undefined, + errors: results.flatMap((result, index) => result.status === "rejected" ? [errorText(result.reason, `数据块 ${index + 1} 不可用`)] : []), + }); + }); + return () => { active = false; }; + }, []); + + const usageByFeature = useMemo(() => new Map((state.usage?.data ?? []).map((row) => [row.featureKey, { + hasData: row.hasData, + metrics: { + costMicrousd: metric(row.metrics.costMicrousd), inputTokens: metric(row.metrics.inputTokens), + outputTokens: metric(row.metrics.outputTokens), durationMs: metric(row.metrics.durationMs), + }, + } satisfies UsageAggregate])), [state.usage]); + + const rows = useMemo(() => FEATURE_KEYS.map((featureKey) => { + const pricing = (state.pricing ?? []).find((row) => row.featureKey === featureKey && row.status === "published" && row.enabled); + const model = (state.models ?? []).find((row) => row.modelTier === pricing?.modelTier && row.status === "published" && row.enabled) ?? null; + const estimate = estimateFeature({ + featureKey, + creditCost: pricing?.creditCost ?? null, + model, + usage: usageByFeature.get(featureKey) ?? null, + usdToCny, + }); + return { key: featureKey, featureKey, pricing, model, usage: usageByFeature.get(featureKey) ?? null, estimate }; + }), [state.models, state.pricing, usageByFeature, usdToCny]); + + const standardRow = rows.find((row) => row.featureKey === "chat.standard"); + const monthly = (state.products ?? []).find((product) => product.code === "standard_monthly" && product.status === "published"); + const monthlyEntitlement = monthly?.entitlements.find((entry) => entry.featureKey === "chat.standard"); + const averageCost = membershipCost(distribution, standardRow?.estimate.costCny ?? null, monthlyEntitlement?.metadata?.billingLimit ?? null); + const monthlyPrice = monthly ? monthly.priceCents / 100 : null; + const averageMargin = monthlyPrice === null || averageCost.averageCostCny === null ? null : ((monthlyPrice - averageCost.averageCostCny) / monthlyPrice) * 100; + const worstMargin = monthlyPrice === null || averageCost.worstCostCny === null ? null : ((monthlyPrice - averageCost.worstCostCny) / monthlyPrice) * 100; + const breakEven = fixedCostCny === null || monthlyPrice === null || averageMargin === null || averageMargin <= 0 ? null : Math.ceil(fixedCostCny / (monthlyPrice * averageMargin / 100)); + const capacity = memberCapacity(distribution, CONSULTATION_DOMAIN_DURATION_MS); + + return ( + + 前往功能定价}> + + 只读测算页:价格和用量来自后台实时接口;本页输入仅用于当前浏览器内的假设,不会写入价格配置。 + + {state.errors.map((error) => )} + + + + + USD/CNY 汇率(假设) + 固定成本 ¥/月(假设) + 会员月均消息分布(假设) setDistribution((items) => items.map((item, index) => index === 0 ? { ...item, weight: value ?? 0 } : item))} placeholder="占比" /> setDistribution((items) => items.map((item, index) => index === 0 ? { ...item, monthlyMessages: value ?? 0 } : item))} placeholder="条/月" /> + + + 首屏不填充没有实测依据的分布与汇率;其余三档可在下面表格继续编辑。权重会归一化,合计不必恰好等于 1。 + + size="small" pagination={false} rowKey={(_, index) => String(index)} dataSource={distribution} columns={[ + { title: "档位", render: (_, __, index) => `第 ${index + 1} 档` }, + { title: "占比", render: (_, item, index) => setDistribution((items) => items.map((entry, i) => i === index ? { ...entry, weight: value ?? 0 } : entry))} /> }, + { title: "条数/月", render: (_, item, index) => setDistribution((items) => items.map((entry, i) => i === index ? { ...entry, monthlyMessages: value ?? 0 } : entry))} /> }, + ]} /> + + + + row.pricing?.modelTier ?? 无发布配置 }, + { title: "售价", render: (_, row) => row.estimate.saleCny === null ? "无发布价格" : `${row.pricing?.creditCost} 积分 / ${formatCny(row.estimate.saleCny)}` }, + { title: "单位成本", render: (_, row) => row.estimate.source === "unavailable" ? 无实测数据 : <>{formatCny(row.estimate.costCny)}{formatNumber(row.estimate.costMicrousd, " µUSD")} · {row.estimate.source === "ledger" ? "账本实测" : "模型估算"} }, + { title: "毛利率", render: (_, row) => row.estimate.marginPercent === null ? "需汇率或价格" : `${row.estimate.marginPercent.toFixed(1)}%` }, + ]} /> + + + + + + + + + + + + billingLimit 是异常账号熔断,不是“随便聊”的总量承诺。当前会员参数:minuteLimit {formatNumber(monthlyEntitlement?.metadata?.minuteLimit ?? null)},dayLimit {formatNumber(monthlyEntitlement?.metadata?.dayLimit ?? null)},billingLimit {monthlyEntitlement?.metadata?.billingLimit === null || monthlyEntitlement?.metadata?.billingLimit === undefined ? "不限" : formatNumber(monthlyEntitlement.metadata.billingLimit)}。 + + + + + + + + + ); +} diff --git a/frontend/src/lib/pricing-simulation.ts b/frontend/src/lib/pricing-simulation.ts new file mode 100644 index 00000000..989e6153 --- /dev/null +++ b/frontend/src/lib/pricing-simulation.ts @@ -0,0 +1,90 @@ +export const CREDITS_PER_CNY = 10; +export const CONSULTATION_DOMAIN_DURATION_MS = 21_000; +const MICROUSD_PER_USD = 1_000_000; + +export type Metric = Readonly<{ runs: number; avg: number | null; p50: number | null; p95: number | null; max: number | null }>; +export type UsageAggregate = Readonly<{ + hasData: boolean; + metrics: Readonly<{ costMicrousd: Metric; inputTokens: Metric; outputTokens: Metric; durationMs: Metric }>; +}>; +export type ModelPrice = Readonly<{ + modelTier: string; + inputCostMicrousdPerMillion: number; + outputCostMicrousdPerMillion: number; +}>; +export type DistributionBand = Readonly<{ weight: number; monthlyMessages: number }>; + +export type FeatureSimulationInput = Readonly<{ + featureKey: string; + creditCost: number | null; + model: ModelPrice | null; + usage: UsageAggregate | null; + usdToCny: number | null; +}>; + +export type FeatureSimulation = Readonly<{ + featureKey: string; + saleCny: number | null; + costMicrousd: number | null; + costCny: number | null; + marginPercent: number | null; + source: "ledger" | "model_estimate" | "unavailable"; +}>; + +function metricValue(metric: Metric, percentile: "p50" | "p95" | "max"): number | null { + return metric[percentile]; +} + +export function estimateFeature(input: FeatureSimulationInput, percentile: "p50" | "p95" | "max" = "p50"): FeatureSimulation { + const saleCny = input.creditCost === null ? null : input.creditCost / CREDITS_PER_CNY; + if (input.creditCost === null || !input.usage?.hasData) { + return { featureKey: input.featureKey, saleCny, costMicrousd: null, costCny: null, marginPercent: null, source: "unavailable" }; + } + + const observedCost = metricValue(input.usage.metrics.costMicrousd, percentile); + const modelCost = input.model && input.usage.metrics.inputTokens[percentile] !== null && input.usage.metrics.outputTokens[percentile] !== null + ? (input.usage.metrics.inputTokens[percentile]! * input.model.inputCostMicrousdPerMillion + + input.usage.metrics.outputTokens[percentile]! * input.model.outputCostMicrousdPerMillion) / 1_000_000 + : null; + const costMicrousd = observedCost ?? modelCost; + const costCny = costMicrousd === null || input.usdToCny === null + ? null + : (costMicrousd / MICROUSD_PER_USD) * input.usdToCny; + return { + featureKey: input.featureKey, + saleCny, + costMicrousd, + costCny, + marginPercent: costCny === null || saleCny === null || saleCny === 0 ? null : ((saleCny - costCny) / saleCny) * 100, + source: observedCost !== null ? "ledger" : costMicrousd === null ? "unavailable" : "model_estimate", + }; +} + +export function weightedAverageMessages(distribution: readonly DistributionBand[]): number | null { + const usable = distribution.filter((band) => Number.isFinite(band.weight) && band.weight > 0 && Number.isFinite(band.monthlyMessages)); + const totalWeight = usable.reduce((sum, band) => sum + band.weight, 0); + if (totalWeight <= 0) return null; + return usable.reduce((sum, band) => sum + (band.weight / totalWeight) * Math.max(0, band.monthlyMessages), 0); +} + +export function membershipCost(distribution: readonly DistributionBand[], unitCostCny: number | null, billingLimit: number | null) { + const averageMessages = weightedAverageMessages(distribution); + return { + averageMessages, + averageCostCny: averageMessages === null || unitCostCny === null ? null : averageMessages * unitCostCny, + worstCostCny: billingLimit === null || unitCostCny === null ? null : billingLimit * unitCostCny, + unlimitedWorstCase: billingLimit === null, + }; +} + +export function dailySerialCapacity(durationMs = CONSULTATION_DOMAIN_DURATION_MS): number | null { + if (!Number.isFinite(durationMs) || durationMs <= 0) return null; + return Math.floor(86_400_000 / durationMs); +} + +export function memberCapacity(distribution: readonly DistributionBand[], unitDurationMs: number): number | null { + const averageMessages = weightedAverageMessages(distribution); + const dailyCapacity = dailySerialCapacity(unitDurationMs); + if (averageMessages === null || dailyCapacity === null || averageMessages <= 0) return null; + return Math.floor(dailyCapacity / (averageMessages / 30)); +} diff --git a/frontend/tests/pricing-simulation.test.ts b/frontend/tests/pricing-simulation.test.ts new file mode 100644 index 00000000..c0fdd399 --- /dev/null +++ b/frontend/tests/pricing-simulation.test.ts @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { estimateFeature, membershipCost, weightedAverageMessages } from "../src/lib/pricing-simulation.ts"; + +const metric = (value: number | null) => ({ runs: value === null ? 0 : 1, avg: value, p50: value, p95: value, max: value }); + +test("normalizes a distribution whose weights do not sum to one", () => { + assert.equal(weightedAverageMessages([{ weight: 1, monthlyMessages: 10 }, { weight: 1, monthlyMessages: 20 }]), 15); +}); + +test("billingLimit null is explicitly unlimited rather than zero", () => { + const result = membershipCost([{ weight: 1, monthlyMessages: 10 }], 2, null); + assert.equal(result.worstCostCny, null); + assert.equal(result.unlimitedWorstCase, true); +}); + +test("zero model prices remain a measured zero cost", () => { + const result = estimateFeature({ + featureKey: "chat.standard", + creditCost: 10, + usdToCny: 7, + model: { modelTier: "standard", inputCostMicrousdPerMillion: 0, outputCostMicrousdPerMillion: 0 }, + usage: { hasData: true, metrics: { costMicrousd: metric(0), inputTokens: metric(100), outputTokens: metric(20), durationMs: metric(1000) } }, + }); + assert.equal(result.costMicrousd, 0); + assert.equal(result.costCny, 0); + assert.equal(result.marginPercent, 100); + assert.equal(result.source, "ledger"); +}); + +test("ledger with no data is not silently converted to zero", () => { + const result = estimateFeature({ + featureKey: "report.full", + creditCost: null, + usdToCny: 7, + model: null, + usage: { hasData: false, metrics: { costMicrousd: metric(null), inputTokens: metric(null), outputTokens: metric(null), durationMs: metric(null) } }, + }); + assert.equal(result.costMicrousd, null); + assert.equal(result.source, "unavailable"); +});