为 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>
175 lines
5.5 KiB
TypeScript
175 lines
5.5 KiB
TypeScript
import PDFDocument from 'pdfkit'
|
|
import crypto from 'crypto'
|
|
|
|
const HMAC_SECRET = process.env.HMAC_SECRET || 'newapi-dashboard-default-secret-key-change-in-production'
|
|
|
|
interface BillingReportData {
|
|
siteName: string
|
|
siteUrl: string
|
|
userId: number
|
|
username: string
|
|
group: string
|
|
startDate: string
|
|
endDate: string
|
|
totalQuota: number
|
|
totalRequests: number
|
|
modelSummary: { model: string; quota: number; count: number }[]
|
|
logs: {
|
|
created_at: number
|
|
model_name: string
|
|
token_name: string
|
|
quota: number
|
|
prompt_tokens: number
|
|
completion_tokens: number
|
|
request_id: string
|
|
}[]
|
|
}
|
|
|
|
export function generateHmacSignature(data: {
|
|
userId: number
|
|
startDate: string
|
|
endDate: string
|
|
totalQuota: number
|
|
totalRecords: number
|
|
generatedAt: string
|
|
}): string {
|
|
const signStr = `${data.userId}|${data.startDate}|${data.endDate}|${data.totalQuota}|${data.totalRecords}|${data.generatedAt}`
|
|
return crypto.createHmac('sha256', HMAC_SECRET).update(signStr).digest('hex')
|
|
}
|
|
|
|
export function verifyHmacSignature(data: {
|
|
userId: number
|
|
startDate: string
|
|
endDate: string
|
|
totalQuota: number
|
|
totalRecords: number
|
|
generatedAt: string
|
|
signature: string
|
|
}): boolean {
|
|
const expected = generateHmacSignature(data)
|
|
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(data.signature))
|
|
}
|
|
|
|
export function generateBillingPDF(data: BillingReportData): Promise<Buffer> {
|
|
return new Promise((resolve, reject) => {
|
|
const doc = new PDFDocument({ size: 'A4', margin: 50 })
|
|
const chunks: Buffer[] = []
|
|
|
|
doc.on('data', (chunk: Buffer) => chunks.push(chunk))
|
|
doc.on('end', () => resolve(Buffer.concat(chunks)))
|
|
doc.on('error', reject)
|
|
|
|
const generatedAt = new Date().toISOString()
|
|
const reportId = crypto.createHash('md5')
|
|
.update(`${data.userId}-${generatedAt}`)
|
|
.digest('hex')
|
|
.substring(0, 12)
|
|
.toUpperCase()
|
|
|
|
// -- Watermark --
|
|
doc.save()
|
|
doc.opacity(0.06)
|
|
doc.fontSize(60)
|
|
doc.rotate(-45, { origin: [300, 400] })
|
|
doc.text(data.siteName, 100, 350, { width: 600 })
|
|
doc.restore()
|
|
|
|
// -- Header --
|
|
doc.fontSize(20).text(data.siteName, { align: 'center' })
|
|
doc.fontSize(14).text('API Usage Billing Report', { align: 'center' })
|
|
doc.moveDown(0.5)
|
|
doc.fontSize(9).fillColor('#666')
|
|
.text(`Report ID: RPT-${reportId}`, { align: 'center' })
|
|
doc.moveDown(1)
|
|
|
|
// -- Report Info --
|
|
doc.fillColor('#000').fontSize(10)
|
|
doc.text(`Period: ${data.startDate} ~ ${data.endDate}`)
|
|
doc.text(`User ID: ${data.userId} Username: ${data.username} Group: ${data.group}`)
|
|
doc.text(`Site: ${data.siteUrl}`)
|
|
doc.moveDown(1)
|
|
|
|
// -- Summary --
|
|
doc.fontSize(12).text('Summary', { underline: true })
|
|
doc.moveDown(0.3)
|
|
doc.fontSize(10)
|
|
doc.text(`Total Quota Consumed: ${(data.totalQuota / 500000).toFixed(4)} USD (${data.totalQuota} quota units)`)
|
|
doc.text(`Total Requests: ${data.totalRequests}`)
|
|
doc.moveDown(0.5)
|
|
|
|
// Model summary
|
|
if (data.modelSummary.length > 0) {
|
|
doc.fontSize(11).text('By Model:', { underline: true })
|
|
doc.moveDown(0.3)
|
|
doc.fontSize(9)
|
|
for (const m of data.modelSummary) {
|
|
doc.text(` ${m.model}: ${(m.quota / 500000).toFixed(4)} USD, ${m.count} requests`)
|
|
}
|
|
doc.moveDown(1)
|
|
}
|
|
|
|
// -- Detail Table --
|
|
doc.fontSize(12).text('Usage Details', { underline: true })
|
|
doc.moveDown(0.3)
|
|
|
|
const tableTop = doc.y
|
|
const col = [50, 150, 280, 370, 460]
|
|
doc.fontSize(8).fillColor('#333')
|
|
doc.text('Time', col[0], tableTop)
|
|
doc.text('Model', col[1], tableTop)
|
|
doc.text('Token', col[2], tableTop)
|
|
doc.text('Cost (USD)', col[3], tableTop)
|
|
doc.text('Request ID', col[4], tableTop)
|
|
|
|
doc.moveTo(50, tableTop + 12).lineTo(560, tableTop + 12).stroke('#ccc')
|
|
|
|
let y = tableTop + 16
|
|
const maxRows = Math.min(data.logs.length, 200)
|
|
for (let i = 0; i < maxRows; i++) {
|
|
if (y > 750) {
|
|
doc.addPage()
|
|
y = 50
|
|
}
|
|
const log = data.logs[i]
|
|
const time = new Date(log.created_at * 1000).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })
|
|
doc.fontSize(7).fillColor('#000')
|
|
doc.text(time, col[0], y, { width: 95 })
|
|
doc.text(log.model_name || '-', col[1], y, { width: 125 })
|
|
doc.text(log.token_name || '-', col[2], y, { width: 85 })
|
|
doc.text((log.quota / 500000).toFixed(6), col[3], y, { width: 85 })
|
|
doc.text(log.request_id?.substring(0, 12) || '-', col[4], y, { width: 100 })
|
|
y += 14
|
|
}
|
|
|
|
if (data.logs.length > maxRows) {
|
|
doc.moveDown(0.5)
|
|
doc.fontSize(8).fillColor('#999')
|
|
.text(`... Total ${data.logs.length} records, showing first ${maxRows}`)
|
|
}
|
|
|
|
// -- HMAC Signature --
|
|
const signature = generateHmacSignature({
|
|
userId: data.userId,
|
|
startDate: data.startDate,
|
|
endDate: data.endDate,
|
|
totalQuota: data.totalQuota,
|
|
totalRecords: data.logs.length,
|
|
generatedAt
|
|
})
|
|
|
|
doc.addPage()
|
|
doc.fontSize(10).fillColor('#000').text('Report Verification', { underline: true })
|
|
doc.moveDown(0.3)
|
|
doc.fontSize(8).fillColor('#333')
|
|
doc.text(`Generated At: ${generatedAt}`)
|
|
doc.text(`Total Records: ${data.logs.length}`)
|
|
doc.text(`HMAC-SHA256 Signature: ${signature}`)
|
|
doc.moveDown(0.5)
|
|
doc.fontSize(7).fillColor('#999')
|
|
doc.text('This report is system-generated. The signature can be verified through the system verification API.')
|
|
doc.text('Verification endpoint: POST /api/billing/verify')
|
|
|
|
doc.end()
|
|
})
|
|
}
|