fix: 应用 xhigh 代码评审的跨端修复
来自 xhigh code review 的正确性/健壮性修复,覆盖全部五端: - server:鉴权 fail-closed、计量交叉校验与配额扣穿处理、WS 网关并发与关闭顺序、 billing 行锁、redis Lua 过期与设备槽刷新、config 解析 - desktop:会话 epoch 防串话、WS 重连与 401 处理、api 客户端复用、统一 usePoll 轮询 - android:握手时序、请求头封装、账户状态派生、按需重组 - ios:finalize 宽限、串行采集、错误文案服务端优先、删除死代码 CommitController Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
// 弱网下握手 >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) {
|
||||
|
||||
@@ -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<KeyboardUiState>, 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<KeyboardUiState>) {
|
||||
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<KeyboardUiState>) {
|
||||
val p = LocalDuduPalette.current
|
||||
val willCancel by remember(ui) { derivedStateOf { ui.value.willCancel } }
|
||||
val color = if (willCancel) p.danger else p.text3
|
||||
Row(
|
||||
Modifier
|
||||
|
||||
@@ -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()
|
||||
|
||||
private fun getRequest(path: String, token: String?): Request =
|
||||
Request.Builder().url(base + path).apply {
|
||||
/** 统一附加设备/平台头: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")
|
||||
}.build()
|
||||
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).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 <T> exec(req: Request, parse: (String) -> T): T = withContext(Dispatchers.IO) {
|
||||
val resp = try {
|
||||
|
||||
@@ -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<reqwest::Client> = 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<Value> {
|
||||
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<Value> {
|
||||
#[tauri::command]
|
||||
pub async fn login_qr_create(app: AppHandle) -> Option<Value> {
|
||||
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<Value> {
|
||||
let mut s = store.get();
|
||||
s.token = token.to_string();
|
||||
store.set(s);
|
||||
// 通知 ws 重连携带新 token
|
||||
let _ = app.state::<crate::ws::WsHandle>().tx.send(crate::ws::WsCmd::Reconnect);
|
||||
// 通知 ws 重连携带新 token(bounded channel:用 WsHandle::send 非阻塞投递)
|
||||
let _ = app.state::<crate::ws::WsHandle>().send(crate::ws::WsCmd::Reconnect);
|
||||
}
|
||||
}
|
||||
Some(v)
|
||||
@@ -96,7 +112,7 @@ pub async fn login_qr_poll(app: AppHandle, state: String) -> Option<Value> {
|
||||
#[tauri::command]
|
||||
pub async fn create_order(app: AppHandle, pack_id: String) -> Option<Value> {
|
||||
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::<crate::settings::SettingsStore>().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<u64> {
|
||||
s.split('.')
|
||||
.map(|seg| {
|
||||
seg.chars()
|
||||
.take_while(|c| c.is_ascii_digit())
|
||||
.collect::<String>()
|
||||
.parse::<u64>()
|
||||
.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<Value> {
|
||||
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,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 用系统默认方式打开外部链接。
|
||||
|
||||
@@ -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<crate::audio::Capture>,
|
||||
final_text: String,
|
||||
partial_text: String,
|
||||
started: Instant,
|
||||
got_first_partial: bool,
|
||||
}
|
||||
@@ -31,11 +41,16 @@ pub fn start(app: &AppHandle) {
|
||||
return; // 已暂停使用
|
||||
}
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
// 开新会话代际:递增 epoch,重置 buffer / 丢弃标志 / 收尾信号(18A/18B/18C)。
|
||||
let buf = app.state::<CommitBuffer>();
|
||||
let epoch = buf.begin_session();
|
||||
|
||||
set_tray_tooltip(app, "dudu — 录音中");
|
||||
let _ = app.emit("hotkey", json!({"state": "down"}));
|
||||
emit_overlay(app, "hotkey", json!({"state": "down"}));
|
||||
show_overlay(app);
|
||||
|
||||
let ws = app.state::<crate::ws::WsHandle>().tx.clone();
|
||||
let ws = (*app.state::<crate::ws::WsHandle>()).clone();
|
||||
let _ = ws.send(crate::ws::WsCmd::Start {
|
||||
session_id: session_id.clone(),
|
||||
});
|
||||
@@ -52,7 +67,7 @@ pub fn start(app: &AppHandle) {
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("audio start failed: {e}");
|
||||
let _ = app.emit("asr", json!({"type":"error","code":"AUDIO","message": e}));
|
||||
emit_overlay(app, "asr", json!({"type":"error","code":"AUDIO","message": e}));
|
||||
None
|
||||
}
|
||||
};
|
||||
@@ -69,24 +84,35 @@ pub fn start(app: &AppHandle) {
|
||||
|
||||
*state.inner.lock() = Some(Session {
|
||||
id: session_id,
|
||||
epoch,
|
||||
capture,
|
||||
final_text: String::new(),
|
||||
partial_text: String::new(),
|
||||
started: Instant::now(),
|
||||
got_first_partial: false,
|
||||
});
|
||||
}
|
||||
|
||||
/// 当前是否有进行中的录音会话(18E:ws 重连后据此判断是否需中止会话)。
|
||||
pub fn is_recording(app: &AppHandle) -> bool {
|
||||
app.state::<DictationState>().inner.lock().is_some()
|
||||
}
|
||||
|
||||
pub fn stop(app: &AppHandle, canceled: bool) {
|
||||
let state = app.state::<DictationState>();
|
||||
let Some(mut sess) = state.inner.lock().take() else {
|
||||
return;
|
||||
};
|
||||
let _ = app.emit("hotkey", json!({"state": "up"}));
|
||||
let epoch = sess.epoch;
|
||||
emit_overlay(app, "hotkey", json!({"state": "up"}));
|
||||
set_tray_tooltip(app, "dudu — 就绪");
|
||||
sess.capture.take(); // 停止采集
|
||||
|
||||
let ws = app.state::<crate::ws::WsHandle>().tx.clone();
|
||||
let buf = app.state::<CommitBuffer>();
|
||||
if canceled {
|
||||
// 取消即丢弃:标记该 epoch,后续晚到 final 直接丢弃不入 buffer(18A)。
|
||||
buf.discard(epoch);
|
||||
}
|
||||
|
||||
let ws = (*app.state::<crate::ws::WsHandle>()).clone();
|
||||
let cmd = if canceled {
|
||||
crate::ws::WsCmd::Cancel { session_id: sess.id.clone() }
|
||||
} else {
|
||||
@@ -94,23 +120,31 @@ pub fn stop(app: &AppHandle, canceled: bool) {
|
||||
};
|
||||
let _ = ws.send(cmd);
|
||||
|
||||
// 取尾部 final 完成信号(事件驱动注入,18C)。
|
||||
let finalized = buf.finalize_signal();
|
||||
|
||||
let app = app.clone();
|
||||
let release_at = Instant::now();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if !canceled {
|
||||
// 等网关 flush 尾部 final(句间已实时累计,这里只兜尾部)
|
||||
tokio::time::sleep(Duration::from_millis(350)).await;
|
||||
let committed = {
|
||||
let state = app.state::<DictationState>();
|
||||
let pending = state.inner.lock();
|
||||
drop(pending);
|
||||
// 事件驱动:收到 stop 后的尾部 final(或 usage 结算帧)即触发注入;
|
||||
// 350ms 仅作超时兜底,避免 flush 慢于固定 sleep 截丢尾 final(18C)。
|
||||
tokio::select! {
|
||||
_ = finalized.notified() => {}
|
||||
_ = tokio::time::sleep(Duration::from_millis(350)) => {}
|
||||
}
|
||||
|
||||
let buf = app.state::<CommitBuffer>();
|
||||
buf.take()
|
||||
// 会话代际校验:本任务只处理自己的 epoch,快速连说时旧任务醒来不串新会话(18B)。
|
||||
let Some(committed) = buf.take_for_epoch(epoch) else {
|
||||
// epoch 不匹配(已被新会话覆盖)或已被丢弃:不注入、不 hide(18A/18B)。
|
||||
return;
|
||||
};
|
||||
if !committed.is_empty() {
|
||||
if !crate::inject::accessibility_ok() {
|
||||
// 无辅助功能权限(macOS):注入必失败 → 浮层引导去授权(10E)
|
||||
let _ = app.emit(
|
||||
emit_overlay(
|
||||
&app,
|
||||
"asr",
|
||||
json!({"type":"error","code":"NO_ACCESSIBILITY","message":"需要辅助功能权限"}),
|
||||
);
|
||||
@@ -130,7 +164,8 @@ pub fn stop(app: &AppHandle, canceled: bool) {
|
||||
_ => {
|
||||
log::error!("inject failed: {injected:?}");
|
||||
if !crate::inject::accessibility_ok() {
|
||||
let _ = app.emit(
|
||||
emit_overlay(
|
||||
&app,
|
||||
"asr",
|
||||
json!({"type":"error","code":"NO_ACCESSIBILITY","message":"需要辅助功能权限"}),
|
||||
);
|
||||
@@ -141,34 +176,99 @@ pub fn stop(app: &AppHandle, canceled: bool) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
app.state::<CommitBuffer>().take();
|
||||
// 取消:清空 buffer(仅当仍是本 epoch,避免误清新会话),不上屏(18A)。
|
||||
let buf = app.state::<CommitBuffer>();
|
||||
let _ = buf.take_for_epoch(epoch);
|
||||
}
|
||||
hide_overlay(&app);
|
||||
});
|
||||
}
|
||||
|
||||
/// CommitBuffer 跨 ws 任务与停止流程共享的"待注入文本"。
|
||||
#[derive(Default)]
|
||||
/// CommitBuffer 跨 ws 任务与停止流程共享的"待注入文本"+ 会话代际/丢弃标志/收尾信号。
|
||||
pub struct CommitBuffer {
|
||||
text: Mutex<(String, String)>, // (final 累计, 最新 partial)
|
||||
inner: Mutex<BufferInner>,
|
||||
/// 当前会话代际(18A/18B):start 自增,on_server_msg / 收尾任务据此校验归属。
|
||||
epoch: AtomicU64,
|
||||
/// 尾部 final 到达信号,驱动收尾注入(18C)。
|
||||
finalized: std::sync::Arc<Notify>,
|
||||
}
|
||||
|
||||
struct BufferInner {
|
||||
text: (String, String), // (final 累计, 最新 partial)
|
||||
/// text 归属的会话代际。
|
||||
text_epoch: u64,
|
||||
/// 被取消的会话代际:该 epoch 的后续 final 直接丢弃(18A)。
|
||||
discarded_epoch: Option<u64>,
|
||||
}
|
||||
|
||||
impl Default for CommitBuffer {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
inner: Mutex::new(BufferInner {
|
||||
text: (String::new(), String::new()),
|
||||
text_epoch: 0,
|
||||
discarded_epoch: None,
|
||||
}),
|
||||
epoch: AtomicU64::new(0),
|
||||
finalized: std::sync::Arc::new(Notify::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CommitBuffer {
|
||||
fn take(&self) -> String {
|
||||
let mut t = self.text.lock();
|
||||
let committed = format!("{}{}", t.0, t.1);
|
||||
*t = (String::new(), String::new());
|
||||
committed
|
||||
/// 开新会话:epoch+1,清空文本与丢弃标志,返回新 epoch(18A/18B)。
|
||||
fn begin_session(&self) -> u64 {
|
||||
let epoch = self.epoch.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let mut g = self.inner.lock();
|
||||
g.text = (String::new(), String::new());
|
||||
g.text_epoch = epoch;
|
||||
g.discarded_epoch = None;
|
||||
epoch
|
||||
}
|
||||
|
||||
fn current_epoch(&self) -> u64 {
|
||||
self.epoch.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// 标记某会话被取消,其后续 final 一律丢弃(18A)。
|
||||
fn discard(&self, epoch: u64) {
|
||||
self.inner.lock().discarded_epoch = Some(epoch);
|
||||
}
|
||||
|
||||
/// 收尾信号克隆,供 stop 任务 await(18C)。
|
||||
fn finalize_signal(&self) -> std::sync::Arc<Notify> {
|
||||
self.finalized.clone()
|
||||
}
|
||||
|
||||
/// 取出并清空文本——仅当 buffer 仍属指定 epoch 且未被丢弃时返回(18A/18B)。
|
||||
/// epoch 不匹配(已被新会话覆盖)返回 None,调用方据此放弃注入/hide。
|
||||
fn take_for_epoch(&self, epoch: u64) -> Option<String> {
|
||||
let mut g = self.inner.lock();
|
||||
if g.text_epoch != epoch {
|
||||
return None; // 已被新会话覆盖
|
||||
}
|
||||
if g.discarded_epoch == Some(epoch) {
|
||||
g.text = (String::new(), String::new());
|
||||
return Some(String::new()); // 本会话已取消,返回空(不注入但允许 hide 自身浮层)
|
||||
}
|
||||
let committed = format!("{}{}", g.text.0, g.text.1);
|
||||
g.text = (String::new(), String::new());
|
||||
Some(committed)
|
||||
}
|
||||
}
|
||||
|
||||
/// ws 下行回调:维护文本缓冲 + 首字延迟打点(在 ws.rs 收包处调用)。
|
||||
pub fn on_server_msg(app: &AppHandle, v: &Value) {
|
||||
let buf = app.state::<CommitBuffer>();
|
||||
let cur = buf.current_epoch();
|
||||
match v.get("type").and_then(|t| t.as_str()) {
|
||||
Some("partial") => {
|
||||
if let Some(text) = v.get("text").and_then(|t| t.as_str()) {
|
||||
buf.text.lock().1 = text.to_string();
|
||||
let mut g = buf.inner.lock();
|
||||
// 仅当属于当前会话且未被丢弃才写入(18A/18B)。
|
||||
if g.text_epoch == cur && g.discarded_epoch != Some(cur) {
|
||||
g.text.1 = text.to_string();
|
||||
}
|
||||
}
|
||||
let state = app.state::<DictationState>();
|
||||
let mut guard = state.inner.lock();
|
||||
@@ -185,11 +285,20 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Mutex<Vec<Value>>>,
|
||||
@@ -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,
|
||||
|
||||
+121
-12
@@ -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<WsCmd>,
|
||||
pub tx: mpsc::Sender<WsCmd>,
|
||||
}
|
||||
|
||||
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::<WsCmd>();
|
||||
// bounded channel(18F):容量 CMD_CAP,满时由 WsHandle::send 丢最旧/当前帧并记录。
|
||||
let (tx, mut rx) = mpsc::channel::<WsCmd>(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(),
|
||||
// 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":"登录凭证异常,请重新登录"}),
|
||||
);
|
||||
req.headers_mut()
|
||||
.insert("X-Device-ID", device_id.parse().unwrap());
|
||||
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::<serde_json::Value>(&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::<crate::settings::SettingsStore>();
|
||||
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<WsCmd>) {
|
||||
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":"网络已重连,本次听写中断,请重试"}),
|
||||
);
|
||||
}
|
||||
|
||||
+51
-25
@@ -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 });
|
||||
let live = true;
|
||||
setQrExpired(false);
|
||||
setQr(null);
|
||||
invoke('login_qr_create').then((r) => { if (live && r) setQr(r); });
|
||||
return () => { live = false; };
|
||||
}, [step, qrNonce]);
|
||||
|
||||
// 扫码登录轮询:拿到 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') {
|
||||
clearInterval(timer.current);
|
||||
const m = await invoke('fetch_me');
|
||||
setMe(m);
|
||||
const ps = await invoke('fetch_packs');
|
||||
if (!alive()) return 'stop';
|
||||
setMe(m);
|
||||
setPacks(ps || []);
|
||||
setStep('plans');
|
||||
return 'stop';
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
return () => { stop = true; clearInterval(timer.current); };
|
||||
}, [step]);
|
||||
// 后端 2 分钟 qrTTL 过期后返回 expired:停轮询并提示点击刷新。
|
||||
if (st && st.status === 'expired') {
|
||||
setQrExpired(true);
|
||||
return 'stop';
|
||||
}
|
||||
return undefined;
|
||||
}, 1000, [qr && qr.state], step === 'login' && !!qr && !qrExpired);
|
||||
|
||||
// 支付轮询
|
||||
useEffect(() => {
|
||||
if (step !== 'pay' || !order) return undefined;
|
||||
const t = setInterval(async () => {
|
||||
// 支付轮询:付款成功即停。
|
||||
usePoll(async (alive) => {
|
||||
const st = await invoke('order_status', { orderId: order.order_id });
|
||||
if (!alive()) return 'stop';
|
||||
if (st && st.status === 'paid') {
|
||||
clearInterval(t);
|
||||
setMe(await invoke('fetch_me'));
|
||||
const m = await invoke('fetch_me');
|
||||
if (!alive()) return 'stop';
|
||||
setMe(m);
|
||||
setStep('done');
|
||||
return 'stop';
|
||||
}
|
||||
}, 1000);
|
||||
return () => clearInterval(t);
|
||||
}, [step, order]);
|
||||
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 (
|
||||
<Center>
|
||||
<div style={{ fontSize: 'var(--text-lg)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)' }}>微信扫码登录</div>
|
||||
<QrBox value={qr && qr.qr_url} />
|
||||
<QrBox value={qrExpired ? '二维码已过期' : (qr && qr.qr_url)} />
|
||||
{qrExpired ? (
|
||||
<>
|
||||
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)', textAlign: 'center' }}>二维码已过期,点击刷新重新扫码</div>
|
||||
<Button variant="primary" size="sm" onClick={refreshQr}>刷新二维码</Button>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)', textAlign: 'center' }}>打开微信扫一扫,确认后自动登录</div>
|
||||
)}
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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());
|
||||
}, []);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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: "音频格式不支持")
|
||||
}
|
||||
q.sync {
|
||||
converter = conv
|
||||
outFormat = out
|
||||
buffered.removeAll()
|
||||
firstBufferSeen = false
|
||||
}
|
||||
|
||||
input.removeTap(onBus: 0)
|
||||
// tap 回调在音频渲染线程串行执行;buffer 仅在回调期间有效,
|
||||
@@ -69,19 +75,28 @@ final class AsrAudioCapture {
|
||||
func stop() {
|
||||
guard running else { return }
|
||||
running = false
|
||||
// 先摘 tap,阻止新回调入队;再 q.sync 等在途 consume 跑完后才置 nil
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
engine.stop()
|
||||
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 }
|
||||
var firstSeen = false
|
||||
var frames: [Data] = []
|
||||
q.sync {
|
||||
guard let converter, let outFormat else { return } // stop 已置 nil:丢弃本帧
|
||||
if !firstBufferSeen {
|
||||
firstBufferSeen = true
|
||||
onFirstBuffer?()
|
||||
firstSeen = true
|
||||
}
|
||||
let ratio = outFormat.sampleRate / buf.format.sampleRate
|
||||
let capacity = AVAudioFrameCount(Double(buf.frameLength) * ratio) + 64
|
||||
@@ -101,11 +116,14 @@ final class AsrAudioCapture {
|
||||
guard convErr == nil, out.frameLength > 0, let ch = out.int16ChannelData else { return }
|
||||
buffered.append(Data(bytes: ch[0], count: Int(out.frameLength) * MemoryLayout<Int16>.size))
|
||||
while buffered.count >= DuduProtocol.frameBytes {
|
||||
let frame = Data(buffered.prefix(DuduProtocol.frameBytes))
|
||||
frames.append(Data(buffered.prefix(DuduProtocol.frameBytes)))
|
||||
buffered.removeFirst(DuduProtocol.frameBytes)
|
||||
onFrame?(frame)
|
||||
}
|
||||
}
|
||||
// 回调出口在 q 外触发(避免持锁回调引发的潜在重入 / 死锁)
|
||||
if firstSeen { onFirstBuffer?() }
|
||||
for frame in frames { onFrame?(frame) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WS 客户端(URLSessionWebSocketTask,wss /v1/asr/stream)
|
||||
@@ -250,6 +268,8 @@ final class DictationController: ObservableObject {
|
||||
private var pendingFrames: [Data] = []
|
||||
private var timer: Timer?
|
||||
private var finishFallback: Task<Void, Never>?
|
||||
/// 21A:收尾阶段收到 final 后的短 grace 窗口,容纳连续尾部 final 再定稿
|
||||
private var finalGrace: Task<Void, Never>?
|
||||
|
||||
// 打点(事件名严格对齐 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 内自动上屏)
|
||||
// 21G:主通道 = App Group pending_text(dudu 键盘 120s 内自动上屏,且键盘 consume
|
||||
// 后立即清除,无一致性缝隙)。剪贴板降级为兜底:仅当 App Group 写失败(无签名调试 /
|
||||
// 键盘读不到)时才写剪贴板,避免每次 finalize 无条件污染用户剪贴板、造成重复粘贴。
|
||||
if !PendingTextStore.write(committed) {
|
||||
UIPasteboard.general.string = committed
|
||||
PendingTextStore.write(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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = "<group>"; };
|
||||
571D0A90CFE122E360A515B4 /* MockAsrDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAsrDriver.swift; sourceTree = "<group>"; };
|
||||
5C1B0CEDF3F7F268A48BACB3 /* DuduProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuduProtocol.swift; sourceTree = "<group>"; };
|
||||
6F7D11ACDCB7E8129751AE4F /* CommitController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitController.swift; sourceTree = "<group>"; };
|
||||
8099F56A0503D89BC0AA6B90 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
|
||||
8C524E4A9E51E9AB3C1B493B /* KeyboardExt.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KeyboardExt.entitlements; sourceTree = "<group>"; };
|
||||
9DE2739924D6777B2128D145 /* WaveformView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaveformView.swift; sourceTree = "<group>"; };
|
||||
@@ -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 */,
|
||||
|
||||
@@ -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?("", "")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,20 +73,35 @@ 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 }
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 上报(仅主 App 前台调用;键盘扩展只写队列)
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/// 键盘扩展消费:取走并立即清除(只上屏一次)。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 / 踢出)。
|
||||
@@ -70,6 +82,7 @@ func (j *JWT) Revoke(ctx context.Context, jti string, expiresAt time.Time) error
|
||||
const (
|
||||
CtxUserID = "auth.user_id"
|
||||
CtxJTI = "auth.jti"
|
||||
CtxExpires = "auth.expires_at" // token 自然过期时间(time.Time),供 Logout 拉黑到期用
|
||||
)
|
||||
|
||||
// Middleware gin 鉴权中间件:Authorization: Bearer <JWT>。
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
}
|
||||
s.audioBytes += len(pcm)
|
||||
overCap := s.audioSeconds() >= protocol.MaxSessionSeconds
|
||||
s.mu.Unlock()
|
||||
|
||||
// 先用"若计入本帧"的时长判是否超 180s
|
||||
overCap := (s.audioBytes+len(pcm)+31999)/32000 >= protocol.MaxSessionSeconds
|
||||
if overCap {
|
||||
s.mu.Lock()
|
||||
already := s.truncated
|
||||
// 本帧触发截断:冻结记账(本帧不计入),flush 定稿
|
||||
s.truncated = true
|
||||
s.mu.Unlock()
|
||||
if !already {
|
||||
// 截断:flush 定稿,但保持会话记账状态直至客户端 stop / 连接收尾
|
||||
_ = s.provider.Close()
|
||||
s.ws.sendErr(s.id, protocol.ErrSessionLimit)
|
||||
}
|
||||
return
|
||||
}
|
||||
s.audioBytes += len(pcm)
|
||||
s.mu.Unlock()
|
||||
_ = 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
|
||||
|
||||
@@ -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 帧
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
break
|
||||
}
|
||||
}
|
||||
stop, _ := json.Marshal(protocol.ClientMsg{Type: "stop", SessionID: "s0"})
|
||||
stop, _ := json.Marshal(protocol.ClientMsg{Type: "stop", SessionID: sid})
|
||||
_ = conn.WriteMessage(websocket.TextMessage, stop)
|
||||
// 读到连接收尾的 usage 帧为止
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
|
||||
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.SessionSeconds >= protocol.TrialDailySeconds {
|
||||
if msg.Type == protocol.MsgUsage && msg.TrialRemaining <= 0 {
|
||||
exhausted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
conn.Close()
|
||||
_ = context.Background()
|
||||
if exhausted {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("failed to exhaust trial via repeated sessions")
|
||||
}
|
||||
|
||||
func TestSessionLimitTruncates(t *testing.T) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
// 需登录
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+521
-10
@@ -254,10 +254,10 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<h1>dudu — 项目 TODO</h1>
|
||||
<div class="header-meta">生成于 2026-06-12 · 真相源 todo/todo.json</div>
|
||||
<div class="stats">
|
||||
<div class="stat-pill"><strong>15</strong>全部</div>
|
||||
<div class="stat-pill"><strong>3</strong>待开始</div>
|
||||
<div class="stat-pill"><strong>22</strong>全部</div>
|
||||
<div class="stat-pill"><strong>4</strong>待开始</div>
|
||||
<div class="stat-pill"><strong>0</strong>开发中</div>
|
||||
<div class="stat-pill"><strong>12</strong>待验收</div>
|
||||
<div class="stat-pill"><strong>18</strong>待验收</div>
|
||||
<div class="stat-pill"><strong>0</strong>已验收</div>
|
||||
|
||||
</div>
|
||||
@@ -292,7 +292,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="filter-sep"></div>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">平台 / 标签</span>
|
||||
<button class="filter-chip" data-filter-tag="Android">Android</button><button class="filter-chip" data-filter-tag="CI/CD">CI/CD</button><button class="filter-chip" data-filter-tag="Web">Web</button><button class="filter-chip" data-filter-tag="Windows">Windows</button><button class="filter-chip" data-filter-tag="iOS">iOS</button><button class="filter-chip" data-filter-tag="mac">mac</button><button class="filter-chip" data-filter-tag="前端">前端</button><button class="filter-chip" data-filter-tag="后端">后端</button><button class="filter-chip" data-filter-tag="外部">外部</button><button class="filter-chip" data-filter-tag="数据库">数据库</button><button class="filter-chip" data-filter-tag="跨端">跨端</button>
|
||||
<button class="filter-chip" data-filter-tag="Android">Android</button><button class="filter-chip" data-filter-tag="CI/CD">CI/CD</button><button class="filter-chip" data-filter-tag="Web">Web</button><button class="filter-chip" data-filter-tag="Windows">Windows</button><button class="filter-chip" data-filter-tag="iOS">iOS</button><button class="filter-chip" data-filter-tag="mac">mac</button><button class="filter-chip" data-filter-tag="前端">前端</button><button class="filter-chip" data-filter-tag="后端">后端</button><button class="filter-chip" data-filter-tag="外部">外部</button><button class="filter-chip" data-filter-tag="数据库">数据库</button><button class="filter-chip" data-filter-tag="文档">文档</button><button class="filter-chip" data-filter-tag="跨端">跨端</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -301,7 +301,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
|
||||
<div class="section-block" id="section-open">
|
||||
<div class="section-title st-open" data-toggle="open">
|
||||
📋 待开始 <span class="s-count">3</span>
|
||||
📋 待开始 <span class="s-count">4</span>
|
||||
<span class="s-arrow">▴ 收起</span>
|
||||
</div>
|
||||
<div class="section-list-wrap " id="list-wrap-open">
|
||||
@@ -462,6 +462,35 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="item-meta">
|
||||
<span class="meta-date">🕐 2026-06-11</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-open"
|
||||
data-id="22"
|
||||
data-level="mid"
|
||||
data-status="open"
|
||||
data-tier="1"
|
||||
data-tags="跨端,文档">
|
||||
<div class="card-header">
|
||||
<span class="item-title">跨端架构改进 backlog(评审后再做)</span>
|
||||
<div class="card-badges">
|
||||
<span class="tag status-badge s-open">待开始</span>
|
||||
<span class="tag t-high">重要</span>
|
||||
<span class="tag tier-1">一级</span>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-desc">本轮不改代码,仅记录待评审:限制常量(180s/30次/1800s)服务端下发、gummy 断连重建分层、三端 WS 连接策略统一、错误码文案服务端下发、web 价格表第三份副本与 SeedPacks 单源、设备准入校验防随机 device_id 绕过限流</div>
|
||||
|
||||
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="跨端">跨端</span> <span class="tag t-tag" data-tag="文档">文档</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="meta-date">🕐 2026-06-12</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@@ -481,7 +510,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
</div>
|
||||
<div class="section-block" id="section-done">
|
||||
<div class="section-title st-done" data-toggle="done">
|
||||
🔍 待验收 <span class="s-count">12</span>
|
||||
🔍 待验收 <span class="s-count">18</span>
|
||||
<span class="s-arrow">▴ 收起</span>
|
||||
</div>
|
||||
<div class="section-list-wrap " id="list-wrap-done">
|
||||
@@ -930,6 +959,255 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-done"
|
||||
data-id="16"
|
||||
data-level="high"
|
||||
data-status="done"
|
||||
data-tier="2"
|
||||
data-tags="后端,数据库">
|
||||
<div class="card-header">
|
||||
<span class="item-title">后端·网关与计费正确性修复</span>
|
||||
<div class="card-badges">
|
||||
<span class="tag status-badge s-done">待验收</span>
|
||||
<span class="tag t-block">高优 · 紧急</span>
|
||||
<span class="tag tier-2">二级</span>
|
||||
<span class="tag sub-progress-badge">7/7 子任务</span>
|
||||
<button class="reject-btn" data-id="16" data-title="后端·网关与计费正确性修复">拒绝验收</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-desc">code-review 发现的网关/计费严重 bug:截断或 provider 死亡后仍扣费、WriteJSON 无写超时致 goroutine 泄漏、WaitGroup Add 竞态、finish 漏在途扣费、扣穿当句未掐断致白嫖、GORM v2 锁失效、usage 帧 omitempty 吞合法 0 值</div>
|
||||
|
||||
<div class="subtask-block">
|
||||
<div class="subtask-header">
|
||||
<span class="subtask-label">子任务</span>
|
||||
<span class="subtask-progress-text">7 / 7 完成</span>
|
||||
<div class="subtask-progress-bar"><div class="subtask-progress-fill" style="width:100%"></div></div>
|
||||
</div>
|
||||
<ul class="subtask-list"><li class="subtask-item s-done" data-sid="16A">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">16A</span>
|
||||
<span class="sub-title">feed 先判截断再累计;provider 死亡/截断后终结会话停止扣费</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="16B">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">16B</span>
|
||||
<span class="sub-title">wsConn 写超时(SetWriteDeadline)+finish 的 Wait 加超时+修 defer 顺序避免 goroutine 泄漏</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="16C">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">16C</span>
|
||||
<span class="sub-title">resultsDone.Add(1) 移到 go 语句之前,消除 WaitGroup 竞态</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="16D">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">16D</span>
|
||||
<span class="sub-title">finish 等待在途 consumeDelta 完成后再取 settle 快照,避免漏账</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="16E">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">16E</span>
|
||||
<span class="sub-title">扣穿当句不掐断:consumeDelta 扣穿后置标志,本句 final 后下发 QUOTA_EXCEEDED 结束会话</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="16F">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">16F</span>
|
||||
<span class="sub-title">billing.MarkPaid 改用 GORM v2 Clauses(锁)写法,恢复行锁</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="16G">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">16G</span>
|
||||
<span class="sub-title">usage 帧 SessionSeconds/BalanceSeconds/TrialRemaining 去掉 omitempty,让 0 显式下发</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li></ul>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="数据库">数据库</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="meta-date">🕐 2026-06-12</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-done"
|
||||
data-id="17"
|
||||
data-level="high"
|
||||
data-status="done"
|
||||
data-tier="2"
|
||||
data-tags="后端">
|
||||
<div class="card-header">
|
||||
<span class="item-title">后端·认证限流与依赖装配修复</span>
|
||||
<div class="card-badges">
|
||||
<span class="tag status-badge s-done">待验收</span>
|
||||
<span class="tag t-block">高优 · 紧急</span>
|
||||
<span class="tag tier-2">二级</span>
|
||||
<span class="tag sub-progress-badge">9/9 子任务</span>
|
||||
<button class="reject-btn" data-id="17" data-title="后端·认证限流与依赖装配修复">拒绝验收</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-desc">JWT 黑名单 fail-open、logout TTL 硬编码 8 天、滑动窗口 seq 键永不过期、设备槽 4min TTL 不续期、pickProvider 永远 mock(gummy 永不装配)、/v1/pay/notify 无鉴权+pickPay 永远 mock=免费充值、metrics/batch 无可选鉴权 user_id 恒空、AppLatest 裸读 env、死代码清理</div>
|
||||
|
||||
<div class="subtask-block">
|
||||
<div class="subtask-header">
|
||||
<span class="subtask-label">子任务</span>
|
||||
<span class="subtask-progress-text">9 / 9 完成</span>
|
||||
<div class="subtask-progress-bar"><div class="subtask-progress-fill" style="width:100%"></div></div>
|
||||
</div>
|
||||
<ul class="subtask-list"><li class="subtask-item s-done" data-sid="17A">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">17A</span>
|
||||
<span class="sub-title">JWT 黑名单检查 fail-closed:Redis Exists 出错时拒绝并记录,不放行</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="17B">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">17B</span>
|
||||
<span class="sub-title">logout Revoke TTL 用 token 实际过期时间(claims.ExpiresAt),不硬编码 8 天</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="17C">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">17C</span>
|
||||
<span class="sub-title">滑动窗口 Lua 给 key:seq 计数器设 EXPIRE,消除永久泄漏键</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="17D">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">17D</span>
|
||||
<span class="sub-title">设备槽会话期间续期 TTL(usageLoop 里 EXPIRE),避免长会话槽过期致多路</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="17E">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">17E</span>
|
||||
<span class="sub-title">pickProvider 真正按 ASR_PROVIDER 装配 gummy(有 key 用 gummy),启动日志打印 real/mock</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="17F">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">17F</span>
|
||||
<span class="sub-title">metrics/batch 挂可选鉴权中间件,注入 user_id</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="17G">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">17G</span>
|
||||
<span class="sub-title">AppLatest 配置进 config.Load 启动校验,避免每请求裸读 env</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="17H">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">17H</span>
|
||||
<span class="sub-title">清理死代码:AllowAudioSeconds、fmt 占位行、gummy lastText 字段</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="17I">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">17I</span>
|
||||
<span class="sub-title">pay/notify 鉴权与验签:MockPay 加共享密钥校验或文档标注部署前必换真实验签</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li></ul>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="meta-date">🕐 2026-06-12</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-done"
|
||||
data-id="18"
|
||||
data-level="high"
|
||||
data-status="done"
|
||||
data-tier="2"
|
||||
data-tags="mac,Windows">
|
||||
<div class="card-header">
|
||||
<span class="item-title">桌面 Rust 客户端正确性修复</span>
|
||||
<div class="card-badges">
|
||||
<span class="tag status-badge s-done">待验收</span>
|
||||
<span class="tag t-block">高优 · 紧急</span>
|
||||
<span class="tag tier-2">二级</span>
|
||||
<span class="tag sub-progress-badge">8/8 子任务</span>
|
||||
<button class="reject-btn" data-id="18" data-title="桌面 Rust 客户端正确性修复">拒绝验收</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-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</div>
|
||||
|
||||
<div class="subtask-block">
|
||||
<div class="subtask-header">
|
||||
<span class="subtask-label">子任务</span>
|
||||
<span class="subtask-progress-text">8 / 8 完成</span>
|
||||
<div class="subtask-progress-bar"><div class="subtask-progress-fill" style="width:100%"></div></div>
|
||||
</div>
|
||||
<ul class="subtask-list"><li class="subtask-item s-done" data-sid="18A">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">18A</span>
|
||||
<span class="sub-title">dictation 会话代际+取消守卫:取消后晚到 final 不残留、不注入错上下文</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="18B">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">18B</span>
|
||||
<span class="sub-title">stop spawn 任务加代际校验,快速连说不串文本/不误隐藏新会话浮层</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="18C">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">18C</span>
|
||||
<span class="sub-title">收尾注入改事件驱动(收到 stop 后 final/usage 即注入),350ms 仅作超时兜底</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="18D">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">18D</span>
|
||||
<span class="sub-title">ws.rs token/device_id 用 HeaderValue::from_str 优雅处理非法值,不 unwrap panic</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="18E">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">18E</span>
|
||||
<span class="sub-title">ws.rs 重连后若会话进行中重发 start 或通知客户端会话失败,避免静默丢音频</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="18F">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">18F</span>
|
||||
<span class="sub-title">ws.rs 处理 UNAUTHORIZED(emit+清 token 引导登录)+音频 channel 改 bounded</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="18G">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">18G</span>
|
||||
<span class="sub-title">asr/hotkey 事件改 emit_to(overlay) 定向,不广播 6 窗口</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="18H">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">18H</span>
|
||||
<span class="sub-title">reqwest::Client 改 OnceLock 共享实例;metrics platform 改 mac/win;check_update 处理 force</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li></ul>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="mac">mac</span> <span class="tag t-tag" data-tag="Windows">Windows</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="meta-date">🕐 2026-06-12</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-done"
|
||||
data-id="8"
|
||||
data-level="mid"
|
||||
@@ -1197,6 +1475,213 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="item-meta">
|
||||
<span class="meta-date">🕐 2026-06-11</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-done"
|
||||
data-id="19"
|
||||
data-level="mid"
|
||||
data-status="done"
|
||||
data-tier="2"
|
||||
data-tags="前端">
|
||||
<div class="card-header">
|
||||
<span class="item-title">桌面前端正确性修复</span>
|
||||
<div class="card-badges">
|
||||
<span class="tag status-badge s-done">待验收</span>
|
||||
<span class="tag t-high">重要</span>
|
||||
<span class="tag tier-2">二级</span>
|
||||
<span class="tag sub-progress-badge">5/5 子任务</span>
|
||||
<button class="reject-btn" data-id="19" data-title="桌面前端正确性修复">拒绝验收</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-desc">登录 QR 不处理 expired+隐藏窗口永久轮询、设置整结构回写覆盖 token 致登出、overlay 错误帧 BAD_REQUEST/INTERNAL 无文案 setError 空、tray/login 窗口主题未初始化、轮询样板收敛 usePoll</div>
|
||||
|
||||
<div class="subtask-block">
|
||||
<div class="subtask-header">
|
||||
<span class="subtask-label">子任务</span>
|
||||
<span class="subtask-progress-text">5 / 5 完成</span>
|
||||
<div class="subtask-progress-bar"><div class="subtask-progress-fill" style="width:100%"></div></div>
|
||||
</div>
|
||||
<ul class="subtask-list"><li class="subtask-item s-done" data-sid="19A">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">19A</span>
|
||||
<span class="sub-title">登录 QR 轮询处理 expired 自动换码;窗口隐藏(非卸载)时停止轮询</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="19B">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">19B</span>
|
||||
<span class="sub-title">设置窗口改增量写(set_settings 仅传改动项),不整结构覆盖 token</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="19C">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">19C</span>
|
||||
<span class="sub-title">overlay 错误帧兜底文案(BAD_REQUEST/INTERNAL/未知码),不 setError 空串</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="19D">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">19D</span>
|
||||
<span class="sub-title">tray/login 入口补主题初始化(get_settings→setThemePref)</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="19E">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">19E</span>
|
||||
<span class="sub-title">收敛轮询样板为 shared/usePoll 钩子(存活标志+清理+在途去重)</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li></ul>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="meta-date">🕐 2026-06-12</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-done"
|
||||
data-id="20"
|
||||
data-level="mid"
|
||||
data-status="done"
|
||||
data-tier="2"
|
||||
data-tags="Android">
|
||||
<div class="card-header">
|
||||
<span class="item-title">Android 客户端正确性修复</span>
|
||||
<div class="card-badges">
|
||||
<span class="tag status-badge s-done">待验收</span>
|
||||
<span class="tag t-high">重要</span>
|
||||
<span class="tag tier-2">二级</span>
|
||||
<span class="tag sub-progress-badge">5/5 子任务</span>
|
||||
<button class="reject-btn" data-id="20" data-title="Android 客户端正确性修复">拒绝验收</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-desc">onRelease 在 onOpen 前 sendStop 时序错乱致空文本、/v1/me 未发 X-Device-ID 致 touchDevice no-op、错误文案改 server-first、AccountRepository 冗余派生状态简化、KeyboardScreen partial 全屏重组收窄</div>
|
||||
|
||||
<div class="subtask-block">
|
||||
<div class="subtask-header">
|
||||
<span class="subtask-label">子任务</span>
|
||||
<span class="subtask-progress-text">5 / 5 完成</span>
|
||||
<div class="subtask-progress-bar"><div class="subtask-progress-fill" style="width:100%"></div></div>
|
||||
</div>
|
||||
<ul class="subtask-list"><li class="subtask-item s-done" data-sid="20A">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">20A</span>
|
||||
<span class="sub-title">onRelease 等 onOpen 后再 sendStop(或缓冲 stop 待 start 后补发),修握手时序</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="20B">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">20B</span>
|
||||
<span class="sub-title">/v1/me 请求带 X-Device-ID 头,使 touchDevice 生效</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="20C">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">20C</span>
|
||||
<span class="sub-title">错误文案改 server message 优先、本地表仅兜底未知码</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="20D">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">20D</span>
|
||||
<span class="sub-title">AccountRepository 只存 token/balance/trialUsed/limit,state/trialRemaining 推导</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="20E">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">20E</span>
|
||||
<span class="sub-title">KeyboardScreen 拆分状态流,partial 只重组 PartialBar</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li></ul>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="Android">Android</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="meta-date">🕐 2026-06-12</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-done"
|
||||
data-id="21"
|
||||
data-level="mid"
|
||||
data-status="done"
|
||||
data-tier="2"
|
||||
data-tags="iOS">
|
||||
<div class="card-header">
|
||||
<span class="item-title">iOS 客户端正确性修复</span>
|
||||
<div class="card-badges">
|
||||
<span class="tag status-badge s-done">待验收</span>
|
||||
<span class="tag t-high">重要</span>
|
||||
<span class="tag tier-2">二级</span>
|
||||
<span class="tag sub-progress-badge">7/7 子任务</span>
|
||||
<button class="reject-btn" data-id="21" data-title="iOS 客户端正确性修复">拒绝验收</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-desc">finishing 阶段周期 usage 帧误判 stop 结算致丢字、AsrAudioCapture stop 与 tap 回调数据竞争崩溃、usage omitempty 0 值处理、CommitController 死代码删除、180s 本地计时器双轨移除、MetricsQueue 每事件全目录扫描、错误文案 server-first、deep link 剪贴板无条件覆盖</div>
|
||||
|
||||
<div class="subtask-block">
|
||||
<div class="subtask-header">
|
||||
<span class="subtask-label">子任务</span>
|
||||
<span class="subtask-progress-text">7 / 7 完成</span>
|
||||
<div class="subtask-progress-bar"><div class="subtask-progress-fill" style="width:100%"></div></div>
|
||||
</div>
|
||||
<ul class="subtask-list"><li class="subtask-item s-done" data-sid="21A">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">21A</span>
|
||||
<span class="sub-title">finishing 阶段只用收尾标志/final 结束会话,不把周期 usage 帧当 stop 结算致丢字</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="21B">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">21B</span>
|
||||
<span class="sub-title">AsrAudioCapture stop 与 tap 回调同步(串行队列/锁),消除数据竞争崩溃</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="21C">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">21C</span>
|
||||
<span class="sub-title">applyUsage 处理 usage 0 值(协议去 omitempty 后按 0 覆盖余额/试用)</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="21D">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">21D</span>
|
||||
<span class="sub-title">删除 CommitController 死代码(移出键盘 target)</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="21E">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">21E</span>
|
||||
<span class="sub-title">移除 iOS 180s 本地强制定稿计时器,统一以服务端 SESSION_LIMIT 为准</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="21F">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">21F</span>
|
||||
<span class="sub-title">MetricsQueue 改近似计数/flush 时 trim,不每事件全目录扫描</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li>
|
||||
<li class="subtask-item s-done" data-sid="21G">
|
||||
<span class="sub-icon s-done">✓</span>
|
||||
<span class="sub-sid">21G</span>
|
||||
<span class="sub-title">错误文案 server message 优先;deep link 剪贴板覆盖降级为兜底</span>
|
||||
<div class="sub-badges"><span class="tag status-badge s-done">待验收</span></div>
|
||||
</li></ul>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="iOS">iOS</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="meta-date">🕐 2026-06-12</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@@ -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);
|
||||
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 => {
|
||||
|
||||
+450
-2
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user