feat(admin): add read-only pricing simulator

This commit is contained in:
Jesse_Chen
2026-08-31 04:29:53 +08:00
parent 6e13949d3b
commit a499c3444e
5 changed files with 351 additions and 0 deletions
+53
View File
@@ -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 成功 completedurable worker 成功 complete,终态失败 releaseretryable failure 在重试耗尽前不 release。
- request id 使用既有 `payload.requestId`,保持幂等。
- token 按 plan、章节、summary、repair 的实际调用累计后 complete;不使用伪造的固定 token 数。
## 任务 2 · 校正按功能定价
已接入 `resolveFeaturePricing(accounting, "rectification", selectedModel.id)`,不再把模型 `creditCost` 当作校正价格。继续使用既有 case 级幂等键 `rectification:case:<caseId>`,未修改公平使用、配额或 `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 errors23 个既有 warnings
- `npm run test:db`34 passed0 failedDocker 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 passed1 failed。唯一失败为既有的 `tests/staging-backend-workflows.test.ts` YAML 语法检查,失败原因是运行环境的 Python 缺少 `yaml` 模块(`ModuleNotFoundError: No module named 'yaml'`);本轮未修改 `.gitea/workflows/**`,因此不是本轮回归。该环境缺口未通过新增依赖绕过。
@@ -0,0 +1,5 @@
import { PricingSimulator } from "@/components/admin/pricing-simulator";
export default function PricingSimulatorPage() {
return <PricingSimulator />;
}
@@ -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<Metric> | 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<LoadState>({ errors: [] });
const [usdToCny, setUsdToCny] = useState<number | null>(null);
const [fixedCostCny, setFixedCostCny] = useState<number | null>(null);
const [distribution, setDistribution] = useState<DistributionBand[]>(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<UsagePayload>("/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 (
<Space direction="vertical" size={18} style={{ display: "flex" }}>
<Card title="定价测算" extra={<Button href="/admin/feature-pricing"></Button>}>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
</Typography.Paragraph>
{state.errors.map((error) => <Alert key={error} type="warning" showIcon message="部分数据不可用" description={error} />)}
</Card>
<Card title="可调假设">
<Row gutter={[16, 16]}>
<Col xs={24} md={8}><Typography.Text>USD/CNY </Typography.Text><InputNumber min={0} value={usdToCny ?? undefined} onChange={setUsdToCny} placeholder="未设置" style={{ width: "100%" }} /></Col>
<Col xs={24} md={8}><Typography.Text> ¥/</Typography.Text><InputNumber min={0} value={fixedCostCny ?? undefined} onChange={setFixedCostCny} placeholder="未设置" style={{ width: "100%" }} /></Col>
<Col xs={24} md={8}><Typography.Text></Typography.Text><Space.Compact style={{ width: "100%" }}><InputNumber min={0} value={distribution[0]?.weight} onChange={(value) => setDistribution((items) => items.map((item, index) => index === 0 ? { ...item, weight: value ?? 0 } : item))} placeholder="占比" /><InputNumber min={0} value={distribution[0]?.monthlyMessages} onChange={(value) => setDistribution((items) => items.map((item, index) => index === 0 ? { ...item, monthlyMessages: value ?? 0 } : item))} placeholder="条/月" /></Space.Compact></Col>
</Row>
<Typography.Paragraph type="secondary" style={{ margin: "12px 0 0" }}>
1
</Typography.Paragraph>
<Table<DistributionBand> size="small" pagination={false} rowKey={(_, index) => String(index)} dataSource={distribution} columns={[
{ title: "档位", render: (_, __, index) => `${index + 1}` },
{ title: "占比", render: (_, item, index) => <InputNumber min={0} value={item.weight} onChange={(value) => setDistribution((items) => items.map((entry, i) => i === index ? { ...entry, weight: value ?? 0 } : entry))} /> },
{ title: "条数/月", render: (_, item, index) => <InputNumber min={0} value={item.monthlyMessages} onChange={(value) => setDistribution((items) => items.map((entry, i) => i === index ? { ...entry, monthlyMessages: value ?? 0 } : entry))} /> },
]} />
</Card>
<Card title="单功能表(p50">
<Table dataSource={rows} rowKey="key" pagination={false} scroll={{ x: 920 }} columns={[
{ title: "功能", dataIndex: "featureKey" },
{ title: "模型档位", render: (_, row) => row.pricing?.modelTier ?? <Tag></Tag> },
{ title: "售价", render: (_, row) => row.estimate.saleCny === null ? "无发布价格" : `${row.pricing?.creditCost} 积分 / ${formatCny(row.estimate.saleCny)}` },
{ title: "单位成本", render: (_, row) => row.estimate.source === "unavailable" ? <Tag></Tag> : <>{formatCny(row.estimate.costCny)}<Typography.Text type="secondary" style={{ display: "block" }}>{formatNumber(row.estimate.costMicrousd, " µUSD")} · {row.estimate.source === "ledger" ? "账本实测" : "模型估算"}</Typography.Text></> },
{ title: "毛利率", render: (_, row) => row.estimate.marginPercent === null ? "需汇率或价格" : `${row.estimate.marginPercent.toFixed(1)}%` },
]} />
</Card>
<Card title="标准月卡风险视图">
<Row gutter={[16, 16]}>
<Col xs={24} md={6}><Statistic title="月卡售价" value={monthlyPrice === null ? "—" : monthlyPrice} prefix={monthlyPrice === null ? undefined : "¥"} /></Col>
<Col xs={24} md={6}><Statistic title="加权平均成本" value={averageCost.averageCostCny === null ? "—" : averageCost.averageCostCny} prefix={averageCost.averageCostCny === null ? undefined : "¥"} /></Col>
<Col xs={24} md={6}><Statistic title="平均毛利率" value={averageMargin === null ? "—" : averageMargin.toFixed(1)} suffix={averageMargin === null ? undefined : "%"} /></Col>
<Col xs={24} md={6}><Statistic title="最坏毛利率" value={averageCost.unlimitedWorstCase ? "不限" : worstMargin === null ? "—" : worstMargin.toFixed(1)} suffix={averageCost.unlimitedWorstCase || worstMargin === null ? undefined : "%"} /></Col>
</Row>
<Divider />
<Typography.Paragraph type="secondary">
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)}
</Typography.Paragraph>
<Row gutter={[16, 16]}>
<Col xs={24} md={8}><Statistic title="盈亏平衡月卡数" value={breakEven ?? "—"} /></Col>
<Col xs={24} md={8}><Statistic title="21 秒串行日吞吐" value={formatNumber(Math.floor(86_400_000 / CONSULTATION_DOMAIN_DURATION_MS))} suffix="次" /></Col>
<Col xs={24} md={8}><Statistic title="画像下可承载会员数" value={capacity ?? "—"} /></Col>
</Row>
</Card>
</Space>
);
}
+90
View File
@@ -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));
}
+41
View File
@@ -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");
});