240 lines
7.0 KiB
TypeScript
240 lines
7.0 KiB
TypeScript
import type { AnalyticsData, TrafficRow, TrafficScope } from '$lib/analytics';
|
|
import { configuredHostname, publicCacheTag } from './cache';
|
|
|
|
// 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
|
|
>
|
|
>;
|
|
|
|
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 {
|
|
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/${encodeURIComponent(env.CF_ZONE_ID)}/purge_cache`,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${env.CF_API_TOKEN}`
|
|
},
|
|
body: JSON.stringify({ tags: [publicCacheTag(hostname)] }),
|
|
signal: AbortSignal.timeout(10000)
|
|
}
|
|
);
|
|
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('; ') || '未返回成功确认'}`
|
|
);
|
|
}
|
|
} 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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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 = '';
|
|
if (d1Id) {
|
|
d1Query = `
|
|
d1AnalyticsAdaptiveGroups(
|
|
limit: 10000,
|
|
filter: { date_geq: "${thirtyDaysAgo}", databaseId: ${JSON.stringify(d1Id)} }
|
|
) {
|
|
sum {
|
|
readQueries
|
|
writeQueries
|
|
rowsRead
|
|
rowsWritten
|
|
}
|
|
}
|
|
`;
|
|
}
|
|
|
|
let r2Query = '';
|
|
if (r2Bucket) {
|
|
r2Query = `
|
|
r2StorageAdaptiveGroups(
|
|
limit: 10000,
|
|
filter: { datetime_geq: "${thirtyDaysAgo}T00:00:00Z", bucketName: ${JSON.stringify(r2Bucket)} }
|
|
) {
|
|
max {
|
|
payloadSize
|
|
}
|
|
}
|
|
`;
|
|
}
|
|
|
|
if (d1Query || r2Query) {
|
|
accountQuery = `
|
|
accounts(filter: { accountTag: ${JSON.stringify(accountId)} }) {
|
|
${d1Query}
|
|
${r2Query}
|
|
}
|
|
`;
|
|
}
|
|
}
|
|
try {
|
|
if (accountQuery) {
|
|
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 || [];
|
|
}
|
|
} catch (cause) {
|
|
result.errorMsg = `账户资源统计不可用:${cause instanceof Error ? cause.message : '查询失败'}`;
|
|
}
|
|
result.scopes = await trafficPromise;
|
|
return result;
|
|
}
|