284 lines
11 KiB
TypeScript
284 lines
11 KiB
TypeScript
import { Router, Request, Response } from 'express'
|
|
import { sessionAuth } from '../middleware/auth.js'
|
|
import { generateBillingPDF, verifyHmacSignature } from '../utils/pdf.js'
|
|
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
|
|
|
|
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)
|
|
|
|
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
|
|
for (const log of allLogs) {
|
|
totalQuota += log.quota || 0
|
|
const model = log.model_name || 'unknown'
|
|
const existing = modelMap.get(model) || { quota: 0, count: 0 }
|
|
existing.quota += log.quota || 0
|
|
existing.count += 1
|
|
modelMap.set(model, existing)
|
|
}
|
|
|
|
const modelSummary = Array.from(modelMap.entries())
|
|
.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,
|
|
userId: userInfo.id,
|
|
username: userInfo.username,
|
|
group: userInfo.group || 'default',
|
|
startDate,
|
|
endDate,
|
|
totalQuota,
|
|
totalRequests: allLogs.length,
|
|
modelSummary,
|
|
topups: allTopups,
|
|
logs: allLogs
|
|
})
|
|
|
|
res.setHeader('Content-Type', 'application/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}` })
|
|
}
|
|
})
|
|
|
|
// POST /api/billing/export/csv
|
|
router.post('/export/csv', sessionAuth, async (req: Request, res: Response) => {
|
|
const session = req.session!
|
|
const { startDate, endDate } = req.body
|
|
const site = db.prepare('SELECT name FROM sites WHERE id = ?').get(session.site_id) as any
|
|
|
|
try {
|
|
const startTs = Math.floor(new Date(startDate).getTime() / 1000)
|
|
const endTs = Math.floor(new Date(endDate).getTime() / 1000)
|
|
|
|
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 = ['时间', '令牌', '分组', '类型', '模型', '流式', '用时(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')
|
|
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
|
|
try {
|
|
const valid = verifyHmacSignature({ userId, startDate, endDate, totalQuota, totalRecords, generatedAt, signature })
|
|
res.json({ success: true, data: { valid } })
|
|
} catch {
|
|
res.json({ success: true, data: { valid: false } })
|
|
}
|
|
})
|
|
|
|
export default router
|