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:
parent
3b4cd103d0
commit
085a4d722f
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 等)一律过滤。
|
||||
|
||||
@ -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
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user