Skip to main content

pagesift_api/
attachments.rs

1//! 附件的存储与下载:响应里只列 `{id, format, bytes, hash}`,内容用 `GET /v2/attachments/{id}` 取。
2//!
3//! - 保留期与结果相同([`crate::jobs::JOB_TTL`],任务结果的保留期);每次再出现同一内容就续期。
4//! - 内容寻址(id 是 SHA-256 前缀加扩展名),同一内容只存一份。
5//! - 占用有上限:内存预算(`pagesift_net::resources::budget`)的 `δ`,超出时先淘汰最早过期的。
6//! - 先存在进程内存里。服务开了任务持久化(有存储)时同时写进存储(HTTP API;MCP 只用内存),内存里淘汰了、
7//!   或者服务重启了,下载接口从存储里取,到期前链接一直有效;0.90 只存内存,重启或被新图片挤掉后
8//!   链接就 404,而任务结果还在。到期的在启动时和下次访问时从存储删掉。
9//! - 有温备时(网关的任务镜像),附件的写入和删除同任务记录一样复制到温备,快照里也带着;
10//!   单个附件超过一条复制记录的上限(帧上限的一半,[`crate::JobLog::max_op`])时只存本地、记警告,
11//!   温备接管后这个附件的链接 404。
12//! - 没有存储时只在内存里:重启后没了,内存满了先淘汰最早过期的(易失缓存,不是可靠交付)。
13//! - 下载接口除了 API Key 鉴权外还校验账户授权(`attachment-access/<account>/<id>`):未授权返回 404,不泄漏存在性。
14//! - 全局内容寻址与跨账户内容去重保留;同一内容被多个账户各自抓取时,授权关系独立维护。
15//! - 升级前已存、没有授权记录的附件,启动时从已持久化的任务结果回填;回填失败只记警告、不阻塞启动。
16
17use std::collections::{BTreeMap, HashMap, HashSet};
18use std::sync::{Arc, Mutex, OnceLock};
19use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
20
21use axum::extract::{Extension, Path, State};
22use axum::http::header;
23use axum::response::{IntoResponse, Response};
24
25use crate::error::{ApiError, ErrorCode};
26use crate::limits::Caller;
27use crate::types::Document;
28use crate::types::attachment::mime_of;
29
30/// 存储里附件内容的键(`attachment/<id>`)、到期时刻的键(`attachment-exp/<id>`,Unix 毫秒)
31/// 以及账户访问授权的键(`attachment-access/<account>/<id>`,Unix 毫秒)。
32fn data_key(id: &str) -> String {
33    format!("attachment/{id}")
34}
35
36fn exp_key(id: &str) -> String {
37    format!("attachment-exp/{id}")
38}
39
40fn access_key(account: &str, id: &str) -> String {
41    format!("attachment-access/{account}/{id}")
42}
43
44fn now_ms() -> u64 {
45    SystemTime::now()
46        .duration_since(UNIX_EPOCH)
47        .map_or(0, |d| d.as_millis() as u64)
48}
49
50/// 附件写进的地方:任务存储,和(网关上)把写入复制到温备的镜像。
51#[derive(Clone)]
52pub(crate) struct Disk {
53    pub store: pagesift_store::Store,
54    pub mirror: Option<Arc<crate::jobs::mirror::JobMirror>>,
55}
56
57/// 删掉一个附件(经镜像,温备上也删),一并删掉其全部授权记录。
58fn remove(d: &Disk, id: &str) {
59    let mut access_keys = Vec::new();
60    if let Ok(keys) = d.store.tasks().kv_keys("attachment-access/") {
61        let suffix = format!("/{id}");
62        for k in keys {
63            if let Ok(s) = String::from_utf8(k)
64                && s.ends_with(&suffix)
65            {
66                access_keys.push(s);
67            }
68        }
69    }
70    let r = crate::jobs::mirror::write(&d.store, d.mirror.as_deref(), |w| {
71        w.kv_delete(&data_key(id))?;
72        w.kv_delete(&exp_key(id))?;
73        for ak in &access_keys {
74            w.kv_delete(ak)?;
75        }
76        Ok(())
77    });
78    if let Err(e) = r {
79        tracing::warn!(attachment = %id, error = %e, "deleting an expired attachment failed");
80    }
81}
82
83/// (键,值)。
84type Kv = (String, Vec<u8>);
85
86/// 存储里的全部附件:((内容键,内容),(到期键,到期时刻),[(授权键,到期时刻)])。快照用。
87pub(crate) fn stored(t: &pagesift_store::Tasks<'_>) -> Vec<(Kv, Kv, Vec<Kv>)> {
88    let mut access_map: HashMap<String, Vec<Kv>> = HashMap::new();
89    for k in t.kv_keys("attachment-access/").unwrap_or_default() {
90        let Ok(k_str) = String::from_utf8(k) else {
91            continue;
92        };
93        let Some(rest) = k_str.strip_prefix("attachment-access/") else {
94            continue;
95        };
96        let Some((_account, id)) = rest.rsplit_once('/') else {
97            continue;
98        };
99        if let Ok(Some(val)) = t.kv_get(&k_str) {
100            access_map
101                .entry(id.to_string())
102                .or_default()
103                .push((k_str, val));
104        }
105    }
106    let mut out = Vec::new();
107    for k in t.kv_keys("attachment-exp/").unwrap_or_default() {
108        let Ok(k) = String::from_utf8(k) else {
109            continue;
110        };
111        let id = &k["attachment-exp/".len()..];
112        if let (Ok(Some(exp)), Ok(Some(data))) = (t.kv_get(&k), t.kv_get(data_key(id))) {
113            let access = access_map.remove(id).unwrap_or_default();
114            out.push(((data_key(id), data), (k.clone(), exp), access));
115        }
116    }
117    out
118}
119
120/// 清掉存储里已经到期的附件与授权记录(建服务时调用,在后台做)。要在 tokio 运行时里调用。
121pub(crate) fn sweep_expired(d: Disk) {
122    tokio::spawn(async move {
123        let s = d.store.clone();
124        s.blocking(move |s| {
125            let now = now_ms();
126            let mut expired_ids = HashSet::new();
127            for k in s.tasks().kv_keys("attachment-exp/").unwrap_or_default() {
128                let Ok(k) = String::from_utf8(k) else {
129                    continue;
130                };
131                let id = &k["attachment-exp/".len()..];
132                if read_exp(s, id).is_none_or(|e| e <= now) {
133                    expired_ids.insert(id.to_string());
134                    remove(&d, id);
135                }
136            }
137            // 清理已过期附件或已到期的授权记录
138            for k in s.tasks().kv_keys("attachment-access/").unwrap_or_default() {
139                let Ok(k_str) = String::from_utf8(k) else {
140                    continue;
141                };
142                let Some(rest) = k_str.strip_prefix("attachment-access/") else {
143                    continue;
144                };
145                let Some((_, id)) = rest.rsplit_once('/') else {
146                    continue;
147                };
148                // 已随附件一起删掉的授权不再重复写删除记录
149                if expired_ids.contains(id) {
150                    continue;
151                }
152                let should_delete = match s.tasks().kv_get(&k_str) {
153                    Ok(Some(v)) if v.len() == 8 => {
154                        let exp = u64::from_le_bytes(v.as_slice().try_into().unwrap());
155                        exp <= now
156                    }
157                    Ok(Some(_)) => read_exp(s, id).is_none_or(|e| e <= now),
158                    _ => true,
159                };
160                if should_delete {
161                    let _ =
162                        crate::jobs::mirror::write(s, d.mirror.as_deref(), |w| w.kv_delete(&k_str));
163                }
164            }
165        })
166        .await;
167    });
168}
169
170fn read_exp(s: &pagesift_store::Store, id: &str) -> Option<u64> {
171    let v = s.tasks().kv_get(exp_key(id)).ok()??;
172    Some(u64::from_le_bytes(v.as_slice().try_into().ok()?))
173}
174
175/// 写进存储(已有就只续期)并写入访问授权,有镜像时同时复制到温备。在后台做,不等。
176fn save(disk: Option<&Disk>, id: String, data: Arc<[u8]>, account: String) {
177    let (Some(d), Ok(rt)) = (disk.cloned(), tokio::runtime::Handle::try_current()) else {
178        return;
179    };
180    let exp = now_ms() + crate::jobs::JOB_TTL.as_millis() as u64;
181    rt.spawn(async move {
182        let s = d.store.clone();
183        s.blocking(move |s| {
184            // 超过一条复制记录上限的只存本地
185            let mirror = d
186                .mirror
187                .as_deref()
188                .filter(|m| data.len() + data_key(&id).len() < m.max_op());
189            if d.mirror.is_some() && mirror.is_none() {
190                tracing::warn!(attachment = %id, bytes = data.len(), "attachment too large to replicate to the standby; stored locally only");
191            }
192            let fresh = read_exp(s, &id).is_none();
193            let r = crate::jobs::mirror::write(s, mirror, |w| {
194                if fresh {
195                    w.kv_put(&data_key(&id), &data)?;
196                }
197                w.kv_put(&exp_key(&id), &exp.to_le_bytes())?;
198                w.kv_put(&access_key(&account, &id), &exp.to_le_bytes())
199            });
200            if let Err(e) = r {
201                tracing::warn!(attachment = %id, error = %e, "saving an attachment failed; it stays in memory only");
202            }
203        })
204        .await;
205    });
206}
207
208/// 仅将附件访问授权写进存储(数据已在存储中时),到期时刻与附件一致。
209fn save_access(disk: Option<&Disk>, id: String, account: String) {
210    let (Some(d), Ok(rt)) = (disk.cloned(), tokio::runtime::Handle::try_current()) else {
211        return;
212    };
213    rt.spawn(async move {
214        let s = d.store.clone();
215        s.blocking(move |s| {
216            let Some(exp) = read_exp(s, &id).filter(|e| *e > now_ms()) else {
217                return;
218            };
219            let r = crate::jobs::mirror::write(s, d.mirror.as_deref(), |w| {
220                w.kv_put(&access_key(&account, &id), &exp.to_le_bytes())
221            });
222            if let Err(e) = r {
223                tracing::warn!(attachment = %id, account = %account, error = %e, "saving attachment authorization failed");
224            }
225        })
226        .await;
227    });
228}
229
230/// 从存储取(内存里没有时)。到期的顺手删掉。
231pub(crate) async fn load(disk: Option<Disk>, id: String) -> Option<(Arc<[u8]>, &'static str)> {
232    let d = disk?;
233    let store = d.store.clone();
234    store
235        .blocking(move |s| {
236            let exp = read_exp(s, &id)?;
237            if exp <= now_ms() {
238                remove(&d, &id);
239                return None;
240            }
241            let data = s.tasks().kv_get(data_key(&id)).ok()??;
242            let ext = id.rsplit_once('.').map_or("", |(_, e)| e);
243            Some((Arc::from(data), mime_of(ext)))
244        })
245        .await
246}
247
248struct Entry {
249    data: Arc<[u8]>,
250    mime: &'static str,
251    expires: Instant,
252}
253
254#[derive(Default)]
255struct Store {
256    map: HashMap<String, Entry>,
257    /// (过期时刻,id):按过期先后淘汰。
258    order: BTreeMap<(Instant, u64), String>,
259    /// (账户, id)-> 过期时刻
260    access: HashMap<(String, String), Instant>,
261    seq: u64,
262    bytes: usize,
263}
264
265fn store() -> &'static Mutex<Store> {
266    static STORE: OnceLock<Mutex<Store>> = OnceLock::new();
267    STORE.get_or_init(Default::default)
268}
269
270/// 存储上限:内存预算的 δ(与统计表同一份额)。
271fn cap() -> usize {
272    // [δ] 全局风险水平的默认值 0.05,作为愿意分给附件的内存份额,与统计表一致
273    let delta = 0.05;
274    // [策略] 读不到内存预算(非 Linux、刷新之前)时的回退:64 MiB,约 300 张 200 KiB 的图片
275    let fallback = 64 * 1024 * 1024;
276    pagesift_net::resources::budget()
277        .map_or(fallback, |b| (b as f64 * delta) as usize)
278        .max(1)
279}
280
281/// 记下文档里的附件内容(没有内容的跳过);有 `disk`(任务持久化的存储)时也写进去。
282pub fn keep(doc: &Document, disk: Option<&pagesift_store::Store>) {
283    let disk = disk.map(|store| Disk {
284        store: store.clone(),
285        mirror: None,
286    });
287    keep_in_for(doc, disk.as_ref(), "anonymous");
288}
289
290/// 记下附件并授予调用方账户访问权限;写进任务存储并经镜像复制到温备(HTTP API 用)。
291pub(crate) fn keep_in_for(doc: &Document, disk: Option<&Disk>, account: &str) {
292    keep_inner(doc, disk, true, account);
293}
294
295/// Job pages persist opt-in assets synchronously with their receipt instead.
296pub(crate) fn keep_job_in_for(doc: &Document, disk: Option<&Disk>, account: &str) {
297    keep_inner(doc, disk, false, account);
298}
299
300fn keep_inner(doc: &Document, disk: Option<&Disk>, save_inline: bool, account: &str) {
301    if doc.attachments.is_empty() {
302        return;
303    }
304    let expires = Instant::now() + crate::jobs::JOB_TTL;
305    let cap = cap();
306    let mut st = store().lock().unwrap_or_else(|e| e.into_inner());
307    let now = Instant::now();
308    // 先清掉过期的
309    while let Some((&key, _)) = st.order.first_key_value() {
310        if key.0 > now {
311            break;
312        }
313        drop_oldest(&mut st);
314    }
315    st.access.retain(|_, exp| *exp > now);
316
317    for a in &doc.attachments {
318        st.access
319            .insert((account.to_string(), a.id.clone()), expires);
320        let Some(data) = &a.data else {
321            if save_inline {
322                save_access(disk, a.id.clone(), account.to_string());
323            }
324            continue;
325        };
326        if data.len() > cap {
327            // 超过内存上限的只进存储(有的话)
328            if save_inline || !a.inline_data {
329                save(disk, a.id.clone(), data.clone(), account.to_string());
330            }
331            continue;
332        }
333        if let Some(old) = st.map.remove(&a.id) {
334            st.bytes -= old.data.len();
335            let id = a.id.clone();
336            st.order.retain(|_, v| *v != id);
337        }
338        while st.bytes + data.len() > cap && !st.order.is_empty() {
339            drop_oldest(&mut st);
340        }
341        st.seq += 1;
342        let seq = st.seq;
343        st.order.insert((expires, seq), a.id.clone());
344        st.bytes += data.len();
345        st.map.insert(
346            a.id.clone(),
347            Entry {
348                data: data.clone(),
349                mime: a.mime(),
350                expires,
351            },
352        );
353        if save_inline || !a.inline_data {
354            save(disk, a.id.clone(), data.clone(), account.to_string());
355        }
356    }
357}
358
359/// Store the one canonical copy before the job receipt that refers to it.
360pub(crate) fn persist_job_asset(
361    w: &mut crate::jobs::mirror::Writer<'_>,
362    id: &str,
363    data: &[u8],
364) -> pagesift_store::Result<()> {
365    let key = data_key(id);
366    if w.kv_get(&key)?.is_none() {
367        w.kv_put(&key, data)?;
368    }
369    let exp = now_ms() + crate::jobs::JOB_TTL.as_millis() as u64;
370    w.kv_put(&exp_key(id), &exp.to_le_bytes())
371}
372
373/// 为某个账户持久化附件访问授权,到期时刻与附件一致。
374pub(crate) fn persist_access(
375    w: &mut crate::jobs::mirror::Writer<'_>,
376    account: &str,
377    id: &str,
378) -> pagesift_store::Result<()> {
379    let exp = match w.kv_get(&exp_key(id))? {
380        Some(v) if v.len() == 8 => u64::from_le_bytes(v.as_slice().try_into().unwrap()),
381        _ => now_ms() + crate::jobs::JOB_TTL.as_millis() as u64,
382    };
383    w.kv_put(&access_key(account, id), &exp.to_le_bytes())
384}
385
386/// 检查调用方是否有权访问该附件。先查内存,没有再查存储。
387pub(crate) async fn check_access(disk: Option<&Disk>, account: &str, id: &str) -> bool {
388    let now = Instant::now();
389    let in_memory = {
390        let mut st = store().lock().unwrap_or_else(|e| e.into_inner());
391        st.access.retain(|_, exp| *exp > now);
392        st.access
393            .contains_key(&(account.to_string(), id.to_string()))
394    };
395    if in_memory {
396        return true;
397    }
398    let Some(d) = disk else {
399        return false;
400    };
401    let s = d.store.clone();
402    let account_owned = account.to_string();
403    let id_owned = id.to_string();
404    let mirror = d.mirror.clone();
405    let has = s
406        .blocking(move |s| {
407            let key = access_key(&account_owned, &id_owned);
408            let Some(val) = s.tasks().kv_get(&key).ok().flatten() else {
409                return false;
410            };
411            if val.len() == 8 {
412                let exp = u64::from_le_bytes(val.as_slice().try_into().unwrap());
413                if exp <= now_ms() {
414                    let _ = crate::jobs::mirror::write(s, mirror.as_deref(), |w| w.kv_delete(&key));
415                    return false;
416                }
417            }
418            true
419        })
420        .await;
421    if has {
422        let mut st = store().lock().unwrap_or_else(|e| e.into_inner());
423        st.access.insert(
424            (account.to_string(), id.to_string()),
425            Instant::now() + crate::jobs::JOB_TTL,
426        );
427        true
428    } else {
429        false
430    }
431}
432
433/// 存量附件迁移:从已持久化的任务结果中回填 (job.owner, id) 授权。
434pub(crate) fn backfill_from_jobs(d: &Disk) -> pagesift_store::Result<usize> {
435    let s = &d.store;
436    let t = s.tasks();
437    let keys = t.kv_keys("job/")?;
438    let now = SystemTime::now();
439    let now_ms_val = now_ms();
440    let mut count = 0;
441    for key in keys {
442        let Some(job_id) = std::str::from_utf8(&key)
443            .ok()
444            .and_then(|k| k.strip_prefix("job/"))
445            .filter(|id| !id.contains('/'))
446        else {
447            continue;
448        };
449        let Some(meta) = t
450            .kv_get(&key)
451            .ok()
452            .flatten()
453            .and_then(|v| serde_json::from_slice::<crate::jobs::store::Meta>(&v).ok())
454        else {
455            continue;
456        };
457        let created = UNIX_EPOCH + Duration::from_millis(meta.created_ms);
458        if created + crate::jobs::JOB_TTL <= now {
459            continue;
460        }
461        let owner = meta.owner.as_deref().unwrap_or("anonymous");
462        let pages_key = crate::jobs::store::list_key(job_id, "pages");
463        let len = t.list_len(&pages_key);
464        for start in (0..len).step_by(256) {
465            let Ok(batch) = t.list_range(&pages_key, start, 256) else {
466                continue;
467            };
468            for raw in batch {
469                let Ok(val) = serde_json::from_slice::<serde_json::Value>(&raw) else {
470                    continue;
471                };
472                let Some(attachments) = val.get("attachments").and_then(|v| v.as_array()) else {
473                    continue;
474                };
475                for a in attachments {
476                    let Some(id) = a.get("id").and_then(|v| v.as_str()) else {
477                        continue;
478                    };
479                    let Some(exp) = read_exp(s, id).filter(|e| *e > now_ms_val) else {
480                        continue;
481                    };
482                    let acc_key = access_key(owner, id);
483                    if t.kv_get(&acc_key).ok().flatten().is_none() {
484                        let _ = crate::jobs::mirror::write(s, d.mirror.as_deref(), |w| {
485                            w.kv_put(&acc_key, &exp.to_le_bytes())
486                        });
487                        count += 1;
488                    }
489                }
490            }
491        }
492    }
493    Ok(count)
494}
495
496/// 服务启动时在后台回填授权,回填失败只记警告、不阻塞启动。
497pub(crate) fn backfill_access(d: Disk) {
498    tokio::spawn(async move {
499        let d_clone = d.clone();
500        let r = d
501            .store
502            .blocking(move |_| backfill_from_jobs(&d_clone))
503            .await;
504        match r {
505            Ok(n) => {
506                if n > 0 {
507                    tracing::info!(
508                        count = n,
509                        "backfilled attachment authorizations from saved jobs"
510                    );
511                }
512            }
513            Err(e) => {
514                tracing::warn!(error = %e, "backfilling attachment authorizations failed");
515            }
516        }
517    });
518}
519
520fn drop_oldest(st: &mut Store) {
521    if let Some((_, id)) = st.order.pop_first()
522        && let Some(e) = st.map.remove(&id)
523    {
524        st.bytes -= e.data.len();
525    }
526}
527
528/// 取一个附件:(内容,MIME 类型)。过期或不存在时为 `None`。
529pub fn get(id: &str) -> Option<(Arc<[u8]>, &'static str)> {
530    let st = store().lock().unwrap_or_else(|e| e.into_inner());
531    st.map
532        .get(id)
533        .filter(|e| e.expires > Instant::now())
534        .map(|e| (e.data.clone(), e.mime))
535}
536
537/// `GET /v2/attachments/{id}`。
538pub(crate) async fn download(
539    State(s): State<crate::server::AppState>,
540    caller: Option<Extension<Caller>>,
541    Path(id): Path<String>,
542) -> Result<Response, ApiError> {
543    let account = caller
544        .as_ref()
545        .map_or("anonymous", |Extension(c)| &*c.account);
546    if !check_access(s.jobs.disk().as_ref(), account, &id).await {
547        return Err(ApiError::new(
548            ErrorCode::NotFound,
549            format!("attachment {id} not found or expired"),
550        ));
551    }
552    let found = match get(&id) {
553        Some(x) => Some(x),
554        None => load(s.jobs.disk(), id.clone()).await,
555    };
556    let Some((data, mime)) = found else {
557        return Err(ApiError::new(
558            ErrorCode::NotFound,
559            format!("attachment {id} not found or expired"),
560        ));
561    };
562    Ok((
563        [
564            (header::CONTENT_TYPE, mime),
565            (header::CACHE_CONTROL, "private, max-age=86400, immutable"),
566        ],
567        data.to_vec(),
568    )
569        .into_response())
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575    use crate::types::Attachment;
576
577    #[test]
578    fn kept_attachments_can_be_fetched_by_id() {
579        let mut doc = Document::default();
580        doc.attachments
581            .push(Attachment::new(b"GIF89a-test".to_vec(), "gif", None));
582        keep(&doc, None);
583        let id = &doc.attachments[0].id;
584        let (data, mime) = get(id).unwrap();
585        assert_eq!(&*data, b"GIF89a-test");
586        assert_eq!(mime, "image/gif");
587        assert!(get("0000.png").is_none());
588    }
589
590    #[tokio::test]
591    async fn synchronous_job_asset_expires_and_is_removed() {
592        let dir = tempfile::tempdir().unwrap();
593        let store = pagesift_store::Store::open_dir(dir.path()).unwrap();
594        let a = Attachment::new(b"\x89PNG\r\n\x1a\nexpiry-test".to_vec(), "png", None);
595        crate::jobs::mirror::write(&store, None, |w| {
596            persist_job_asset(w, &a.id, a.data.as_deref().unwrap())
597        })
598        .unwrap();
599        let disk = Disk {
600            store: store.clone(),
601            mirror: None,
602        };
603        let (bytes, mime) = load(Some(disk.clone()), a.id.clone()).await.unwrap();
604        assert_eq!(bytes.as_ref(), a.data.as_deref().unwrap());
605        assert_eq!(mime, "image/png");
606        store
607            .tasks()
608            .kv_put(exp_key(&a.id), &1u64.to_le_bytes())
609            .unwrap();
610        assert!(load(Some(disk), a.id.clone()).await.is_none());
611        assert!(store.tasks().kv_get(data_key(&a.id)).unwrap().is_none());
612    }
613
614    /// 有存储时,内存里没有了(重启、被挤掉)也能从存储读回;0.90 这时 404。
615    #[tokio::test(flavor = "multi_thread")]
616    async fn persisted_attachments_survive_losing_the_memory_copy() {
617        let dir = tempfile::tempdir().unwrap();
618        let store = pagesift_store::Store::open_dir(dir.path()).unwrap();
619        let mut doc = Document::default();
620        doc.attachments
621            .push(Attachment::new(b"\x89PNG-persisted".to_vec(), "png", None));
622        keep(&doc, Some(&store));
623        let id = doc.attachments[0].id.clone();
624        // 等后台写完
625        for _ in 0..500 {
626            if read_exp(&store, &id).is_some() {
627                break;
628            }
629            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
630        }
631        // 模拟重启:清空内存
632        {
633            let mut st = super::store().lock().unwrap();
634            let e = st.map.remove(&id).unwrap();
635            st.bytes -= e.data.len();
636            st.order.retain(|_, v| *v != id);
637        }
638        assert!(get(&id).is_none());
639        let (data, mime) = load(
640            Some(Disk {
641                store: store.clone(),
642                mirror: None,
643            }),
644            id.clone(),
645        )
646        .await
647        .unwrap();
648        assert_eq!((&*data, mime), (&b"\x89PNG-persisted"[..], "image/png"));
649        // 到期的不再给出,并从存储删掉
650        store
651            .tasks()
652            .kv_put(exp_key(&id), &1u64.to_le_bytes())
653            .unwrap();
654        assert!(
655            load(
656                Some(Disk {
657                    store: store.clone(),
658                    mirror: None
659                }),
660                id.clone()
661            )
662            .await
663            .is_none()
664        );
665        assert!(store.tasks().kv_get(data_key(&id)).unwrap().is_none());
666    }
667
668    struct Ops(Mutex<Vec<Vec<u8>>>);
669
670    impl crate::JobLog for Ops {
671        fn append(&self, op: Vec<u8>) -> Result<(), String> {
672            self.0.lock().unwrap().push(op);
673            Ok(())
674        }
675    }
676
677    /// 网关上的附件随任务记录复制:温备回放日志后能下载,从快照重建的副本也能(0.90 不复制附件)。
678    #[tokio::test(flavor = "multi_thread")]
679    async fn attachments_replicate_with_the_job_records() {
680        let dirs: Vec<_> = (0..3).map(|_| tempfile::tempdir().unwrap()).collect();
681        let open = |i: usize| pagesift_store::Store::open_dir(dirs[i].path()).unwrap();
682        let (src, dst, from_snapshot) = (open(0), open(1), open(2));
683        let log = Arc::new(Ops(Mutex::default()));
684        let mirror = crate::JobMirror::new(log.clone());
685        let mut doc = Document::default();
686        doc.attachments
687            .push(Attachment::new(b"GIF89a-replicated".to_vec(), "gif", None));
688        let disk = Disk {
689            store: src.clone(),
690            mirror: Some(mirror.clone()),
691        };
692        keep_in_for(&doc, Some(&disk), "alice");
693        let id = doc.attachments[0].id.clone();
694        for _ in 0..500 {
695            if log.0.lock().unwrap().len() >= 3 {
696                break;
697            }
698            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
699        }
700        for op in log.0.lock().unwrap().iter() {
701            crate::apply_op(&dst, op).unwrap();
702        }
703        for op in mirror.dump(&src) {
704            crate::apply_op(&from_snapshot, &op).unwrap();
705        }
706        for s in [dst, from_snapshot] {
707            // 授权键本身也随日志/快照复制;直接查存储,避免内存缓存掩盖缺失
708            assert!(
709                s.tasks()
710                    .kv_get(access_key("alice", &id))
711                    .unwrap()
712                    .is_some()
713            );
714            assert!(s.tasks().kv_get(access_key("bob", &id)).unwrap().is_none());
715            let d = Disk {
716                store: s,
717                mirror: None,
718            };
719            assert!(check_access(Some(&d), "alice", &id).await);
720            assert!(!check_access(Some(&d), "bob", &id).await);
721            let (data, mime) = load(Some(d), id.clone()).await.expect("replicated");
722            assert_eq!((&*data, mime), (&b"GIF89a-replicated"[..], "image/gif"));
723        }
724    }
725
726    /// 存量附件在升级后通过任务结果回填授权(0.90 / 1.2.3 存量无 access 键)。
727    #[tokio::test(flavor = "multi_thread")]
728    async fn legacy_attachments_are_backfilled_from_saved_jobs() {
729        let dir = tempfile::tempdir().unwrap();
730        let store = pagesift_store::Store::open_dir(dir.path()).unwrap();
731        let disk = Disk {
732            store: store.clone(),
733            mirror: None,
734        };
735        let a = Attachment::new(b"\x89PNG-legacy".to_vec(), "png", None);
736        let id = a.id.clone();
737        // 模拟升级前存入的附件:只有 attachment/{id} 和 attachment-exp/{id},无 attachment-access
738        crate::jobs::mirror::write(&store, None, |w| {
739            persist_job_asset(w, &id, a.data.as_deref().unwrap())
740        })
741        .unwrap();
742
743        // 存一个属于 alice 的任务,结果中引用该附件
744        let mut doc = Document::default();
745        doc.attachments.push(a);
746        let meta = crate::jobs::store::Meta {
747            kind: crate::jobs::JobKind::BatchScrape,
748            state: crate::jobs::JobState::Completed,
749            created_ms: now_ms(),
750            total: 1,
751            completed: 1,
752            owner: Some("alice".into()),
753            webhook: None,
754            crawl: None,
755            batch: None,
756            summary: None,
757        };
758        store
759            .tasks()
760            .kv_put("job/job-1", &serde_json::to_vec(&meta).unwrap())
761            .unwrap();
762        let page_raw = serde_json::to_vec(&doc).unwrap();
763        store
764            .tasks()
765            .list_push("job/job-1/pages", &page_raw)
766            .unwrap();
767
768        // 回填前:alice 和 bob 均无授权
769        assert!(!check_access(Some(&disk), "alice", &id).await);
770        assert!(!check_access(Some(&disk), "bob", &id).await);
771
772        // 执行回填
773        let count = backfill_from_jobs(&disk).unwrap();
774        assert_eq!(count, 1);
775
776        // 回填后:alice 获得授权,bob 依然无权
777        assert!(check_access(Some(&disk), "alice", &id).await);
778        assert!(!check_access(Some(&disk), "bob", &id).await);
779    }
780}