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, maxRetries = 3): Promise { 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, params: URLSearchParams, maxItems = 50000, delayMs = 200, ): Promise { 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() 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 = { 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() 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() 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() 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