fix: 应用 xhigh 代码评审的跨端修复
ci / server (push) Failing after 13s
ci / design-tokens (push) Failing after 11s

来自 xhigh code review 的正确性/健壮性修复,覆盖全部五端:
- server:鉴权 fail-closed、计量交叉校验与配额扣穿处理、WS 网关并发与关闭顺序、
  billing 行锁、redis Lua 过期与设备槽刷新、config 解析
- desktop:会话 epoch 防串话、WS 重连与 401 处理、api 客户端复用、统一 usePoll 轮询
- android:握手时序、请求头封装、账户状态派生、按需重组
- ios:finalize 宽限、串行采集、错误文案服务端优先、删除死代码 CommitController

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-13 11:50:08 +08:00
parent 50b49f3cbe
commit b5ab92a57e
42 changed files with 2125 additions and 469 deletions
+144 -35
View File
@@ -4,9 +4,19 @@
use parking_lot::Mutex;
use serde_json::{json, Value};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter, Manager};
use tauri::{AppHandle, Emitter, EventTarget, Manager};
use tokio::sync::Notify;
/// 浮层窗口 label(仅 overlay 监听 asr/hotkey 事件,见 tauri.conf.json)。
const OVERLAY: &str = "overlay";
/// 向 overlay 窗口定向 emit18G):避免广播给全部 webview。
fn emit_overlay(app: &AppHandle, event: &str, payload: Value) {
let _ = app.emit_to(EventTarget::webview_window(OVERLAY), event, payload);
}
#[derive(Default)]
pub struct DictationState {
@@ -15,9 +25,9 @@ pub struct DictationState {
struct Session {
id: String,
/// 会话代际(18A/18B):每次 start 自增,on_server_msg / 收尾任务校验后才生效。
epoch: u64,
capture: Option<crate::audio::Capture>,
final_text: String,
partial_text: String,
started: Instant,
got_first_partial: bool,
}
@@ -31,11 +41,16 @@ pub fn start(app: &AppHandle) {
return; // 已暂停使用
}
let session_id = uuid::Uuid::new_v4().to_string();
// 开新会话代际:递增 epoch,重置 buffer / 丢弃标志 / 收尾信号(18A/18B/18C)。
let buf = app.state::<CommitBuffer>();
let epoch = buf.begin_session();
set_tray_tooltip(app, "dudu — 录音中");
let _ = app.emit("hotkey", json!({"state": "down"}));
emit_overlay(app, "hotkey", json!({"state": "down"}));
show_overlay(app);
let ws = app.state::<crate::ws::WsHandle>().tx.clone();
let ws = (*app.state::<crate::ws::WsHandle>()).clone();
let _ = ws.send(crate::ws::WsCmd::Start {
session_id: session_id.clone(),
});
@@ -52,7 +67,7 @@ pub fn start(app: &AppHandle) {
}
Err(e) => {
log::error!("audio start failed: {e}");
let _ = app.emit("asr", json!({"type":"error","code":"AUDIO","message": e}));
emit_overlay(app, "asr", json!({"type":"error","code":"AUDIO","message": e}));
None
}
};
@@ -69,24 +84,35 @@ pub fn start(app: &AppHandle) {
*state.inner.lock() = Some(Session {
id: session_id,
epoch,
capture,
final_text: String::new(),
partial_text: String::new(),
started: Instant::now(),
got_first_partial: false,
});
}
/// 当前是否有进行中的录音会话(18E:ws 重连后据此判断是否需中止会话)。
pub fn is_recording(app: &AppHandle) -> bool {
app.state::<DictationState>().inner.lock().is_some()
}
pub fn stop(app: &AppHandle, canceled: bool) {
let state = app.state::<DictationState>();
let Some(mut sess) = state.inner.lock().take() else {
return;
};
let _ = app.emit("hotkey", json!({"state": "up"}));
let epoch = sess.epoch;
emit_overlay(app, "hotkey", json!({"state": "up"}));
set_tray_tooltip(app, "dudu — 就绪");
sess.capture.take(); // 停止采集
let ws = app.state::<crate::ws::WsHandle>().tx.clone();
let buf = app.state::<CommitBuffer>();
if canceled {
// 取消即丢弃:标记该 epoch,后续晚到 final 直接丢弃不入 buffer18A)。
buf.discard(epoch);
}
let ws = (*app.state::<crate::ws::WsHandle>()).clone();
let cmd = if canceled {
crate::ws::WsCmd::Cancel { session_id: sess.id.clone() }
} else {
@@ -94,23 +120,31 @@ pub fn stop(app: &AppHandle, canceled: bool) {
};
let _ = ws.send(cmd);
// 取尾部 final 完成信号(事件驱动注入,18C)。
let finalized = buf.finalize_signal();
let app = app.clone();
let release_at = Instant::now();
tauri::async_runtime::spawn(async move {
if !canceled {
// 等网关 flush 尾部 final(句间已实时累计,这里只兜尾部)
tokio::time::sleep(Duration::from_millis(350)).await;
let committed = {
let state = app.state::<DictationState>();
let pending = state.inner.lock();
drop(pending);
let buf = app.state::<CommitBuffer>();
buf.take()
// 事件驱动:收到 stop 后的尾部 final(或 usage 结算帧)即触发注入;
// 350ms 仅作超时兜底,避免 flush 慢于固定 sleep 截丢尾 final18C)。
tokio::select! {
_ = finalized.notified() => {}
_ = tokio::time::sleep(Duration::from_millis(350)) => {}
}
let buf = app.state::<CommitBuffer>();
// 会话代际校验:本任务只处理自己的 epoch,快速连说时旧任务醒来不串新会话(18B)。
let Some(committed) = buf.take_for_epoch(epoch) else {
// epoch 不匹配(已被新会话覆盖)或已被丢弃:不注入、不 hide(18A/18B)。
return;
};
if !committed.is_empty() {
if !crate::inject::accessibility_ok() {
// 无辅助功能权限(macOS):注入必失败 → 浮层引导去授权(10E)
let _ = app.emit(
emit_overlay(
&app,
"asr",
json!({"type":"error","code":"NO_ACCESSIBILITY","message":"需要辅助功能权限"}),
);
@@ -130,7 +164,8 @@ pub fn stop(app: &AppHandle, canceled: bool) {
_ => {
log::error!("inject failed: {injected:?}");
if !crate::inject::accessibility_ok() {
let _ = app.emit(
emit_overlay(
&app,
"asr",
json!({"type":"error","code":"NO_ACCESSIBILITY","message":"需要辅助功能权限"}),
);
@@ -141,34 +176,99 @@ pub fn stop(app: &AppHandle, canceled: bool) {
}
}
} else {
app.state::<CommitBuffer>().take();
// 取消:清空 buffer(仅当仍是本 epoch,避免误清新会话),不上屏(18A)。
let buf = app.state::<CommitBuffer>();
let _ = buf.take_for_epoch(epoch);
}
hide_overlay(&app);
});
}
/// CommitBuffer 跨 ws 任务与停止流程共享的"待注入文本"。
#[derive(Default)]
/// CommitBuffer 跨 ws 任务与停止流程共享的"待注入文本"+ 会话代际/丢弃标志/收尾信号
pub struct CommitBuffer {
text: Mutex<(String, String)>, // (final 累计, 最新 partial)
inner: Mutex<BufferInner>,
/// 当前会话代际(18A/18B):start 自增,on_server_msg / 收尾任务据此校验归属。
epoch: AtomicU64,
/// 尾部 final 到达信号,驱动收尾注入(18C)。
finalized: std::sync::Arc<Notify>,
}
struct BufferInner {
text: (String, String), // (final 累计, 最新 partial)
/// text 归属的会话代际。
text_epoch: u64,
/// 被取消的会话代际:该 epoch 的后续 final 直接丢弃(18A)。
discarded_epoch: Option<u64>,
}
impl Default for CommitBuffer {
fn default() -> Self {
Self {
inner: Mutex::new(BufferInner {
text: (String::new(), String::new()),
text_epoch: 0,
discarded_epoch: None,
}),
epoch: AtomicU64::new(0),
finalized: std::sync::Arc::new(Notify::new()),
}
}
}
impl CommitBuffer {
fn take(&self) -> String {
let mut t = self.text.lock();
let committed = format!("{}{}", t.0, t.1);
*t = (String::new(), String::new());
committed
/// 开新会话:epoch+1,清空文本与丢弃标志,返回新 epoch(18A/18B)。
fn begin_session(&self) -> u64 {
let epoch = self.epoch.fetch_add(1, Ordering::SeqCst) + 1;
let mut g = self.inner.lock();
g.text = (String::new(), String::new());
g.text_epoch = epoch;
g.discarded_epoch = None;
epoch
}
fn current_epoch(&self) -> u64 {
self.epoch.load(Ordering::SeqCst)
}
/// 标记某会话被取消,其后续 final 一律丢弃(18A)。
fn discard(&self, epoch: u64) {
self.inner.lock().discarded_epoch = Some(epoch);
}
/// 收尾信号克隆,供 stop 任务 await18C)。
fn finalize_signal(&self) -> std::sync::Arc<Notify> {
self.finalized.clone()
}
/// 取出并清空文本——仅当 buffer 仍属指定 epoch 且未被丢弃时返回(18A/18B)。
/// epoch 不匹配(已被新会话覆盖)返回 None,调用方据此放弃注入/hide。
fn take_for_epoch(&self, epoch: u64) -> Option<String> {
let mut g = self.inner.lock();
if g.text_epoch != epoch {
return None; // 已被新会话覆盖
}
if g.discarded_epoch == Some(epoch) {
g.text = (String::new(), String::new());
return Some(String::new()); // 本会话已取消,返回空(不注入但允许 hide 自身浮层)
}
let committed = format!("{}{}", g.text.0, g.text.1);
g.text = (String::new(), String::new());
Some(committed)
}
}
/// ws 下行回调:维护文本缓冲 + 首字延迟打点(在 ws.rs 收包处调用)。
pub fn on_server_msg(app: &AppHandle, v: &Value) {
let buf = app.state::<CommitBuffer>();
let cur = buf.current_epoch();
match v.get("type").and_then(|t| t.as_str()) {
Some("partial") => {
if let Some(text) = v.get("text").and_then(|t| t.as_str()) {
buf.text.lock().1 = text.to_string();
let mut g = buf.inner.lock();
// 仅当属于当前会话且未被丢弃才写入(18A/18B)。
if g.text_epoch == cur && g.discarded_epoch != Some(cur) {
g.text.1 = text.to_string();
}
}
let state = app.state::<DictationState>();
let mut guard = state.inner.lock();
@@ -185,10 +285,19 @@ pub fn on_server_msg(app: &AppHandle, v: &Value) {
}
Some("final") => {
if let Some(text) = v.get("text").and_then(|t| t.as_str()) {
let mut t = buf.text.lock();
t.0.push_str(text);
t.1.clear();
let mut g = buf.inner.lock();
// 校验会话:不属当前会话或已被取消 → 丢弃,不写 buffer(18A/18B)。
if g.text_epoch == cur && g.discarded_epoch != Some(cur) {
g.text.0.push_str(text);
g.text.1.clear();
}
}
// 尾部 final 到达 → 唤醒收尾注入任务(18C)。
buf.finalized.notify_waiters();
}
// usage 结算帧(参考 iOS):作为收尾兜底信号,立即触发注入(18C)。
Some("usage") => {
buf.finalized.notify_waiters();
}
_ => {}
}
@@ -202,7 +311,7 @@ fn set_tray_tooltip(app: &AppHandle, text: &str) {
}
fn show_overlay(app: &AppHandle) {
if let Some(w) = app.get_webview_window("overlay") {
if let Some(w) = app.get_webview_window(OVERLAY) {
// 跟随光标:浮层出现在指针下方 24px,水平居中
if let Ok(pos) = app.cursor_position() {
let _ = w.set_position(tauri::PhysicalPosition::new(pos.x - 180.0, pos.y + 24.0));
@@ -212,7 +321,7 @@ fn show_overlay(app: &AppHandle) {
}
fn hide_overlay(app: &AppHandle) {
if let Some(w) = app.get_webview_window("overlay") {
if let Some(w) = app.get_webview_window(OVERLAY) {
let _ = w.hide();
}
}