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