1777 lines
55 KiB
TypeScript
1777 lines
55 KiB
TypeScript
import { createReadStream, existsSync, statSync } from 'fs'
|
|
import { createInterface } from 'readline'
|
|
import { createGunzip } from 'zlib'
|
|
import path from 'path'
|
|
|
|
type TrafficStatusBucket = '2xx' | '3xx' | '4xx' | '5xx' | 'other'
|
|
|
|
export interface PublicTrafficRange {
|
|
start: Date
|
|
end: Date
|
|
}
|
|
|
|
export interface PublicTrafficSourceIssue {
|
|
path: string
|
|
reason: string
|
|
}
|
|
|
|
export interface PublicTrafficLogEntry {
|
|
timestamp: Date
|
|
domain: string
|
|
remoteAddress: string
|
|
method: string
|
|
path: string
|
|
status: number
|
|
responseBytes: number
|
|
requestBytes: number | null
|
|
}
|
|
|
|
export interface PublicTrafficPurpose {
|
|
key: string
|
|
label: string
|
|
category: string
|
|
detail: string
|
|
}
|
|
|
|
export interface PublicTrafficStats {
|
|
generatedAt: string
|
|
available: boolean
|
|
range: {
|
|
start: string
|
|
end: string
|
|
}
|
|
summary: {
|
|
responseBytes: number
|
|
requestBytes: number | null
|
|
requestBytesKnownCount: number
|
|
totalRequests: number
|
|
uniqueIpCount: number
|
|
statusCounts: Record<TrafficStatusBucket, number>
|
|
firstSeen: string | null
|
|
lastSeen: string | null
|
|
}
|
|
byDay: Array<{
|
|
date: string
|
|
responseBytes: number
|
|
requestBytes: number | null
|
|
requestBytesKnownCount: number
|
|
totalRequests: number
|
|
}>
|
|
byDomain: Array<{
|
|
domain: string
|
|
responseBytes: number
|
|
requestBytes: number | null
|
|
requestBytesKnownCount: number
|
|
totalRequests: number
|
|
statusCounts: Record<TrafficStatusBucket, number>
|
|
}>
|
|
byPurpose: Array<{
|
|
key: string
|
|
label: string
|
|
category: string
|
|
detail: string
|
|
responseBytes: number
|
|
requestBytes: number | null
|
|
requestBytesKnownCount: number
|
|
totalRequests: number
|
|
}>
|
|
topPaths: Array<{
|
|
path: string
|
|
method: string
|
|
purposeLabel: string
|
|
purposeCategory: string
|
|
purposeDetail: string
|
|
responseBytes: number
|
|
totalRequests: number
|
|
}>
|
|
source: {
|
|
logPaths: string[]
|
|
scannedFiles: number
|
|
scannedLines: number
|
|
matchedLines: number
|
|
skippedLines: number
|
|
truncated: boolean
|
|
issues: PublicTrafficSourceIssue[]
|
|
requestBytesAvailable: boolean
|
|
cache?: {
|
|
persisted: boolean
|
|
hit: boolean
|
|
stale: boolean
|
|
refreshInProgress: boolean
|
|
refreshedAt: string | null
|
|
expiresAt: string | null
|
|
}
|
|
}
|
|
}
|
|
|
|
interface PublicTrafficAggregationState {
|
|
uniqueIps: Set<string>
|
|
dayMap: Map<string, PublicTrafficStats['byDay'][number]>
|
|
domainMap: Map<string, PublicTrafficStats['byDomain'][number]>
|
|
purposeMap: Map<string, PublicTrafficStats['byPurpose'][number]>
|
|
topPathMap: Map<string, PublicTrafficStats['topPaths'][number]>
|
|
}
|
|
|
|
interface PublicTrafficCacheEntry {
|
|
key: string
|
|
createdAt: number
|
|
expiresAt: number
|
|
stats: PublicTrafficStats
|
|
}
|
|
|
|
interface PublicTrafficStatsOptions {
|
|
forceRefresh?: boolean
|
|
}
|
|
|
|
export type PublicTrafficPreset = 'today' | 'last7' | 'last30'
|
|
|
|
export type PublicTrafficRollupGranularity = '5m' | 'day'
|
|
|
|
export interface PublicTrafficRollupInput {
|
|
bucketStart: string
|
|
bucketEnd: string
|
|
businessDate?: string | null
|
|
granularity: PublicTrafficRollupGranularity
|
|
domain: string
|
|
method?: string | null
|
|
path: string
|
|
responseBytes: number
|
|
requestBytes?: number | null
|
|
requestBytesKnownCount?: number | null
|
|
totalRequests: number
|
|
statusCounts?: Partial<Record<TrafficStatusBucket, number>> | null
|
|
sourceServer?: string | null
|
|
generatedAt?: string | null
|
|
frozen?: boolean | null
|
|
}
|
|
|
|
export interface PublicTrafficRollupImportResult {
|
|
received: number
|
|
accepted: number
|
|
skipped: number
|
|
}
|
|
|
|
interface PublicTrafficScanContext {
|
|
logPaths: string[]
|
|
maxLines: number
|
|
stableCacheKey: string
|
|
sourceSignature: string
|
|
ttlSeconds: number
|
|
}
|
|
|
|
interface PersistentTrafficCacheRow {
|
|
stats: PublicTrafficStats | string
|
|
refreshed_at: Date | string
|
|
expires_at: Date | string
|
|
}
|
|
|
|
interface PublicTrafficRollupRow {
|
|
bucket_start: Date | string
|
|
bucket_end: Date | string
|
|
business_date: string
|
|
granularity: PublicTrafficRollupGranularity
|
|
domain: string
|
|
method: string
|
|
path: string
|
|
purpose_key: string
|
|
purpose_label: string
|
|
purpose_category: string
|
|
purpose_detail: string
|
|
response_bytes: bigint | number | string
|
|
request_bytes: bigint | number | string | null
|
|
request_bytes_known_count: number
|
|
total_requests: number
|
|
status_2xx: number
|
|
status_3xx: number
|
|
status_4xx: number
|
|
status_5xx: number
|
|
status_other: number
|
|
}
|
|
|
|
interface PublicTrafficRollupAggregationState {
|
|
dayMap: Map<string, PublicTrafficStats['byDay'][number]>
|
|
domainMap: Map<string, PublicTrafficStats['byDomain'][number]>
|
|
purposeMap: Map<string, PublicTrafficStats['byPurpose'][number]>
|
|
topPathMap: Map<string, PublicTrafficStats['topPaths'][number]>
|
|
}
|
|
|
|
const MONTHS: Record<string, number> = {
|
|
Jan: 0,
|
|
Feb: 1,
|
|
Mar: 2,
|
|
Apr: 3,
|
|
May: 4,
|
|
Jun: 5,
|
|
Jul: 6,
|
|
Aug: 7,
|
|
Sep: 8,
|
|
Oct: 9,
|
|
Nov: 10,
|
|
Dec: 11,
|
|
}
|
|
|
|
const DEFAULT_LOG_PATHS = [
|
|
'/var/log/nginx/sc.jiansun.vip.access.log',
|
|
'/var/log/nginx/sc.jiansun.vip.access.log.1',
|
|
'/var/log/nginx/s3.jiansun.vip.access.log',
|
|
'/var/log/nginx/s3.jiansun.vip.access.log.1',
|
|
'/var/log/nginx/cp.jiansun.vip.access.log',
|
|
'/var/log/nginx/cp.jiansun.vip.access.log.1',
|
|
]
|
|
|
|
const statsCache = new Map<string, PublicTrafficCacheEntry>()
|
|
const MAX_CACHE_ENTRIES = 20
|
|
const refreshPromises = new Map<string, Promise<void>>()
|
|
let persistentCacheTableEnsured = false
|
|
let rollupTableEnsured = false
|
|
|
|
function emptyStatusCounts(): Record<TrafficStatusBucket, number> {
|
|
return {
|
|
'2xx': 0,
|
|
'3xx': 0,
|
|
'4xx': 0,
|
|
'5xx': 0,
|
|
other: 0,
|
|
}
|
|
}
|
|
|
|
function createAggregationState(): PublicTrafficAggregationState {
|
|
return {
|
|
uniqueIps: new Set<string>(),
|
|
dayMap: new Map(),
|
|
domainMap: new Map(),
|
|
purposeMap: new Map(),
|
|
topPathMap: new Map(),
|
|
}
|
|
}
|
|
|
|
function createRollupAggregationState(): PublicTrafficRollupAggregationState {
|
|
return {
|
|
dayMap: new Map(),
|
|
domainMap: new Map(),
|
|
purposeMap: new Map(),
|
|
topPathMap: new Map(),
|
|
}
|
|
}
|
|
|
|
function splitEnvList(value: string | undefined): string[] {
|
|
return String(value || '')
|
|
.split(/[\n,;]/)
|
|
.map((item) => item.trim())
|
|
.filter(Boolean)
|
|
}
|
|
|
|
export function getConfiguredPublicTrafficLogPaths(): string[] {
|
|
const configured = splitEnvList(process.env.PUBLIC_TRAFFIC_LOG_PATHS)
|
|
return configured.length > 0 ? configured : DEFAULT_LOG_PATHS
|
|
}
|
|
|
|
export function normalizeTrafficRange(startValue?: string | null, endValue?: string | null): PublicTrafficRange {
|
|
const now = new Date()
|
|
const fallbackEnd = new Date(now)
|
|
const fallbackStart = new Date(now.getTime() - 6 * 24 * 60 * 60 * 1000)
|
|
fallbackStart.setHours(0, 0, 0, 0)
|
|
fallbackEnd.setHours(23, 59, 59, 999)
|
|
|
|
const parsedStart = startValue ? new Date(`${startValue}T00:00:00+08:00`) : fallbackStart
|
|
const parsedEnd = endValue ? new Date(`${endValue}T23:59:59.999+08:00`) : fallbackEnd
|
|
|
|
const start = Number.isNaN(parsedStart.getTime()) ? fallbackStart : parsedStart
|
|
const end = Number.isNaN(parsedEnd.getTime()) ? fallbackEnd : parsedEnd
|
|
|
|
if (start.getTime() > end.getTime()) {
|
|
return { start: end, end: start }
|
|
}
|
|
|
|
return { start, end }
|
|
}
|
|
|
|
function toChinaDateString(date: Date): string {
|
|
return new Intl.DateTimeFormat('en-CA', {
|
|
timeZone: 'Asia/Shanghai',
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
}).format(date)
|
|
}
|
|
|
|
function addDays(date: Date, days: number): Date {
|
|
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000)
|
|
}
|
|
|
|
export function getPublicTrafficPresetRange(preset: PublicTrafficPreset, now = new Date()): PublicTrafficRange {
|
|
const endDate = toChinaDateString(now)
|
|
const daysBack = preset === 'today' ? 0 : preset === 'last7' ? 6 : 29
|
|
const startDate = toChinaDateString(addDays(now, -daysBack))
|
|
return normalizeTrafficRange(startDate, endDate)
|
|
}
|
|
|
|
function parseNginxTime(value: string): Date | null {
|
|
const match = value.match(/^(\d{1,2})\/([A-Za-z]{3})\/(\d{4}):(\d{2}):(\d{2}):(\d{2}) ([+-]\d{4})$/)
|
|
if (!match) return null
|
|
|
|
const [, day, monthName, year, hour, minute, second, offset] = match
|
|
const month = MONTHS[monthName]
|
|
if (month === undefined) return null
|
|
|
|
const isoOffset = `${offset.slice(0, 3)}:${offset.slice(3)}`
|
|
const date = new Date(
|
|
`${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}T${hour}:${minute}:${second}${isoOffset}`,
|
|
)
|
|
return Number.isNaN(date.getTime()) ? null : date
|
|
}
|
|
|
|
function inferDomainFromPath(logPath: string): string {
|
|
const fileName = path.basename(logPath)
|
|
const matched = fileName.match(/([a-z0-9-]+(?:\.[a-z0-9-]+)+)\.access\.log/i)
|
|
if (matched) return matched[1]
|
|
if (fileName.includes('sc.')) return 'sc.jiansun.vip'
|
|
if (fileName.includes('s3.')) return 's3.jiansun.vip'
|
|
if (fileName.includes('cp.')) return 'cp.jiansun.vip'
|
|
return fileName.replace(/\.log(?:\.\d+|\.gz)?$/i, '')
|
|
}
|
|
|
|
function normalizeRequestTarget(request: string) {
|
|
const parts = request.trim().split(/\s+/)
|
|
const method = parts[0] || 'UNKNOWN'
|
|
const rawPath = parts[1] || '/'
|
|
const withoutQuery = rawPath.split('?')[0] || '/'
|
|
return {
|
|
method,
|
|
path: withoutQuery.length > 180 ? `${withoutQuery.slice(0, 177)}...` : withoutQuery,
|
|
}
|
|
}
|
|
|
|
function statusBucket(status: number): TrafficStatusBucket {
|
|
if (status >= 200 && status < 300) return '2xx'
|
|
if (status >= 300 && status < 400) return '3xx'
|
|
if (status >= 400 && status < 500) return '4xx'
|
|
if (status >= 500 && status < 600) return '5xx'
|
|
return 'other'
|
|
}
|
|
|
|
function parseNumber(value: unknown): number | null {
|
|
if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, value)
|
|
if (typeof value === 'string' && value.trim() && value !== '-') {
|
|
const parsed = Number.parseInt(value, 10)
|
|
return Number.isFinite(parsed) ? Math.max(0, parsed) : null
|
|
}
|
|
return null
|
|
}
|
|
|
|
function safeDecodePath(value: string): string {
|
|
try {
|
|
return decodeURIComponent(value)
|
|
} catch {
|
|
return value
|
|
}
|
|
}
|
|
|
|
function isAssetPath(pathValue: string): boolean {
|
|
return /\.(?:avif|webp|png|jpe?g|gif|svg|mp4|webm|mp3|wav|zip|rar|7z|pdf|psd|ai|cdr)$/i.test(pathValue)
|
|
}
|
|
|
|
function assetTypeLabel(pathValue: string): string {
|
|
if (/\.(?:avif|webp|png|jpe?g|gif|svg)$/i.test(pathValue)) return '图片直链访问'
|
|
if (/\.(?:mp4|webm)$/i.test(pathValue)) return '视频直链访问'
|
|
if (/\.(?:mp3|wav)$/i.test(pathValue)) return '音频直链访问'
|
|
if (/\.(?:zip|rar|7z)$/i.test(pathValue)) return '压缩包直链访问'
|
|
if (/\.(?:pdf|psd|ai|cdr)$/i.test(pathValue)) return '设计文件直链访问'
|
|
return '资源文件直链访问'
|
|
}
|
|
|
|
function fileNameFromPath(pathValue: string): string {
|
|
const rawName = pathValue.split('/').filter(Boolean).pop() || pathValue
|
|
return safeDecodePath(rawName)
|
|
}
|
|
|
|
export function classifyPublicTrafficPurpose(domain: string, pathValue: string, method = 'GET'): PublicTrafficPurpose {
|
|
const pathLower = pathValue.toLowerCase()
|
|
const domainLower = domain.toLowerCase()
|
|
const methodUpper = method.toUpperCase()
|
|
|
|
if (pathLower === '/api/materials/download') {
|
|
return {
|
|
key: 'material-download',
|
|
label: '素材下载/打包下载',
|
|
category: '素材库',
|
|
detail: '用户下载素材原文件、压缩包或分享页下载时产生的流量。',
|
|
}
|
|
}
|
|
|
|
if (pathLower === '/api/external/download') {
|
|
return {
|
|
key: 'external-material-download',
|
|
label: '外部平台素材下载',
|
|
category: '外部接口',
|
|
detail: '脉象、产品平台等外部系统通过下载 token 拉取素材文件。',
|
|
}
|
|
}
|
|
|
|
if (pathLower.startsWith('/api/external/')) {
|
|
return {
|
|
key: 'external-api',
|
|
label: '外部平台对接接口',
|
|
category: '外部接口',
|
|
detail: '外部系统查询素材、SPU、SKU、条码或图片信息。',
|
|
}
|
|
}
|
|
|
|
if (domainLower === 'cp.jiansun.vip') {
|
|
if (pathLower === '/api/intake/non-integrated') {
|
|
return {
|
|
key: 'product-intake-non-integrated',
|
|
label: '产品设计平台未集成素材列表',
|
|
category: '产品设计平台',
|
|
detail: '产品设计平台查看未集成素材或待处理录入内容。',
|
|
}
|
|
}
|
|
if (pathLower === '/api/intake/product-patents') {
|
|
return {
|
|
key: 'product-intake-submit',
|
|
label: '产品设计平台资料提交',
|
|
category: '产品设计平台',
|
|
detail: '产品设计平台提交产品图片、专利或录入资料。',
|
|
}
|
|
}
|
|
if (pathLower === '/api/storage/object') {
|
|
return {
|
|
key: 'product-storage-object',
|
|
label: '产品设计平台取图跳转',
|
|
category: '产品设计平台',
|
|
detail: '产品设计平台请求对象文件地址,通常后续由存储或 CDN 承接真实图片流量。',
|
|
}
|
|
}
|
|
if (pathLower.startsWith('/api/intake/')) {
|
|
return {
|
|
key: 'product-intake-api',
|
|
label: '产品设计平台录入接口',
|
|
category: '产品设计平台',
|
|
detail: '产品设计平台录入、查询或处理产品资料的接口请求。',
|
|
}
|
|
}
|
|
if (pathLower.startsWith('/api/')) {
|
|
return {
|
|
key: 'product-platform-api',
|
|
label: '产品设计平台业务接口',
|
|
category: '产品设计平台',
|
|
detail: '产品设计平台页面调用的业务接口。',
|
|
}
|
|
}
|
|
return {
|
|
key: 'product-platform-page',
|
|
label: '产品设计平台页面访问',
|
|
category: '产品设计平台',
|
|
detail: '用户打开产品设计平台页面或加载前端静态资源。',
|
|
}
|
|
}
|
|
|
|
if (pathLower.startsWith('/gongxiangpan/public-display/white-background/')) {
|
|
return {
|
|
key: 'white-background-preview',
|
|
label: '标准模型白底图预览',
|
|
category: '资源直链',
|
|
detail: '白底图等标准模型展示图片从资源入口直接返回。',
|
|
}
|
|
}
|
|
|
|
if (pathLower.startsWith('/gongxiangpan/public-display/task-detail/')) {
|
|
return {
|
|
key: 'task-detail-preview',
|
|
label: '任务详情素材预览图',
|
|
category: '资源直链',
|
|
detail: '素材详情、主图、SKU 图、详情图等预览资源直链访问。',
|
|
}
|
|
}
|
|
|
|
if (pathLower.startsWith('/gongxiangpan/public-display/materials/')) {
|
|
return {
|
|
key: 'material-preview',
|
|
label: '素材预览图访问',
|
|
category: '资源直链',
|
|
detail: '素材库列表、详情或分享页展示预览图。',
|
|
}
|
|
}
|
|
|
|
if (pathLower.startsWith('/gongxiangpan/uploads/direct/')) {
|
|
return {
|
|
key: 'direct-upload-asset',
|
|
label: assetTypeLabel(pathValue),
|
|
category: '直传资源',
|
|
detail: '直传上传目录下的图片、视频、压缩包等资源直链汇总,具体文件请看“流量最高路径”。',
|
|
}
|
|
}
|
|
|
|
if (pathLower.startsWith('/gongxiangpan/uploads/')) {
|
|
return {
|
|
key: 'legacy-upload-asset',
|
|
label: assetTypeLabel(pathValue),
|
|
category: '历史上传资源',
|
|
detail: '历史上传目录下的图片、视频、压缩包等资源直链汇总,具体文件请看“流量最高路径”。',
|
|
}
|
|
}
|
|
|
|
if (pathLower.startsWith('/gongxiangpan/product-package/')) {
|
|
return {
|
|
key: 'product-package-asset',
|
|
label: '产品包资源访问',
|
|
category: '资源直链',
|
|
detail: '产品包、原始文件或成套资源通过资源入口直接访问。',
|
|
}
|
|
}
|
|
|
|
if (pathLower.startsWith('/gongxiangpan/') || domainLower === 's3.jiansun.vip') {
|
|
return {
|
|
key: 'storage-asset',
|
|
label: isAssetPath(pathValue) ? assetTypeLabel(pathValue) : '资源存储入口访问',
|
|
category: '资源直链',
|
|
detail: isAssetPath(pathValue)
|
|
? '资源入口直接返回的对象存储文件汇总,具体文件请看“流量最高路径”。'
|
|
: '资源入口上的对象存储、探测或目录类请求。',
|
|
}
|
|
}
|
|
|
|
if (pathLower.startsWith('/api/materials/upload/')) {
|
|
return {
|
|
key: 'material-upload',
|
|
label: methodUpper === 'GET' ? '上传状态查询' : '素材上传接口',
|
|
category: '素材库',
|
|
detail: '素材上传分片、合并、刷新或上传状态相关请求。',
|
|
}
|
|
}
|
|
|
|
if (pathLower === '/api/materials') {
|
|
return {
|
|
key: 'material-list-search',
|
|
label: '素材列表/搜索接口',
|
|
category: '素材库',
|
|
detail: '首页、素材列表或筛选搜索素材时加载的数据。',
|
|
}
|
|
}
|
|
|
|
if (pathLower.startsWith('/api/materials/')) {
|
|
return {
|
|
key: 'material-detail-api',
|
|
label: '素材详情/管理接口',
|
|
category: '素材库',
|
|
detail: '素材详情、SKU、资产排序、恢复、收藏、点赞等接口请求。',
|
|
}
|
|
}
|
|
|
|
if (pathLower === '/api/asset-center/substrates') {
|
|
return {
|
|
key: 'asset-center-substrates',
|
|
label: '资产中心基底素材列表',
|
|
category: '素材库',
|
|
detail: '资产中心加载基底素材、贴图或可选素材列表。',
|
|
}
|
|
}
|
|
|
|
if (pathLower.startsWith('/api/asset-center/')) {
|
|
return {
|
|
key: 'asset-center-api',
|
|
label: '资产中心接口',
|
|
category: '素材库',
|
|
detail: '资产中心页面的数据查询或操作接口。',
|
|
}
|
|
}
|
|
|
|
if (pathLower === '/api/rankings') {
|
|
return {
|
|
key: 'home-ranking',
|
|
label: '首页排行榜接口',
|
|
category: '素材库',
|
|
detail: '首页展示热门、排行或推荐素材数据。',
|
|
}
|
|
}
|
|
|
|
if (pathLower.startsWith('/api/auth/') || pathLower.startsWith('/api/dingtalk/') || pathLower === '/dingtalk-callback') {
|
|
return {
|
|
key: 'login-auth',
|
|
label: '登录/钉钉认证',
|
|
category: '账号',
|
|
detail: '登录状态、钉钉回调、会话检查等账号认证请求。',
|
|
}
|
|
}
|
|
|
|
if (pathLower.startsWith('/api/performance-survey') || pathLower.startsWith('/performance-survey')) {
|
|
return {
|
|
key: 'performance-survey',
|
|
label: '考核调研功能',
|
|
category: '素材库',
|
|
detail: '考核调研页面、列表、提交和结果查看相关请求。',
|
|
}
|
|
}
|
|
|
|
if (pathLower.startsWith('/api/work-review') || pathLower.startsWith('/work-review')) {
|
|
return {
|
|
key: 'work-review',
|
|
label: '作品过稿看板',
|
|
category: '素材库',
|
|
detail: '作品提交、版本留档、主管与元泽评审相关请求。',
|
|
}
|
|
}
|
|
|
|
if (pathLower.startsWith('/admin') || pathLower.startsWith('/api/admin/')) {
|
|
return {
|
|
key: 'admin-console',
|
|
label: '后台管理功能',
|
|
category: '后台',
|
|
detail: '管理员后台页面和管理接口请求。',
|
|
}
|
|
}
|
|
|
|
if (pathLower.startsWith('/share/')) {
|
|
return {
|
|
key: 'share-page',
|
|
label: '素材分享页',
|
|
category: '素材库',
|
|
detail: '外部或内部用户打开素材分享页面。',
|
|
}
|
|
}
|
|
|
|
if (pathLower.startsWith('/_next/') || pathLower === '/favicon.ico' || pathLower === '/api/favicon') {
|
|
return {
|
|
key: 'frontend-static',
|
|
label: '前端静态资源',
|
|
category: '页面加载',
|
|
detail: '页面 JS、CSS、图标等前端基础资源。',
|
|
}
|
|
}
|
|
|
|
if (isAssetPath(pathValue)) {
|
|
return {
|
|
key: 'static-asset',
|
|
label: assetTypeLabel(pathValue),
|
|
category: '资源文件',
|
|
detail: `直接访问的文件:${fileNameFromPath(pathValue)}`,
|
|
}
|
|
}
|
|
|
|
if (pathLower.startsWith('/api/')) {
|
|
return {
|
|
key: 'other-api',
|
|
label: '其他业务接口',
|
|
category: '其他',
|
|
detail: '尚未细分到具体模块的接口请求。',
|
|
}
|
|
}
|
|
|
|
return {
|
|
key: 'other-page',
|
|
label: '其他页面访问',
|
|
category: '其他',
|
|
detail: '尚未细分到具体模块的页面或资源请求。',
|
|
}
|
|
}
|
|
|
|
function parseJsonLogLine(line: string, fallbackDomain: string): PublicTrafficLogEntry | null {
|
|
try {
|
|
const raw = JSON.parse(line) as Record<string, unknown>
|
|
const timeValue = String(raw.time_local || raw.time || raw.timestamp || '')
|
|
const timestamp = timeValue.includes('/') ? parseNginxTime(timeValue) : new Date(timeValue)
|
|
if (!timestamp || Number.isNaN(timestamp.getTime())) return null
|
|
|
|
const request = String(raw.request || `${raw.method || ''} ${raw.uri || raw.path || '/'} HTTP/1.1`)
|
|
const target = normalizeRequestTarget(request)
|
|
const status = parseNumber(raw.status) || 0
|
|
const responseBytes =
|
|
parseNumber(raw.body_bytes_sent) ??
|
|
parseNumber(raw.bytes_sent) ??
|
|
parseNumber(raw.response_bytes) ??
|
|
0
|
|
const requestBytes = parseNumber(raw.request_length) ?? parseNumber(raw.request_bytes)
|
|
|
|
return {
|
|
timestamp,
|
|
domain: String(raw.host || raw.server_name || fallbackDomain),
|
|
remoteAddress: String(raw.remote_addr || raw.remoteAddress || ''),
|
|
method: target.method,
|
|
path: target.path,
|
|
status,
|
|
responseBytes,
|
|
requestBytes,
|
|
}
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function parsePublicTrafficLogLine(line: string, fallbackDomain: string): PublicTrafficLogEntry | null {
|
|
const trimmed = line.trim()
|
|
if (!trimmed) return null
|
|
|
|
if (trimmed.startsWith('{')) {
|
|
return parseJsonLogLine(trimmed, fallbackDomain)
|
|
}
|
|
|
|
const match = trimmed.match(
|
|
/^(\S+)\s+\S+\s+\S+\s+\[([^\]]+)]\s+"([^"]*)"\s+(\d{3})\s+(\d+|-)(?:\s+"[^"]*"\s+"[^"]*")?(.*)$/,
|
|
)
|
|
if (!match) return null
|
|
|
|
const [, remoteAddress, timeText, request, statusText, responseBytesText, tail] = match
|
|
const timestamp = parseNginxTime(timeText)
|
|
if (!timestamp) return null
|
|
|
|
const target = normalizeRequestTarget(request)
|
|
const bytesSentMatch = tail.match(/(?:bytes_sent|sent_bytes|response_bytes)=(\d+)/i)
|
|
const requestLengthMatch = tail.match(/(?:request_length|req_len|requestBytes|request_bytes)=(\d+)/i)
|
|
const requestBytes = requestLengthMatch ? parseNumber(requestLengthMatch[1]) : null
|
|
|
|
return {
|
|
timestamp,
|
|
domain: fallbackDomain,
|
|
remoteAddress,
|
|
method: target.method,
|
|
path: target.path,
|
|
status: Number.parseInt(statusText, 10),
|
|
responseBytes: bytesSentMatch ? parseNumber(bytesSentMatch[1]) || 0 : parseNumber(responseBytesText) || 0,
|
|
requestBytes,
|
|
}
|
|
}
|
|
|
|
function dateKey(date: Date): string {
|
|
const formatter = new Intl.DateTimeFormat('en-CA', {
|
|
timeZone: 'Asia/Shanghai',
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
})
|
|
return formatter.format(date)
|
|
}
|
|
|
|
function addEntry(stats: PublicTrafficStats, aggregation: PublicTrafficAggregationState, entry: PublicTrafficLogEntry) {
|
|
const bucket = statusBucket(entry.status)
|
|
stats.summary.responseBytes += entry.responseBytes
|
|
stats.summary.totalRequests += 1
|
|
stats.summary.statusCounts[bucket] += 1
|
|
if (entry.remoteAddress) aggregation.uniqueIps.add(entry.remoteAddress)
|
|
|
|
if (entry.requestBytes !== null) {
|
|
stats.summary.requestBytes = (stats.summary.requestBytes || 0) + entry.requestBytes
|
|
stats.summary.requestBytesKnownCount += 1
|
|
}
|
|
|
|
const seenIso = entry.timestamp.toISOString()
|
|
if (!stats.summary.firstSeen || seenIso < stats.summary.firstSeen) stats.summary.firstSeen = seenIso
|
|
if (!stats.summary.lastSeen || seenIso > stats.summary.lastSeen) stats.summary.lastSeen = seenIso
|
|
|
|
const key = dateKey(entry.timestamp)
|
|
let day = aggregation.dayMap.get(key)
|
|
if (!day) {
|
|
day = {
|
|
date: key,
|
|
responseBytes: 0,
|
|
requestBytes: null,
|
|
requestBytesKnownCount: 0,
|
|
totalRequests: 0,
|
|
}
|
|
aggregation.dayMap.set(key, day)
|
|
stats.byDay.push(day)
|
|
}
|
|
day.responseBytes += entry.responseBytes
|
|
day.totalRequests += 1
|
|
if (entry.requestBytes !== null) {
|
|
day.requestBytes = (day.requestBytes || 0) + entry.requestBytes
|
|
day.requestBytesKnownCount += 1
|
|
}
|
|
|
|
let domain = aggregation.domainMap.get(entry.domain)
|
|
if (!domain) {
|
|
domain = {
|
|
domain: entry.domain,
|
|
responseBytes: 0,
|
|
requestBytes: null,
|
|
requestBytesKnownCount: 0,
|
|
totalRequests: 0,
|
|
statusCounts: emptyStatusCounts(),
|
|
}
|
|
aggregation.domainMap.set(entry.domain, domain)
|
|
stats.byDomain.push(domain)
|
|
}
|
|
domain.responseBytes += entry.responseBytes
|
|
domain.totalRequests += 1
|
|
domain.statusCounts[bucket] += 1
|
|
if (entry.requestBytes !== null) {
|
|
domain.requestBytes = (domain.requestBytes || 0) + entry.requestBytes
|
|
domain.requestBytesKnownCount += 1
|
|
}
|
|
|
|
const purpose = classifyPublicTrafficPurpose(entry.domain, entry.path, entry.method)
|
|
let purposeStat = aggregation.purposeMap.get(purpose.key)
|
|
if (!purposeStat) {
|
|
purposeStat = {
|
|
key: purpose.key,
|
|
label: purpose.label,
|
|
category: purpose.category,
|
|
detail: purpose.detail,
|
|
responseBytes: 0,
|
|
requestBytes: null,
|
|
requestBytesKnownCount: 0,
|
|
totalRequests: 0,
|
|
}
|
|
aggregation.purposeMap.set(purpose.key, purposeStat)
|
|
stats.byPurpose.push(purposeStat)
|
|
}
|
|
purposeStat.responseBytes += entry.responseBytes
|
|
purposeStat.totalRequests += 1
|
|
if (entry.requestBytes !== null) {
|
|
purposeStat.requestBytes = (purposeStat.requestBytes || 0) + entry.requestBytes
|
|
purposeStat.requestBytesKnownCount += 1
|
|
}
|
|
|
|
const pathKey = `${entry.method} ${entry.path}`
|
|
let topPath = aggregation.topPathMap.get(pathKey)
|
|
if (!topPath) {
|
|
topPath = {
|
|
method: entry.method,
|
|
path: entry.path,
|
|
purposeLabel: purpose.label,
|
|
purposeCategory: purpose.category,
|
|
purposeDetail: purpose.detail,
|
|
responseBytes: 0,
|
|
totalRequests: 0,
|
|
}
|
|
aggregation.topPathMap.set(pathKey, topPath)
|
|
stats.topPaths.push(topPath)
|
|
}
|
|
topPath.responseBytes += entry.responseBytes
|
|
topPath.totalRequests += 1
|
|
}
|
|
|
|
async function scanLogFile(
|
|
logPath: string,
|
|
range: PublicTrafficRange,
|
|
stats: PublicTrafficStats,
|
|
aggregation: PublicTrafficAggregationState,
|
|
maxLines: number,
|
|
) {
|
|
if (!existsSync(logPath)) {
|
|
stats.source.issues.push({ path: logPath, reason: '文件不存在' })
|
|
return
|
|
}
|
|
|
|
const fileStat = statSync(logPath)
|
|
if (!fileStat.isFile()) {
|
|
stats.source.issues.push({ path: logPath, reason: '不是文件' })
|
|
return
|
|
}
|
|
|
|
const fallbackDomain = inferDomainFromPath(logPath)
|
|
const fileStream = createReadStream(logPath)
|
|
const input = logPath.endsWith('.gz') ? fileStream.pipe(createGunzip()) : fileStream
|
|
const reader = createInterface({ input, crlfDelay: Infinity })
|
|
|
|
stats.source.scannedFiles += 1
|
|
|
|
for await (const line of reader) {
|
|
if (stats.source.scannedLines >= maxLines) {
|
|
stats.source.truncated = true
|
|
reader.close()
|
|
break
|
|
}
|
|
|
|
stats.source.scannedLines += 1
|
|
const entry = parsePublicTrafficLogLine(line, fallbackDomain)
|
|
if (!entry) {
|
|
stats.source.skippedLines += 1
|
|
continue
|
|
}
|
|
|
|
if (entry.timestamp < range.start || entry.timestamp > range.end) continue
|
|
|
|
stats.source.matchedLines += 1
|
|
addEntry(stats, aggregation, entry)
|
|
}
|
|
}
|
|
|
|
function buildStableCacheKey(range: PublicTrafficRange, logPaths: string[], maxLines: number): string {
|
|
return [
|
|
'public-traffic-v2',
|
|
range.start.toISOString(),
|
|
range.end.toISOString(),
|
|
maxLines,
|
|
logPaths.join('|'),
|
|
].join('::')
|
|
}
|
|
|
|
function buildSourceSignature(logPaths: string[]): string {
|
|
return logPaths
|
|
.map((logPath) => {
|
|
try {
|
|
const fileStat = statSync(logPath)
|
|
return `${logPath}:${fileStat.size}:${Math.round(fileStat.mtimeMs)}`
|
|
} catch {
|
|
return `${logPath}:missing`
|
|
}
|
|
})
|
|
.join('|')
|
|
}
|
|
|
|
function getCacheTtlSeconds() {
|
|
const parsed = Number.parseInt(process.env.PUBLIC_TRAFFIC_STATS_CACHE_TTL_SECONDS || '900', 10)
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 900
|
|
}
|
|
|
|
function getScanContext(range: PublicTrafficRange): PublicTrafficScanContext {
|
|
const logPaths = getConfiguredPublicTrafficLogPaths()
|
|
const maxLines = Math.max(1000, Number.parseInt(process.env.PUBLIC_TRAFFIC_MAX_LINES || '1000000', 10) || 1000000)
|
|
return {
|
|
logPaths,
|
|
maxLines,
|
|
stableCacheKey: buildStableCacheKey(range, logPaths, maxLines),
|
|
sourceSignature: buildSourceSignature(logPaths),
|
|
ttlSeconds: getCacheTtlSeconds(),
|
|
}
|
|
}
|
|
|
|
function cloneStats(stats: PublicTrafficStats): PublicTrafficStats {
|
|
return JSON.parse(JSON.stringify(stats)) as PublicTrafficStats
|
|
}
|
|
|
|
function withCacheMetadata(
|
|
stats: PublicTrafficStats,
|
|
cache: NonNullable<PublicTrafficStats['source']['cache']>,
|
|
): PublicTrafficStats {
|
|
const nextStats = cloneStats(stats)
|
|
nextStats.source.cache = cache
|
|
return nextStats
|
|
}
|
|
|
|
function persistentTrafficCacheEnabled() {
|
|
return process.env.NODE_ENV !== 'test' && process.env.PUBLIC_TRAFFIC_STATS_DB_CACHE !== '0' && Boolean(process.env.DATABASE_URL)
|
|
}
|
|
|
|
async function ensurePersistentTrafficCacheTable() {
|
|
if (persistentCacheTableEnsured) return true
|
|
if (!persistentTrafficCacheEnabled()) return false
|
|
|
|
const { prisma } = await import('./prisma')
|
|
|
|
await prisma.$executeRawUnsafe(`
|
|
CREATE TABLE IF NOT EXISTS "public_traffic_stats_cache" (
|
|
"cache_key" TEXT PRIMARY KEY,
|
|
"range_start" TIMESTAMPTZ NOT NULL,
|
|
"range_end" TIMESTAMPTZ NOT NULL,
|
|
"stats" JSONB NOT NULL,
|
|
"source_signature" TEXT NOT NULL DEFAULT '',
|
|
"refreshed_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
"expires_at" TIMESTAMPTZ NOT NULL,
|
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`)
|
|
await prisma.$executeRawUnsafe(`
|
|
CREATE INDEX IF NOT EXISTS "public_traffic_stats_cache_range_idx"
|
|
ON "public_traffic_stats_cache" ("range_start", "range_end")
|
|
`)
|
|
await prisma.$executeRawUnsafe(`
|
|
CREATE INDEX IF NOT EXISTS "public_traffic_stats_cache_expires_at_idx"
|
|
ON "public_traffic_stats_cache" ("expires_at")
|
|
`)
|
|
|
|
persistentCacheTableEnsured = true
|
|
return true
|
|
}
|
|
|
|
async function readPersistentTrafficCache(cacheKey: string): Promise<PersistentTrafficCacheRow | null> {
|
|
if (!(await ensurePersistentTrafficCacheTable())) return null
|
|
|
|
const { prisma } = await import('./prisma')
|
|
const rows = await prisma.$queryRawUnsafe<PersistentTrafficCacheRow[]>(
|
|
`
|
|
SELECT "stats", "refreshed_at", "expires_at"
|
|
FROM "public_traffic_stats_cache"
|
|
WHERE "cache_key" = $1
|
|
LIMIT 1
|
|
`,
|
|
cacheKey,
|
|
)
|
|
|
|
return rows[0] || null
|
|
}
|
|
|
|
async function writePersistentTrafficCache(
|
|
cacheKey: string,
|
|
range: PublicTrafficRange,
|
|
context: PublicTrafficScanContext,
|
|
stats: PublicTrafficStats,
|
|
) {
|
|
if (!(await ensurePersistentTrafficCacheTable())) return
|
|
|
|
const { prisma } = await import('./prisma')
|
|
const now = new Date()
|
|
const expiresAt = new Date(now.getTime() + context.ttlSeconds * 1000)
|
|
const statsForStorage = cloneStats(stats)
|
|
delete statsForStorage.source.cache
|
|
|
|
await prisma.$executeRawUnsafe(
|
|
`
|
|
INSERT INTO "public_traffic_stats_cache" (
|
|
"cache_key",
|
|
"range_start",
|
|
"range_end",
|
|
"stats",
|
|
"source_signature",
|
|
"refreshed_at",
|
|
"expires_at",
|
|
"updated_at"
|
|
)
|
|
VALUES ($1, $2::timestamptz, $3::timestamptz, $4::jsonb, $5, $6::timestamptz, $7::timestamptz, $6::timestamptz)
|
|
ON CONFLICT ("cache_key") DO UPDATE SET
|
|
"range_start" = EXCLUDED."range_start",
|
|
"range_end" = EXCLUDED."range_end",
|
|
"stats" = EXCLUDED."stats",
|
|
"source_signature" = EXCLUDED."source_signature",
|
|
"refreshed_at" = EXCLUDED."refreshed_at",
|
|
"expires_at" = EXCLUDED."expires_at",
|
|
"updated_at" = EXCLUDED."updated_at"
|
|
`,
|
|
cacheKey,
|
|
range.start.toISOString(),
|
|
range.end.toISOString(),
|
|
JSON.stringify(statsForStorage),
|
|
context.sourceSignature,
|
|
now.toISOString(),
|
|
expiresAt.toISOString(),
|
|
)
|
|
}
|
|
|
|
function bigintToNumber(value: bigint | number | string | null | undefined): number {
|
|
if (value === null || value === undefined) return 0
|
|
if (typeof value === 'bigint') return Number(value)
|
|
if (typeof value === 'number') return Number.isFinite(value) ? value : 0
|
|
const parsed = Number(value)
|
|
return Number.isFinite(parsed) ? parsed : 0
|
|
}
|
|
|
|
function normalizeStatusCounts(
|
|
counts?: Partial<Record<TrafficStatusBucket, number>> | null,
|
|
): Record<TrafficStatusBucket, number> {
|
|
return {
|
|
'2xx': Math.max(0, Math.floor(Number(counts?.['2xx'] || 0))),
|
|
'3xx': Math.max(0, Math.floor(Number(counts?.['3xx'] || 0))),
|
|
'4xx': Math.max(0, Math.floor(Number(counts?.['4xx'] || 0))),
|
|
'5xx': Math.max(0, Math.floor(Number(counts?.['5xx'] || 0))),
|
|
other: Math.max(0, Math.floor(Number(counts?.other || 0))),
|
|
}
|
|
}
|
|
|
|
function normalizeRollupInput(input: PublicTrafficRollupInput) {
|
|
const bucketStart = new Date(input.bucketStart)
|
|
const bucketEnd = new Date(input.bucketEnd)
|
|
const domain = String(input.domain || '').trim().toLowerCase()
|
|
const method = String(input.method || 'GET').trim().toUpperCase() || 'GET'
|
|
const pathValue = normalizeRequestTarget(`${method} ${String(input.path || '').trim() || '/'} HTTP/1.1`).path
|
|
const responseBytes = Math.max(0, Math.floor(Number(input.responseBytes || 0)))
|
|
const totalRequests = Math.max(0, Math.floor(Number(input.totalRequests || 0)))
|
|
const requestBytes =
|
|
input.requestBytes === null || input.requestBytes === undefined
|
|
? null
|
|
: Math.max(0, Math.floor(Number(input.requestBytes || 0)))
|
|
const requestBytesKnownCount = Math.max(0, Math.floor(Number(input.requestBytesKnownCount || 0)))
|
|
const statusCounts = normalizeStatusCounts(input.statusCounts)
|
|
|
|
if (
|
|
Number.isNaN(bucketStart.getTime()) ||
|
|
Number.isNaN(bucketEnd.getTime()) ||
|
|
bucketStart >= bucketEnd ||
|
|
!domain ||
|
|
!pathValue ||
|
|
totalRequests <= 0 ||
|
|
!['5m', 'day'].includes(input.granularity)
|
|
) {
|
|
return null
|
|
}
|
|
|
|
const purpose = classifyPublicTrafficPurpose(domain, pathValue, method)
|
|
const businessDate = input.businessDate && /^\d{4}-\d{2}-\d{2}$/.test(input.businessDate)
|
|
? input.businessDate
|
|
: toChinaDateString(bucketStart)
|
|
const generatedAt = input.generatedAt ? new Date(input.generatedAt) : new Date()
|
|
|
|
return {
|
|
bucketStart: bucketStart.toISOString(),
|
|
bucketEnd: bucketEnd.toISOString(),
|
|
businessDate,
|
|
granularity: input.granularity,
|
|
domain,
|
|
method,
|
|
path: pathValue,
|
|
purposeKey: purpose.key,
|
|
purposeLabel: purpose.label,
|
|
purposeCategory: purpose.category,
|
|
purposeDetail: purpose.detail,
|
|
responseBytes,
|
|
requestBytes,
|
|
requestBytesKnownCount,
|
|
totalRequests,
|
|
status2xx: statusCounts['2xx'],
|
|
status3xx: statusCounts['3xx'],
|
|
status4xx: statusCounts['4xx'],
|
|
status5xx: statusCounts['5xx'],
|
|
statusOther: statusCounts.other,
|
|
sourceServer: String(input.sourceServer || '').trim(),
|
|
generatedAt: Number.isNaN(generatedAt.getTime()) ? new Date().toISOString() : generatedAt.toISOString(),
|
|
frozen: Boolean(input.frozen),
|
|
}
|
|
}
|
|
|
|
type NormalizedPublicTrafficRollupRow = NonNullable<ReturnType<typeof normalizeRollupInput>>
|
|
|
|
function getRollupConflictKey(row: NormalizedPublicTrafficRollupRow) {
|
|
return [
|
|
row.businessDate,
|
|
row.granularity,
|
|
row.bucketStart,
|
|
row.bucketEnd,
|
|
row.domain,
|
|
row.method,
|
|
row.path,
|
|
].join('\u001f')
|
|
}
|
|
|
|
function mergeRollupRows(rows: NormalizedPublicTrafficRollupRow[]) {
|
|
const rowMap = new Map<string, NormalizedPublicTrafficRollupRow>()
|
|
|
|
for (const row of rows) {
|
|
const key = getRollupConflictKey(row)
|
|
const existing = rowMap.get(key)
|
|
|
|
if (!existing) {
|
|
rowMap.set(key, { ...row })
|
|
continue
|
|
}
|
|
|
|
existing.responseBytes += row.responseBytes
|
|
existing.totalRequests += row.totalRequests
|
|
existing.status2xx += row.status2xx
|
|
existing.status3xx += row.status3xx
|
|
existing.status4xx += row.status4xx
|
|
existing.status5xx += row.status5xx
|
|
existing.statusOther += row.statusOther
|
|
|
|
if (row.requestBytesKnownCount > 0) {
|
|
existing.requestBytes = (existing.requestBytes || 0) + (row.requestBytes || 0)
|
|
existing.requestBytesKnownCount += row.requestBytesKnownCount
|
|
}
|
|
|
|
if (row.generatedAt > existing.generatedAt) {
|
|
existing.generatedAt = row.generatedAt
|
|
}
|
|
|
|
existing.frozen = existing.frozen || row.frozen
|
|
if (row.sourceServer && !existing.sourceServer.includes(row.sourceServer)) {
|
|
existing.sourceServer = existing.sourceServer ? `${existing.sourceServer},${row.sourceServer}` : row.sourceServer
|
|
}
|
|
}
|
|
|
|
return Array.from(rowMap.values())
|
|
}
|
|
|
|
async function ensurePublicTrafficRollupTable() {
|
|
if (rollupTableEnsured) return true
|
|
if (!persistentTrafficCacheEnabled()) return false
|
|
|
|
const { prisma } = await import('./prisma')
|
|
|
|
await prisma.$executeRawUnsafe(`
|
|
CREATE TABLE IF NOT EXISTS "public_traffic_rollups" (
|
|
"id" BIGSERIAL PRIMARY KEY,
|
|
"bucket_start" TIMESTAMPTZ NOT NULL,
|
|
"bucket_end" TIMESTAMPTZ NOT NULL,
|
|
"business_date" TEXT NOT NULL,
|
|
"granularity" TEXT NOT NULL,
|
|
"domain" TEXT NOT NULL,
|
|
"method" TEXT NOT NULL,
|
|
"path" TEXT NOT NULL,
|
|
"purpose_key" TEXT NOT NULL,
|
|
"purpose_label" TEXT NOT NULL,
|
|
"purpose_category" TEXT NOT NULL,
|
|
"purpose_detail" TEXT NOT NULL,
|
|
"response_bytes" BIGINT NOT NULL DEFAULT 0,
|
|
"request_bytes" BIGINT,
|
|
"request_bytes_known_count" INTEGER NOT NULL DEFAULT 0,
|
|
"total_requests" INTEGER NOT NULL DEFAULT 0,
|
|
"status_2xx" INTEGER NOT NULL DEFAULT 0,
|
|
"status_3xx" INTEGER NOT NULL DEFAULT 0,
|
|
"status_4xx" INTEGER NOT NULL DEFAULT 0,
|
|
"status_5xx" INTEGER NOT NULL DEFAULT 0,
|
|
"status_other" INTEGER NOT NULL DEFAULT 0,
|
|
"source_server" TEXT NOT NULL DEFAULT '',
|
|
"generated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
"frozen" BOOLEAN NOT NULL DEFAULT false,
|
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
CONSTRAINT "public_traffic_rollups_unique_bucket_path"
|
|
UNIQUE ("business_date", "granularity", "bucket_start", "bucket_end", "domain", "method", "path")
|
|
)
|
|
`)
|
|
await prisma.$executeRawUnsafe(`
|
|
CREATE INDEX IF NOT EXISTS "public_traffic_rollups_date_granularity_idx"
|
|
ON "public_traffic_rollups" ("business_date", "granularity")
|
|
`)
|
|
await prisma.$executeRawUnsafe(`
|
|
CREATE INDEX IF NOT EXISTS "public_traffic_rollups_bucket_idx"
|
|
ON "public_traffic_rollups" ("bucket_start", "bucket_end")
|
|
`)
|
|
|
|
rollupTableEnsured = true
|
|
return true
|
|
}
|
|
|
|
export async function upsertPublicTrafficRollups(
|
|
inputs: PublicTrafficRollupInput[],
|
|
): Promise<PublicTrafficRollupImportResult> {
|
|
if (!(await ensurePublicTrafficRollupTable())) {
|
|
throw new Error('公网流量汇总表未启用')
|
|
}
|
|
|
|
const rows: NormalizedPublicTrafficRollupRow[] = []
|
|
for (const input of inputs) {
|
|
const row = normalizeRollupInput(input)
|
|
if (row) rows.push(row)
|
|
}
|
|
|
|
if (rows.length === 0) {
|
|
return { received: inputs.length, accepted: 0, skipped: inputs.length }
|
|
}
|
|
|
|
const { prisma } = await import('./prisma')
|
|
const chunkSize = 500
|
|
const mergedRows = mergeRollupRows(rows)
|
|
|
|
for (let index = 0; index < mergedRows.length; index += chunkSize) {
|
|
const chunk = mergedRows.slice(index, index + chunkSize)
|
|
await prisma.$executeRawUnsafe(
|
|
`
|
|
INSERT INTO "public_traffic_rollups" (
|
|
"bucket_start",
|
|
"bucket_end",
|
|
"business_date",
|
|
"granularity",
|
|
"domain",
|
|
"method",
|
|
"path",
|
|
"purpose_key",
|
|
"purpose_label",
|
|
"purpose_category",
|
|
"purpose_detail",
|
|
"response_bytes",
|
|
"request_bytes",
|
|
"request_bytes_known_count",
|
|
"total_requests",
|
|
"status_2xx",
|
|
"status_3xx",
|
|
"status_4xx",
|
|
"status_5xx",
|
|
"status_other",
|
|
"source_server",
|
|
"generated_at",
|
|
"frozen",
|
|
"updated_at"
|
|
)
|
|
SELECT
|
|
"bucketStart"::timestamptz,
|
|
"bucketEnd"::timestamptz,
|
|
"businessDate",
|
|
"granularity",
|
|
"domain",
|
|
"method",
|
|
"path",
|
|
"purposeKey",
|
|
"purposeLabel",
|
|
"purposeCategory",
|
|
"purposeDetail",
|
|
"responseBytes"::bigint,
|
|
NULLIF("requestBytes", '')::bigint,
|
|
"requestBytesKnownCount"::integer,
|
|
"totalRequests"::integer,
|
|
"status2xx"::integer,
|
|
"status3xx"::integer,
|
|
"status4xx"::integer,
|
|
"status5xx"::integer,
|
|
"statusOther"::integer,
|
|
"sourceServer",
|
|
"generatedAt"::timestamptz,
|
|
"frozen"::boolean,
|
|
CURRENT_TIMESTAMP
|
|
FROM jsonb_to_recordset($1::jsonb) AS x(
|
|
"bucketStart" text,
|
|
"bucketEnd" text,
|
|
"businessDate" text,
|
|
"granularity" text,
|
|
"domain" text,
|
|
"method" text,
|
|
"path" text,
|
|
"purposeKey" text,
|
|
"purposeLabel" text,
|
|
"purposeCategory" text,
|
|
"purposeDetail" text,
|
|
"responseBytes" text,
|
|
"requestBytes" text,
|
|
"requestBytesKnownCount" text,
|
|
"totalRequests" text,
|
|
"status2xx" text,
|
|
"status3xx" text,
|
|
"status4xx" text,
|
|
"status5xx" text,
|
|
"statusOther" text,
|
|
"sourceServer" text,
|
|
"generatedAt" text,
|
|
"frozen" text
|
|
)
|
|
ON CONFLICT ("business_date", "granularity", "bucket_start", "bucket_end", "domain", "method", "path")
|
|
DO UPDATE SET
|
|
"purpose_key" = EXCLUDED."purpose_key",
|
|
"purpose_label" = EXCLUDED."purpose_label",
|
|
"purpose_category" = EXCLUDED."purpose_category",
|
|
"purpose_detail" = EXCLUDED."purpose_detail",
|
|
"response_bytes" = EXCLUDED."response_bytes",
|
|
"request_bytes" = EXCLUDED."request_bytes",
|
|
"request_bytes_known_count" = EXCLUDED."request_bytes_known_count",
|
|
"total_requests" = EXCLUDED."total_requests",
|
|
"status_2xx" = EXCLUDED."status_2xx",
|
|
"status_3xx" = EXCLUDED."status_3xx",
|
|
"status_4xx" = EXCLUDED."status_4xx",
|
|
"status_5xx" = EXCLUDED."status_5xx",
|
|
"status_other" = EXCLUDED."status_other",
|
|
"source_server" = EXCLUDED."source_server",
|
|
"generated_at" = EXCLUDED."generated_at",
|
|
"frozen" = EXCLUDED."frozen",
|
|
"updated_at" = CURRENT_TIMESTAMP
|
|
`,
|
|
JSON.stringify(
|
|
chunk.map((row) => ({
|
|
...row,
|
|
requestBytes: row.requestBytes === null ? '' : String(row.requestBytes),
|
|
responseBytes: String(row.responseBytes),
|
|
requestBytesKnownCount: String(row.requestBytesKnownCount),
|
|
totalRequests: String(row.totalRequests),
|
|
status2xx: String(row.status2xx),
|
|
status3xx: String(row.status3xx),
|
|
status4xx: String(row.status4xx),
|
|
status5xx: String(row.status5xx),
|
|
statusOther: String(row.statusOther),
|
|
frozen: String(row.frozen),
|
|
})),
|
|
),
|
|
)
|
|
}
|
|
|
|
return { received: inputs.length, accepted: rows.length, skipped: inputs.length - rows.length }
|
|
}
|
|
|
|
function addRollupRow(
|
|
stats: PublicTrafficStats,
|
|
aggregation: PublicTrafficRollupAggregationState,
|
|
row: PublicTrafficRollupRow,
|
|
) {
|
|
const responseBytes = bigintToNumber(row.response_bytes)
|
|
const requestBytes = row.request_bytes === null ? null : bigintToNumber(row.request_bytes)
|
|
const requestBytesKnownCount = Number(row.request_bytes_known_count || 0)
|
|
const totalRequests = Number(row.total_requests || 0)
|
|
const bucketStart = row.bucket_start instanceof Date ? row.bucket_start : new Date(row.bucket_start)
|
|
const bucketEnd = row.bucket_end instanceof Date ? row.bucket_end : new Date(row.bucket_end)
|
|
const statusCounts: Record<TrafficStatusBucket, number> = {
|
|
'2xx': Number(row.status_2xx || 0),
|
|
'3xx': Number(row.status_3xx || 0),
|
|
'4xx': Number(row.status_4xx || 0),
|
|
'5xx': Number(row.status_5xx || 0),
|
|
other: Number(row.status_other || 0),
|
|
}
|
|
|
|
stats.summary.responseBytes += responseBytes
|
|
if (requestBytesKnownCount > 0) {
|
|
stats.summary.requestBytes = (stats.summary.requestBytes || 0) + (requestBytes || 0)
|
|
stats.summary.requestBytesKnownCount += requestBytesKnownCount
|
|
}
|
|
stats.summary.totalRequests += totalRequests
|
|
for (const bucket of Object.keys(statusCounts) as TrafficStatusBucket[]) {
|
|
stats.summary.statusCounts[bucket] += statusCounts[bucket]
|
|
}
|
|
|
|
const firstSeen = Number.isNaN(bucketStart.getTime()) ? null : bucketStart.toISOString()
|
|
const lastSeen = Number.isNaN(bucketEnd.getTime()) ? null : bucketEnd.toISOString()
|
|
if (firstSeen && (!stats.summary.firstSeen || firstSeen < stats.summary.firstSeen)) stats.summary.firstSeen = firstSeen
|
|
if (lastSeen && (!stats.summary.lastSeen || lastSeen > stats.summary.lastSeen)) stats.summary.lastSeen = lastSeen
|
|
|
|
let dayItem = aggregation.dayMap.get(row.business_date)
|
|
if (!dayItem) {
|
|
dayItem = {
|
|
date: row.business_date,
|
|
responseBytes: 0,
|
|
requestBytes: null,
|
|
requestBytesKnownCount: 0,
|
|
totalRequests: 0,
|
|
}
|
|
stats.byDay.push(dayItem)
|
|
aggregation.dayMap.set(row.business_date, dayItem)
|
|
}
|
|
dayItem.responseBytes += responseBytes
|
|
dayItem.totalRequests += totalRequests
|
|
if (requestBytesKnownCount > 0) {
|
|
dayItem.requestBytes = (dayItem.requestBytes || 0) + (requestBytes || 0)
|
|
dayItem.requestBytesKnownCount += requestBytesKnownCount
|
|
}
|
|
|
|
let domainItem = aggregation.domainMap.get(row.domain)
|
|
if (!domainItem) {
|
|
domainItem = {
|
|
domain: row.domain,
|
|
responseBytes: 0,
|
|
requestBytes: null,
|
|
requestBytesKnownCount: 0,
|
|
totalRequests: 0,
|
|
statusCounts: emptyStatusCounts(),
|
|
}
|
|
stats.byDomain.push(domainItem)
|
|
aggregation.domainMap.set(row.domain, domainItem)
|
|
}
|
|
domainItem.responseBytes += responseBytes
|
|
domainItem.totalRequests += totalRequests
|
|
if (requestBytesKnownCount > 0) {
|
|
domainItem.requestBytes = (domainItem.requestBytes || 0) + (requestBytes || 0)
|
|
domainItem.requestBytesKnownCount += requestBytesKnownCount
|
|
}
|
|
for (const bucket of Object.keys(statusCounts) as TrafficStatusBucket[]) {
|
|
domainItem.statusCounts[bucket] += statusCounts[bucket]
|
|
}
|
|
|
|
let purposeItem = aggregation.purposeMap.get(row.purpose_key)
|
|
if (!purposeItem) {
|
|
purposeItem = {
|
|
key: row.purpose_key,
|
|
label: row.purpose_label,
|
|
category: row.purpose_category,
|
|
detail: row.purpose_detail,
|
|
responseBytes: 0,
|
|
requestBytes: null,
|
|
requestBytesKnownCount: 0,
|
|
totalRequests: 0,
|
|
}
|
|
stats.byPurpose.push(purposeItem)
|
|
aggregation.purposeMap.set(row.purpose_key, purposeItem)
|
|
}
|
|
purposeItem.responseBytes += responseBytes
|
|
purposeItem.totalRequests += totalRequests
|
|
if (requestBytesKnownCount > 0) {
|
|
purposeItem.requestBytes = (purposeItem.requestBytes || 0) + (requestBytes || 0)
|
|
purposeItem.requestBytesKnownCount += requestBytesKnownCount
|
|
}
|
|
|
|
const topPathKey = `${row.method}\u001f${row.path}`
|
|
let topPathItem = aggregation.topPathMap.get(topPathKey)
|
|
if (!topPathItem) {
|
|
topPathItem = {
|
|
path: row.path,
|
|
method: row.method,
|
|
purposeLabel: row.purpose_label,
|
|
purposeCategory: row.purpose_category,
|
|
purposeDetail: row.purpose_detail,
|
|
responseBytes: 0,
|
|
totalRequests: 0,
|
|
}
|
|
stats.topPaths.push(topPathItem)
|
|
aggregation.topPathMap.set(topPathKey, topPathItem)
|
|
}
|
|
topPathItem.responseBytes += responseBytes
|
|
topPathItem.totalRequests += totalRequests
|
|
}
|
|
|
|
async function getPublicTrafficStatsFromRollups(range: PublicTrafficRange): Promise<PublicTrafficStats | null> {
|
|
if (!(await ensurePublicTrafficRollupTable())) return null
|
|
|
|
const startDate = toChinaDateString(range.start)
|
|
const endDate = toChinaDateString(range.end)
|
|
const todayDate = toChinaDateString(new Date())
|
|
const { prisma } = await import('./prisma')
|
|
const rows = await prisma.$queryRawUnsafe<PublicTrafficRollupRow[]>(
|
|
`
|
|
SELECT
|
|
"bucket_start",
|
|
"bucket_end",
|
|
"business_date",
|
|
"granularity",
|
|
"domain",
|
|
"method",
|
|
"path",
|
|
"purpose_key",
|
|
"purpose_label",
|
|
"purpose_category",
|
|
"purpose_detail",
|
|
"response_bytes",
|
|
"request_bytes",
|
|
"request_bytes_known_count",
|
|
"total_requests",
|
|
"status_2xx",
|
|
"status_3xx",
|
|
"status_4xx",
|
|
"status_5xx",
|
|
"status_other"
|
|
FROM "public_traffic_rollups"
|
|
WHERE "business_date" >= $1
|
|
AND "business_date" <= $2
|
|
AND (
|
|
("business_date" = $3 AND "granularity" = '5m')
|
|
OR ("business_date" <> $3 AND "granularity" = 'day')
|
|
)
|
|
ORDER BY "bucket_start" ASC
|
|
`,
|
|
startDate,
|
|
endDate,
|
|
todayDate,
|
|
)
|
|
|
|
if (!rows.length) return null
|
|
|
|
const stats: PublicTrafficStats = {
|
|
generatedAt: new Date().toISOString(),
|
|
available: true,
|
|
range: {
|
|
start: range.start.toISOString(),
|
|
end: range.end.toISOString(),
|
|
},
|
|
summary: {
|
|
responseBytes: 0,
|
|
requestBytes: null,
|
|
requestBytesKnownCount: 0,
|
|
totalRequests: 0,
|
|
uniqueIpCount: 0,
|
|
statusCounts: emptyStatusCounts(),
|
|
firstSeen: null,
|
|
lastSeen: null,
|
|
},
|
|
byDay: [],
|
|
byDomain: [],
|
|
byPurpose: [],
|
|
topPaths: [],
|
|
source: {
|
|
logPaths: [],
|
|
scannedFiles: 0,
|
|
scannedLines: 0,
|
|
matchedLines: 0,
|
|
skippedLines: 0,
|
|
truncated: false,
|
|
issues: [],
|
|
requestBytesAvailable: false,
|
|
cache: {
|
|
persisted: true,
|
|
hit: true,
|
|
stale: false,
|
|
refreshInProgress: false,
|
|
refreshedAt: null,
|
|
expiresAt: null,
|
|
},
|
|
},
|
|
}
|
|
|
|
const aggregation = createRollupAggregationState()
|
|
for (const row of rows) addRollupRow(stats, aggregation, row)
|
|
|
|
stats.source.matchedLines = stats.summary.totalRequests
|
|
stats.source.requestBytesAvailable = stats.summary.requestBytesKnownCount > 0
|
|
stats.byDay.sort((a, b) => a.date.localeCompare(b.date))
|
|
stats.byDomain.sort((a, b) => b.responseBytes - a.responseBytes)
|
|
stats.byPurpose.sort((a, b) => b.responseBytes - a.responseBytes)
|
|
stats.topPaths.sort((a, b) => b.responseBytes - a.responseBytes)
|
|
stats.topPaths = stats.topPaths.slice(0, 20)
|
|
|
|
return stats
|
|
}
|
|
|
|
function getCachedStats(cacheKey: string): PublicTrafficStats | null {
|
|
const cached = statsCache.get(cacheKey)
|
|
if (!cached) return null
|
|
if (cached.expiresAt <= Date.now()) {
|
|
statsCache.delete(cacheKey)
|
|
return null
|
|
}
|
|
statsCache.delete(cacheKey)
|
|
statsCache.set(cacheKey, cached)
|
|
return cached.stats
|
|
}
|
|
|
|
function setCachedStats(cacheKey: string, stats: PublicTrafficStats, ttlSeconds = 60) {
|
|
statsCache.set(cacheKey, {
|
|
key: cacheKey,
|
|
createdAt: Date.now(),
|
|
expiresAt: Date.now() + ttlSeconds * 1000,
|
|
stats,
|
|
})
|
|
|
|
while (statsCache.size > MAX_CACHE_ENTRIES) {
|
|
const oldestKey = statsCache.keys().next().value
|
|
if (!oldestKey) break
|
|
statsCache.delete(oldestKey)
|
|
}
|
|
}
|
|
|
|
async function scanPublicTrafficStats(range: PublicTrafficRange, context: PublicTrafficScanContext): Promise<PublicTrafficStats> {
|
|
const aggregation = createAggregationState()
|
|
|
|
const stats: PublicTrafficStats = {
|
|
generatedAt: new Date().toISOString(),
|
|
available: false,
|
|
range: {
|
|
start: range.start.toISOString(),
|
|
end: range.end.toISOString(),
|
|
},
|
|
summary: {
|
|
responseBytes: 0,
|
|
requestBytes: null,
|
|
requestBytesKnownCount: 0,
|
|
totalRequests: 0,
|
|
uniqueIpCount: 0,
|
|
statusCounts: emptyStatusCounts(),
|
|
firstSeen: null,
|
|
lastSeen: null,
|
|
},
|
|
byDay: [],
|
|
byDomain: [],
|
|
byPurpose: [],
|
|
topPaths: [],
|
|
source: {
|
|
logPaths: context.logPaths,
|
|
scannedFiles: 0,
|
|
scannedLines: 0,
|
|
matchedLines: 0,
|
|
skippedLines: 0,
|
|
truncated: false,
|
|
issues: [],
|
|
requestBytesAvailable: false,
|
|
},
|
|
}
|
|
|
|
for (const logPath of context.logPaths) {
|
|
try {
|
|
await scanLogFile(logPath, range, stats, aggregation, context.maxLines)
|
|
} catch (error) {
|
|
stats.source.issues.push({
|
|
path: logPath,
|
|
reason: error instanceof Error ? error.message : '读取失败',
|
|
})
|
|
}
|
|
|
|
if (stats.source.truncated) break
|
|
}
|
|
|
|
stats.available = stats.source.scannedFiles > 0
|
|
stats.summary.uniqueIpCount = aggregation.uniqueIps.size
|
|
stats.source.requestBytesAvailable = stats.summary.requestBytesKnownCount > 0
|
|
stats.byDay.sort((a, b) => a.date.localeCompare(b.date))
|
|
stats.byDomain.sort((a, b) => b.responseBytes - a.responseBytes)
|
|
stats.byPurpose.sort((a, b) => b.responseBytes - a.responseBytes)
|
|
stats.topPaths.sort((a, b) => b.responseBytes - a.responseBytes)
|
|
stats.topPaths = stats.topPaths.slice(0, 20)
|
|
|
|
return stats
|
|
}
|
|
|
|
async function refreshPublicTrafficStatsInBackground(
|
|
cacheKey: string,
|
|
range: PublicTrafficRange,
|
|
context: PublicTrafficScanContext,
|
|
) {
|
|
if (refreshPromises.has(cacheKey)) return
|
|
|
|
const promise = (async () => {
|
|
try {
|
|
const stats = await scanPublicTrafficStats(range, context)
|
|
await writePersistentTrafficCache(cacheKey, range, context, stats)
|
|
setCachedStats(cacheKey, stats, context.ttlSeconds)
|
|
} catch (error) {
|
|
console.error('[public-traffic] 后台刷新公网流量统计失败:', error)
|
|
} finally {
|
|
refreshPromises.delete(cacheKey)
|
|
}
|
|
})()
|
|
|
|
refreshPromises.set(cacheKey, promise)
|
|
}
|
|
|
|
function parsePersistentStats(row: PersistentTrafficCacheRow): PublicTrafficStats | null {
|
|
const rawStats = typeof row.stats === 'string' ? JSON.parse(row.stats) : row.stats
|
|
if (!rawStats || typeof rawStats !== 'object') return null
|
|
return rawStats as PublicTrafficStats
|
|
}
|
|
|
|
export async function getPublicTrafficStats(
|
|
range: PublicTrafficRange,
|
|
options: PublicTrafficStatsOptions = {},
|
|
): Promise<PublicTrafficStats> {
|
|
try {
|
|
const rollupStats = await getPublicTrafficStatsFromRollups(range)
|
|
if (rollupStats) return rollupStats
|
|
} catch (error) {
|
|
console.error('[public-traffic] 读取公网流量汇总表失败:', error)
|
|
}
|
|
|
|
const context = getScanContext(range)
|
|
const cacheKey = context.stableCacheKey
|
|
|
|
if (!options.forceRefresh) {
|
|
const cachedStats = getCachedStats(cacheKey)
|
|
if (cachedStats) {
|
|
return withCacheMetadata(cachedStats, {
|
|
persisted: false,
|
|
hit: true,
|
|
stale: false,
|
|
refreshInProgress: refreshPromises.has(cacheKey),
|
|
refreshedAt: cachedStats.generatedAt,
|
|
expiresAt: null,
|
|
})
|
|
}
|
|
|
|
try {
|
|
const persistentRow = await readPersistentTrafficCache(cacheKey)
|
|
if (persistentRow) {
|
|
const persistentStats = parsePersistentStats(persistentRow)
|
|
if (persistentStats) {
|
|
const refreshedAt = new Date(persistentRow.refreshed_at)
|
|
const expiresAt = new Date(persistentRow.expires_at)
|
|
const stale = expiresAt.getTime() <= Date.now()
|
|
|
|
if (stale) {
|
|
await refreshPublicTrafficStatsInBackground(cacheKey, range, context)
|
|
} else {
|
|
const secondsToExpire = Math.floor((expiresAt.getTime() - Date.now()) / 1000)
|
|
setCachedStats(cacheKey, persistentStats, Math.max(10, secondsToExpire))
|
|
}
|
|
|
|
return withCacheMetadata(persistentStats, {
|
|
persisted: true,
|
|
hit: true,
|
|
stale,
|
|
refreshInProgress: refreshPromises.has(cacheKey),
|
|
refreshedAt: Number.isNaN(refreshedAt.getTime()) ? null : refreshedAt.toISOString(),
|
|
expiresAt: Number.isNaN(expiresAt.getTime()) ? null : expiresAt.toISOString(),
|
|
})
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('[public-traffic] 读取公网流量缓存失败,回退实时统计:', error)
|
|
}
|
|
}
|
|
|
|
const stats = await scanPublicTrafficStats(range, context)
|
|
|
|
try {
|
|
await writePersistentTrafficCache(cacheKey, range, context, stats)
|
|
} catch (error) {
|
|
console.error('[public-traffic] 写入公网流量缓存失败:', error)
|
|
}
|
|
|
|
setCachedStats(cacheKey, stats, context.ttlSeconds)
|
|
|
|
return withCacheMetadata(stats, {
|
|
persisted: persistentTrafficCacheEnabled(),
|
|
hit: false,
|
|
stale: false,
|
|
refreshInProgress: false,
|
|
refreshedAt: stats.generatedAt,
|
|
expiresAt: new Date(Date.now() + context.ttlSeconds * 1000).toISOString(),
|
|
})
|
|
}
|