refactor: 改进 Cloudflare 缓存清理与独立域名的流量统计面板

This commit is contained in:
seahi committed 2026-09-26 19:28:27 +08:00
1 parent fa8c04e297
commit 6ad717533c
10 files changed
+570 -559

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
export interface TrafficRow {
count: number;
sum: { edgeResponseBytes: number };
dimensions: { date: string; cacheStatus: string; clientRequestPath: string };
}
export interface TrafficScope {
label: string;
hostname: string;
days: number;
rows: TrafficRow[];
error?: string;
}
export interface AnalyticsData {
scopes: TrafficScope[];
d1: { sum: { rowsRead: number; rowsWritten: number } }[];
r2: { max: { payloadSize: number } }[];
missingEnvVars: boolean;
errorMsg?: string;
}
export function summarizeTraffic(rows: TrafficRow[]) {
let requests = 0;
let bytes = 0;
let hits = 0;
let privateRequests = 0;
const statuses: Record<string, number> = {};
const daily = new Map<string, number>();
for (const row of rows) {
requests += row.count;
bytes += row.sum.edgeResponseBytes;
const status = row.dimensions.cacheStatus.toUpperCase();
statuses[status] = (statuses[status] || 0) + row.count;
if (status === 'HIT') hits += row.count;
if (/^\/(admin|api)(\/|$)/.test(row.dimensions.clientRequestPath)) {
privateRequests += row.count;
}
daily.set(row.dimensions.date, (daily.get(row.dimensions.date) || 0) + row.count);
}
return {
requests,
bytes,
hits,
privateRequests,
hitRate: requests ? (hits / requests) * 100 : null,
statuses,
daily: [...daily].sort(([a], [b]) => a.localeCompare(b))
};
}
+179 -366
View File
@@ -1,180 +1,102 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
let { analyticsPromise } = $props<{
analyticsPromise: Promise<any>;
}>();
let chartCanvas = $state<HTMLCanvasElement | undefined>(undefined);
let chartInstance: any = null;
import type { AnalyticsData } from '$lib/analytics';
import { summarizeTraffic } from '$lib/analytics';
let { analyticsPromise }: { analyticsPromise: Promise<AnalyticsData> } = $props();
let data = $state<AnalyticsData | null>(null);
let loading = $state(true);
let trafficData: any[] = $state([]);
let d1Data: any[] = $state([]);
let r2Data: any[] = $state([]);
let missingEnvVars = $state(false);
let error = $state('');
let totalRequests = $state(0);
let totalCached = $state(0);
let totalBytes = $state(0);
let hitRate = $state(0);
let totalPV = $state(0);
let totalUV = $state(0);
let d1RowsRead = $state(0);
let d1RowsWritten = $state(0);
let r2PayloadSize = $state(0);
let d1ReadDaily = $derived(d1Data.length > 0 ? Math.round(d1RowsRead / 30) : 0);
let d1ReadPercent = $derived(d1Data.length > 0 ? (d1ReadDaily / 5000000) * 100 : 0);
let d1WriteDaily = $derived(d1Data.length > 0 ? Math.round(d1RowsWritten / 30) : 0);
let d1WritePercent = $derived(d1Data.length > 0 ? (d1WriteDaily / 100000) * 100 : 0);
let r2Percent = $derived(
r2Data.length > 0 ? (r2PayloadSize / (10 * 1024 * 1024 * 1024)) * 100 : 0
let chartCanvas = $state<HTMLCanvasElement>();
let scopes = $derived(
(data?.scopes || []).map((scope) => ({ ...scope, summary: summarizeTraffic(scope.rows) }))
);
let d1Data = $derived(data?.d1 || []);
let r2Data = $derived(data?.r2 || []);
let missingEnvVars = $derived(data?.missingEnvVars ?? true);
let d1ReadDaily = $derived(
Math.round(d1Data.reduce((sum, row) => sum + row.sum.rowsRead, 0) / 30)
);
let d1WriteDaily = $derived(
Math.round(d1Data.reduce((sum, row) => sum + row.sum.rowsWritten, 0) / 30)
);
let d1ReadPercent = $derived((d1ReadDaily / 5000000) * 100);
let d1WritePercent = $derived((d1WriteDaily / 100000) * 100);
let r2PayloadSize = $derived(Math.max(0, ...r2Data.map((row) => row.max.payloadSize)));
let r2Percent = $derived((r2PayloadSize / (10 * 1024 * 1024 * 1024)) * 100);
$effect(() => {
let cancelled = false;
loading = true;
data = null;
error = '';
analyticsPromise
.then((data: any) => {
if (data) {
trafficData = data.traffic || [];
d1Data = data.d1 || [];
r2Data = data.r2 || [];
missingEnvVars = data.missingEnvVars || false;
if (trafficData.length > 0) {
trafficData.forEach((day: any) => {
totalRequests += day.sum.requests || 0;
totalCached += day.sum.cachedRequests || 0;
totalBytes += day.sum.bytes || 0;
totalPV += day.sum.pageViews || 0;
totalUV += day.uniq?.uniques || 0;
});
hitRate = totalRequests > 0 ? (totalCached / totalRequests) * 100 : 0;
}
if (d1Data.length > 0) {
d1Data.forEach((row: any) => {
d1RowsRead += row.sum.rowsRead || 0;
d1RowsWritten += row.sum.rowsWritten || 0;
});
}
if (r2Data.length > 0) {
r2PayloadSize = r2Data[0]?.max?.payloadSize || 0;
}
if (data.errorMsg) {
error = data.errorMsg;
}
.then((value) => {
if (!cancelled) {
data = value;
loading = false;
}
loading = false;
})
.catch((err: unknown) => {
console.error(err);
error = '获取数据失败,请检查 Cloudflare 环境变量配置。';
loading = false;
.catch(() => {
if (!cancelled) {
error = '统计不可用,请检查网络和 Cloudflare 配置。';
loading = false;
}
});
return () => {
cancelled = true;
};
});
$effect(() => {
if (chartCanvas && trafficData && trafficData.length > 0) {
renderChart(trafficData);
}
const canvas = chartCanvas;
const current = scopes.filter((scope) => !scope.error);
if (!canvas || !current.length) return;
let cancelled = false;
let chart: import('chart.js').Chart | undefined;
const labels = [
...new Set(current.flatMap((scope) => scope.summary.daily.map(([date]) => date)))
].sort();
void import('chart.js/auto')
.then(({ default: Chart }) => {
if (cancelled) return;
chart = new Chart(canvas, {
type: 'line',
data: {
labels,
datasets: current.map((scope, index) => ({
label: `${scope.label}请求数`,
data: labels.map((date) => new Map(scope.summary.daily).get(date) ?? null),
borderColor: index === 0 ? '#333' : '#2196f3',
tension: 0.3
}))
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: { y: { beginAtZero: true } }
}
});
})
.catch(() => {
/* The numeric metrics remain available if the chart cannot load. */
});
return () => {
cancelled = true;
chart?.destroy();
};
});
async function renderChart(data: any[]) {
if (!chartCanvas) return;
const ChartJS = await import('chart.js/auto');
const Chart = ChartJS.default || ChartJS;
// 格式化日期:去除年份,将 2026-09-27 转为 09-27,降低 X 轴文字密度
const labels = data.map((d: any) => d.dimensions.date.substring(5));
const requests = data.map((d: any) => d.sum.requests);
const pvs = data.map((d: any) => d.sum.pageViews);
if (chartInstance) {
chartInstance.destroy();
}
chartInstance = new Chart(chartCanvas, {
type: 'line',
data: {
labels,
datasets: [
{
label: '总请求数',
data: requests,
borderColor: '#333',
backgroundColor: 'rgba(0,0,0,0.05)',
tension: 0.3,
fill: true,
yAxisID: 'y'
},
{
label: '页面浏览量 (PV)',
data: pvs,
borderColor: '#2196F3',
backgroundColor: 'rgba(33, 150, 243, 0.1)',
tension: 0.3,
fill: true,
yAxisID: 'y1'
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
tooltip: {
mode: 'index',
intersect: false
}
},
scales: {
x: {
ticks: {
maxRotation: 45,
maxTicksLimit: 15 // 最多显示 15 个刻度,避免太拥挤
}
},
y: {
type: 'linear',
display: true,
position: 'left',
beginAtZero: true
},
y1: {
type: 'linear',
display: true,
position: 'right',
beginAtZero: true,
grid: {
drawOnChartArea: false
}
}
}
}
});
}
function formatBytes(bytes: number) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
if (!bytes) return '0 B';
const index = Math.min(4, Math.floor(Math.log(bytes) / Math.log(1024)));
return `${(bytes / 1024 ** index).toFixed(2)} ${['B', 'KB', 'MB', 'GB', 'TB'][index]}`;
}
function formatNumber(num: number) {
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
return Math.round(num).toString();
function formatNumber(value: number) {
return value >= 1000000
? `${(value / 1000000).toFixed(1)}M`
: value >= 1000
? `${(value / 1000).toFixed(1)}K`
: Math.round(value).toLocaleString();
}
</script>
@@ -182,129 +104,64 @@
{#if loading}
<div class="loading-state">
<div class="spinner"></div>
<p>正在从 Cloudflare 获取最近 30 天流量数据...</p>
</div>
{:else if error}
<div class="error-state">
{error}
</div>
{:else if !trafficData || trafficData.length === 0}
<div class="empty-state">
没有获取到有效的流量数据,请确保 CF_API_TOKEN 和 CF_ZONE_ID 配置正确,且网站已有访问。
<p>正在获取博客与 R2 附件统计...</p>
</div>
{:else}
<div class="section-title">30天访问流量</div>
<div class="dashboard-grid">
{#if error || data?.errorMsg}<div class="error-state">{error || data?.errorMsg}</div>{/if}
<div class="section-title">项目访问流量</div>
<p class="scope-note">
仅统计配置的博客域名和 R2 附件域名,不包含文章中的外链资源。CDN 命中率为 HIT
请求占比,不代表页面内部缓存命中率或数据库查询比例。数据可能由 Cloudflare 抽样估算。
</p>
{#each scopes as scope (scope.label)}
<section class="scope-card">
<h3>{scope.label} <span>{scope.hostname || '未配置域名'}</span></h3>
{#if scope.error}
<div class="error-state">统计不可用:{scope.error}</div>
{:else}
<p class="scope-note">
最近 {scope.days} 天 · UTC{scope.days < 30
? ' · 受查询范围或数据量限制,已缩短统计时段'
: ''}
</p>
<div class="scope-metrics">
<div>
<div class="metric-title">总请求数</div>
<div class="metric-value">{formatNumber(scope.summary.requests)}</div>
</div>
<div>
<div class="metric-title">消耗带宽</div>
<div class="metric-value">{formatBytes(scope.summary.bytes)}</div>
</div>
<div>
<div class="metric-title">CDN 请求命中率</div>
<div class="metric-value">
{scope.summary.hitRate === null
? '暂无请求'
: `${scope.summary.hitRate.toFixed(1)}%`}
</div>
</div>
</div>
{#if scope.label === '博客'}<p class="scope-note">
后台及 API 请求:{formatNumber(
scope.summary.privateRequests
)};其他请求:{formatNumber(
scope.summary.requests - scope.summary.privateRequests
)}。均包含在总请求数中。
</p>{/if}
<div class="status-list" aria-label="缓存状态分布">
{#each Object.entries(scope.summary.statuses) as [status, count] (status)}<span
>{status}:{formatNumber(count)}</span
>{/each}
</div>
{/if}
</section>
{/each}
{#if scopes.some((scope) => !scope.error && scope.rows.length)}
<div class="chart-container">
<div class="canvas-wrapper">
<canvas bind:this={chartCanvas}></canvas>
</div>
<div class="canvas-wrapper"><canvas bind:this={chartCanvas}></canvas></div>
</div>
<div class="core-metrics">
<div class="metric-item">
<div class="metric-title">
<span class="metric-icon" style="color: #2196F3; background: rgba(33,150,243,0.1)">
<svg
viewBox="0 0 24 24"
width="14"
height="14"
stroke="currentColor"
stroke-width="2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle
cx="12"
cy="12"
r="3"
></circle></svg
>
</span>
页面浏览 (PV)
</div>
<div class="metric-value" style="color: #2196F3">{totalPV.toLocaleString()}</div>
<div class="metric-subtitle">近 30 天累计访问量</div>
</div>
<div class="metric-item">
<div class="metric-title">
<span class="metric-icon">
<svg
viewBox="0 0 24 24"
width="14"
height="14"
stroke="currentColor"
stroke-width="2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle
cx="12"
cy="7"
r="4"
></circle></svg
>
</span>
独立访客 (UV)
</div>
<div class="metric-value">{totalUV.toLocaleString()}</div>
<div class="metric-subtitle">近 30 天去重访客数</div>
</div>
<div class="metric-item">
<div class="metric-title">
<span class="metric-icon">
<svg
viewBox="0 0 24 24"
width="14"
height="14"
stroke="currentColor"
stroke-width="2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
><rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect><rect
x="2"
y="14"
width="20"
height="8"
rx="2"
ry="2"
></rect><line x1="6" y1="6" x2="6.01" y2="6"></line><line
x1="6"
y1="18"
x2="6.01"
y2="18"
></line></svg
>
</span>
消耗总带宽
</div>
<div class="metric-value">{formatBytes(totalBytes)}</div>
<div class="metric-subtitle">包含网页及所有静态资源</div>
</div>
<div class="metric-item">
<div class="metric-title">
<span class="metric-icon" style="color: #4caf50; background: rgba(76,175,80,0.1)">
<svg
viewBox="0 0 24 24"
width="14"
height="14"
stroke="currentColor"
stroke-width="2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg
>
</span>
缓存命中率
</div>
<div class="metric-value" style="color: #4caf50">{hitRate.toFixed(1)}%</div>
<div class="metric-subtitle">由 Cloudflare CDN 成功拦截</div>
</div>
</div>
</div>
{/if}
<div class="section-title">Cloudflare 资源配额</div>
{#if missingEnvVars}
<div class="info-alert">
@@ -336,7 +193,7 @@
></div>
</div>
<div class="quota-footer">
<span>{d1ReadPercent < 1 ? '极度充裕' : '健康'}</span>
<span>{d1Data.length ? (d1ReadPercent < 1 ? '极度充裕' : '健康') : '暂无数据'}</span>
<span
>{d1ReadPercent < 0.01 && d1Data.length > 0
? '< 0.01'
@@ -367,7 +224,7 @@
></div>
</div>
<div class="quota-footer">
<span>{d1WritePercent < 1 ? '极度充裕' : '健康'}</span>
<span>{d1Data.length ? (d1WritePercent < 1 ? '极度充裕' : '健康') : '暂无数据'}</span>
<span
>{d1WritePercent < 0.01 && d1Data.length > 0
? '< 0.01'
@@ -392,7 +249,7 @@
></div>
</div>
<div class="quota-footer">
<span>{r2Percent < 1 ? '空间海量' : '健康'}</span>
<span>{r2Data.length ? (r2Percent < 1 ? '空间海量' : '健康') : '暂无数据'}</span>
<span>{r2Percent < 0.01 && r2Data.length > 0 ? '< 0.01' : r2Percent.toFixed(2)}%</span>
</div>
</div>
@@ -401,6 +258,46 @@
</div>
<style>
.scope-card {
border: 1px solid #e5e5e5;
border-radius: 12px;
padding: 24px;
}
.scope-card h3 {
margin: 0 0 12px;
font-size: 16px;
}
.scope-card h3 span {
color: #777;
font-size: 13px;
font-weight: 400;
overflow-wrap: anywhere;
}
.scope-note {
color: #666;
font-size: 13px;
line-height: 1.7;
margin: 0 0 12px;
}
.scope-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin: 20px 0;
}
.status-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 12px;
color: #666;
}
@media (max-width: 600px) {
.scope-metrics {
grid-template-columns: 1fr;
}
}
.analytics-panel {
display: flex;
flex-direction: column;
@@ -434,20 +331,6 @@
font-family: monospace;
}
.dashboard-grid {
display: grid;
grid-template-columns: 7fr 3fr; /* 增加图表宽度,缩小右侧网格 */
gap: 0;
border: 2px solid var(--text-color, #000);
background: transparent;
}
@media (max-width: 900px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
}
.chart-container {
padding: 20px;
display: flex;
@@ -461,56 +344,6 @@
width: 100%;
}
.core-metrics {
display: grid;
grid-template-columns: 1fr 1fr;
border-left: 2px solid var(--text-color, #000);
}
@media (max-width: 900px) {
.core-metrics {
border-left: none;
border-top: 2px solid var(--text-color, #000);
}
}
.metric-item {
padding: 20px 16px;
display: flex;
flex-direction: column;
gap: 6px;
justify-content: center;
align-items: center;
text-align: center;
min-width: 0;
}
/* 为 2x2 网格绘制内部边框 */
.metric-item:nth-child(1),
.metric-item:nth-child(2) {
border-bottom: 2px solid var(--text-color, #000);
}
.metric-item:nth-child(1),
.metric-item:nth-child(3) {
border-right: 2px solid var(--text-color, #000);
}
@media (max-width: 600px) {
.core-metrics {
grid-template-columns: 1fr;
}
.metric-item:nth-child(1),
.metric-item:nth-child(3) {
border-right: none;
}
.metric-item:nth-child(1),
.metric-item:nth-child(2),
.metric-item:nth-child(3) {
border-bottom: 2px solid var(--text-color, #000);
}
}
.metric-title {
font-size: 0.8rem;
font-weight: 600;
@@ -520,31 +353,12 @@
justify-content: center;
}
.metric-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
background: rgba(0, 0, 0, 0.06);
border-radius: 4px;
margin-right: 6px;
color: var(--text-color, #000);
}
.metric-value {
font-size: 1.6rem;
font-weight: 800;
font-family: monospace;
}
.metric-subtitle {
font-size: 0.7rem;
color: var(--text-muted, rgba(0, 0, 0, 0.4));
margin-top: 2px;
text-align: center;
}
.quota-container {
border: 2px solid var(--text-color, #000);
background: transparent;
@@ -647,8 +461,7 @@
}
.loading-state,
.error-state,
.empty-state {
.error-state {
padding: 40px;
text-align: center;
border: 2px dashed var(--border-color, rgba(0, 0, 0, 0.2));
+151
View File
@@ -0,0 +1,151 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { fetchAnalytics, purgePublicCache } from './cloudflare';
import { summarizeTraffic, type TrafficRow } from '$lib/analytics';
function platform(extra: Record<string, unknown> = {}) {
return {
env: {
CF_API_TOKEN: 'test-token',
CF_ZONE_ID: 'blog-zone',
CF_DOMAIN: 'https://blog.example.com',
...extra
}
} as unknown as App.Platform;
}
function trafficResponse(rows: TrafficRow[] = []) {
return Response.json({ data: { viewer: { zones: [{ httpRequestsAdaptiveGroups: rows }] } } });
}
const row = (count: number, status: string, path = '/'): TrafficRow => ({
count,
dimensions: { date: '2026-09-26', cacheStatus: status, clientRequestPath: path },
sum: { edgeResponseBytes: count * 100 }
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('domain-scoped analytics', () => {
it('queries only configured hosts and eyeball traffic, with separate asset zone support', async () => {
const mock = vi.fn().mockImplementation(() => trafficResponse([row(2, 'hit')]));
vi.stubGlobal('fetch', mock);
const result = await fetchAnalytics(
platform({ CF_ASSETS_ZONE_ID: 'assets-zone' }),
'https://assets.example.com'
);
const requests = mock.mock.calls.map((call) => JSON.parse(call[1].body));
expect(requests.map((request) => request.variables.filter.clientRequestHTTPHost)).toEqual([
'blog.example.com',
'assets.example.com'
]);
expect(requests.map((request) => request.variables.zone)).toEqual(['blog-zone', 'assets-zone']);
expect(requests.every((request) => request.variables.filter.requestSource === 'eyeball')).toBe(
true
);
expect(result.scopes.map((scope) => scope.rows.length)).toEqual([1, 1]);
});
it('does not widen scope when an asset domain is missing', async () => {
const mock = vi.fn().mockImplementation(() => trafficResponse());
vi.stubGlobal('fetch', mock);
const result = await fetchAnalytics(platform());
expect(mock).toHaveBeenCalledTimes(1);
expect(result.scopes[1].error).toContain('未配置域名');
});
it('reports permission errors instead of displaying a false zero or zone-wide fallback', async () => {
const mock = vi
.fn()
.mockImplementation(() => Response.json({ errors: [{ message: 'not authorized' }] }));
vi.stubGlobal('fetch', mock);
const result = await fetchAnalytics(platform(), 'https://assets.example.com');
expect(mock).toHaveBeenCalledTimes(2);
expect(result.scopes.every((scope) => scope.error === 'not authorized')).toBe(true);
});
it('retries smaller supported time windows and labels the actual period', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation((_url, options) => {
const { filter } = JSON.parse(options.body).variables;
const days = (Date.parse(filter.datetime_lt) - Date.parse(filter.datetime_geq)) / 86400000;
return days > 1
? Response.json({ errors: [{ message: 'query time range is too large' }] })
: trafficResponse([row(1, 'miss')]);
})
);
const result = await fetchAnalytics(platform(), 'https://assets.example.com');
expect(result.scopes.map((scope) => [scope.days, scope.error])).toEqual([
[1, undefined],
[1, undefined]
]);
});
it('does not present truncated results as complete statistics', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockImplementation(() =>
trafficResponse(Array.from({ length: 10000 }, () => row(1, 'hit')))
)
);
const result = await fetchAnalytics(platform(), 'https://assets.example.com');
expect(result.scopes[0].error).toContain('查询上限');
expect(result.scopes[0].rows).toEqual([]);
});
it('keeps domain traffic visible if account-resource queries fail', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockImplementation((_url, options) =>
JSON.parse(options.body).query.includes('accounts(')
? Response.json({ errors: [{ message: 'account denied' }] })
: trafficResponse([row(1, 'hit')])
)
);
const result = await fetchAnalytics(
platform({ CF_ACCOUNT_ID: 'account', CF_D1_ID: 'database' }),
'https://assets.example.com'
);
expect(result.errorMsg).toContain('account denied');
expect(result.scopes[0].rows).toHaveLength(1);
});
it('computes weighted HIT rates without accumulating across reloads or claiming UV', () => {
const rows = [row(2, 'hit'), row(98, 'dynamic', '/admin/settings')];
expect(summarizeTraffic(rows).hitRate).toBe(2);
expect(summarizeTraffic(rows).requests).toBe(100);
expect(summarizeTraffic(rows).privateRequests).toBe(98);
expect(summarizeTraffic([]).hitRate).toBeNull();
});
});
describe('public cache invalidation', () => {
it('purges only this blog tag, covering old slugs and query variants without clearing attachments', async () => {
const mock = vi.fn().mockResolvedValue(Response.json({ success: true }));
vi.stubGlobal('fetch', mock);
expect(await purgePublicCache(platform())).toBeNull();
expect(JSON.parse(mock.mock.calls[0][1].body)).toEqual({
tags: ['blogflare-public:blog.example.com']
});
});
it('persists a purge failure so saved content does not imply a successful refresh', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValue(
Response.json({ success: false, errors: [{ message: 'rate limited' }] }, { status: 429 })
)
);
const bind = vi.fn().mockReturnValue({ run: vi.fn().mockResolvedValue({ success: true }) });
const error = await purgePublicCache(platform({ DB: { prepare: () => ({ bind }) } }));
expect(error).toContain('rate limited');
expect(bind).toHaveBeenCalledWith(error);
});
it('fails closed with missing credentials and does not clear an entire zone', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const mock = vi.fn();
vi.stubGlobal('fetch', mock);
expect(await purgePublicCache(undefined)).toContain('缺少');
expect(mock).not.toHaveBeenCalled();
});
});
+175 -131
View File
@@ -1,76 +1,179 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export async function purgeCache(platform: App.Platform | undefined, paths: string[]) {
// @ts-expect-error Cloudflare env types
const token = platform?.env?.CF_API_TOKEN;
// @ts-expect-error Cloudflare env types
const zoneId = platform?.env?.CF_ZONE_ID;
const domain = platform?.env?.CF_DOMAIN; // 必须是带协议的绝对域名,如 https://blog.example.com
import type { AnalyticsData, TrafficRow, TrafficScope } from '$lib/analytics';
import { configuredHostname, publicCacheTag } from './cache';
if (!token || !zoneId) {
console.warn('未配置 CF_API_TOKEN 或 CF_ZONE_ID,跳过清除 Cloudflare 缓存');
return;
}
// Optional dashboard secrets are not generated from wrangler.jsonc.
type AnalyticsEnv = Env &
Partial<
Record<
| 'CF_API_TOKEN'
| 'CF_ZONE_ID'
| 'CF_ACCOUNT_ID'
| 'CF_D1_ID'
| 'CF_R2_BUCKET'
| 'CF_ASSETS_ZONE_ID',
string
>
>;
if (!domain) {
console.warn('未配置 CF_DOMAIN,无法精准拼接 URL,跳过清除缓存以保护其他项目。');
return;
}
// 将路径转换为带域名的绝对 URL,Cloudflare 的 purge_cache(files) 要求必须为绝对 URL
const urls = paths.map((p) => {
// 确保拼接正确,防止出现双斜杠或少斜杠
const cleanDomain = domain.replace(/\/$/, '');
const cleanPath = p.startsWith('/') ? p : `/${p}`;
return `${cleanDomain}${cleanPath}`;
async function graphql<T>(
token: string,
query: string,
variables: Record<string, unknown> = {}
): Promise<T> {
const response = await fetch('https://api.cloudflare.com/client/v4/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ query, variables }),
signal: AbortSignal.timeout(15000)
});
const result = (await response.json()) as { data?: T; errors?: { message: string }[] };
if (!response.ok || result.errors?.length || !result.data) {
throw new Error(
result.errors?.map((error) => error.message).join('; ') ||
`Cloudflare HTTP ${response.status}`
);
}
return result.data;
}
export async function purgePublicCache(platform: App.Platform | undefined): Promise<string | null> {
const env = platform?.env as AnalyticsEnv | undefined;
let error: string | null = null;
try {
const body = { files: urls };
if (!env?.CF_API_TOKEN || !env.CF_ZONE_ID) throw new Error('缺少 CF_API_TOKEN 或 CF_ZONE_ID');
const hostname = configuredHostname(env.CF_DOMAIN);
const response = await fetch(
`https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`,
`https://api.cloudflare.com/client/v4/zones/${encodeURIComponent(env.CF_ZONE_ID)}/purge_cache`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
Authorization: `Bearer ${env.CF_API_TOKEN}`
},
body: JSON.stringify(body)
body: JSON.stringify({ tags: [publicCacheTag(hostname)] }),
signal: AbortSignal.timeout(10000)
}
);
const result = (await response.json()) as any;
if (result.success) {
console.log(
'✅ 成功清除 Cloudflare 缓存',
urls ? `(URLs: ${urls.join(', ')})` : '(全部缓存)'
const result = (await response.json()) as { success?: boolean; errors?: { message: string }[] };
if (!response.ok || !result.success) {
throw new Error(
`Cloudflare 清理失败(HTTP ${response.status}):${result.errors?.map((item) => item.message).join('; ') || '未返回成功确认'}`
);
} else {
console.error('❌ 清除缓存失败:', result.errors);
}
} catch (error) {
console.error('❌ 调用 Cloudflare Purge API 时发生异常:', error);
} catch (cause) {
error = cause instanceof Error ? cause.message : '缓存清理失败';
console.error('Public cache purge failed:', error);
}
// Persist failures so an asynchronous API failure is visible in the admin UI.
try {
await env?.DB.prepare(
"INSERT INTO settings (key, value, updated_at) VALUES ('cache.purge_error', ?, CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP"
)
.bind(error || '')
.run();
} catch (cause) {
console.error('Failed to persist cache purge status', cause);
}
return error;
}
export async function fetchAnalytics(platform: App.Platform | undefined) {
// @ts-expect-error Cloudflare env types
const token = platform?.env?.CF_API_TOKEN;
// @ts-expect-error Cloudflare env types
const zoneId = platform?.env?.CF_ZONE_ID;
// @ts-expect-error Cloudflare env types
const accountId = platform?.env?.CF_ACCOUNT_ID;
// @ts-expect-error Cloudflare env types
const d1Id = platform?.env?.CF_D1_ID;
// @ts-expect-error Cloudflare env types
const r2Bucket = platform?.env?.CF_R2_BUCKET;
if (!token || !zoneId) {
return null;
async function fetchTraffic(
token: string,
zoneId: string,
label: string,
domain: string | undefined,
now: Date
): Promise<TrafficScope> {
const scope: TrafficScope = { label, hostname: '', days: 30, rows: [] };
try {
scope.hostname = configuredHostname(domain);
// Never fall back to zone-wide traffic: that would include unrelated external images.
for (const days of [30, 7, 1]) {
scope.days = days;
try {
const data = await graphql<{
viewer: { zones: { httpRequestsAdaptiveGroups: TrafficRow[] }[] };
}>(
token,
`
query Traffic($zone: string, $filter: filter) {
viewer {
zones(filter: { zoneTag: $zone }) {
httpRequestsAdaptiveGroups(limit: 10000, filter: $filter) {
count
sum {
edgeResponseBytes
}
dimensions {
date
cacheStatus
clientRequestPath
}
}
}
}
}
`,
{
zone: zoneId,
filter: {
datetime_geq: new Date(now.getTime() - days * 86400000).toISOString(),
datetime_lt: now.toISOString(),
clientRequestHTTPHost: scope.hostname,
requestSource: 'eyeball'
}
}
);
const rows = data.viewer.zones[0]?.httpRequestsAdaptiveGroups;
if (!Array.isArray(rows))
throw new Error('未返回该 Zone 的数据,请检查域名归属和 Token 权限');
if (rows.length >= 10000) throw new Error('分组数量达到查询上限,无法显示完整统计');
scope.rows = rows;
return scope;
} catch (cause) {
const message = cause instanceof Error ? cause.message : '流量查询失败';
if (
days !== 1 &&
/time range|time window|date range|older than|too old|retention|cannot request data before|query time|分组数量/i.test(
message
)
)
continue;
throw cause;
}
}
} catch (cause) {
scope.error = cause instanceof Error ? cause.message : '流量查询失败';
}
return scope;
}
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
export async function fetchAnalytics(
platform: App.Platform | undefined,
assetsDomain?: string
): Promise<AnalyticsData> {
const env = platform?.env as AnalyticsEnv | undefined;
const token = env?.CF_API_TOKEN;
const zoneId = env?.CF_ZONE_ID;
const accountId = env?.CF_ACCOUNT_ID;
const d1Id = env?.CF_D1_ID;
const r2Bucket = env?.CF_R2_BUCKET;
const now = new Date();
const thirtyDaysAgo = new Date(now.getTime() - 30 * 86400000).toISOString().split('T')[0];
const result: AnalyticsData = {
scopes: [],
d1: [],
r2: [],
missingEnvVars: !accountId || !d1Id || !r2Bucket
};
if (!token || !zoneId) {
result.errorMsg = '统计不可用:请配置 CF_API_TOKEN 和 CF_ZONE_ID';
return result;
}
const trafficPromise = Promise.all([
fetchTraffic(token, zoneId, '博客', env?.CF_DOMAIN, now),
fetchTraffic(token, env?.CF_ASSETS_ZONE_ID || zoneId, 'R2 附件', assetsDomain, now)
]);
let accountQuery = '';
if (accountId) {
let d1Query = '';
@@ -78,7 +181,7 @@ export async function fetchAnalytics(platform: App.Platform | undefined) {
d1Query = `
d1AnalyticsAdaptiveGroups(
limit: 10000,
filter: { date_geq: "${thirtyDaysAgo}", databaseId: "${d1Id}" }
filter: { date_geq: "${thirtyDaysAgo}", databaseId: ${JSON.stringify(d1Id)} }
) {
sum {
readQueries
@@ -95,7 +198,7 @@ export async function fetchAnalytics(platform: App.Platform | undefined) {
r2Query = `
r2StorageAdaptiveGroups(
limit: 10000,
filter: { datetime_geq: "${thirtyDaysAgo}T00:00:00Z", bucketName: "${r2Bucket}" }
filter: { datetime_geq: "${thirtyDaysAgo}T00:00:00Z", bucketName: ${JSON.stringify(r2Bucket)} }
) {
max {
payloadSize
@@ -106,90 +209,31 @@ export async function fetchAnalytics(platform: App.Platform | undefined) {
if (d1Query || r2Query) {
accountQuery = `
accounts(filter: { accountTag: "${accountId}" }) {
accounts(filter: { accountTag: ${JSON.stringify(accountId)} }) {
${d1Query}
${r2Query}
}
`;
}
}
const trafficQuery = `
query {
viewer {
zones(filter: { zoneTag: "${zoneId}" }) {
httpRequests1dGroups(
limit: 30,
filter: { date_gt: "${thirtyDaysAgo}" }
orderBy: [date_ASC]
) {
dimensions {
date
}
sum {
cachedBytes
bytes
cachedRequests
requests
pageViews
}
uniq {
uniques
}
}
}
}
}
`;
try {
const trafficRes = await fetch('https://api.cloudflare.com/client/v4/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ query: trafficQuery })
});
const trafficResult = (await trafficRes.json()) as any;
const trafficData = trafficResult?.data?.viewer?.zones?.[0]?.httpRequests1dGroups || [];
let d1Data: any[] = [];
let r2Data: any[] = [];
let accErrorMsg = '';
if (accountQuery) {
const accQueryStr = `query { viewer { ${accountQuery} } }`;
const accRes = await fetch('https://api.cloudflare.com/client/v4/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ query: accQueryStr })
});
const accResult = (await accRes.json()) as any;
if (accResult.errors) {
console.error('Account GraphQL Errors:', accResult.errors);
accErrorMsg = '账户数据查询失败: ' + (accResult.errors[0]?.message || 'Unknown');
}
const accountData = accResult?.data?.viewer?.accounts?.[0];
if (accountData) {
d1Data = accountData.d1AnalyticsAdaptiveGroups || [];
r2Data = accountData.r2StorageAdaptiveGroups || [];
}
const data = await graphql<{
viewer: {
accounts: {
d1AnalyticsAdaptiveGroups?: AnalyticsData['d1'];
r2StorageAdaptiveGroups?: AnalyticsData['r2'];
}[];
};
}>(token, `query { viewer { ${accountQuery} } }`);
const account = data.viewer.accounts[0];
if (!account) throw new Error('未返回账户资源数据');
result.d1 = account.d1AnalyticsAdaptiveGroups || [];
result.r2 = account.r2StorageAdaptiveGroups || [];
}
return {
traffic: trafficData,
d1: d1Data,
r2: r2Data,
missingEnvVars: !accountId || !d1Id || !r2Bucket,
errorMsg: accErrorMsg ? accErrorMsg : undefined
};
} catch (err: any) {
console.error('Failed to fetch analytics', err);
return { errorMsg: '获取统计数据失败,请检查 Token 权限或网络环境' };
} catch (cause) {
result.errorMsg = `账户资源统计不可用:${cause instanceof Error ? cause.message : '查询失败'}`;
}
result.scopes = await trafficPromise;
return result;
}
-16
View File
@@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unused-vars */
import type { PageServerLoad } from './$types';
import { purgeCache } from '$lib/server/cloudflare';
interface PostListRow {
id: number;
@@ -83,27 +82,12 @@ export const actions = {
try {
const placeholders = ids.map(() => '?').join(',');
const { results: postsToDelete } = await db
.prepare(`SELECT slug FROM posts WHERE id IN (${placeholders})`)
.bind(...ids)
.all<{ slug: string }>();
await db
.prepare(`DELETE FROM posts WHERE id IN (${placeholders})`)
.bind(...ids)
.run();
try {
const pathsToPurge = ['/', '/posts', ...postsToDelete.map((p) => `/posts/${p.slug}`)];
if (platform?.context?.waitUntil) {
platform.context.waitUntil(purgeCache(platform, pathsToPurge));
} else {
await purgeCache(platform, pathsToPurge);
}
} catch (err) {
console.error('Failed to trigger cache purge:', err);
}
return { success: true };
} catch (err: any) {
console.error('Error deleting post:', err);
@@ -8,7 +8,6 @@ import {
resolveSeriesWeight,
SeriesValidationError
} from '$lib/server/series';
import { purgeCache } from '$lib/server/cloudflare';
export const load: PageServerLoad = async ({ params, platform, parent }) => {
const db = platform?.env?.DB;
@@ -177,17 +176,6 @@ export const actions: Actions = {
return fail(500, { error: '保存修改失败' });
}
try {
const pathsToPurge = ['/', '/posts', `/posts/${slug}`];
if (platform?.context?.waitUntil) {
platform.context.waitUntil(purgeCache(platform, pathsToPurge));
} else {
await purgeCache(platform, pathsToPurge);
}
} catch (err) {
console.error('Failed to trigger cache purge:', err);
}
redirect(303, '/admin/posts');
}
};
@@ -8,7 +8,6 @@ import {
resolveSeriesWeight,
SeriesValidationError
} from '$lib/server/series';
import { purgeCache } from '$lib/server/cloudflare';
export const load: PageServerLoad = async ({ platform }) => {
const db = platform?.env?.DB;
@@ -117,17 +116,6 @@ export const actions: Actions = {
return fail(500, { error: '保存失败' });
}
try {
const pathsToPurge = ['/', '/posts', `/posts/${slug}`];
if (platform?.context?.waitUntil) {
platform.context.waitUntil(purgeCache(platform, pathsToPurge));
} else {
await purgeCache(platform, pathsToPurge);
}
} catch (err) {
console.error('Failed to trigger cache purge:', err);
}
redirect(303, '/admin/posts');
}
};
+1 -1
View File
@@ -25,7 +25,7 @@ export const load: PageServerLoad = async ({ locals, platform }) => {
platformEnv: db ? 'Cloudflare D1 (Connected)' : 'Local Mock',
r2Env: r2 ? 'Cloudflare R2 (Connected)' : 'Local Mock',
settings,
analytics: fetchAnalytics(platform) // Streaming promise
analytics: fetchAnalytics(platform, settings['r2.public_domain']) // Streaming promise
};
};
+14
View File
@@ -52,6 +52,13 @@
bind:activeTab
/>
{#if data.settings['cache.purge_error']}
<p role="alert" class="cache-warning">
内容已保存,但最近一次缓存刷新失败:{data.settings['cache.purge_error']}。列表页最多可能延迟 5
分钟,详情页最多 1 小时更新。请检查缓存清理权限后重试保存。
</p>
{/if}
<div class="settings-container">
{#if activeTab === 'basic'}
<div class="basic-top-row">
@@ -357,6 +364,13 @@
</div>
<style>
.cache-warning {
padding: 16px;
background: #fff3cd;
color: #735400;
border-radius: 8px;
line-height: 1.6;
}
.admin-page-header {
margin-bottom: 25px;
border-bottom: 2px solid var(--text-color);
-21
View File
@@ -1,6 +1,5 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { purgeCache } from '$lib/server/cloudflare';
export const GET: RequestHandler = async ({ params, platform }) => {
const db = platform?.env.DB;
@@ -125,16 +124,6 @@ export const PUT: RequestHandler = async ({ params, request, platform }) => {
}
}
}
try {
const pathsToPurge = ['/', '/posts', `/posts/${slug}`];
if (platform?.context?.waitUntil) {
platform.context.waitUntil(purgeCache(platform, pathsToPurge));
} else {
await purgeCache(platform, pathsToPurge);
}
} catch (err) {
console.error('Failed to trigger cache purge:', err);
}
return json({ success: true, slug });
} else {
return json({ error: 'Failed to update post' }, { status: 500 });
@@ -164,16 +153,6 @@ export const DELETE: RequestHandler = async ({ params, platform }) => {
const result = await db.prepare('DELETE FROM posts WHERE slug = ?').bind(slug).run();
if (result.success) {
try {
const pathsToPurge = ['/', '/posts', `/posts/${slug}`];
if (platform?.context?.waitUntil) {
platform.context.waitUntil(purgeCache(platform, pathsToPurge));
} else {
await purgeCache(platform, pathsToPurge);
}
} catch (err) {
console.error('Failed to trigger cache purge:', err);
}
return json({ success: true });
} else {
return json({ error: 'Failed to delete post' }, { status: 500 });