import PDFDocument from 'pdfkit' import crypto from 'crypto' import { getChineseFont } from './font.js' const HMAC_SECRET = process.env.HMAC_SECRET || 'newapi-dashboard-default-secret-key-change-in-production' const DASHBOARD_URL = process.env.DASHBOARD_URL || 'https://www.lamclod.cn' 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 }[] topups: { create_time: number amount: number money: number payment_method: string status: string trade_no: string }[] logs: { created_at: number type: number model_name: string token_name: string group: string is_stream: boolean quota: number prompt_tokens: number completion_tokens: number use_time: number ip: string request_id: string other: 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)) } function fmtTime(ts: number): string { return new Date(ts * 1000).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }) } function q2usd(q: number, d = 2): string { return (q / 500000).toFixed(d) } function parseOther(other: string | any): any { if (!other) return {} if (typeof other === 'object') return other try { return JSON.parse(other) } catch { return {} } } const BLUE = '#1a4b8c' const BLUE_LIGHT = '#e9eff8' const BLUE_MID = '#3a7bd5' const GRAY = '#666666' const GRAY_LIGHT = '#f6f7f9' const BLACK = '#1a1a1a' const MARGIN = 50 const PAGE_W = 595.28 // A4 const CONTENT_W = PAGE_W - MARGIN * 2 // Draw watermark on existing page via switchToPage (final pass only) function drawWatermarkOnPage(doc: PDFKit.PDFDocument, text: string) { doc.save() doc.opacity(0.03) doc.font(getChineseFont()).fontSize(9).fillColor('#999999') const label = `${text} ${text} ${text}` for (let row = 0; row < 6; row++) { doc.text(label, MARGIN, 120 + row * 100, { width: CONTENT_W, align: 'center', lineBreak: false }) } doc.restore() } function hline(doc: PDFKit.PDFDocument, y: number, color = '#dddddd') { doc.save().moveTo(MARGIN, y).lineTo(MARGIN + CONTENT_W, y).strokeColor(color).lineWidth(0.5).stroke().restore() } function rect(doc: PDFKit.PDFDocument, x: number, y: number, w: number, h: number, color: string) { doc.save().rect(x, y, w, h).fill(color).restore() } function footer(doc: PDFKit.PDFDocument, site: string) { hline(doc, 778, '#cccccc') doc.font(getChineseFont()).fontSize(6.5).fillColor('#aaaaaa') doc.text(`${site} - API 使用账单报告`, MARGIN, 782, { width: CONTENT_W * 0.6, lineBreak: false }) } export function generateBillingPDF(data: BillingReportData): Promise { return new Promise((resolve, reject) => { const doc = new PDFDocument({ size: 'A4', margin: MARGIN, bufferPages: true }) const chunks: Buffer[] = [] doc.on('data', (c: Buffer) => chunks.push(c)) doc.on('end', () => resolve(Buffer.concat(chunks))) doc.on('error', reject) // Register Chinese font as default doc.font(getChineseFont()) const now = new Date() const generatedAt = now.toISOString() const generatedLocal = now.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false }) const reportId = 'RPT-' + crypto.createHash('md5').update(`${data.userId}-${generatedAt}`).digest('hex').substring(0, 12).toUpperCase() const totalUsd = q2usd(data.totalQuota) const days = Math.max(1, Math.ceil((new Date(data.endDate).getTime() - new Date(data.startDate).getTime()) / 86400000)) // ===== PAGE 1: COVER ===== rect(doc, 0, 0, PAGE_W, 100, BLUE) doc.fontSize(22).fillColor('#ffffff').text(data.siteName, MARGIN, 28, { width: CONTENT_W, align: 'center' }) doc.fontSize(11).fillColor('#c0d0e8').text('API 使用账单报告', MARGIN, 58, { width: CONTENT_W, align: 'center' }) doc.fontSize(7.5).fillColor('#8fabc8').text(reportId, MARGIN, 78, { width: CONTENT_W, align: 'center' }) // Report info section let y = 120 doc.fontSize(12).fillColor(BLUE).text('报告信息', MARGIN, y) hline(doc, y + 18, BLUE) y += 28 const info: [string, string][] = [ ['报告周期', `${data.startDate} ~ ${data.endDate} (${days} 天)`], ['用户 ID', String(data.userId)], ['用户名', data.username], ['用户组', data.group], ['站点地址', data.siteUrl], ['生成时间', generatedLocal], ['报告编号', reportId], ] for (const [k, v] of info) { doc.fontSize(9).fillColor(GRAY).text(k, MARGIN + 10, y, { width: 100, lineBreak: false }) doc.fontSize(9).fillColor(BLACK).text(v, MARGIN + 120, y, { width: CONTENT_W - 130, lineBreak: false }) y += 18 } // Summary boxes y += 15 doc.fontSize(12).fillColor(BLUE).text('概览', MARGIN, y) hline(doc, y + 18, BLUE) y += 28 const boxW = (CONTENT_W - 20) / 3 const boxes = [ { title: '总消耗', val: `$ ${totalUsd}`, sub: `${data.totalQuota.toLocaleString()} 额度`, accent: BLUE }, { title: '总请求数', val: data.totalRequests.toLocaleString(), sub: `${data.modelSummary.length} 个模型`, accent: '#0a7c50' }, { title: '平均单价', val: `$ ${data.totalRequests > 0 ? q2usd(Math.round(data.totalQuota / data.totalRequests), 4) : '0'}`, sub: `共 ${days} 天`, accent: '#b45309' }, ] for (let i = 0; i < 3; i++) { const bx = MARGIN + i * (boxW + 10) rect(doc, bx, y, boxW, 60, GRAY_LIGHT) rect(doc, bx, y, 3, 60, boxes[i].accent) doc.fontSize(7.5).fillColor(GRAY).text(boxes[i].title, bx + 12, y + 8, { width: boxW - 20, lineBreak: false }) doc.fontSize(15).fillColor(boxes[i].accent).text(boxes[i].val, bx + 12, y + 22, { width: boxW - 20, lineBreak: false }) doc.fontSize(7).fillColor('#aaaaaa').text(boxes[i].sub, bx + 12, y + 44, { width: boxW - 20, lineBreak: false }) } // Disclaimer y += 80 hline(doc, y) y += 8 doc.fontSize(7).fillColor('#aaaaaa') doc.text('本报告由系统自动生成,包含 HMAC-SHA256 数字签名用于完整性验证。', MARGIN, y, { width: CONTENT_W }) doc.text(`验证接口: POST ${DASHBOARD_URL}/api/billing/verify`, MARGIN, y + 12, { width: CONTENT_W }) footer(doc, data.siteName) // ===== PAGE 2: FINANCIAL SUMMARY ===== doc.addPage() rect(doc, MARGIN, MARGIN, CONTENT_W, 25, BLUE) doc.fontSize(11).fillColor('#ffffff').text('财务汇总 - 按模型消耗统计', MARGIN + 12, MARGIN + 7) y = MARGIN + 40 // Total line rect(doc, MARGIN, y, CONTENT_W, 46, BLUE_LIGHT) doc.fontSize(9).fillColor(BLUE).text('总消耗:', MARGIN + 10, y + 6) doc.fontSize(14).fillColor(BLUE).text(`$ ${totalUsd} USD`, MARGIN + 10, y + 17, { continued: false }) doc.fontSize(7.5).fillColor(GRAY).text( `${data.totalQuota.toLocaleString()} 额度 | ${data.totalRequests.toLocaleString()} 次请求`, MARGIN + 10, y + 36, { width: CONTENT_W - 20, lineBreak: false } ) y += 56 // Model table header const mc = { n: MARGIN + 5, m: MARGIN + 35, req: MARGIN + 215, quota: MARGIN + 290, usd: MARGIN + 370, pct: MARGIN + 430 } rect(doc, MARGIN, y, CONTENT_W, 16, BLUE) doc.fontSize(7).fillColor('#ffffff') doc.text('#', mc.n, y + 4, { width: 25, lineBreak: false }) doc.text('模型', mc.m, y + 4, { width: 175, lineBreak: false }) doc.text('请求数', mc.req, y + 4, { width: 70, lineBreak: false }) doc.text('额度', mc.quota, y + 4, { width: 75, lineBreak: false }) doc.text('费用 (USD)', mc.usd, y + 4, { width: 55, lineBreak: false }) doc.text('占比', mc.pct, y + 4, { width: 60, lineBreak: false }) y += 18 for (let i = 0; i < data.modelSummary.length; i++) { if (y > 740) { footer(doc, data.siteName) doc.addPage() y = MARGIN + 10 } const m = data.modelSummary[i] const pct = data.totalQuota > 0 ? (m.quota / data.totalQuota * 100) : 0 if (i % 2 === 0) rect(doc, MARGIN, y - 1, CONTENT_W, 14, GRAY_LIGHT) doc.fontSize(6.5).fillColor(BLACK) doc.text(String(i + 1), mc.n, y + 2, { width: 25, lineBreak: false }) doc.text(m.model, mc.m, y + 2, { width: 175, lineBreak: false }) doc.text(m.count.toLocaleString(), mc.req, y + 2, { width: 70, lineBreak: false }) doc.text(m.quota.toLocaleString(), mc.quota, y + 2, { width: 75, lineBreak: false }) doc.text(q2usd(m.quota), mc.usd, y + 2, { width: 55, lineBreak: false }) const barW = 40 rect(doc, mc.pct, y + 1, barW, 8, '#e0e0e0') rect(doc, mc.pct, y + 1, Math.max(1, barW * pct / 100), 8, BLUE_MID) doc.fontSize(5.5).fillColor(GRAY).text(`${pct.toFixed(1)}%`, mc.pct + barW + 3, y + 2, { width: 25, lineBreak: false }) y += 14 } // Total row hline(doc, y, BLUE) y += 4 doc.fontSize(7).fillColor(BLUE) doc.text('合计', mc.m, y, { width: 175, lineBreak: false }) doc.text(data.totalRequests.toLocaleString(), mc.req, y, { width: 70, lineBreak: false }) doc.text(data.totalQuota.toLocaleString(), mc.quota, y, { width: 75, lineBreak: false }) doc.text(`$ ${totalUsd}`, mc.usd, y, { width: 55, lineBreak: false }) doc.text('100%', mc.pct, y, { width: 60, lineBreak: false }) footer(doc, data.siteName) // ===== TOPUP RECORDS ===== if (data.topups.length > 0) { doc.addPage() rect(doc, MARGIN, MARGIN, CONTENT_W, 25, BLUE) doc.fontSize(11).fillColor('#ffffff').text(`充值记录 (共 ${data.topups.length.toLocaleString()} 条)`, MARGIN + 12, MARGIN + 7) y = MARGIN + 35 const successTopups = data.topups.filter(t => t.status === 'success') const totalTopupAmount = successTopups.reduce((s, t) => s + (t.amount || 0), 0) const totalTopupMoney = successTopups.reduce((s, t) => s + (t.money || 0), 0) rect(doc, MARGIN, y, CONTENT_W, 22, BLUE_LIGHT) doc.fontSize(8).fillColor(BLUE).text( `充值成功: $ ${totalTopupAmount.toFixed(2)} USD | ¥ ${totalTopupMoney.toFixed(2)} CNY | ${successTopups.length} 笔交易`, MARGIN + 10, y + 6, { width: CONTENT_W - 20, lineBreak: false } ) y += 30 const tc = { time: MARGIN + 5, amount: MARGIN + 110, money: MARGIN + 185, method: MARGIN + 260, status: MARGIN + 320, trade: MARGIN + 360, } function drawTopupHeader() { rect(doc, MARGIN, y, CONTENT_W, 16, BLUE) doc.fontSize(7).fillColor('#ffffff') doc.text('时间', tc.time, y + 4, { width: 100, lineBreak: false }) doc.text('充值金额 (USD)', tc.amount, y + 4, { width: 70, lineBreak: false }) doc.text('支付金额 (CNY)', tc.money, y + 4, { width: 70, lineBreak: false }) doc.text('支付方式', tc.method, y + 4, { width: 55, lineBreak: false }) doc.text('状态', tc.status, y + 4, { width: 35, lineBreak: false }) doc.text('订单号', tc.trade, y + 4, { width: 135, lineBreak: false }) y += 18 } drawTopupHeader() for (let i = 0; i < data.topups.length; i++) { if (y > 760) { footer(doc, data.siteName) doc.addPage() y = MARGIN + 5 drawTopupHeader() } const t = data.topups[i] if (i % 2 === 0) rect(doc, MARGIN, y - 1, CONTENT_W, 13, GRAY_LIGHT) doc.fontSize(6.5).fillColor(BLACK) doc.text(fmtTime(t.create_time), tc.time, y + 2, { width: 100, lineBreak: false }) doc.text(`$ ${(t.amount || 0).toFixed(2)}`, tc.amount, y + 2, { width: 70, lineBreak: false }) doc.text(`¥ ${(t.money || 0).toFixed(2)}`, tc.money, y + 2, { width: 70, lineBreak: false }) doc.text(t.payment_method || '-', tc.method, y + 2, { width: 55, lineBreak: false }) const statusMap: Record = { success: '成功', pending: '待支付', expired: '已过期' } doc.text(statusMap[t.status] || t.status || '-', tc.status, y + 2, { width: 35, lineBreak: false }) doc.text(t.trade_no || '-', tc.trade, y + 2, { width: 135, lineBreak: false }) y += 13 } footer(doc, data.siteName) } // ===== DETAIL RECORDS ===== doc.addPage() rect(doc, MARGIN, MARGIN, CONTENT_W, 25, BLUE) doc.fontSize(11).fillColor('#ffffff').text(`调用明细 (共 ${data.logs.length.toLocaleString()} 条)`, MARGIN + 12, MARGIN + 7) y = MARGIN + 35 // Column layout: 时间 | 令牌 | 分组 | 类型 | 模型 | 流式 | 用时/首字 | 输入(命中/创建) | 输出 | 花费 | 详情 const typeMap: Record = { 1: '充值', 2: '消费', 3: '管理', 4: '系统' } const dc = [ { x: MARGIN + 2, w: 68, label: '时间' }, { x: MARGIN + 70, w: 34, label: '令牌' }, { x: MARGIN + 104, w: 28, label: '分组' }, { x: MARGIN + 132, w: 22, label: '类型' }, { x: MARGIN + 154, w: 68, label: '模型' }, { x: MARGIN + 222, w: 18, label: '流式' }, { x: MARGIN + 240, w: 40, label: '用时/首字' }, { x: MARGIN + 280, w: 58, label: '输入(命中/创建)' }, { x: MARGIN + 338, w: 28, label: '输出' }, { x: MARGIN + 366, w: 36, label: '花费' }, { x: MARGIN + 402, w: 93, label: '详情' }, ] function drawDetailHeader() { rect(doc, MARGIN, y, CONTENT_W, 14, BLUE) doc.fontSize(4.5).fillColor('#ffffff') for (const c of dc) doc.text(c.label, c.x, y + 3, { width: c.w, lineBreak: false }) y += 16 } drawDetailHeader() for (let i = 0; i < data.logs.length; i++) { if (y > 760) { footer(doc, data.siteName) doc.addPage() y = MARGIN + 5 drawDetailHeader() } const log = data.logs[i] const other = parseOther(log.other) const cacheHit = other.cache_tokens || 0 const cacheCreate = other.cache_creation_tokens || 0 const frt = other.frt || 0 if (i % 2 === 0) rect(doc, MARGIN, y - 1, CONTENT_W, 12, GRAY_LIGHT) doc.fontSize(4.5).fillColor(BLACK) doc.text(fmtTime(log.created_at), dc[0].x, y + 1, { width: dc[0].w, lineBreak: false }) doc.text(log.token_name || '-', dc[1].x, y + 1, { width: dc[1].w, lineBreak: false }) doc.text(log.group || '-', dc[2].x, y + 1, { width: dc[2].w, lineBreak: false }) doc.text(typeMap[log.type] || String(log.type || '-'), dc[3].x, y + 1, { width: dc[3].w, lineBreak: false }) doc.text(log.model_name || '-', dc[4].x, y + 1, { width: dc[4].w, lineBreak: false }) doc.text(log.is_stream ? '是' : '否', dc[5].x, y + 1, { width: dc[5].w, lineBreak: false }) const timing = `${log.use_time || 0}s${frt ? '/' + Math.round(frt) + 'ms' : ''}` doc.text(timing, dc[6].x, y + 1, { width: dc[6].w, lineBreak: false }) // 输入(命中/创建): e.g. "1234(500/100)" let inputStr = String(log.prompt_tokens || 0) if (cacheHit > 0 || cacheCreate > 0) { inputStr += `(${cacheHit}/${cacheCreate})` } doc.text(inputStr, dc[7].x, y + 1, { width: dc[7].w, lineBreak: false }) doc.text(String(log.completion_tokens || 0), dc[8].x, y + 1, { width: dc[8].w, lineBreak: false }) doc.text('$' + q2usd(log.quota || 0, 4), dc[9].x, y + 1, { width: dc[9].w, lineBreak: false }) doc.text(log.request_id || '-', dc[10].x, y + 1, { width: dc[10].w, lineBreak: false }) y += 12 } footer(doc, data.siteName) // ===== LAST PAGE: SIGNATURE ===== doc.addPage() rect(doc, MARGIN, MARGIN, CONTENT_W, 25, BLUE) doc.fontSize(11).fillColor('#ffffff').text('报告验证与数字签名', MARGIN + 12, MARGIN + 7) y = MARGIN + 40 const signature = generateHmacSignature({ userId: data.userId, startDate: data.startDate, endDate: data.endDate, totalQuota: data.totalQuota, totalRecords: data.logs.length, generatedAt, }) rect(doc, MARGIN, y, CONTENT_W, 130, GRAY_LIGHT) y += 10 const sigRows: [string, string][] = [ ['报告编号', reportId], ['生成时间', generatedLocal], ['记录总数', data.logs.length.toLocaleString()], ['总额度', `${data.totalQuota.toLocaleString()} ( $ ${totalUsd} USD )`], ['算法', 'HMAC-SHA256'], ['签名', signature], ] for (const [k, v] of sigRows) { doc.fontSize(8).fillColor(GRAY).text(k, MARGIN + 12, y, { width: 100, lineBreak: false }) const isSig = k === '签名' doc.fontSize(isSig ? 6.5 : 8).fillColor(isSig ? BLUE : BLACK) .text(v, MARGIN + 120, y, { width: CONTENT_W - 140, lineBreak: false }) y += 18 } y += 20 doc.fontSize(9).fillColor(BLUE).text('验证说明', MARGIN, y) hline(doc, y + 14, BLUE) y += 22 const instructions = [ '本报告由系统自动生成,包含数字签名用于完整性验证。', '验证方式: 将签名数据提交至 POST /api/billing/verify 接口。', '任何对报告数据的修改都将导致签名验证失败。', '本文件仅供财务审计用途。', ] for (let i = 0; i < instructions.length; i++) { doc.fontSize(7.5).fillColor(GRAY).text(`${i + 1}. ${instructions[i]}`, MARGIN + 5, y, { width: CONTENT_W - 10 }) y += 14 } y += 15 rect(doc, MARGIN, y, CONTENT_W, 25, '#fff7e0') rect(doc, MARGIN, y, 3, 25, '#b45309') doc.fontSize(7).fillColor('#b45309') doc.text(`由 ${data.siteName} Dashboard 生成 | ${DASHBOARD_URL} | ${generatedLocal}`, MARGIN + 12, y + 8, { width: CONTENT_W - 20, lineBreak: false }) footer(doc, data.siteName) // Final pass: draw watermarks + page numbers on all pages const total = doc.bufferedPageRange().count for (let i = 0; i < total; i++) { doc.switchToPage(i) drawWatermarkOnPage(doc, data.siteName) doc.font(getChineseFont()).fontSize(6.5).fillColor('#aaaaaa') doc.text(`${i + 1} / ${total}`, MARGIN + CONTENT_W - 50, 782, { width: 50, align: 'right', lineBreak: false }) } doc.end() }) }