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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-13 11:50:08 +08:00
parent 50b49f3cbe
commit b5ab92a57e
42 changed files with 2125 additions and 469 deletions
@@ -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 注入 DuduApiREST 请求带 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 → guestbalance>0 → oktrialRemaining>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()
socket?.sendStop(sessionId)
// 弱网下握手 >300ms 时 start 尚未写出:缓冲 stop,待 onOpen 发完 start 后补发,
// 保证服务端始终收到有序的 start→audio→stop(参考 iOS DictationController pendingFrames)。
if (started) {
socket?.sendStop(sessionId)
} else {
pendingStop = true
}
_state.value = _state.value.copy(recording = false, willCancel = false)
// 收尾兜底:超时仍未收到 final → commit 已有内容
@@ -204,10 +214,19 @@ class KeyboardController(
override fun onOpen() {
scope.launch {
if (phase != Phase.RECORDING) return@launch
// 会话已彻底结束(取消/断连)才忽略;FINALIZINGonOpen 前松开)仍需先发 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 / accountpartial 刷新不进入本作用域。
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 / partialpartial 刷新仅重组 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()
/** 统一附加设备/平台头:X-Device-ID(服务端 touchDevice 依赖)、X-Platform、X-App-Version。 */
private fun Request.Builder.commonHeaders(token: String?): Request.Builder = apply {
if (token != null) header("Authorization", "Bearer $token")
if (deviceId.isNotEmpty()) header("X-Device-ID", deviceId)
header("X-Platform", "android")
header("X-App-Version", BuildConfig.VERSION_NAME)
}
private fun getRequest(path: String, token: String?): Request =
Request.Builder().url(base + path).apply {
if (token != null) header("Authorization", "Bearer $token")
}.build()
Request.Builder().url(base + path).commonHeaders(token).build()
private fun jsonRequest(path: String, token: String?, body: JSONObject): Request =
Request.Builder().url(base + path).apply {
if (token != null) header("Authorization", "Bearer $token")
}.post(body.toString().toRequestBody(JSON)).build()
Request.Builder().url(base + path).commonHeaders(token)
.post(body.toString().toRequestBody(JSON)).build()
private suspend fun <T> exec(req: Request, parse: (String) -> T): T = withContext(Dispatchers.IO) {
val resp = try {