feat(billing): add usage analytics charts and summary totals
Add daily usage trend charts with multi-metric toggles, overview vs user analysis views, date-range filtering, and usage summary API for interval and all-time totals. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+31
-1
@@ -4,7 +4,7 @@ import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import express from 'express';
|
||||
import { listUsagePaged, listLedgerPaged } from './pagination.mjs';
|
||||
import { listUsagePaged, listLedgerPaged, getUsageStats, getUsageSummary } from './pagination.mjs';
|
||||
|
||||
const projectRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
@@ -307,7 +307,29 @@ export function createAdminApp(services) {
|
||||
res.json({ user: result.user });
|
||||
});
|
||||
|
||||
async function handleUsageStats(req, res) {
|
||||
try {
|
||||
const result = await getUsageStats(pool, req.query);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
if (err?.code === 'INVALID_TIME_RANGE') {
|
||||
return res.status(400).json({ message: '无效的时间范围' });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
adminApi.get('/usage/summary', requireAdmin, async (req, res) => {
|
||||
const result = await getUsageSummary(pool, req.query);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/usage/stats', requireAdmin, handleUsageStats);
|
||||
|
||||
adminApi.get('/usage', requireAdmin, async (req, res) => {
|
||||
if (req.query.stats === '1' || req.query.stats === 'true') {
|
||||
return handleUsageStats(req, res);
|
||||
}
|
||||
const result = await listUsagePaged(pool, req.query);
|
||||
res.json(result);
|
||||
});
|
||||
@@ -399,6 +421,14 @@ export function createAdminApp(services) {
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/memory-v2/runtime', requireAdmin, async (_req, res) => {
|
||||
if (!memoryV2ConfigService?.getRuntimeState) {
|
||||
return res.status(503).json({ message: 'Memory V2 配置服务未启用' });
|
||||
}
|
||||
const result = await memoryV2ConfigService.getRuntimeState();
|
||||
return res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/mindsearch/config', requireAdmin, async (_req, res) => {
|
||||
if (!mindSearchConfigService?.getAdminConfig) return res.status(503).json({ message: 'MindSearch 配置未启用' });
|
||||
res.json(await mindSearchConfigService.getAdminConfig());
|
||||
|
||||
+126
-2
@@ -17,15 +17,139 @@ export function paginateArray(items, query, defaultPageSize = 20) {
|
||||
};
|
||||
}
|
||||
|
||||
const MS_THRESHOLD = 1_000_000_000_000;
|
||||
|
||||
function parseUsageTimeFilters(query) {
|
||||
const startAt = Number(query.startAt);
|
||||
const endAt = Number(query.endAt);
|
||||
const hasStart = Number.isFinite(startAt) && startAt > 0;
|
||||
const hasEnd = Number.isFinite(endAt) && endAt > 0;
|
||||
if (!hasStart || !hasEnd || startAt > endAt) {
|
||||
return null;
|
||||
}
|
||||
// Frontend sends unix seconds; h5_usage_records.created_at is stored in milliseconds.
|
||||
const startAtMs = startAt < MS_THRESHOLD ? startAt * 1000 : startAt;
|
||||
const endAtMs = endAt < MS_THRESHOLD ? endAt * 1000 + 999 : endAt;
|
||||
return { startAt, endAt, startAtMs, endAtMs };
|
||||
}
|
||||
|
||||
function formatSqlDay(value) {
|
||||
if (value instanceof Date) {
|
||||
const y = value.getFullYear();
|
||||
const m = String(value.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(value.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
return String(value).slice(0, 10);
|
||||
}
|
||||
|
||||
export async function getUsageTotals(pool, { userId = null, startAtMs = null, endAtMs = null } = {}) {
|
||||
const params = [];
|
||||
const clauses = [];
|
||||
if (userId) {
|
||||
clauses.push('r.user_id = ?');
|
||||
params.push(userId);
|
||||
}
|
||||
if (startAtMs != null && endAtMs != null) {
|
||||
clauses.push('r.created_at >= ?', 'r.created_at <= ?');
|
||||
params.push(startAtMs, endAtMs);
|
||||
}
|
||||
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||
const [[row]] = await pool.query(
|
||||
`SELECT COUNT(*) AS count,
|
||||
COALESCE(SUM(r.input_tokens), 0) AS input_tokens,
|
||||
COALESCE(SUM(r.output_tokens), 0) AS output_tokens,
|
||||
COALESCE(SUM(r.cost_cents), 0) AS cost_cents
|
||||
FROM h5_usage_records r
|
||||
${where}`,
|
||||
params,
|
||||
);
|
||||
return {
|
||||
count: Number(row.count),
|
||||
inputTokens: Number(row.input_tokens),
|
||||
outputTokens: Number(row.output_tokens),
|
||||
costCents: Number(row.cost_cents),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getUsageSummary(pool, query) {
|
||||
const userId = typeof query.userId === 'string' && query.userId ? query.userId : null;
|
||||
const range = parseUsageTimeFilters(query);
|
||||
const allTime = await getUsageTotals(pool, { userId });
|
||||
if (!range) {
|
||||
return { range: null, allTime };
|
||||
}
|
||||
const rangeTotals = await getUsageTotals(pool, {
|
||||
userId,
|
||||
startAtMs: range.startAtMs,
|
||||
endAtMs: range.endAtMs,
|
||||
});
|
||||
return {
|
||||
range: {
|
||||
...rangeTotals,
|
||||
startAt: range.startAt,
|
||||
endAt: range.endAt,
|
||||
},
|
||||
allTime,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getUsageStats(pool, query) {
|
||||
const userId = typeof query.userId === 'string' && query.userId ? query.userId : null;
|
||||
const range = parseUsageTimeFilters(query);
|
||||
if (!range) {
|
||||
const err = new Error('INVALID_TIME_RANGE');
|
||||
err.code = 'INVALID_TIME_RANGE';
|
||||
throw err;
|
||||
}
|
||||
const { startAt, endAt, startAtMs, endAtMs } = range;
|
||||
const params = [startAtMs, endAtMs];
|
||||
const clauses = ['r.created_at >= ?', 'r.created_at <= ?'];
|
||||
if (userId) {
|
||||
clauses.push('r.user_id = ?');
|
||||
params.push(userId);
|
||||
}
|
||||
const where = `WHERE ${clauses.join(' AND ')}`;
|
||||
const [rows] = await pool.query(
|
||||
`SELECT DATE(FROM_UNIXTIME(r.created_at / 1000)) AS day,
|
||||
COUNT(*) AS count,
|
||||
COALESCE(SUM(r.input_tokens), 0) AS input_tokens,
|
||||
COALESCE(SUM(r.output_tokens), 0) AS output_tokens,
|
||||
COALESCE(SUM(r.cost_cents), 0) AS cost_cents
|
||||
FROM h5_usage_records r
|
||||
${where}
|
||||
GROUP BY day
|
||||
ORDER BY day ASC`,
|
||||
params,
|
||||
);
|
||||
return {
|
||||
buckets: rows.map((row) => ({
|
||||
day: formatSqlDay(row.day),
|
||||
count: Number(row.count),
|
||||
inputTokens: Number(row.input_tokens),
|
||||
outputTokens: Number(row.output_tokens),
|
||||
costCents: Number(row.cost_cents),
|
||||
})),
|
||||
startAt,
|
||||
endAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listUsagePaged(pool, query) {
|
||||
const { page, pageSize, offset } = parsePageQuery(query, 20);
|
||||
const userId = typeof query.userId === 'string' && query.userId ? query.userId : null;
|
||||
const range = parseUsageTimeFilters(query);
|
||||
const params = [];
|
||||
let where = '';
|
||||
const clauses = [];
|
||||
if (userId) {
|
||||
where = 'WHERE r.user_id = ?';
|
||||
clauses.push('r.user_id = ?');
|
||||
params.push(userId);
|
||||
}
|
||||
if (range) {
|
||||
clauses.push('r.created_at >= ?', 'r.created_at <= ?');
|
||||
params.push(range.startAtMs, range.endAtMs);
|
||||
}
|
||||
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||
const [[{ total }]] = await pool.query(
|
||||
`SELECT COUNT(*) AS total FROM h5_usage_records r ${where}`,
|
||||
params,
|
||||
|
||||
Reference in New Issue
Block a user