Skip to main content

yggdrasil/components/assets/
upload_pool.rs

1//! 上传引擎内部状态机:与 UI 完全解耦的纯逻辑(无 `view!` / 无组件),
2//! 供 [`crate::components::assets::AssetUploadModal`] 组合使用。
3//!
4//! 三条入口(点击选择 / 拖拽 / 粘贴)收敛到同一个 `enqueue_files`:拿到的
5//! `web_sys::File` 先过 `validate_file`(镜像服务端 5MiB / 四种 MIME 的硬限制,
6//! 不合格立即记失败行、不发请求),合格项入共享队列后由 **worker 池**并发上传:
7//! 在跑 worker 数上限 = 并发配置(「站点配置」面板 / `UPLOAD_CONCURRENCY` env 播种,
8//! 挂载时经 `get_upload_settings` 拉取,失败回退默认 3)。每个 worker 张间停顿
9//! `500ms × 当前并发数`——N 路并行时聚合速率恒 ≤ 2/s,与默认上传限流桶
10//! (`RATE_LIMIT_UPLOAD_PER_SEC=2` / `BURST=15`)对齐:N=1 时与旧顺序版逐张 500ms
11//! 完全一致,N 调大也不触发 429。上传管线与文章编辑器完全同源
12//! (`upload_image_file` → `utils::web_upload::post_multipart_file` → `POST /api/upload`)。
13//!
14//! 注:worker 池共享状态用 `Rc<UploadPool>`:各入口闭包与 worker 克隆的都是 Rc,
15//! 共享同一份队列与句柄表。含 `web_sys::File` 的池不是 `Send + Sync`,由组件放进
16//! `StoredValue::new_local` 持有,并在 `on_cleanup` 里调用 `UploadPool::dispose`
17//! 释放文件句柄、清空队列并让在跑的 worker 退出。
18
19#[cfg(target_arch = "wasm32")]
20use leptos::prelude::*;
21
22#[cfg(target_arch = "wasm32")]
23use crate::bridges::tiptap::upload_image_file;
24#[cfg(target_arch = "wasm32")]
25use crate::utils::format_bytes;
26
27/// 单文件大小硬上限(5MiB),镜像服务端 `crate::utils::server::MAX_FILE_SIZE`。
28#[cfg(any(test, target_arch = "wasm32"))]
29const MAX_UPLOAD_BYTES: u64 = 5 * 1024 * 1024;
30/// 允许的 MIME 白名单,镜像服务端 `api/upload.rs` 的 `ALLOWED_MIME_TYPES`。
31#[cfg(any(test, target_arch = "wasm32"))]
32const ALLOWED_MIME: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"];
33
34/// 单条上传状态机:Queued → Uploading → Done / Failed(原因)。
35// 变体仅在 WASM 端构造,server SSR 只匹配渲染,非 wasm 构建放行 dead_code。
36#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
37#[derive(Clone, PartialEq)]
38pub enum UploadStatus {
39    Queued,
40    Uploading,
41    Done,
42    Failed(String),
43}
44
45/// 列表行数据(纯数据,两端都可编译;`web_sys::File` 句柄不进 signal,
46/// 由 WASM 端的 files 表以 id 关联保存,供重试取回)。
47// 同 UploadStatus:实例仅在 WASM 端构造。
48#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
49#[derive(Clone, PartialEq)]
50pub struct UploadItem {
51    pub(crate) id: u64,
52    pub(crate) name: String,
53    /// 入队时已用 `format_bytes` 格式化好的可读大小。
54    pub(crate) size: String,
55    pub(crate) status: UploadStatus,
56    /// 用户点了 ×:先播退出过渡(asset-modals.css 的 .asset-upload-row[data-removing]),EXIT_ANIM_MS 后真正摘除。
57    pub(crate) removing: bool,
58}
59
60/// 一批入队(一次拖拽/选择/粘贴)的完成追踪:remaining 归零且 ≥1 成功时回调一次。
61#[cfg(target_arch = "wasm32")]
62struct BatchCtx {
63    remaining: std::cell::Cell<usize>,
64    any_done: std::cell::Cell<bool>,
65}
66
67/// Worker 池共享状态:id 分配、重试句柄表、待传队列、在跑 worker 数。
68/// 组件持 `Rc<UploadPool>`(`StoredValue::new_local`):各入口闭包与 worker 看到的
69/// 永远是同一份队列/句柄表。字段私有——UI 层只经下方方法访问,
70/// 不直接触碰 files/queue 等内部表示。
71#[cfg(target_arch = "wasm32")]
72pub(crate) struct UploadPool {
73    next_id: std::cell::Cell<u64>,
74    files: std::cell::RefCell<Vec<(u64, web_sys::File)>>,
75    queue: std::cell::RefCell<std::collections::VecDeque<(u64, std::rc::Rc<BatchCtx>)>>,
76    active_workers: std::cell::Cell<u32>,
77    /// 组件卸载后置位:worker 不再取新任务,也不再回调父组件。
78    closed: std::cell::Cell<bool>,
79}
80
81#[cfg(target_arch = "wasm32")]
82impl UploadPool {
83    pub(crate) fn new() -> Self {
84        Self {
85            next_id: std::cell::Cell::new(0),
86            files: std::cell::RefCell::new(Vec::new()),
87            queue: std::cell::RefCell::new(std::collections::VecDeque::new()),
88            active_workers: std::cell::Cell::new(0),
89            closed: std::cell::Cell::new(false),
90        }
91    }
92
93    /// 组件卸载时调用:释放全部文件句柄、清空待传队列,在跑的 worker 在当前张结束后退出。
94    pub(crate) fn dispose(&self) {
95        self.closed.set(true);
96        self.files.borrow_mut().clear();
97        self.queue.borrow_mut().clear();
98    }
99
100    /// 取回指定 id 的文件句柄克隆(供 UI 层「重试」重新读取原始文件;不移除句柄表条目)。
101    pub(crate) fn find_file(&self, id: u64) -> Option<web_sys::File> {
102        self.files
103            .borrow()
104            .iter()
105            .find(|(fid, _)| *fid == id)
106            .map(|(_, f)| f.clone())
107    }
108
109    /// 移除指定 id 的文件句柄(UI 层用户点「移除」摘除该条目时调用,释放持有)。
110    pub(crate) fn remove_file(&self, id: u64) {
111        self.files.borrow_mut().retain(|(fid, _)| *fid != id);
112    }
113}
114
115/// 预校验:MIME 白名单 + 5MiB 上限。失败返回可读原因(直接展示在行内,不发请求)。
116///
117/// `pub(crate)`:`asset_picker.rs` 的内嵌上传入口复用同一份校验规则,与本模块
118/// worker 池入队时执行的规则保持单一实现来源。
119#[cfg(any(test, target_arch = "wasm32"))]
120pub(crate) fn validate_file(mime: &str, size: u64) -> Result<(), String> {
121    if !ALLOWED_MIME.contains(&mime) {
122        return Err("不支持的文件类型(仅 JPEG / PNG / GIF / WebP)".into());
123    }
124    if size > MAX_UPLOAD_BYTES {
125        return Err("大小超过 5MB 限制".into());
126    }
127    Ok(())
128}
129
130/// 更新单条状态:write guard 在函数内立即释放,绝不跨 await 持有。
131///
132/// 返回 `false` 表示信号已随组件销毁(异步任务在卸载后醒来时不得再写,否则 panic)。
133#[cfg(target_arch = "wasm32")]
134pub(crate) fn set_status(items: RwSignal<Vec<UploadItem>>, id: u64, status: UploadStatus) -> bool {
135    items
136        .try_update(|list| {
137            if let Some(it) = list.iter_mut().find(|it| it.id == id) {
138                it.status = status;
139            }
140        })
141        .is_some()
142}
143
144/// 单个 worker 的消费循环:队列空即退出并归还名额。
145///
146/// 张间停顿 `500ms × 当前并发数`:N 个 worker 并行时聚合速率恒 ≤ 2/s(停顿随 N
147/// 线性放大),与默认上传限流桶(`RATE_LIMIT_UPLOAD_PER_SEC=2` / `BURST=15`)对齐——
148/// N=1 时与旧顺序版逐张 500ms 完全一致,N 调大也不会触发 429。
149#[cfg(target_arch = "wasm32")]
150async fn worker_loop(
151    items: RwSignal<Vec<UploadItem>>,
152    pool: std::rc::Rc<UploadPool>,
153    concurrency: RwSignal<i32>,
154    on_uploaded: Callback<()>,
155) {
156    // 组件卸载后(信号已销毁 / 池已 dispose)所有写入与回调都必须停止。
157    loop {
158        if pool.closed.get() {
159            break;
160        }
161        // borrow 不出块,guard 不跨 await。
162        let next = pool.queue.borrow_mut().pop_front();
163        let Some((id, batch)) = next else { break };
164        // 句柄可能已被「×」移除:跳过上传但仍计入批次完成度(否则该批永不合拢,
165        // 其他成功项的 on_uploaded 无法触发)。
166        let file = pool.find_file(id);
167        if let Some(file) = file {
168            if !set_status(items, id, UploadStatus::Uploading) {
169                break;
170            }
171            let alive = match upload_image_file(file).await {
172                Ok(_) => {
173                    batch.any_done.set(true);
174                    set_status(items, id, UploadStatus::Done)
175                }
176                Err(msg) => set_status(items, id, UploadStatus::Failed(msg)),
177            };
178            if !alive || pool.closed.get() {
179                break;
180            }
181        }
182        // 本批最后一条收尾且 ≥1 成功 → 回调一次(父组件刷新网格)。
183        let remaining = batch.remaining.get() - 1;
184        batch.remaining.set(remaining);
185        if remaining == 0 && batch.any_done.get() {
186            on_uploaded.run(());
187        }
188        // 队列未空则停顿压速率;停顿随并发数线性放大(见 fn doc),live 读取
189        // 让面板改动即时生效。clamp(1, 32) 纯防御:服务端已钳到 1–8,
190        // 此处只防异常值导致 500*n 溢出或路径级长停。
191        if !pool.queue.borrow().is_empty() {
192            let Some(n) = concurrency.try_get_untracked() else {
193                break;
194            };
195            crate::utils::time::sleep_ms(500 * n.clamp(1, 32) as u32).await;
196        }
197    }
198    pool.active_workers
199        .set(pool.active_workers.get().saturating_sub(1));
200}
201
202/// 三入口收敛点:校验入队 + 按需补足 worker。仅 WASM 端存在(`web_sys::File` /
203/// `spawn_local` / `upload_image_file` 都是 WASM-only 符号)。
204#[cfg(target_arch = "wasm32")]
205pub(crate) fn enqueue_files(
206    items: RwSignal<Vec<UploadItem>>,
207    pool: std::rc::Rc<UploadPool>,
208    concurrency: RwSignal<i32>,
209    on_uploaded: Callback<()>,
210    new_files: Vec<web_sys::File>,
211) {
212    if pool.closed.get() {
213        return;
214    }
215    // 1) 校验入队:不合格直接记 Failed(不发请求);合格记 Queued 并留存句柄供重试。
216    let mut valid_ids = Vec::new();
217    for file in new_files {
218        let id = pool.next_id.get() + 1;
219        pool.next_id.set(id);
220        let item = UploadItem {
221            id,
222            name: file.name(),
223            size: format_bytes(file.size() as i64),
224            removing: false,
225            status: match validate_file(&file.type_(), file.size() as u64) {
226                Ok(()) => {
227                    pool.files.borrow_mut().push((id, file));
228                    valid_ids.push(id);
229                    UploadStatus::Queued
230                }
231                Err(msg) => UploadStatus::Failed(msg),
232            },
233        };
234        items.update(|list| list.push(item));
235    }
236    if valid_ids.is_empty() {
237        return;
238    }
239
240    // 2) 一批一个 BatchCtx 追踪完成度;id 入共享队列后补足 worker:在跑数低于
241    //    并发上限且队列非空时逐个 spawn(worker 队列空自退,不会超额驻留)。
242    let batch = std::rc::Rc::new(BatchCtx {
243        remaining: std::cell::Cell::new(valid_ids.len()),
244        any_done: std::cell::Cell::new(false),
245    });
246    {
247        let mut q = pool.queue.borrow_mut();
248        for id in valid_ids {
249            q.push_back((id, batch.clone()));
250        }
251    }
252    // clamp 上界与 worker_loop 的停顿同款防御(正常值 1–8)。
253    let target = concurrency.get_untracked().clamp(1, 32) as u32;
254    while pool.active_workers.get() < target && !pool.queue.borrow().is_empty() {
255        pool.active_workers.set(pool.active_workers.get() + 1);
256        leptos::task::spawn_local(worker_loop(items, pool.clone(), concurrency, on_uploaded));
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::{validate_file, ALLOWED_MIME, MAX_UPLOAD_BYTES};
263
264    /// 四种支持的类型在上限边缘全部接受。
265    #[test]
266    fn validate_file_accepts_supported_types_at_limit() {
267        for mime in ALLOWED_MIME {
268            assert!(
269                validate_file(mime, MAX_UPLOAD_BYTES).is_ok(),
270                "{mime} 应被接受"
271            );
272        }
273    }
274
275    /// svg 不在白名单(服务端同样拒绝)。
276    #[test]
277    fn validate_file_rejects_svg() {
278        assert!(validate_file("image/svg+xml", 1024).is_err());
279    }
280
281    /// 空 MIME(浏览器给不出类型)也拒绝。
282    #[test]
283    fn validate_file_rejects_empty_mime() {
284        assert!(validate_file("", 1024).is_err());
285    }
286
287    /// 上限 +1 字节拒绝。
288    #[test]
289    fn validate_file_rejects_oversize() {
290        assert!(validate_file("image/png", MAX_UPLOAD_BYTES + 1).is_err());
291    }
292}