From b5ab92a57e9e3be3353ebf7efdd891e1fa05b555 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sat, 13 Jun 2026 11:50:08 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=BA=94=E7=94=A8=20xhigh=20=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E8=AF=84=E5=AE=A1=E7=9A=84=E8=B7=A8=E7=AB=AF=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 来自 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 --- .../src/main/java/app/dudu/app/BuyScreen.kt | 1 - .../app/src/main/java/app/dudu/app/DuduApp.kt | 5 +- .../src/main/java/app/dudu/app/LoginScreen.kt | 1 - .../src/main/java/app/dudu/app/MineScreen.kt | 1 - .../java/app/dudu/data/AccountRepository.kt | 81 +-- .../java/app/dudu/ime/KeyboardController.kt | 25 +- .../java/app/dudu/ime/ui/KeyboardScreen.kt | 58 +- .../app/src/main/java/app/dudu/net/DuduApi.kt | 31 +- desktop/src-tauri/src/api.rs | 84 ++- desktop/src-tauri/src/dictation.rs | 179 ++++-- desktop/src-tauri/src/metrics.rs | 12 +- desktop/src-tauri/src/ws.rs | 135 ++++- desktop/src/login/main.jsx | 98 ++-- desktop/src/onboarding/main.jsx | 25 +- desktop/src/overlay/main.jsx | 13 +- desktop/src/settings/SettingsApp.jsx | 13 +- desktop/src/shared/usePoll.js | 44 ++ desktop/src/tray/main.jsx | 3 + ios/dudu/App/DictationController.swift | 139 +++-- ios/dudu/Dudu.xcodeproj/project.pbxproj | 4 - ios/dudu/KeyboardExt/CommitController.swift | 68 --- .../KeyboardExt/KeyboardViewController.swift | 3 +- ios/dudu/Shared/AccountStore.swift | 7 +- ios/dudu/Shared/DuduProtocol.swift | 6 +- ios/dudu/Shared/MetricsQueue.swift | 40 +- ios/dudu/Shared/PendingTextStore.swift | 27 +- server/internal/auth/handlers.go | 10 +- server/internal/auth/jwt.go | 49 +- server/internal/auth/jwt_test.go | 31 +- server/internal/billing/billing.go | 4 +- server/internal/config/config.go | 25 + server/internal/gateway/gateway.go | 144 ++++- server/internal/gateway/gateway_test.go | 59 +- server/internal/httpapi/router.go | 65 ++- server/internal/quota/quota.go | 6 + server/internal/store/redis.go | 35 +- server/internal/store/redis_test.go | 45 +- server/internal/user/handlers.go | 21 +- server/pkg/protocol/ws.go | 10 +- server/run-dev.sh | 2 +- todo/todo.html | 533 +++++++++++++++++- todo/todo.json | 452 ++++++++++++++- 42 files changed, 2125 insertions(+), 469 deletions(-) create mode 100644 desktop/src/shared/usePoll.js delete mode 100644 ios/dudu/KeyboardExt/CommitController.swift diff --git a/android/app/src/main/java/app/dudu/app/BuyScreen.kt b/android/app/src/main/java/app/dudu/app/BuyScreen.kt index d4701e6..e275724 100644 --- a/android/app/src/main/java/app/dudu/app/BuyScreen.kt +++ b/android/app/src/main/java/app/dudu/app/BuyScreen.kt @@ -132,7 +132,6 @@ fun BuyScreen(refreshKey: Int, onBack: () -> Unit) { balanceSeconds = me.balanceSeconds, trialDailyLimit = me.trialDailyLimit, trialUsedToday = me.trialUsedToday, - accountState = me.accountState, ) } metrics.track("purchase.success", mapOf("pack_id" to pack.id)) diff --git a/android/app/src/main/java/app/dudu/app/DuduApp.kt b/android/app/src/main/java/app/dudu/app/DuduApp.kt index 3f8836a..515ce40 100644 --- a/android/app/src/main/java/app/dudu/app/DuduApp.kt +++ b/android/app/src/main/java/app/dudu/app/DuduApp.kt @@ -1,15 +1,18 @@ package app.dudu.app import android.app.Application +import app.dudu.data.AccountRepository import app.dudu.data.MetricsFlushWorker +import app.dudu.net.DuduApi /** * 应用入口:主 App 与 IME Service 同进程,这里做一次性初始化。 - * 目前只负责注册埋点周期上报(WorkManager)。 + * 注册埋点周期上报(WorkManager),并把设备 ID 注入 DuduApi(REST 请求带 X-Device-ID)。 */ class DuduApp : Application() { override fun onCreate() { super.onCreate() + DuduApi.init(AccountRepository.get(this).deviceId) MetricsFlushWorker.schedule(this) } } diff --git a/android/app/src/main/java/app/dudu/app/LoginScreen.kt b/android/app/src/main/java/app/dudu/app/LoginScreen.kt index 56e5fa4..d2541b8 100644 --- a/android/app/src/main/java/app/dudu/app/LoginScreen.kt +++ b/android/app/src/main/java/app/dudu/app/LoginScreen.kt @@ -77,7 +77,6 @@ fun LoginScreen(onLoggedIn: () -> Unit, onBack: () -> Unit) { balanceSeconds = me.balanceSeconds, trialDailyLimit = me.trialDailyLimit, trialUsedToday = me.trialUsedToday, - accountState = me.accountState, ) metrics.track("login.success") onLoggedIn() diff --git a/android/app/src/main/java/app/dudu/app/MineScreen.kt b/android/app/src/main/java/app/dudu/app/MineScreen.kt index bfc267b..a3c05ff 100644 --- a/android/app/src/main/java/app/dudu/app/MineScreen.kt +++ b/android/app/src/main/java/app/dudu/app/MineScreen.kt @@ -79,7 +79,6 @@ fun MineScreen( balanceSeconds = me.balanceSeconds, trialDailyLimit = me.trialDailyLimit, trialUsedToday = me.trialUsedToday, - accountState = me.accountState, ) } catch (e: ApiException) { if (e.code == "UNAUTHORIZED") repo.markUnauthorized() diff --git a/android/app/src/main/java/app/dudu/data/AccountRepository.kt b/android/app/src/main/java/app/dudu/data/AccountRepository.kt index 760a7c4..cfe2fa9 100644 --- a/android/app/src/main/java/app/dudu/data/AccountRepository.kt +++ b/android/app/src/main/java/app/dudu/data/AccountRepository.kt @@ -67,15 +67,31 @@ class AccountRepository private constructor(context: Context) { return id } + /** + * 只持久化 token/balance/trialUsed/limit 四个事实,state 与 trialRemaining 读取时推导 + * (对齐 iOS AccountStore,与 server dto.go 判定规则一致): + * - 无 token → guest;balance>0 → ok;trialRemaining>0 → trial;否则 quota + * - trialRemaining = limit - trialUsed(下限 0) + */ private fun load(): AccountSnapshot { - val state = AccountState.fromWire(prefs.getString(KEY_STATE, AccountState.GUEST.wire)) + val token = prefs.getString(KEY_TOKEN, null) + val balance = prefs.getLong(KEY_BALANCE, 0L) + val limit = prefs.getInt(KEY_TRIAL_LIMIT, DEFAULT_TRIAL_LIMIT) + val trialUsed = prefs.getInt(KEY_TRIAL_USED, 0) + val trialRemaining = (limit - trialUsed).coerceAtLeast(0) + val state = when { + token == null -> AccountState.GUEST + balance > 0 -> AccountState.OK + trialRemaining > 0 -> AccountState.TRIAL + else -> AccountState.QUOTA + } return AccountSnapshot( state = state, - balanceSeconds = prefs.getLong(KEY_BALANCE, 0L), - trialRemainingSeconds = prefs.getInt(KEY_TRIAL_REMAIN, 0), - trialDailyLimitSeconds = prefs.getInt(KEY_TRIAL_LIMIT, DEFAULT_TRIAL_LIMIT), - trialUsedTodaySeconds = prefs.getInt(KEY_TRIAL_USED, 0), - token = prefs.getString(KEY_TOKEN, null), + balanceSeconds = balance, + trialRemainingSeconds = trialRemaining, + trialDailyLimitSeconds = limit, + trialUsedTodaySeconds = trialUsed, + token = token, nicknameMasked = prefs.getString(KEY_NICKNAME, null), userId = prefs.getString(KEY_USER_ID, null), ) @@ -85,22 +101,24 @@ class AccountRepository private constructor(context: Context) { /** 微信登录成功(POST /v1/auth/wechat):写入 JWT 与用户信息,随后应调 /v1/me 校正余额。 */ fun setLogin(token: String, userId: String, nicknameMasked: String) { + // state 由 token/balance/trial 推导,写入 token 即从 guest 转出,applyMe 再校正余额 prefs.edit() .putString(KEY_TOKEN, token) .putString(KEY_USER_ID, userId) .putString(KEY_NICKNAME, nicknameMasked) - .putString(KEY_STATE, AccountState.TRIAL.wire) // 占位,applyMe 立刻校正 .apply() } - /** GET /v1/me → 刷新余额/试用/账户态(字段对齐 protocol.MeResponse,试用单位为秒)。 */ + /** + * GET /v1/me → 刷新余额/试用(字段对齐 protocol.MeResponse,试用单位为秒)。 + * 只落盘事实字段;state 由 [load] 依 token/balance/trial 推导,与服务端 account_state 判定一致。 + */ fun applyMe( userId: String, nicknameMasked: String, balanceSeconds: Long, trialDailyLimit: Int, trialUsedToday: Int, - accountState: String, ) { prefs.edit() .putString(KEY_USER_ID, userId) @@ -108,71 +126,64 @@ class AccountRepository private constructor(context: Context) { .putLong(KEY_BALANCE, balanceSeconds) .putInt(KEY_TRIAL_LIMIT, trialDailyLimit) .putInt(KEY_TRIAL_USED, trialUsedToday) - .putInt(KEY_TRIAL_REMAIN, (trialDailyLimit - trialUsedToday).coerceAtLeast(0)) - .putString(KEY_STATE, accountState) .apply() } - /** 退出登录:清 JWT 与用户信息,回游客态(服务端 /v1/auth/logout 由调用方发起)。 */ + /** 退出登录:清 JWT 与用户信息,回游客态(清 token 后 [load] 即推导为 guest)。 */ fun logout() { prefs.edit() .remove(KEY_TOKEN) .remove(KEY_USER_ID) .remove(KEY_NICKNAME) .putLong(KEY_BALANCE, 0L) - .putString(KEY_STATE, AccountState.GUEST.wire) .apply() } - /** WS usage 帧 → 实时刷新余额与试用剩余。 */ + /** + * WS usage 帧 → 实时刷新余额与试用。只更新事实字段(balance/trialUsed), + * state 由 [load] 依 balance/trialRemaining 推导(与 dto.go 判定规则一致)。 + */ fun applyUsage(balanceSeconds: Long, trialRemaining: Int) { val limit = prefs.getInt(KEY_TRIAL_LIMIT, DEFAULT_TRIAL_LIMIT) prefs.edit() .putLong(KEY_BALANCE, balanceSeconds) - .putInt(KEY_TRIAL_REMAIN, trialRemaining) .putInt(KEY_TRIAL_USED, (limit - trialRemaining).coerceAtLeast(0)) .apply() - // 余额/试用归零时联动账户态(与 dto.go 判定规则一致) - val cur = _snapshot.value.state - if (cur == AccountState.OK || cur == AccountState.TRIAL) { - val next = when { - balanceSeconds > 0 -> AccountState.OK - trialRemaining > 0 -> AccountState.TRIAL - else -> AccountState.QUOTA - } - if (next != cur) prefs.edit().putString(KEY_STATE, next.wire).apply() - } } /** 当前是否处于离线 mock 会话(setMockState 写入的假 token,不可对真实后端发请求)。 */ val offlineMock: Boolean get() = prefs.getString(KEY_TOKEN, null) == MOCK_TOKEN - /** 服务端回 QUOTA_EXCEEDED → 本地立刻转 quota 态。 */ + /** 服务端回 QUOTA_EXCEEDED → 余额清零 + 试用耗尽,[load] 即推导为 quota 态。 */ fun markQuotaExceeded() { - prefs.edit().putString(KEY_STATE, AccountState.QUOTA.wire).apply() + val limit = prefs.getInt(KEY_TRIAL_LIMIT, DEFAULT_TRIAL_LIMIT) + prefs.edit() + .putLong(KEY_BALANCE, 0L) + .putInt(KEY_TRIAL_USED, limit) + .apply() } - /** 服务端回 UNAUTHORIZED → 清登录态。 */ + /** 服务端回 UNAUTHORIZED → 清 JWT,[load] 即推导为 guest 态。 */ fun markUnauthorized() { prefs.edit() .remove(KEY_TOKEN) - .putString(KEY_STATE, AccountState.GUEST.wire) .apply() } // ─── 离线 mock 开关:无后端时手动切四态做联调(仅 debug UI 暴露入口) ──── // 真实登录路径见 setLogin/applyMe;本方法保留用于后端不可用时的键盘联调 fun setMockState(state: AccountState) { - val e = prefs.edit().putString(KEY_STATE, state.wire).putInt(KEY_TRIAL_LIMIT, DEFAULT_TRIAL_LIMIT) + // 只写事实字段,目标 state 由 [load] 推导(token/balance/trial → state)。 + val e = prefs.edit().putInt(KEY_TRIAL_LIMIT, DEFAULT_TRIAL_LIMIT) when (state) { - AccountState.OK -> e.putLong(KEY_BALANCE, 472L * 60).putInt(KEY_TRIAL_REMAIN, 120).putInt(KEY_TRIAL_USED, 60) + AccountState.OK -> e.putLong(KEY_BALANCE, 472L * 60).putInt(KEY_TRIAL_USED, 60) .putString(KEY_TOKEN, MOCK_TOKEN).putString(KEY_NICKNAME, "wang***") - AccountState.TRIAL -> e.putLong(KEY_BALANCE, 0L).putInt(KEY_TRIAL_REMAIN, 120).putInt(KEY_TRIAL_USED, 60) + AccountState.TRIAL -> e.putLong(KEY_BALANCE, 0L).putInt(KEY_TRIAL_USED, 60) .putString(KEY_TOKEN, MOCK_TOKEN).putString(KEY_NICKNAME, "wang***") - AccountState.QUOTA -> e.putLong(KEY_BALANCE, 0L).putInt(KEY_TRIAL_REMAIN, 0).putInt(KEY_TRIAL_USED, DEFAULT_TRIAL_LIMIT) + AccountState.QUOTA -> e.putLong(KEY_BALANCE, 0L).putInt(KEY_TRIAL_USED, DEFAULT_TRIAL_LIMIT) .putString(KEY_TOKEN, MOCK_TOKEN).putString(KEY_NICKNAME, "wang***") - AccountState.GUEST -> e.putLong(KEY_BALANCE, 0L).putInt(KEY_TRIAL_REMAIN, 0).putInt(KEY_TRIAL_USED, 0) + AccountState.GUEST -> e.putLong(KEY_BALANCE, 0L).putInt(KEY_TRIAL_USED, 0) .remove(KEY_TOKEN).remove(KEY_NICKNAME) } e.apply() @@ -180,9 +191,7 @@ class AccountRepository private constructor(context: Context) { companion object { private const val PREFS = "dudu_account" - private const val KEY_STATE = "state" private const val KEY_BALANCE = "balance_seconds" - private const val KEY_TRIAL_REMAIN = "trial_remaining_seconds" private const val KEY_TRIAL_LIMIT = "trial_daily_limit_seconds" private const val KEY_TRIAL_USED = "trial_used_today_seconds" private const val KEY_TOKEN = "token" diff --git a/android/app/src/main/java/app/dudu/ime/KeyboardController.kt b/android/app/src/main/java/app/dudu/ime/KeyboardController.kt index 74b0a92..c946b33 100644 --- a/android/app/src/main/java/app/dudu/ime/KeyboardController.kt +++ b/android/app/src/main/java/app/dudu/ime/KeyboardController.kt @@ -83,6 +83,8 @@ class KeyboardController( private var phase = Phase.IDLE private var discarded = false // 上滑取消后置位:后续 final 全部丢弃 + private var started = false // start 帧是否已写出(onOpen 内置位) + private var pendingStop = false // 用户在 onOpen 前松开:缓冲 stop,待 start 发完补发 init { // 账户缓存变化(主 App 切 mock 态 / usage 帧刷新)→ 工具条实时更新 @@ -115,6 +117,8 @@ class KeyboardController( phase = Phase.RECORDING discarded = false + started = false + pendingStop = false sessionId = "c-" + UUID.randomUUID() _state.value = _state.value.copy( recording = true, willCancel = false, @@ -158,7 +162,13 @@ class KeyboardController( releaseAtMs = SystemClock.elapsedRealtime() phase = Phase.FINALIZING stopAudioAndTicker() - socket?.sendStop(sessionId) + // 弱网下握手 >300ms 时 start 尚未写出:缓冲 stop,待 onOpen 发完 start 后补发, + // 保证服务端始终收到有序的 start→audio→stop(参考 iOS DictationController pendingFrames)。 + if (started) { + socket?.sendStop(sessionId) + } else { + pendingStop = true + } _state.value = _state.value.copy(recording = false, willCancel = false) // 收尾兜底:超时仍未收到 final → commit 已有内容 @@ -204,10 +214,19 @@ class KeyboardController( override fun onOpen() { scope.launch { - if (phase != Phase.RECORDING) return@launch + // 会话已彻底结束(取消/断连)才忽略;FINALIZING(onOpen 前松开)仍需先发 start。 + if (phase == Phase.IDLE) return@launch metrics.track("ws.connect_ms", mapOf("ms" to SystemClock.elapsedRealtime() - wsConnectAtMs)) socket?.sendStart(sessionId) - // start 发出后才开始推音频帧 + started = true + // 用户在握手完成前就松开:start 已发出,立刻补发缓冲的 stop,保证 start→stop 有序。 + if (pendingStop) { + pendingStop = false + socket?.sendStop(sessionId) + return@launch + } + // 仍在录音:start 发出后才开始推音频帧 + if (phase != Phase.RECORDING) return@launch audio = AudioStreamer( onFrame = { frame -> if (!gotFirstFrame) { diff --git a/android/app/src/main/java/app/dudu/ime/ui/KeyboardScreen.kt b/android/app/src/main/java/app/dudu/ime/ui/KeyboardScreen.kt index 6bd6c10..df326c9 100644 --- a/android/app/src/main/java/app/dudu/ime/ui/KeyboardScreen.kt +++ b/android/app/src/main/java/app/dudu/ime/ui/KeyboardScreen.kt @@ -16,7 +16,9 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -62,7 +64,9 @@ fun KeyboardScreen( onOpenApp: () -> Unit, ) { val p = LocalDuduPalette.current - val ui by controller.state.collectAsState() + // 单点收集整个状态,但下面各区只通过 derivedStateOf 读各自所需字段: + // partial(~5-10/s)只重组 PartialBar,不波及 Toolbar / MicBar / CancelHint。 + val ui = controller.state.collectAsState() Column( Modifier @@ -76,17 +80,26 @@ fun KeyboardScreen( ) { Toolbar(ui, onOpenApp) - val showTextBar = ui.recording || ui.finalText.isNotEmpty() || ui.partialText.isNotEmpty() + // 中部区域选择:录音 / 已有文本 → 文本条;否则 notice / 功能键行。 + // showTextBar 仅在"有无文本/是否录音"翻转时变化,partial 持续刷新不触发本处重组。 + val showTextBar by remember(ui) { + derivedStateOf { + ui.value.recording || ui.value.finalText.isNotEmpty() || ui.value.partialText.isNotEmpty() + } + } + val notice by remember(ui) { derivedStateOf { ui.value.notice } } when { showTextBar -> PartialBar(ui) - ui.notice != null -> NoticeBar(ui.notice!!) + notice != null -> NoticeBar(notice!!) else -> FunctionRow(onSwitchIme, onText, onBackspace, onEnter) } Box(Modifier.padding(horizontal = 2.dp)) { + val micBarState by remember(ui) { derivedStateOf { micState(ui.value) } } + val willCancel by remember(ui) { derivedStateOf { ui.value.willCancel } } MicBar( - state = micState(ui), - willCancel = ui.willCancel, + state = micBarState, + willCancel = willCancel, onPressDown = controller::onPressDown, onDrag = controller::onDrag, onRelease = controller::onRelease, @@ -94,7 +107,8 @@ fun KeyboardScreen( ) } - if (ui.recording) CancelHint(ui.willCancel) + val recording by remember(ui) { derivedStateOf { ui.value.recording } } + if (recording) CancelHint(ui) } } @@ -108,8 +122,12 @@ private fun micState(ui: KeyboardUiState): MicBarState = when { // ─── 工具条:左 logo + 名称,右 账户态文案(设计规范 6.2) ───────────────────── @Composable -private fun Toolbar(ui: KeyboardUiState, onOpenApp: () -> Unit) { +private fun Toolbar(ui: State, onOpenApp: () -> Unit) { val p = LocalDuduPalette.current + // 仅读 recording / seconds / account:partial 刷新不进入本作用域。 + val recording by remember(ui) { derivedStateOf { ui.value.recording } } + val seconds by remember(ui) { derivedStateOf { ui.value.seconds } } + val account by remember(ui) { derivedStateOf { ui.value.account } } Row( Modifier .fillMaxWidth() @@ -130,16 +148,16 @@ private fun Toolbar(ui: KeyboardUiState, onOpenApp: () -> Unit) { verticalAlignment = Alignment.CenterVertically, ) { when { - ui.recording -> DText(fmtTimer(ui.seconds), 12.sp, p.positive, mono = true) - ui.account.state == AccountState.GUEST -> DText("未登录", 12.sp, p.text3) - ui.account.state == AccountState.QUOTA -> DText("今日试用已用完", 12.sp, p.warning) - ui.account.state == AccountState.TRIAL -> - DText("试用 · 今日剩 ${fmtDuration(ui.account.trialRemainingSeconds.toLong())}", 12.sp, p.text3) + recording -> DText(fmtTimer(seconds), 12.sp, p.positive, mono = true) + account.state == AccountState.GUEST -> DText("未登录", 12.sp, p.text3) + account.state == AccountState.QUOTA -> DText("今日试用已用完", 12.sp, p.warning) + account.state == AccountState.TRIAL -> + DText("试用 · 今日剩 ${fmtDuration(account.trialRemainingSeconds.toLong())}", 12.sp, p.text3) else -> Row( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, ) { - DText("余额 ${fmtDuration(ui.account.balanceSeconds)}", 12.sp, p.text3) + DText("余额 ${fmtDuration(account.balanceSeconds)}", 12.sp, p.text3) IconCheck(11.dp, p.text3) } } @@ -161,14 +179,17 @@ private fun fmtDuration(seconds: Long): String = // ─── partial 文本条:深色玻璃(硬规则),final 亮 / partial 灰拼接 ───────────── @Composable -private fun PartialBar(ui: KeyboardUiState) { +private fun PartialBar(ui: State) { val p = LocalDuduPalette.current + // 只有本作用域读 final / partial:partial 刷新仅重组 PartialBar。 + val finalText by remember(ui) { derivedStateOf { ui.value.finalText } } + val partialText by remember(ui) { derivedStateOf { ui.value.partialText } } val text: AnnotatedString = buildAnnotatedString { - if (ui.finalText.isEmpty() && ui.partialText.isEmpty()) { + if (finalText.isEmpty() && partialText.isEmpty()) { withStyle(SpanStyle(color = p.overlayText2)) { append("聆听中…") } } else { - withStyle(SpanStyle(color = p.overlayText)) { append(ui.finalText) } - withStyle(SpanStyle(color = p.overlayText2)) { append(ui.partialText) } + withStyle(SpanStyle(color = p.overlayText)) { append(finalText) } + withStyle(SpanStyle(color = p.overlayText2)) { append(partialText) } } } Box( @@ -253,8 +274,9 @@ private fun RowScope.FnKey( // ─── 取消提示(仅录音中):"↑ 上滑取消",越过阈值转 danger"松开取消" ─────────── @Composable -private fun CancelHint(willCancel: Boolean) { +private fun CancelHint(ui: State) { val p = LocalDuduPalette.current + val willCancel by remember(ui) { derivedStateOf { ui.value.willCancel } } val color = if (willCancel) p.danger else p.text3 Row( Modifier diff --git a/android/app/src/main/java/app/dudu/net/DuduApi.kt b/android/app/src/main/java/app/dudu/net/DuduApi.kt index 410e67a..494178e 100644 --- a/android/app/src/main/java/app/dudu/net/DuduApi.kt +++ b/android/app/src/main/java/app/dudu/net/DuduApi.kt @@ -41,6 +41,16 @@ object DuduApi { // buildType 注入不同 BuildConfig.API_BASE,此处无需改动。 private val base = BuildConfig.API_BASE.trimEnd('/') + // 设备 ID:与 WS 握手 X-Device-ID 同源(AccountRepository.deviceId),DuduApp.onCreate 注入一次。 + // 服务端 touchDevice 依赖该头维护 devices 表,缺失则 did=="" 直接 return。 + @Volatile + private var deviceId: String = "" + + /** 进程启动时由 DuduApp 注入设备 ID,供所有 REST 请求带 X-Device-ID 头。 */ + fun init(deviceId: String) { + this.deviceId = deviceId + } + private val client: OkHttpClient by lazy { OkHttpClient.Builder() .connectTimeout(5, TimeUnit.SECONDS) @@ -174,9 +184,7 @@ object DuduApi { } val req = Request.Builder() .url(base + "/v1/feedback") - .header("Authorization", "Bearer $token") - .header("X-Platform", "android") - .header("X-App-Version", BuildConfig.VERSION_NAME) + .commonHeaders(token) .post(mb.build()) .build() return exec(req) { body -> JSONObject(body).getString("feedback_id") } @@ -216,15 +224,20 @@ object DuduApi { private val JSON = "application/json; charset=utf-8".toMediaType() + /** 统一附加设备/平台头:X-Device-ID(服务端 touchDevice 依赖)、X-Platform、X-App-Version。 */ + private fun Request.Builder.commonHeaders(token: String?): Request.Builder = apply { + if (token != null) header("Authorization", "Bearer $token") + if (deviceId.isNotEmpty()) header("X-Device-ID", deviceId) + header("X-Platform", "android") + header("X-App-Version", BuildConfig.VERSION_NAME) + } + private fun getRequest(path: String, token: String?): Request = - Request.Builder().url(base + path).apply { - if (token != null) header("Authorization", "Bearer $token") - }.build() + Request.Builder().url(base + path).commonHeaders(token).build() private fun jsonRequest(path: String, token: String?, body: JSONObject): Request = - Request.Builder().url(base + path).apply { - if (token != null) header("Authorization", "Bearer $token") - }.post(body.toString().toRequestBody(JSON)).build() + Request.Builder().url(base + path).commonHeaders(token) + .post(body.toString().toRequestBody(JSON)).build() private suspend fun exec(req: Request, parse: (String) -> T): T = withContext(Dispatchers.IO) { val resp = try { diff --git a/desktop/src-tauri/src/api.rs b/desktop/src-tauri/src/api.rs index 4865666..170907e 100644 --- a/desktop/src-tauri/src/api.rs +++ b/desktop/src-tauri/src/api.rs @@ -2,8 +2,24 @@ use serde_json::{json, Value}; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::OnceLock; use tauri::{AppHandle, Manager}; +/// 协议平台标识(18H):mac / win / linux(区别于 std::env::consts::OS 的 macos/windows)。 +const PLATFORM: &str = if cfg!(target_os = "macos") { + "mac" +} else if cfg!(target_os = "windows") { + "win" +} else { + "linux" +}; + +/// 共享 reqwest::Client(18H):复用连接池,避免每次请求新建 Client。 +fn http() -> &'static reqwest::Client { + static CLIENT: OnceLock = OnceLock::new(); + CLIENT.get_or_init(reqwest::Client::new) +} + /// 全局暂停开关(11B 托盘菜单"暂停使用"):暂停时快捷键不再触发听写。 #[derive(Default)] pub struct Paused(AtomicBool); @@ -39,7 +55,7 @@ fn base(app: &AppHandle) -> (String, String) { async fn get_json(app: &AppHandle, path: &str, authed: bool) -> Option { let (url, token) = base(app); - let mut req = reqwest::Client::new().get(format!("{url}{path}")); + let mut req = http().get(format!("{url}{path}")); if authed { if token.is_empty() { return None; @@ -67,7 +83,7 @@ pub async fn fetch_packs(app: AppHandle) -> Option { #[tauri::command] pub async fn login_qr_create(app: AppHandle) -> Option { let (url, _) = base(&app); - reqwest::Client::new() + http() .post(format!("{url}/v1/auth/qr")) .send() .await @@ -86,8 +102,8 @@ pub async fn login_qr_poll(app: AppHandle, state: String) -> Option { let mut s = store.get(); s.token = token.to_string(); store.set(s); - // 通知 ws 重连携带新 token - let _ = app.state::().tx.send(crate::ws::WsCmd::Reconnect); + // 通知 ws 重连携带新 token(bounded channel:用 WsHandle::send 非阻塞投递) + let _ = app.state::().send(crate::ws::WsCmd::Reconnect); } } Some(v) @@ -96,7 +112,7 @@ pub async fn login_qr_poll(app: AppHandle, state: String) -> Option { #[tauri::command] pub async fn create_order(app: AppHandle, pack_id: String) -> Option { let (url, token) = base(&app); - reqwest::Client::new() + http() .post(format!("{url}/v1/orders")) .bearer_auth(token) .json(&serde_json::json!({"pack_id": pack_id, "channel": "native"})) @@ -179,17 +195,17 @@ pub async fn submit_feedback( let s = app.state::().get(); let diag = json!({ "app_version": app_version, - "platform": std::env::consts::OS, + "platform": PLATFORM, "os_version": os_version(), "device_id": s.device_id, }); form = form.text("diagnostics", diag.to_string()); } - let resp = reqwest::Client::new() + let resp = http() .post(format!("{url}/v1/feedback")) .bearer_auth(token) - .header("X-Platform", std::env::consts::OS) + .header("X-Platform", PLATFORM) .header("X-App-Version", app_version) .multipart(form) .send() @@ -256,24 +272,54 @@ pub fn finish_onboarding(app: AppHandle) { } } -/// 检查更新:GET /v1/app/latest,有新版则打开下载页;返回结果供 UI 提示。 +/// 比较语义化版本:a 是否严格大于 b(仅按 . 分段的数字比较,非数字段视为 0)。 +/// 用于避免把降级(服务端版本反而更旧)误判为可更新(18H)。 +fn version_gt(a: &str, b: &str) -> bool { + let parse = |s: &str| -> Vec { + s.split('.') + .map(|seg| { + seg.chars() + .take_while(|c| c.is_ascii_digit()) + .collect::() + .parse::() + .unwrap_or(0) + }) + .collect() + }; + let (va, vb) = (parse(a), parse(b)); + let n = va.len().max(vb.len()); + for i in 0..n { + let x = va.get(i).copied().unwrap_or(0); + let y = vb.get(i).copied().unwrap_or(0); + if x != y { + return x > y; + } + } + false +} + +/// 检查更新:GET /v1/app/latest。返回结果供 UI 提示;force 时走强制更新路径。 #[tauri::command] pub async fn check_update(app: AppHandle) -> Option { let current = app.package_info().version.to_string(); - #[cfg(target_os = "macos")] - let platform = "mac"; - #[cfg(target_os = "windows")] - let platform = "win"; - #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] - let platform = "linux"; - let latest = get_json(&app, &format!("/v1/app/latest?platform={platform}"), false).await?; + let latest = get_json(&app, &format!("/v1/app/latest?platform={PLATFORM}"), false).await?; let version = latest.get("version").and_then(|v| v.as_str()).unwrap_or_default(); let dl = latest.get("url").and_then(|v| v.as_str()).unwrap_or_default(); - let available = !version.is_empty() && version != current; - if available && !dl.is_empty() { + let force = latest.get("force").and_then(|v| v.as_bool()).unwrap_or(false); + // 仅当服务端版本严格高于当前版本才算可更新;降级/同版不提示(18H)。 + let available = !version.is_empty() && version_gt(version, ¤t); + // 普通可更新:自动打开下载页。强制更新:不自动打开,交由 UI 走强制更新提示路径, + // 避免与普通更新等同(用户可能被强制弹窗拦截,需 UI 显式引导)。 + if available && !force && !dl.is_empty() { open_external(dl); } - Some(json!({"current": current, "latest": version, "update_available": available, "url": dl})) + Some(json!({ + "current": current, + "latest": version, + "update_available": available, + "force": force && available, + "url": dl, + })) } /// 用系统默认方式打开外部链接。 diff --git a/desktop/src-tauri/src/dictation.rs b/desktop/src-tauri/src/dictation.rs index 93784e8..3ca48ef 100644 --- a/desktop/src-tauri/src/dictation.rs +++ b/desktop/src-tauri/src/dictation.rs @@ -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 窗口定向 emit(18G):避免广播给全部 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, - 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::(); + 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::().tx.clone(); + let ws = (*app.state::()).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::().inner.lock().is_some() +} + pub fn stop(app: &AppHandle, canceled: bool) { let state = app.state::(); 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::().tx.clone(); + let buf = app.state::(); + if canceled { + // 取消即丢弃:标记该 epoch,后续晚到 final 直接丢弃不入 buffer(18A)。 + buf.discard(epoch); + } + + let ws = (*app.state::()).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::(); - let pending = state.inner.lock(); - drop(pending); - let buf = app.state::(); - buf.take() + // 事件驱动:收到 stop 后的尾部 final(或 usage 结算帧)即触发注入; + // 350ms 仅作超时兜底,避免 flush 慢于固定 sleep 截丢尾 final(18C)。 + tokio::select! { + _ = finalized.notified() => {} + _ = tokio::time::sleep(Duration::from_millis(350)) => {} + } + + let buf = app.state::(); + // 会话代际校验:本任务只处理自己的 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::().take(); + // 取消:清空 buffer(仅当仍是本 epoch,避免误清新会话),不上屏(18A)。 + let buf = app.state::(); + 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, + /// 当前会话代际(18A/18B):start 自增,on_server_msg / 收尾任务据此校验归属。 + epoch: AtomicU64, + /// 尾部 final 到达信号,驱动收尾注入(18C)。 + finalized: std::sync::Arc, +} + +struct BufferInner { + text: (String, String), // (final 累计, 最新 partial) + /// text 归属的会话代际。 + text_epoch: u64, + /// 被取消的会话代际:该 epoch 的后续 final 直接丢弃(18A)。 + discarded_epoch: Option, +} + +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 任务 await(18C)。 + fn finalize_signal(&self) -> std::sync::Arc { + self.finalized.clone() + } + + /// 取出并清空文本——仅当 buffer 仍属指定 epoch 且未被丢弃时返回(18A/18B)。 + /// epoch 不匹配(已被新会话覆盖)返回 None,调用方据此放弃注入/hide。 + fn take_for_epoch(&self, epoch: u64) -> Option { + 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::(); + 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::(); 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(); } } diff --git a/desktop/src-tauri/src/metrics.rs b/desktop/src-tauri/src/metrics.rs index ebd12f1..adabe71 100644 --- a/desktop/src-tauri/src/metrics.rs +++ b/desktop/src-tauri/src/metrics.rs @@ -6,6 +6,15 @@ use std::sync::Arc; use std::time::Duration; use tauri::{AppHandle, Manager}; +/// 协议平台标识(18H):mac / win / linux(区别于 std::env::consts::OS 的 macos/windows)。 +const PLATFORM: &str = if cfg!(target_os = "macos") { + "mac" +} else if cfg!(target_os = "windows") { + "win" +} else { + "linux" +}; + #[derive(Clone)] pub struct Metrics { queue: Arc>>, @@ -48,7 +57,8 @@ pub fn spawn(app: AppHandle) -> Metrics { }; let body = json!({ "device_id": device_id, - "platform": std::env::consts::OS, + // 协议平台标识 mac/win/linux(18H,区别于 std::env::consts::OS 的 macos/windows)。 + "platform": PLATFORM, "app_version": env!("CARGO_PKG_VERSION"), "os_version": "", "events": events, diff --git a/desktop/src-tauri/src/ws.rs b/desktop/src-tauri/src/ws.rs index 31f9c9f..7aeb4ac 100644 --- a/desktop/src-tauri/src/ws.rs +++ b/desktop/src-tauri/src/ws.rs @@ -2,13 +2,25 @@ //! 上行音频二进制帧 / 控制 JSON,下行 ServerMsg → emit("asr") + 维护听写文本状态。 use futures_util::{SinkExt, StreamExt}; -use serde_json::json; +use serde_json::{json, Value}; use std::time::Duration; -use tauri::{AppHandle, Emitter, Manager}; +use tauri::{AppHandle, Emitter, EventTarget, Manager}; use tokio::sync::mpsc; use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::http::HeaderValue; use tokio_tungstenite::tungstenite::Message; +/// 音频命令 channel 容量(18F):200 帧 × 100ms = 20s 音频,满时丢最旧帧避免无限堆积。 +const CMD_CAP: usize = 200; + +/// 浮层窗口 label(仅 overlay 监听 asr 事件)。 +const OVERLAY: &str = "overlay"; + +/// 定向 emit 到 overlay 窗口(18G)。 +fn emit_overlay(app: &AppHandle, event: &str, payload: Value) { + let _ = app.emit_to(EventTarget::webview_window(OVERLAY), event, payload); +} + #[derive(Debug)] pub enum WsCmd { Start { session_id: String }, @@ -20,12 +32,28 @@ pub enum WsCmd { #[derive(Clone)] pub struct WsHandle { - pub tx: mpsc::UnboundedSender, + pub tx: mpsc::Sender, +} + +impl WsHandle { + /// 投递命令;channel 满时(音频积压)丢弃当前帧并记录,避免无限堆积(18F)。 + /// 对非音频控制帧仍尽量投递(try_send 满时记录但不阻塞)。 + pub fn send(&self, cmd: WsCmd) -> Result<(), ()> { + match self.tx.try_send(cmd) { + Ok(()) => Ok(()), + Err(mpsc::error::TrySendError::Full(c)) => { + log::warn!("ws cmd channel full, dropping frame: {c:?}"); + Err(()) + } + Err(mpsc::error::TrySendError::Closed(_)) => Err(()), + } + } } /// 启动常驻连接任务(应用启动即预连接,按键时零握手)。 pub fn spawn(app: AppHandle) -> WsHandle { - let (tx, mut rx) = mpsc::unbounded_channel::(); + // bounded channel(18F):容量 CMD_CAP,满时由 WsHandle::send 丢最旧/当前帧并记录。 + let (tx, mut rx) = mpsc::channel::(CMD_CAP); tauri::async_runtime::spawn(async move { let mut backoff = Duration::from_millis(500); loop { @@ -52,12 +80,36 @@ pub fn spawn(app: AppHandle) -> WsHandle { continue; } }; - req.headers_mut().insert( - "Authorization", - format!("Bearer {token}").parse().unwrap(), - ); - req.headers_mut() - .insert("X-Device-ID", device_id.parse().unwrap()); + // header 用 from_str 处理 Result(18D):token/device_id 含非法字符(换行/中文) + // 时不 unwrap panic(否则杀死唯一 WS 泵任务致听写永久静默),而是 emit 错误并跳过本次连接。 + let auth = match HeaderValue::from_str(&format!("Bearer {token}")) { + Ok(v) => v, + Err(e) => { + log::error!("invalid Authorization header (token): {e}"); + emit_overlay( + &app, + "asr", + json!({"type":"error","code":"AUTH_HEADER","message":"登录凭证异常,请重新登录"}), + ); + tokio::time::sleep(Duration::from_secs(5)).await; + continue; + } + }; + let dev = match HeaderValue::from_str(&device_id) { + Ok(v) => v, + Err(e) => { + log::error!("invalid X-Device-ID header: {e}"); + emit_overlay( + &app, + "asr", + json!({"type":"error","code":"DEVICE_HEADER","message":"设备标识异常"}), + ); + tokio::time::sleep(Duration::from_secs(5)).await; + continue; + } + }; + req.headers_mut().insert("Authorization", auth); + req.headers_mut().insert("X-Device-ID", dev); let conn = tokio_tungstenite::connect_async(req).await; let (mut sink, mut stream) = match conn { @@ -67,6 +119,18 @@ pub fn spawn(app: AppHandle) -> WsHandle { ws.split() } Err(e) => { + // 401 握手被拒(18F):清 token + emit UNAUTHORIZED + 停止无脑重连。 + if is_unauthorized(&e) { + log::warn!("ws handshake unauthorized (401); clearing token"); + clear_token(&app); + emit_overlay( + &app, + "asr", + json!({"type":"error","code":"UNAUTHORIZED","message":"登录已失效,请重新登录"}), + ); + // token 已清空,loop 顶部会进入"未登录"分支等待重新登录后的 Reconnect。 + continue; + } log::warn!("ws connect failed: {e}; retry in {backoff:?}"); tokio::time::sleep(backoff).await; backoff = (backoff * 2).min(Duration::from_secs(8)); @@ -74,6 +138,10 @@ pub fn spawn(app: AppHandle) -> WsHandle { } }; + // 重连成功(18E):旧会话在断连期间的 start/stop 已丢失,服务端不认旧 session。 + // 不续旧会话——清空积压的旧会话音频帧 + 停止当前录音 + emit 会话级错误,避免静默丢音频。 + abort_active_session(&app, &mut rx); + let mut ping = tokio::time::interval(Duration::from_secs(30)); 'conn: loop { tokio::select! { @@ -90,7 +158,7 @@ pub fn spawn(app: AppHandle) -> WsHandle { WsCmd::Reconnect => break 'conn, }; if sink.send(msg).await.is_err() { - let _ = app.emit("asr", json!({"type":"error","code":"NETWORK"})); + emit_overlay(&app, "asr", json!({"type":"error","code":"NETWORK"})); break 'conn; } } @@ -99,14 +167,14 @@ pub fn spawn(app: AppHandle) -> WsHandle { Some(Ok(Message::Text(text))) => { if let Ok(v) = serde_json::from_str::(&text) { crate::dictation::on_server_msg(&app, &v); - let _ = app.emit("asr", v); + emit_overlay(&app, "asr", v); } } Some(Ok(Message::Ping(p))) => { let _ = sink.send(Message::Pong(p)).await; } Some(Ok(_)) => {} Some(Err(e)) => { log::warn!("ws read error: {e}"); - let _ = app.emit("asr", json!({"type":"error","code":"NETWORK"})); + emit_overlay(&app, "asr", json!({"type":"error","code":"NETWORK"})); break 'conn; } None => break 'conn, @@ -123,3 +191,44 @@ pub fn spawn(app: AppHandle) -> WsHandle { }); WsHandle { tx } } + +/// 握手错误是否为 401 未授权(18F)。 +fn is_unauthorized(e: &tokio_tungstenite::tungstenite::Error) -> bool { + matches!( + e, + tokio_tungstenite::tungstenite::Error::Http(resp) + if resp.status() == tokio_tungstenite::tungstenite::http::StatusCode::UNAUTHORIZED + ) +} + +/// 清空本地 token(18F):401 后需重新登录,避免用失效 token 无脑重连。 +fn clear_token(app: &AppHandle) { + let store = app.state::(); + let mut s = store.get(); + if !s.token.is_empty() { + s.token.clear(); + store.set(s); + } +} + +/// 重连成功后中止仍在进行的录音会话(18E): +/// 通知 dictation 停止采集 + emit 会话级错误(让用户知道这次听写失败需重说), +/// 并清空积压的旧会话音频/控制帧,避免发往新连接被服务端静默丢弃。 +fn abort_active_session(app: &AppHandle, rx: &mut mpsc::Receiver) { + if !crate::dictation::is_recording(app) { + // 没有进行中的会话:仍清掉可能积压的陈旧帧。 + while rx.try_recv().is_ok() {} + return; + } + log::warn!("ws reconnected mid-session; aborting active dictation session"); + // 停止采集(取消模式,丢弃已采集文本,不上屏)。 + crate::dictation::stop(app, true); + // 清空积压的旧会话音频/控制帧(新连接无对应 session,服务端会静默丢弃)。 + while rx.try_recv().is_ok() {} + // 会话级错误:告知用户本次听写因网络重连失败,需重说。 + emit_overlay( + app, + "asr", + json!({"type":"error","code":"SESSION_ABORTED","message":"网络已重连,本次听写中断,请重试"}), + ); +} diff --git a/desktop/src/login/main.jsx b/desktop/src/login/main.jsx index 23c8af1..7591fd3 100644 --- a/desktop/src/login/main.jsx +++ b/desktop/src/login/main.jsx @@ -1,11 +1,13 @@ // 登录 / 购买窗口四步流:扫码登录 → 时长包 → 支付二维码 → 完成 // 规格见 doc/frontend-design.html 4.2;二维码内容来自后端,轮询自动推进。 -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { createRoot } from 'react-dom/client'; import '@dudu/design/styles.css'; import { Button } from '@dudu/design/components/core/Button'; import { Badge } from '@dudu/design/components/core/Badge'; import { invoke } from '../shared/tauri'; +import { setThemePref } from '../shared/theme'; +import { usePoll } from '../shared/usePoll'; function Center({ children }) { return ( @@ -32,47 +34,64 @@ function QrBox({ value }) { function LoginApp() { const [step, setStep] = useState('login'); // login | plans | pay | done const [qr, setQr] = useState(null); + const [qrExpired, setQrExpired] = useState(false); const [packs, setPacks] = useState([]); const [sel, setSel] = useState('pack_500'); const [order, setOrder] = useState(null); const [me, setMe] = useState(null); - const timer = useRef(null); + const [qrNonce, setQrNonce] = useState(0); // 递增以触发二维码重建(刷新) - // 扫码登录:创建二维码 + 轮询 + // 启动应用主题:随系统/已存偏好(深色模式下不再停在浅色)。 + useEffect(() => { + invoke('get_settings').then((s) => s && setThemePref(s.theme)); + }, []); + + // 扫码登录:创建二维码(进入 login 步或点击刷新时重建)。 useEffect(() => { if (step !== 'login') return undefined; - let stop = false; - invoke('login_qr_create').then((r) => { - if (!r || stop) return; - setQr(r); - timer.current = setInterval(async () => { - const st = await invoke('login_qr_poll', { state: r.state }); - if (st && st.status === 'confirmed') { - clearInterval(timer.current); - const m = await invoke('fetch_me'); - setMe(m); - const ps = await invoke('fetch_packs'); - setPacks(ps || []); - setStep('plans'); - } - }, 1000); - }); - return () => { stop = true; clearInterval(timer.current); }; - }, [step]); + let live = true; + setQrExpired(false); + setQr(null); + invoke('login_qr_create').then((r) => { if (live && r) setQr(r); }); + return () => { live = false; }; + }, [step, qrNonce]); - // 支付轮询 - useEffect(() => { - if (step !== 'pay' || !order) return undefined; - const t = setInterval(async () => { - const st = await invoke('order_status', { orderId: order.order_id }); - if (st && st.status === 'paid') { - clearInterval(t); - setMe(await invoke('fetch_me')); - setStep('done'); - } - }, 1000); - return () => clearInterval(t); - }, [step, order]); + // 扫码登录轮询:拿到 confirmed/expired 即停(不再永久 1Hz 打后端)。 + usePoll(async (alive) => { + const st = await invoke('login_qr_poll', { state: qr.state }); + if (!alive()) return 'stop'; + if (st && st.status === 'confirmed') { + const m = await invoke('fetch_me'); + const ps = await invoke('fetch_packs'); + if (!alive()) return 'stop'; + setMe(m); + setPacks(ps || []); + setStep('plans'); + return 'stop'; + } + // 后端 2 分钟 qrTTL 过期后返回 expired:停轮询并提示点击刷新。 + if (st && st.status === 'expired') { + setQrExpired(true); + return 'stop'; + } + return undefined; + }, 1000, [qr && qr.state], step === 'login' && !!qr && !qrExpired); + + // 支付轮询:付款成功即停。 + usePoll(async (alive) => { + const st = await invoke('order_status', { orderId: order.order_id }); + if (!alive()) return 'stop'; + if (st && st.status === 'paid') { + const m = await invoke('fetch_me'); + if (!alive()) return 'stop'; + setMe(m); + setStep('done'); + return 'stop'; + } + return undefined; + }, 1000, [order && order.order_id], step === 'pay' && !!order); + + const refreshQr = () => setQrNonce((n) => n + 1); const pack = packs.find((p) => p.id === sel); @@ -80,8 +99,15 @@ function LoginApp() { return (
微信扫码登录
- -
打开微信扫一扫,确认后自动登录
+ + {qrExpired ? ( + <> +
二维码已过期,点击刷新重新扫码
+ + + ) : ( +
打开微信扫一扫,确认后自动登录
+ )}
); } diff --git a/desktop/src/onboarding/main.jsx b/desktop/src/onboarding/main.jsx index 6c2940b..deedb03 100644 --- a/desktop/src/onboarding/main.jsx +++ b/desktop/src/onboarding/main.jsx @@ -8,6 +8,7 @@ import { Badge } from '@dudu/design/components/core/Badge'; import { HotkeyCombo } from '@dudu/design/components/core/Kbd'; import { invoke } from '../shared/tauri'; import { setThemePref } from '../shared/theme'; +import { usePoll } from '../shared/usePoll'; const isMac = navigator.platform.toUpperCase().includes('MAC'); const STEPS = ['welcome', 'perms', 'login', 'try']; @@ -78,24 +79,16 @@ function OnboardingApp() { }, []); // 权限步:辅助功能状态轮询(用户去系统设置开启后自动变绿) - useEffect(() => { - if (STEPS[step] !== 'perms' || !isMac) return undefined; - let live = true; - const tick = () => invoke('check_accessibility').then((ok) => live && setAxOk(!!ok)); - tick(); - const t = setInterval(tick, 1000); - return () => { live = false; clearInterval(t); }; - }, [step]); + usePoll(async (alive) => { + const ok = await invoke('check_accessibility'); + if (alive()) setAxOk(!!ok); + }, 1000, [step], STEPS[step] === 'perms' && isMac); // 登录步:轮询账号,扫码成功后自动亮起 - useEffect(() => { - if (STEPS[step] !== 'login') return undefined; - let live = true; - const tick = () => invoke('fetch_me').then((m) => live && m && setMe(m)); - tick(); - const t = setInterval(tick, 1500); - return () => { live = false; clearInterval(t); }; - }, [step]); + usePoll(async (alive) => { + const m = await invoke('fetch_me'); + if (alive() && m) setMe(m); + }, 1500, [step], STEPS[step] === 'login'); const next = () => setStep((s) => Math.min(s + 1, STEPS.length - 1)); const prev = () => setStep((s) => Math.max(s - 1, 0)); diff --git a/desktop/src/overlay/main.jsx b/desktop/src/overlay/main.jsx index f34062a..68b4155 100644 --- a/desktop/src/overlay/main.jsx +++ b/desktop/src/overlay/main.jsx @@ -13,8 +13,19 @@ const ERR_TEXT = { ASR_UNAVAILABLE: '识别服务暂不可用,稍后再试', NETWORK: '连接中断,松开重试', NO_ACCESSIBILITY: '需要辅助功能权限才能上屏,去系统设置开启 dudu', + UNAUTHORIZED: '登录已失效,去重新登录', + SESSION_ABORTED: '连接断了,这次没说完,重新说一次', + AUTH_HEADER: '登录信息有误,去重新登录', + DEVICE_HEADER: '设备标识有误,重启应用试试', }; +// 未知 code(如 BAD_REQUEST/INTERNAL)且服务端 message(omitempty)缺失时的兜底, +// 保证任何 error 帧都有可见错误态,不会因空串停在「听写中」。 +const ERR_FALLBACK = '出错了,稍后再试'; +function errText(payload) { + return ERR_TEXT[payload.code] || payload.message || payload.text || ERR_FALLBACK; +} + function OverlayApp() { const [finalText, setFinalText] = useState(''); const [partialText, setPartialText] = useState(''); @@ -28,7 +39,7 @@ function OverlayApp() { listen('asr', ({ payload }) => { if (payload.type === 'partial') setPartialText(payload.text); else if (payload.type === 'final') { setFinalText((f) => f + payload.text); setPartialText(''); } - else if (payload.type === 'error') setError(ERR_TEXT[payload.code] || payload.message || ''); + else if (payload.type === 'error') setError(errText(payload)); }).then((off) => offs.push(off)); return () => offs.forEach((off) => off()); }, []); diff --git a/desktop/src/settings/SettingsApp.jsx b/desktop/src/settings/SettingsApp.jsx index cc1c97a..e035c5b 100644 --- a/desktop/src/settings/SettingsApp.jsx +++ b/desktop/src/settings/SettingsApp.jsx @@ -38,11 +38,22 @@ export function SettingsApp() { invoke('fetch_me').then((m) => m && setMe(m)); }, []); + // 仅这些字段属于「用户可改的设置」,回写时只动这几项, + // 其余字段(token / device_id / onboarding_done / server_url)保持后端最新值。 + const USER_FIELDS = ['hotkey', 'mic', 'sound', 'autostart', 'theme']; + const update = (patch) => { const next = { ...settings, ...patch }; setSettings(next); - invoke('set_settings', { settings: next }); if (patch.theme) setThemePref(patch.theme); + // 保存前重新读最新 settings 再合并,避免用陈旧(可能空)token 覆盖登录流程写入的 token, + // 导致先开设置窗再扫码登录、回设置页改开关时静默登出(丢失更新)。 + invoke('get_settings').then((latest) => { + const base = latest || settings; + const merged = { ...base }; + for (const k of USER_FIELDS) merged[k] = next[k]; + invoke('set_settings', { settings: merged }); + }); }; const minutes = me ? Math.floor(me.balance_seconds / 60) : 0; diff --git a/desktop/src/shared/usePoll.js b/desktop/src/shared/usePoll.js new file mode 100644 index 0000000..3e8e870 --- /dev/null +++ b/desktop/src/shared/usePoll.js @@ -0,0 +1,44 @@ +// 统一轮询钩子:interval + 存活标志 + 卸载清理 + 在途去重。 +// 用法:usePoll(async () => {...; return 'stop' 以提前停止}, intervalMs, deps, enabled) +// - fn 在每个 tick 调用,await 期间组件卸载则丢弃结果(不 setState)。 +// - fn 返回 'stop'(或经 alive() 自行判断)时停止本轮轮询,避免拿到终态后还永久 1Hz 打后端。 +// - 同一时刻只有一个在途请求;上一次未返回则跳过本 tick,避免请求堆积/竞态。 +// - deps 变化或卸载时清理定时器并失活,await 中的回调不再写状态。 +import { useEffect, useRef } from 'react'; + +export function usePoll(fn, intervalMs, deps = [], enabled = true) { + // 用 ref 持有最新 fn,避免把 fn 放进 deps 造成每次渲染重启轮询。 + const fnRef = useRef(fn); + fnRef.current = fn; + + useEffect(() => { + if (!enabled) return undefined; + let alive = true; + let inFlight = false; + let timer = null; + + const stop = () => { + alive = false; + if (timer) { clearInterval(timer); timer = null; } + }; + + const tick = async () => { + if (!alive || inFlight) return; + inFlight = true; + try { + const r = await fnRef.current(() => alive); + // await 期间组件可能已卸载/deps 变化:丢弃结果。 + if (!alive) return; + if (r === 'stop') stop(); + } finally { + inFlight = false; + } + }; + + // 立即触发一次,再按间隔轮询。 + tick(); + timer = setInterval(tick, intervalMs); + return stop; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [intervalMs, enabled, ...deps]); +} diff --git a/desktop/src/tray/main.jsx b/desktop/src/tray/main.jsx index 019961e..7239673 100644 --- a/desktop/src/tray/main.jsx +++ b/desktop/src/tray/main.jsx @@ -4,6 +4,7 @@ import { createRoot } from 'react-dom/client'; import '@dudu/design/styles.css'; import { Badge } from '@dudu/design/components/core/Badge'; import { invoke, listen } from '../shared/tauri'; +import { setThemePref } from '../shared/theme'; function Item({ children, right, onClick }) { const [hover, setHover] = useState(false); @@ -28,6 +29,8 @@ function TrayApp() { const [me, setMe] = useState(null); const [paused, setPaused] = useState(false); useEffect(() => { + // 启动应用主题:深色模式下托盘菜单不再停在浅色。 + invoke('get_settings').then((s) => s && setThemePref(s.theme)); invoke('fetch_me').then((m) => m && setMe(m)); invoke('is_paused').then((p) => setPaused(!!p)); listen('account', ({ payload }) => setMe(payload)); diff --git a/ios/dudu/App/DictationController.swift b/ios/dudu/App/DictationController.swift index e668e78..b29ae43 100644 --- a/ios/dudu/App/DictationController.swift +++ b/ios/dudu/App/DictationController.swift @@ -20,9 +20,13 @@ import UIKit final class AsrAudioCapture { private let engine = AVAudioEngine() + /// 21B:converter / outFormat / buffered / firstBufferSeen 由本串行队列保护。 + /// tap 回调在音频线程跑 consume(),stop() 在主线程置 nil —— 二者经此队列序列化, + /// stop() removeTap 后再 q.sync 等在途回调跑完才置 nil,杜绝 EXC_BAD_ACCESS 数据竞争。 + private let q = DispatchQueue(label: "app.dudu.asr.capture") private var converter: AVAudioConverter? private var outFormat: AVAudioFormat? - /// 重采样后字节缓冲:tap 回调(音频线程,串行)独占读写 + /// 重采样后字节缓冲(q 保护) private var buffered = Data() private var firstBufferSeen = false private var running = false @@ -50,10 +54,12 @@ final class AsrAudioCapture { let conv = AVAudioConverter(from: inFormat, to: out) else { throw ApiError(code: "AUDIO", message: "音频格式不支持") } - converter = conv - outFormat = out - buffered.removeAll() - firstBufferSeen = false + q.sync { + converter = conv + outFormat = out + buffered.removeAll() + firstBufferSeen = false + } input.removeTap(onBus: 0) // tap 回调在音频渲染线程串行执行;buffer 仅在回调期间有效, @@ -69,42 +75,54 @@ final class AsrAudioCapture { func stop() { guard running else { return } running = false + // 先摘 tap,阻止新回调入队;再 q.sync 等在途 consume 跑完后才置 nil engine.inputNode.removeTap(onBus: 0) engine.stop() - converter = nil - outFormat = nil + q.sync { + converter = nil + outFormat = nil + buffered.removeAll() + } // 残余不足一帧的尾巴(<100ms)直接丢弃,对识别影响可忽略 try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation]) } + /// tap 回调(音频线程):在 q 上序列化访问 converter/outFormat/buffered。 + /// buffer 仅在回调期间有效,故重采样与取帧都在 q.sync 内同步完成。 private func consume(_ buf: AVAudioPCMBuffer) { - guard let converter, let outFormat else { return } - if !firstBufferSeen { - firstBufferSeen = true - onFirstBuffer?() - } - let ratio = outFormat.sampleRate / buf.format.sampleRate - let capacity = AVAudioFrameCount(Double(buf.frameLength) * ratio) + 64 - guard let out = AVAudioPCMBuffer(pcmFormat: outFormat, frameCapacity: capacity) else { return } - var fed = false - var convErr: NSError? - // 单 buffer 喂入:本次给 buf,再要就 noDataNow(下个 tap 回调再来) - converter.convert(to: out, error: &convErr) { _, status in - if fed { - status.pointee = .noDataNow - return nil + var firstSeen = false + var frames: [Data] = [] + q.sync { + guard let converter, let outFormat else { return } // stop 已置 nil:丢弃本帧 + if !firstBufferSeen { + firstBufferSeen = true + firstSeen = true + } + let ratio = outFormat.sampleRate / buf.format.sampleRate + let capacity = AVAudioFrameCount(Double(buf.frameLength) * ratio) + 64 + guard let out = AVAudioPCMBuffer(pcmFormat: outFormat, frameCapacity: capacity) else { return } + var fed = false + var convErr: NSError? + // 单 buffer 喂入:本次给 buf,再要就 noDataNow(下个 tap 回调再来) + converter.convert(to: out, error: &convErr) { _, status in + if fed { + status.pointee = .noDataNow + return nil + } + fed = true + status.pointee = .haveData + return buf + } + guard convErr == nil, out.frameLength > 0, let ch = out.int16ChannelData else { return } + buffered.append(Data(bytes: ch[0], count: Int(out.frameLength) * MemoryLayout.size)) + while buffered.count >= DuduProtocol.frameBytes { + frames.append(Data(buffered.prefix(DuduProtocol.frameBytes))) + buffered.removeFirst(DuduProtocol.frameBytes) } - fed = true - status.pointee = .haveData - return buf - } - guard convErr == nil, out.frameLength > 0, let ch = out.int16ChannelData else { return } - buffered.append(Data(bytes: ch[0], count: Int(out.frameLength) * MemoryLayout.size)) - while buffered.count >= DuduProtocol.frameBytes { - let frame = Data(buffered.prefix(DuduProtocol.frameBytes)) - buffered.removeFirst(DuduProtocol.frameBytes) - onFrame?(frame) } + // 回调出口在 q 外触发(避免持锁回调引发的潜在重入 / 死锁) + if firstSeen { onFirstBuffer?() } + for frame in frames { onFrame?(frame) } } } @@ -250,6 +268,8 @@ final class DictationController: ObservableObject { private var pendingFrames: [Data] = [] private var timer: Timer? private var finishFallback: Task? + /// 21A:收尾阶段收到 final 后的短 grace 窗口,容纳连续尾部 final 再定稿 + private var finalGrace: Task? // 打点(事件名严格对齐 MetricWhitelist,白名单外服务端直接丢弃) private var micDownAt: Date? @@ -348,6 +368,8 @@ final class DictationController: ObservableObject { } finishFallback?.cancel() finishFallback = nil + finalGrace?.cancel() + finalGrace = nil stopCaptureAndTimer() ws?.close() ws = nil @@ -417,27 +439,35 @@ final class DictationController: ObservableObject { case DuduProtocol.ServerMsgType.final: finalText += msg.text ?? "" // final 追加式定稿,随后 partial 清空 partialText = "" + // 21A:收尾阶段以 stop 后的 final 帧为主要结束依据。收到 final 后再开一个 + // 短 grace 窗口(容纳可能的连续尾部 final),窗口内无新 final 即定稿, + // 避免被周期 usage 帧提前掐断而丢尾字。 + if phase == .finishing { + armFinalGrace() + } case DuduProtocol.ServerMsgType.usage: + // 21A:usage 仅用于实时回写余额,不再当作收尾信号立即 finalize。 + // 服务端 usageLoop 每 2s 独立下发周期 usage,松开瞬间可能恰好落在 tick 窗口、 + // 早于尾部 final 到达——若据此 finalize 会丢字。收尾改以 final 帧 + grace 窗口 + // 为准,并由 armFinishFallback(2.5s)兜底服务端不补发 final 的极端情况。 account?.applyUsage(balanceSeconds: msg.balanceSeconds, trialRemaining: msg.trialRemaining) - if phase == .finishing { - finalize(cancelled: false) // stop 后服务端补发的结算 usage = 会话收尾信号 - } case DuduProtocol.ServerMsgType.error: let code = msg.code ?? "INTERNAL" + // 21G:文案 server-first(errors.go 为真相源),本地表仅兜底未知码 / 服务端空文案 + let text = msg.message ?? Self.errText[code] ?? "出错了 · 再试一次" if code == "SESSION_LIMIT" { // 单会话超长:服务端已自动截断定稿,按完成处理(保留提示文案) - errorText = Self.errText[code] + errorText = text stopCaptureAndTimer() if phase == .recording { phase = .finishing armFinishFallback() } } else { - failWith(code: code, - message: Self.errText[code] ?? (msg.message ?? "出错了 · 再试一次")) + failWith(code: code, message: text) } default: @@ -450,6 +480,8 @@ final class DictationController: ObservableObject { private func finalize(cancelled: Bool) { finishFallback?.cancel() finishFallback = nil + finalGrace?.cancel() + finalGrace = nil stopCaptureAndTimer() ws?.close() ws = nil @@ -478,15 +510,20 @@ final class DictationController: ObservableObject { return } committedText = committed - // 双保险:剪贴板(任何宿主都可手动粘贴)+ App Group(dudu 键盘 120s 内自动上屏) - UIPasteboard.general.string = committed - PendingTextStore.write(committed) + // 21G:主通道 = App Group pending_text(dudu 键盘 120s 内自动上屏,且键盘 consume + // 后立即清除,无一致性缝隙)。剪贴板降级为兜底:仅当 App Group 写失败(无签名调试 / + // 键盘读不到)时才写剪贴板,避免每次 finalize 无条件污染用户剪贴板、造成重复粘贴。 + if !PendingTextStore.write(committed) { + UIPasteboard.general.string = committed + } phase = .done } private func failWith(code: String?, message: String) { finishFallback?.cancel() finishFallback = nil + finalGrace?.cancel() + finalGrace = nil stopCaptureAndTimer() ws?.close() ws = nil @@ -520,8 +557,10 @@ final class DictationController: ObservableObject { Task { @MainActor in guard let self, self.phase == .recording else { return } self.elapsedSeconds += 1 - // 对齐服务端 MaxSessionSeconds:到点自动定稿(服务端同时会下发 SESSION_LIMIT) - if self.elapsedSeconds >= DuduProtocol.maxSessionSeconds { + // 21E:单会话上限真相源是服务端 SESSION_LIMIT 帧(与 Android/桌面一致)。 + // 这里只保留更宽松的本地防御性安全上限(localSafetyCapSeconds=200s), + // 仅在服务端异常不下发 SESSION_LIMIT 时兜底,避免无限录音。 + if self.elapsedSeconds >= DuduProtocol.localSafetyCapSeconds { self.stop() } } @@ -539,7 +578,19 @@ final class DictationController: ObservableObject { finishFallback = Task { [weak self] in try? await Task.sleep(nanoseconds: 2_500_000_000) guard let self, !Task.isCancelled, self.phase == .finishing else { return } - self.finalize(cancelled: false) // 结算 usage 帧迟迟不到:用已有文本定稿 + self.finalize(cancelled: false) // 尾部 final / 结算帧迟迟不到:用已有文本定稿 + } + } + + /// 21A:收尾阶段收到 final 后开一个短 grace 窗口(600ms)。窗口内每来一帧 final + /// 重置窗口,确保连续尾部 final 全部落袋;窗口静默到点即定稿。比 2.5s 兜底更快, + /// 又不会被周期 usage 提前掐断。 + private func armFinalGrace() { + finalGrace?.cancel() + finalGrace = Task { [weak self] in + try? await Task.sleep(nanoseconds: 600_000_000) + guard let self, !Task.isCancelled, self.phase == .finishing else { return } + self.finalize(cancelled: false) } } diff --git a/ios/dudu/Dudu.xcodeproj/project.pbxproj b/ios/dudu/Dudu.xcodeproj/project.pbxproj index 457843e..210c288 100644 --- a/ios/dudu/Dudu.xcodeproj/project.pbxproj +++ b/ios/dudu/Dudu.xcodeproj/project.pbxproj @@ -26,7 +26,6 @@ 96EA045382879C3FEAB34491 /* PayClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDAC00E47916B8C92D5E1CAD /* PayClient.swift */; }; 9C0B987F813A4C95EE21DC48 /* WaveformView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DE2739924D6777B2128D145 /* WaveformView.swift */; }; 9C4548762EFA1E7A0D728B17 /* KeyboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BA800811CF8E3D2EF0F9AF7 /* KeyboardView.swift */; }; - AB818A27DE8367F71BB40757 /* CommitController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F7D11ACDCB7E8129751AE4F /* CommitController.swift */; }; B760D93939F57C5678B6379A /* DuduApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC10A277F09485441D27F140 /* DuduApp.swift */; }; BB91C976BB44F21208C966F1 /* WaveformView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DE2739924D6777B2128D145 /* WaveformView.swift */; }; BD087D01A9FEB63DC81614C4 /* AccountStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD3FE643E049274FD6C64184 /* AccountStore.swift */; }; @@ -72,7 +71,6 @@ 4BA800811CF8E3D2EF0F9AF7 /* KeyboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardView.swift; sourceTree = ""; }; 571D0A90CFE122E360A515B4 /* MockAsrDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAsrDriver.swift; sourceTree = ""; }; 5C1B0CEDF3F7F268A48BACB3 /* DuduProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuduProtocol.swift; sourceTree = ""; }; - 6F7D11ACDCB7E8129751AE4F /* CommitController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitController.swift; sourceTree = ""; }; 8099F56A0503D89BC0AA6B90 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; 8C524E4A9E51E9AB3C1B493B /* KeyboardExt.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KeyboardExt.entitlements; sourceTree = ""; }; 9DE2739924D6777B2128D145 /* WaveformView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaveformView.swift; sourceTree = ""; }; @@ -95,7 +93,6 @@ 18621DC7CA97E8B3490F2096 /* KeyboardExt */ = { isa = PBXGroup; children = ( - 6F7D11ACDCB7E8129751AE4F /* CommitController.swift */, EE008586DD71949CA27B40D0 /* Info.plist */, 8C524E4A9E51E9AB3C1B493B /* KeyboardExt.entitlements */, 4BA800811CF8E3D2EF0F9AF7 /* KeyboardView.swift */, @@ -233,7 +230,6 @@ files = ( 816C6B3D217A42D3E89809FA /* AccountStore.swift in Sources */, 6C9986723B0F43A9493191B6 /* ApiClient.swift in Sources */, - AB818A27DE8367F71BB40757 /* CommitController.swift in Sources */, 3F7DDC3D7AAEAF40F3E3FB94 /* DuduProtocol.swift in Sources */, 45E603119FFE9CE9FBCE03AE /* DuduTheme.swift in Sources */, 9C4548762EFA1E7A0D728B17 /* KeyboardView.swift in Sources */, diff --git a/ios/dudu/KeyboardExt/CommitController.swift b/ios/dudu/KeyboardExt/CommitController.swift deleted file mode 100644 index c0ea878..0000000 --- a/ios/dudu/KeyboardExt/CommitController.swift +++ /dev/null @@ -1,68 +0,0 @@ -// 上屏框架核心:持有 (final, partial) 双缓冲,决定何时把文字写进宿主输入框。 -// -// 14D 注:1A spike 实证键盘扩展内无法录音,流式识别整体移到主 App 听写页 -// (App/DictationController.swift),键盘改为消费 App Group pending_text 一次性上屏, -// 本控制器暂无调用方;保留文件 —— 双缓冲与 (final, partial) 语义是流式上屏的 -// 通用方案,若未来平台放开键盘内录音可直接复用。 -// -// partial 上屏策略取舍(结论:partial 不进宿主,松开一次性 insertText): -// -// 方案 A —— setMarkedText 实时把 partial 写进宿主: -// 宿主反馈最快,但 markedText 在第三方宿主里支持参差(部分聊天 App、网页输入框、 -// 密码框行为不一致,甚至直接丢弃),且会与宿主自身的中文组合区冲突,体验不可控。 -// -// 方案 B —— final 帧到达即增量 insertText、partial 只留在键盘内: -// 宿主反馈较快且无 markedText 兼容性问题,但"上滑取消=丢弃本次结果"做不干净: -// 已上屏的 final 只能靠 deleteBackward N 次回退,对 emoji/代理对/宿主自动纠错都很脆。 -// -// 方案 C(采用)—— 录音期间 final/partial 都只累积在缓冲并显示在键盘的 partial 文本条, -// 松开时一次性 insertText(final + 未定稿 partial): -// 与 doc/frontend-design.html 6.3 一致("partial 不进宿主输入框"、 -// "松开 → committed(final + 未定稿 partial)经 insertText 上屏"), -// 取消干净(清缓冲即可),代价是宿主要等松开才见字——partial 文本条补足了即时反馈。 -import UIKit - -final class CommitController { - /// 已定稿文本(final 帧累积) - private(set) var finalText = "" - /// 未定稿文本(最近一帧 partial,整帧替换) - private(set) var partialText = "" - - /// 缓冲变化回调,驱动键盘 partial 文本条刷新(final 亮 / partial 灰拼接) - var onChange: ((_ final: String, _ partial: String) -> Void)? - - /// 应用一帧下行消息。语义对齐 ws.go: - /// partial 为当前未定稿段的全量文本(替换式),final 为定稿一句(追加式,随后 partial 清空)。 - func apply(_ msg: DuduProtocol.ServerMsg) { - switch msg.type { - case DuduProtocol.ServerMsgType.partial: - partialText = msg.text ?? "" - case DuduProtocol.ServerMsgType.final: - finalText += msg.text ?? "" - partialText = "" - default: - return // usage / error 由上层处理,不进文本缓冲 - } - onChange?(finalText, partialText) - } - - /// 松开完成:committed = final + 未定稿 partial,一次性写入宿主。 - func commit(to proxy: UITextDocumentProxy) { - let committed = finalText + partialText - if !committed.isEmpty { - proxy.insertText(committed) - } - reset() - } - - /// 上滑取消:丢弃本次结果,不上屏。 - func cancel() { - reset() - } - - private func reset() { - finalText = "" - partialText = "" - onChange?("", "") - } -} diff --git a/ios/dudu/KeyboardExt/KeyboardViewController.swift b/ios/dudu/KeyboardExt/KeyboardViewController.swift index 46b274f..750c92d 100644 --- a/ios/dudu/KeyboardExt/KeyboardViewController.swift +++ b/ios/dudu/KeyboardExt/KeyboardViewController.swift @@ -16,7 +16,8 @@ // → 听写定稿写 App Group pending_text(+ 剪贴板双保险) // → 用户手动切回原 App 调起本键盘(iOS 无自动跳回 API) // → viewWillAppear 检出 120s 内的 pending_text,textDocumentProxy.insertText 自动上屏。 -// 14C 的录音态 UI / CommitController 流式上屏框架不再被键盘使用,文件保留备查。 +// 14C 的录音态 UI / 流式上屏框架(CommitController)已随 21D 删除(零调用方), +// 双缓冲 (final, partial) 语义已在主 App DictationController 复刻。 import SwiftUI import UIKit diff --git a/ios/dudu/Shared/AccountStore.swift b/ios/dudu/Shared/AccountStore.swift index 01a8b00..7f76c2b 100644 --- a/ios/dudu/Shared/AccountStore.swift +++ b/ios/dudu/Shared/AccountStore.swift @@ -92,8 +92,11 @@ public final class AccountStore: ObservableObject { } /// 14D:WS usage 帧实时回写余额 / 试用。 - /// ws.go ServerMsg 字段带 omitempty,0 值会被省略 → 仅在字段存在时覆盖, - /// 用尽(0)的最终态由 error 帧(QUOTA_EXCEEDED)与下次 GET /v1/me 兜底。 + /// 语义为「字段存在即覆盖」(含 0):ServerMsg 用 decodeIfPresent, + /// balance_seconds=0 显式下发时解码为 0(非 nil),这里写入 0 让余额归零, + /// state 推导 balance=0 && trial=0 → quota。后端 #16 去掉 omitempty 后 0 会显式下发; + /// 即便后端仍 omitempty 吞掉 0(字段缺失 → nil → 不覆盖),也由 error 帧 + /// (QUOTA_EXCEEDED)与下次 GET /v1/me 兜底,不会误把非 0 余额清掉。 public func applyUsage(balanceSeconds: Int64?, trialRemaining: Int?) { if let b = balanceSeconds { defaults.set(Int(b), forKey: Key.balanceSeconds) } if let t = trialRemaining { defaults.set(t, forKey: Key.trialRemaining) } diff --git a/ios/dudu/Shared/DuduProtocol.swift b/ios/dudu/Shared/DuduProtocol.swift index af567d6..fc86115 100644 --- a/ios/dudu/Shared/DuduProtocol.swift +++ b/ios/dudu/Shared/DuduProtocol.swift @@ -8,8 +8,12 @@ public enum DuduProtocol { public static let frameMillis = 100 public static let frameBytes = sampleRate * 2 * frameMillis / 1000 // 3200 - /// 单会话上限,到点服务端自动截断定稿(SESSION_LIMIT) + /// 单会话上限的真相源是服务端:到点服务端自动截断定稿并下发 SESSION_LIMIT 帧。 + /// 此常量仅作参考(与服务端 MaxSessionSeconds 对齐),iOS 不再据此主动早于服务端掐断。 public static let maxSessionSeconds = 180 + /// 本地防御性安全上限(秒):仅防服务端不下发 SESSION_LIMIT 时无限录音; + /// 故意比服务端上限宽松,确保正常情况下永远是服务端先截断(见 21E)。 + public static let localSafetyCapSeconds = 200 /// 每日免费试用时长(秒) public static let trialDailySeconds = 180 diff --git a/ios/dudu/Shared/MetricsQueue.swift b/ios/dudu/Shared/MetricsQueue.swift index fce87cf..461d07b 100644 --- a/ios/dudu/Shared/MetricsQueue.swift +++ b/ios/dudu/Shared/MetricsQueue.swift @@ -39,6 +39,12 @@ public final class MetricsQueue { private let fm = FileManager.default private let defaults = UserDefaults(suiteName: AccountStore.appGroupID) ?? .standard + /// 近似队列计数(仅 io 队列访问):track 路径不再每事件全目录扫描; + /// 首次写入时从磁盘 lazy 校准一次,之后递增。trim 只在 flush 路径做。 + /// 仅本进程视角的近似值(键盘扩展是另一进程,各自计数)—— 真正的精确 + /// 上限收口在 flush() 的全量扫描里兜底,这里只为避免 track 的 O(n) 热路径。 + private var approxCount: Int? + /// 事件落盘目录:App Group 容器 /metrics;无 App Group(无签名本地调试)退回 caches 保证不崩 private var dir: URL? { if let base = fm.containerURL(forSecurityApplicationGroupIdentifier: AccountStore.appGroupID) { @@ -67,19 +73,34 @@ public final class MetricsQueue { // 文件名前缀时间戳:flush 时按名排序即按时间序 let name = String(format: "%017.6f", Date().timeIntervalSince1970) + "-" + UUID().uuidString + ".json" - if let data = try? JSONEncoder().encode(ev) { - try? data.write(to: dir.appendingPathComponent(name)) + if let data = try? JSONEncoder().encode(ev), + (try? data.write(to: dir.appendingPathComponent(name))) != nil { + // 近似计数 +1(首次 lazy 从磁盘校准)。track 路径不再全目录扫描; + // 实际 trim 推迟到 flush(),那里的全量扫描会精确收口到 maxQueued。 + let base = approxCount ?? diskCount(dir) + approxCount = base + 1 } - trimIfNeeded(dir) } } + /// 当前队列文件数(全目录扫描,仅 flush / lazy 校准用,不在 track 热路径) + private func diskCount(_ dir: URL) -> Int { + (try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil))? + .filter { $0.pathExtension == "json" }.count ?? 0 + } + + /// flush 入口同步 trim(io 队列):超 maxQueued 丢最旧,并校准 approxCount private func trimIfNeeded(_ dir: URL) { - guard let files = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil), - files.count > Self.maxQueued else { return } - let sorted = files.sorted { $0.lastPathComponent < $1.lastPathComponent } - for f in sorted.prefix(sorted.count - Self.maxQueued) { - try? fm.removeItem(at: f) + guard let files = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) else { return } + let jsons = files.filter { $0.pathExtension == "json" } + if jsons.count > Self.maxQueued { + let sorted = jsons.sorted { $0.lastPathComponent < $1.lastPathComponent } + for f in sorted.prefix(sorted.count - Self.maxQueued) { + try? fm.removeItem(at: f) + } + approxCount = Self.maxQueued + } else { + approxCount = jsons.count } } @@ -89,6 +110,9 @@ public final class MetricsQueue { // 退避冷却期内不发 guard Date().timeIntervalSince1970 >= defaults.double(forKey: Key.nextAttempt), let dir else { return } + // 上限收口推迟到此(前台 flush 路径):在 io 队列同步 trim + 校准 approxCount, + // track 热路径不再每事件全目录扫描 + io.sync { trimIfNeeded(dir) } guard var files = (try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil))? .filter({ $0.pathExtension == "json" }) .sorted(by: { $0.lastPathComponent < $1.lastPathComponent }), diff --git a/ios/dudu/Shared/PendingTextStore.swift b/ios/dudu/Shared/PendingTextStore.swift index 25210c1..1c3986e 100644 --- a/ios/dudu/Shared/PendingTextStore.swift +++ b/ios/dudu/Shared/PendingTextStore.swift @@ -1,6 +1,8 @@ // 14D deep link 听写的跨进程交接(App Group UserDefaults): -// 主 App 听写完成 → write(text)(同时复制到剪贴板,双保险); -// 用户手动切回原 App 调起 dudu 键盘 → consume() 取走并 insertText 自动上屏。 +// 主 App 听写完成 → write(text);用户手动切回原 App 调起 dudu 键盘 +// → consume() 取走并 insertText 自动上屏。 +// 21G:App Group pending_text 是主通道(稳定、不污染用户剪贴板),write 回报是否 +// 成功写入真正的 App Group;剪贴板降级为 App Group 不可用时的兜底(见 DictationController)。 // // 为什么是"用户手动切回":iOS 不提供"跳回上一个 App"的公开 API, // 键盘扩展经 extensionContext.open 调起主 App 时也无法携带可用于自动跳回的 @@ -17,15 +19,26 @@ public enum PendingTextStore { /// 待上屏文本有效期(秒) public static let maxAgeSeconds: TimeInterval = 120 + /// App Group 是否可用:无签名本地调试场景拿不到 suite,键盘扩展(另一进程)读不到, + /// 此时主通道失效,需剪贴板兜底。 + private static var appGroupDefaults: UserDefaults? { + UserDefaults(suiteName: AccountStore.appGroupID) + } + private static var defaults: UserDefaults { - UserDefaults(suiteName: AccountStore.appGroupID) ?? .standard + appGroupDefaults ?? .standard } /// 主 App 听写完成时写入(空文本不写)。 - public static func write(_ text: String) { - guard !text.isEmpty else { return } - defaults.set(text, forKey: Key.text) - defaults.set(Date().timeIntervalSince1970, forKey: Key.ts) + /// 返回是否成功写入 App Group(主通道);false 表示 App Group 不可用, + /// 调用方应改用剪贴板兜底(21G:避免无条件污染用户剪贴板)。 + @discardableResult + public static func write(_ text: String) -> Bool { + guard !text.isEmpty else { return false } + guard let group = appGroupDefaults else { return false } // App Group 不可用 → 主通道失效 + group.set(text, forKey: Key.text) + group.set(Date().timeIntervalSince1970, forKey: Key.ts) + return true } /// 键盘扩展消费:取走并立即清除(只上屏一次)。 diff --git a/server/internal/auth/handlers.go b/server/internal/auth/handlers.go index 52f3878..66c454e 100644 --- a/server/internal/auth/handlers.go +++ b/server/internal/auth/handlers.go @@ -131,8 +131,16 @@ func (h *Handlers) MobileLogin(c *gin.Context) { } // Logout POST /v1/auth/logout(需登录) +// 拉黑到 token 真实自然过期时间(中间件解析后存于 CtxExpires),确保即便 +// JWT_TTL_HOURS 配得很大,注销 token 也不会在固定窗口后复活(17B)。 func (h *Handlers) Logout(c *gin.Context) { jti := c.GetString(CtxJTI) - _ = h.JWT.Revoke(c, jti, time.Now().Add(8*24*time.Hour)) // 覆盖最长 TTL + exp, _ := c.Get(CtxExpires) + expiresAt, _ := exp.(time.Time) + if expiresAt.IsZero() { + // 兜底:claims 未带 exp(理论上不会发生),按当前配置 TTL 估一个上界。 + expiresAt = time.Now().Add(time.Duration(h.JWT.ttl)) + } + _ = h.JWT.Revoke(c, jti, expiresAt) c.Status(http.StatusNoContent) } diff --git a/server/internal/auth/jwt.go b/server/internal/auth/jwt.go index c2fce5b..368953c 100644 --- a/server/internal/auth/jwt.go +++ b/server/internal/auth/jwt.go @@ -3,6 +3,7 @@ package auth import ( "context" + "log/slog" "net/http" "strings" "time" @@ -41,21 +42,32 @@ func (j *JWT) Sign(userID string) (string, error) { return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(j.secret) } -// Parse 校验签名、过期与黑名单,返回 userID 与 jti。 -func (j *JWT) Parse(ctx context.Context, token string) (userID, jti string, err error) { +// Parse 校验签名、过期与黑名单,返回 userID、jti 与 token 自然过期时间。 +// 黑名单检查 fail-closed:Redis 不可用(Exists 返回 error)时拒绝放行, +// 宁可误拒也不能让已登出/被踢出的 token 在 Redis 故障窗口复活(17A)。 +func (j *JWT) Parse(ctx context.Context, token string) (userID, jti string, expiresAt time.Time, err error) { var claims Claims _, err = jwt.ParseWithClaims(token, &claims, func(t *jwt.Token) (any, error) { return j.secret, nil }, jwt.WithValidMethods([]string{"HS256"})) if err != nil { - return "", "", err + return "", "", time.Time{}, err } if j.rdb != nil { - if n, _ := j.rdb.Exists(ctx, store.KeyJwtBlock(claims.ID)).Result(); n == 1 { - return "", "", jwt.ErrTokenExpired + n, exErr := j.rdb.Exists(ctx, store.KeyJwtBlock(claims.ID)).Result() + if exErr != nil { + // fail-closed:无法确认黑名单状态时拒绝,避免故障期放行被撤销的 token。 + slog.Warn("jwt blacklist check failed, rejecting (fail-closed)", "jti", claims.ID, "err", exErr) + return "", "", time.Time{}, exErr + } + if n == 1 { + return "", "", time.Time{}, jwt.ErrTokenExpired } } - return claims.Subject, claims.ID, nil + if claims.ExpiresAt != nil { + expiresAt = claims.ExpiresAt.Time + } + return claims.Subject, claims.ID, expiresAt, nil } // Revoke 将 jti 拉黑至 token 自然过期(logout / 踢出)。 @@ -68,8 +80,9 @@ func (j *JWT) Revoke(ctx context.Context, jti string, expiresAt time.Time) error } const ( - CtxUserID = "auth.user_id" - CtxJTI = "auth.jti" + CtxUserID = "auth.user_id" + CtxJTI = "auth.jti" + CtxExpires = "auth.expires_at" // token 自然过期时间(time.Time),供 Logout 拉黑到期用 ) // Middleware gin 鉴权中间件:Authorization: Bearer 。 @@ -80,13 +93,31 @@ func (j *JWT) Middleware() gin.HandlerFunc { c.AbortWithStatusJSON(http.StatusUnauthorized, protocol.NewAPIError(protocol.ErrUnauthorized)) return } - uid, jti, err := j.Parse(c.Request.Context(), token) + uid, jti, exp, err := j.Parse(c.Request.Context(), token) if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, protocol.NewAPIError(protocol.ErrUnauthorized)) return } c.Set(CtxUserID, uid) c.Set(CtxJTI, jti) + c.Set(CtxExpires, exp) + c.Next() + } +} + +// OptionalMiddleware 可选鉴权(17F):带合法 Authorization 时注入 user_id; +// 无 token 或解析失败(含 Redis 故障导致的 fail-closed)一律放行匿名,不拦截。 +// 用于 /v1/metrics/batch 等既接受匿名打点、又希望登录用户能关联 user_id 的端点。 +func (j *JWT) OptionalMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ") + if token != "" { + if uid, jti, exp, err := j.Parse(c.Request.Context(), token); err == nil { + c.Set(CtxUserID, uid) + c.Set(CtxJTI, jti) + c.Set(CtxExpires, exp) + } + } c.Next() } } diff --git a/server/internal/auth/jwt_test.go b/server/internal/auth/jwt_test.go index b4af726..d97864f 100644 --- a/server/internal/auth/jwt_test.go +++ b/server/internal/auth/jwt_test.go @@ -19,21 +19,46 @@ func TestSignParseRevoke(t *testing.T) { if err != nil { t.Fatal(err) } - uid, jti, err := j.Parse(ctx, token) + uid, jti, exp, err := j.Parse(ctx, token) if err != nil || uid != "u1" || jti == "" { t.Fatalf("parse failed: uid=%s jti=%s err=%v", uid, jti, err) } + if exp.IsZero() || time.Until(exp) <= 0 { + t.Fatalf("expiresAt should be in the future, got %v", exp) + } // 拉黑后解析失败 if err := j.Revoke(ctx, jti, time.Now().Add(time.Hour)); err != nil { t.Fatal(err) } - if _, _, err := j.Parse(ctx, token); err == nil { + if _, _, _, err := j.Parse(ctx, token); err == nil { t.Fatal("expect parse failure after revoke") } // 篡改签名失败 - if _, _, err := j.Parse(ctx, token+"x"); err == nil { + if _, _, _, err := j.Parse(ctx, token+"x"); err == nil { t.Fatal("expect parse failure on tampered token") } } + +// TestParseFailClosed Redis 不可用时黑名单检查应 fail-closed:拒绝放行(17A)。 +func TestParseFailClosed(t *testing.T) { + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + j := NewJWT("test-secret", time.Hour, rdb) + ctx := context.Background() + + token, err := j.Sign("u1") + if err != nil { + t.Fatal(err) + } + // 正常情况下放行 + if _, _, _, err := j.Parse(ctx, token); err != nil { + t.Fatalf("parse should pass while redis up: %v", err) + } + // Redis 故障:Exists 返回 error → 必须拒绝(不得 fail-open)。 + mr.Close() + if _, _, _, err := j.Parse(ctx, token); err == nil { + t.Fatal("expect parse failure when redis is unavailable (fail-closed)") + } +} diff --git a/server/internal/billing/billing.go b/server/internal/billing/billing.go index f64f3d9..26ce7da 100644 --- a/server/internal/billing/billing.go +++ b/server/internal/billing/billing.go @@ -16,6 +16,7 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" "gorm.io/gorm" + "gorm.io/gorm/clause" "dudu/server/internal/auth" "dudu/server/internal/quota" @@ -171,7 +172,8 @@ func (h *Handlers) MarkPaid(ctx context.Context, res NotifyResult) error { var seconds int64 err := h.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var order store.Order - if err := tx.Set("gorm:query_option", "FOR UPDATE"). + // GORM v2 行锁:v1 的 Set("gorm:query_option","FOR UPDATE") 在 v2 静默失效(16F)。 + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). First(&order, "id = ?", res.OrderID).Error; err != nil { return err } diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 43f5795..2e20a45 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -2,8 +2,12 @@ package config import ( + "encoding/json" + "log/slog" "os" "strconv" + + "dudu/server/pkg/protocol" ) type Config struct { @@ -35,6 +39,10 @@ type Config struct { OSSBucket string OSSKeyID string OSSKeySecret string + + // AppLatest 启动时解析 APP_LATEST_JSON 一次(17G):平台→版本信息。 + // 解析失败或未配置则为 nil/空,handler 据此返回 204,不 fail-fast。 + AppLatest map[string]protocol.AppLatestResponse } func Load() Config { @@ -64,9 +72,26 @@ func Load() Config { OSSBucket: os.Getenv("OSS_BUCKET"), OSSKeyID: os.Getenv("OSS_KEY_ID"), OSSKeySecret: os.Getenv("OSS_KEY_SECRET"), + + AppLatest: loadAppLatest(), } } +// loadAppLatest 启动时解析一次 APP_LATEST_JSON(17G)。失败仅告警并返回 nil, +// 不 fail-fast——未配置此项时服务仍需正常启动。 +func loadAppLatest() map[string]protocol.AppLatestResponse { + raw := os.Getenv("APP_LATEST_JSON") + if raw == "" { + return nil + } + var all map[string]protocol.AppLatestResponse + if err := json.Unmarshal([]byte(raw), &all); err != nil { + slog.Warn("invalid APP_LATEST_JSON, app update disabled", "err", err) + return nil + } + return all +} + func getenv(k, def string) string { if v := os.Getenv(k); v != "" { return v diff --git a/server/internal/gateway/gateway.go b/server/internal/gateway/gateway.go index b2c0cdf..a56c540 100644 --- a/server/internal/gateway/gateway.go +++ b/server/internal/gateway/gateway.go @@ -48,9 +48,14 @@ type wsConn struct { c *websocket.Conn } +// wsWriteTimeout 单次写超时(16B):客户端不读时避免 WriteJSON 永久阻塞, +// 致 pumpResults / 收尾路径卡死、goroutine 与连接泄漏。 +const wsWriteTimeout = 5 * time.Second + func (w *wsConn) sendJSON(v any) error { w.mu.Lock() defer w.mu.Unlock() + _ = w.c.SetWriteDeadline(time.Now().Add(wsWriteTimeout)) return w.c.WriteJSON(v) } @@ -77,7 +82,6 @@ func (h *Handler) HandleWS(c *gin.Context) { if err != nil { return } - defer conn.Close() ws := &wsConn{c: conn} conn.SetPongHandler(func(string) error { @@ -86,7 +90,11 @@ func (h *Handler) HandleWS(c *gin.Context) { _ = conn.SetReadDeadline(time.Now().Add(90 * time.Second)) var sess *session // 同连接串行多次会话 + // 单个收尾 defer,顺序显式(16B):先 conn.Close 拆连接——解开任何阻塞中的 + // pumpResults 写,再 finish(其 Wait 已带超时)。避免 LIFO 让 finish 先跑、 + // 而 Close 永不执行导致的 goroutine + 连接 + 设备槽泄漏。 defer func() { + conn.Close() if sess != nil { sess.finish(context.Background(), false) } @@ -165,6 +173,9 @@ func (h *Handler) startSession(ctx context.Context, ws *wsConn, uid, deviceID st h: h, ws: ws, id: msg.SessionID, uid: uid, deviceID: deviceID, provider: ps, started: now, done: make(chan struct{}), } + // Add 须 happen-before finish 的 Wait(WaitGroup 文档要求),放在启动 + // goroutine 之前,避免 Add 与 Wait 竞态(16C)。 + s.resultsDone.Add(1) go s.pumpResults() go s.usageLoop() return s @@ -179,41 +190,48 @@ type session struct { provider asr.Session started time.Time + // consumeMu 串行化 Consume RPC 与 finish 取快照(16D):避免 finish 在 + // 一笔在途 consumeDelta 已扣 Redis、尚未把 trialPart/balancePart 计入快照时 + // 读到漏记的账。consumeMu 须在 mu 之外单独获取,且不可在持有 mu 时跨越 Redis 调用。 + consumeMu sync.Mutex + mu sync.Mutex - audioBytes int + audioBytes int // 计费音频字节累计;截断/死亡/扣穿后在 feed 处冻结不再增长 providerEndMs int64 // Provider 报告的最大句尾时间戳(ms),0=未提供 consumedSec int // 已增量扣减的秒数 - trialPart int - balancePart int - truncated bool - finished bool - done chan struct{} - resultsDone sync.WaitGroup + trialPart int + balancePart int + truncated bool + providerDead bool // provider 死亡(r.Err / Results 非正常关闭)后置位,停止计费(16A) + quotaExhausted bool // 余额+试用扣穿后置位,已下发过一次 QUOTA_EXCEEDED(16E) + finished bool + done chan struct{} + resultsDone sync.WaitGroup } // feed 转发音频帧并累计实收时长;超 180s 自动截断。 +// 截断 / provider 死亡点之后到达的帧不再计入 audioBytes(不扣费): +// 用户为"未识别的音频"付费是 bug——先判停止条件,再决定是否累计(16A)。 func (s *session) feed(pcm []byte) { s.mu.Lock() - if s.finished { + if s.finished || s.truncated || s.providerDead || s.quotaExhausted { + // 已停止计费:丢弃此帧,不累计、不转发(扣穿后冻结,避免结算记录虚增秒数) s.mu.Unlock() return } + // 先用"若计入本帧"的时长判是否超 180s + overCap := (s.audioBytes+len(pcm)+31999)/32000 >= protocol.MaxSessionSeconds + if overCap { + // 本帧触发截断:冻结记账(本帧不计入),flush 定稿 + s.truncated = true + s.mu.Unlock() + // 截断:flush 定稿,但保持会话记账状态直至客户端 stop / 连接收尾 + _ = s.provider.Close() + s.ws.sendErr(s.id, protocol.ErrSessionLimit) + return + } s.audioBytes += len(pcm) - overCap := s.audioSeconds() >= protocol.MaxSessionSeconds s.mu.Unlock() - - if overCap { - s.mu.Lock() - already := s.truncated - s.truncated = true - s.mu.Unlock() - if !already { - // 截断:flush 定稿,但保持会话记账状态直至客户端 stop / 连接收尾 - _ = s.provider.Close() - s.ws.sendErr(s.id, protocol.ErrSessionLimit) - } - return - } _ = s.provider.SendAudio(pcm) } @@ -223,11 +241,13 @@ func (s *session) audioSeconds() int { } // pumpResults Provider → 客户端下行泵。 +// Add(1) 已移至 startSession(16C)。provider 死亡(r.Err 或 Results 非正常关闭) +// 时置 providerDead,停止后续计费(16A)。 func (s *session) pumpResults() { - s.resultsDone.Add(1) defer s.resultsDone.Done() for r := range s.provider.Results() { if r.Err != nil { + s.markProviderDead() s.ws.sendErr(s.id, protocol.ErrASRUnavailable) return } @@ -244,6 +264,19 @@ func (s *session) pumpResults() { } _ = s.ws.sendJSON(protocol.ServerMsg{Type: typ, SessionID: s.id, Text: r.Text}) } + // Results 通道关闭:若并非由 finish/截断/扣穿主动 Close 触发,则 provider 自行 + // 死亡(上游 EOF / 异常 task-finished),标记停止计费(16A)。 + s.markProviderDead() +} + +// markProviderDead 标记 provider 已死:feed 据此冻结 audioBytes(不再计入死亡点 +// 之后的帧),consumeDelta 据此停止继续扣费;已识别部分仍由 finish 的 consumeFinal +// 正常结算。由 finish/截断/扣穿主动 Close 触发的通道关闭也会走到这里,但此时计费已 +// 另行冻结,置位无副作用。 +func (s *session) markProviderDead() { + s.mu.Lock() + s.providerDead = true + s.mu.Unlock() } // usageLoop 每 2s 增量扣减并下发 usage 帧。 @@ -256,15 +289,28 @@ func (s *session) usageLoop() { return case <-t.C: s.consumeDelta(context.Background()) + // 续期设备槽(17D):单会话墙钟可远超 4min TTL(低速发帧刷新读超时), + // 不续期则 slot 先于会话过期,同设备第二路 start 会被错误放行。 + _, _ = store.RefreshDeviceSlot(context.Background(), s.h.RDB, s.deviceID, s.id) } } } // consumeDelta 将"实收音频秒数 − 已扣秒数"差额扣减并广播余额。 +// consumeMu 串行化与 finish 取快照(16D):持锁期间 finish 不会读到漏记的在途账。 func (s *session) consumeDelta(ctx context.Context) { + s.consumeMu.Lock() + defer s.consumeMu.Unlock() + s.mu.Lock() + // providerDead 后停止继续计费(16A):audioBytes 已在 feed 处冻结, + // 已识别部分由 finish 的 consumeFinal 正常结算。 + if s.finished || s.providerDead { + s.mu.Unlock() + return + } delta := s.audioSeconds() - s.consumedSec - if delta <= 0 || s.finished { + if delta <= 0 { s.mu.Unlock() return } @@ -287,9 +333,30 @@ func (s *session) consumeDelta(ctx context.Context) { BalanceSeconds: res.BalanceSeconds, TrialRemaining: max(0, protocol.TrialDailySeconds-res.TrialUsedToday), }) + + // 扣穿检测(16E):余额与今日试用均已耗尽。优雅结束会话——不立刻掐断当句 + // (Close 让 provider flush 当前句 final),下发一次 QUOTA_EXCEEDED 后由 finish 收尾。 + if res.Exhausted() { + s.mu.Lock() + already := s.quotaExhausted + s.quotaExhausted = true + s.mu.Unlock() + if !already { + s.ws.sendErr(s.id, protocol.ErrQuotaExceeded) + // 触发 provider flush 当前句尾 final 并结束;finish 串行收尾。 + _ = s.provider.Close() + } + } } +// finishWaitTimeout 收尾等待尾部 final 下发的最长时间(16B):客户端不读时 +// pumpResults 可能卡在写上(已由 sendJSON 写超时兜底),此处再加一层超时, +// 超时则继续收尾不无限等。 +const finishWaitTimeout = 3 * time.Second + // finish 结束会话:flush final → 补扣 → usage 帧 → 异步 settle → 记窗口 → 释放槽。 +// 注意 done/consumeMu 顺序(16D):先停 usageLoop 并 drain 在途 consumeDelta, +// 再读 trialPart/balancePart 快照,避免漏记在途扣费。 func (s *session) finish(ctx context.Context, canceled bool) { s.mu.Lock() if s.finished { @@ -299,11 +366,20 @@ func (s *session) finish(ctx context.Context, canceled bool) { s.mu.Unlock() _ = s.provider.Close() - s.resultsDone.Wait() // 尾部 final 全部下发后再收尾 + s.waitResults() // 尾部 final 全部下发后再收尾(带超时,16B) + // 先标记 finished 并停 usageLoop,再 drain 在途 consumeDelta(16D): + // 取 consumeMu 会等待任何已过 finished 检查、正等 Redis 返回的 consumeDelta + // 完成并把 trialPart/balancePart 计入,从而快照不漏账。 s.mu.Lock() s.finished = true close(s.done) + s.mu.Unlock() + + s.consumeMu.Lock() // drain:等当前在途 consume(若有)完成 + s.consumeMu.Unlock() + + s.mu.Lock() seconds := s.audioSeconds() audioMs := int64(s.audioBytes) * 1000 / 32000 providerMs := s.providerEndMs @@ -347,7 +423,23 @@ func (s *session) finish(ctx context.Context, canceled bool) { }() } +// waitResults 等 pumpResults 退出(尾部 final 下发完),最多 finishWaitTimeout(16B)。 +// 超时则放弃等待继续收尾——避免客户端不读时永久阻塞收尾路径。 +func (s *session) waitResults() { + done := make(chan struct{}) + go func() { + s.resultsDone.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(finishWaitTimeout): + slog.Warn("finish: results wait timed out", "session", s.id) + } +} + // consumeFinal 结束时补扣差额(不足 2s 的短会话由此兜底)。 +// 由 finish 在持有 consumeMu drain 后调用,且 usageLoop 已停,天然与增量扣减串行。 func (s *session) consumeFinal(ctx context.Context, seconds int) { s.mu.Lock() delta := seconds - s.consumedSec diff --git a/server/internal/gateway/gateway_test.go b/server/internal/gateway/gateway_test.go index 0962e7d..8c16c39 100644 --- a/server/internal/gateway/gateway_test.go +++ b/server/internal/gateway/gateway_test.go @@ -1,7 +1,6 @@ package gateway import ( - "context" "encoding/json" "net/http/httptest" "strings" @@ -155,35 +154,45 @@ func TestQuotaExceededOnStart(t *testing.T) { } } -// mrConsumeAll 把 u1 的当日试用直接耗尽(经 quota 通道,保证键一致)。 +// mrConsumeAll 把 u1 的当日试用耗尽(经 quota 通道,保证键一致)。 +// 单会话被截断在略低于 180s 处(截断帧不再计费,16A),故一会话不足以扣满 180s 试用; +// 此处循环开会话推音频,直到某个 usage 帧报告 TrialRemaining<=0 为止。 func mrConsumeAll(t *testing.T, srv *httptest.Server, db *gorm.DB) { t.Helper() - conn := dial(t, srv) - start, _ := json.Marshal(protocol.ClientMsg{Type: "start", SessionID: "s0", SampleRate: 16000}) - _ = conn.WriteMessage(websocket.TextMessage, start) frame := make([]byte, protocol.FrameBytes) - // 180s 音频 = 1800 帧 - for i := 0; i < protocol.TrialDailySeconds*10; i++ { - if err := conn.WriteMessage(websocket.BinaryMessage, frame); err != nil { - t.Fatal(err) + deadline := time.Now().Add(15 * time.Second) + for sess := 0; sess < 5 && time.Now().Before(deadline); sess++ { + conn := dial(t, srv) + sid := "s0_" + string(rune('a'+sess)) + start, _ := json.Marshal(protocol.ClientMsg{Type: "start", SessionID: sid, SampleRate: 16000}) + _ = conn.WriteMessage(websocket.TextMessage, start) + // 推 180s 音频(1800 帧)→ 会话在临界处截断 + for i := 0; i < protocol.TrialDailySeconds*10; i++ { + if err := conn.WriteMessage(websocket.BinaryMessage, frame); err != nil { + break + } + } + stop, _ := json.Marshal(protocol.ClientMsg{Type: "stop", SessionID: sid}) + _ = conn.WriteMessage(websocket.TextMessage, stop) + + exhausted := false + for time.Now().Before(deadline) { + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + var msg protocol.ServerMsg + if err := conn.ReadJSON(&msg); err != nil { + break + } + if msg.Type == protocol.MsgUsage && msg.TrialRemaining <= 0 { + exhausted = true + break + } + } + conn.Close() + if exhausted { + return } } - stop, _ := json.Marshal(protocol.ClientMsg{Type: "stop", SessionID: "s0"}) - _ = conn.WriteMessage(websocket.TextMessage, stop) - // 读到连接收尾的 usage 帧为止 - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) { - _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) - var msg protocol.ServerMsg - if err := conn.ReadJSON(&msg); err != nil { - break - } - if msg.Type == protocol.MsgUsage && msg.SessionSeconds >= protocol.TrialDailySeconds { - break - } - } - conn.Close() - _ = context.Background() + t.Fatal("failed to exhaust trial via repeated sessions") } func TestSessionLimitTruncates(t *testing.T) { diff --git a/server/internal/httpapi/router.go b/server/internal/httpapi/router.go index 0c67df4..f7a4a10 100644 --- a/server/internal/httpapi/router.go +++ b/server/internal/httpapi/router.go @@ -4,6 +4,7 @@ package httpapi import ( "context" + "log/slog" "net/http" "github.com/gin-gonic/gin" @@ -29,47 +30,65 @@ type Deps struct { Quota *quota.Manager } -// pickProvider 按配置选择 ASR Provider(gummy 在 6B 实现 + DASHSCOPE_API_KEY 就绪后接入)。 -func pickProvider(cfg config.Config) asr.Provider { +// pickProvider 按配置选择 ASR Provider(17E)。ASR_PROVIDER=gummy 且 DASHSCOPE_API_KEY +// 就绪时装配真实 gummy;缺 key 则告警并降级 mock。返回值 kind 为装配结果("gummy"/"mock")。 +func pickProvider(cfg config.Config) (asr.Provider, string) { switch cfg.ASRProvider { + case "gummy": + if cfg.DashScopeAPIKey != "" { + return asr.NewGummy(cfg.DashScopeAPIKey), "gummy" + } + slog.Warn("ASR_PROVIDER=gummy but DASHSCOPE_API_KEY empty, falling back to mock") + return asr.NewMock(), "mock" default: - return asr.NewMock() + return asr.NewMock(), "mock" } } -// pickWechat 微信凭证未配置(#2A 申请中)时使用 mock。 -func pickWechat(cfg config.Config) auth.WechatClient { +// pickWechat 微信凭证未配置(#2A 申请中)时使用 mock。返回 kind="mock"(真实实现就绪前恒为 mock)。 +func pickWechat(cfg config.Config) (auth.WechatClient, string) { if cfg.WechatWebAppID == "" { - return auth.MockWechat{} + return auth.MockWechat{}, "mock" } - return auth.MockWechat{} // TODO(5C-真实): 开放平台实现,凭证就绪后替换 + return auth.MockWechat{}, "mock" // TODO(5C-真实): 开放平台实现,凭证就绪后替换 } -// pickPay 商户号未配置(#2B 申请中)时使用 mock。 -func pickPay(cfg config.Config) billing.PayClient { +// pickPay 商户号未配置(#2B 申请中)时使用 mock。返回 kind="mock"。 +// ⚠️⚠️ 上线前必须替换为真实 PayClient!MockPay 不验签,/v1/pay/notify 等于公开充值接口。 +func pickPay(cfg config.Config) (billing.PayClient, string) { if cfg.WxPayMchID == "" { - return billing.MockPay{} + return billing.MockPay{}, "mock" } - return billing.MockPay{} // TODO(7D-真实): wechatpay-go 实现,商户号就绪后替换 + // TODO(7D-真实)【上线阻断项】: wechatpay-go 实现 + APIv3 验签,商户号就绪后替换,否则充值接口可被伪造。 + return billing.MockPay{}, "mock" } -// pickStorage OSS 未配置时图片落本地盘。 -func pickStorage(cfg config.Config) feedback.Storage { +// pickStorage OSS 未配置时图片落本地盘。返回 kind="local"(OSS 就绪后改为 "oss")。 +func pickStorage(cfg config.Config) (feedback.Storage, string) { if cfg.OSSBucket == "" { - return feedback.LocalStorage{Dir: "var/uploads"} + return feedback.LocalStorage{Dir: "var/uploads"}, "local" } - return feedback.LocalStorage{Dir: "var/uploads"} // TODO(8C-真实): OSS 实现 + return feedback.LocalStorage{Dir: "var/uploads"}, "local" // TODO(8C-真实): OSS 实现 } func Register(r *gin.Engine, d *Deps) { r.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) }) - authH := &auth.Handlers{DB: d.DB, RDB: d.RDB, JWT: d.JWT, Wechat: pickWechat(d.Cfg)} - billH := &billing.Handlers{DB: d.DB, Pay: pickPay(d.Cfg), Quota: d.Quota} - userH := &user.Handlers{DB: d.DB, Quota: d.Quota} - fbH := &feedback.Handlers{DB: d.DB, RDB: d.RDB, Storage: pickStorage(d.Cfg)} + provider, providerKind := pickProvider(d.Cfg) + wechat, wechatKind := pickWechat(d.Cfg) + pay, payKind := pickPay(d.Cfg) + storage, storageKind := pickStorage(d.Cfg) + + // 启动时打印各外部依赖的装配结果(real/mock),便于部署核对(17E)。 + slog.Info("dependency assembly", + "asr_provider", providerKind, "wechat", wechatKind, "pay", payKind, "storage", storageKind) + + authH := &auth.Handlers{DB: d.DB, RDB: d.RDB, JWT: d.JWT, Wechat: wechat} + billH := &billing.Handlers{DB: d.DB, Pay: pay, Quota: d.Quota} + userH := &user.Handlers{DB: d.DB, Quota: d.Quota, AppVersions: d.Cfg.AppLatest} + fbH := &feedback.Handlers{DB: d.DB, RDB: d.RDB, Storage: storage} telH := telemetry.New(d.DB, d.RDB) - gwH := &gateway.Handler{Provider: pickProvider(d.Cfg), Quota: d.Quota, RDB: d.RDB} + gwH := &gateway.Handler{Provider: provider, Quota: d.Quota, RDB: d.RDB} // 后台任务 bg := context.Background() @@ -85,8 +104,12 @@ func Register(r *gin.Engine, d *Deps) { v1.GET("/auth/wechat/callback", authH.QrCallback) v1.POST("/auth/wechat", authH.MobileLogin) v1.GET("/packs", billH.Packs) + // ⚠️ 部署前必须切换到真实 PayClient(验签)!当前 MockPay 不验签, + // /v1/pay/notify 等于一个无鉴权的公开充值接口(任意人可伪造支付成功回调充值)。 + // 上线前务必让 pickPay 返回带 APIv3 验签的真实实现(见 pickPay 的 TODO(7D-真实))。 v1.POST("/pay/notify", billH.Notify) - v1.POST("/metrics/batch", telH.Batch) + // metrics/batch 可匿名上报;OptionalMiddleware 在带合法 JWT 时注入 user_id 以关联用户(17F)。 + v1.POST("/metrics/batch", d.JWT.OptionalMiddleware(), telH.Batch) v1.GET("/app/latest", userH.AppLatest) // 需登录 diff --git a/server/internal/quota/quota.go b/server/internal/quota/quota.go index 303dfa8..2f317b3 100644 --- a/server/internal/quota/quota.go +++ b/server/internal/quota/quota.go @@ -95,6 +95,12 @@ type ConsumeResult struct { TrialUsedToday int } +// Exhausted 本次扣减后余额与今日试用是否均已耗尽(扣穿)。 +// 网关据此在句间下发 QUOTA_EXCEEDED 并优雅结束会话("扣穿当句不掐断")。 +func (r ConsumeResult) Exhausted() bool { + return r.BalanceSeconds <= 0 && r.TrialUsedToday >= protocol.TrialDailySeconds +} + // Consume 扣减 seconds 秒(识别中每 2s 增量调用 + 会话结束补差)。 func (m *Manager) Consume(ctx context.Context, uid string, seconds int) (ConsumeResult, error) { if err := m.ensureBalanceLoaded(ctx, uid); err != nil { diff --git a/server/internal/store/redis.go b/server/internal/store/redis.go index a7b08b8..cbb6d37 100644 --- a/server/internal/store/redis.go +++ b/server/internal/store/redis.go @@ -2,7 +2,6 @@ package store import ( "context" - "fmt" "time" "github.com/redis/go-redis/v9" @@ -47,7 +46,9 @@ else end end if sum + val > limit then return 0 end -redis.call('ZADD', key, now, now .. '-' .. redis.call('INCR', key .. ':seq') .. ':' .. val) +local seq = redis.call('INCR', key .. ':seq') +redis.call('EXPIRE', key .. ':seq', window + 60) -- 17C:seq 计数器与 ZSET 同寿命,避免按设备永久泄漏 +redis.call('ZADD', key, now, now .. '-' .. seq .. ':' .. val) redis.call('EXPIRE', key, window + 60) return 1 `) @@ -59,13 +60,6 @@ func AllowSession(ctx context.Context, rdb *redis.Client, deviceID string, now t return ok == 1, err } -// AllowAudioSeconds 设备维度时长准入(原子检查并记录):30 分钟内累计 ≤1800s。 -func AllowAudioSeconds(ctx context.Context, rdb *redis.Client, deviceID string, seconds int, now time.Time) (bool, error) { - ok, err := slideScript.Run(ctx, rdb, []string{KeyRateSecs(deviceID)}, - now.Unix(), 30*60, 30*60, seconds, "sum").Int() - return ok == 1, err -} - // AudioWindowExhausted 会话 start 时检查时长窗口是否已满(只查不记;本次秒数在结束时 // 经 RecordAudioSeconds 记录——音频已实际消耗,结束时无条件记账)。 func AudioWindowExhausted(ctx context.Context, rdb *redis.Client, deviceID string, now time.Time) (bool, error) { @@ -80,9 +74,28 @@ func RecordAudioSeconds(ctx context.Context, rdb *redis.Client, deviceID string, now.Unix(), 30*60, 1<<30, seconds, "sum").Err() } +const deviceSlotTTL = 4 * time.Minute + // AcquireDeviceSlot 单设备同时仅 1 路识别会话(SET NX + TTL 兜底防泄漏)。 +// +// 续期契约(17D):槽位 TTL 仅 deviceSlotTTL(4min)作为崩溃/泄漏兜底,并非会话上限。 +// 长会话期间持有者必须周期性调用 RefreshDeviceSlot 续期(间隔需 < TTL), +// 否则 TTL 到期后槽位被释放、并发设备可抢占。网关 usageLoop 每个 tick(2s) +// 应顺带调用 RefreshDeviceSlot 续命;会话正常/异常结束时由 ReleaseDeviceSlot 主动释放。 func AcquireDeviceSlot(ctx context.Context, rdb *redis.Client, deviceID, sessionID string) (bool, error) { - return rdb.SetNX(ctx, KeyActiveSession(deviceID), sessionID, 4*time.Minute).Result() + return rdb.SetNX(ctx, KeyActiveSession(deviceID), sessionID, deviceSlotTTL).Result() +} + +// RefreshDeviceSlot 仅当槽位仍由本会话持有时续期 TTL(17D)。 +// 返回 (true,nil) 表示续期成功;(false,nil) 表示槽位已不属于自己(被抢占/已释放), +// 调用方应据此判定会话是否仍合法持有槽位。供 gateway usageLoop 每 tick 调用。 +func RefreshDeviceSlot(ctx context.Context, rdb *redis.Client, deviceID, sessionID string) (bool, error) { + // 持有者校验 + EXPIRE 原子化,避免续到别人刚抢占的槽位。 + script := redis.NewScript(` +if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('EXPIRE', KEYS[1], ARGV[2]) end +return 0`) + n, err := script.Run(ctx, rdb, []string{KeyActiveSession(deviceID)}, sessionID, int(deviceSlotTTL.Seconds())).Int() + return n == 1, err } func ReleaseDeviceSlot(ctx context.Context, rdb *redis.Client, deviceID, sessionID string) error { @@ -103,5 +116,3 @@ func IncrDailyCounter(ctx context.Context, rdb *redis.Client, key string) (int64 } return incr.Val(), nil } - -var _ = fmt.Sprintf // keep fmt for future use diff --git a/server/internal/store/redis_test.go b/server/internal/store/redis_test.go index 516b52e..dac7f21 100644 --- a/server/internal/store/redis_test.go +++ b/server/internal/store/redis_test.go @@ -35,21 +35,25 @@ func TestAllowSessionWindow(t *testing.T) { } } -func TestAllowAudioSecondsWindow(t *testing.T) { +// TestAudioWindowExhausted 复用生产路径(AudioWindowExhausted 只查 + RecordAudioSeconds 记账)。 +func TestAudioWindowExhausted(t *testing.T) { r := rdb(t) ctx := context.Background() now := time.Now() - // 1700s 放行 - if ok, _ := AllowAudioSeconds(ctx, r, "dev1", 1700, now); !ok { - t.Fatal("1700s should pass") + // 初始未满 + if full, _ := AudioWindowExhausted(ctx, r, "dev1", now); full { + t.Fatal("fresh window should not be exhausted") } - // 再 100s(累计 1800)放行 - if ok, _ := AllowAudioSeconds(ctx, r, "dev1", 100, now.Add(time.Second)); !ok { - t.Fatal("cumulative 1800s should pass") + // 记满后窗口耗尽(AudioWindowExhausted 只查不记,val=0,sum>limit 才算满) + if err := RecordAudioSeconds(ctx, r, "dev1", 1801, now); err != nil { + t.Fatal(err) } - // 再 1s 超限拒绝 - if ok, _ := AllowAudioSeconds(ctx, r, "dev1", 1, now.Add(2*time.Second)); ok { - t.Fatal("1801s should be rejected") + if full, _ := AudioWindowExhausted(ctx, r, "dev1", now.Add(time.Second)); !full { + t.Fatal("window should be exhausted after exceeding 1800s") + } + // 其他设备不受影响 + if full, _ := AudioWindowExhausted(ctx, r, "dev2", now); full { + t.Fatal("other device window should not be exhausted") } } @@ -78,3 +82,24 @@ func TestDeviceSlot(t *testing.T) { t.Fatal("acquire after release should pass") } } + +// TestRefreshDeviceSlot 仅持有者能续期;非持有者续期无效(17D)。 +func TestRefreshDeviceSlot(t *testing.T) { + r := rdb(t) + ctx := context.Background() + if ok, err := AcquireDeviceSlot(ctx, r, "dev1", "s1"); err != nil || !ok { + t.Fatalf("acquire should pass: %v", err) + } + // 持有者续期成功 + if ok, err := RefreshDeviceSlot(ctx, r, "dev1", "s1"); err != nil || !ok { + t.Fatalf("holder refresh should succeed: ok=%v err=%v", ok, err) + } + // 非持有者续期失败 + if ok, _ := RefreshDeviceSlot(ctx, r, "dev1", "s2"); ok { + t.Fatal("non-holder refresh should fail") + } + // 续期未释放槽位:他人仍抢不到 + if ok, _ := AcquireDeviceSlot(ctx, r, "dev1", "s2"); ok { + t.Fatal("slot should still be held after refresh") + } +} diff --git a/server/internal/user/handlers.go b/server/internal/user/handlers.go index 06dfbc6..1a2eba5 100644 --- a/server/internal/user/handlers.go +++ b/server/internal/user/handlers.go @@ -3,11 +3,8 @@ package user import ( "net/http" - "os" "time" - "encoding/json" - "github.com/gin-gonic/gin" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -21,6 +18,8 @@ import ( type Handlers struct { DB *gorm.DB Quota *quota.Manager + // AppVersions 启动时由 config 解析好的平台→版本信息(17G);nil/空表示未配置。 + AppVersions map[string]protocol.AppLatestResponse } // Me GET /v1/me(需登录):余额/试用/账户态聚合 + 设备心跳登记。 @@ -75,20 +74,10 @@ func (h *Handlers) touchDevice(c *gin.Context, uid string) { } // AppLatest GET /v1/app/latest?platform=mac|win|android|ios 🔓 -// 版本信息来自 APP_LATEST_JSON 环境变量({"mac":{"version":...,"url":...},...}), -// MVP 不建表;未配置平台返回 204。 +// 版本信息来自启动时 config 解析好的 APP_LATEST_JSON(17G,{"mac":{"version":...,"url":...},...}), +// MVP 不建表;未配置/未知平台返回 204。 func (h *Handlers) AppLatest(c *gin.Context) { - raw := os.Getenv("APP_LATEST_JSON") - if raw == "" { - c.Status(http.StatusNoContent) - return - } - var all map[string]protocol.AppLatestResponse - if err := json.Unmarshal([]byte(raw), &all); err != nil { - c.Status(http.StatusNoContent) - return - } - if v, ok := all[c.Query("platform")]; ok { + if v, ok := h.AppVersions[c.Query("platform")]; ok { c.JSON(http.StatusOK, v) return } diff --git a/server/pkg/protocol/ws.go b/server/pkg/protocol/ws.go index bc622b6..7026e32 100644 --- a/server/pkg/protocol/ws.go +++ b/server/pkg/protocol/ws.go @@ -52,10 +52,12 @@ type ServerMsg struct { // partial / final Text string `json:"text,omitempty"` - // usage:识别中每 2s 及 stop 后下发,驱动 UI 实时刷新余额 - SessionSeconds int `json:"session_seconds,omitempty"` - BalanceSeconds int64 `json:"balance_seconds,omitempty"` - TrialRemaining int `json:"trial_remaining,omitempty"` + // usage:识别中每 2s 及 stop 后下发,驱动 UI 实时刷新余额。 + // 去 omitempty(16G):0 是合法值(扣穿 / 试用用尽),客户端须能区分 + // "未下发"与"为 0"。partial/final 帧亦会带这三个 0 字段,客户端以 type 分发,无碍。 + SessionSeconds int `json:"session_seconds"` + BalanceSeconds int64 `json:"balance_seconds"` + TrialRemaining int `json:"trial_remaining"` // error Code string `json:"code,omitempty"` diff --git a/server/run-dev.sh b/server/run-dev.sh index a19c404..7d74a6a 100755 --- a/server/run-dev.sh +++ b/server/run-dev.sh @@ -5,7 +5,7 @@ set -e if rbw get dashscope-api-key >/dev/null 2>&1; then echo "[run-dev] 使用真实 gummy provider" - rbw get dashscope-api-key | { read -r K; DASHSCOPE_API_KEY="$K" exec go run ./cmd/server; } + rbw get dashscope-api-key | { read -r K; ASR_PROVIDER=gummy DASHSCOPE_API_KEY="$K" exec go run ./cmd/server; } else echo "[run-dev] 未取到 dashscope-api-key(rbw 未解锁或条目不存在),使用 mock provider" exec go run ./cmd/server diff --git a/todo/todo.html b/todo/todo.html index 99b74b2..b6332e9 100644 --- a/todo/todo.html +++ b/todo/todo.html @@ -254,10 +254,10 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }

dudu — 项目 TODO

生成于 2026-06-12 · 真相源 todo/todo.json
-
15全部
-
3待开始
+
22全部
+
4待开始
0开发中
-
12待验收
+
18待验收
0已验收
@@ -292,7 +292,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
平台 / 标签 - +
@@ -301,7 +301,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
- 📋 待开始 3 + 📋 待开始 4 ▴ 收起
@@ -462,6 +462,35 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
🕐 2026-06-11 +
+
+ + +
  • +
    + 跨端架构改进 backlog(评审后再做) +
    + 待开始 + 重要 + 一级 + + +
    +
    + +
    本轮不改代码,仅记录待评审:限制常量(180s/30次/1800s)服务端下发、gummy 断连重建分层、三端 WS 连接策略统一、错误码文案服务端下发、web 价格表第三份副本与 SeedPacks 单源、设备准入校验防随机 device_id 绕过限流
    + + +
  • @@ -481,7 +510,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
    - 🔍 待验收 12 + 🔍 待验收 18 ▴ 收起
    @@ -930,6 +959,255 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
    +
  • +
    + 后端·网关与计费正确性修复 +
    + 待验收 + 高优 · 紧急 + 二级 + 7/7 子任务 + +
    +
    + +
    code-review 发现的网关/计费严重 bug:截断或 provider 死亡后仍扣费、WriteJSON 无写超时致 goroutine 泄漏、WaitGroup Add 竞态、finish 漏在途扣费、扣穿当句未掐断致白嫖、GORM v2 锁失效、usage 帧 omitempty 吞合法 0 值
    + +
    +
    + 子任务 + 7 / 7 完成 +
    +
    +
    • + + 16A + feed 先判截断再累计;provider 死亡/截断后终结会话停止扣费 +
      待验收
      +
    • +
    • + + 16B + wsConn 写超时(SetWriteDeadline)+finish 的 Wait 加超时+修 defer 顺序避免 goroutine 泄漏 +
      待验收
      +
    • +
    • + + 16C + resultsDone.Add(1) 移到 go 语句之前,消除 WaitGroup 竞态 +
      待验收
      +
    • +
    • + + 16D + finish 等待在途 consumeDelta 完成后再取 settle 快照,避免漏账 +
      待验收
      +
    • +
    • + + 16E + 扣穿当句不掐断:consumeDelta 扣穿后置标志,本句 final 后下发 QUOTA_EXCEEDED 结束会话 +
      待验收
      +
    • +
    • + + 16F + billing.MarkPaid 改用 GORM v2 Clauses(锁)写法,恢复行锁 +
      待验收
      +
    • +
    • + + 16G + usage 帧 SessionSeconds/BalanceSeconds/TrialRemaining 去掉 omitempty,让 0 显式下发 +
      待验收
      +
    +
    + +
  • + +
  • +
    + 后端·认证限流与依赖装配修复 +
    + 待验收 + 高优 · 紧急 + 二级 + 9/9 子任务 + +
    +
    + +
    JWT 黑名单 fail-open、logout TTL 硬编码 8 天、滑动窗口 seq 键永不过期、设备槽 4min TTL 不续期、pickProvider 永远 mock(gummy 永不装配)、/v1/pay/notify 无鉴权+pickPay 永远 mock=免费充值、metrics/batch 无可选鉴权 user_id 恒空、AppLatest 裸读 env、死代码清理
    + +
    +
    + 子任务 + 9 / 9 完成 +
    +
    +
    • + + 17A + JWT 黑名单检查 fail-closed:Redis Exists 出错时拒绝并记录,不放行 +
      待验收
      +
    • +
    • + + 17B + logout Revoke TTL 用 token 实际过期时间(claims.ExpiresAt),不硬编码 8 天 +
      待验收
      +
    • +
    • + + 17C + 滑动窗口 Lua 给 key:seq 计数器设 EXPIRE,消除永久泄漏键 +
      待验收
      +
    • +
    • + + 17D + 设备槽会话期间续期 TTL(usageLoop 里 EXPIRE),避免长会话槽过期致多路 +
      待验收
      +
    • +
    • + + 17E + pickProvider 真正按 ASR_PROVIDER 装配 gummy(有 key 用 gummy),启动日志打印 real/mock +
      待验收
      +
    • +
    • + + 17F + metrics/batch 挂可选鉴权中间件,注入 user_id +
      待验收
      +
    • +
    • + + 17G + AppLatest 配置进 config.Load 启动校验,避免每请求裸读 env +
      待验收
      +
    • +
    • + + 17H + 清理死代码:AllowAudioSeconds、fmt 占位行、gummy lastText 字段 +
      待验收
      +
    • +
    • + + 17I + pay/notify 鉴权与验签:MockPay 加共享密钥校验或文档标注部署前必换真实验签 +
      待验收
      +
    +
    + +
  • + +
  • +
    + 桌面 Rust 客户端正确性修复 +
    + 待验收 + 高优 · 紧急 + 二级 + 8/8 子任务 + +
    +
    + +
    取消后晚到 final 残留 CommitBuffer 错上下文注入、stop spawn 无 abort/代际校验、350ms 固定延迟改事件驱动、token/device_id parse unwrap panic、重连不重建会话丢音频、UNAUTHORIZED 无处理+unbounded channel 堆积、emit 全局广播 6 窗口、reqwest Client 每次新建、platform=macos 不符约定、check_update 忽略 force
    + +
    +
    + 子任务 + 8 / 8 完成 +
    +
    +
    • + + 18A + dictation 会话代际+取消守卫:取消后晚到 final 不残留、不注入错上下文 +
      待验收
      +
    • +
    • + + 18B + stop spawn 任务加代际校验,快速连说不串文本/不误隐藏新会话浮层 +
      待验收
      +
    • +
    • + + 18C + 收尾注入改事件驱动(收到 stop 后 final/usage 即注入),350ms 仅作超时兜底 +
      待验收
      +
    • +
    • + + 18D + ws.rs token/device_id 用 HeaderValue::from_str 优雅处理非法值,不 unwrap panic +
      待验收
      +
    • +
    • + + 18E + ws.rs 重连后若会话进行中重发 start 或通知客户端会话失败,避免静默丢音频 +
      待验收
      +
    • +
    • + + 18F + ws.rs 处理 UNAUTHORIZED(emit+清 token 引导登录)+音频 channel 改 bounded +
      待验收
      +
    • +
    • + + 18G + asr/hotkey 事件改 emit_to(overlay) 定向,不广播 6 窗口 +
      待验收
      +
    • +
    • + + 18H + reqwest::Client 改 OnceLock 共享实例;metrics platform 改 mac/win;check_update 处理 force +
      待验收
      +
    +
    + +
  • +
  • 🕐 2026-06-11 +
  • + + + +
  • +
    + 桌面前端正确性修复 +
    + 待验收 + 重要 + 二级 + 5/5 子任务 + +
    +
    + +
    登录 QR 不处理 expired+隐藏窗口永久轮询、设置整结构回写覆盖 token 致登出、overlay 错误帧 BAD_REQUEST/INTERNAL 无文案 setError 空、tray/login 窗口主题未初始化、轮询样板收敛 usePoll
    + +
    +
    + 子任务 + 5 / 5 完成 +
    +
    +
    • + + 19A + 登录 QR 轮询处理 expired 自动换码;窗口隐藏(非卸载)时停止轮询 +
      待验收
      +
    • +
    • + + 19B + 设置窗口改增量写(set_settings 仅传改动项),不整结构覆盖 token +
      待验收
      +
    • +
    • + + 19C + overlay 错误帧兜底文案(BAD_REQUEST/INTERNAL/未知码),不 setError 空串 +
      待验收
      +
    • +
    • + + 19D + tray/login 入口补主题初始化(get_settings→setThemePref) +
      待验收
      +
    • +
    • + + 19E + 收敛轮询样板为 shared/usePoll 钩子(存活标志+清理+在途去重) +
      待验收
      +
    +
    + +
  • + +
  • +
    + Android 客户端正确性修复 +
    + 待验收 + 重要 + 二级 + 5/5 子任务 + +
    +
    + +
    onRelease 在 onOpen 前 sendStop 时序错乱致空文本、/v1/me 未发 X-Device-ID 致 touchDevice no-op、错误文案改 server-first、AccountRepository 冗余派生状态简化、KeyboardScreen partial 全屏重组收窄
    + +
    +
    + 子任务 + 5 / 5 完成 +
    +
    +
    • + + 20A + onRelease 等 onOpen 后再 sendStop(或缓冲 stop 待 start 后补发),修握手时序 +
      待验收
      +
    • +
    • + + 20B + /v1/me 请求带 X-Device-ID 头,使 touchDevice 生效 +
      待验收
      +
    • +
    • + + 20C + 错误文案改 server message 优先、本地表仅兜底未知码 +
      待验收
      +
    • +
    • + + 20D + AccountRepository 只存 token/balance/trialUsed/limit,state/trialRemaining 推导 +
      待验收
      +
    • +
    • + + 20E + KeyboardScreen 拆分状态流,partial 只重组 PartialBar +
      待验收
      +
    +
    + +
  • + +
  • +
    + iOS 客户端正确性修复 +
    + 待验收 + 重要 + 二级 + 7/7 子任务 + +
    +
    + +
    finishing 阶段周期 usage 帧误判 stop 结算致丢字、AsrAudioCapture stop 与 tap 回调数据竞争崩溃、usage omitempty 0 值处理、CommitController 死代码删除、180s 本地计时器双轨移除、MetricsQueue 每事件全目录扫描、错误文案 server-first、deep link 剪贴板无条件覆盖
    + +
    +
    + 子任务 + 7 / 7 完成 +
    +
    +
    • + + 21A + finishing 阶段只用收尾标志/final 结束会话,不把周期 usage 帧当 stop 结算致丢字 +
      待验收
      +
    • +
    • + + 21B + AsrAudioCapture stop 与 tap 回调同步(串行队列/锁),消除数据竞争崩溃 +
      待验收
      +
    • +
    • + + 21C + applyUsage 处理 usage 0 值(协议去 omitempty 后按 0 覆盖余额/试用) +
      待验收
      +
    • +
    • + + 21D + 删除 CommitController 死代码(移出键盘 target) +
      待验收
      +
    • +
    • + + 21E + 移除 iOS 180s 本地强制定稿计时器,统一以服务端 SESSION_LIMIT 为准 +
      待验收
      +
    • +
    • + + 21F + MetricsQueue 改近似计数/flush 时 trim,不每事件全目录扫描 +
      待验收
      +
    • +
    • + + 21G + 错误文案 server message 优先;deep link 剪贴板覆盖降级为兜底 +
      待验收
      +
    +
    +
  • @@ -1244,6 +1729,16 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; } let curTier = 'all'; let curTags = new Set(); + // 通过本地服务(http)打开时,按钮直接写库;用 file:// 直接打开则回退为「复制命令」 + const SERVED = location.protocol === 'http:' || location.protocol === 'https:'; + function postAction(url, payload) { + return fetch(url, { + method: 'POST', + headers: payload ? { 'Content-Type': 'application/json' } : undefined, + body: payload ? JSON.stringify(payload) : undefined, + }).then(r => r.json()); + } + function applyFilter() { document.querySelectorAll('.todo-card').forEach(card => { const lvl = card.dataset.level; @@ -1364,8 +1859,13 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; } reasonInput.focus(); return; } - const escaped = reason.replace(/\/g, '\\').replace(/"/g, '\"'); - const cmd = '/todo reject ' + rejectId + ' ' + escaped; + if (SERVED) { + postAction('/api/reject/' + rejectId, { reason }) + .then(d => { if (d.ok) { location.reload(); } else { alert('拒绝失败:' + (d.error || '')); } }) + .catch(() => alert('请求失败,确认本地服务是否在运行')); + return; + } + const cmd = '/todo reject ' + rejectId + ' ' + JSON.stringify(reason); cmdText.textContent = cmd; cmdWrap.style.display = 'block'; document.getElementById('reject-confirm-btn').style.display = 'none'; @@ -1384,12 +1884,23 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; } }); }); - // ── 确认闸:展开命令 + 复制 ── + // ── 确认闸:服务模式直接写库;file:// 模式回退为展开命令 ── document.querySelectorAll('.approve-btn').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); - const box = document.getElementById('approve-cmd-' + btn.dataset.id); - if (box) box.style.display = box.style.display === 'none' ? 'block' : 'none'; + const id = btn.dataset.id; + if (SERVED) { + btn.disabled = true; btn.textContent = '确认中…'; + postAction('/api/approve/' + id) + .then(d => { + if (d.ok) { location.reload(); } + else { btn.disabled = false; btn.textContent = '确认执行 ✓'; alert('确认失败:' + (d.error || '')); } + }) + .catch(() => { btn.disabled = false; btn.textContent = '确认执行 ✓'; alert('请求失败,确认本地服务是否在运行'); }); + } else { + const box = document.getElementById('approve-cmd-' + id); + if (box) box.style.display = box.style.display === 'none' ? 'block' : 'none'; + } }); }); document.querySelectorAll('.approve-copy-btn').forEach(btn => { diff --git a/todo/todo.json b/todo/todo.json index 174a846..accc9fd 100644 --- a/todo/todo.json +++ b/todo/todo.json @@ -1,9 +1,9 @@ { "meta": { "title": "dudu — 项目 TODO", - "updated_at": "2026-06-11T16:34:46.039Z" + "updated_at": "2026-06-11T17:26:45.125Z" }, - "seq": 15, + "seq": 22, "items": [ { "id": 1, @@ -788,6 +788,454 @@ "done": false, "completed_at": null, "version": null + }, + { + "id": 16, + "title": "后端·网关与计费正确性修复", + "desc": "code-review 发现的网关/计费严重 bug:截断或 provider 死亡后仍扣费、WriteJSON 无写超时致 goroutine 泄漏、WaitGroup Add 竞态、finish 漏在途扣费、扣穿当句未掐断致白嫖、GORM v2 锁失效、usage 帧 omitempty 吞合法 0 值", + "level": "high", + "tier": 2, + "tags": [ + "后端", + "数据库" + ], + "status": "done", + "created_at": "2026-06-11T17:15:07.520Z", + "done": false, + "completed_at": null, + "version": null, + "subtasks": [ + { + "sid": "16A", + "title": "feed 先判截断再累计;provider 死亡/截断后终结会话停止扣费", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:15:23.816Z" + }, + { + "sid": "16B", + "title": "wsConn 写超时(SetWriteDeadline)+finish 的 Wait 加超时+修 defer 顺序避免 goroutine 泄漏", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:15:23.886Z" + }, + { + "sid": "16C", + "title": "resultsDone.Add(1) 移到 go 语句之前,消除 WaitGroup 竞态", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:15:23.954Z" + }, + { + "sid": "16D", + "title": "finish 等待在途 consumeDelta 完成后再取 settle 快照,避免漏账", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:15:24.026Z" + }, + { + "sid": "16E", + "title": "扣穿当句不掐断:consumeDelta 扣穿后置标志,本句 final 后下发 QUOTA_EXCEEDED 结束会话", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:15:24.099Z" + }, + { + "sid": "16F", + "title": "billing.MarkPaid 改用 GORM v2 Clauses(锁)写法,恢复行锁", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:15:24.168Z" + }, + { + "sid": "16G", + "title": "usage 帧 SessionSeconds/BalanceSeconds/TrialRemaining 去掉 omitempty,让 0 显式下发", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:15:24.237Z" + } + ] + }, + { + "id": 17, + "title": "后端·认证限流与依赖装配修复", + "desc": "JWT 黑名单 fail-open、logout TTL 硬编码 8 天、滑动窗口 seq 键永不过期、设备槽 4min TTL 不续期、pickProvider 永远 mock(gummy 永不装配)、/v1/pay/notify 无鉴权+pickPay 永远 mock=免费充值、metrics/batch 无可选鉴权 user_id 恒空、AppLatest 裸读 env、死代码清理", + "level": "high", + "tier": 2, + "tags": [ + "后端" + ], + "status": "done", + "created_at": "2026-06-11T17:15:07.591Z", + "done": false, + "completed_at": null, + "version": null, + "subtasks": [ + { + "sid": "17A", + "title": "JWT 黑名单检查 fail-closed:Redis Exists 出错时拒绝并记录,不放行", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:15:37.952Z" + }, + { + "sid": "17B", + "title": "logout Revoke TTL 用 token 实际过期时间(claims.ExpiresAt),不硬编码 8 天", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:15:38.022Z" + }, + { + "sid": "17C", + "title": "滑动窗口 Lua 给 key:seq 计数器设 EXPIRE,消除永久泄漏键", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:15:38.093Z" + }, + { + "sid": "17D", + "title": "设备槽会话期间续期 TTL(usageLoop 里 EXPIRE),避免长会话槽过期致多路", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:15:38.163Z" + }, + { + "sid": "17E", + "title": "pickProvider 真正按 ASR_PROVIDER 装配 gummy(有 key 用 gummy),启动日志打印 real/mock", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:15:38.234Z" + }, + { + "sid": "17F", + "title": "metrics/batch 挂可选鉴权中间件,注入 user_id", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:15:38.306Z" + }, + { + "sid": "17G", + "title": "AppLatest 配置进 config.Load 启动校验,避免每请求裸读 env", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:15:38.377Z" + }, + { + "sid": "17H", + "title": "清理死代码:AllowAudioSeconds、fmt 占位行、gummy lastText 字段", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:15:38.448Z" + }, + { + "sid": "17I", + "title": "pay/notify 鉴权与验签:MockPay 加共享密钥校验或文档标注部署前必换真实验签", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:15:38.521Z" + } + ] + }, + { + "id": 18, + "title": "桌面 Rust 客户端正确性修复", + "desc": "取消后晚到 final 残留 CommitBuffer 错上下文注入、stop spawn 无 abort/代际校验、350ms 固定延迟改事件驱动、token/device_id parse unwrap panic、重连不重建会话丢音频、UNAUTHORIZED 无处理+unbounded channel 堆积、emit 全局广播 6 窗口、reqwest Client 每次新建、platform=macos 不符约定、check_update 忽略 force", + "level": "high", + "tier": 2, + "tags": [ + "mac", + "Windows" + ], + "status": "done", + "created_at": "2026-06-11T17:15:07.663Z", + "done": false, + "completed_at": null, + "version": null, + "subtasks": [ + { + "sid": "18A", + "title": "dictation 会话代际+取消守卫:取消后晚到 final 不残留、不注入错上下文", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:15:53.259Z" + }, + { + "sid": "18B", + "title": "stop spawn 任务加代际校验,快速连说不串文本/不误隐藏新会话浮层", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:15:53.331Z" + }, + { + "sid": "18C", + "title": "收尾注入改事件驱动(收到 stop 后 final/usage 即注入),350ms 仅作超时兜底", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:15:53.400Z" + }, + { + "sid": "18D", + "title": "ws.rs token/device_id 用 HeaderValue::from_str 优雅处理非法值,不 unwrap panic", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:15:53.470Z" + }, + { + "sid": "18E", + "title": "ws.rs 重连后若会话进行中重发 start 或通知客户端会话失败,避免静默丢音频", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:15:53.540Z" + }, + { + "sid": "18F", + "title": "ws.rs 处理 UNAUTHORIZED(emit+清 token 引导登录)+音频 channel 改 bounded", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:15:53.611Z" + }, + { + "sid": "18G", + "title": "asr/hotkey 事件改 emit_to(overlay) 定向,不广播 6 窗口", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:15:53.683Z" + }, + { + "sid": "18H", + "title": "reqwest::Client 改 OnceLock 共享实例;metrics platform 改 mac/win;check_update 处理 force", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:15:53.754Z" + } + ] + }, + { + "id": 19, + "title": "桌面前端正确性修复", + "desc": "登录 QR 不处理 expired+隐藏窗口永久轮询、设置整结构回写覆盖 token 致登出、overlay 错误帧 BAD_REQUEST/INTERNAL 无文案 setError 空、tray/login 窗口主题未初始化、轮询样板收敛 usePoll", + "level": "mid", + "tier": 2, + "tags": [ + "前端" + ], + "status": "done", + "created_at": "2026-06-11T17:15:07.736Z", + "done": false, + "completed_at": null, + "version": null, + "subtasks": [ + { + "sid": "19A", + "title": "登录 QR 轮询处理 expired 自动换码;窗口隐藏(非卸载)时停止轮询", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:16:06.711Z" + }, + { + "sid": "19B", + "title": "设置窗口改增量写(set_settings 仅传改动项),不整结构覆盖 token", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:16:06.784Z" + }, + { + "sid": "19C", + "title": "overlay 错误帧兜底文案(BAD_REQUEST/INTERNAL/未知码),不 setError 空串", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:16:06.858Z" + }, + { + "sid": "19D", + "title": "tray/login 入口补主题初始化(get_settings→setThemePref)", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:16:06.927Z" + }, + { + "sid": "19E", + "title": "收敛轮询样板为 shared/usePoll 钩子(存活标志+清理+在途去重)", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:16:06.996Z" + } + ] + }, + { + "id": 20, + "title": "Android 客户端正确性修复", + "desc": "onRelease 在 onOpen 前 sendStop 时序错乱致空文本、/v1/me 未发 X-Device-ID 致 touchDevice no-op、错误文案改 server-first、AccountRepository 冗余派生状态简化、KeyboardScreen partial 全屏重组收窄", + "level": "mid", + "tier": 2, + "tags": [ + "Android" + ], + "status": "done", + "created_at": "2026-06-11T17:15:07.807Z", + "done": false, + "completed_at": null, + "version": null, + "subtasks": [ + { + "sid": "20A", + "title": "onRelease 等 onOpen 后再 sendStop(或缓冲 stop 待 start 后补发),修握手时序", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:16:07.065Z" + }, + { + "sid": "20B", + "title": "/v1/me 请求带 X-Device-ID 头,使 touchDevice 生效", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:16:07.137Z" + }, + { + "sid": "20C", + "title": "错误文案改 server message 优先、本地表仅兜底未知码", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:16:07.207Z" + }, + { + "sid": "20D", + "title": "AccountRepository 只存 token/balance/trialUsed/limit,state/trialRemaining 推导", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:16:07.279Z" + }, + { + "sid": "20E", + "title": "KeyboardScreen 拆分状态流,partial 只重组 PartialBar", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:16:07.350Z" + } + ] + }, + { + "id": 21, + "title": "iOS 客户端正确性修复", + "desc": "finishing 阶段周期 usage 帧误判 stop 结算致丢字、AsrAudioCapture stop 与 tap 回调数据竞争崩溃、usage omitempty 0 值处理、CommitController 死代码删除、180s 本地计时器双轨移除、MetricsQueue 每事件全目录扫描、错误文案 server-first、deep link 剪贴板无条件覆盖", + "level": "mid", + "tier": 2, + "tags": [ + "iOS" + ], + "status": "done", + "created_at": "2026-06-11T17:15:07.880Z", + "done": false, + "completed_at": null, + "version": null, + "subtasks": [ + { + "sid": "21A", + "title": "finishing 阶段只用收尾标志/final 结束会话,不把周期 usage 帧当 stop 结算致丢字", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:16:21.039Z" + }, + { + "sid": "21B", + "title": "AsrAudioCapture stop 与 tap 回调同步(串行队列/锁),消除数据竞争崩溃", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:16:21.112Z" + }, + { + "sid": "21C", + "title": "applyUsage 处理 usage 0 值(协议去 omitempty 后按 0 覆盖余额/试用)", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:16:21.187Z" + }, + { + "sid": "21D", + "title": "删除 CommitController 死代码(移出键盘 target)", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:16:21.260Z" + }, + { + "sid": "21E", + "title": "移除 iOS 180s 本地强制定稿计时器,统一以服务端 SESSION_LIMIT 为准", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:16:21.336Z" + }, + { + "sid": "21F", + "title": "MetricsQueue 改近似计数/flush 时 trim,不每事件全目录扫描", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:16:21.410Z" + }, + { + "sid": "21G", + "title": "错误文案 server message 优先;deep link 剪贴板覆盖降级为兜底", + "tier": null, + "deps": [], + "status": "done", + "created_at": "2026-06-11T17:16:21.483Z" + } + ] + }, + { + "id": 22, + "title": "跨端架构改进 backlog(评审后再做)", + "desc": "本轮不改代码,仅记录待评审:限制常量(180s/30次/1800s)服务端下发、gummy 断连重建分层、三端 WS 连接策略统一、错误码文案服务端下发、web 价格表第三份副本与 SeedPacks 单源、设备准入校验防随机 device_id 绕过限流", + "level": "mid", + "tier": 1, + "tags": [ + "跨端", + "文档" + ], + "status": "open", + "created_at": "2026-06-11T17:15:07.950Z", + "done": false, + "completed_at": null, + "version": null } ] }