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
+7 -5
View File
@@ -49,18 +49,20 @@ H5_ADMIN_PASSWORD=change-me-admin
H5_ACCESS_PASSWORD=change-me
# 计费(Phase 2,金额单位均为人民币分)
# 默认按 Token 单价扣费(人民币结算);勿开启美元成本换算除非明确需要
# 默认DeepSeek V4 Flash 公开价 × 3(随 H5_USD_CNY_RATE 换算);可用下方变量覆盖
# H5_USE_BACKEND_COST=0
# H5_BILL_INPUT_CENTS_PER_1K=2 # 输入 Token2 分/1k(¥0.02/1k
# H5_BILL_OUTPUT_CENTS_PER_1K=6 # 输出 Token6 分/1k(¥0.06/1k
# H5_USD_CNY_RATE=7.2
# 汇率 7.2 时默认约:输入 0.302 分/1k(¥0.00302/1k)、输出 0.605 分/1k(¥0.00605/1k
# H5_BILL_INPUT_CENTS_PER_1K=0.302
# H5_BILL_OUTPUT_CENTS_PER_1K=0.605
# H5_MIN_BILL_CENTS=1
# 仅调试:H5_USE_BACKEND_COST=1 + H5_USD_CNY_RATE=7.2(上游 USD × 汇率 → 人民币分)
# 用户自助充值(微信支付)
# H5_RECHARGE_TIERS_CENTS=500,1000,3000,5000,10000,20000
# H5_MIN_RECHARGE_CENTS=500
# H5_RECHARGE_ORDER_TTL_MS=900000
# H5_RECHARGE_MAX_PENDING=3
# H5_RECHARGE_ORDER_TTL_MS=180000
# H5_RECHARGE_MAX_PENDING=5
# H5_RECHARGE_DAILY_LIMIT_CENTS=200000
# H5_WECHAT_PAY_ENABLED=1
# H5_WECHAT_APP_ID=wx...
+6 -2
View File
@@ -1,4 +1,5 @@
import crypto from 'node:crypto';
import { loadBillingConfig } from './billing.mjs';
export function loadRechargeConfig() {
const tiers = (process.env.H5_RECHARGE_TIERS_CENTS ?? '500,1000,3000,5000,10000,20000')
@@ -8,8 +9,8 @@ export function loadRechargeConfig() {
return {
tiersCents: tiers.length ? tiers : [500, 1000, 3000, 5000, 10000, 20000],
minRechargeCents: Number(process.env.H5_MIN_RECHARGE_CENTS ?? 500),
orderTtlMs: Number(process.env.H5_RECHARGE_ORDER_TTL_MS ?? 15 * 60 * 1000),
maxPendingOrders: Number(process.env.H5_RECHARGE_MAX_PENDING ?? 3),
orderTtlMs: Number(process.env.H5_RECHARGE_ORDER_TTL_MS ?? 3 * 60 * 1000),
maxPendingOrders: Number(process.env.H5_RECHARGE_MAX_PENDING ?? 5),
dailyLimitCents: Number(process.env.H5_RECHARGE_DAILY_LIMIT_CENTS ?? 200_000),
};
}
@@ -104,11 +105,14 @@ export function createRechargeService(pool, { userAuth, wechatPay, config = load
const getBillingConfig = async (userId) => {
const user = await userAuth.getUserById(userId);
const billing = loadBillingConfig();
return {
wechatEnabled: Boolean(wechatPay?.enabled),
tiersCents: config.tiersCents,
minRechargeCents: config.minRechargeCents,
balanceCents: user ? Number(user.balance_cents ?? 0) : 0,
inputCentsPer1k: billing.inputCentsPer1k,
outputCentsPer1k: billing.outputCentsPer1k,
};
};
+21 -3
View File
@@ -1,11 +1,29 @@
// DeepSeek V4 Flash 公开价($/1M tokenscache-miss 输入);见 api-docs.deepseek.com
export const DEEPSEEK_FLASH_INPUT_USD_PER_1M = 0.14;
export const DEEPSEEK_FLASH_OUTPUT_USD_PER_1M = 0.28;
export const DEEPSEEK_BILLING_MARKUP = 3;
function deepSeekFlashCentsPer1k(usdPer1M, usdCnyRate) {
return (usdPer1M / 1000) * usdCnyRate * 100 * DEEPSEEK_BILLING_MARKUP;
}
export function loadBillingConfig() {
// 默认按人民币分(CNY cents)计费;仅当 H5_USE_BACKEND_COST=1 时才用上游 USD 成本换算。
const useBackendCost = process.env.H5_USE_BACKEND_COST === '1';
const usdCnyRate = Number(process.env.H5_USD_CNY_RATE ?? 7.2);
const defaultInputCentsPer1k = deepSeekFlashCentsPer1k(
DEEPSEEK_FLASH_INPUT_USD_PER_1M,
usdCnyRate,
);
const defaultOutputCentsPer1k = deepSeekFlashCentsPer1k(
DEEPSEEK_FLASH_OUTPUT_USD_PER_1M,
usdCnyRate,
);
return {
useBackendCost,
usdCnyRate: Number(process.env.H5_USD_CNY_RATE ?? 7.2),
inputCentsPer1k: Number(process.env.H5_BILL_INPUT_CENTS_PER_1K ?? 2),
outputCentsPer1k: Number(process.env.H5_BILL_OUTPUT_CENTS_PER_1K ?? 6),
usdCnyRate,
inputCentsPer1k: Number(process.env.H5_BILL_INPUT_CENTS_PER_1K ?? defaultInputCentsPer1k),
outputCentsPer1k: Number(process.env.H5_BILL_OUTPUT_CENTS_PER_1K ?? defaultOutputCentsPer1k),
minBillCents: Number(process.env.H5_MIN_BILL_CENTS ?? 1),
};
}
+42 -1
View File
@@ -1,6 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { computeDeltaCostCents, normalizeTokenState } from './billing.mjs';
import {
computeDeltaCostCents,
loadBillingConfig,
normalizeTokenState,
} from './billing.mjs';
const config = {
useBackendCost: false,
@@ -10,6 +14,43 @@ const config = {
minBillCents: 1,
};
test('loadBillingConfig defaults to DeepSeek Flash × 3 at USD/CNY 7.2', () => {
const savedInput = process.env.H5_BILL_INPUT_CENTS_PER_1K;
const savedOutput = process.env.H5_BILL_OUTPUT_CENTS_PER_1K;
const savedRate = process.env.H5_USD_CNY_RATE;
delete process.env.H5_BILL_INPUT_CENTS_PER_1K;
delete process.env.H5_BILL_OUTPUT_CENTS_PER_1K;
process.env.H5_USD_CNY_RATE = '7.2';
try {
const billing = loadBillingConfig();
assert.ok(Math.abs(billing.inputCentsPer1k - 0.3024) < 1e-9);
assert.ok(Math.abs(billing.outputCentsPer1k - 0.6048) < 1e-9);
} finally {
if (savedInput == null) delete process.env.H5_BILL_INPUT_CENTS_PER_1K;
else process.env.H5_BILL_INPUT_CENTS_PER_1K = savedInput;
if (savedOutput == null) delete process.env.H5_BILL_OUTPUT_CENTS_PER_1K;
else process.env.H5_BILL_OUTPUT_CENTS_PER_1K = savedOutput;
if (savedRate == null) delete process.env.H5_USD_CNY_RATE;
else process.env.H5_USD_CNY_RATE = savedRate;
}
});
test('computeDeltaCostCents bills DeepSeek Flash × 3 defaults with min charge', () => {
const billing = {
useBackendCost: false,
usdCnyRate: 7.2,
inputCentsPer1k: 0.3024,
outputCentsPer1k: 0.6048,
minBillCents: 1,
};
const previous = { lastInputTokens: 0, lastOutputTokens: 0 };
const current = normalizeTokenState({
accumulatedInputTokens: 1000,
accumulatedOutputTokens: 500,
});
assert.equal(computeDeltaCostCents(previous, current, billing), 1);
});
test('normalizeTokenState reads camelCase fields', () => {
const state = normalizeTokenState({
inputTokens: 10,
+6
View File
@@ -255,6 +255,12 @@ export async function migrateSchema(pool) {
`ALTER TABLE h5_payment_orders
MODIFY pay_mode ENUM('native', 'h5', 'jsapi') NOT NULL DEFAULT 'native'`,
);
if (!(await indexExists(pool, 'h5_usage_records', 'idx_h5_usage_created'))) {
await pool.query(
`ALTER TABLE h5_usage_records ADD KEY idx_h5_usage_created (created_at)`,
);
}
}
export async function initSchema(pool) {
+1
View File
@@ -354,6 +354,7 @@ CREATE TABLE IF NOT EXISTS h5_usage_records (
balance_after_cents BIGINT NOT NULL,
created_at BIGINT NOT NULL,
KEY idx_h5_usage_user_time (user_id, created_at),
KEY idx_h5_usage_created (created_at),
CONSTRAINT fk_h5_usage_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env node
/**
* One-off billing compensation for duplicate charges / rate overcharge.
*
* Usage:
* node scripts/compensate-user-billing.mjs wx_mk4zzaps
* node scripts/compensate-user-billing.mjs wx_mk4zzaps --apply
* node scripts/compensate-user-billing.mjs wx_mk4zzaps --apply --include-rate-adjustment
*/
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import mysql from 'mysql2/promise';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return;
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq < 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1).trim();
if (!process.env[key]) process.env[key] = value;
}
}
loadEnvFile(path.join(root, '.env'));
const username = process.argv[2];
const apply = process.argv.includes('--apply');
const includeRateAdjustment = process.argv.includes('--include-rate-adjustment');
if (!username) {
console.error('Usage: node scripts/compensate-user-billing.mjs <username> [--apply] [--include-rate-adjustment]');
process.exit(1);
}
if (!process.env.DATABASE_URL) {
console.error('DATABASE_URL is not configured');
process.exit(1);
}
/** wx_mk4zzaps duplicate Finish billing (2026-06-16 session 20260616_600). */
const DUPLICATE_COST_CENTS = 1735;
const DUPLICATE_TOKENS = 838567;
/** Unique bills at 2/6 分/1k vs documented DeepSeek ×3 default (0.302/0.605 分/1k). */
const RATE_OVERCHARGE_CENTS = 1467;
async function main() {
const pool = mysql.createPool(process.env.DATABASE_URL);
try {
const [users] = await pool.query(
`SELECT u.id, u.username, u.display_name, u.status,
COALESCE(w.balance_cents, 0) AS balance_cents,
COALESCE(w.tokens_used, 0) AS tokens_used
FROM h5_users u
LEFT JOIN h5_user_wallets w ON w.user_id = u.id
WHERE u.username = ?
LIMIT 1`,
[username],
);
const user = users[0];
if (!user) {
console.error(`User not found: ${username}`);
process.exit(1);
}
const amountCents = DUPLICATE_COST_CENTS + (includeRateAdjustment ? RATE_OVERCHARGE_CENTS : 0);
const tokenAdjust = -DUPLICATE_TOKENS;
const note = includeRateAdjustment
? `补偿:重复扣费 ¥${(DUPLICATE_COST_CENTS / 100).toFixed(2)} + 费率差额 ¥${(RATE_OVERCHARGE_CENTS / 100).toFixed(2)}`
: `补偿:重复扣费 ¥${(DUPLICATE_COST_CENTS / 100).toFixed(2)}session 20260616_600`;
console.log('User:', user.username, user.display_name ?? '', `(${user.id})`);
console.log('Current status:', user.status);
console.log('Current balance:', `¥${(Number(user.balance_cents) / 100).toFixed(2)}`);
console.log('Current tokens_used:', Number(user.tokens_used).toLocaleString());
console.log('');
console.log('Compensation plan:');
console.log(' balance +', `¥${(amountCents / 100).toFixed(2)}`, `(${amountCents} cents)`);
if (tokenAdjust !== 0) {
console.log(' tokens_used', tokenAdjust.toLocaleString(), '(remove duplicate token count)');
}
console.log(' note:', note);
console.log(' reactivate account if suspended:', user.status === 'suspended' ? 'yes' : 'no');
if (!apply) {
console.log('');
console.log('Dry run only. Re-run with --apply to execute.');
return;
}
const now = Date.now();
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
await conn.query(
`INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at)
VALUES (?, ?, 0, ?)
ON DUPLICATE KEY UPDATE
balance_cents = balance_cents + VALUES(balance_cents),
tokens_used = GREATEST(CAST(tokens_used AS SIGNED) + ?, 0),
updated_at = VALUES(updated_at)`,
[user.id, amountCents, now, tokenAdjust],
);
await conn.query(
`INSERT INTO h5_billing_ledger
(user_id, type, amount_cents, tokens, note, operator_id, created_at)
VALUES (?, 'adjust', ?, ?, ?, NULL, ?)`,
[user.id, amountCents, tokenAdjust, note, now],
);
if (user.status === 'suspended') {
await conn.query(`UPDATE h5_users SET status = 'active', updated_at = ? WHERE id = ?`, [
now,
user.id,
]);
}
await conn.commit();
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
const [updatedRows] = await pool.query(
`SELECT u.status, COALESCE(w.balance_cents, 0) AS balance_cents,
COALESCE(w.tokens_used, 0) AS tokens_used
FROM h5_users u
LEFT JOIN h5_user_wallets w ON w.user_id = u.id
WHERE u.id = ?
LIMIT 1`,
[user.id],
);
const updated = updatedRows[0];
console.log('');
console.log('Applied.');
console.log('New status:', updated.status);
console.log('New balance:', `¥${(Number(updated.balance_cents) / 100).toFixed(2)}`);
console.log('New tokens_used:', Number(updated.tokens_used).toLocaleString());
} finally {
await pool.end();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+19 -2
View File
@@ -804,9 +804,16 @@ adminApi.post('/users/:userId/recharge', requireAdmin, async (req, res) => {
res.json({ user: result.user });
});
adminApi.get('/usage/summary', requireAdmin, async (req, res) => {
const userId = typeof req.query.userId === 'string' ? req.query.userId : null;
const summary = await userAuth.getUsageSummary({ userId });
if (userId && !summary) return res.status(404).json({ message: '用户不存在' });
res.json({ summary });
});
adminApi.get('/usage', requireAdmin, async (req, res) => {
const userId = typeof req.query.userId === 'string' ? req.query.userId : null;
const limit = Number(req.query.limit ?? 50);
const limit = Number(req.query.limit ?? 20);
const records = await userAuth.listUsageRecords({ userId, limit });
res.json({ records });
});
@@ -1055,12 +1062,22 @@ adminApi.post('/plaza/posts/:id/review', requireAdmin, async (req, res) => {
}
});
app.get('/auth/usage/summary', async (req, res) => {
await userAuthReady;
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const summary = await userAuth.getUsageSummary({ userId: me.id });
res.json({ summary });
});
app.get('/auth/usage', async (req, res) => {
await userAuthReady;
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const records = await userAuth.listUsageRecords({ userId: me.id, limit: 30 });
const limit = Number(req.query.limit ?? 20);
const records = await userAuth.listUsageRecords({ userId: me.id, limit });
res.json({ records });
});
+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)}`;
}
+126 -35
View File
@@ -912,55 +912,65 @@ export function createUserAuth(pool, options = {}) {
};
}
const tokenState = normalizeTokenState(tokenStateRaw);
const previous = await getBillingState(agentSessionId);
if (
previous &&
tokenState.accumulatedInputTokens <= Number(previous.lastInputTokens ?? 0) &&
tokenState.accumulatedOutputTokens <= Number(previous.lastOutputTokens ?? 0)
) {
const user = await getUserById(userId);
return {
ok: true,
costCents: 0,
balanceCents: user ? Number(user.balance_cents) : null,
tokensUsed: user ? Number(user.tokens_used ?? 0) : null,
deltaInputTokens: 0,
deltaOutputTokens: 0,
};
}
const config = loadBillingConfig();
const costCents = computeDeltaCostCents(previous, tokenState, config);
const deltaIn = Math.max(
0,
tokenState.accumulatedInputTokens - Number(previous?.lastInputTokens ?? 0),
);
const deltaOut = Math.max(
0,
tokenState.accumulatedOutputTokens - Number(previous?.lastOutputTokens ?? 0),
);
const deltaTokens = deltaIn + deltaOut;
const now = Date.now();
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
// Serialize concurrent Finish handlers for the same session.
await conn.query(
`INSERT INTO h5_session_billing_state
(agent_session_id, user_id, last_accumulated_cost, last_input_tokens, last_output_tokens, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
last_accumulated_cost = VALUES(last_accumulated_cost),
last_input_tokens = VALUES(last_input_tokens),
last_output_tokens = VALUES(last_output_tokens),
updated_at = VALUES(updated_at)`,
VALUES (?, ?, NULL, 0, 0, ?)
ON DUPLICATE KEY UPDATE agent_session_id = agent_session_id`,
[agentSessionId, userId, now],
);
const [stateRows] = await conn.query(
`SELECT last_accumulated_cost, last_input_tokens, last_output_tokens
FROM h5_session_billing_state
WHERE agent_session_id = ?
FOR UPDATE`,
[agentSessionId],
);
const stateRow = stateRows[0];
const previous = {
lastAccumulatedCost: stateRow?.last_accumulated_cost ?? null,
lastInputTokens: Number(stateRow?.last_input_tokens ?? 0),
lastOutputTokens: Number(stateRow?.last_output_tokens ?? 0),
};
if (
tokenState.accumulatedInputTokens <= previous.lastInputTokens &&
tokenState.accumulatedOutputTokens <= previous.lastOutputTokens
) {
await conn.commit();
const fresh = await getUserById(userId);
return {
ok: true,
costCents: 0,
balanceCents: fresh ? Number(fresh.balance_cents) : null,
tokensUsed: fresh ? Number(fresh.tokens_used ?? 0) : null,
deltaInputTokens: 0,
deltaOutputTokens: 0,
};
}
const costCents = computeDeltaCostCents(previous, tokenState, config);
const deltaIn = Math.max(0, tokenState.accumulatedInputTokens - previous.lastInputTokens);
const deltaOut = Math.max(0, tokenState.accumulatedOutputTokens - previous.lastOutputTokens);
const deltaTokens = deltaIn + deltaOut;
await conn.query(
`UPDATE h5_session_billing_state
SET last_accumulated_cost = ?, last_input_tokens = ?, last_output_tokens = ?, updated_at = ?
WHERE agent_session_id = ?`,
[
agentSessionId,
userId,
tokenState.accumulatedCost,
tokenState.accumulatedInputTokens,
tokenState.accumulatedOutputTokens,
now,
agentSessionId,
],
);
@@ -1039,6 +1049,86 @@ export function createUserAuth(pool, options = {}) {
}
};
const getUsageSummary = async ({ userId = null } = {}) => {
const since24h = Date.now() - 24 * 60 * 60 * 1000;
if (userId) {
const user = await getUserById(userId);
if (!user) return null;
const [[usage24h]] = await pool.query(
`SELECT COUNT(*) AS request_count,
COALESCE(SUM(input_tokens), 0) AS input_tokens,
COALESCE(SUM(output_tokens), 0) AS output_tokens,
COALESCE(SUM(cost_cents), 0) AS cost_cents
FROM h5_usage_records
WHERE user_id = ? AND created_at >= ?`,
[userId, since24h],
);
const [[deductAll]] = await pool.query(
`SELECT COUNT(*) AS request_count,
COALESCE(SUM(ABS(amount_cents)), 0) AS cost_cents
FROM h5_billing_ledger
WHERE user_id = ? AND type = 'deduct'`,
[userId],
);
return {
allTime: {
requestCount: Number(deductAll.request_count),
inputTokens: null,
outputTokens: null,
totalTokens: Number(user.tokens_used ?? 0),
costCents: Number(user.spent_cents ?? deductAll.cost_cents),
},
last24h: {
requestCount: Number(usage24h.request_count),
inputTokens: Number(usage24h.input_tokens),
outputTokens: Number(usage24h.output_tokens),
totalTokens:
Number(usage24h.input_tokens) + Number(usage24h.output_tokens),
costCents: Number(usage24h.cost_cents),
},
};
}
const [[wallet]] = await pool.query(
`SELECT COALESCE(SUM(tokens_used), 0) AS total_tokens FROM h5_user_wallets`,
);
const [[deductAll]] = await pool.query(
`SELECT COUNT(*) AS request_count,
COALESCE(SUM(ABS(amount_cents)), 0) AS cost_cents
FROM h5_billing_ledger
WHERE type = 'deduct'`,
);
const [[deduct24h]] = await pool.query(
`SELECT COUNT(*) AS request_count,
COALESCE(SUM(ABS(amount_cents)), 0) AS cost_cents,
COALESCE(SUM(tokens), 0) AS total_tokens
FROM h5_billing_ledger
WHERE type = 'deduct' AND created_at >= ?`,
[since24h],
);
return {
allTime: {
requestCount: Number(deductAll.request_count),
inputTokens: null,
outputTokens: null,
totalTokens: Number(wallet.total_tokens),
costCents: Number(deductAll.cost_cents),
},
last24h: {
requestCount: Number(deduct24h.request_count),
inputTokens: null,
outputTokens: null,
totalTokens: Number(deduct24h.total_tokens),
costCents: Number(deduct24h.cost_cents),
},
};
};
const listUsageRecords = async ({ userId = null, limit = 50 } = {}) => {
const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 200);
const params = [];
@@ -2151,6 +2241,7 @@ export function createUserAuth(pool, options = {}) {
recharge,
billSessionUsage,
listUsageRecords,
getUsageSummary,
listBillingLedger,
getAdminSummary,
ensureAdminUser,