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:
John
2026-06-16 03:10:05 -07:00
parent befeedfd37
commit 03690ee354
14 changed files with 472 additions and 52 deletions
+22 -4
View File
@@ -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 ?? [];
}
+7
View File
@@ -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
+30
View File
@@ -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);
+15
View File
@@ -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;
+17
View File
@@ -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)}`;
}