Fix duplicate session billing and align pricing with DeepSeek ×3.
Serialize billSessionUsage with row locks, expose rates on recharge, add compensation script and admin usage views. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+22
-4
@@ -41,6 +41,7 @@ import type {
|
||||
SessionEvent,
|
||||
SessionListResponse,
|
||||
UsageRecord,
|
||||
UsageSummary,
|
||||
BalanceUpdate,
|
||||
} from '../types';
|
||||
|
||||
@@ -299,8 +300,14 @@ export async function getMe(): Promise<{
|
||||
return portalFetch('/auth/me');
|
||||
}
|
||||
|
||||
export async function getMyUsage(): Promise<UsageRecord[]> {
|
||||
const result = await portalFetch<{ records: UsageRecord[] }>('/auth/usage');
|
||||
export async function getMyUsageSummary(): Promise<UsageSummary> {
|
||||
const result = await portalFetch<{ summary: UsageSummary }>('/auth/usage/summary');
|
||||
return result.summary;
|
||||
}
|
||||
|
||||
export async function getMyUsage(limit = 20): Promise<UsageRecord[]> {
|
||||
const query = limit ? `?limit=${encodeURIComponent(String(limit))}` : '';
|
||||
const result = await portalFetch<{ records: UsageRecord[] }>(`/auth/usage${query}`);
|
||||
return result.records ?? [];
|
||||
}
|
||||
|
||||
@@ -1010,8 +1017,19 @@ export async function getAdminDashboardSummary(): Promise<AdminDashboardSummary>
|
||||
return result.summary;
|
||||
}
|
||||
|
||||
export async function listAdminUsage(userId?: string): Promise<UsageRecord[]> {
|
||||
const query = userId ? `?userId=${encodeURIComponent(userId)}` : '';
|
||||
export async function getAdminUsageSummary(userId?: string): Promise<UsageSummary> {
|
||||
const params = new URLSearchParams();
|
||||
if (userId) params.set('userId', userId);
|
||||
const query = params.toString() ? `?${params.toString()}` : '';
|
||||
const result = await portalFetch<{ summary: UsageSummary }>(`/admin-api/usage/summary${query}`);
|
||||
return result.summary;
|
||||
}
|
||||
|
||||
export async function listAdminUsage(userId?: string, limit = 20): Promise<UsageRecord[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (userId) params.set('userId', userId);
|
||||
if (limit) params.set('limit', String(limit));
|
||||
const query = params.toString() ? `?${params.toString()}` : '';
|
||||
const result = await portalFetch<{ records: UsageRecord[] }>(`/admin-api/usage${query}`);
|
||||
return result.records ?? [];
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
|
||||
import QRCode from 'qrcode';
|
||||
import { createRechargeOrder, getBillingConfig, getRechargeOrder } from '../api/client';
|
||||
import type { BillingConfig, RechargeOrder } from '../types';
|
||||
import { formatBillingRates } from '../utils/billing';
|
||||
import { invokeWechatJsapiPay, isWeChatBrowser } from '../utils/wechatPay';
|
||||
|
||||
function formatYuan(cents: number) {
|
||||
@@ -215,6 +216,12 @@ export function RechargeModal({
|
||||
<p className="recharge-muted">
|
||||
最低充值 {formatYuan(config?.minRechargeCents ?? 500)},用于 AI 对话按量扣费
|
||||
</p>
|
||||
{config && (
|
||||
<p className="recharge-rates">
|
||||
当前费率:{formatBillingRates(config.inputCentsPer1k, config.outputCentsPer1k)}
|
||||
。Agent 模式含工具与记忆,实际消耗通常高于普通聊天。
|
||||
</p>
|
||||
)}
|
||||
<div className="recharge-tier-grid">
|
||||
{tiers.map((tier) => (
|
||||
<button
|
||||
|
||||
@@ -2873,6 +2873,17 @@ body,
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.recharge-rates {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.recharge-tier-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@@ -3022,6 +3033,25 @@ body,
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.admin-usage-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-usage-summary-card h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.admin-usage-hint {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.admin-usage-filter {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.admin-stat-card {
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
|
||||
@@ -141,6 +141,8 @@ export type BillingConfig = {
|
||||
tiersCents: number[];
|
||||
minRechargeCents: number;
|
||||
balanceCents: number;
|
||||
inputCentsPer1k: number;
|
||||
outputCentsPer1k: number;
|
||||
};
|
||||
|
||||
export type JsapiPayParams = {
|
||||
@@ -619,6 +621,19 @@ export type UsageRecord = {
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export type UsageTotals = {
|
||||
requestCount: number;
|
||||
inputTokens: number | null;
|
||||
outputTokens: number | null;
|
||||
totalTokens: number;
|
||||
costCents: number;
|
||||
};
|
||||
|
||||
export type UsageSummary = {
|
||||
allTime: UsageTotals;
|
||||
last24h: UsageTotals;
|
||||
};
|
||||
|
||||
export type BalanceUpdate = {
|
||||
balanceCents: number;
|
||||
tokensUsed?: number;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Format CNY cents-per-1k-tokens as readable yuan string. */
|
||||
export function formatYuanPer1kTokens(centsPer1k: number) {
|
||||
const yuan = centsPer1k / 100;
|
||||
if (yuan >= 0.01) return `¥${yuan.toFixed(2)}/1k`;
|
||||
return `¥${yuan.toFixed(3)}/1k`;
|
||||
}
|
||||
|
||||
/** Approximate yuan per million tokens from cents-per-1k rate. */
|
||||
export function formatYuanPerMillionTokens(centsPer1k: number) {
|
||||
const yuanPerM = (centsPer1k / 100) * 1000;
|
||||
if (yuanPerM >= 1) return `约 ¥${yuanPerM.toFixed(1).replace(/\.0$/, '')}/百万`;
|
||||
return `约 ¥${yuanPerM.toFixed(2)}/百万`;
|
||||
}
|
||||
|
||||
export function formatBillingRates(inputCentsPer1k: number, outputCentsPer1k: number) {
|
||||
return `输入 ${formatYuanPer1kTokens(inputCentsPer1k)}(${formatYuanPerMillionTokens(inputCentsPer1k)}),输出 ${formatYuanPer1kTokens(outputCentsPer1k)}(${formatYuanPerMillionTokens(outputCentsPer1k)})`;
|
||||
}
|
||||
Reference in New Issue
Block a user