import { query } from "./db"; import { quotaToUsd } from "./metrics"; import { CACHE_CREATION, CACHE_READ, REAL_MODEL, cacheKey, cached, getChannelNames, getDisplayNames, timeWhere } from "./query-shared"; // ── 明细日志 ────────────────────────────────────────────────── export interface LogEntry { id: number; created_at: string; username: string; display_name: string; real_model: string; request_model: string; channel_name: string; channel_id: number; prompt_tokens: number; completion_tokens: number; cache_creation_tokens: number; cache_read_tokens: number; total_tokens: number; quota: number; quota_usd: number; use_time: number; is_stream: boolean; token_name: string; } export interface LogsResult { logs: LogEntry[]; total: number; page: number; page_size: number; } function normalizePositiveInteger(value: number | undefined, defaultValue: number, max?: number): number { if (!Number.isFinite(value)) return defaultValue; const positive = Math.max(1, Math.trunc(value ?? defaultValue)); return max === undefined ? positive : Math.min(max, positive); } export async function getLogs(options: { page?: number; pageSize?: number; startTs?: number; endTs?: number; username?: string; model?: string; channelId?: number; tokenName?: string; }): Promise { const ck = cacheKey("logs", options.page, options.pageSize, options.startTs, options.endTs, options.username, options.model, options.channelId, options.tokenName); return cached(ck, async () => { const page = normalizePositiveInteger(options.page, 1); const pageSize = normalizePositiveInteger(options.pageSize, 100, 200); const params: (string | number | boolean | null)[] = []; let where = timeWhere(params, options.startTs, options.endTs); if (options.username) { params.push(`%${options.username}%`); where += ` AND ( username ILIKE $${params.length} OR user_id IN ( SELECT id FROM users WHERE display_name ILIKE $${params.length} OR users.username ILIKE $${params.length} ) )`; } if (options.model) { params.push(options.model); where += ` AND ${REAL_MODEL} = $${params.length}`; } if (options.channelId) { params.push(options.channelId); where += ` AND channel_id = $${params.length}`; } if (options.tokenName) { params.push(`%${options.tokenName}%`); where += ` AND token_name ILIKE $${params.length}`; } // Count const countRows = await query( `SELECT COUNT(*)::int as total FROM logs WHERE ${where}`, params ); const total = Number(countRows[0].total); // Data. For large production tables, add indexes on logs(created_at), // logs(username, created_at), logs(channel_id, created_at), and the real-model expression. const offset = (page - 1) * pageSize; const dataParams = [...params, pageSize, offset]; const displayNames = await getDisplayNames(); const channelNames = await getChannelNames(); const rows = await query( `SELECT id, created_at, user_id, username, model_name, ${REAL_MODEL} as real_model, channel_id, prompt_tokens, completion_tokens, quota, ${CACHE_CREATION} as cache_creation, ${CACHE_READ} as cache_read, use_time, is_stream, token_name FROM logs WHERE ${where} ORDER BY id DESC LIMIT $${dataParams.length - 1} OFFSET $${dataParams.length}`, dataParams ); return { total, page, page_size: pageSize, logs: rows.map((r) => ({ id: Number(r.id), created_at: new Date(Number(r.created_at) * 1000).toISOString(), username: r.username, display_name: displayNames[r.user_id] || r.username, real_model: r.real_model || r.model_name, request_model: r.model_name, channel_name: channelNames[r.channel_id] || `已删除(${r.channel_id})`, channel_id: Number(r.channel_id), prompt_tokens: Number(r.prompt_tokens), completion_tokens: Number(r.completion_tokens), cache_creation_tokens: Number(r.cache_creation), cache_read_tokens: Number(r.cache_read), total_tokens: Number(r.prompt_tokens) + Number(r.completion_tokens) + Number(r.cache_creation) + Number(r.cache_read), quota: Number(r.quota), quota_usd: quotaToUsd(Number(r.quota)), use_time: Number(r.use_time), is_stream: Boolean(r.is_stream), token_name: r.token_name || "", })), }; }); }