Files
newapi-dashboard/server/utils/pdf.ts
LAMCLOD f6036cab66 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>
2026-03-08 18:00:28 +08:00

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()
})
}