feat: newapi-dashboard 全栈项目初始化
为 new-api (LLM API 网关) 构建独立前端管理面板: - React 18 + TypeScript + Vite + Ant Design 5 前端 - Node.js + Express + better-sqlite3 BFF 后端 - 登录页: 站点选择器 + UserID + AccessToken 认证 - 仪表盘: 用户信息、额度环形图、7天趋势图、模型饼图、令牌概览、日志时间线 - 账单页: 筛选日志表格、模型消耗柱状图、充值记录、CSV/PDF 导出(HMAC签名) - 管理员站点管理: 站点 CRUD - API 代理: 多站点切换,会话管理 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
141
server/routes/billing.ts
Normal file
141
server/routes/billing.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
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()
|
||||
|
||||
// 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 {
|
||||
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 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)
|
||||
|
||||
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,
|
||||
logs: allLogs
|
||||
})
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(site.name)}_billing_${startDate}_${endDate}.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)
|
||||
|
||||
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 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 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"`)
|
||||
res.send(csv)
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ success: false, message: `Failed to generate CSV: ${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
|
||||
Reference in New Issue
Block a user