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:
37
server/db.ts
Normal file
37
server/db.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import fs from 'fs'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const DB_PATH = path.join(__dirname, '..', 'data', 'dashboard.db')
|
||||
|
||||
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true })
|
||||
|
||||
const db = new Database(DB_PATH)
|
||||
db.pragma('journal_mode = WAL')
|
||||
db.pragma('foreign_keys = ON')
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
access_token TEXT NOT NULL,
|
||||
site_id INTEGER NOT NULL,
|
||||
site_url TEXT NOT NULL,
|
||||
user_info TEXT DEFAULT '{}',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
expires_at TEXT NOT NULL,
|
||||
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
|
||||
export default db
|
||||
27
server/index.ts
Normal file
27
server/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import sitesRouter from './routes/sites.js'
|
||||
import authRouter from './routes/auth.js'
|
||||
import proxyRouter from './routes/proxy.js'
|
||||
import billingRouter from './routes/billing.js'
|
||||
|
||||
const app = express()
|
||||
const PORT = 3001
|
||||
|
||||
app.use(cors())
|
||||
app.use(express.json())
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ success: true, message: 'NewAPI Dashboard BFF running' })
|
||||
})
|
||||
|
||||
app.use('/api/sites', sitesRouter)
|
||||
app.use('/api/auth', authRouter)
|
||||
app.use('/proxy', proxyRouter)
|
||||
app.use('/api/billing', billingRouter)
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`BFF server running on http://localhost:${PORT}`)
|
||||
})
|
||||
|
||||
export default app
|
||||
51
server/middleware/auth.ts
Normal file
51
server/middleware/auth.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import db from '../db.js'
|
||||
|
||||
export interface SessionData {
|
||||
id: string
|
||||
user_id: number
|
||||
access_token: string
|
||||
site_id: number
|
||||
site_url: string
|
||||
user_info: string
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
session?: SessionData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionAuth(req: Request, res: Response, next: NextFunction) {
|
||||
const token = req.headers['x-session-token'] as string
|
||||
if (!token) {
|
||||
res.status(401).json({ success: false, message: '未登录' })
|
||||
return
|
||||
}
|
||||
|
||||
const session = db.prepare(
|
||||
"SELECT * FROM sessions WHERE id = ? AND expires_at > datetime('now')"
|
||||
).get(token) as SessionData | undefined
|
||||
|
||||
if (!session) {
|
||||
res.status(401).json({ success: false, message: '会话已过期,请重新登录' })
|
||||
return
|
||||
}
|
||||
|
||||
req.session = session
|
||||
next()
|
||||
}
|
||||
|
||||
export function adminAuth(req: Request, res: Response, next: NextFunction) {
|
||||
sessionAuth(req, res, () => {
|
||||
if (!req.session) return
|
||||
const userInfo = JSON.parse(req.session.user_info || '{}')
|
||||
if (userInfo.role < 10) {
|
||||
res.status(403).json({ success: false, message: '需要管理员权限' })
|
||||
return
|
||||
}
|
||||
next()
|
||||
})
|
||||
}
|
||||
78
server/routes/auth.ts
Normal file
78
server/routes/auth.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Router, Request, Response } from 'express'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import db from '../db.js'
|
||||
import { sessionAuth } from '../middleware/auth.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// POST /api/auth/login
|
||||
router.post('/login', async (req: Request, res: Response) => {
|
||||
const { userId, accessToken, siteId } = req.body
|
||||
|
||||
if (!userId || !accessToken || !siteId) {
|
||||
res.json({ success: false, message: '请填写完整信息' })
|
||||
return
|
||||
}
|
||||
|
||||
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(siteId) as any
|
||||
if (!site) {
|
||||
res.json({ success: false, message: '站点不存在' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${site.url}/api/user/self`, {
|
||||
headers: { 'Authorization': accessToken }
|
||||
})
|
||||
const result = await response.json() as any
|
||||
|
||||
if (!result.success) {
|
||||
res.json({ success: false, message: result.message || '认证失败,请检查用户ID和令牌' })
|
||||
return
|
||||
}
|
||||
|
||||
const userInfo = result.data
|
||||
if (userInfo.id !== Number(userId)) {
|
||||
res.json({ success: false, message: '用户ID与令牌不匹配' })
|
||||
return
|
||||
}
|
||||
|
||||
const sessionId = uuidv4()
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
|
||||
|
||||
db.prepare(
|
||||
'INSERT INTO sessions (id, user_id, access_token, site_id, site_url, user_info, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(sessionId, userId, accessToken, siteId, site.url, JSON.stringify(userInfo), expiresAt)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
sessionToken: sessionId,
|
||||
userInfo,
|
||||
site: { id: site.id, name: site.name, url: site.url }
|
||||
}
|
||||
})
|
||||
} catch (error: any) {
|
||||
res.json({ success: false, message: `无法连接站点: ${error.message}` })
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/auth/logout
|
||||
router.post('/logout', sessionAuth, (req: Request, res: Response) => {
|
||||
db.prepare('DELETE FROM sessions WHERE id = ?').run(req.session!.id)
|
||||
res.json({ success: true })
|
||||
})
|
||||
|
||||
// GET /api/auth/me
|
||||
router.get('/me', sessionAuth, (req: Request, res: Response) => {
|
||||
const site = db.prepare('SELECT id, name, url FROM sites WHERE id = ?').get(req.session!.site_id)
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
userInfo: JSON.parse(req.session!.user_info),
|
||||
site
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
export default router
|
||||
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
|
||||
49
server/routes/proxy.ts
Normal file
49
server/routes/proxy.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Router, Request, Response } from 'express'
|
||||
import { sessionAuth } from '../middleware/auth.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.all('/*', sessionAuth, async (req: Request, res: Response) => {
|
||||
const session = req.session!
|
||||
const targetPath = req.originalUrl.replace(/^\/proxy/, '')
|
||||
const targetUrl = `${session.site_url}${targetPath}`
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Authorization': session.access_token,
|
||||
'Content-Type': req.headers['content-type'] || 'application/json'
|
||||
}
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method: req.method,
|
||||
headers
|
||||
}
|
||||
|
||||
if (['POST', 'PUT', 'PATCH'].includes(req.method) && req.body) {
|
||||
fetchOptions.body = JSON.stringify(req.body)
|
||||
}
|
||||
|
||||
const response = await fetch(targetUrl, fetchOptions)
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
|
||||
res.status(response.status)
|
||||
|
||||
const forwardHeaders = ['content-type', 'x-request-id']
|
||||
for (const h of forwardHeaders) {
|
||||
const val = response.headers.get(h)
|
||||
if (val) res.setHeader(h, val)
|
||||
}
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
const data = await response.json()
|
||||
res.json(data)
|
||||
} else {
|
||||
const buffer = await response.arrayBuffer()
|
||||
res.send(Buffer.from(buffer))
|
||||
}
|
||||
} catch (error: any) {
|
||||
res.status(502).json({ success: false, message: `代理请求失败: ${error.message}` })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
41
server/routes/sites.ts
Normal file
41
server/routes/sites.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Router, Request, Response } from 'express'
|
||||
import db from '../db.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// GET /api/sites — list all sites (public, for login page)
|
||||
router.get('/', (_req: Request, res: Response) => {
|
||||
const sites = db.prepare('SELECT id, name, url, created_at, updated_at FROM sites ORDER BY id').all()
|
||||
res.json({ success: true, data: sites })
|
||||
})
|
||||
|
||||
// POST /api/sites — create site
|
||||
router.post('/', (req: Request, res: Response) => {
|
||||
const { name, url } = req.body
|
||||
if (!name || !url) {
|
||||
res.json({ success: false, message: '站点名称和 URL 不能为空' })
|
||||
return
|
||||
}
|
||||
const result = db.prepare('INSERT INTO sites (name, url) VALUES (?, ?)').run(name, url.replace(/\/+$/, ''))
|
||||
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(result.lastInsertRowid)
|
||||
res.json({ success: true, data: site })
|
||||
})
|
||||
|
||||
// PUT /api/sites/:id — update site
|
||||
router.put('/:id', (req: Request, res: Response) => {
|
||||
const { name, url } = req.body
|
||||
const { id } = req.params
|
||||
db.prepare("UPDATE sites SET name = ?, url = ?, updated_at = datetime('now') WHERE id = ?")
|
||||
.run(name, url.replace(/\/+$/, ''), id)
|
||||
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(id)
|
||||
res.json({ success: true, data: site })
|
||||
})
|
||||
|
||||
// DELETE /api/sites/:id — delete site
|
||||
router.delete('/:id', (req: Request, res: Response) => {
|
||||
const { id } = req.params
|
||||
db.prepare('DELETE FROM sites WHERE id = ?').run(id)
|
||||
res.json({ success: true })
|
||||
})
|
||||
|
||||
export default router
|
||||
174
server/utils/pdf.ts
Normal file
174
server/utils/pdf.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
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()
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user