Files
DevFlow/docs/02-架构设计/专项设计/定时消息与待发箱持久化-设计-2026-10-10.md
T
lxy 8df4e33656 文档: 看板销账与登记 + 设计档六份 + 索引补录
- 看板: 907 补注/909~941 登记与销账(含 929 模型停用根因与 4 处次生缺口、931 队列丢失、933/935/937 新需求、939 上下文瓶颈清单、941 C-r0 行为变更待拍板)
- 归档: 全量完成态条目/段落 + 只读核验销账 4 段迁 todo归档/2026-10-10.md(机械搬运零措辞改动,未完成项零丢失)
- 设计档: 输入框列表预览与自动编号 / 定时消息与待发箱持久化(统一 ai_outbox 底座) / 对话内定时延时任务 / 消息渲染管线重构(P0-P2 分期) / 上下文分层组装器落地方案(记账基点单一真值,与并行写者职责划清) / FileTree 大目录方案(推荐后端分页,否决虚拟滚动) / u-relay 运维手册
- 索引与事实修正: docs/INDEX crate 数 8→13 + 四子树补录 25 条 + 专项设计补录 27 条;纠正「项目无独立 ARCHITECTURE.md」等过时声明;统一 bun 口径
- CLAUDE.md: 新增「命名与代码风格」节(变量/方法名精简+准确;DRY·简洁·扩展性·高内聚低耦合·单一真值来源)
2026-10-10 08:09:25 +08:00

23 KiB

定时消息与待发箱持久化 — 设计

日期: 2026-10-10 | 状态: 设计稿(待拍板) | 勘察: 只读实测锚点 覆盖需求: todo 935(定时消息多条 + 统一「定时列表」)与 todo 931(发送队列网络超时/重启不丢)。 关联: todo 937(对话内定时/延时任务)共用本设计调度器,仅预留扩展点,不在本设计实施。 目标(用户原话转译): 消息可设为定时发送、可设多条,有一个统一「定时列表」可查看/改期/取消;发队列遇网络超时、杀进程重启都不能丢。


一、现状勘察(锚点实测)

1.1 发送队列(931 病灶)

件 锚点 现状
队列数据结构 src/stores/ai.ts:104(类型)、:136(queue: [] 初始化) 纯内存模块级数组,字段 { text, skill?, enqueuedAt, parts?, spans?, conversationId? },无任何持久化
入队 src/composables/ai/useAiSend.ts:392-404 sendMessage 查 ai_is_generating 得 backendGenerating → 忙则 state.queue.push(...);上限 QUEUE_LIMIT=10(:53),超时 QUEUE_TIMEOUT_MS=30_000(:56)
续发派发 useAiSend.ts:308-353 drainQueue;监听器 :607-612 initDrainQueueListener 前端自驱:AiCompleted → useAiEvents 发 ai-drain-queue → drainQueue 取出队首 sendMessage
显式清队 useAiSend.ts:193-196(regenerate)、:269-272(editMessage)、:488-495 clearQueue 重生成/编辑属用户显式动作,清本会话队列合理;notifyQueueCleared(aiShared.ts:772)toast 提示
队列 UI src/components/AiChat.vue:78-113;activeQueue 计算 :489 按 activeConversationId 过滤渲染当前会话队列,含 强制发送/清空/编辑/回填/立即发送/取消

病灶(与 todo 931 勘察一致):①队列内存态,杀进程即丢;②后端 busy 判定 + 后续 AiCompleted 续发全依赖前端活着并收到事件——网络超时/事件竞态丢失则队列永久卡住或静默丢;③state.queue 无启动恢复入口。修法须从「内存 + 前端自驱」下沉到「持久化 + 后端自驱」。

1.2 后端发送受理链(定时复用的正典入口)

件 锚点 语义
ai_chat_send IPC src-tauri/src/commands/ai/commands/chat.rs:636 拦截 /status → 解析前导技能 → 委派 spawn_agentic_session
spawn_agentic_session chat.rs:796(pub(crate)) 发送正典核心:解析 provider → 目标 conv 入参优先/缺省 active/都无则懒建 → can_accept_request 门禁(仅 Idle|Error 可接,拒 Generating/Stopping/Compressed)→ 写 stop_flag/language/iteration/model_override → spawn run_agentic_loop。pin_active 决定是否劫持用户活跃面板
generating 判定 chat.rs:471 ai_is_generating 读无锁 state.conv_states.is_active(conv)
完成出口(单点) src-tauri/src/commands/ai/agentic/mod.rs:2882 emit_ai_completed_once → 调 unattended::notify_batch_done(conv_id)(:2917) 全部正常收尾汇聚于此单点,是「会话转空闲」的唯一可靠信号

结论:spawn_agentic_session 是后端可无前端直接调用的发送入口(无人值守引擎已这么用),定时到点可直接走它,无需前端在场。

1.3 后端常驻调度器先例(可直接抄形态)

src-tauri/src/commands/ai/unattended.rs:

  • start(app)(:505 起)用 tauri::async_runtime::spawn + tokio::time::interval + tokio::select!{ interval.tick(), notify.notified() } 起常驻循环;周期/开关/范围全部从 Settings KV 每轮热读(df-unattended-poll-secs 等)。
  • 派发走 spawn_agentic_session(..., Some(new_id()), ..., false)(:847-857):独立后台会话 + pin_active=false,不劫持用户活跃面板。
  • notify_batch_done(:483)在完成出口清在途标志并 notify_one 唤醒轮询器。
  • 单会话单在途双保险:内部 in_flight 标志 + can_accept_request 兜底清残留(异常退出漏钩子时不永久卡死)。
  • 挂载点 src-tauri/src/lib.rs:84 commands::ai::unattended::start(app.handle().clone())。
  • 通知出口:托盘 notify_session_event(commands/ai/notify.rs:47,NotifyKind::Completed/Failed/NeedAction);飞书 feishu_notify::push_card/push_monitor_alert,目标 KV df-monitor-chat-id(unattended.rs:52)。

1.4 生命周期(「应用关闭后是否生效」的事实)

  • lib.rs:51 .setup():开库+迁移(AppState::init)→ app.manage → 各 spawn(feishu、unattended::start、mcp、proxy gateway、tray)。
  • lib.rs:614-658 on_window_event:CloseRequested → api.prevent_close() + window.hide() 托盘化,进程不退出;真正退出只走托盘菜单「退出」(或系统关机)。
  • 无 RunEvent::Exit 级关闭钩子(默认 .run())。

关键事实:窗口关闭 → 托盘常驻 → 进程仍在,后端 tokio 调度器照常跑;只有进程真退出(托盘退出/关机)调度器才停。故「应用关闭后是否生效」的精确答案是:关窗(托盘化)期间生效;进程退出期间不生效,重启后按追赶策略补发——这是必须写进验收口径的边界。

1.5 存储与迁移底座

  • 迁移版本链 crates/df-storage/src/migrations.rs,当前 V50;migrations_helpers::{column_exists, table_exists} 提供幂等探测。
  • Repo 两种范式:impl_repo! 宏(crates/df-storage/src/crud/mod.rs,生成 insert/get_by_id/list_all/update_full,适合固定 CRUD);SettingsRepo(crud/settings.rs)手写 KV(app_settings 表,key/value JSON),适合标量偏好。
  • 会话记录 AiConversationRecord(crates/df-storage/src/models.rs:372)已有 loop_state(V49)/status(V50)等生命周期列先例。
  • 前端会话 CRUD:ai_conversation_create(src/api/ai.ts:318)、ai_conversation_list(:323)——定时目标会话可在 composer 处先建。

二、目标与验收口径

功能目标

  1. Composer 旁「定时」入口:把当前输入(文本/技能/图片)设定为某绝对时间发送;可连续设多条。
  2. 统一「定时列表」:跨会话查看全部定时项,支持 查看/改期/取消/编辑文本;到点自动发送。
  3. 待发队列持久化:入队/出队/取消即落库;网络超时不清队列(仅标记该条可重试);杀进程重启后队列仍在且可续发。
  4. 定时到点时会话忙 → 顺延到会话空闲再发(不丢、不打断当前生成)。
  5. 失败可见:连续失败到上限 → 标失败 + 托盘通知(可选飞书),用户可在列表重试/取消。

验收口径

  • 断网/超时:构造后端生成中入队 → 断网触发错误 → 队列项仍在(标记可重试),恢复网络后续发。
  • 重启:入队 N 条 → 杀进程 → 重启 → 队列恢复 N 条且自动续发;sending 残留被复位为 pending。
  • 定时:设 1 分钟后发送 → 关窗托盘化 → 到点仍在后台发出(session 落库可见);设 1 分钟后发送 → 立刻进程退出 → 重启 → 按追赶策略补发或标记过期。
  • 多会话:定时项按目标会话独立排队,不误发到当前活跃会话;到点会话忙则顺延。
  • 不倒退:无定时/无队列时,普通发送链路(L0/L1/L2)行为与改造前逐字节一致。

三、设计方案

3.0 核心判断:统一为后端「待发箱(outbox)」

931 队列与 935 定时消息本质是同一实体、不同触发条件:队列 = 「会话空闲即发」,定时 = 「到点且空闲即发」。因此不建两套调度,统一落一张表 + 一个后端调度器:

  • 触发条件统一为 due_at:队列项 due_at = NULL(尽快);定时项 due_at = epoch_ms。调度器每次取「due_at 已到 且 目标会话空闲」的项派发。
  • 单一真相源:表是真相,前端 state.queue 退化为该会话 outbox 的只读视图,前后端各一套派发逻辑的「草率聚合」不再存在。
  • 调度落点 = 后端 tokio 常驻(抄 unattended 形态),复用 spawn_agentic_session 正典链 + emit_ai_completed_once 空闲钩子。

3.1 数据结构(新表 ai_outbox,迁移 V51)

选独立表而非 KV:app_settings 是标量偏好,读改写整块 JSON 无索引、并发改期/取消会互相覆盖;outbox 需按 status+due_at 索引扫描、按 id 逐条 CAS 更新,表是对的解。

CREATE TABLE IF NOT EXISTS ai_outbox (
    id              TEXT PRIMARY KEY,
    conversation_id TEXT NOT NULL,                    -- 目标会话(composer 处确定;无则前端先建)
    kind            TEXT NOT NULL DEFAULT 'queue',    -- 'queue'(尽快) | 'scheduled'(定时);预留 'task'(937)
    text            TEXT NOT NULL,
    skill           TEXT,                             -- /技能名
    model_override  TEXT,                             -- 用户指定模型(null=路由)
    parts_json      TEXT,                             -- ContentPart[] JSON(图片;空/NULL=纯文本)
    spans_json      TEXT,                             -- MentionSpan[] JSON
    due_at          INTEGER,                          -- epoch ms;NULL=尽快(queue)
    status          TEXT NOT NULL DEFAULT 'pending',  -- pending|sending|sent|failed|canceled
    attempts        INTEGER NOT NULL DEFAULT 0,
    last_error      TEXT,
    created_at      INTEGER NOT NULL,
    updated_at      INTEGER NOT NULL,
    sent_at         INTEGER
);
CREATE INDEX IF NOT EXISTS idx_ai_outbox_due  ON ai_outbox(status, due_at);
CREATE INDEX IF NOT EXISTS idx_ai_outbox_conv ON ai_outbox(conversation_id, status);

迁移:V51 = 建表 + 两索引(table_exists 幂等守卫)。可逆:DOWN 等价 = DROP TABLE ai_outbox(纯新增表,不影响旧数据;回退即弃表 + 关调度器开关)。务必按项逐条落状态标记,禁依赖全局内存推算(V21 教训:docs/todo.md:860 全局 COUNT 幂等窗口中途崩溃致永久残缺)。

Rust 记录(加 crates/df-storage/src/models.rs):

pub struct AiOutboxRecord {
    pub id: String,
    pub conversation_id: String,
    pub kind: String,
    pub text: String,
    pub skill: Option<String>,
    pub model_override: Option<String>,
    pub parts_json: Option<String>,
    pub spans_json: Option<String>,
    pub due_at: Option<i64>,
    pub status: String,
    pub attempts: i64,
    pub last_error: Option<String>,
    pub created_at: i64,
    pub updated_at: i64,
    pub sent_at: Option<i64>,
}

3.2 存储层 Repo(手写,不走 impl_repo!)

crates/df-storage/src/crud/outbox_repo.rs(新)。因需状态 CAS 与条件更新,手写(同 SettingsRepo 范式):

  • insert(&record) -> id
  • list_by_conv(conv_id, include_done: bool) -> Vec<AiOutboxRecord>
  • list_due(now_ms, limit) -> Vec<AiOutboxRecord>(status='pending' AND (due_at IS NULL OR due_at<=now) ORDER BY created_at)
  • claim(id) -> bool:CAS UPDATE ... SET status='sending', updated_at=? WHERE id=? AND status='pending'(changes==1 才 true,防并发双取)
  • mark_sent(id) / mark_failed(id, err) / revert_pending(id, attempts, err)
  • reschedule(id, due_at) / cancel(id) / update_text(id, text) / clear(conv_id?)
  • reset_stale_sending():启动把残留 sending 复位为 pending(崩溃半途,镜像 unattended 的 in_flight 泄漏处置)

AppState 增 pub outbox: OutboxRepo(state.rs,AppState::init 内 OutboxRepo::new(&db)),与 settings/conv_states 并列。

3.3 后端调度器 + IPC(新文件 src-tauri/src/commands/ai/outbox.rs)

挂载:lib.rs setup 增 commands::ai::outbox::start(app.handle().clone())(与 unattended::start 并列);启动即先 outbox.reset_stale_sending()。

循环(抄 unattended 形态):

start(app): spawn loop {
    select!{ interval.tick(), notify.notified() }      // 周期兜底 + 事件即时唤醒
    if !enabled(KV) continue
    for item in list_due(now, 32) {                     // 按 created_at 排序
        if dispatched_this_tick(conv) continue          // 同会话同轮只派一条(串行保序)
        if !conv_states.can_accept_request(conv) continue  // 忙 → 本轮跳过,下次再取(不丢)
        if !claim(item.id) continue                     // CAS 失败(他轮已取)
        match spawn_agentic_session(app, state, item.text, None, item.skill,
                                    item.model_override, Some(conv), parts, spans, false) {
            Ok(_)  => { mark_sent(item.id); emit("ai-outbox-sent", {id, conv}); mark_dispatched(conv) }
            Err(e) => {                                     // 仅 spawn 前置失败(provider未配/会话忙竞态)
                if permanent(e) { mark_failed(item.id, e); notify(...) }
                else { revert_pending(item.id); emit("ai-outbox-changed"); }
            }
        }
    }
}

关键语义

  • pin_active=false:定时/队列的自动发送不劫持用户活跃面板(同无人值守);用户切到该会话仍能看到实时流(AiTextDelta 已按 conv 路由)。
  • 「完成即唤醒」钩子:agentic/mod.rs:2917 现有 unattended::notify_batch_done(conv_id) 旁并列加 outbox::notify_conv_idle(conv_id)——会话一空闲就唤醒调度器续发,替代前端 ai-drain-queue 自驱。
  • 失败面界定:spawn_agentic_session 返回 Ok 即「受理成功」,outbox 立即标 sent;LLM 轮次内的失败(流断/工具错)由既有 loop 自愈+错误气泡+看门狗处置,不回流 outbox(避免与既有韧性机制重叠)。outbox 的 failed 只覆盖受理失败(provider 未配、永久 4xx 特征串如 404/额度耗尽——复用 classifySendError 同源判据的 Rust 版)。
  • 重试:revert_pending 保留下轮再试(忙场景天然重试);attempts 达 df-outbox-max-attempts(默认 3)记 failed + 通知。
  • 过期追赶:调度器只认「due_at <= now」,故进程退出期间到期的项在重启后立即命中(补发)。是否对「迟到过久」的项改标过期见拍板点 4。

IPC 面(注册进 lib.rs invoke_handler):

  • ai_outbox_enqueue(conversation_id, text, skill, model_override, parts, spans, due_at?) -> id(due_at 空=队列项)
  • ai_outbox_list(conversation_id?, include_done?) -> Vec<AiOutboxItem>
  • ai_outbox_reschedule(id, due_at) / ai_outbox_cancel(id) / ai_outbox_update_text(id, text)
  • ai_outbox_send_now(id)(插队=force;后端 CAS claim 后立即派发)
  • ai_outbox_clear(conversation_id?)

事件面:ai-outbox-changed(任何变更后前端刷新视图)、ai-outbox-sent { id, conversation_id }、ai-outbox-failed { id, conversation_id, error, permanent }。

通知出口:失败经 notify_session_event(app, NotifyKind::Failed/NeedAction, conv, ...) 托盘补位;需要飞书时复用 feishu_notify::push_card + KV df-monitor-chat-id(与 unattended 同款,零新通道)。

KV 配置(app_settings,热读):df-outbox-enabled(默认 true,回退开关)、df-outbox-poll-secs(默认 10,下限钳 5)、df-outbox-max-attempts(默认 3)、df-outbox-notify-feishu(默认 false)。调度器另用 Notify 由 enqueue/完成钩子即时唤醒,周期 tick 仅兜底 due_at 到点。

3.4 前端改造(队列视图化 + 定时入口)

src/composables/ai/useAiSend.ts

  • sendMessage L1 分支(:392-404):state.queue.push(...) → await aiApi.outboxEnqueue(...),随后刷新本会话视图。
  • 删前端派发:drainQueue(:308)、initDrainQueueListener(:607)、ai-drain-queue 监听改由 ai-outbox-changed/ai-outbox-sent 驱动的视图刷新;useAiEvents 的 AiCompleted 不再发 ai-drain-queue。
  • 保留 cancelQueued/clearQueue/editQueued/sendQueuedNow/tryForceSend,后端代理为对应 IPC。
  • regenerate/editMessage 的「清本会话队列」改为 ai_outbox_clear(convId)(语义不变:用户显式动作允许清)。

新增 src/composables/ai/useAiOutbox.ts:state.queue 保持为「本会话待发视图」数组(shape 兼容 AiChat.vue),由 ai_outbox_list 拉取 + 事件增量刷新;提供 scheduleMessage(convId, text, ..., dueAt)、listAll()、reschedule/cancel 等。

src/stores/ai.ts:queue 字段类型不变(视图契约零破裂),来源改为 composable 写入。

Composer 入口 src/components/ai/ChatInput.vue:发送按钮区(:107-108)旁加「定时」图标按钮 → 弹轻量时间选择(预设 5/30/60 分钟 + 自选时间)→ 调 scheduleMessage。空文本仅选时间时提示需输入内容。

列表 UI src/components/AiChat.vue:

  • 现有内联队列条(:78-113)保留渲染本会话待发(含定时项可加 🕐 时间 角标 + 「改期」按钮)。
  • 新增**「定时列表」抽屉**:跨会话展示全部 scheduled(+可选 queue)项,列 时间/会话/摘要/状态,操作 查看/改期/取消/编辑。可复用 TaskDrawer 抽屉范式(temp/S4-TaskDrawer跳转勘察-2026-10-03.md 已有跳转勘察)。

启动恢复:useAiOutbox 初始化时拉 ai_outbox_list;若存在恢复项,ai-toast 提示「已恢复 N 条待发」(参考 notifyQueueCleared 文案范式)。

3.5 UI 形态示意(文字稿)

┌ ChatInput ─────────────────────────────┐
│ [图片预览行]                            │
│  ┌──────────────────────────┐  ⏰  ➤  │   ⏰=定时;➤=立即
│  └──────────────────────────┘          │
└────────────────────────────────────────┘
   点 ⏰ → 小面板: [5分钟] [30分钟] [1小时] [自选时间 ▾]  (确认后入 outbox kind=scheduled)

┌ 待发/定时列表(抽屉) ───────────────────┐
│ 定时 (2)                                │
│  🕐 14:30  会话A   「跑一下构建…」 改期 ✕ │
│  🕐 明天9:00 会话B 「/status」     改期 ✕ │
│ 待发 (1)                                │
│  ⏳ 会话A   「继续」              立即 ✕ │
└─────────────────────────────────────────┘

四、与 todo 931 的共用关系(同源方案)

  • 同一持久层:931 队列项与 935 定时项同表 ai_outbox,仅 kind/due_at 区分。931 不再单独造 KV/表。
  • 同一调度器:队列=「due_at 为空、空闲即发」,定时=「due_at 到时、空闲即发」,共用 outbox.rs 派发循环与 spawn_agentic_session。
  • 共用修复点:网络超时/重启不丢(§3.3 reconcile + §3.4 视图化)同时治 931;「错误不清队列」由「outbox 项仅 sending→pending 回退,不删」天然满足。
  • 分期上允许 P0 先落 scheduled(935),queue(931 迁移)随后并入同表——同表即刻共用,队列侧前端派发下线放在 P1,降低单批爆炸半径。

五、分期与最小可用

P0 — 后端待发箱 + 定时(最小可用,独立可验收)

  • 新增:表 V51 + models::AiOutboxRecord + crud/outbox_repo.rs;commands/ai/outbox.rs(调度器+IPC+事件);lib.rs 挂载 outbox::start + agentic/mod.rs:2917 加 outbox::notify_conv_idle 钩子;state.rs 加 outbox。
  • 前端:useAiOutbox.ts + ChatInput ⏰ 入口 + 定时列表抽屉 + api/ai.ts 包装 + i18n。
  • 验收:§二「定时」两条 + 「多会话」+「不倒退」。此期只服务 kind='scheduled';queue 仍走旧内存路径(未有回归)。

P1 — 队列并入 outbox(治 931)

  • 前端 sendMessage L1 改走 outbox;下线 drainQueue/initDrainQueueListener/ai-drain-queue;regenerate/edit/clear 接 IPC 清队。
  • 验收:§二「断网/超时」「重启」两条 + 既有队列 UI 交互(编辑/回填/立即发送/取消/超时强发)零弱化。

P2 — 进阶

  • 飞书通知(df-outbox-notify-feishu);过期窗口策略(拍板点 4);kind='task' 扩展位预留供 937(定时任务/工作流经同一调度器,派发目标改 task/workflow 而非 spawn_agentic_session)。

六、影响面(新能力引入评估纪律五维,docs/todo.md:446)

  • 兼容性:纯新增表 + 新增 IPC/事件,旧库由 V51 幂等建表向上迁移,老数据零触碰;前端 state.queue shape 不变(视图契约)。迁移可断点续跑(逐条状态标记,无全局 COUNT 推算)、可逆(DROP TABLE+关开关)。旧 ai-drain-queue 事件在 P1 下线时保留一个版本的空发兜底,防旧前端混跑。
  • 破坏性:不可逆操作仅「迁移 V51 建表」(可 DROP 回退);P1 下线上前端派发属行为变更,以开关 df-outbox-enabled=false 可回退旧路径(过渡期保留旧内存队列代码)。
  • 安全面:无新端点/命令执行面。outbox 项若触发工具调用,仍走既有 audit/approval/边界模型闸门,定时不等于放行(与无人值守 L1 同级,不自动升档)。落盘内容(文本/图片 base64)属本地库既有信任域(与 ai_messages 同级)。改期/取消为本地 IPC。
  • 资源面:DB 增长——sent/canceled 行建议保留(胜于删除,便于审计)但须有上限:图片 base64 项加体积上限(沿用前端 PARTS_SIZE_BUDGET 50MB 量级口径,超限入队即拒并提示);可加「已完成项保留 N 天」的清理点(默认可不做,登记为观测)。调度器 tick 默认 10s,配合 Notify 即时唤醒,常驻开销可忽略(低于 unattended 300s 轮询)。
  • 回退开关:df-outbox-enabled(默认 true,关=调度停=项只入不发,可观测);df-outbox-poll-secs/df-outbox-max-attempts 热读。灰度顺序:P0 scheduled 先在本机自用验证 → P1 队列迁移 → P2 通知/937。观测指标:outbox 积压条数、sending 残留数、失败率、due→sent 延迟。

七、拍板点清单

# 决策 选项 建议
1 调度落点与「进程退出」语义 A 后端 tokio 常驻(关窗托盘化期间生效;进程退出期间不生效,重启追赶补发);B 前端定时器(仅界面活着时生效);C 注册 OS 级计划任务(进程死也触发) A。与无人值守同形态、复用 spawn_agentic_session;明确「进程退出=不触发,重启补发」为本设计边界,C 作远期非目标(跨平台成本高,偏移本设计)
2 存储形态 A 新表 ai_outbox;B 复用 app_settings KV A。需按 status+due_at 索引扫描 + 逐条 CAS 改期/取消,KV 整块 JSON 读改写有并发覆盖风险且不可索引
3 队列与定时是否统一实体 A 统一并入 outbox(kind/due_at 区分),前端队列视图化; B 队列保前端内存 + 独立后端定时器 A。931 根因就是内存+前端自驱,B 会留双调度(草率聚合);A 一次治两需求
4 进程退出期间到期项的追赶策略 A 全部到点即补发(迟到多久都补);B 迟到超阈值(如 >12h)标「已过期」+通知不补发;A+B 折衷 B(阈值建议 12h,值可调)。补发全部易在长关机后制造批量突发发送;过期标记保证「不静默丢」(用户可见可手动重发),符合不静默丢失纪律
5 定时消息是否允许携带图片 A 允许(限体积);B 定时项禁图片仅文本 A(B 作兜底)。图片 base64 落库有膨胀,但用户「设好图片定时发」是合理诉求;用体积上限而非一刀切禁用