feat: 完善全栈 Dashboard 项目 - UI优化、Docker支持、账单系统等
This commit is contained in:
@@ -5,37 +5,81 @@ import db from '../db.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Helper: fetch with rate-limit retry
|
||||
async function fetchWithRetry(url: string, headers: Record<string, string>, maxRetries = 3): Promise<any> {
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
const res = await fetch(url, { headers })
|
||||
if (res.status === 429) {
|
||||
if (attempt < maxRetries) {
|
||||
const retryAfter = Number(res.headers.get('retry-after')) || (2 ** attempt)
|
||||
await new Promise(r => setTimeout(r, retryAfter * 1000))
|
||||
continue
|
||||
}
|
||||
return { success: false, message: 'Rate limited' }
|
||||
}
|
||||
if (!res.ok) {
|
||||
return { success: false, message: `HTTP ${res.status}` }
|
||||
}
|
||||
return await res.json()
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: paginate logs with rate-limit awareness
|
||||
async function paginateLogs(
|
||||
baseUrl: string,
|
||||
headers: Record<string, string>,
|
||||
params: URLSearchParams,
|
||||
maxItems = 50000,
|
||||
delayMs = 200,
|
||||
): Promise<any[]> {
|
||||
const allLogs: any[] = []
|
||||
let page = 1
|
||||
let hasMore = true
|
||||
|
||||
while (hasMore) {
|
||||
params.set('p', String(page))
|
||||
params.set('page_size', '100')
|
||||
const data = await fetchWithRetry(`${baseUrl}?${params.toString()}`, headers)
|
||||
if (!data.success || !data.data?.items?.length) {
|
||||
hasMore = false
|
||||
} else {
|
||||
allLogs.push(...data.data.items)
|
||||
hasMore = data.data.items.length === 100
|
||||
page++
|
||||
}
|
||||
if (allLogs.length >= maxItems) hasMore = false
|
||||
if (hasMore && delayMs > 0) await new Promise(r => setTimeout(r, delayMs))
|
||||
}
|
||||
return allLogs
|
||||
}
|
||||
|
||||
// Helper: fetch fresh user info from new-api
|
||||
async function fetchUserInfo(siteUrl: string, accessToken: string, userId: string) {
|
||||
const res = await fetch(`${siteUrl}/api/user/self`, {
|
||||
headers: { 'Authorization': accessToken, 'New-Api-User': userId }
|
||||
})
|
||||
const data = await res.json() as any
|
||||
if (!data.success) throw new Error('获取用户信息失败')
|
||||
return data.data
|
||||
}
|
||||
|
||||
// POST /api/billing/export/pdf
|
||||
router.post('/export/pdf', sessionAuth, async (req: Request, res: Response) => {
|
||||
const session = req.session!
|
||||
const { startDate, endDate } = req.body
|
||||
const userInfo = JSON.parse(session.user_info)
|
||||
const site = db.prepare('SELECT name, url FROM sites WHERE id = ?').get(session.site_id) as any
|
||||
|
||||
try {
|
||||
// 实时从 new-api 获取最新用户信息
|
||||
const userInfo = await fetchUserInfo(session.site_url, session.access_token, String(session.user_id))
|
||||
const site = db.prepare('SELECT name, url FROM sites WHERE id = ?').get(session.site_id) as any
|
||||
const startTs = Math.floor(new Date(startDate).getTime() / 1000)
|
||||
const endTs = Math.floor(new Date(endDate).getTime() / 1000)
|
||||
|
||||
let allLogs: any[] = []
|
||||
let page = 1
|
||||
const pageSize = 100
|
||||
let hasMore = true
|
||||
|
||||
while (hasMore) {
|
||||
const logRes = await fetch(
|
||||
`${session.site_url}/api/log/self?start_timestamp=${startTs}&end_timestamp=${endTs}&type=2&p=${page}&page_size=${pageSize}`,
|
||||
{ headers: { 'Authorization': session.access_token } }
|
||||
)
|
||||
const logData = await logRes.json() as any
|
||||
if (!logData.success || !logData.data?.items?.length) {
|
||||
hasMore = false
|
||||
} else {
|
||||
allLogs = allLogs.concat(logData.data.items)
|
||||
hasMore = logData.data.items.length === pageSize
|
||||
page++
|
||||
}
|
||||
if (allLogs.length > 10000) hasMore = false
|
||||
}
|
||||
const apiHeaders = { 'Authorization': session.access_token, 'New-Api-User': String(session.user_id) }
|
||||
const logParams = new URLSearchParams({
|
||||
start_timestamp: String(startTs), end_timestamp: String(endTs), type: '2',
|
||||
})
|
||||
const allLogs = await paginateLogs(`${session.site_url}/api/log/self`, apiHeaders, logParams)
|
||||
|
||||
const modelMap = new Map<string, { quota: number; count: number }>()
|
||||
let totalQuota = 0
|
||||
@@ -52,6 +96,10 @@ router.post('/export/pdf', sessionAuth, async (req: Request, res: Response) => {
|
||||
.map(([model, data]) => ({ model, ...data }))
|
||||
.sort((a, b) => b.quota - a.quota)
|
||||
|
||||
// Fetch topup records
|
||||
const topupParams = new URLSearchParams()
|
||||
const allTopups = await paginateLogs(`${session.site_url}/api/user/topup/self`, apiHeaders, topupParams, 10000)
|
||||
|
||||
const pdfBuffer = await generateBillingPDF({
|
||||
siteName: site.name,
|
||||
siteUrl: site.url,
|
||||
@@ -63,11 +111,13 @@ router.post('/export/pdf', sessionAuth, async (req: Request, res: Response) => {
|
||||
totalQuota,
|
||||
totalRequests: allLogs.length,
|
||||
modelSummary,
|
||||
topups: allTopups,
|
||||
logs: allLogs
|
||||
})
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(site.name)}_billing_${startDate}_${endDate}.pdf"`)
|
||||
const ts = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(site.name)}_billing_${startDate}_${endDate}_${ts}.pdf"`)
|
||||
res.send(pdfBuffer)
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ success: false, message: `Failed to generate report: ${error.message}` })
|
||||
@@ -84,49 +134,141 @@ router.post('/export/csv', sessionAuth, async (req: Request, res: Response) => {
|
||||
const startTs = Math.floor(new Date(startDate).getTime() / 1000)
|
||||
const endTs = Math.floor(new Date(endDate).getTime() / 1000)
|
||||
|
||||
let allLogs: any[] = []
|
||||
let page = 1
|
||||
let hasMore = true
|
||||
|
||||
while (hasMore) {
|
||||
const logRes = await fetch(
|
||||
`${session.site_url}/api/log/self?start_timestamp=${startTs}&end_timestamp=${endTs}&type=2&p=${page}&page_size=100`,
|
||||
{ headers: { 'Authorization': session.access_token } }
|
||||
)
|
||||
const logData = await logRes.json() as any
|
||||
if (!logData.success || !logData.data?.items?.length) {
|
||||
hasMore = false
|
||||
} else {
|
||||
allLogs = allLogs.concat(logData.data.items)
|
||||
hasMore = logData.data.items.length === 100
|
||||
page++
|
||||
}
|
||||
if (allLogs.length > 10000) hasMore = false
|
||||
}
|
||||
const apiHeaders = { 'Authorization': session.access_token, 'New-Api-User': String(session.user_id) }
|
||||
const logParams = new URLSearchParams({
|
||||
start_timestamp: String(startTs), end_timestamp: String(endTs), type: '2',
|
||||
})
|
||||
const allLogs = await paginateLogs(`${session.site_url}/api/log/self`, apiHeaders, logParams)
|
||||
|
||||
const BOM = '\uFEFF'
|
||||
const headers = ['Time', 'Model', 'Token', 'Quota', 'Cost(USD)', 'Prompt Tokens', 'Completion Tokens', 'Request ID']
|
||||
const rows = allLogs.map(log => [
|
||||
new Date((log.created_at || 0) * 1000).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }),
|
||||
log.model_name || '',
|
||||
log.token_name || '',
|
||||
log.quota || 0,
|
||||
((log.quota || 0) / 500000).toFixed(6),
|
||||
log.prompt_tokens || 0,
|
||||
log.completion_tokens || 0,
|
||||
log.request_id || ''
|
||||
])
|
||||
const headers = ['时间', '令牌', '分组', '类型', '模型', '流式', '用时(s)', '首字(ms)', '输入', '缓存命中', '缓存创建', '输出', '花费(USD)', '额度', '请求ID']
|
||||
const rows = allLogs.map(log => {
|
||||
let other: any = {}
|
||||
try { other = typeof log.other === 'string' ? JSON.parse(log.other) : (log.other || {}) } catch {}
|
||||
const typeMap: Record<number, string> = { 1: '充值', 2: '消费', 3: '管理', 4: '系统' }
|
||||
return [
|
||||
new Date((log.created_at || 0) * 1000).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }),
|
||||
log.token_name || '',
|
||||
log.group || '',
|
||||
typeMap[log.type] || String(log.type || ''),
|
||||
log.model_name || '',
|
||||
log.is_stream ? '是' : '否',
|
||||
log.use_time || 0,
|
||||
other.frt ? Math.round(other.frt) : '',
|
||||
log.prompt_tokens || 0,
|
||||
other.cache_tokens || 0,
|
||||
other.cache_creation_tokens || 0,
|
||||
log.completion_tokens || 0,
|
||||
((log.quota || 0) / 500000).toFixed(6),
|
||||
log.quota || 0,
|
||||
log.request_id || '',
|
||||
]
|
||||
})
|
||||
|
||||
const csv = BOM + [headers, ...rows].map(row => row.map(cell => `"${cell}"`).join(',')).join('\n')
|
||||
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(site.name)}_billing_${startDate}_${endDate}.csv"`)
|
||||
const ts = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(site.name)}_billing_${startDate}_${endDate}_${ts}.csv"`)
|
||||
res.send(csv)
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ success: false, message: `Failed to generate CSV: ${error.message}` })
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/billing/stats — aggregate stats for date range
|
||||
router.post('/stats', sessionAuth, async (req: Request, res: Response) => {
|
||||
const session = req.session!
|
||||
const { startTimestamp, endTimestamp, type, modelName, tokenName, group, requestId } = req.body
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (startTimestamp) params.set('start_timestamp', String(startTimestamp))
|
||||
if (endTimestamp) params.set('end_timestamp', String(endTimestamp))
|
||||
if (type !== undefined && type !== '') params.set('type', String(type))
|
||||
if (modelName) params.set('model_name', modelName)
|
||||
if (tokenName) params.set('token_name', tokenName)
|
||||
if (group) params.set('group', group)
|
||||
if (requestId) params.set('request_id', requestId)
|
||||
|
||||
const headers = { 'Authorization': session.access_token, 'New-Api-User': String(session.user_id) }
|
||||
const allLogs = await paginateLogs(`${session.site_url}/api/log/self`, headers, params)
|
||||
|
||||
let totalQuota = 0
|
||||
let totalPromptTokens = 0
|
||||
let totalCompletionTokens = 0
|
||||
const modelSet = new Set<string>()
|
||||
for (const log of allLogs) {
|
||||
totalQuota += log.quota || 0
|
||||
totalPromptTokens += log.prompt_tokens || 0
|
||||
totalCompletionTokens += log.completion_tokens || 0
|
||||
if (log.model_name) modelSet.add(log.model_name)
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
totalRecords: allLogs.length,
|
||||
totalQuota,
|
||||
totalPromptTokens,
|
||||
totalCompletionTokens,
|
||||
totalTokens: totalPromptTokens + totalCompletionTokens,
|
||||
modelCount: modelSet.size,
|
||||
}
|
||||
})
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ success: false, message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/billing/chart-data — aggregated chart data for dashboard
|
||||
router.post('/chart-data', sessionAuth, async (req: Request, res: Response) => {
|
||||
const session = req.session!
|
||||
|
||||
try {
|
||||
const sevenDaysAgo = Math.floor((Date.now() - 7 * 24 * 60 * 60 * 1000) / 1000)
|
||||
const params = new URLSearchParams({ start_timestamp: String(sevenDaysAgo), type: '2' })
|
||||
const headers = { 'Authorization': session.access_token, 'New-Api-User': String(session.user_id) }
|
||||
const allLogs = await paginateLogs(`${session.site_url}/api/log/self`, headers, params)
|
||||
|
||||
// Daily aggregation (last 7 days)
|
||||
const dayMap = new Map<string, { count: number; quota: number }>()
|
||||
const now = new Date()
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const d = new Date(now)
|
||||
d.setDate(d.getDate() - i)
|
||||
const key = d.toISOString().split('T')[0]
|
||||
dayMap.set(key, { count: 0, quota: 0 })
|
||||
}
|
||||
for (const log of allLogs) {
|
||||
const date = new Date((log.created_at || 0) * 1000).toISOString().split('T')[0]
|
||||
if (dayMap.has(date)) {
|
||||
const day = dayMap.get(date)!
|
||||
day.count += 1
|
||||
day.quota += log.quota || 0
|
||||
}
|
||||
}
|
||||
const daily = Array.from(dayMap.entries()).map(([date, val]) => ({
|
||||
date, count: val.count, quota: val.quota,
|
||||
}))
|
||||
|
||||
// Model distribution (top 10)
|
||||
const modelMap = new Map<string, number>()
|
||||
for (const log of allLogs) {
|
||||
const model = log.model_name || 'unknown'
|
||||
modelMap.set(model, (modelMap.get(model) || 0) + (log.quota || 0))
|
||||
}
|
||||
const models = Array.from(modelMap.entries())
|
||||
.map(([name, value]) => ({ name, value }))
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.slice(0, 10)
|
||||
|
||||
res.json({ success: true, data: { daily, models, totalLogs: allLogs.length } })
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ success: false, message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/billing/verify — verify HMAC signature
|
||||
router.post('/verify', (req: Request, res: Response) => {
|
||||
const { userId, startDate, endDate, totalQuota, totalRecords, generatedAt, signature } = req.body
|
||||
|
||||
Reference in New Issue
Block a user