固化公网流量汇总链路

This commit is contained in:
chenpei 2026-07-08 14:35:15 +08:00
parent 85c545eeb0
commit 2047e4eb2e
6 changed files with 1060 additions and 0 deletions

View File

@ -82,6 +82,8 @@ RUN sed -i "s|https://dl-cdn.alpinelinux.org/alpine|${ALPINE_MIRROR}|g" /etc/apk
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
RUN mkdir -p /app/scripts
# 复制必要文件
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
@ -98,9 +100,11 @@ COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@smithy ./node_modul
# 只复制 schema.prisma,不复制 migrations 和 engine-cache(减少 35MB)
COPY --from=builder --chown=nextjs:nodejs /app/prisma/schema.prisma ./prisma/schema.prisma
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib
COPY --from=builder --chown=nextjs:nodejs /app/scripts/public-traffic-rollup-from-nginx.py ./scripts/public-traffic-rollup-from-nginx.py
# 创建必要目录并设置权限
RUN mkdir -p /app/public /app/uploads && \
mkdir -p /app/scripts && \
mkdir -p /app/uploads/temp && \
mkdir -p /app/uploads/assembled && \
mkdir -p /app/uploads/materials && \

View File

@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server'
import {
upsertPublicTrafficRollups,
type PublicTrafficRollupInput,
} from '@/lib/public-traffic-stats'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
const MAX_ROWS_PER_REQUEST = 2000
function readInternalToken(request: NextRequest) {
const bearer = request.headers.get('authorization')?.match(/^Bearer\s+(.+)$/i)?.[1]
return bearer || request.headers.get('x-internal-job-token') || ''
}
function normalizeRows(payload: unknown): PublicTrafficRollupInput[] {
if (!payload || typeof payload !== 'object') return []
const rows = (payload as { rows?: unknown }).rows
return Array.isArray(rows) ? rows.slice(0, MAX_ROWS_PER_REQUEST) as PublicTrafficRollupInput[] : []
}
export async function POST(request: NextRequest) {
const expectedToken = process.env.PUBLIC_TRAFFIC_ROLLUP_TOKEN
if (!expectedToken) {
return NextResponse.json({ error: '公网流量汇总写入未启用' }, { status: 404 })
}
if (readInternalToken(request) !== expectedToken) {
return NextResponse.json({ error: '无权限写入公网流量汇总' }, { status: 403 })
}
const payload = await request.json().catch(() => null)
const rows = normalizeRows(payload)
if (rows.length === 0) {
return NextResponse.json({ error: '没有可写入的公网流量汇总行' }, { status: 400 })
}
const startedAt = Date.now()
const result = await upsertPublicTrafficRollups(rows)
return NextResponse.json({
ok: true,
durationMs: Date.now() - startedAt,
...result,
})
}

View File

@ -3,6 +3,7 @@ import os from 'os'
import path from 'path'
import {
classifyPublicTrafficPurpose,
getPublicTrafficPresetRange,
getPublicTrafficStats,
normalizeTrafficRange,
parsePublicTrafficLogLine,
@ -52,6 +53,20 @@ describe('public traffic stats', () => {
expect(range.end.toISOString()).toBe('2026-07-04T15:59:59.999Z')
})
test('builds public traffic presets using China business dates', () => {
const now = new Date('2026-07-08T05:00:00.000Z')
const today = getPublicTrafficPresetRange('today', now)
const last7 = getPublicTrafficPresetRange('last7', now)
const last30 = getPublicTrafficPresetRange('last30', now)
expect(today.start.toISOString()).toBe('2026-07-07T16:00:00.000Z')
expect(today.end.toISOString()).toBe('2026-07-08T15:59:59.999Z')
expect(last7.start.toISOString()).toBe('2026-07-01T16:00:00.000Z')
expect(last7.end.toISOString()).toBe('2026-07-08T15:59:59.999Z')
expect(last30.start.toISOString()).toBe('2026-06-08T16:00:00.000Z')
expect(last30.end.toISOString()).toBe('2026-07-08T15:59:59.999Z')
})
test('groups daily traffic by China business date', async () => {
const previousPaths = process.env.PUBLIC_TRAFFIC_LOG_PATHS
const tempDir = mkdtempSync(path.join(os.tmpdir(), 'public-traffic-'))

View File

@ -123,6 +123,34 @@ 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
@ -137,6 +165,29 @@ interface PersistentTrafficCacheRow {
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
}
const MONTHS: Record<string, number> = {
Jan: 0,
Feb: 1,
@ -165,6 +216,7 @@ 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 {
@ -218,6 +270,26 @@ export function normalizeTrafficRange(startValue?: string | null, endValue?: str
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
@ -941,6 +1013,486 @@ async function writePersistentTrafficCache(
)
}
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),
}
}
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: Array<NonNullable<ReturnType<typeof normalizeRollupInput>>> = []
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
for (let index = 0; index < rows.length; index += chunkSize) {
const chunk = rows.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, 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 = stats.byDay.find((item) => item.date === row.business_date)
if (!dayItem) {
dayItem = {
date: row.business_date,
responseBytes: 0,
requestBytes: null,
requestBytesKnownCount: 0,
totalRequests: 0,
}
stats.byDay.push(dayItem)
}
dayItem.responseBytes += responseBytes
dayItem.totalRequests += totalRequests
if (requestBytesKnownCount > 0) {
dayItem.requestBytes = (dayItem.requestBytes || 0) + (requestBytes || 0)
dayItem.requestBytesKnownCount += requestBytesKnownCount
}
let domainItem = stats.byDomain.find((item) => item.domain === row.domain)
if (!domainItem) {
domainItem = {
domain: row.domain,
responseBytes: 0,
requestBytes: null,
requestBytesKnownCount: 0,
totalRequests: 0,
statusCounts: emptyStatusCounts(),
}
stats.byDomain.push(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 = stats.byPurpose.find((item) => item.key === 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)
}
purposeItem.responseBytes += responseBytes
purposeItem.totalRequests += totalRequests
if (requestBytesKnownCount > 0) {
purposeItem.requestBytes = (purposeItem.requestBytes || 0) + (requestBytes || 0)
purposeItem.requestBytesKnownCount += requestBytesKnownCount
}
let topPathItem = stats.topPaths.find((item) => item.method === row.method && item.path === row.path)
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)
}
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,
},
},
}
for (const row of rows) addRollupRow(stats, 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
@ -1061,6 +1613,13 @@ 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

View File

@ -528,6 +528,10 @@ start_container() {
run_args+=(-e "DOWNLOAD_TOKEN_SECRET=$DOWNLOAD_TOKEN_SECRET")
fi
if [ -n "${PUBLIC_TRAFFIC_ROLLUP_TOKEN:-}" ]; then
run_args+=(-e "PUBLIC_TRAFFIC_ROLLUP_TOKEN=$PUBLIC_TRAFFIC_ROLLUP_TOKEN")
fi
"${run_args[@]}" "$image"
}
@ -557,6 +561,139 @@ PROXY_SERVICE="${CONTAINER_NAME}-local-proxy.service"
CURRENT_FILE="${PROXY_ROOT}/current-container"
BLUE_CONTAINER="${CONTAINER_NAME}-blue"
GREEN_CONTAINER="${CONTAINER_NAME}-green"
PUBLIC_TRAFFIC_SYNC_ROOT="/mnt/PoolOne/apps/youyidian-3d/public-traffic-sync"
PUBLIC_TRAFFIC_ROLLUP_TOKEN_FILE="${PUBLIC_TRAFFIC_SYNC_ROOT}/rollup.token"
PUBLIC_TRAFFIC_LOG_SYNC_SCRIPT="${PUBLIC_TRAFFIC_SYNC_ROOT}/public-traffic-log-sync.sh"
PUBLIC_TRAFFIC_ROLLUP_SCRIPT="${PUBLIC_TRAFFIC_SYNC_ROOT}/public-traffic-rollup-sync.sh"
PUBLIC_TRAFFIC_ROLLUP_SERVICE="public-traffic-rollup-sync.service"
PUBLIC_TRAFFIC_ROLLUP_TIMER="public-traffic-rollup-sync.timer"
PUBLIC_TRAFFIC_ROLLUP_TOKEN=""
ensure_public_traffic_rollup_token() {
if [ "$TARGET" != "production" ]; then
return 0
fi
mkdir -p "$PUBLIC_TRAFFIC_SYNC_ROOT"
chmod 700 "$PUBLIC_TRAFFIC_SYNC_ROOT"
if [ ! -s "$PUBLIC_TRAFFIC_ROLLUP_TOKEN_FILE" ]; then
if command -v openssl >/dev/null 2>&1; then
openssl rand -hex 32 > "$PUBLIC_TRAFFIC_ROLLUP_TOKEN_FILE"
else
tr -dc 'A-Za-z0-9' </dev/urandom | head -c 64 > "$PUBLIC_TRAFFIC_ROLLUP_TOKEN_FILE"
fi
fi
chmod 600 "$PUBLIC_TRAFFIC_ROLLUP_TOKEN_FILE"
PUBLIC_TRAFFIC_ROLLUP_TOKEN="$(tr -d '\r\n' < "$PUBLIC_TRAFFIC_ROLLUP_TOKEN_FILE")"
}
install_public_traffic_rollup_timers() {
if [ "$TARGET" != "production" ]; then
return 0
fi
mkdir -p "$PUBLIC_TRAFFIC_SYNC_ROOT"
cat > "$PUBLIC_TRAFFIC_LOG_SYNC_SCRIPT" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
remote='trafficlog@218.93.9.38'
key='/root/.ssh/public_traffic_log_sync_ed25519'
dest='/mnt/PoolOne/apps/youyidian-3d/public-traffic-logs'
ssh_opts="ssh -i ${key} -o BatchMode=yes -o StrictHostKeyChecking=yes -o ConnectTimeout=10"
domains=(
sc.jiansun.vip
s3.jiansun.vip
cp.jiansun.vip
)
mkdir -p "$dest"
chmod 755 "$dest"
status=0
for domain in "${domains[@]}"; do
if ! rsync -rtz --timeout=45 --chmod=F644,D755 -e "$ssh_opts" "${remote}:/var/log/nginx/${domain}.access.log*" "$dest/"; then
echo "WARN: failed to sync ${domain}.access.log*" >&2
status=1
fi
done
find "$dest" -maxdepth 1 -type f -name '*.access.log*' -exec chmod 0644 {} +
exit "$status"
EOF
chmod 700 "$PUBLIC_TRAFFIC_LOG_SYNC_SCRIPT"
cat > "/etc/systemd/system/public-traffic-log-sync.service" <<EOF
[Unit]
Description=Sync public traffic nginx logs from tunnel server
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=${PUBLIC_TRAFFIC_LOG_SYNC_SCRIPT}
TimeoutStartSec=180
EOF
cat > "/etc/systemd/system/public-traffic-log-sync.timer" <<EOF
[Unit]
Description=Run public traffic log sync every 5 minutes
[Timer]
OnBootSec=1min
OnUnitActiveSec=5min
Persistent=true
[Install]
WantedBy=timers.target
EOF
cat > "$PUBLIC_TRAFFIC_ROLLUP_SCRIPT" <<EOF
#!/usr/bin/env bash
set -euo pipefail
current_file='${CURRENT_FILE}'
container="\$(cat "\$current_file" 2>/dev/null || true)"
if [ -z "\$container" ] || ! docker inspect "\$container" >/dev/null 2>&1; then
echo "No active material container found" >&2
exit 1
fi
systemctl start public-traffic-log-sync.service >/dev/null 2>&1 || true
docker exec "\$container" python3 /app/scripts/public-traffic-rollup-from-nginx.py --granularity 5m
yesterday="\$(TZ=Asia/Shanghai date -d 'yesterday' +%F)"
docker exec "\$container" python3 /app/scripts/public-traffic-rollup-from-nginx.py --granularity day --start-date "\$yesterday" --end-date "\$yesterday"
EOF
chmod 700 "$PUBLIC_TRAFFIC_ROLLUP_SCRIPT"
cat > "/etc/systemd/system/${PUBLIC_TRAFFIC_ROLLUP_SERVICE}" <<EOF
[Unit]
Description=Sync public traffic statistics rollups
After=network-online.target ${PROXY_SERVICE}
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=${PUBLIC_TRAFFIC_ROLLUP_SCRIPT}
TimeoutStartSec=360
EOF
cat > "/etc/systemd/system/${PUBLIC_TRAFFIC_ROLLUP_TIMER}" <<EOF
[Unit]
Description=Run public traffic statistics rollup sync
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
RandomizedDelaySec=30s
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable public-traffic-log-sync.timer >/dev/null 2>&1 || true
systemctl enable "$PUBLIC_TRAFFIC_ROLLUP_TIMER" >/dev/null 2>&1 || true
systemctl restart public-traffic-log-sync.timer >/dev/null 2>&1 || true
systemctl restart "$PUBLIC_TRAFFIC_ROLLUP_TIMER" >/dev/null 2>&1 || true
}
is_proxy_running() {
systemctl is-active --quiet "$PROXY_SERVICE" 2>/dev/null || {
@ -703,6 +840,8 @@ if is_proxy_running; then
PROXY_WAS_RUNNING=1
fi
ensure_public_traffic_rollup_token
log "Starting candidate container $NEXT_CONTAINER"
docker rm -f "$NEXT_CONTAINER" >/dev/null 2>&1 || true
start_container "$NEXT_CONTAINER" "$REGISTRY_COMMIT" 0 >/tmp/git-deploy-new-container-id.txt
@ -762,6 +901,28 @@ printf '%s\n' "$NEXT_CONTAINER" > "$CURRENT_FILE"
ok "Stable local entry is healthy on port $PORT"
docker logs --tail 20 "$NEXT_CONTAINER" 2>&1 || true
if install_public_traffic_rollup_timers; then
if [ "$TARGET" = "production" ]; then
if systemctl start "$PUBLIC_TRAFFIC_ROLLUP_SERVICE" >/tmp/public-traffic-rollup-sync.log 2>/tmp/public-traffic-rollup-sync.err; then
ok "Public traffic statistics rollups synced"
else
warn "Public traffic statistics rollup sync failed; page remains available, check /tmp/public-traffic-rollup-sync.err"
cat /tmp/public-traffic-rollup-sync.err >&2 || true
fi
backfill_start="$(TZ=Asia/Shanghai date -d '7 days ago' +%F)"
backfill_end="$(TZ=Asia/Shanghai date -d 'yesterday' +%F)"
if docker exec "$NEXT_CONTAINER" python3 /app/scripts/public-traffic-rollup-from-nginx.py --granularity day --start-date "$backfill_start" --end-date "$backfill_end" >/tmp/public-traffic-rollup-backfill.log 2>/tmp/public-traffic-rollup-backfill.err; then
ok "Public traffic historical day rollups backfilled from ${backfill_start} to ${backfill_end}"
else
warn "Public traffic historical backfill failed; check /tmp/public-traffic-rollup-backfill.err"
cat /tmp/public-traffic-rollup-backfill.err >&2 || true
fi
fi
else
warn "Public traffic statistics rollup timers were not installed"
fi
if [ -n "$CURRENT_CONTAINER" ] && [ "$CURRENT_CONTAINER" != "$NEXT_CONTAINER" ]; then
docker rm -f "$CURRENT_CONTAINER" >/dev/null 2>&1 || true
fi

View File

@ -0,0 +1,272 @@
#!/usr/bin/env python3
import argparse
import gzip
import json
import os
import re
import socket
import sys
import urllib.error
import urllib.parse
import urllib.request
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from pathlib import Path
CHINA_TZ = timezone(timedelta(hours=8))
DOMAINS = ("sc.jiansun.vip", "s3.jiansun.vip", "cp.jiansun.vip")
LOG_RE = re.compile(
r'^(?P<remote>\S+) \S+ \S+ \[(?P<time>[^\]]+)\] '
r'"(?P<method>[A-Z]+) (?P<target>\S+) HTTP/[^"]+" '
r'(?P<status>\d{3}) (?P<body_bytes>\S+)(?P<rest>.*)$'
)
def parse_args():
parser = argparse.ArgumentParser(description="Aggregate public traffic nginx logs and push rollups.")
parser.add_argument("--log-dir", default="/var/log/nginx")
parser.add_argument("--endpoint", default="http://127.0.0.1:3000/api/internal/public-traffic/rollups")
parser.add_argument("--token-env", default="PUBLIC_TRAFFIC_ROLLUP_TOKEN")
parser.add_argument("--granularity", choices=("5m", "day"), required=True)
parser.add_argument("--start-date", help="China business date, YYYY-MM-DD")
parser.add_argument("--end-date", help="China business date, YYYY-MM-DD")
parser.add_argument("--source-server", default=socket.gethostname())
parser.add_argument("--batch-size", type=int, default=1000)
parser.add_argument("--domains", default=",".join(DOMAINS))
return parser.parse_args()
def parse_date(value):
return datetime.strptime(value, "%Y-%m-%d").date()
def default_dates(granularity):
today = datetime.now(CHINA_TZ).date()
if granularity == "5m":
return today, today
yesterday = today - timedelta(days=1)
return yesterday, yesterday
def iter_log_paths(log_dir, domains):
root = Path(log_dir)
paths = []
for domain in domains:
paths.extend(root.glob(f"{domain}.access.log*"))
return sorted(paths, key=lambda item: item.name)
def open_log(path):
if str(path).endswith(".gz"):
return gzip.open(path, "rt", encoding="utf-8", errors="replace")
return open(path, "rt", encoding="utf-8", errors="replace")
def infer_domain(path):
name = Path(path).name
for domain in DOMAINS:
if name.startswith(f"{domain}."):
return domain
return name.split(".access.log", 1)[0]
def normalize_target(target):
try:
parsed = urllib.parse.urlsplit(target)
path = parsed.path or "/"
except Exception:
path = target.split("?", 1)[0] or "/"
if not path.startswith("/"):
path = "/" + path
return path
def parse_size(value):
if value == "-":
return 0
try:
return max(0, int(value))
except ValueError:
return 0
def parse_extra_int(rest, key):
match = re.search(rf"{re.escape(key)}=(\d+)", rest)
return int(match.group(1)) if match else None
def status_bucket(status):
if 200 <= status <= 299:
return "2xx"
if 300 <= status <= 399:
return "3xx"
if 400 <= status <= 499:
return "4xx"
if 500 <= status <= 599:
return "5xx"
return "other"
def parse_line(line, domain):
match = LOG_RE.match(line)
if not match:
return None
try:
timestamp = datetime.strptime(match.group("time"), "%d/%b/%Y:%H:%M:%S %z").astimezone(CHINA_TZ)
status = int(match.group("status"))
except ValueError:
return None
rest = match.group("rest") or ""
response_bytes = parse_extra_int(rest, "bytes_sent")
if response_bytes is None:
response_bytes = parse_size(match.group("body_bytes"))
request_bytes = parse_extra_int(rest, "request_length")
return {
"timestamp": timestamp,
"businessDate": timestamp.date().isoformat(),
"domain": domain,
"method": match.group("method").upper(),
"path": normalize_target(match.group("target")),
"statusBucket": status_bucket(status),
"responseBytes": response_bytes,
"requestBytes": request_bytes,
}
def bucket_for(timestamp, granularity):
if granularity == "day":
start = timestamp.replace(hour=0, minute=0, second=0, microsecond=0)
end = start + timedelta(days=1)
return start, end
minute = (timestamp.minute // 5) * 5
start = timestamp.replace(minute=minute, second=0, microsecond=0)
end = start + timedelta(minutes=5)
return start, end
def iso_utc(value):
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
def aggregate(args):
domains = tuple(item.strip() for item in args.domains.split(",") if item.strip())
start_default, end_default = default_dates(args.granularity)
start_date = parse_date(args.start_date) if args.start_date else start_default
end_date = parse_date(args.end_date) if args.end_date else end_default
rows = {}
scanned_lines = 0
matched_lines = 0
for path in iter_log_paths(args.log_dir, domains):
domain = infer_domain(path)
try:
handle = open_log(path)
except OSError as exc:
print(f"WARN: cannot open {path}: {exc}", file=sys.stderr)
continue
with handle:
for line in handle:
scanned_lines += 1
entry = parse_line(line, domain)
if not entry:
continue
entry_date = parse_date(entry["businessDate"])
if entry_date < start_date or entry_date > end_date:
continue
matched_lines += 1
bucket_start, bucket_end = bucket_for(entry["timestamp"], args.granularity)
key = (
iso_utc(bucket_start),
iso_utc(bucket_end),
entry["businessDate"],
args.granularity,
entry["domain"],
entry["method"],
entry["path"],
)
row = rows.setdefault(
key,
{
"bucketStart": key[0],
"bucketEnd": key[1],
"businessDate": key[2],
"granularity": key[3],
"domain": key[4],
"method": key[5],
"path": key[6],
"responseBytes": 0,
"requestBytes": None,
"requestBytesKnownCount": 0,
"totalRequests": 0,
"statusCounts": defaultdict(int),
"sourceServer": args.source_server,
"generatedAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"frozen": args.granularity == "day",
},
)
row["responseBytes"] += entry["responseBytes"]
row["totalRequests"] += 1
row["statusCounts"][entry["statusBucket"]] += 1
if entry["requestBytes"] is not None:
row["requestBytes"] = (row["requestBytes"] or 0) + entry["requestBytes"]
row["requestBytesKnownCount"] += 1
normalized_rows = []
for row in rows.values():
row["statusCounts"] = dict(row["statusCounts"])
normalized_rows.append(row)
return normalized_rows, scanned_lines, matched_lines
def post_rows(endpoint, token, rows, batch_size):
accepted = 0
for index in range(0, len(rows), batch_size):
chunk = rows[index:index + batch_size]
body = json.dumps({"rows": chunk}, ensure_ascii=False).encode("utf-8")
request = urllib.request.Request(
endpoint,
data=body,
method="POST",
headers={
"Content-Type": "application/json",
"X-Internal-Job-Token": token,
},
)
try:
with urllib.request.urlopen(request, timeout=240) as response:
data = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"POST failed HTTP {exc.code}: {detail}") from exc
accepted += int(data.get("accepted", 0))
return accepted
def main():
args = parse_args()
token = os.environ.get(args.token_env, "").strip()
if not token:
raise SystemExit(f"Missing token env: {args.token_env}")
rows, scanned_lines, matched_lines = aggregate(args)
if rows:
accepted = post_rows(args.endpoint, token, rows, max(1, args.batch_size))
else:
accepted = 0
print(json.dumps({
"ok": True,
"granularity": args.granularity,
"rows": len(rows),
"accepted": accepted,
"scannedLines": scanned_lines,
"matchedLines": matched_lines,
}, ensure_ascii=False))
if __name__ == "__main__":
main()