feat: 完善全栈 Dashboard 项目 - UI优化、Docker支持、账单系统等
This commit is contained in:
@@ -28,10 +28,18 @@ db.exec(`
|
||||
site_id INTEGER NOT NULL,
|
||||
site_url TEXT NOT NULL,
|
||||
user_info TEXT DEFAULT '{}',
|
||||
is_admin INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
expires_at TEXT NOT NULL,
|
||||
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
|
||||
// Migration: add is_admin column if missing
|
||||
try {
|
||||
db.exec(`ALTER TABLE sessions ADD COLUMN is_admin INTEGER DEFAULT 0`)
|
||||
} catch {
|
||||
// column already exists
|
||||
}
|
||||
|
||||
export default db
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import fs from 'fs'
|
||||
import sitesRouter from './routes/sites.js'
|
||||
import authRouter from './routes/auth.js'
|
||||
import proxyRouter from './routes/proxy.js'
|
||||
import billingRouter from './routes/billing.js'
|
||||
import { ensureChineseFont } from './utils/font.js'
|
||||
|
||||
const app = express()
|
||||
const PORT = 3001
|
||||
const PORT = Number(process.env.PORT) || 3001
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
app.use(cors())
|
||||
app.use(express.json())
|
||||
|
||||
// API routes
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ success: true, message: 'NewAPI Dashboard BFF running' })
|
||||
})
|
||||
@@ -20,8 +26,26 @@ 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}`)
|
||||
})
|
||||
// Production: serve frontend static files from dist/
|
||||
const distPath = path.join(__dirname, '..', 'dist')
|
||||
if (fs.existsSync(path.join(distPath, 'index.html'))) {
|
||||
app.use(express.static(distPath))
|
||||
// SPA fallback: any non-API GET request returns index.html
|
||||
app.get('*', (_req, res) => {
|
||||
res.sendFile(path.join(distPath, 'index.html'))
|
||||
})
|
||||
}
|
||||
|
||||
export default app
|
||||
// Download Chinese font then start server
|
||||
ensureChineseFont()
|
||||
.then(() => {
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`BFF server running on http://0.0.0.0:${PORT}`)
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Font initialization failed:', err.message)
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`BFF server running on http://0.0.0.0:${PORT} (font unavailable)`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -41,9 +41,8 @@ export function sessionAuth(req: Request, res: Response, next: NextFunction) {
|
||||
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: '需要管理员权限' })
|
||||
if (!(req.session as any).is_admin) {
|
||||
res.status(403).json({ success: false, message: '需要 Dashboard 管理员权限,请先升格' })
|
||||
return
|
||||
}
|
||||
next()
|
||||
|
||||
@@ -5,6 +5,8 @@ import { sessionAuth } from '../middleware/auth.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'newapi-admin'
|
||||
|
||||
// POST /api/auth/login
|
||||
router.post('/login', async (req: Request, res: Response) => {
|
||||
const { userId, accessToken, siteId } = req.body
|
||||
@@ -22,7 +24,10 @@ router.post('/login', async (req: Request, res: Response) => {
|
||||
|
||||
try {
|
||||
const response = await fetch(`${site.url}/api/user/self`, {
|
||||
headers: { 'Authorization': accessToken }
|
||||
headers: {
|
||||
'Authorization': accessToken,
|
||||
'New-Api-User': String(userId),
|
||||
}
|
||||
})
|
||||
const result = await response.json() as any
|
||||
|
||||
@@ -70,9 +75,27 @@ router.get('/me', sessionAuth, (req: Request, res: Response) => {
|
||||
success: true,
|
||||
data: {
|
||||
userInfo: JSON.parse(req.session!.user_info),
|
||||
site
|
||||
site,
|
||||
isAdmin: !!(req.session as any).is_admin
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// POST /api/auth/elevate — promote to dashboard admin
|
||||
router.post('/elevate', sessionAuth, (req: Request, res: Response) => {
|
||||
const { password } = req.body
|
||||
if (password !== ADMIN_PASSWORD) {
|
||||
res.json({ success: false, message: '管理密码错误' })
|
||||
return
|
||||
}
|
||||
db.prepare('UPDATE sessions SET is_admin = 1 WHERE id = ?').run(req.session!.id)
|
||||
res.json({ success: true, message: '已升格为管理员' })
|
||||
})
|
||||
|
||||
// POST /api/auth/demote — revoke dashboard admin
|
||||
router.post('/demote', sessionAuth, (req: Request, res: Response) => {
|
||||
db.prepare('UPDATE sessions SET is_admin = 0 WHERE id = ?').run(req.session!.id)
|
||||
res.json({ success: true, message: '已取消管理员权限' })
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -5,37 +5,81 @@ import db from '../db.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Helper: fetch with rate-limit retry
|
||||
async function fetchWithRetry(url: string, headers: Record<string, string>, maxRetries = 3): Promise<any> {
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
const res = await fetch(url, { headers })
|
||||
if (res.status === 429) {
|
||||
if (attempt < maxRetries) {
|
||||
const retryAfter = Number(res.headers.get('retry-after')) || (2 ** attempt)
|
||||
await new Promise(r => setTimeout(r, retryAfter * 1000))
|
||||
continue
|
||||
}
|
||||
return { success: false, message: 'Rate limited' }
|
||||
}
|
||||
if (!res.ok) {
|
||||
return { success: false, message: `HTTP ${res.status}` }
|
||||
}
|
||||
return await res.json()
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: paginate logs with rate-limit awareness
|
||||
async function paginateLogs(
|
||||
baseUrl: string,
|
||||
headers: Record<string, string>,
|
||||
params: URLSearchParams,
|
||||
maxItems = 50000,
|
||||
delayMs = 200,
|
||||
): Promise<any[]> {
|
||||
const allLogs: any[] = []
|
||||
let page = 1
|
||||
let hasMore = true
|
||||
|
||||
while (hasMore) {
|
||||
params.set('p', String(page))
|
||||
params.set('page_size', '100')
|
||||
const data = await fetchWithRetry(`${baseUrl}?${params.toString()}`, headers)
|
||||
if (!data.success || !data.data?.items?.length) {
|
||||
hasMore = false
|
||||
} else {
|
||||
allLogs.push(...data.data.items)
|
||||
hasMore = data.data.items.length === 100
|
||||
page++
|
||||
}
|
||||
if (allLogs.length >= maxItems) hasMore = false
|
||||
if (hasMore && delayMs > 0) await new Promise(r => setTimeout(r, delayMs))
|
||||
}
|
||||
return allLogs
|
||||
}
|
||||
|
||||
// Helper: fetch fresh user info from new-api
|
||||
async function fetchUserInfo(siteUrl: string, accessToken: string, userId: string) {
|
||||
const res = await fetch(`${siteUrl}/api/user/self`, {
|
||||
headers: { 'Authorization': accessToken, 'New-Api-User': userId }
|
||||
})
|
||||
const data = await res.json() as any
|
||||
if (!data.success) throw new Error('获取用户信息失败')
|
||||
return data.data
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 实时从 new-api 获取最新用户信息
|
||||
const userInfo = await fetchUserInfo(session.site_url, session.access_token, String(session.user_id))
|
||||
const site = db.prepare('SELECT name, url FROM sites WHERE id = ?').get(session.site_id) as any
|
||||
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 apiHeaders = { 'Authorization': session.access_token, 'New-Api-User': String(session.user_id) }
|
||||
const logParams = new URLSearchParams({
|
||||
start_timestamp: String(startTs), end_timestamp: String(endTs), type: '2',
|
||||
})
|
||||
const allLogs = await paginateLogs(`${session.site_url}/api/log/self`, apiHeaders, logParams)
|
||||
|
||||
const modelMap = new Map<string, { quota: number; count: number }>()
|
||||
let totalQuota = 0
|
||||
@@ -52,6 +96,10 @@ router.post('/export/pdf', sessionAuth, async (req: Request, res: Response) => {
|
||||
.map(([model, data]) => ({ model, ...data }))
|
||||
.sort((a, b) => b.quota - a.quota)
|
||||
|
||||
// Fetch topup records
|
||||
const topupParams = new URLSearchParams()
|
||||
const allTopups = await paginateLogs(`${session.site_url}/api/user/topup/self`, apiHeaders, topupParams, 10000)
|
||||
|
||||
const pdfBuffer = await generateBillingPDF({
|
||||
siteName: site.name,
|
||||
siteUrl: site.url,
|
||||
@@ -63,11 +111,13 @@ router.post('/export/pdf', sessionAuth, async (req: Request, res: Response) => {
|
||||
totalQuota,
|
||||
totalRequests: allLogs.length,
|
||||
modelSummary,
|
||||
topups: allTopups,
|
||||
logs: allLogs
|
||||
})
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(site.name)}_billing_${startDate}_${endDate}.pdf"`)
|
||||
const ts = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(site.name)}_billing_${startDate}_${endDate}_${ts}.pdf"`)
|
||||
res.send(pdfBuffer)
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ success: false, message: `Failed to generate report: ${error.message}` })
|
||||
@@ -84,49 +134,141 @@ router.post('/export/csv', sessionAuth, async (req: Request, res: Response) => {
|
||||
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 apiHeaders = { 'Authorization': session.access_token, 'New-Api-User': String(session.user_id) }
|
||||
const logParams = new URLSearchParams({
|
||||
start_timestamp: String(startTs), end_timestamp: String(endTs), type: '2',
|
||||
})
|
||||
const allLogs = await paginateLogs(`${session.site_url}/api/log/self`, apiHeaders, logParams)
|
||||
|
||||
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 headers = ['时间', '令牌', '分组', '类型', '模型', '流式', '用时(s)', '首字(ms)', '输入', '缓存命中', '缓存创建', '输出', '花费(USD)', '额度', '请求ID']
|
||||
const rows = allLogs.map(log => {
|
||||
let other: any = {}
|
||||
try { other = typeof log.other === 'string' ? JSON.parse(log.other) : (log.other || {}) } catch {}
|
||||
const typeMap: Record<number, string> = { 1: '充值', 2: '消费', 3: '管理', 4: '系统' }
|
||||
return [
|
||||
new Date((log.created_at || 0) * 1000).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }),
|
||||
log.token_name || '',
|
||||
log.group || '',
|
||||
typeMap[log.type] || String(log.type || ''),
|
||||
log.model_name || '',
|
||||
log.is_stream ? '是' : '否',
|
||||
log.use_time || 0,
|
||||
other.frt ? Math.round(other.frt) : '',
|
||||
log.prompt_tokens || 0,
|
||||
other.cache_tokens || 0,
|
||||
other.cache_creation_tokens || 0,
|
||||
log.completion_tokens || 0,
|
||||
((log.quota || 0) / 500000).toFixed(6),
|
||||
log.quota || 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"`)
|
||||
const ts = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(site.name)}_billing_${startDate}_${endDate}_${ts}.csv"`)
|
||||
res.send(csv)
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ success: false, message: `Failed to generate CSV: ${error.message}` })
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/billing/stats — aggregate stats for date range
|
||||
router.post('/stats', sessionAuth, async (req: Request, res: Response) => {
|
||||
const session = req.session!
|
||||
const { startTimestamp, endTimestamp, type, modelName, tokenName, group, requestId } = req.body
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (startTimestamp) params.set('start_timestamp', String(startTimestamp))
|
||||
if (endTimestamp) params.set('end_timestamp', String(endTimestamp))
|
||||
if (type !== undefined && type !== '') params.set('type', String(type))
|
||||
if (modelName) params.set('model_name', modelName)
|
||||
if (tokenName) params.set('token_name', tokenName)
|
||||
if (group) params.set('group', group)
|
||||
if (requestId) params.set('request_id', requestId)
|
||||
|
||||
const headers = { 'Authorization': session.access_token, 'New-Api-User': String(session.user_id) }
|
||||
const allLogs = await paginateLogs(`${session.site_url}/api/log/self`, headers, params)
|
||||
|
||||
let totalQuota = 0
|
||||
let totalPromptTokens = 0
|
||||
let totalCompletionTokens = 0
|
||||
const modelSet = new Set<string>()
|
||||
for (const log of allLogs) {
|
||||
totalQuota += log.quota || 0
|
||||
totalPromptTokens += log.prompt_tokens || 0
|
||||
totalCompletionTokens += log.completion_tokens || 0
|
||||
if (log.model_name) modelSet.add(log.model_name)
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
totalRecords: allLogs.length,
|
||||
totalQuota,
|
||||
totalPromptTokens,
|
||||
totalCompletionTokens,
|
||||
totalTokens: totalPromptTokens + totalCompletionTokens,
|
||||
modelCount: modelSet.size,
|
||||
}
|
||||
})
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ success: false, message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/billing/chart-data — aggregated chart data for dashboard
|
||||
router.post('/chart-data', sessionAuth, async (req: Request, res: Response) => {
|
||||
const session = req.session!
|
||||
|
||||
try {
|
||||
const sevenDaysAgo = Math.floor((Date.now() - 7 * 24 * 60 * 60 * 1000) / 1000)
|
||||
const params = new URLSearchParams({ start_timestamp: String(sevenDaysAgo), type: '2' })
|
||||
const headers = { 'Authorization': session.access_token, 'New-Api-User': String(session.user_id) }
|
||||
const allLogs = await paginateLogs(`${session.site_url}/api/log/self`, headers, params)
|
||||
|
||||
// Daily aggregation (last 7 days)
|
||||
const dayMap = new Map<string, { count: number; quota: number }>()
|
||||
const now = new Date()
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const d = new Date(now)
|
||||
d.setDate(d.getDate() - i)
|
||||
const key = d.toISOString().split('T')[0]
|
||||
dayMap.set(key, { count: 0, quota: 0 })
|
||||
}
|
||||
for (const log of allLogs) {
|
||||
const date = new Date((log.created_at || 0) * 1000).toISOString().split('T')[0]
|
||||
if (dayMap.has(date)) {
|
||||
const day = dayMap.get(date)!
|
||||
day.count += 1
|
||||
day.quota += log.quota || 0
|
||||
}
|
||||
}
|
||||
const daily = Array.from(dayMap.entries()).map(([date, val]) => ({
|
||||
date, count: val.count, quota: val.quota,
|
||||
}))
|
||||
|
||||
// Model distribution (top 10)
|
||||
const modelMap = new Map<string, number>()
|
||||
for (const log of allLogs) {
|
||||
const model = log.model_name || 'unknown'
|
||||
modelMap.set(model, (modelMap.get(model) || 0) + (log.quota || 0))
|
||||
}
|
||||
const models = Array.from(modelMap.entries())
|
||||
.map(([name, value]) => ({ name, value }))
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.slice(0, 10)
|
||||
|
||||
res.json({ success: true, data: { daily, models, totalLogs: allLogs.length } })
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ success: false, message: 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
|
||||
|
||||
@@ -11,6 +11,7 @@ router.all('/*', sessionAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Authorization': session.access_token,
|
||||
'New-Api-User': String(session.user_id),
|
||||
'Content-Type': req.headers['content-type'] || 'application/json'
|
||||
}
|
||||
|
||||
|
||||
66
server/utils/font.ts
Normal file
66
server/utils/font.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const FONTS_DIR = path.join(__dirname, '..', 'fonts')
|
||||
const FONT_FILE = path.join(FONTS_DIR, 'NotoSansSC-Regular.otf')
|
||||
|
||||
const CDN_URLS = [
|
||||
'https://cdn.jsdelivr.net/gh/googlefonts/noto-cjk@main/Sans/SubsetOTF/SC/NotoSansSC-Regular.otf',
|
||||
'https://raw.githubusercontent.com/googlefonts/noto-cjk/main/Sans/SubsetOTF/SC/NotoSansSC-Regular.otf',
|
||||
]
|
||||
|
||||
let fontReady: string | null = null
|
||||
|
||||
export async function ensureChineseFont(): Promise<string> {
|
||||
if (fontReady) return fontReady
|
||||
|
||||
// Check if already downloaded
|
||||
if (fs.existsSync(FONT_FILE) && fs.statSync(FONT_FILE).size > 100000) {
|
||||
fontReady = FONT_FILE
|
||||
return FONT_FILE
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(FONTS_DIR)) fs.mkdirSync(FONTS_DIR, { recursive: true })
|
||||
|
||||
// Try each CDN URL
|
||||
for (const url of CDN_URLS) {
|
||||
try {
|
||||
console.log(`Downloading Chinese font from ${url.split('/')[2]}...`)
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(60000) })
|
||||
if (!res.ok) continue
|
||||
const buf = Buffer.from(await res.arrayBuffer())
|
||||
if (buf.length < 100000) continue // too small, probably error page
|
||||
fs.writeFileSync(FONT_FILE, buf)
|
||||
console.log(`Chinese font downloaded (${(buf.length / 1024 / 1024).toFixed(1)} MB)`)
|
||||
fontReady = FONT_FILE
|
||||
return FONT_FILE
|
||||
} catch (e: any) {
|
||||
console.warn(`Font download failed from ${url.split('/')[2]}: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try local system fonts
|
||||
const fallbacks = [
|
||||
'C:\\Windows\\Fonts\\simhei.ttf',
|
||||
'C:\\Windows\\Fonts\\msyh.ttf',
|
||||
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
|
||||
'/System/Library/Fonts/PingFang.ttc',
|
||||
]
|
||||
for (const f of fallbacks) {
|
||||
if (fs.existsSync(f)) {
|
||||
console.log(`Using fallback system font: ${f}`)
|
||||
fontReady = f
|
||||
return f
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('No Chinese font available. Set PDF_FONT_PATH env variable to a TTF/OTF font path.')
|
||||
}
|
||||
|
||||
export function getChineseFont(): string {
|
||||
if (!fontReady) throw new Error('Font not initialized. Call ensureChineseFont() first.')
|
||||
return fontReady
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
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
|
||||
@@ -14,160 +16,433 @@ interface BillingReportData {
|
||||
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
|
||||
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
|
||||
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<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const doc = new PDFDocument({ size: 'A4', margin: 50 })
|
||||
const doc = new PDFDocument({ size: 'A4', margin: MARGIN, bufferPages: true })
|
||||
const chunks: Buffer[] = []
|
||||
|
||||
doc.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
doc.on('data', (c: Buffer) => chunks.push(c))
|
||||
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()
|
||||
// Register Chinese font as default
|
||||
doc.font(getChineseFont())
|
||||
|
||||
// -- 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()
|
||||
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))
|
||||
|
||||
// -- 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)
|
||||
// ===== 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 --
|
||||
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)
|
||||
// Report info section
|
||||
let y = 120
|
||||
doc.fontSize(12).fillColor(BLUE).text('报告信息', MARGIN, y)
|
||||
hline(doc, y + 18, BLUE)
|
||||
y += 28
|
||||
|
||||
// -- 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)
|
||||
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
|
||||
}
|
||||
|
||||
// -- Detail Table --
|
||||
doc.fontSize(12).text('Usage Details', { underline: true })
|
||||
doc.moveDown(0.3)
|
||||
// Summary boxes
|
||||
y += 15
|
||||
doc.fontSize(12).fillColor(BLUE).text('概览', MARGIN, y)
|
||||
hline(doc, y + 18, BLUE)
|
||||
y += 28
|
||||
|
||||
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)
|
||||
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 })
|
||||
}
|
||||
|
||||
doc.moveTo(50, tableTop + 12).lineTo(560, tableTop + 12).stroke('#ccc')
|
||||
// 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 })
|
||||
|
||||
let y = tableTop + 16
|
||||
const maxRows = Math.min(data.logs.length, 200)
|
||||
for (let i = 0; i < maxRows; i++) {
|
||||
if (y > 750) {
|
||||
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 = 50
|
||||
y = MARGIN + 10
|
||||
}
|
||||
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 })
|
||||
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
|
||||
}
|
||||
|
||||
if (data.logs.length > maxRows) {
|
||||
doc.moveDown(0.5)
|
||||
doc.fontSize(8).fillColor('#999')
|
||||
.text(`... Total ${data.logs.length} records, showing first ${maxRows}`)
|
||||
// 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<string, string> = { 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)
|
||||
}
|
||||
|
||||
// -- HMAC Signature --
|
||||
// ===== 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<number, string> = { 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
|
||||
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')
|
||||
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()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user