feat(checkin): 签到语义化结果 + 多语言 + 轮换/并发安全

- Repository 改为返回语义化 CheckinOutcome(不再直出硬编码中文),
  UI 经 UiText 按 Locale 解析;补齐三语言文案(含连签/需配置凭据/已签禁用态)
- WorkBuddy authedPost 改 sealed 结果:失败也回传轮换出的新 token,
  多候选路径共享轮换状态,避免旧 refreshToken 单次消费后卡死(永久 401)
- 签到的凭据写入改走 RefreshBalanceUseCase.withCredentialSession,
  按快照校验提交,避免并发改凭据被旧快照覆盖(lost update)
- Trae 仅在实际被服务端接受时才标记 lastVerifiedAt(400 业务失败不标记)
- DEVICE_BLOCKED 改用 Notice 态:不置灰按钮,与「今日已签到」语义区分
- 新增 TraeRepositoryTest(10) / WorkBuddyRepositoryTest(5),纯 JVM 解析用例
This commit is contained in:
Liuxinyu176 2026-09-13 01:33:26 +08:00
parent 3b4cd103d0
commit 085a4d722f
10 changed files with 2754 additions and 2167 deletions

View File

@ -195,13 +195,32 @@ class TraeRepository constructor(
// ---------- 每日签到(checkin_credits)----------
/** 签到动作语义结果(UI 层据此本地化,不直接展示 Repository 中文串)。 */
enum class CheckinOutcome {
/** 签到成功 */
SUCCESS,
/** 今日已签到(账号级,幂等) */
ALREADY,
/** 设备级拦截(提示而非报错) */
DEVICE_BLOCKED,
/** INTL 区签到接口未实测,暂不支持 */
INTL_UNSUPPORTED,
/** 其他失败(服务端给了原因/错误码) */
FAILED
}
/** 签到结果(来源:trae-check 实测字段与语义)。 */
data class CheckinResult(
val success: Boolean,
/** 今日已签(账号级,非设备拦截) */
val alreadyChecked: Boolean,
val points: Long,
val message: String
/** 服务端原始提示文案(仅作 [outcome] 之外的补充透传) */
val message: String,
/** 动作语义结果:UI 按此本地化,不依赖 [message] 语言 */
val outcome: CheckinOutcome,
/** 服务端错误码(outcome=FAILED 时可能有;-1 表示未提供) */
val code: Long = -1L
)
/**
@ -222,7 +241,13 @@ class TraeRepository constructor(
val regionKey = credential.region.uppercase().takeIf { it == "INTL" } ?: "CN"
if (regionKey == "INTL") {
return@withContext Result.success(
CheckinResult(false, false, 0, "INTL 区签到接口暂未支持,请用 CN 区账号")
CheckinResult(
success = false,
alreadyChecked = false,
points = 0,
message = "",
outcome = CheckinOutcome.INTL_UNSUPPORTED
)
)
}
val request = Request.Builder()
@ -251,35 +276,16 @@ class TraeRepository constructor(
return@withContext Result.failure(RepositoryError.Unknown(e))
}
parseCheckin(body)
}
/** 解析 claim 响应(code/message/points 多候选;语义判断见 checkIn 注释)。 */
private fun parseCheckin(body: String): Result<CheckinResult> = runCatching {
val root = json.parseToJsonElement(body).jsonObject
val data = root["data"]?.jsonObject ?: root
val code = data.numberOr("code")?.toLong() ?: root.numberOr("code")?.toLong() ?: -1L
val msg = data.scalarOr("message", "msg") ?: root.scalarOr("message", "msg") ?: ""
val points = (data.numberOr("points") ?: root.numberOr("points"))?.toLong() ?: 0L
val succeeded = code == 0L || code == 200L
val lowerMsg = msg.lowercase()
val deviceBlock = msg.contains("设备") || lowerMsg.contains("device") || lowerMsg.contains("machine")
val already = code == 1001L || (!deviceBlock && (
msg.contains("已签到") || msg.contains("已经签到") || msg.contains("明日再来") ||
msg.contains("今日已完成") || msg.contains("已领取") ||
lowerMsg.contains("already") || lowerMsg.contains("checked") || lowerMsg.contains("claimed")
))
when {
succeeded -> Result.success(
CheckinResult(true, false, points.takeIf { it > 0 } ?: 200L, msg.ifBlank { "签到成功" })
)
already -> Result.success(CheckinResult(true, true, 0L, "今日已签到"))
deviceBlock -> Result.success(CheckinResult(false, false, 0L, msg.ifBlank { "该设备今日签到次数已达上限" }))
else -> Result.success(CheckinResult(false, false, 0L, msg.ifBlank { "签到失败(code=" + code + ")" }))
// 仅「凭据被服务端接受」时才算已验证:SUCCESS/ALREADY/DEVICE_BLOCKED 都说明
// JWT 有效(401/403 已在上面提前返回);FAILED(含 HTTP 400 业务失败)不标记,
// 避免把无效凭据误标为已验证。
val parsed = parseCheckin(body)
val outcome = parsed.getOrNull()?.outcome
if (outcome != null && outcome != CheckinOutcome.FAILED) {
credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis()))
}
}.getOrElse { Result.failure(RepositoryError.Unknown(it)) }
parsed
}
/** 由 JWT 派生稳定伪 deviceId:同账号恒定、不同账号不同(防 claim 设备级去重误伤)。 */
private fun stableDeviceId(seed: String): String = runCatching {
@ -288,13 +294,6 @@ class TraeRepository constructor(
digest.take(12).joinToString("") { "%02x".format(it) }
}.getOrDefault("rainytoken-device")
private fun JsonObject.scalarOr(vararg keys: String): String? {
for (key in keys) {
this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }?.let { return it }
}
return null
}
private fun mapHttpError(code: Int): RepositoryError = when (code) {
401, 403 -> RepositoryError.InvalidCredential()
429 -> RepositoryError.RateLimited()
@ -315,4 +314,75 @@ class TraeRepository constructor(
val totalQuota: Double?,
val extras: Map<String, String>
)
companion object {
/** 解析专用 Json(companion 内独立持有,使 parseCheckin 可静态调用/单测)。 */
private val parseJson = Json { ignoreUnknownKeys = true }
/**
* 解析 claim 响应(code/message/points 多候选)。
*
* 语义判定(实测口径):
* - code 0/200 → SUCCESS;code 1001 或文案含「已签到/already/claimed」→ ALREADY;
* - 文案含「设备/device/machine」→ DEVICE_BLOCKED(设备级拦截,优先级高于 already,避免误报已签);
* - 其余 → FAILED 并回传 code。
*
* 放在 companion 内以便单测直接调用(不依赖 OkHttp/凭据)。
*/
internal fun parseCheckin(body: String): Result<CheckinResult> = runCatching {
val root = parseJson.parseToJsonElement(body).jsonObject
val data = root["data"]?.jsonObject ?: root
val code = data.numberOr("code")?.toLong() ?: root.numberOr("code")?.toLong() ?: -1L
val msg = data.scalarOr("message", "msg") ?: root.scalarOr("message", "msg") ?: ""
val points = (data.numberOr("points") ?: root.numberOr("points"))?.toLong() ?: 0L
val succeeded = code == 0L || code == 200L
val lowerMsg = msg.lowercase()
val deviceBlock = msg.contains("设备") || lowerMsg.contains("device") || lowerMsg.contains("machine")
val already = code == 1001L || (!deviceBlock && (
msg.contains("已签到") || msg.contains("已经签到") || msg.contains("明日再来") ||
msg.contains("今日已完成") || msg.contains("已领取") ||
lowerMsg.contains("already") || lowerMsg.contains("checked") || lowerMsg.contains("claimed")
))
when {
succeeded -> Result.success(
CheckinResult(
success = true,
alreadyChecked = false,
points = points.takeIf { it > 0 } ?: 200L,
message = msg,
outcome = CheckinOutcome.SUCCESS
)
)
already -> Result.success(
CheckinResult(true, true, 0L, msg, CheckinOutcome.ALREADY)
)
deviceBlock -> Result.success(
CheckinResult(false, false, 0L, msg, CheckinOutcome.DEVICE_BLOCKED)
)
else -> Result.success(
CheckinResult(false, false, 0L, msg, CheckinOutcome.FAILED, code)
)
}
}.getOrElse { Result.failure(RepositoryError.Unknown(it)) }
private fun JsonObject.scalarOr(vararg keys: String): String? {
for (key in keys) {
this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }?.let { return it }
}
return null
}
/** 平铺多候选数值扫描。 */
private fun JsonObject.numberOr(vararg keys: String): Double? {
for (key in keys) {
this[key]?.let { el ->
(el as? JsonPrimitive)?.doubleOrNull?.let { return it }
(el as? JsonPrimitive)?.contentOrNull?.toDoubleOrNull()?.let { return it }
}
}
return null
}
}
}

View File

@ -282,6 +282,18 @@ class WorkBuddyRepository constructor(
// ---------- 每日签到(Buddy 加油站 / codebuddy.cn)----------
/** 签到动作语义结果(UI 层据此本地化,不直接展示 Repository 中文串)。 */
enum class CheckinOutcome {
/** 查询模式:只读状态,无动作结果 */
QUERY,
/** 签到成功 */
SUCCESS,
/** 今日已签到(幂等) */
ALREADY,
/** 签到失败(服务端给了原因,见 message) */
FAILED
}
/**
* 签到状态摘要(来源:cpa-multi-plugins workbuddy 插件实测字段)。
* active=活动进行中;todayCheckedIn=今日已签;streakDays=连签;daily/todayCredit=每日/今日积分。
@ -293,8 +305,10 @@ class WorkBuddyRepository constructor(
val dailyCredit: Long,
val todayCredit: Long,
val activityName: String,
/** 签到动作后的提示(签到成功/今日已签);查询模式为空串 */
val message: String = ""
/** 服务端原始提示文案(可能是中文/英文;仅作 [outcome] 之外的补充透传) */
val message: String = "",
/** 动作语义结果:UI 按此本地化,不依赖 [message] 语言 */
val outcome: CheckinOutcome = CheckinOutcome.QUERY
)
/** 查询今日签到状态。uid 未知时省略 X-User-Id 头(源码确认该头可选)。 */
@ -305,9 +319,12 @@ class WorkBuddyRepository constructor(
return@withContext Result.failure(RepositoryError.InvalidCredential())
}
// 两条候选路径 fallback(cpa-plugin 实测:activity-status 优先,旧路径 checkin-status 兜底)
val body = authedPost(credential, "/v2/billing/meter/checkin-activity-status")
?: authedPost(credential, "/v2/billing/meter/checkin-status")
?: return@withContext Result.failure(RepositoryError.Unknown())
// 共享轮换状态:前一条路径轮换出的新 token 会用于下一条,且失败也先落库。
val body = authedPostFirstSuccessful(
credential,
"/v2/billing/meter/checkin-activity-status",
"/v2/billing/meter/checkin-status"
) ?: return@withContext Result.failure(RepositoryError.Unknown())
runCatching { parseCheckin(body) }.fold(
onSuccess = { Result.success(it) },
onFailure = {
@ -331,8 +348,20 @@ class WorkBuddyRepository constructor(
if (credential !is Credential.WorkBuddyCredential) {
return@withContext Result.failure(RepositoryError.InvalidCredential())
}
val body = authedPost(credential, "/v2/billing/meter/daily-checkin")
?: return@withContext Result.failure(RepositoryError.Unknown())
val body = when (val r = authedPost(credential, "/v2/billing/meter/daily-checkin")) {
is AuthedPostResult.Ok -> {
persistCheckinOutcome(credential, r.rotatedCredential)
r.body
}
is AuthedPostResult.Failed -> {
// 即便签到请求失败,也必须先落库轮换出的新 token,否则账号卡在死 token 上;
// 但未发生轮换(纯网络失败)时不写入——避免把失败请求误标为「刚验证通过」。
if (r.rotatedCredential != null) {
persistCheckinOutcome(credential, r.rotatedCredential)
}
return@withContext Result.failure(RepositoryError.Unknown())
}
}
val code = runCatching {
val root = json.parseToJsonElement(body).jsonObject
val obj = root["data"]?.jsonObject ?: root
@ -341,26 +370,60 @@ class WorkBuddyRepository constructor(
val status = fetchCheckinStatus().getOrNull()
when {
code == 0L -> Result.success(
status?.copy(message = "签到成功 +" + (status.todayCredit.takeIf { it > 0 } ?: status.dailyCredit) + " 积分")
?: CheckinStatus(true, true, 1, 0, 0, "", "签到成功")
(status ?: CheckinStatus(true, true, 1, 0, 0, "")).copy(
outcome = CheckinOutcome.SUCCESS
)
)
code == 10001L -> Result.success(
(status ?: CheckinStatus(true, true, 0, 0, 0, "")).copy(
outcome = CheckinOutcome.ALREADY
)
)
code == 10001L -> Result.success(status?.copy(message = "今日已签到") ?: CheckinStatus(true, true, 0, 0, 0, "", "今日已签到"))
else -> {
val msg = runCatching {
val root = json.parseToJsonElement(body).jsonObject
val obj = root["data"]?.jsonObject ?: root
obj.scalar("msg", "message", "Message") ?: ""
}.getOrDefault("")
if (status != null) Result.success(status.copy(message = if (msg.isBlank()) "签到失败" else msg))
else Result.failure(RepositoryError.Unknown())
if (status != null) {
Result.success(status.copy(message = msg, outcome = CheckinOutcome.FAILED))
} else {
Result.failure(RepositoryError.Unknown())
}
}
}
}
/** 带自动 refresh 一次的 POST(Bearer);返回响应体,失败 null。 */
private fun authedPost(credential: Credential.WorkBuddyCredential, path: String): String? {
/**
* [authedPost] 的结果。
*
* [rotatedCredential] 一旦非空,表示服务端已作废旧 accessToken(新的 access/refresh
* 已轮换),调用方**必须**先落库再决定后续动作——即使本次请求最终失败,也不能丢弃它,
* 否则旧 refreshToken 单次消费后失效,账号会卡在死 token 上(永久 401)。
*/
private sealed interface AuthedPostResult {
val rotatedCredential: Credential.WorkBuddyCredential?
/** 请求成功(2xx),[body] 为响应体。 */
data class Ok(
val body: String,
override val rotatedCredential: Credential.WorkBuddyCredential?
) : AuthedPostResult
/** 请求失败,但过程中发生过 token 轮换(新凭据必须落库)。 */
data class Failed(
override val rotatedCredential: Credential.WorkBuddyCredential?
) : AuthedPostResult
}
/**
* 带自动 refresh 一次的 POST(Bearer)。
* 失败时若已发生轮换,会以 [AuthedPostResult.Failed] 回传新凭据(不丢轮换结果)。
*/
private fun authedPost(credential: Credential.WorkBuddyCredential, path: String): AuthedPostResult {
var access = credential.accessToken
var refresh = credential.refreshToken
var rotated: Credential.WorkBuddyCredential? = null
repeat(2) { attempt ->
val request = Request.Builder()
.url("https://www.codebuddy.cn" + path)
@ -372,44 +435,72 @@ class WorkBuddyRepository constructor(
val resp = try {
okHttpClient.newCall(request).execute()
} catch (_: Throwable) {
return null
return AuthedPostResult.Failed(rotated)
}
val code = resp.code
val body = runCatching { resp.body?.string().orEmpty() }.getOrDefault("")
resp.close()
when {
code in 200..299 -> return body
code in 200..299 -> return AuthedPostResult.Ok(body, rotated)
(code == 401 || code == 403) && attempt == 0 -> {
val refreshed = tryRefesh(refresh) ?: return null
// 轮换后的凭据由调用方主流程 save(此处仅内存内继续),避免半途改库
val refreshed = tryRefesh(refresh) ?: return AuthedPostResult.Failed(rotated)
val expiresAt = if (refreshed.expiresIn > 0) {
System.currentTimeMillis() + refreshed.expiresIn * 1000
} else {
credential.expiresAt.coerceAtLeast(System.currentTimeMillis())
}
rotated = credential.copy(
accessToken = refreshed.accessToken,
refreshToken = refreshed.refreshToken,
expiresAt = expiresAt
)
access = refreshed.accessToken
refresh = refreshed.refreshToken
}
else -> return null
else -> return AuthedPostResult.Failed(rotated)
}
}
return AuthedPostResult.Failed(rotated)
}
/**
* 候选路径 fallback 专用:依次尝试各路径,但**同一次查询内共享轮换状态**——
* 若前一条路径已轮换 token,后续路径必须用新 accessToken 请求,并把轮换结果
* 落库后再返回,避免旧 token 重试直接失败。
*/
private suspend fun authedPostFirstSuccessful(
credential: Credential.WorkBuddyCredential,
vararg paths: String
): String? {
var current = credential
for (path in paths) {
when (val r = authedPost(current, path)) {
is AuthedPostResult.Ok -> {
persistCheckinOutcome(credential, r.rotatedCredential)
return r.body
}
is AuthedPostResult.Failed -> {
// 即便本路径失败,也要先把轮换出的新 token 落库并继续用新 token 试下一条
if (r.rotatedCredential != null) {
persistCheckinOutcome(credential, r.rotatedCredential)
current = r.rotatedCredential
}
}
}
}
return null
}
/** 解析 checkin-activity-status 响应(data 平铺或 data 包裹,防御式多候选)。 */
private fun parseCheckin(body: String): CheckinStatus {
val root = json.parseToJsonElement(body).jsonObject
val obj = root["data"]?.jsonObject ?: root
return CheckinStatus(
active = obj.scalar("active", "Active")?.toBoolean() ?: true,
todayCheckedIn = obj.scalar("today_checked_in", "todayCheckedIn")?.toBoolean() ?: false,
streakDays = obj.longOr("streak_days", "streakDays") ?: 0,
dailyCredit = obj.longOr("daily_credit", "dailyCredit") ?: 0,
todayCredit = obj.longOr("today_credit", "todayCredit") ?: 0,
activityName = obj.scalar("activity_name", "activityName", "theme_name") ?: ""
)
}
private fun JsonObject.longOr(vararg keys: String): Long? {
for (key in keys) {
this[key]?.jsonPrimitive?.contentOrNull?.toLongOrNull()?.let { return it }
}
return null
/**
* 落库本次签到/状态查询产出:轮换后的 token(若有)+ lastVerifiedAt。
* 与 [fetchBalance] 同款语义——一次成功请求即视为凭据已验证。
*/
private suspend fun persistCheckinOutcome(
credential: Credential.WorkBuddyCredential,
rotated: Credential.WorkBuddyCredential?
) {
val base = rotated ?: credential
credentialRepository.save(base.copy(lastVerifiedAt = System.currentTimeMillis()))
}
private fun mapHttpError(code: Int, body: String): RepositoryError = when (code) {
@ -433,4 +524,40 @@ class WorkBuddyRepository constructor(
val unit: String?,
val extras: Map<String, String>
)
companion object {
/** 解析专用 Json(companion 内独立持有,使 parseCheckin 可静态调用/单测)。 */
private val parseJson = Json { ignoreUnknownKeys = true }
/**
* 解析 checkin 响应(data 平铺或 data 包裹,防御式多候选)。
* 放在 companion 内以便单测直接调用(不依赖 OkHttp/凭据)。
*/
internal fun parseCheckin(body: String): CheckinStatus {
val root = parseJson.parseToJsonElement(body).jsonObject
val obj = root["data"]?.jsonObject ?: root
return CheckinStatus(
active = obj.scalar("active", "Active")?.toBoolean() ?: true,
todayCheckedIn = obj.scalar("today_checked_in", "todayCheckedIn")?.toBoolean() ?: false,
streakDays = obj.longOr("streak_days", "streakDays") ?: 0,
dailyCredit = obj.longOr("daily_credit", "dailyCredit") ?: 0,
todayCredit = obj.longOr("today_credit", "todayCredit") ?: 0,
activityName = obj.scalar("activity_name", "activityName", "theme_name") ?: ""
)
}
private fun JsonObject.scalar(vararg keys: String): String? {
for (key in keys) {
this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }?.let { return it }
}
return null
}
private fun JsonObject.longOr(vararg keys: String): Long? {
for (key in keys) {
this[key]?.jsonPrimitive?.contentOrNull?.toLongOrNull()?.let { return it }
}
return null
}
}
}

View File

@ -92,6 +92,29 @@ class RefreshBalanceUseCase @Inject constructor(
ollamaRepositoryProvider.get().triggerUsage(model)
}
/**
* 执行每日签到(TRAE / WORKBUDDY),返回各自的语义结果类型。
*
* 必须走 [withCredentialSession]:签到会触发 OAuth 轮换(WorkBuddy 401/403 时刷新
* access/refresh),轮换结果需按凭据快照校验后提交,避免并发改凭据被旧快照覆盖;
* 同时会话内重试才能读到已轮换的新 token(否则旧 refreshToken 单次消费后直接失效)。
*/
suspend fun checkInWorkBuddy(): Result<WorkBuddyRepository.CheckinStatus> =
withCredentialSession(ServiceType.WORKBUDDY) {
workBuddyRepositoryProvider.get().checkIn()
}
suspend fun checkInTrae(): Result<TraeRepository.CheckinResult> =
withCredentialSession(ServiceType.TRAE) {
traeRepositoryProvider.get().checkIn()
}
/** 查询今日签到状态(仅 WORKBUDDY 有该接口)。走会话以保证状态查询的凭据写入不覆盖并发编辑。 */
suspend fun fetchCheckinStatus(): Result<WorkBuddyRepository.CheckinStatus> =
withCredentialSession(ServiceType.WORKBUDDY) {
workBuddyRepositoryProvider.get().fetchCheckinStatus()
}
private suspend fun <T> withCredentialSession(
service: ServiceType,
block: suspend () -> Result<T>

View File

@ -117,6 +117,8 @@ fun ServiceDetailScreen(
val selectedModel by viewModel.selectedModel.collectAsStateWithLifecycle()
val modelsLoading by viewModel.modelsLoading.collectAsStateWithLifecycle()
val checkinState by viewModel.checkinState.collectAsStateWithLifecycle()
val checkinInfo by viewModel.checkinInfo.collectAsStateWithLifecycle()
// 面板管理(Key/分组/兑换)已移到首页 Sub2API 卡片的「面板管理」入口,
// 详情页只保留用量明细所需的窗口统计状态。
val panelWindowStats by viewModel.panelWindowStats.collectAsStateWithLifecycle()
@ -193,11 +195,18 @@ fun ServiceDetailScreen(
ServiceType.TRAE,
ServiceType.WORKBUDDY -> {
// 每日签到卡(WorkBuddy/Trae 共用);下方仍展示通用 extras
// 未配置凭据时展示引导卡,避免暴露必然失败的签到按钮
item {
CheckinCard(
state = checkinState,
onCheckin = { viewModel.checkin() }
)
if (uiState.hasCredential) {
CheckinCard(
state = checkinState,
todayCheckedIn = checkinInfo?.todayCheckedIn == true,
streakDays = checkinInfo?.streakDays ?: 0L,
onCheckin = { viewModel.checkin() }
)
} else {
CheckinNeedCredentialCard()
}
}
item { GenericExtrasCard(uiState.state) }
}
@ -1098,7 +1107,13 @@ private fun BreakdownRow(label: String, value: Double, unit: String) {
* 结果消息直接采用官方响应语义(签到成功/今日已签到/设备拦截/失败原因)。
*/
@Composable
private fun CheckinCard(state: CheckinState, onCheckin: () -> Unit) {
private fun CheckinCard(
state: CheckinState,
todayCheckedIn: Boolean,
streakDays: Long,
onCheckin: () -> Unit
) {
val actionDone = todayCheckedIn || state is CheckinState.Done
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
@ -1120,27 +1135,48 @@ private fun CheckinCard(state: CheckinState, onCheckin: () -> Unit) {
Spacer(modifier = Modifier.height(12.dp))
when (state) {
is CheckinState.Done -> Text(
text = state.message,
text = state.message.asString(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
is CheckinState.Error -> Text(
text = state.message,
text = state.message.asString(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
else -> Unit
// 提示态(设备级拦截等):中性色,且不置灰按钮
is CheckinState.Notice -> Text(
text = state.message.asString(),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
// 尚未点过按钮但预取到「今日已签」:给出连签提示
else -> if (todayCheckedIn) {
Text(
text = if (streakDays > 0) {
stringResource(R.string.checkin_already_streak, streakDays)
} else {
stringResource(R.string.checkin_already)
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
}
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = onCheckin,
enabled = state !is CheckinState.Loading,
enabled = state !is CheckinState.Loading && !actionDone,
modifier = Modifier.fillMaxWidth().height(44.dp),
shape = RoundedCornerShape(12.dp)
) {
Text(
text = stringResource(
if (state is CheckinState.Loading) R.string.checkin_loading else R.string.checkin_action
when {
state is CheckinState.Loading -> R.string.checkin_loading
actionDone -> R.string.checkin_action_done
else -> R.string.checkin_action
}
)
)
}
@ -1148,6 +1184,31 @@ private fun CheckinCard(state: CheckinState, onCheckin: () -> Unit) {
}
}
/** 未配置凭据时的签到占位卡:引导去配置,而不是直接暴露必然失败的签到按钮。 */
@Composable
private fun CheckinNeedCredentialCard() {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(
text = stringResource(R.string.checkin_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = stringResource(R.string.checkin_need_credential),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
}
}
}
/**
* 通用明细卡:把 balance.extras 中的友好键逐行展示(Trae 权益包 / WorkBuddy 资源 /
* Sub2API 账户等)。技术键(models / reset* / raw 等)一律过滤。

View File

@ -7,6 +7,8 @@ import com.rainy.token.data.cache.CachedBalance
import com.rainy.token.data.debug.DebugLog
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.data.repository.RepositoryError
import com.rainy.token.data.repository.TraeRepository
import com.rainy.token.data.repository.WorkBuddyRepository
import com.rainy.token.data.repository.TriggerError
import com.rainy.token.data.repository.TriggerErrorReason
import com.rainy.token.domain.model.Credential
@ -33,8 +35,6 @@ class ServiceDetailViewModel @Inject constructor(
private val credentialRepository: CredentialRepository,
private val balanceCache: BalanceCache,
private val refreshBalanceUseCase: RefreshBalanceUseCase,
private val workBuddyRepository: com.rainy.token.data.repository.WorkBuddyRepository,
private val traeRepository: com.rainy.token.data.repository.TraeRepository,
private val sub2ApiRepository: com.rainy.token.data.repository.Sub2ApiRepository
) : ViewModel() {
@ -58,7 +58,33 @@ class ServiceDetailViewModel @Inject constructor(
private val _checkinState = MutableStateFlow<CheckinState>(CheckinState.Idle)
val checkinState: StateFlow<CheckinState> = _checkinState.asStateFlow()
/** 触发每日签到:仅 WORKBUDDY / TRAE 有效;结果消息来自官方响应语义。 */
/**
* 进入详情页时预取的「今日签到信息」(WorkBuddy)。
* null=未知/未加载;用于展示「今日已签到(连签 N 天)」并禁用按钮,避免重复点击。
*/
private val _checkinInfo = MutableStateFlow<CheckinInfo?>(null)
val checkinInfo: StateFlow<CheckinInfo?> = _checkinInfo.asStateFlow()
/** 今日签到信息摘要(仅字段已确认的才填)。 */
data class CheckinInfo(val todayCheckedIn: Boolean, val streakDays: Long)
/** 进入详情页时预取签到状态:仅 WORKBUDDY 支持状态查询接口,TRAE 无该接口。 */
private fun preloadCheckinInfo(service: ServiceType) {
_checkinInfo.value = null
if (service != ServiceType.WORKBUDDY) return
val generation = serviceGeneration
viewModelScope.launch {
// 走 RefreshBalanceUseCase 会话:状态查询也会触发 OAuth 轮换,需按快照校验提交
val status = refreshBalanceUseCase.fetchCheckinStatus().getOrNull() ?: return@launch
if (generation != serviceGeneration || _serviceType.value != service) return@launch
_checkinInfo.value = CheckinInfo(
todayCheckedIn = status.todayCheckedIn,
streakDays = status.streakDays
)
}
}
/** 触发每日签到:仅 WORKBUDDY / TRAE 有效;结果按语义本地化(不直接显示 Repository 中文串)。 */
fun checkin() {
val service = _serviceType.value
if (service != ServiceType.WORKBUDDY && service != ServiceType.TRAE) return
@ -67,23 +93,75 @@ class ServiceDetailViewModel @Inject constructor(
val generation = serviceGeneration
viewModelScope.launch {
val result = when (service) {
ServiceType.WORKBUDDY -> workBuddyRepository.checkIn().fold(
ServiceType.WORKBUDDY -> refreshBalanceUseCase.checkInWorkBuddy().fold(
onSuccess = { s ->
CheckinState.Done(
s.message.ifBlank {
if (s.todayCheckedIn) "今日已签到(连签 " + s.streakDays + " 天)" else "签到成功"
when (s.outcome) {
WorkBuddyRepository.CheckinOutcome.ALREADY ->
// 已签时带上连签天数(接口已回传,别浪费)
CheckinState.Done(
if (s.streakDays > 0) {
UiText.Resource(R.string.checkin_already_streak, listOf(s.streakDays))
} else {
UiText.Resource(R.string.checkin_already)
}
)
WorkBuddyRepository.CheckinOutcome.SUCCESS -> {
val credits = s.todayCredit.takeIf { it > 0 } ?: s.dailyCredit
CheckinState.Done(
if (credits > 0) {
UiText.Resource(R.string.checkin_wb_success_credits, listOf(credits))
} else {
UiText.Resource(R.string.checkin_success)
}
)
}
)
WorkBuddyRepository.CheckinOutcome.FAILED ->
// 失败原因优先透传服务端原文(多为官方中文提示),缺省用通用失败文案
CheckinState.Error(
if (s.message.isNotBlank()) UiText.Dynamic(s.message)
else UiText.Resource(R.string.checkin_failed)
)
// QUERY 不该出现在签到动作结果里,兜底为通用成功
WorkBuddyRepository.CheckinOutcome.QUERY ->
CheckinState.Done(UiText.Resource(R.string.checkin_success))
}
},
onFailure = { e -> CheckinState.Error(e.message ?: "签到失败,请检查凭据") }
// RepositoryError.message 是硬编码中文,必须走 errorMessage() 本地化映射
onFailure = { e -> CheckinState.Error(errorMessage(e)) }
)
ServiceType.TRAE -> traeRepository.checkIn().fold(
onSuccess = { r -> CheckinState.Done(r.message) },
onFailure = { e -> CheckinState.Error(e.message ?: "签到失败,请检查凭据") }
ServiceType.TRAE -> refreshBalanceUseCase.checkInTrae().fold(
onSuccess = { r ->
when (r.outcome) {
TraeRepository.CheckinOutcome.SUCCESS ->
CheckinState.Done(UiText.Resource(R.string.checkin_success))
TraeRepository.CheckinOutcome.ALREADY ->
CheckinState.Done(UiText.Resource(R.string.checkin_already))
// 设备级拦截不是「账号已签」:用 Notice 展示,按钮保持可点(用户可换网/稍后重试)
TraeRepository.CheckinOutcome.DEVICE_BLOCKED ->
CheckinState.Notice(UiText.Resource(R.string.checkin_trae_device_blocked))
TraeRepository.CheckinOutcome.INTL_UNSUPPORTED ->
CheckinState.Error(UiText.Resource(R.string.checkin_trae_intl))
TraeRepository.CheckinOutcome.FAILED -> CheckinState.Error(
// 服务端原始原因优先;缺省用带 code 的失败文案
if (r.message.isNotBlank()) {
UiText.Dynamic(r.message)
} else if (r.code >= 0) {
UiText.Resource(R.string.checkin_trae_failed_code, listOf(r.code))
} else {
UiText.Resource(R.string.checkin_failed)
}
)
}
},
onFailure = { e -> CheckinState.Error(errorMessage(e)) }
)
else -> CheckinState.Error("该服务不支持签到")
else -> CheckinState.Error(UiText.Resource(R.string.checkin_unsupported))
}
if (generation == serviceGeneration) {
_checkinState.value = result
// 签到成功后刷新「今日已签到/连签」摘要,让按钮态即时更新
if (result is CheckinState.Done) preloadCheckinInfo(service)
}
if (generation == serviceGeneration) _checkinState.value = result
}
}
@ -294,11 +372,14 @@ class ServiceDetailViewModel @Inject constructor(
_selectedModel.value = null
_modelsLoading.value = false
_triggerState.value = TriggerState.Idle
// 切换服务时重置签到状态,避免上一服务的 Done/Error 残留(按钮态/文案串台)
_checkinState.value = CheckinState.Idle
if (service == ServiceType.CODEX || service == ServiceType.OPENCODE_GO || service == ServiceType.OLLAMA) {
loadSelectedModel(service)?.let { _selectedModel.value = it }
loadModelsCache(service).takeIf { it.isNotEmpty() }?.let { _models.value = it }
}
preloadCheckinInfo(service)
loadFromCache()
}
@ -796,11 +877,18 @@ class ServiceDetailViewModel @Inject constructor(
}
/** 每日签到 UI 状态。 */
/** 每日签到 UI 状态。消息用 [UiText] 承载,由 UI 层按当前 Locale 解析(支持多语言)。 */
sealed class CheckinState {
data object Idle : CheckinState()
data object Loading : CheckinState()
data class Done(val message: String) : CheckinState()
data class Error(val message: String) : CheckinState()
data class Done(val message: UiText) : CheckinState()
data class Error(val message: UiText) : CheckinState()
/**
* 提示态(非错误、也非完成):例如 Trae 设备级拦截「该设备今日签到次数已达上限」。
* 与 [Done] 的区别是**不置灰按钮**——账号本身尚未签到,换网络/稍后可重试。
*/
data class Notice(val message: UiText) : CheckinState()
}
/** Sub2API 面板 Key 列表状态。 */

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,110 @@
package com.rainy.token.data.repository
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Unit tests for [TraeRepository.parseCheckin](每日签到 claim 响应 → 语义结果)。
*
* 覆盖真实实测口径:code 0/200 成功、code 1001 已签、消息含「已签到」等账号级已签、
* 消息含「设备」为设备级拦截(提示而非报错)、其它 code 走 FAILED 且带回错误码。
* 全部纯 JVM、无网络(parseCheckin 位于 companion,静态调用,不构造 Repository)。
*/
class TraeRepositoryTest {
private fun parse(body: String) = TraeRepository.parseCheckin(body)
@Test
fun success_parsesPointsAndOutcome() {
val body = """{"code":0,"message":"签到成功","data":{"points":200}}"""
val r = parse(body).getOrThrow()
assertTrue(r.success)
assertFalse(r.alreadyChecked)
assertEquals(200L, r.points)
assertEquals(TraeRepository.CheckinOutcome.SUCCESS, r.outcome)
}
@Test
fun success_withoutPoints_fallsBackTo200() {
// 服务端未回 points 时按 200 兜底(实测默认奖励)
val r = parse("""{"code":200,"message":"ok"}""").getOrThrow()
assertTrue(r.success)
assertEquals(200L, r.points)
assertEquals(TraeRepository.CheckinOutcome.SUCCESS, r.outcome)
}
@Test
fun code1001_marksAlreadyChecked() {
val r = parse("""{"code":1001,"message":"今日已签到"}""").getOrThrow()
assertTrue(r.alreadyChecked)
assertEquals(TraeRepository.CheckinOutcome.ALREADY, r.outcome)
// 已签不应再报成功奖励
assertEquals(0L, r.points)
}
@Test
fun alreadyKeywordWithoutCode_marksAlreadyChecked() {
// 服务端换 code 但文案不变时仍识别为「账号级已签」
val r = parse("""{"code":5,"message":"您今天已经签到过了"}""").getOrThrow()
assertTrue(r.alreadyChecked)
assertEquals(TraeRepository.CheckinOutcome.ALREADY, r.outcome)
}
@Test
fun englishAlreadyKeyword_marksAlreadyChecked() {
val r = parse("""{"code":7,"message":"Already claimed today"}""").getOrThrow()
assertEquals(TraeRepository.CheckinOutcome.ALREADY, r.outcome)
}
@Test
fun deviceBlock_isNotSuccessAndNotAlready() {
// 设备级拦截:既不是成功,也不能误判成「账号已签」(否则用户以为签过了)
val body = """{"code":9074,"message":"该设备今日签到次数已达上限"}"""
val r = parse(body).getOrThrow()
assertFalse(r.success)
assertFalse(r.alreadyChecked)
assertEquals(TraeRepository.CheckinOutcome.DEVICE_BLOCKED, r.outcome)
}
@Test
fun deviceBlock_englishWording_alsoDetected() {
val r = parse("""{"code":9074,"message":"device limit reached"}""").getOrThrow()
assertEquals(TraeRepository.CheckinOutcome.DEVICE_BLOCKED, r.outcome)
}
@Test
fun unknownCode_isFailedAndKeepsCode() {
val r = parse("""{"code":500,"message":"服务异常"}""").getOrThrow()
assertFalse(r.success)
assertFalse(r.alreadyChecked)
assertEquals(TraeRepository.CheckinOutcome.FAILED, r.outcome)
assertEquals(500L, r.code)
}
@Test
fun dataWrappedResponse_isUnwrapped() {
// data 包裹结构(服务端两种布局都出现过)
val body = """{"data":{"code":0,"points":150},"trace":"x"}"""
val r = parse(body).getOrThrow()
assertTrue(r.success)
assertEquals(150L, r.points)
}
@Test
fun malformedJson_returnsFailureNotCrash() {
val result = parse("not-json")
assertTrue(result.isFailure)
}
}

View File

@ -0,0 +1,75 @@
package com.rainy.token.data.repository
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Unit tests for [WorkBuddyRepository.parseCheckin](checkin-activity-status 响应 → 签到状态)。
*
* 覆盖实测字段口径:data 平铺 / {data:{...}} 包裹、snake_case 与 camelCase 双候选、
* 数值字段字符串化容错、字段缺失兜底。全部纯 JVM、无网络
* (parseCheckin 位于 companion,静态调用,不构造 Repository)。
*/
class WorkBuddyRepositoryTest {
private fun parse(body: String) = WorkBuddyRepository.parseCheckin(body)
@Test
fun flatResponse_parsesAllFields() {
val body = """
{"active":true,"today_checked_in":true,"streak_days":5,
"daily_credit":10,"today_credit":10,"activity_name":"Buddy 加油站"}
""".trimIndent()
val s = parse(body)
assertTrue(s.active)
assertTrue(s.todayCheckedIn)
assertEquals(5L, s.streakDays)
assertEquals(10L, s.dailyCredit)
assertEquals(10L, s.todayCredit)
assertEquals("Buddy 加油站", s.activityName)
// 查询模式不带动作语义
assertEquals(WorkBuddyRepository.CheckinOutcome.QUERY, s.outcome)
}
@Test
fun dataWrappedResponse_isUnwrapped() {
val body = """{"code":0,"data":{"active":true,"todayCheckedIn":true,"streakDays":"3"}}"""
val s = parse(body)
assertTrue(s.todayCheckedIn)
// camelCase + 字符串数字都要能解析
assertEquals(3L, s.streakDays)
}
@Test
fun notCheckedInToday_isFalse() {
val s = parse("""{"today_checked_in":false,"streak_days":0}""")
assertFalse(s.todayCheckedIn)
assertEquals(0L, s.streakDays)
}
@Test
fun missingFields_fallBackToDefaults() {
// 字段缺失时:active 默认 true(活动视为进行中)、签到相关默认 0/false
val s = parse("{}")
assertTrue(s.active)
assertFalse(s.todayCheckedIn)
assertEquals(0L, s.streakDays)
assertEquals(0L, s.dailyCredit)
assertEquals(0L, s.todayCredit)
assertEquals("", s.activityName)
}
@Test
fun themeName_isAcceptedAsActivityNameFallback() {
// 部分响应只有 theme_name
val s = parse("""{"theme_name":"每日签到"}""")
assertEquals("每日签到", s.activityName)
}
}