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)---------- // ---------- 每日签到(checkin_credits)----------
/** 签到动作语义结果(UI 层据此本地化,不直接展示 Repository 中文串)。 */
enum class CheckinOutcome {
/** 签到成功 */
SUCCESS,
/** 今日已签到(账号级,幂等) */
ALREADY,
/** 设备级拦截(提示而非报错) */
DEVICE_BLOCKED,
/** INTL 区签到接口未实测,暂不支持 */
INTL_UNSUPPORTED,
/** 其他失败(服务端给了原因/错误码) */
FAILED
}
/** 签到结果(来源:trae-check 实测字段与语义)。 */ /** 签到结果(来源:trae-check 实测字段与语义)。 */
data class CheckinResult( data class CheckinResult(
val success: Boolean, val success: Boolean,
/** 今日已签(账号级,非设备拦截) */ /** 今日已签(账号级,非设备拦截) */
val alreadyChecked: Boolean, val alreadyChecked: Boolean,
val points: Long, 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" val regionKey = credential.region.uppercase().takeIf { it == "INTL" } ?: "CN"
if (regionKey == "INTL") { if (regionKey == "INTL") {
return@withContext Result.success( 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() val request = Request.Builder()
@ -251,35 +276,16 @@ class TraeRepository constructor(
return@withContext Result.failure(RepositoryError.Unknown(e)) return@withContext Result.failure(RepositoryError.Unknown(e))
} }
parseCheckin(body) // 仅「凭据被服务端接受」时才算已验证:SUCCESS/ALREADY/DEVICE_BLOCKED 都说明
} // JWT 有效(401/403 已在上面提前返回);FAILED(含 HTTP 400 业务失败)不标记,
// 避免把无效凭据误标为已验证。
/** 解析 claim 响应(code/message/points 多候选;语义判断见 checkIn 注释)。 */ val parsed = parseCheckin(body)
private fun parseCheckin(body: String): Result<CheckinResult> = runCatching { val outcome = parsed.getOrNull()?.outcome
val root = json.parseToJsonElement(body).jsonObject if (outcome != null && outcome != CheckinOutcome.FAILED) {
val data = root["data"]?.jsonObject ?: root credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis()))
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 + ")" }))
} }
}.getOrElse { Result.failure(RepositoryError.Unknown(it)) } parsed
}
/** 由 JWT 派生稳定伪 deviceId:同账号恒定、不同账号不同(防 claim 设备级去重误伤)。 */ /** 由 JWT 派生稳定伪 deviceId:同账号恒定、不同账号不同(防 claim 设备级去重误伤)。 */
private fun stableDeviceId(seed: String): String = runCatching { private fun stableDeviceId(seed: String): String = runCatching {
@ -288,13 +294,6 @@ class TraeRepository constructor(
digest.take(12).joinToString("") { "%02x".format(it) } digest.take(12).joinToString("") { "%02x".format(it) }
}.getOrDefault("rainytoken-device") }.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) { private fun mapHttpError(code: Int): RepositoryError = when (code) {
401, 403 -> RepositoryError.InvalidCredential() 401, 403 -> RepositoryError.InvalidCredential()
429 -> RepositoryError.RateLimited() 429 -> RepositoryError.RateLimited()
@ -315,4 +314,75 @@ class TraeRepository constructor(
val totalQuota: Double?, val totalQuota: Double?,
val extras: Map<String, String> 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)---------- // ---------- 每日签到(Buddy 加油站 / codebuddy.cn)----------
/** 签到动作语义结果(UI 层据此本地化,不直接展示 Repository 中文串)。 */
enum class CheckinOutcome {
/** 查询模式:只读状态,无动作结果 */
QUERY,
/** 签到成功 */
SUCCESS,
/** 今日已签到(幂等) */
ALREADY,
/** 签到失败(服务端给了原因,见 message) */
FAILED
}
/** /**
* 签到状态摘要(来源:cpa-multi-plugins workbuddy 插件实测字段)。 * 签到状态摘要(来源:cpa-multi-plugins workbuddy 插件实测字段)。
* active=活动进行中;todayCheckedIn=今日已签;streakDays=连签;daily/todayCredit=每日/今日积分。 * active=活动进行中;todayCheckedIn=今日已签;streakDays=连签;daily/todayCredit=每日/今日积分。
@ -293,8 +305,10 @@ class WorkBuddyRepository constructor(
val dailyCredit: Long, val dailyCredit: Long,
val todayCredit: Long, val todayCredit: Long,
val activityName: String, val activityName: String,
/** 签到动作后的提示(签到成功/今日已签);查询模式为空串 */ /** 服务端原始提示文案(可能是中文/英文;仅作 [outcome] 之外的补充透传) */
val message: String = "" val message: String = "",
/** 动作语义结果:UI 按此本地化,不依赖 [message] 语言 */
val outcome: CheckinOutcome = CheckinOutcome.QUERY
) )
/** 查询今日签到状态。uid 未知时省略 X-User-Id 头(源码确认该头可选)。 */ /** 查询今日签到状态。uid 未知时省略 X-User-Id 头(源码确认该头可选)。 */
@ -305,9 +319,12 @@ class WorkBuddyRepository constructor(
return@withContext Result.failure(RepositoryError.InvalidCredential()) return@withContext Result.failure(RepositoryError.InvalidCredential())
} }
// 两条候选路径 fallback(cpa-plugin 实测:activity-status 优先,旧路径 checkin-status 兜底) // 两条候选路径 fallback(cpa-plugin 实测:activity-status 优先,旧路径 checkin-status 兜底)
val body = authedPost(credential, "/v2/billing/meter/checkin-activity-status") // 共享轮换状态:前一条路径轮换出的新 token 会用于下一条,且失败也先落库。
?: authedPost(credential, "/v2/billing/meter/checkin-status") val body = authedPostFirstSuccessful(
?: return@withContext Result.failure(RepositoryError.Unknown()) credential,
"/v2/billing/meter/checkin-activity-status",
"/v2/billing/meter/checkin-status"
) ?: return@withContext Result.failure(RepositoryError.Unknown())
runCatching { parseCheckin(body) }.fold( runCatching { parseCheckin(body) }.fold(
onSuccess = { Result.success(it) }, onSuccess = { Result.success(it) },
onFailure = { onFailure = {
@ -331,8 +348,20 @@ class WorkBuddyRepository constructor(
if (credential !is Credential.WorkBuddyCredential) { if (credential !is Credential.WorkBuddyCredential) {
return@withContext Result.failure(RepositoryError.InvalidCredential()) return@withContext Result.failure(RepositoryError.InvalidCredential())
} }
val body = authedPost(credential, "/v2/billing/meter/daily-checkin") val body = when (val r = authedPost(credential, "/v2/billing/meter/daily-checkin")) {
?: return@withContext Result.failure(RepositoryError.Unknown()) 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 code = runCatching {
val root = json.parseToJsonElement(body).jsonObject val root = json.parseToJsonElement(body).jsonObject
val obj = root["data"]?.jsonObject ?: root val obj = root["data"]?.jsonObject ?: root
@ -341,26 +370,60 @@ class WorkBuddyRepository constructor(
val status = fetchCheckinStatus().getOrNull() val status = fetchCheckinStatus().getOrNull()
when { when {
code == 0L -> Result.success( code == 0L -> Result.success(
status?.copy(message = "签到成功 +" + (status.todayCredit.takeIf { it > 0 } ?: status.dailyCredit) + " 积分") (status ?: CheckinStatus(true, true, 1, 0, 0, "")).copy(
?: CheckinStatus(true, true, 1, 0, 0, "", "签到成功") 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 -> { else -> {
val msg = runCatching { val msg = runCatching {
val root = json.parseToJsonElement(body).jsonObject val root = json.parseToJsonElement(body).jsonObject
val obj = root["data"]?.jsonObject ?: root val obj = root["data"]?.jsonObject ?: root
obj.scalar("msg", "message", "Message") ?: "" obj.scalar("msg", "message", "Message") ?: ""
}.getOrDefault("") }.getOrDefault("")
if (status != null) Result.success(status.copy(message = if (msg.isBlank()) "签到失败" else msg)) if (status != null) {
else Result.failure(RepositoryError.Unknown()) 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 access = credential.accessToken
var refresh = credential.refreshToken var refresh = credential.refreshToken
var rotated: Credential.WorkBuddyCredential? = null
repeat(2) { attempt -> repeat(2) { attempt ->
val request = Request.Builder() val request = Request.Builder()
.url("https://www.codebuddy.cn" + path) .url("https://www.codebuddy.cn" + path)
@ -372,44 +435,72 @@ class WorkBuddyRepository constructor(
val resp = try { val resp = try {
okHttpClient.newCall(request).execute() okHttpClient.newCall(request).execute()
} catch (_: Throwable) { } catch (_: Throwable) {
return null return AuthedPostResult.Failed(rotated)
} }
val code = resp.code val code = resp.code
val body = runCatching { resp.body?.string().orEmpty() }.getOrDefault("") val body = runCatching { resp.body?.string().orEmpty() }.getOrDefault("")
resp.close() resp.close()
when { when {
code in 200..299 -> return body code in 200..299 -> return AuthedPostResult.Ok(body, rotated)
(code == 401 || code == 403) && attempt == 0 -> { (code == 401 || code == 403) && attempt == 0 -> {
val refreshed = tryRefesh(refresh) ?: return null val refreshed = tryRefesh(refresh) ?: return AuthedPostResult.Failed(rotated)
// 轮换后的凭据由调用方主流程 save(此处仅内存内继续),避免半途改库 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 access = refreshed.accessToken
refresh = refreshed.refreshToken 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 return null
} }
/** 解析 checkin-activity-status 响应(data 平铺或 data 包裹,防御式多候选)。 */ /**
private fun parseCheckin(body: String): CheckinStatus { * 落库本次签到/状态查询产出:轮换后的 token(若有)+ lastVerifiedAt。
val root = json.parseToJsonElement(body).jsonObject * 与 [fetchBalance] 同款语义——一次成功请求即视为凭据已验证。
val obj = root["data"]?.jsonObject ?: root */
return CheckinStatus( private suspend fun persistCheckinOutcome(
active = obj.scalar("active", "Active")?.toBoolean() ?: true, credential: Credential.WorkBuddyCredential,
todayCheckedIn = obj.scalar("today_checked_in", "todayCheckedIn")?.toBoolean() ?: false, rotated: Credential.WorkBuddyCredential?
streakDays = obj.longOr("streak_days", "streakDays") ?: 0, ) {
dailyCredit = obj.longOr("daily_credit", "dailyCredit") ?: 0, val base = rotated ?: credential
todayCredit = obj.longOr("today_credit", "todayCredit") ?: 0, credentialRepository.save(base.copy(lastVerifiedAt = System.currentTimeMillis()))
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
} }
private fun mapHttpError(code: Int, body: String): RepositoryError = when (code) { private fun mapHttpError(code: Int, body: String): RepositoryError = when (code) {
@ -433,4 +524,40 @@ class WorkBuddyRepository constructor(
val unit: String?, val unit: String?,
val extras: Map<String, 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) 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( private suspend fun <T> withCredentialSession(
service: ServiceType, service: ServiceType,
block: suspend () -> Result<T> block: suspend () -> Result<T>

View File

@ -117,6 +117,8 @@ fun ServiceDetailScreen(
val selectedModel by viewModel.selectedModel.collectAsStateWithLifecycle() val selectedModel by viewModel.selectedModel.collectAsStateWithLifecycle()
val modelsLoading by viewModel.modelsLoading.collectAsStateWithLifecycle() val modelsLoading by viewModel.modelsLoading.collectAsStateWithLifecycle()
val checkinState by viewModel.checkinState.collectAsStateWithLifecycle() val checkinState by viewModel.checkinState.collectAsStateWithLifecycle()
val checkinInfo by viewModel.checkinInfo.collectAsStateWithLifecycle()
// 面板管理(Key/分组/兑换)已移到首页 Sub2API 卡片的「面板管理」入口, // 面板管理(Key/分组/兑换)已移到首页 Sub2API 卡片的「面板管理」入口,
// 详情页只保留用量明细所需的窗口统计状态。 // 详情页只保留用量明细所需的窗口统计状态。
val panelWindowStats by viewModel.panelWindowStats.collectAsStateWithLifecycle() val panelWindowStats by viewModel.panelWindowStats.collectAsStateWithLifecycle()
@ -193,11 +195,18 @@ fun ServiceDetailScreen(
ServiceType.TRAE, ServiceType.TRAE,
ServiceType.WORKBUDDY -> { ServiceType.WORKBUDDY -> {
// 每日签到卡(WorkBuddy/Trae 共用);下方仍展示通用 extras // 每日签到卡(WorkBuddy/Trae 共用);下方仍展示通用 extras
// 未配置凭据时展示引导卡,避免暴露必然失败的签到按钮
item { item {
CheckinCard( if (uiState.hasCredential) {
state = checkinState, CheckinCard(
onCheckin = { viewModel.checkin() } state = checkinState,
) todayCheckedIn = checkinInfo?.todayCheckedIn == true,
streakDays = checkinInfo?.streakDays ?: 0L,
onCheckin = { viewModel.checkin() }
)
} else {
CheckinNeedCredentialCard()
}
} }
item { GenericExtrasCard(uiState.state) } item { GenericExtrasCard(uiState.state) }
} }
@ -1098,7 +1107,13 @@ private fun BreakdownRow(label: String, value: Double, unit: String) {
* 结果消息直接采用官方响应语义(签到成功/今日已签到/设备拦截/失败原因)。 * 结果消息直接采用官方响应语义(签到成功/今日已签到/设备拦截/失败原因)。
*/ */
@Composable @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( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp), shape = RoundedCornerShape(20.dp),
@ -1120,27 +1135,48 @@ private fun CheckinCard(state: CheckinState, onCheckin: () -> Unit) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
when (state) { when (state) {
is CheckinState.Done -> Text( is CheckinState.Done -> Text(
text = state.message, text = state.message.asString(),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
is CheckinState.Error -> Text( is CheckinState.Error -> Text(
text = state.message, text = state.message.asString(),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error 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)) Spacer(modifier = Modifier.height(12.dp))
Button( Button(
onClick = onCheckin, onClick = onCheckin,
enabled = state !is CheckinState.Loading, enabled = state !is CheckinState.Loading && !actionDone,
modifier = Modifier.fillMaxWidth().height(44.dp), modifier = Modifier.fillMaxWidth().height(44.dp),
shape = RoundedCornerShape(12.dp) shape = RoundedCornerShape(12.dp)
) { ) {
Text( Text(
text = stringResource( 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 资源 / * 通用明细卡:把 balance.extras 中的友好键逐行展示(Trae 权益包 / WorkBuddy 资源 /
* Sub2API 账户等)。技术键(models / reset* / raw 等)一律过滤。 * 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.debug.DebugLog
import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.data.repository.RepositoryError 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.TriggerError
import com.rainy.token.data.repository.TriggerErrorReason import com.rainy.token.data.repository.TriggerErrorReason
import com.rainy.token.domain.model.Credential import com.rainy.token.domain.model.Credential
@ -33,8 +35,6 @@ class ServiceDetailViewModel @Inject constructor(
private val credentialRepository: CredentialRepository, private val credentialRepository: CredentialRepository,
private val balanceCache: BalanceCache, private val balanceCache: BalanceCache,
private val refreshBalanceUseCase: RefreshBalanceUseCase, 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 private val sub2ApiRepository: com.rainy.token.data.repository.Sub2ApiRepository
) : ViewModel() { ) : ViewModel() {
@ -58,7 +58,33 @@ class ServiceDetailViewModel @Inject constructor(
private val _checkinState = MutableStateFlow<CheckinState>(CheckinState.Idle) private val _checkinState = MutableStateFlow<CheckinState>(CheckinState.Idle)
val checkinState: StateFlow<CheckinState> = _checkinState.asStateFlow() 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() { fun checkin() {
val service = _serviceType.value val service = _serviceType.value
if (service != ServiceType.WORKBUDDY && service != ServiceType.TRAE) return if (service != ServiceType.WORKBUDDY && service != ServiceType.TRAE) return
@ -67,23 +93,75 @@ class ServiceDetailViewModel @Inject constructor(
val generation = serviceGeneration val generation = serviceGeneration
viewModelScope.launch { viewModelScope.launch {
val result = when (service) { val result = when (service) {
ServiceType.WORKBUDDY -> workBuddyRepository.checkIn().fold( ServiceType.WORKBUDDY -> refreshBalanceUseCase.checkInWorkBuddy().fold(
onSuccess = { s -> onSuccess = { s ->
CheckinState.Done( when (s.outcome) {
s.message.ifBlank { WorkBuddyRepository.CheckinOutcome.ALREADY ->
if (s.todayCheckedIn) "今日已签到(连签 " + s.streakDays + " 天)" else "签到成功" // 已签时带上连签天数(接口已回传,别浪费)
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( ServiceType.TRAE -> refreshBalanceUseCase.checkInTrae().fold(
onSuccess = { r -> CheckinState.Done(r.message) }, onSuccess = { r ->
onFailure = { e -> CheckinState.Error(e.message ?: "签到失败,请检查凭据") } 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 _selectedModel.value = null
_modelsLoading.value = false _modelsLoading.value = false
_triggerState.value = TriggerState.Idle _triggerState.value = TriggerState.Idle
// 切换服务时重置签到状态,避免上一服务的 Done/Error 残留(按钮态/文案串台)
_checkinState.value = CheckinState.Idle
if (service == ServiceType.CODEX || service == ServiceType.OPENCODE_GO || service == ServiceType.OLLAMA) { if (service == ServiceType.CODEX || service == ServiceType.OPENCODE_GO || service == ServiceType.OLLAMA) {
loadSelectedModel(service)?.let { _selectedModel.value = it } loadSelectedModel(service)?.let { _selectedModel.value = it }
loadModelsCache(service).takeIf { it.isNotEmpty() }?.let { _models.value = it } loadModelsCache(service).takeIf { it.isNotEmpty() }?.let { _models.value = it }
} }
preloadCheckinInfo(service)
loadFromCache() loadFromCache()
} }
@ -796,11 +877,18 @@ class ServiceDetailViewModel @Inject constructor(
} }
/** 每日签到 UI 状态。 */ /** 每日签到 UI 状态。 */
/** 每日签到 UI 状态。消息用 [UiText] 承载,由 UI 层按当前 Locale 解析(支持多语言)。 */
sealed class CheckinState { sealed class CheckinState {
data object Idle : CheckinState() data object Idle : CheckinState()
data object Loading : CheckinState() data object Loading : CheckinState()
data class Done(val message: String) : CheckinState() data class Done(val message: UiText) : CheckinState()
data class Error(val message: String) : CheckinState() data class Error(val message: UiText) : CheckinState()
/**
* 提示态(非错误、也非完成):例如 Trae 设备级拦截「该设备今日签到次数已达上限」。
* 与 [Done] 的区别是**不置灰按钮**——账号本身尚未签到,换网络/稍后可重试。
*/
data class Notice(val message: UiText) : CheckinState()
} }
/** Sub2API 面板 Key 列表状态。 */ /** Sub2API 面板 Key 列表状态。 */

View File

@ -187,6 +187,17 @@
<string name="checkin_subtitle">每天签到领积分,连签有奖励</string> <string name="checkin_subtitle">每天签到领积分,连签有奖励</string>
<string name="checkin_action">签到</string> <string name="checkin_action">签到</string>
<string name="checkin_loading">签到中…</string> <string name="checkin_loading">签到中…</string>
<string name="checkin_success">签到成功</string>
<string name="checkin_already">今日已签到</string>
<string name="checkin_already_streak">今日已签到(连签 %1$d 天)</string>
<string name="checkin_failed">签到失败,请检查凭据</string>
<string name="checkin_unsupported">该服务不支持签到</string>
<string name="checkin_action_done">今日已签到</string>
<string name="checkin_need_credential">请先添加凭据后再签到。</string>
<string name="checkin_trae_intl">INTL 区签到接口暂未支持,请用 CN 区账号</string>
<string name="checkin_trae_device_blocked">该设备今日签到次数已达上限</string>
<string name="checkin_trae_failed_code">签到失败(code=%1$d)</string>
<string name="checkin_wb_success_credits">签到成功 +%1$d 积分</string>
<!-- ══════════ Sub2API 面板 WebView 登录(全账号统计) ══════════ --> <!-- ══════════ Sub2API 面板 WebView 登录(全账号统计) ══════════ -->
<string name="sub2_panel_login_hint">在打开的页面中正常登录,App 自动读取登录态;也可继续手动填 API Key</string> <string name="sub2_panel_login_hint">在打开的页面中正常登录,App 自动读取登录态;也可继续手动填 API Key</string>

View File

@ -187,6 +187,17 @@
<string name="checkin_subtitle">每天簽到領積分,連簽有獎勵</string> <string name="checkin_subtitle">每天簽到領積分,連簽有獎勵</string>
<string name="checkin_action">簽到</string> <string name="checkin_action">簽到</string>
<string name="checkin_loading">簽到中…</string> <string name="checkin_loading">簽到中…</string>
<string name="checkin_success">簽到成功</string>
<string name="checkin_already">今日已簽到</string>
<string name="checkin_already_streak">今日已簽到(連簽 %1$d 天)</string>
<string name="checkin_failed">簽到失敗,請檢查憑證</string>
<string name="checkin_unsupported">該服務不支援簽到</string>
<string name="checkin_action_done">今日已簽到</string>
<string name="checkin_need_credential">請先新增憑證後再簽到。</string>
<string name="checkin_trae_intl">INTL 區簽到介面暫未支援,請用 CN 區帳號</string>
<string name="checkin_trae_device_blocked">該裝置今日簽到次數已達上限</string>
<string name="checkin_trae_failed_code">簽到失敗(code=%1$d)</string>
<string name="checkin_wb_success_credits">簽到成功 +%1$d 積分</string>
<!-- ══════════ Sub2API 面板 WebView 登入(全帳號統計) ══════════ --> <!-- ══════════ Sub2API 面板 WebView 登入(全帳號統計) ══════════ -->
<string name="sub2_panel_login_hint">在開啟的頁面中正常登入,App 自動讀取登入態;也可繼續手動填 API Key</string> <string name="sub2_panel_login_hint">在開啟的頁面中正常登入,App 自動讀取登入態;也可繼續手動填 API Key</string>

View File

@ -190,6 +190,17 @@
<string name="checkin_subtitle">Check in every day for credits; streaks earn bonuses</string> <string name="checkin_subtitle">Check in every day for credits; streaks earn bonuses</string>
<string name="checkin_action">Check in</string> <string name="checkin_action">Check in</string>
<string name="checkin_loading">Checking…</string> <string name="checkin_loading">Checking…</string>
<string name="checkin_success">Check-in successful</string>
<string name="checkin_already">Already checked in today</string>
<string name="checkin_already_streak">Already checked in (streak %1$d days)</string>
<string name="checkin_failed">Check-in failed, please check credentials</string>
<string name="checkin_unsupported">This service does not support check-in</string>
<string name="checkin_action_done">Checked in today</string>
<string name="checkin_need_credential">Add your credentials first to check in.</string>
<string name="checkin_trae_intl">INTL check-in not yet supported, please use a CN account</string>
<string name="checkin_trae_device_blocked">Device check-in limit reached today</string>
<string name="checkin_trae_failed_code">Check-in failed (code=%1$d)</string>
<string name="checkin_wb_success_credits">Check-in successful +%1$d credits</string>
<!-- ══════════ Sub2API 面板 WebView 登录(全账号统计) ══════════ --> <!-- ══════════ Sub2API 面板 WebView 登录(全账号统计) ══════════ -->
<string name="sub2_panel_login_hint">Sign in normally on the opened page; the app reads the session automatically. Manual API Key still works.</string> <string name="sub2_panel_login_hint">Sign in normally on the opened page; the app reads the session automatically. Manual API Key still works.</string>

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)
}
}