Skip to main content

yggdrasil/tasks/
edge_purge.rs

1//! 内容变更后清除 EdgeOne 节点上缓存的公开页 HTML。
2//!
3//! 白名单匿名页带 `s-maxage`(`SsrCacheSettings::edge_ttl_secs`)时,CDN 节点会缓存 HTML;
4//! 源站 HTML 缓存每次失效(文章、标签、设置写入,跨实例通知,发版启动)都经
5//! `request_purge` 发信号,本任务:
6//!
7//! 1. 防抖 `DEBOUNCE`:一次写入会连续触发多次失效,批量操作更多,合并成一次;
8//! 2. 立刻清一次,`SECOND_PASS_DELAY` 后再清一次:写入前已开始渲染的请求可能在第一次清除
9//!    之后才返回旧页面,EdgeOne 会把它按 `s-maxage` 缓存下来(源站缓存有世代号挡住,边缘没有);
10//! 3. 失败按 `RETRY_DELAYS` 重试,最终失败记 error,陈旧上限退回到 `s-maxage`。
11//!
12//! 清除范围是 `ssr_cache` 的白名单:前缀按目录清除,固定页面按 URL 清除。不用 Hostname 或
13//! 全站清除,否则会连带 `/pkg/` 与静态资源,节点重新回源时可能缓存 EdgeOne 自己压缩的
14//! 更大的 br(见 `docs/首屏性能实测-2026-10-28.md`)。
15//!
16//! 启动时路由装配会使源站缓存失效,所以每次发版或重启都会清一次:边缘不会留下引用已删除
17//! `/pkg/` 分包的旧 HTML(`YGG_PREVIOUS_PUBLIC_DIR` 只保留上一版)。
18//!
19//! 凭据只读环境变量(`EDGEONE_ZONE_ID`、`TENCENTCLOUD_SECRET_ID`、`TENCENTCLOUD_SECRET_KEY`),
20//! 缺任一项时任务不启动,信号只是空操作。建议用只授权该站点 `teo:CreatePurgeTask` 的 CAM 子账号。
21
22use std::time::Duration;
23
24use futures::FutureExt;
25use sha2::{Digest, Sha256};
26use tokio::sync::Notify;
27
28const API_HOST: &str = "teo.tencentcloudapi.com";
29const API_SERVICE: &str = "teo";
30const API_VERSION: &str = "2022-09-01";
31const API_ACTION: &str = "CreatePurgeTask";
32/// 与腾讯云 SDK 一致:签名头只有 `content-type;host`,内容类型不带 charset。
33const CONTENT_TYPE: &str = "application/json";
34
35const DEBOUNCE: Duration = Duration::from_secs(2);
36/// 实测 EdgeOne 完成一次目录或 URL 清除要 9–20 秒(2026-11-10)。第二轮要落在第一轮完成之后,
37/// 才能清掉第一轮进行期间节点从源站拉回的旧页面。
38const SECOND_PASS_DELAY: Duration = Duration::from_secs(30);
39const RETRY_DELAYS: [Duration; 3] = [
40    Duration::from_secs(2),
41    Duration::from_secs(10),
42    Duration::from_secs(30),
43];
44
45static SIGNAL: Notify = Notify::const_new();
46
47/// 请求清除边缘 HTML 缓存。只发信号,立即返回;未配置凭据时没有任务在等,信号被忽略。
48pub fn request_purge() {
49    SIGNAL.notify_one();
50}
51
52struct Credentials {
53    zone_id: String,
54    secret_id: String,
55    secret_key: String,
56}
57
58impl Credentials {
59    fn from_env() -> Option<Self> {
60        let read = |name: &str| {
61            std::env::var(name)
62                .ok()
63                .map(|v| v.trim().to_owned())
64                .filter(|v| !v.is_empty())
65        };
66        let zone_id = read("EDGEONE_ZONE_ID");
67        let secret_id = read("TENCENTCLOUD_SECRET_ID");
68        let secret_key = read("TENCENTCLOUD_SECRET_KEY");
69        match (zone_id, secret_id, secret_key) {
70            (Some(zone_id), Some(secret_id), Some(secret_key)) => Some(Self {
71                zone_id,
72                secret_id,
73                secret_key,
74            }),
75            (None, None, None) => None,
76            _ => {
77                tracing::warn!(
78                    "EDGEONE_ZONE_ID / TENCENTCLOUD_SECRET_ID / TENCENTCLOUD_SECRET_KEY 未配置齐,不清边缘缓存"
79                );
80                None
81            }
82        }
83    }
84}
85
86/// 是否配置了清边缘缓存的凭据(启动日志用)。
87pub fn is_configured() -> bool {
88    [
89        "EDGEONE_ZONE_ID",
90        "TENCENTCLOUD_SECRET_ID",
91        "TENCENTCLOUD_SECRET_KEY",
92    ]
93    .iter()
94    .all(|name| std::env::var(name).is_ok_and(|v| !v.trim().is_empty()))
95}
96
97/// 常驻任务:等待信号,防抖后清两次。
98pub async fn run() {
99    let Some(credentials) = Credentials::from_env() else {
100        return;
101    };
102    let client = match reqwest::Client::builder()
103        .timeout(Duration::from_secs(15))
104        .connect_timeout(Duration::from_secs(5))
105        .user_agent("yggdrasil-edge-purge/1.0")
106        .build()
107    {
108        Ok(client) => client,
109        Err(error) => {
110            tracing::error!(%error, "边缘缓存清理客户端初始化失败,不清边缘缓存");
111            return;
112        }
113    };
114    tracing::info!(zone_id = %credentials.zone_id, "EdgeOne HTML 缓存清理已启用");
115
116    loop {
117        SIGNAL.notified().await;
118        tokio::time::sleep(DEBOUNCE).await;
119        // 防抖窗口内的后续信号由这一轮覆盖。
120        let _ = SIGNAL.notified().now_or_never();
121
122        let base = crate::api::settings::runtime_security_settings()
123            .await
124            .app_base_url;
125        let Some(targets) = PurgeTargets::for_base(&base) else {
126            tracing::warn!("APP_BASE_URL 未设置或不是 http(s) 地址,无法清边缘缓存");
127            continue;
128        };
129        purge_with_retry(&client, &credentials, &targets, "首次").await;
130        // 第二轮期间到达的信号留到下一轮,各自也会清两次。
131        tokio::time::sleep(SECOND_PASS_DELAY).await;
132        purge_with_retry(&client, &credentials, &targets, "延迟").await;
133    }
134}
135
136/// 一轮清除的目标:目录(`purge_prefix`)与固定页面(`purge_url`)。
137#[derive(Debug, PartialEq)]
138struct PurgeTargets {
139    prefixes: Vec<String>,
140    pages: Vec<String>,
141}
142
143impl PurgeTargets {
144    fn for_base(base: &str) -> Option<Self> {
145        let base = base.trim().trim_end_matches('/');
146        let host = base
147            .strip_prefix("https://")
148            .or_else(|| base.strip_prefix("http://"))?;
149        if host.is_empty() || host.contains('/') {
150            return None;
151        }
152        let join = |path: &str| format!("{base}{path}");
153        Some(Self {
154            prefixes: crate::ssr_cache::CACHEABLE_PREFIXES
155                .iter()
156                .map(|p| join(p))
157                .collect(),
158            pages: crate::ssr_cache::CACHEABLE_PAGES
159                .iter()
160                .map(|p| join(p))
161                .collect(),
162        })
163    }
164}
165
166async fn purge_with_retry(
167    client: &reqwest::Client,
168    credentials: &Credentials,
169    targets: &PurgeTargets,
170    pass: &'static str,
171) {
172    let tasks = [
173        (
174            "purge_prefix",
175            serde_json::json!({
176                "ZoneId": credentials.zone_id,
177                "Type": "purge_prefix",
178                // delete:不论源站内容是否变化都删除节点缓存,行为确定。
179                "Method": "delete",
180                "Targets": targets.prefixes,
181            }),
182        ),
183        (
184            "purge_url",
185            serde_json::json!({
186                "ZoneId": credentials.zone_id,
187                "Type": "purge_url",
188                "Targets": targets.pages,
189            }),
190        ),
191    ];
192    for (kind, body) in tasks {
193        let payload = body.to_string();
194        let mut attempt = 0;
195        loop {
196            match create_purge_task(client, credentials, &payload).await {
197                Ok(job_id) => {
198                    tracing::info!(pass, kind, %job_id, "已提交 EdgeOne HTML 缓存清除");
199                    break;
200                }
201                Err(error) if attempt < RETRY_DELAYS.len() => {
202                    tracing::warn!(pass, kind, attempt, %error, "EdgeOne 缓存清除失败,稍后重试");
203                    tokio::time::sleep(RETRY_DELAYS[attempt]).await;
204                    attempt += 1;
205                }
206                Err(error) => {
207                    tracing::error!(
208                        pass,
209                        kind,
210                        %error,
211                        "EdgeOne 缓存清除最终失败,边缘 HTML 要等 s-maxage 到期才更新"
212                    );
213                    break;
214                }
215            }
216        }
217    }
218}
219
220async fn create_purge_task(
221    client: &reqwest::Client,
222    credentials: &Credentials,
223    payload: &str,
224) -> Result<String, String> {
225    let timestamp = chrono::Utc::now().timestamp();
226    let authorization = authorization(
227        &credentials.secret_id,
228        &credentials.secret_key,
229        timestamp,
230        payload,
231    );
232    let response = client
233        .post(format!("https://{API_HOST}/"))
234        .header("Content-Type", CONTENT_TYPE)
235        .header("X-TC-Action", API_ACTION)
236        .header("X-TC-Version", API_VERSION)
237        .header("X-TC-Timestamp", timestamp.to_string())
238        .header("Authorization", authorization)
239        .body(payload.to_owned())
240        .send()
241        .await
242        .map_err(|e| format!("请求失败:{e}"))?;
243    let status = response.status();
244    let text = response
245        .text()
246        .await
247        .map_err(|e| format!("读取响应失败:{e}"))?;
248    if !status.is_success() {
249        return Err(format!("HTTP {status}"));
250    }
251    parse_response(&text)
252}
253
254/// 解析云 API 响应:`Response.Error` 或非空 `FailedList` 视为失败,成功返回 `JobId`。
255fn parse_response(text: &str) -> Result<String, String> {
256    let value: serde_json::Value =
257        serde_json::from_str(text).map_err(|_| "响应不是 JSON".to_owned())?;
258    let response = &value["Response"];
259    if let Some(error) = response.get("Error") {
260        return Err(format!(
261            "{}: {}",
262            error["Code"].as_str().unwrap_or("Unknown"),
263            error["Message"].as_str().unwrap_or("")
264        ));
265    }
266    if let Some(failed) = response["FailedList"].as_array().filter(|l| !l.is_empty()) {
267        let failed: Vec<String> = failed
268            .iter()
269            .map(|item| {
270                format!(
271                    "{} ({})",
272                    item["Target"].as_str().unwrap_or("?"),
273                    item["Reason"].as_str().unwrap_or("?")
274                )
275            })
276            .collect();
277        return Err(format!("部分目标失败:{}", failed.join(", ")));
278    }
279    Ok(response["JobId"].as_str().unwrap_or("").to_owned())
280}
281
282/// TC3-HMAC-SHA256 签名,构造方式与腾讯云 Python SDK(`_get_tc3_signature`)一致。
283fn authorization(secret_id: &str, secret_key: &str, timestamp: i64, payload: &str) -> String {
284    let date = chrono::DateTime::from_timestamp(timestamp, 0)
285        .unwrap_or_default()
286        .format("%Y-%m-%d")
287        .to_string();
288    let canonical_request = format!(
289        "POST\n/\n\ncontent-type:{CONTENT_TYPE}\nhost:{API_HOST}\n\ncontent-type;host\n{}",
290        hex::encode(Sha256::digest(payload.as_bytes()))
291    );
292    let scope = format!("{date}/{API_SERVICE}/tc3_request");
293    let string_to_sign = format!(
294        "TC3-HMAC-SHA256\n{timestamp}\n{scope}\n{}",
295        hex::encode(Sha256::digest(canonical_request.as_bytes()))
296    );
297    let key = hmac_sha256(format!("TC3{secret_key}").as_bytes(), date.as_bytes());
298    let key = hmac_sha256(&key, API_SERVICE.as_bytes());
299    let key = hmac_sha256(&key, b"tc3_request");
300    let signature = hex::encode(hmac_sha256(&key, string_to_sign.as_bytes()));
301    format!(
302        "TC3-HMAC-SHA256 Credential={secret_id}/{scope}, SignedHeaders=content-type;host, Signature={signature}"
303    )
304}
305
306/// HMAC-SHA256(RFC 2104)。只此一处用到,不为它引入 `hmac` crate。
307fn hmac_sha256(key: &[u8], message: &[u8]) -> Vec<u8> {
308    const BLOCK: usize = 64;
309    let mut block_key = [0u8; BLOCK];
310    if key.len() > BLOCK {
311        let digest = Sha256::digest(key);
312        block_key[..digest.len()].copy_from_slice(&digest);
313    } else {
314        block_key[..key.len()].copy_from_slice(key);
315    }
316    let mut inner = Sha256::new();
317    inner.update(block_key.map(|b| b ^ 0x36));
318    inner.update(message);
319    let inner = inner.finalize();
320    let mut outer = Sha256::new();
321    outer.update(block_key.map(|b| b ^ 0x5c));
322    outer.update(inner);
323    outer.finalize().to_vec()
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn hmac_matches_rfc_4231() {
332        // RFC 4231 测试用例 2 与 6(密钥长于块大小)。
333        assert_eq!(
334            hex::encode(hmac_sha256(b"Jefe", b"what do ya want for nothing?")),
335            "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
336        );
337        assert_eq!(
338            hex::encode(hmac_sha256(
339                &[0xaa; 131],
340                b"Test Using Larger Than Block-Size Key - Hash Key First"
341            )),
342            "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"
343        );
344    }
345
346    #[test]
347    fn signature_matches_tencentcloud_sdk() {
348        // 期望值由腾讯云 Python SDK 的 `Sign.sign_tc3` 按同样的规范请求算出。
349        let payload =
350            r#"{"ZoneId":"zone-test","Type":"purge_url","Targets":["https://example.com/"]}"#;
351        assert_eq!(
352            authorization("AKIDtest", "test-secret-key", 1_762_761_600, payload),
353            "TC3-HMAC-SHA256 Credential=AKIDtest/2025-11-10/teo/tc3_request, \
354             SignedHeaders=content-type;host, \
355             Signature=c5074f1f2ae43492077ae5622538a500c6a5af8fc435886f2602c36218bf845b"
356        );
357    }
358
359    #[test]
360    fn targets_cover_the_ssr_cache_whitelist() {
361        let targets = PurgeTargets::for_base(" https://rua.plus/ ").unwrap();
362        assert_eq!(
363            targets.prefixes,
364            [
365                "https://rua.plus/post/",
366                "https://rua.plus/page/",
367                "https://rua.plus/tags/"
368            ]
369        );
370        assert_eq!(
371            targets.pages,
372            [
373                "https://rua.plus/",
374                "https://rua.plus/archives",
375                "https://rua.plus/friends",
376                "https://rua.plus/about",
377                "https://rua.plus/changelog"
378            ]
379        );
380        for base in [
381            "",
382            "rua.plus",
383            "ftp://rua.plus",
384            "https://",
385            "https://rua.plus/blog",
386        ] {
387            assert_eq!(PurgeTargets::for_base(base), None, "{base}");
388        }
389    }
390
391    #[test]
392    fn response_errors_and_failed_targets_are_failures() {
393        assert_eq!(
394            parse_response(r#"{"Response":{"JobId":"job-1","FailedList":[],"RequestId":"r"}}"#),
395            Ok("job-1".to_owned())
396        );
397        assert_eq!(
398            parse_response(
399                r#"{"Response":{"Error":{"Code":"AuthFailure.SignatureFailure","Message":"bad"},"RequestId":"r"}}"#
400            ),
401            Err("AuthFailure.SignatureFailure: bad".to_owned())
402        );
403        assert_eq!(
404            parse_response(
405                r#"{"Response":{"JobId":"job-2","FailedList":[{"Target":"https://rua.plus/","Reason":"quota"}]}}"#
406            ),
407            Err("部分目标失败:https://rua.plus/ (quota)".to_owned())
408        );
409        assert!(parse_response("<html>").is_err());
410    }
411}