feat: 集成 Cloudflare 缓存清除功能并在文章变更时自动触发

This commit is contained in:
seahi committed 2026-09-25 08:54:39 +08:00
1 parent 198d4f5075
commit f340258afd
7 files changed
+177 -3

No files matched your search

+109
View File
@@ -0,0 +1,109 @@
/* 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
if (!token || !zoneId) {
console.warn('未配置 CF_API_TOKEN 或 CF_ZONE_ID,跳过清除 Cloudflare 缓存');
return;
}
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}`;
});
try {
const body = { files: urls };
const response = await fetch(
`https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify(body)
}
);
const result = (await response.json()) as any;
if (result.success) {
console.log(
'✅ 成功清除 Cloudflare 缓存',
urls ? `(URLs: ${urls.join(', ')})` : '(全部缓存)'
);
} else {
console.error('❌ 清除缓存失败:', result.errors);
}
} catch (error) {
console.error('❌ 调用 Cloudflare Purge API 时发生异常:', 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;
if (!token || !zoneId) {
return null;
}
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const query = `
query {
viewer {
zones(filter: { zoneTag: "${zoneId}" }) {
httpRequests1dGroups(
limit: 30,
filter: { date_gt: "${thirtyDaysAgo}" }
orderBy: [date_ASC]
) {
dimensions {
date
}
sum {
cachedBytes
bytes
cachedRequests
requests
pageViews
}
}
}
}
}
`;
try {
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 })
});
const result = (await response.json()) as any;
return result?.data?.viewer?.zones?.[0]?.httpRequests1dGroups || [];
} catch (err) {
console.error('Failed to fetch analytics', err);
return null;
}
}
+18
View File
@@ -1,6 +1,7 @@
/* 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;
@@ -82,10 +83,27 @@ 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,6 +8,7 @@ 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;
@@ -176,6 +177,17 @@ 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,6 +8,7 @@ import {
resolveSeriesWeight,
SeriesValidationError
} from '$lib/server/series';
import { purgeCache } from '$lib/server/cloudflare';
export const load: PageServerLoad = async ({ platform }) => {
const db = platform?.env?.DB;
@@ -116,6 +117,17 @@ 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');
}
};
+21
View File
@@ -1,5 +1,6 @@
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;
@@ -124,6 +125,16 @@ 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 });
@@ -153,6 +164,16 @@ 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 });
+3 -2
View File
@@ -1,5 +1,5 @@
/* eslint-disable */
// Generated by Wrangler by running `wrangler types` (hash: 9194822214de3b6a9d6a85fa866a429b)
// Generated by Wrangler by running `wrangler types` (hash: 1304802731dc94c585a8753b21107217)
// Runtime types generated with workerd@1.20260801.1 2026-08-07 nodejs_compat
interface __BaseEnv_Env {
R2: R2Bucket;
@@ -7,6 +7,7 @@ interface __BaseEnv_Env {
AI: Ai;
CF_ACCESS_TEAM_DOMAIN: 'https://hisland.cloudflareaccess.com';
CF_ACCESS_AUD: '35b84a1c6ea81e1c036272ee68ecc4197041aadc1bed0815accac283a30e01ff';
CF_DOMAIN: 'https://manual.seahi.me';
}
declare namespace Cloudflare {
interface Env extends __BaseEnv_Env {}
@@ -17,7 +18,7 @@ type StringifyValues<EnvType extends Record<string, unknown>> = {
};
declare namespace NodeJS {
interface ProcessEnv extends StringifyValues<
Pick<Cloudflare.Env, 'CF_ACCESS_TEAM_DOMAIN' | 'CF_ACCESS_AUD'>
Pick<Cloudflare.Env, 'CF_ACCESS_TEAM_DOMAIN' | 'CF_ACCESS_AUD' | 'CF_DOMAIN'>
> {}
}
+2 -1
View File
@@ -6,7 +6,8 @@
"pages_build_output_dir": ".svelte-kit/cloudflare",
"vars": {
"CF_ACCESS_TEAM_DOMAIN": "https://hisland.cloudflareaccess.com",
"CF_ACCESS_AUD": "35b84a1c6ea81e1c036272ee68ecc4197041aadc1bed0815accac283a30e01ff"
"CF_ACCESS_AUD": "35b84a1c6ea81e1c036272ee68ecc4197041aadc1bed0815accac283a30e01ff",
"CF_DOMAIN": "https://manual.seahi.me"
},
"d1_databases": [
{