Skip to content

Backend Guidelines

51hhh edited this page Sep 16, 2026 · 1 revision

后端编码规范

Rust 2021 + Tauri v2。关键 crate:rusqlite(bundled)、arboard、sha2、serde、thiserror、anyhow。

  • 入口:main.rs → lib.rs::run() → Tauri Builder
  • 状态:AppState 经 tauri::Builder::manage() 注入,命令中用 State<AppState> 取
  • 注释中文,格式化 cargo fmt + cargo clippy --all-targets -- -D warnings

目录与模块组织

src-tauri/src/
├── main.rs        仅调用 lib::run()
├── lib.rs         接线层:插件、manage()、invoke_handler()
├── models.rs      共享类型枢纽
├── commands.rs    AppState 与稳定 re-export
├── commands/      按功能拆分的 #[tauri::command]
└── <domain>.rs 或 <domain>/
  • 一个模块负责一件事。领域有独立生命周期/状态边界时才用子目录(capture、pin、paste、translation、commands)。
  • models.rs 是共享类型枢纽,跨模块的数据结构都在这里,统一 #[derive(Serialize, Deserialize, Clone, Debug)]。
  • commands 是 IPC 边界,命令只做转发,真正逻辑在领域模块。
  • lib.rs 只接线,不放业务逻辑。

命名:文件 snake_case.rs,类型 PascalCase,函数 snake_case,常量 UPPER_SNAKE_CASE。Tauri 命令名用 snake_case(JS 侧自动转 camelCase)。

错误处理

领域错误用 thiserror,内部传播用 ?,在 IPC 边界转成 String:

#[derive(Error, Debug)]
pub enum StorageError {
    #[error("数据库操作失败: {0}")]
    Database(#[from] rusqlite::Error),
    #[error("内容哈希冲突: {0}")]
    DuplicateContent(String),
}

每个领域错误带稳定的 code();顶层 error.rs 聚合,提供 domain()/code()/identifier(),日志标识形如 paste.portal_start_rejected。

区分两个级别,避免把预期路径记成故障:error::report(warn)是真实故障,error::note(info)是预期路径。

后台线程不能 panic

监听线程遇到瞬时错误应记日志并继续:

loop {
    match clipboard.get_text() {
        Ok(text) => { /* process */ }
        Err(e) => log::warn!("剪贴板读取失败: {}", e),
    }
    std::thread::sleep(poll_interval);
}

禁止

  • I/O 或用户输入上 unwrap() —— 用 ? 或显式处理
  • unwrap() 仅允许用于 Mutex::lock()(poisoned 即不可恢复)和编译期保证
  • 向前端暴露内部错误细节
  • 静默吞掉错误(至少要记日志)

数据库

SQLite + FTS5,无 ORM,裸 SQL 配参数绑定。schema 与索引见剪贴板管线。

规则 说明
始终用 ? 占位符 绝不把用户输入拼进 SQL
高频查询用 prepare_cached 利用 rusqlite 内建 LRU,避免重复编译
去重用 UPSERT ON CONFLICT(content_hash) DO UPDATE,不用先查后改
插入和删除都要同步 FTS 删除用 FTS5 'delete' 命令
多步操作用事务 插入 + FTS 同步 + 清理必须原子
批量删除用有界 IN 子句 最多 500 个 id 一块,所有块共用同一外层事务

PRAGMA:journal_mode = WAL、cache_size = 128、temp_store = MEMORY。

存储模式:默认持久化到应用数据目录;:memory: 用于测试。

本地文件权限

数据库含用户敏感内容。Unix 下 clips.db 及其 -wal/-shm 必须 0600;新库必须在交给 SQLite 打开前就以私有权限创建,启动时同时修复已有库的宽松权限。配置与 token 走同一私有文件模块,目录 0700、文件 0600。权限修复失败必须返回错误或记日志,不能以宽松权限静默运行。

外部工具集成

优先 CLI 子进程,不用 -sys crate 动态链接。

理由:跨发行版 SONAME 不兼容(例如 libtesseract.so.4 vs .so.5)会让分发的 deb/AppImage 直接起不来。唯一例外是有 bundled feature 的 crate(把 C 库静态编进二进制)。

模式:

// 运行时检测
pub fn is_available() -> bool { /* tool --version */ }

// 调用,缺失时给友好错误
.map_err(|e| if e.kind() == ErrorKind::NotFound {
    "工具不可用:请安装 xxx".to_string()
} else { format!("启动工具失败: {}", e) })?

约定:

  • --version 是真实子进程,同一用户操作里不得由能力检查和执行路径各探测一次;成功与失败都要进程内缓存
  • 应用内安装成功、缓存路径启动时报 NotFound 等状态变化必须使缓存失效
  • CPU/内存重任务必须有全局并发上限;同一不可变输入的并发请求用 single-flight 合并
  • 不得持有同步 Mutex 跨越 .await 或子进程执行,锁只保护短暂的缓存/在途表变更
  • 可选工具不放进包依赖,由应用内提示引导安装
  • Release 阶段用 readelf -d 审计动态依赖

日志

log crate facade,消息用中文。

级别 用途
error! 影响用户功能的不可恢复失败
warn! 可恢复问题、瞬时失败
info! 关键生命周期事件
debug! 详细操作信息
trace! 轮询循环等超高频

不许记录:剪贴板内容(可能含密码、密钥、PII)、完整图片数据(只记大小/哈希)、info 级别的内容哈希(短字符串的哈希可被反查,降到 debug)。

log::debug!("检测到新剪贴板内容,类型: {}, 大小: {} 字节", content_type, byte_size);

质量

cd src-tauri && cargo fmt
cd src-tauri && cargo clippy --all-targets -- -D warnings
cd src-tauri && cargo test

禁止模式

模式 原因 替代
I/O 上 unwrap() panic 崩溃 ? / match
SQL 字符串插值 注入 params![]
println!() 打日志 release 抓不到 log:: 宏
引用够用时 clone() 无谓分配 传 &
一律 pub 破坏封装 默认私有
unsafe 本项目不需要 找安全替代

测试要求

测试与实现同文件,用 #[cfg(test)] mod tests。存储层用 :memory: 覆盖 CRUD、FTS、去重、容量清理;配置层覆盖默认值、加载保存、非法输入。

持久性回归必须真正关闭并重开磁盘数据库——把同一个内存连接重新初始化不算重开。

运行时不变量清单

这些是从真实缺陷里总结的硬约束,改动相关代码前先对一遍。

监听与写入

  • 区分「已经观察到」与「成功写入」。编码或数据库暂时失败用有上限的退避,不能在成功前更新已完成标记,否则同一内容永远漏收。
  • 应用主动复制的抑制身份要持续到对应内容被处理完;读取慢图片在写入锁外执行,前后核对写入代次,避免旧快照消耗新写入的抑制记录。
  • 只有可靠的系统变更通知才能跳过整图读取。信号不可用时回退轮询;同一 owner 再次复制也是变化,不能只比较窗口 owner。无新事件时仍须执行已安排的失败重试。
  • X11 写入的完成屏障必须在实际写连接上;其它连接的 roundtrip 只能排空自身事件。用可控服务端阻塞复现,不能用固定 sleep 代替同步。

配置与设置

  • 配置持久化必须返回错误,写入失败不能显示 Saved。快捷键绑定失败要恢复旧配置和绑定;回滚本身失败必须明确反馈。
  • Portal 仅接纳异步请求时显示 pending,不能表述为「系统已启用」。
  • 正在录制与仍待恢复是两个状态,必须分开。失败保留恢复意图;显式 Save 要等恢复完成并校验后端已暂停。同一恢复协议供取消与原生关闭共用,并合并并发关闭。原生窗口关闭不依赖 JS 初始化成功。
  • 开发版不能仅凭自启动项存在就禁用它,必须证明启动命令属于当前开发程序;无法证明时保留。

截图与输出

  • 先渲染再执行复制/保存/贴图。失败保留可恢复编辑或同一产物;可能已发生的外部副作用不能盲目重试。
  • 取消、窗口销毁与异步输出共用精确身份,资源只清理一次。
  • 重试报忙或 IPC 结果不确定时保留恢复状态,允许的动作只能收紧。只有确定的初始渲染失败才能恢复编辑。
  • GTK 对象必须在 GTK 主线程操作。异步截图路径里不得创建 GTK 样式对象或遍历修改 widget 树——发生在首屏全屏化之前的 panic 会同时造成定位偏移和后续屏幕未创建,而单屏无头环境验收不到。

进程级任务与权限

  • 更新安装任务归应用进程持有,多个 WebView 只提交操作并读同一权威快照。开始安装在同一锁内 claim,窗口关闭不能销毁或重复创建任务。
  • 状态成功返回与 catch 都要校验发起时的 revision;迟到的查询失败不能把已完成状态降回未知。
  • 快捷键保存先按原生规范化身份检查内部重复,验证失败不得持久化。
  • 权限解析先全局查 denied 再按窗口匹配 allowed,因此不能用窗口限定的 deny 来隔离某个窗口;应改用独立的 scoped allow 只授予需要的窗口。

主事件线程

  • 主事件线程不能等待包含原生窗口 getter 的 worker 锁:getter 会派发消息并等待事件线程,形成互等。生命周期用非阻塞状态,业务放 worker 执行。
  • 窗口控制命令核验调用者 label 与有效句柄,只操作自身窗口;原生 getter 前后检查生命周期,不能持有会话锁等待 UI。

相关页面

Clone this wiki locally