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:
+7
-5
@@ -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 # 输入 Token:2 分/1k(¥0.02/1k)
|
||||
# H5_BILL_OUTPUT_CENTS_PER_1K=6 # 输出 Token:6 分/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...
|
||||
|
||||
@@ -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
@@ -1,11 +1,29 @@
|
||||
// DeepSeek V4 Flash 公开价($/1M tokens,cache-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
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)})`;
|
||||
}
|
||||
+126
-35
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user