feat(checkin): 签到记录含日期/状态/id/数量,失败可当天重试;同步本地主题与组件改动

This commit is contained in:
Liuxinyu176 2026-10-08 21:08:22 +08:00
parent ffc06a38ca
commit ceb5e6a268
34 changed files with 653 additions and 230 deletions

View File

@ -59,7 +59,7 @@ RainyToken (雨晴Token) — AI Balance & Usage Quota Query · the Rainy Family
| ➕ **One-tap add to home screen** | Tap + inside the app to add the widget directly, no need to browse the system list; double confirmation + permission check |
| 💡 **Usage tips** | Home page shows a random operation tip (refreshed on each launch); settings page lists all 13 hidden tips |
| ⚡ **Room database** | Usage records stored in Room (indexed on workspaceId+timeCreated); DAO queries replace full JSON serialization; auto-migration from the legacy DataStore on first launch |
| 🎀 **Rainy pink theme** | Material Design 3 · Strawberry Pink #FF85A2 · Sakura Pink #FFD1DC |
| 🎀 **Rainy pink theme** | Material Design 3 · Refined Rose primary #C9436C · Blush white background #FDEFF4 |
| 🔐 **Codex OAuth sign-in** | Headless OAuth PKCE: the app generates an authorization link → sign in in an external browser → paste the callback URL to complete authorization; no manual auth.json export needed |
| ⚡ **Codex one-tap usage activation** | Codex detail page can send a short request to the ChatGPT API to trigger usage tracking; model list fetched dynamically from models.dev and persisted, with manual refresh; response dialog is copyable |
| ⚡ **OCGO / Ollama one-tap usage activation** | OCGO detail page can send a request to `opencode.ai/zen/v1`, Ollama detail page to `ollama.com/v1` to trigger usage tracking; API keys are entered manually in settings; model lists fetched dynamically from models.dev |

View File

@ -59,7 +59,7 @@ RainyToken(雨晴Token)— AI Balance & Usage Quota Query · the Rainy Famil
| ➕ **一键添桌面** | APP 内点 + 直接添加小组件,不用去系统列表翻;二次确认 + 权限检测 |
| 💡 **使用小技巧** | 首页随机展示一条操作提示(每次启动刷新);设置页可查看全部 13 条隐藏操作技巧 |
| ⚡ **Room 数据库** | 用量记录存 Room(indexed on workspaceId+timeCreated),DAO 查询替代全量 JSON 序列化;首次启动自动从旧 DataStore 迁移 |
| 🎀 **雨晴粉主题** | Material Design 3 · 草莓粉 #FF85A2 · 樱粉 #FFD1DC |
| 🎀 **雨晴粉主题** | Material Design 3 · 精致玫瑰粉 主色 #C9436C · 粉白背景 #FDEFF4 |
| 🔐 **Codex OAuth 登录** | 无头模式 OAuth PKCE:APP 生成授权链接 → 外部浏览器登录 → 粘贴回调 URL 完成授权,无需手动导出 auth.json |
| ⚡ **Codex 一键激活用量** | Codex 详情页可向 ChatGPT API 发送简短请求触发用量统计;模型列表从 models.dev 动态获取并持久化,支持手动刷新;响应弹窗可复制 |
| ⚡ **OCGO / Ollama 一键激活用量** | OCGO 详情页可向 `opencode.ai/zen/v1` 发送请求,Ollama 详情页可向 `ollama.com/v1` 发送请求触发用量统计;API Key 在设置页手动填写,模型列表从 models.dev 动态获取 |

View File

@ -4,6 +4,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:name=".RainyTokenApplication"
@ -33,6 +34,15 @@
android:name=".ui.widget.WidgetRefreshReceiver"
android:exported="false" />
<!-- 每日自动签到:零点闹钟广播 + 开机后排程(Hilt 经 EntryPoint 注入,无 @AndroidEntryPoint) -->
<receiver
android:name=".ui.dashboard.autocheckin.AutoCheckinReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<!-- OpenCode Go 小组件(MIUI Widget) -->
<receiver
android:name=".ui.widget.OpenCodeGoWidgetProvider"

View File

@ -15,7 +15,7 @@ import dagger.hilt.android.AndroidEntryPoint
*
* 布局策略:
* - 外层只套 RainyTokenTheme(统一品牌色 + 字体)
* - 再套 RainyBackground(樱粉渐变背景,全局共享)
* - 再套 RainyBackground(精致粉调渐变背景,全局共享)
* - NavHost 在背景之上,每个页面自己用 Scaffold 处理 TopAppBar 和 padding
* - **不**在外层再套 Scaffold —— 避免双 Scaffold 嵌套的 padding 计算混乱
*/

View File

@ -6,20 +6,21 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import java.time.LocalDate
import javax.inject.Inject
import javax.inject.Singleton
import org.json.JSONObject
/**
* 每日签到状态的统一本地记录(SharedPreferences "checkin_status")。
*
* 手动签到与自动签到成功/已签后都会落一条 `service → 签到日期` 记录,供三处消费:
* - 服务详情页:进入时直接显示「今日已签到」,不再盲目展示可点按钮;
* - 首页卡片:Trae / WorkBuddy 各账号卡角标「今日已签到 ✓」;
* - 自动签到:当日已有记录的(默认)账号跳过,保证每日只签一次。
* 一条记录包含:
* - checkinDate:签到日期(本地时区 yyyy-MM-dd)
* - status:签到结果状态(SUCCESS/ALREADY/FAILED/RETRYABLE/DEVICE_BLOCKED)
* - checkinId:签到 id(服务端未提供时为空;新签到成功时生成本地 UUID 便于追溯)
* - credits:签到获得数量
* - updatedAt:本地记录更新时间戳
*
* 多账号服务按账号分别记录(键 `last_checkin_{storageKey}:{accountId}`);
* 单账号服务沿用旧键 `last_checkin_{storageKey}`([accountId] 传 null)。
*
* 与 TraeCredential.lastCheckinDate 的关系:后者是 Trae Repository 层的网络去重
* 短路字段(仅 Trae 有);本存储是 UI/编排层的展示与调度记录,覆盖所有可签服务。
* 「今日已签到」的判定 = checkinDate == 今天 && status ∈ {SUCCESS, ALREADY}。
* 因此 FAILED/RETRYABLE/DEVICE_BLOCKED 的记录不会挡住当天重试:标识位和时间
* 对不上时,重开 App 会再次唤起签到流程。
*/
@Singleton
class CheckinStatusStore @Inject constructor(
@ -29,14 +30,80 @@ class CheckinStatusStore @Inject constructor(
private fun prefs() =
context.getSharedPreferences("checkin_status", Context.MODE_PRIVATE)
/** 该服务今日是否已有签到记录(本地时区 yyyy-MM-dd 比对)。 */
fun isCheckedToday(service: ServiceType, accountId: String? = null): Boolean =
lastCheckinDate(service, accountId) == LocalDate.now().toString()
/** 该服务今日是否已「成功」签到(本地时区 yyyy-MM-dd 比对)。 */
fun isCheckedToday(service: ServiceType, accountId: String? = null): Boolean {
val record = lastRecord(service, accountId) ?: return false
return record.checkinDate == LocalDate.now().toString() &&
(record.status == STATUS_SUCCESS || record.status == STATUS_ALREADY)
}
/** 最近一次签到日期(yyyy-MM-dd),无记录返回 null。 */
fun lastCheckinDate(service: ServiceType, accountId: String? = null): String? {
/** 最近一次签到记录(含失败记录),无记录返回 null。 */
fun lastRecord(service: ServiceType, accountId: String? = null): CheckinRecord? {
val raw = prefs().getString(recordKey(service, accountId), null)
if (raw != null) {
return runCatching {
val obj = JSONObject(raw)
val id = if (obj.isNull(KEY_ID)) null else obj.optString(KEY_ID).takeIf { it.isNotBlank() }
CheckinRecord(
service = service.storageKey,
accountId = accountId,
checkinDate = obj.optString(KEY_DATE),
status = obj.optString(KEY_STATUS),
checkinId = id,
credits = obj.optLong(KEY_CREDITS),
updatedAt = obj.optLong(KEY_UPDATED_AT)
)
}.getOrNull()
}
// 旧版 date-only 记录迁移:没有 JSON 记录时回退读旧键
val legacyDate = legacyDate(service, accountId) ?: return null
return CheckinRecord(
service = service.storageKey,
accountId = accountId,
checkinDate = legacyDate,
status = STATUS_SUCCESS,
checkinId = null,
credits = 0,
updatedAt = 0
)
}
/** 最近一次成功签到日期(yyyy-MM-dd),无记录返回 null。 */
fun lastCheckinDate(service: ServiceType, accountId: String? = null): String? =
lastRecord(service, accountId)
?.takeIf { it.status == STATUS_SUCCESS || it.status == STATUS_ALREADY }
?.checkinDate
/** 记录一条签到结果(成功或失败都会落库,便于当天重试/统计)。 */
fun record(
service: ServiceType,
accountId: String? = null,
status: String,
checkinId: String? = null,
credits: Long = 0,
updatedAt: Long = System.currentTimeMillis()
) {
val obj = JSONObject()
obj.put(KEY_DATE, LocalDate.now().toString())
obj.put(KEY_STATUS, status)
obj.put(KEY_ID, checkinId ?: JSONObject.NULL)
obj.put(KEY_CREDITS, credits)
obj.put(KEY_UPDATED_AT, updatedAt)
prefs().edit().putString(recordKey(service, accountId), obj.toString()).apply()
}
/** 旧版「已成功签到」快捷写入(服务端确认已签/手动已签场景)。 */
fun markChecked(service: ServiceType, accountId: String? = null) {
record(service, accountId, STATUS_ALREADY)
}
private fun recordKey(service: ServiceType, accountId: String?): String =
"checkin_record_${service.storageKey}" + if (accountId.isNullOrBlank()) "" else ":$accountId"
private fun legacyDate(service: ServiceType, accountId: String?): String? {
val p = prefs()
val scoped = p.getString(key(service, accountId), null)
val scopedKey = "last_checkin_${service.storageKey}" + if (accountId.isNullOrBlank()) "" else ":$accountId"
val scoped = p.getString(scopedKey, null)
if (scoped != null) return scoped
// 仅旧版迁移来的 “default” 账号回退读旧版单账号键(其他账号没有旧记录,不能共用),
// 避免升级当日重复签到请求
@ -47,15 +114,28 @@ class CheckinStatusStore @Inject constructor(
}
}
/** 记录该服务(账号)今日已签到(SUCCESS / ALREADY 均调用)。 */
fun markChecked(service: ServiceType, accountId: String? = null) {
prefs().edit().putString(key(service, accountId), LocalDate.now().toString()).apply()
}
/** 单条签到记录。 */
data class CheckinRecord(
val service: String,
val accountId: String?,
val checkinDate: String,
val status: String,
val checkinId: String?,
val credits: Long,
val updatedAt: Long
)
private fun key(service: ServiceType, accountId: String?): String =
if (accountId.isNullOrBlank()) {
"last_checkin_${service.storageKey}"
} else {
"last_checkin_${service.storageKey}:$accountId"
}
companion object {
const val STATUS_SUCCESS = "SUCCESS"
const val STATUS_ALREADY = "ALREADY"
const val STATUS_FAILED = "FAILED"
const val STATUS_RETRYABLE = "RETRYABLE"
const val STATUS_DEVICE_BLOCKED = "DEVICE_BLOCKED"
private const val KEY_DATE = "date"
private const val KEY_STATUS = "status"
private const val KEY_ID = "id"
private const val KEY_CREDITS = "credits"
private const val KEY_UPDATED_AT = "updated_at"
}
}

View File

@ -3,6 +3,7 @@ package com.rainy.token.domain.usecase
import com.rainy.token.data.repository.CodexRepository
import com.rainy.token.data.repository.CommandCodeGoRepository
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.data.repository.CheckinStatusStore
import com.rainy.token.data.repository.DeepSeekRepository
import com.rainy.token.data.repository.OllamaRepository
import com.rainy.token.data.repository.OpenCodeGoRepository
@ -107,10 +108,29 @@ class RefreshBalanceUseCase @Inject constructor(
withCredentialSession(ServiceType.WORKBUDDY, accountId) {
workBuddyRepositoryProvider.get().checkIn(accountId).also { result ->
result.getOrNull()?.let { s ->
if (s.outcome == WorkBuddyRepository.CheckinOutcome.SUCCESS ||
s.outcome == WorkBuddyRepository.CheckinOutcome.ALREADY
) {
checkinStatusStore.markChecked(ServiceType.WORKBUDDY, accountId)
val isChecked = s.outcome == WorkBuddyRepository.CheckinOutcome.SUCCESS ||
s.outcome == WorkBuddyRepository.CheckinOutcome.ALREADY ||
s.outcome == WorkBuddyRepository.CheckinOutcome.QUERY
if (isChecked) {
checkinStatusStore.record(
service = ServiceType.WORKBUDDY,
accountId = accountId,
status = if (s.outcome == WorkBuddyRepository.CheckinOutcome.QUERY) {
CheckinStatusStore.STATUS_ALREADY
} else {
s.outcome.name
},
checkinId = if (s.outcome == WorkBuddyRepository.CheckinOutcome.SUCCESS) {
java.util.UUID.randomUUID().toString()
} else {
null
},
credits = if (s.outcome == WorkBuddyRepository.CheckinOutcome.SUCCESS) {
s.todayCredit.takeIf { it > 0 } ?: s.dailyCredit
} else {
0
}
)
}
}
}
@ -123,7 +143,17 @@ class RefreshBalanceUseCase @Inject constructor(
if (r.outcome == TraeRepository.CheckinOutcome.SUCCESS ||
r.outcome == TraeRepository.CheckinOutcome.ALREADY
) {
checkinStatusStore.markChecked(ServiceType.TRAE, accountId)
checkinStatusStore.record(
service = ServiceType.TRAE,
accountId = accountId,
status = r.outcome.name,
checkinId = if (r.outcome == TraeRepository.CheckinOutcome.SUCCESS) {
java.util.UUID.randomUUID().toString()
} else {
null
},
credits = if (r.outcome == TraeRepository.CheckinOutcome.SUCCESS) r.points else 0
)
}
}
}

View File

@ -66,6 +66,7 @@ import com.rainy.token.ui.components.ServiceIcon
import com.rainy.token.ui.components.formatAmount
import com.rainy.token.ui.components.resolve
import com.rainy.token.ui.theme.StrawberryPink
import com.rainy.token.ui.theme.StatusGreen
import com.rainy.token.ui.theme.StatusOrange
import com.rainy.token.ui.theme.inkMuted
import java.text.SimpleDateFormat
@ -420,7 +421,7 @@ private fun ApiRowCard(
@Composable
private fun StatusDot(state: CredentialStatus.State) {
val color = when (state) {
CredentialStatus.State.OK -> Color(0xFF3BB273)
CredentialStatus.State.OK -> StatusGreen
CredentialStatus.State.WARNING -> StatusOrange
CredentialStatus.State.EXPIRED -> MaterialTheme.colorScheme.error
CredentialStatus.State.NOT_CONFIGURED -> inkMuted().copy(alpha = 0.5f)

View File

@ -39,6 +39,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.R
import com.rainy.token.data.debug.DebugLog
import com.rainy.token.ui.theme.InkMuted
import com.rainy.token.ui.theme.StatusOrange
import com.rainy.token.ui.theme.StatusRed
import com.rainy.token.ui.theme.StrawberryPink
@OptIn(ExperimentalMaterial3Api::class)
@ -103,8 +105,8 @@ fun DebugLogScreen(onBack: () -> Unit) {
items(entries, key = { it.timestamp.toString() + it.tag + it.message }) { entry ->
val levelColor = when (entry.level) {
DebugLog.Level.INFO -> InkMuted
DebugLog.Level.WARN -> Color(0xFFFFA726)
DebugLog.Level.ERROR -> Color(0xFFE91E63)
DebugLog.Level.WARN -> StatusOrange
DebugLog.Level.ERROR -> StatusRed
}
Card(
modifier = Modifier.fillMaxWidth(),

View File

@ -13,10 +13,10 @@ import com.rainy.token.ui.theme.DarkSurface
import androidx.compose.foundation.isSystemInDarkTheme
/**
* 雨晴风格全局背景。
* 雨晴风格全局背景(精致粉调)。
*
* Light 模式:樱粉渐变(#FFF0F5 → #FFD1DC,从左上到右下)
* Dark 模式:暖深渐变(#1F1419 → #2A1F25)
* Light 模式:低饱和粉白渐变(#FFF9FB → #FDEFF4,从左上到右下)
* Dark 模式:暖深渐变(#1A1417 → #251C21)
*
* 放在 Scaffold 容器层,所有页面共享同一层渐变。
*/

View File

@ -44,7 +44,7 @@ fun ServiceIcon(
modifier = modifier
.size(size.dp)
.clip(RoundedCornerShape(8.dp))
.background(if (isSystemInDarkTheme()) Color(0xFF352329) else Color.White),
.background(if (isSystemInDarkTheme()) Color(0xFF32262C) else Color.White),
contentAlignment = Alignment.Center
) {
Image(
@ -59,7 +59,7 @@ fun ServiceIcon(
modifier = modifier
.size(size.dp)
.clip(RoundedCornerShape(8.dp))
.background(if (isSystemInDarkTheme()) Color(0xFF352329) else Color.White),
.background(if (isSystemInDarkTheme()) Color(0xFF32262C) else Color.White),
contentAlignment = Alignment.Center
) {
Image(

View File

@ -1,6 +1,7 @@
package com.rainy.token.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -44,11 +45,14 @@ fun StatusChip(
style: StatusStyle,
modifier: Modifier = Modifier
) {
// 暗色模式下胶囊底色是状态色 12% alpha 叠加暗色卡片,
// 深色文字几乎不可读,故暗色使用同色系亮色文字。
val dark = isSystemInDarkTheme()
val (dotColor, textColor) = when (style.level) {
StatusLevel.OK -> StatusGreen to Color(0xFF1B5E20)
StatusLevel.WARNING -> StatusOrange to Color(0xFF8A4A00)
StatusLevel.ERROR -> StatusRed to Color(0xFF8B0033)
StatusLevel.INFO -> StatusBlue to Color(0xFF0D47A1)
StatusLevel.OK -> StatusGreen to if (dark) Color(0xFF9CE8BC) else Color(0xFF1B5E20)
StatusLevel.WARNING -> StatusOrange to if (dark) Color(0xFFFFDBA6) else Color(0xFF8A4A00)
StatusLevel.ERROR -> StatusRed to if (dark) Color(0xFFFFB6CA) else Color(0xFF8A1F40)
StatusLevel.INFO -> StatusBlue to if (dark) Color(0xFFAED7FF) else Color(0xFF0D47A1)
}
val bgColor = dotColor.copy(alpha = 0.12f)
Row(

View File

@ -13,6 +13,7 @@ import com.rainy.token.domain.service.ServiceType
import com.rainy.token.domain.usecase.RefreshBalanceUseCase
import com.rainy.token.R
import com.rainy.token.ui.components.UiText
import com.rainy.token.ui.dashboard.autocheckin.CHECKIN_SERVICES
import com.rainy.token.ui.widget.OpenCodeGoWidgetProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
@ -62,6 +63,8 @@ class DashboardViewModel @Inject constructor(
// 展示自相矛盾。串行后签到先落库,refresh 再拉到含新积分的余额。
// 用 try/catch 而非 runCatching:后者会吞掉 CancellationException,
// 导致 ViewModel 销毁取消后协程仍继续往下跑。
// 2.5) 打开 App 时把零点后台闹钟重排到下次零点(开机/升级/后台被杀后的兜底)
autoCheckinManager.refreshSchedule()
try {
autoCheckinManager.runDailyIfDue()
} catch (cancelled: CancellationException) {
@ -475,9 +478,6 @@ private data class AccountKey(
val accountId: String? = null
)
/** 支持每日签到的服务(首页角标与自动签到的统一口径)。 */
internal val CHECKIN_SERVICES = listOf(ServiceType.TRAE, ServiceType.WORKBUDDY)
/** 把 Repository 错误映射为本地化文案(ParseError 按 reason 映射资源,detail 仅作日志,不流向 UI)。 */
private fun errorToUiText(error: Throwable): UiText = when (error) {
is RepositoryError.SessionExpired ->

View File

@ -76,7 +76,7 @@ import java.util.Locale
// 图表用色板(每个模型一个颜色,12 色 Material Design 500 色阶,足够区分 12 个模型)
internal val modelColors = listOf(
Color(0xFFF44336), // Red
Color(0xFFE91E63), // Pink
Color(0xFFC9436C), // Pink(玫瑰主色)
Color(0xFF9C27B0), // Purple
Color(0xFF673AB7), // Deep Purple
Color(0xFF3F51B5), // Indigo
@ -89,11 +89,11 @@ internal val modelColors = listOf(
Color(0xFF607D8B) // Blue Grey
)
// Token 堆叠色板:命中缓存(浅粉·顶)、未命中输入(粉·中)、输出(深粉·底)
// Token 堆叠色板:命中缓存(浅樱粉·顶)、未命中输入(玫瑰粉·中)、输出(深玫瑰·底)
internal val tokenColors = listOf(
Color(0xFFFFD1DC), // cache hit — 浅粉(上面)
Color(0xFFFF85A2), // cache miss input — 粉色(中间)
Color(0xFFE91E63) // output — 深粉(下面)
Color(0xFFFFE3EC), // cache hit — 浅樱粉(上面)
Color(0xFFFFA3BC), // cache miss input — 玫瑰粉(中间)
Color(0xFFC9436C) // output — 深玫瑰(主色,下面)
)
@OptIn(ExperimentalMaterial3Api::class)

View File

@ -0,0 +1,16 @@
package com.rainy.token.ui.dashboard.autocheckin
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
/**
* 后台广播(自动签到闹钟 / 开机重排)获取 Hilt 单例的入口。
* 与 ui.widget.WidgetRefreshEntryPoint 同一模式:广播接收器不依赖
* @AndroidEntryPoint,通过 EntryPointAccessors 拿 SingletonComponent 里的依赖。
*/
@EntryPoint
@InstallIn(SingletonComponent::class)
interface AutoCheckinEntryPoint {
fun autoCheckinManager(): AutoCheckinManager
}

View File

@ -1,6 +1,9 @@
package com.rainy.token.ui.dashboard.autocheckin
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import com.rainy.token.data.repository.CheckinStatusStore
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.data.repository.RepositoryError
@ -11,23 +14,32 @@ import com.rainy.token.domain.model.ServiceAccountInfo
import com.rainy.token.domain.service.ServiceType
import com.rainy.token.domain.usecase.RefreshBalanceUseCase
import dagger.hilt.android.qualifiers.ApplicationContext
import java.time.LocalDate
import java.time.ZonedDateTime
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* 每日自动签到编排器(小组件开关控制启用)。
*
* 行为契约(用户需求 2026-09-13 / 多账号版 2026-10):
* - 开启后,每天首次打开 App 自动为所有「已配置凭据」的可签服务的【默认账号】各签一次;
* - 当日只签一次:[CheckinStatusStore] 有当日记录的账号直接跳过(手动签过也算);
* 行为契约(用户需求 2026-09-13 / 多账号版 2026-10 / 零点后台版 2026-11):
* - 开启后,每天零点后由 AlarmManager 自动触发([AutoCheckinReceiver]),
* 为所有「已配置凭据」的可签服务的【全部账号】各签一次;
* - 兜底:每天首次打开 App(DashboardViewModel)也会补签一次(错过闹钟/被杀进程场景);
* - 当日每账号只签一次:本地有「今日已成功签到」记录的账号直接跳过(手动签过也算);
* 失败/限流/设备拦截等非成功记录不占位,下次打开 App 会再次唤起签到流程;
* - 结果汇报:签完把每个账号的结果暴露为 [report],首页据此弹「xx 已签到,积分增加 xx」;
* - 开关持久化在 SharedPreferences "auto_checkin"(小组件与 App 内共用同一份)。
*
* 仅负责编排与记录,不做 UI:签到复用 RefreshBalanceUseCase(带凭据快照会话)。
*
* 调度说明:使用框架自带的 AlarmManager(非精确 setAndAllowWhileIdle)实现「每日零点后
* 后台签到」,零新增依赖;重启设备后闹钟由 BOOT_COMPLETED 广播重排。国产 ROM(如 vivo/
* iQOO)可能因电池优化延迟或抑制该闹钟,届时由「首次打开 App」兜底补签。
*/
@Singleton
class AutoCheckinManager @Inject constructor(
@ -54,52 +66,54 @@ class AutoCheckinManager @Inject constructor(
)
/**
* 自动签到汇总结果。null=没有可展示的内容(未开启/无默认账号/全部跳过)。
* 自动签到汇总结果。null=没有可展示的内容(未开启/无候选账号/全部账号已签或静默跳过)。
* 弹窗消费后由 [consumeReport] 置 null。
*/
private val _report = MutableStateFlow<List<ServiceResult>?>(null)
val report: StateFlow<List<ServiceResult>?> = _report.asStateFlow()
/** 防同日并发重复签到:闹钟广播(IO 线程)与 Dashboard init(viewModelScope)可能同时进入。 */
private val runMutex = Mutex()
private fun prefs() =
context.getSharedPreferences("auto_checkin", Context.MODE_PRIVATE)
/** 自动签到开关(小组件 CheckBox 与 App 内共用)。 */
/** 自动签到开关(小组件 CheckBox 与 App 内共用)。开关变更时同步排/撤零点闹钟。 */
var enabled: Boolean
get() = prefs().getBoolean(KEY_ENABLED, false)
set(value) {
prefs().edit().putBoolean(KEY_ENABLED, value).apply()
}
/** 已执行过「今日自动签到」的日期(防止同日多次开 App 重复跑)。 */
private var lastRunDate: String?
get() = prefs().getString(KEY_LAST_RUN, null)
set(value) {
prefs().edit().putString(KEY_LAST_RUN, value).apply()
refreshSchedule()
}
/**
* 每日首次打开 App 时调用:开启且今日未跑过 → 为已配置凭据的可签服务默认账号各签一次。
* 每日签到执行入口(后台闹钟与首次打开 App 共用):
* 开启时 → 为已配置凭据的可签服务中【今日尚未成功签到】的账号各签一次。
* 返回 true 表示本次实际执行了签到(无论结果)。
*/
suspend fun runDailyIfDue(): Boolean {
suspend fun runDailyIfDue(): Boolean = runMutex.withLock {
if (!enabled) return false
val today = LocalDate.now().toString()
if (lastRunDate == today) return false
// 先占位再执行:即使执行中崩溃/杀进程,同日再开也不会重跑(宁可少签不重复打扰)
lastRunDate = today
// 不再使用全局 lastRunDate 占位:只有真正「今日签到成功」的账号才跳过,
// 失败/限流/设备拦截等不会消费当天机会,重开 App 可再次唤起签到流程。
val results = mutableListOf<ServiceResult>()
for (service in CHECKIN_SERVICES) {
val defaultAccount = credentialRepository.getDefaultAccount(service) ?: continue
if (credentialRepository.statusFor(service, defaultAccount.id).state ==
CredentialStatus.State.NOT_CONFIGURED
) {
continue
// 多账号服务遍历全部账号;无账号文档时 getDefaultAccount 同样返回 null,
// 候选为空 = 不签,与旧版「只签默认账号」的边界行为一致,不会误签旧凭据
val accounts = credentialRepository.accountsFor(service)
val candidates = accounts.ifEmpty {
listOfNotNull(credentialRepository.getDefaultAccount(service))
}
for (account in candidates) {
if (credentialRepository.statusFor(service, account.id).state ==
CredentialStatus.State.NOT_CONFIGURED
) {
continue
}
// 今日已有成功签到记录(手动签过也算)→ 跳过,不再发网络请求
if (checkinStatusStore.isCheckedToday(service, account.id)) continue
// runOne 返回 null = 无需汇报(如 INTL 不支持签到),不弹误导性条目
runOne(service, account)?.let { results += it }
}
// 当日已有签到记录(手动签过)→ 跳过,不再发网络请求
if (checkinStatusStore.isCheckedToday(service, defaultAccount.id)) continue
// runOne 返回 null = 无需汇报(如 INTL 不支持签到),不弹误导性条目
runOne(service, defaultAccount)?.let { results += it }
}
if (results.isNotEmpty()) {
_report.value = results
@ -107,6 +121,39 @@ class AutoCheckinManager @Inject constructor(
return true
}
/**
* 注册/取消每日零点后台闹钟(非精确 setAndAllowWhileIdle,零点后几分钟内触发)。
* 设备重启后闹钟丢失,由 BOOT_COMPLETED 广播再次调用本方法重排。
*/
fun refreshSchedule() {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, AutoCheckinReceiver::class.java)
.setAction(ACTION_DAILY_CHECKIN)
val pendingIntent = PendingIntent.getBroadcast(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
if (!enabled) {
alarmManager.cancel(pendingIntent)
return
}
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
nextRunTimeMillis(),
pendingIntent
)
}
/** 下一次零点(00:05,本地时区)的运行时间戳。 */
private fun nextRunTimeMillis(): Long {
val now = ZonedDateTime.now()
var next = now.toLocalDate().atTime(CHECKIN_HOUR, CHECKIN_MINUTE).atZone(now.zone)
if (!next.isAfter(now)) next = next.plusDays(1)
return next.toInstant().toEpochMilli()
}
/**
* 返回 null 表示该账号无需出现在汇报弹窗里(如 INTL 不支持签到、静默跳过)。
*/
@ -132,15 +179,24 @@ class AutoCheckinManager @Inject constructor(
ServiceResult(service, label, false, true, 0, "", false)
// INTL 不支持签到:静默跳过,不进弹窗(避免误报「已签到」)
TraeRepository.CheckinOutcome.INTL_UNSUPPORTED -> null
TraeRepository.CheckinOutcome.DEVICE_BLOCKED ->
TraeRepository.CheckinOutcome.DEVICE_BLOCKED -> {
recordFailure(service, account.id, CheckinStatusStore.STATUS_DEVICE_BLOCKED)
ServiceResult(service, label, false, false, 0, r.message, failed = true)
TraeRepository.CheckinOutcome.RETRYABLE ->
}
TraeRepository.CheckinOutcome.RETRYABLE -> {
recordFailure(service, account.id, CheckinStatusStore.STATUS_RETRYABLE)
ServiceResult(service, label, false, false, 0, r.message, failed = true)
TraeRepository.CheckinOutcome.FAILED ->
}
TraeRepository.CheckinOutcome.FAILED -> {
recordFailure(service, account.id, CheckinStatusStore.STATUS_FAILED)
ServiceResult(service, label, false, false, 0, r.message, failed = true)
}
}
},
onFailure = { e -> failureResult(service, label, account.id, e) }
onFailure = { e ->
recordFailure(service, account.id, CheckinStatusStore.STATUS_FAILED)
failureResult(service, label, account.id, e)
}
)
ServiceType.WORKBUDDY -> refreshBalanceUseCase.checkInWorkBuddy(account.id).fold(
onSuccess = { s ->
@ -158,11 +214,16 @@ class AutoCheckinManager @Inject constructor(
ServiceResult(service, label, false, true, 0, "", false)
WorkBuddyRepository.CheckinOutcome.QUERY ->
ServiceResult(service, label, false, true, 0, "", false)
WorkBuddyRepository.CheckinOutcome.FAILED ->
WorkBuddyRepository.CheckinOutcome.FAILED -> {
recordFailure(service, account.id, CheckinStatusStore.STATUS_FAILED)
ServiceResult(service, label, false, false, 0, s.message, failed = true)
}
}
},
onFailure = { e -> failureResult(service, label, account.id, e) }
onFailure = { e ->
recordFailure(service, account.id, CheckinStatusStore.STATUS_FAILED)
failureResult(service, label, account.id, e)
}
)
// CHECKIN_SERVICES 只含上述两者;穷尽兜底(未来加服务忘了实现时安全返回失败)
else -> null
@ -173,7 +234,7 @@ class AutoCheckinManager @Inject constructor(
* 签到请求失败的汇报结果。
*
* 竞态兜底:签到请求在服务端已成功([RefreshBalanceUseCase.checkInTrae] 的 also 块
* 已 markChecked),但与并发余额刷新争抢凭据快照导致结果被 [RepositoryError.CredentialChanged]
* 已写 CheckinStatusStore 成功记录),但与并发余额刷新争抢凭据快照导致结果被 [RepositoryError.CredentialChanged]
* 丢弃时——若本地记录确认今日已签,按「今日已签到」汇报,而不是报「凭据」错误
* (用户会看到积分实际到账却提示失败的矛盾)。
*/
@ -199,16 +260,37 @@ class AutoCheckinManager @Inject constructor(
)
}
/**
* 落一条非成功签到记录(仅当今天还没有成功记录时写入)。
* 失败/限流/设备拦截不消费当天签到机会,重开 App 仍会重试;
* 但若在一次 CredentialChanged 竞态中服务端实际已成功,保留成功记录不被覆盖。
*/
private fun recordFailure(
service: ServiceType,
accountId: String,
status: String
) {
if (!checkinStatusStore.isCheckedToday(service, accountId)) {
checkinStatusStore.record(service, accountId, status)
}
}
/** 弹窗关闭时调用,清除汇报状态(下次执行前不再重复弹)。 */
fun consumeReport() {
_report.value = null
}
private companion object {
companion object {
const val KEY_ENABLED = "enabled"
const val KEY_LAST_RUN = "last_run_date"
/** 每日自动签到闹钟的广播 action(供 [AutoCheckinReceiver] 分发)。 */
const val ACTION_DAILY_CHECKIN = "com.rainy.token.action.DAILY_AUTO_CHECKIN"
/** 每日签到目标时间:零点后 5 分钟(避开日切边界),本地时区。 */
const val CHECKIN_HOUR = 0
const val CHECKIN_MINUTE = 5
}
}
/** 支持每日签到的服务(与 Dashboard 的 CHECKIN_SERVICES 同一口径,避免两处漂移)。 */
/** 支持每日签到的服务(DashboardViewModel 与自动签到共用的唯一来源,避免两处漂移)。 */
internal val CHECKIN_SERVICES = listOf(ServiceType.TRAE, ServiceType.WORKBUDDY)

View File

@ -0,0 +1,60 @@
package com.rainy.token.ui.dashboard.autocheckin
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import dagger.hilt.android.EntryPointAccessors
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/**
* 每日自动签到后台触发点。
*
* 两种入口共用同一个接收器:
* - [AutoCheckinManager.ACTION_DAILY_CHECKIN]:AlarmManager 每日零点后发出的闹钟广播,
* 到点就为该服务的全部已配置账号签到;
* - ACTION_BOOT_COMPLETED:设备重启后闹钟会被系统清空,这里只负责重新排程,不抢签。
*
* 通过 EntryPointAccessors 获取 Hilt 单例,不依赖 @AndroidEntryPoint。
*/
class AutoCheckinReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action
val appContext = context.applicationContext
val pendingResult = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
val manager = EntryPointAccessors.fromApplication(
appContext, AutoCheckinEntryPoint::class.java
).autoCheckinManager()
when (action) {
AutoCheckinManager.ACTION_DAILY_CHECKIN -> {
// 到点签到(关闭时 runDailyIfDue 自身会短路返回);
// 无论本次签到是否抛异常,都必须把一次性闹钟重排到明天
try {
manager.runDailyIfDue()
} finally {
manager.refreshSchedule()
}
}
Intent.ACTION_BOOT_COMPLETED -> {
// 重启后闹钟丢失,重新排程(今天是否签到由首次打开 App 兜底)
manager.refreshSchedule()
}
}
} catch (cancelled: CancellationException) {
throw cancelled
} catch (_: Exception) {
// 后台静默:签到/排程失败不阻塞下次闹钟,等下次打开 App 再补救
} finally {
pendingResult.finish()
}
}
}
}

View File

@ -66,24 +66,24 @@ import kotlinx.coroutines.flow.first
// ── 热力图颜色 ──────────────────────────────────────────────
/** 亮色模式 6 级粉色梯度 */
/** 亮色模式 6 级玫瑰粉梯度(精致粉调,最高级与主色一致) */
private val LightHeatmapColors = listOf(
Color(0xFFEFE0E5), // Level 0: 极浅暖灰
Color(0xFFFFD6E0), // Level 1: 浅樱粉
Color(0xFFFFB3C6), // Level 2: 草莓粉柔
Color(0xFFFF85A2), // Level 3: 草莓粉
Color(0xFFFF6B8E), // Level 4: 草莓粉深
Color(0xFFE84973), // Level 5: 玫红
Color(0xFFF3E9EE), // Level 0: 极浅暖灰
Color(0xFFFFE3EC), // Level 1: 浅樱粉
Color(0xFFFFC9D9), // Level 2: 浅玫瑰柔
Color(0xFFFFA3BC), // Level 3: 玫瑰粉
Color(0xFFF27E9F), // Level 4: 玫瑰粉深
Color(0xFFC9436C), // Level 5: 深玫瑰(主色)
)
/** 暗色模式 6 级粉色梯度 */
/** 暗色模式 6 级玫瑰粉梯度 */
private val DarkHeatmapColors = listOf(
Color(0xFF3A2A30), // Level 0: 暖深灰
Color(0xFF4A2E3A), // Level 1
Color(0xFF6B3D4A), // Level 2
Color(0xFF8B4D5A), // Level 3
Color(0xFFB86880), // Level 4
Color(0xFFD67F9A), // Level 5
Color(0xFF3A2B33), // Level 0: 暖深灰
Color(0xFF4A3239), // Level 1
Color(0xFF643D4A), // Level 2
Color(0xFF855062), // Level 3
Color(0xFFA9627A), // Level 4
Color(0xFFE07C9A), // Level 5
)
/** 月份名称资源(按月索引 1~12,locale-aware) */

View File

@ -3,40 +3,41 @@ package com.rainy.token.ui.theme
import androidx.compose.ui.graphics.Color
/**
* 雨晴风格配色(RainyStyle Palette)。
* 雨晴风格配色(RainyStyle Palette)— 精致粉调。
*
* 视觉基调:元气樱粉 / 草莓粉主调,搭配纯白卡片与暖灰文本。
* 视觉基调:低饱和樱粉白背景 + 纯白卡片 + 深玫瑰主色点缀,
* 通过克制用色与留白呈现精致感,保留"雨晴粉"品牌识别度。
* 状态色:草绿(正常)、暖橙(注意)、玫红(异常)。
*
* 背景使用樱粉渐变(#FFF0F5 → #FFD1DC),主元素采用草莓粉
* (#FF85A2 / #FF6B8E),与白色形成层次。
* 背景使用柔和粉白渐变(#FFF9FB → #FDEFF4),主元素采用深玫瑰
* (#C9436C / #B3305B),与白色形成优雅层次。
*/
// ─── 品牌主色 ───
val CherryPinkLight = Color(0xFFFFF0F5) // 樱粉浅(背景起始)
val CherryPinkDeep = Color(0xFFFFD1DC) // 樱粉深(背景结束)
val StrawberryPink = Color(0xFFFF85A2) // 草莓粉(主色 / 强调)
val StrawberryPinkDark = Color(0xFFFF6B8E)// 草莓粉深(按下态 / 主按钮)
val StrawberryPinkSoft = Color(0xFFFFB3C6)// 草莓粉柔(hover / 副按钮)
val CherryPinkLight = Color(0xFFFFF9FB) // 粉白(背景起始)
val CherryPinkDeep = Color(0xFFFDEFF4) // 浅樱粉(背景结束)
val StrawberryPink = Color(0xFFC9436C) // 深玫瑰(主色 / 强调,白底对比度 ≥ 4.5:1)
val StrawberryPinkDark = Color(0xFFB3305B)// 深玫瑰按下态(主按钮)
val StrawberryPinkSoft = Color(0xFFFFC9D8)// 浅玫瑰柔(容器 / 选中背景)
// ─── 中性色 ───
val PureWhite = Color(0xFFFFFFFF)
val SnowWhite = Color(0xFFFAF6F8) // 卡片次级背景
val InkWarm = Color(0xFF3D2C35) // 暖黑(主文本)
val InkMuted = Color(0xFF7A6B72) // 暖灰(次要文本)
val InkOutline = Color(0xFFD9CFD3) // 暖灰(分隔线 / 描边)
val SnowWhite = Color(0xFFFAF5F7) // 卡片次级背景
val InkWarm = Color(0xFF3A2B31) // 暖黑(主文本)
val InkMuted = Color(0xFF7A6870) // 暖灰(次要文本,白底 ≥ 4.5:1)
val InkOutline = Color(0xFFE8DCE1) // 暖灰(分隔线 / 描边)
// ─── 状态色 ───
val StatusGreen = Color(0xFF66BB6A) // 正常
val StatusGreen = Color(0xFF4CAF50) // 正常
val StatusOrange = Color(0xFFFFA726) // 注意 / 需重登
val StatusRed = Color(0xFFE91E63) // 错误 / 异常
val StatusRed = Color(0xFFD64A6E) // 错误 / 异常(玫红,与粉调统一)
val StatusBlue = Color(0xFF64B5F6) // 信息(Stale 缓存)
// ─── 暗色模式 ───
val DarkBackground = Color(0xFF1F1419) // 暖深背景
val DarkSurface = Color(0xFF2A1F25) // 卡片
val DarkOnSurface = Color(0xFFEFE0E5) // 卡片文本
val DarkPrimary = Color(0xFFFFB3C6) // 暗色下主色更亮
val DarkInkWarm = Color(0xFFEFE0E5) // 暗色下暖黑→浅粉白
val DarkInkMuted = Color(0xFFC9B8BE) // 暗色下暖灰→浅灰粉
val DarkInkOutline = Color(0xFF4D3A42) // 暗色下描边
val DarkBackground = Color(0xFF1A1417) // 暖深背景
val DarkSurface = Color(0xFF251C21) // 卡片
val DarkOnSurface = Color(0xFFF2E6EB) // 卡片文本
val DarkPrimary = Color(0xFFFFA9BF) // 暗色下主色更亮
val DarkInkWarm = Color(0xFFF2E6EB) // 暗色下暖黑→浅粉白
val DarkInkMuted = Color(0xFFCBB8C0) // 暗色下暖灰→浅灰粉
val DarkInkOutline = Color(0xFF4C3B44) // 暗色下描边

View File

@ -13,21 +13,22 @@ import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
/**
* 雨晴风格主题。
* 雨晴风格主题(精致粉调)。
*
* - **不使用** dynamicColor(系统主题色),固定使用雨晴樱粉品牌色。
* - **不使用** dynamicColor(系统主题色),固定使用雨晴玫瑰粉品牌色。
* 理由:dynamicColor 会让 APP 视觉随系统变化,破坏品牌一致性;
* 玩家一眼看到 APP icon + 粉色调就知道是 RainyToken 系列。
* - 暗色模式:暖深背景 + 亮草莓粉强调,跟 Light 模式同色系不同明度。
* - 主色:深玫瑰 #C9436C;容器色:浅玫瑰粉;背景:低饱和粉白渐变。
* - 暗色模式:暖深背景 + 亮玫瑰粉强调,跟 Light 模式同色系不同明度。
*/
private val LightColors = lightColorScheme(
primary = StrawberryPink,
onPrimary = PureWhite,
primaryContainer = StrawberryPinkSoft,
onPrimaryContainer = InkWarm,
secondary = StrawberryPinkDark,
onPrimaryContainer = Color(0xFF5C112C),
secondary = Color(0xFFB7698B),
onSecondary = PureWhite,
secondaryContainer = Color(0xFFFFE4EC),
secondaryContainer = Color(0xFFFFE7EE),
onSecondaryContainer = InkWarm,
tertiary = StatusGreen,
onTertiary = PureWhite,
@ -38,32 +39,32 @@ private val LightColors = lightColorScheme(
surfaceVariant = SnowWhite,
onSurfaceVariant = InkMuted,
outline = InkOutline,
outlineVariant = Color(0xFFEFE0E5),
outlineVariant = Color(0xFFF2E7EB),
error = StatusRed,
onError = PureWhite
)
private val DarkColors = darkColorScheme(
primary = DarkPrimary,
onPrimary = DarkBackground,
primaryContainer = StrawberryPinkDark,
onPrimaryContainer = PureWhite,
onPrimary = Color(0xFF4A1025),
primaryContainer = Color(0xFF6B2C42),
onPrimaryContainer = DarkOnSurface,
secondary = StrawberryPink,
onSecondary = DarkBackground,
secondaryContainer = Color(0xFF4A2E3A),
onSecondaryContainer = DarkOnSurface,
tertiary = StatusGreen,
onTertiary = DarkBackground,
tertiary = Color(0xFF7BCF9E),
onTertiary = Color(0xFF0D2B1C),
background = DarkBackground,
onBackground = DarkOnSurface,
surface = DarkSurface,
onSurface = DarkOnSurface,
surfaceVariant = Color(0xFF352329),
onSurfaceVariant = Color(0xFFC9B8BE),
outline = Color(0xFF4D3A42),
outlineVariant = Color(0xFF3A2A30),
error = Color(0xFFFF6B8E),
onError = PureWhite
surfaceVariant = Color(0xFF32262C),
onSurfaceVariant = Color(0xFFCBB8C0),
outline = Color(0xFF4C3B44),
outlineVariant = Color(0xFF3B2C33),
error = Color(0xFFFF8194),
onError = Color(0xFF4A1025)
)
@Composable

View File

@ -122,7 +122,7 @@ fun CodexOAuthScreen(
Text(
uiState.error!!.asString(),
style = MaterialTheme.typography.bodyLarge,
color = androidx.compose.ui.graphics.Color(0xFFE91E63)
color = MaterialTheme.colorScheme.error
)
TextButton(onClick = { viewModel.start(CodexOAuthMode.WEBVIEW) }) {
Text(stringResource(R.string.action_retry), color = StrawberryPink)
@ -286,7 +286,7 @@ private fun HeadlessOAuthContent(
Text(
text = error.asString(),
style = MaterialTheme.typography.bodySmall,
color = androidx.compose.ui.graphics.Color(0xFFE91E63),
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 4.dp)
)
TextButton(onClick = onRetry) {

View File

@ -339,9 +339,9 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
/** 根据用量百分比动态改进度条颜色 */
private fun setProgressColor(views: RemoteViews, barViewId: Int, pct: Int) {
val color = when {
pct >= 80 -> 0xFFE91E63.toInt() // 玫红
pct >= 80 -> 0xFFD64A6E.toInt() // 玫红
pct >= 50 -> 0xFFFFA726.toInt() // 暖橙
else -> 0xFFFF85A2.toInt() // 草莓粉
else -> 0xFFC9436C.toInt() // 深玫瑰(主色)
}
views.setColorStateList(barViewId, "setProgressTintList", ColorStateList.valueOf(color))
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -3,8 +3,8 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="16dp" />
<solid android:color="#2D1825" />
<solid android:color="#1A1417" />
<stroke
android:width="1dp"
android:color="#4D2D3D" />
android:color="#4C3B44" />
</shape>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 雨晴Token 背景 · 樱粉渐变 -->
<!-- 雨晴Token 背景 · 精致粉调渐变 -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
@ -12,9 +12,9 @@
android:startX="0" android:startY="0"
android:endX="108" android:endY="108"
android:type="linear">
<item android:offset="0" android:color="#FFF0F5" />
<item android:offset="0.5" android:color="#FFD1DC" />
<item android:offset="1" android:color="#FFB3C1" />
<item android:offset="0" android:color="#FFF9FB" />
<item android:offset="0.5" android:color="#FDEFF4" />
<item android:offset="1" android:color="#FBDCE8" />
</gradient>
</aapt:attr>
</path>

View File

@ -7,14 +7,14 @@
android:viewportHeight="108">
<!--
配色统一雨晴品牌系:
· 水滴:草莓粉 #FF85A2(主品牌色)
· sparkle:樱粉 #FFD1DC(浅层次)
配色统一雨晴品牌系(精致粉调):
· 水滴:深玫瑰 #C9436C(主品牌色)
· sparkle:浅玫瑰粉 #FFC9D8(浅层次)
· 高光:白色
· 背景:樱粉渐变 #FFF0F5→#FFD1DC→#FFB3C1
· 背景:粉白渐变 #FFF9FB→#FDEFF4→#FBDCE8
-->
<!-- 大水滴 · 草莓粉 -->
<!-- 大水滴 · 深玫瑰 -->
<path
android:pathData="
M54,30
@ -24,7 +24,7 @@
Q64,76 68,68
Q72,62 64,50
Q54,30 54,30Z"
android:fillColor="#FF85A2" />
android:fillColor="#C9436C" />
<!-- 水滴高光 · 白色椭圆 -->
<path
@ -32,24 +32,24 @@
android:fillColor="#FFFFFF"
android:fillAlpha="0.75" />
<!-- sparkle 小太阳 · 樱粉 · 右上角 (72,32) -->
<!-- sparkle 小太阳 · 浅玫瑰粉 · 右上角 (72,32) -->
<path android:pathData="M72,22L72,42"
android:strokeColor="#FFD1DC" android:strokeWidth="3"
android:strokeColor="#FFC9D8" android:strokeWidth="3"
android:strokeLineCap="round"
android:fillColor="@android:color/transparent" />
<path android:pathData="M62,32L82,32"
android:strokeColor="#FFD1DC" android:strokeWidth="3"
android:strokeColor="#FFC9D8" android:strokeWidth="3"
android:strokeLineCap="round"
android:fillColor="@android:color/transparent" />
<path android:pathData="M66,26L78,38"
android:strokeColor="#FFD1DC" android:strokeWidth="2"
android:strokeColor="#FFC9D8" android:strokeWidth="2"
android:strokeLineCap="round"
android:fillColor="@android:color/transparent" />
<path android:pathData="M78,26L66,38"
android:strokeColor="#FFD1DC" android:strokeWidth="2"
android:strokeColor="#FFC9D8" android:strokeWidth="2"
android:strokeLineCap="round"
android:fillColor="@android:color/transparent" />
<path android:pathData="M72,32m-2.5,0a2.5,2.5 0,1,1 5,0a2.5,2.5 0,1,1 -5,0"
android:fillColor="#FFD1DC" />
android:fillColor="#FFC9D8" />
</vector>

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
RainyToken 几何字标 · 与 OpenCode Go logo 完全同构
同样的两个 24x30 S 形方块拼接,只是配色换成雨晴粉色系:
- Go 深色 #211E1E → 粉色 #C2185B
- Go 浅色 #CFCECD → 浅粉 #FFD1DC
同样的两个 24x30 S 形方块拼接,只是配色换成雨晴精致粉系:
- Go 深色 #211E1E → 深玫瑰 #C9436C
- Go 浅色 #CFCECD → 浅玫瑰粉 #FFC9D8
viewBox 和 path 与 ic_opencode_go_logo 完全一致
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
@ -11,20 +11,20 @@
android:height="30dp"
android:viewportWidth="54"
android:viewportHeight="30">
<!-- 左块(深粉) -->
<!-- 左块(深玫瑰) -->
<path
android:fillColor="#C2185B"
android:fillColor="#C9436C"
android:pathData="M24 30H0V0H24V6H6V24H18V18H12V12H24V30Z"/>
<!-- 左块 S 内弯(浅粉) -->
<path
android:fillColor="#FFD1DC"
android:fillColor="#FFC9D8"
android:pathData="M12 18H18V24H6V12H12V18Z"/>
<!-- 右块 S 内弯(浅粉) -->
<path
android:fillColor="#FFD1DC"
android:fillColor="#FFC9D8"
android:pathData="M48 12V24H36V12H48Z"/>
<!-- 右块(深粉) -->
<!-- 右块(深玫瑰) -->
<path
android:fillColor="#C2185B"
android:fillColor="#C9436C"
android:pathData="M54 30H30V0H54V30ZM36 24H48V6H36V24Z"/>
</vector>

View File

@ -2,8 +2,8 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="16dp" />
<solid android:color="#FFF0F5" />
<solid android:color="#FFF9FB" />
<stroke
android:width="1dp"
android:color="#FFD1DC" />
android:color="#F2DCE4" />
</shape>

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- MIUI Widget 深色模式布局 — 文字颜色适配暗色背景 #2D1825 -->
<!-- MIUI Widget 深色模式布局 — 文字颜色适配暗色背景 #1A1417 -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@android:id/background"
android:layout_width="match_parent"
@ -35,7 +35,7 @@
android:layout_width="10dp"
android:layout_height="24dp"
android:text=">"
android:textColor="#8A7A82"
android:textColor="#CBB8C0"
android:textSize="14sp"
android:gravity="center" />
@ -44,7 +44,7 @@
android:layout_width="48dp"
android:layout_height="24dp"
android:text="OCGO"
android:textColor="#FF85A2"
android:textColor="#FFA9BF"
android:textSize="10sp"
android:textStyle="bold"
android:layout_marginStart="6dp"
@ -75,7 +75,7 @@
android:layout_width="40dp"
android:layout_height="24dp"
android:text="↻"
android:textColor="#FF85A2"
android:textColor="#FFA9BF"
android:textSize="20sp"
android:layout_marginStart="6dp"
android:gravity="center" />
@ -99,7 +99,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_title_opencode"
android:textColor="#C4A0AB"
android:textColor="#CBB8C0"
android:textSize="11sp" />
</LinearLayout>
@ -113,10 +113,10 @@
<TextView android:id="@+id/row1_label"
android:layout_width="44dp" android:layout_height="wrap_content"
android:maxLines="1" android:ellipsize="end"
android:text="5h" android:textColor="#C4A0AB" android:textSize="11sp" />
android:text="5h" android:textColor="#CBB8C0" android:textSize="11sp" />
<TextView android:id="@+id/row1_pct"
android:layout_width="38dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#FFD1DC" android:textSize="12sp"
android:text="--" android:textColor="#FFC9D8" android:textSize="12sp"
android:textStyle="bold" android:gravity="end" />
<ProgressBar android:id="@+id/row1_bar"
style="@android:style/Widget.ProgressBar.Horizontal"
@ -126,7 +126,7 @@
android:progressTint="@color/widget_bar_5h" />
<TextView android:id="@+id/row1_reset"
android:layout_width="48dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#8A7A82" android:textSize="10sp" />
android:text="--" android:textColor="#CBB8C0" android:textSize="10sp" />
</LinearLayout>
<!-- 本周 -->
@ -139,10 +139,10 @@
<TextView android:id="@+id/row2_label"
android:layout_width="44dp" android:layout_height="wrap_content"
android:maxLines="1" android:ellipsize="end"
android:text="@string/window_weekly" android:textColor="#C4A0AB" android:textSize="11sp" />
android:text="@string/window_weekly" android:textColor="#CBB8C0" android:textSize="11sp" />
<TextView android:id="@+id/row2_pct"
android:layout_width="38dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#FFD1DC" android:textSize="12sp"
android:text="--" android:textColor="#FFC9D8" android:textSize="12sp"
android:textStyle="bold" android:gravity="end" />
<ProgressBar android:id="@+id/row2_bar"
style="@android:style/Widget.ProgressBar.Horizontal"
@ -152,7 +152,7 @@
android:progressTint="@color/widget_bar_weekly" />
<TextView android:id="@+id/row2_reset"
android:layout_width="48dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#8A7A82" android:textSize="10sp" />
android:text="--" android:textColor="#CBB8C0" android:textSize="10sp" />
</LinearLayout>
<!-- 本月 -->
@ -165,10 +165,10 @@
<TextView android:id="@+id/row3_label"
android:layout_width="44dp" android:layout_height="wrap_content"
android:maxLines="1" android:ellipsize="end"
android:text="@string/window_monthly" android:textColor="#C4A0AB" android:textSize="11sp" />
android:text="@string/window_monthly" android:textColor="#CBB8C0" android:textSize="11sp" />
<TextView android:id="@+id/row3_pct"
android:layout_width="38dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#FFD1DC" android:textSize="12sp"
android:text="--" android:textColor="#FFC9D8" android:textSize="12sp"
android:textStyle="bold" android:gravity="end" />
<ProgressBar android:id="@+id/row3_bar"
style="@android:style/Widget.ProgressBar.Horizontal"
@ -178,7 +178,7 @@
android:progressTint="@color/widget_bar_monthly" />
<TextView android:id="@+id/row3_reset"
android:layout_width="48dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#8A7A82" android:textSize="10sp" />
android:text="--" android:textColor="#CBB8C0" android:textSize="10sp" />
</LinearLayout>
<!-- DeepSeek 余额(未配置 DeepSeek 时整行隐藏,id 供 RemoteViews setViewVisibility) -->
@ -200,14 +200,14 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_deepseek_balance"
android:textColor="#C4A0AB"
android:textColor="#CBB8C0"
android:textSize="11sp" />
<TextView
android:id="@+id/widget_ds_amount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="--"
android:textColor="#FFD1DC"
android:textColor="#FFC9D8"
android:textSize="11sp"
android:textStyle="bold"
android:layout_marginStart="4dp" />
@ -233,7 +233,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_brand"
android:textColor="#8A7A82"
android:textColor="#CBB8C0"
android:textSize="9sp" />
</LinearLayout>

View File

@ -36,7 +36,7 @@
android:layout_width="10dp"
android:layout_height="24dp"
android:text=">"
android:textColor="#C4A0AB"
android:textColor="#7A6870"
android:textSize="14sp"
android:gravity="center" />
@ -45,7 +45,7 @@
android:layout_width="48dp"
android:layout_height="24dp"
android:text="OCGO"
android:textColor="#FF85A2"
android:textColor="#C9436C"
android:textSize="10sp"
android:textStyle="bold"
android:layout_marginStart="6dp"
@ -69,7 +69,7 @@
android:maxLines="1"
android:ellipsize="end"
android:text="--"
android:textColor="#C4A0AB"
android:textColor="#7A6870"
android:textSize="10sp" />
<TextView
@ -77,7 +77,7 @@
android:layout_width="40dp"
android:layout_height="24dp"
android:text="↻"
android:textColor="#FF85A2"
android:textColor="#C9436C"
android:textSize="20sp"
android:layout_marginStart="6dp"
android:gravity="center" />
@ -105,7 +105,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_title_opencode"
android:textColor="#8A7A82"
android:textColor="#7A6870"
android:textSize="11sp" />
</LinearLayout>
@ -120,11 +120,11 @@
<TextView android:id="@+id/row1_label"
android:layout_width="44dp" android:layout_height="wrap_content"
android:maxLines="1" android:ellipsize="end"
android:text="5h" android:textColor="#8A7A82" android:textSize="11sp" />
android:text="5h" android:textColor="#7A6870" android:textSize="11sp" />
<TextView android:id="@+id/row1_pct"
android:layout_width="38dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#3D2C35" android:textSize="12sp"
android:text="--" android:textColor="#3A2B31" android:textSize="12sp"
android:textStyle="bold" android:gravity="end" />
<ProgressBar android:id="@+id/row1_bar"
@ -136,7 +136,7 @@
<TextView android:id="@+id/row1_reset"
android:layout_width="48dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#B0A0A7" android:textSize="10sp" />
android:text="--" android:textColor="#7A6870" android:textSize="10sp" />
</LinearLayout>
<!-- 本周 -->
@ -150,11 +150,11 @@
<TextView android:id="@+id/row2_label"
android:layout_width="44dp" android:layout_height="wrap_content"
android:maxLines="1" android:ellipsize="end"
android:text="@string/window_weekly" android:textColor="#8A7A82" android:textSize="11sp" />
android:text="@string/window_weekly" android:textColor="#7A6870" android:textSize="11sp" />
<TextView android:id="@+id/row2_pct"
android:layout_width="38dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#3D2C35" android:textSize="12sp"
android:text="--" android:textColor="#3A2B31" android:textSize="12sp"
android:textStyle="bold" android:gravity="end" />
<ProgressBar android:id="@+id/row2_bar"
@ -166,7 +166,7 @@
<TextView android:id="@+id/row2_reset"
android:layout_width="48dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#B0A0A7" android:textSize="10sp" />
android:text="--" android:textColor="#7A6870" android:textSize="10sp" />
</LinearLayout>
<!-- 本月 -->
@ -180,11 +180,11 @@
<TextView android:id="@+id/row3_label"
android:layout_width="44dp" android:layout_height="wrap_content"
android:maxLines="1" android:ellipsize="end"
android:text="@string/window_monthly" android:textColor="#8A7A82" android:textSize="11sp" />
android:text="@string/window_monthly" android:textColor="#7A6870" android:textSize="11sp" />
<TextView android:id="@+id/row3_pct"
android:layout_width="38dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#3D2C35" android:textSize="12sp"
android:text="--" android:textColor="#3A2B31" android:textSize="12sp"
android:textStyle="bold" android:gravity="end" />
<ProgressBar android:id="@+id/row3_bar"
@ -196,7 +196,7 @@
<TextView android:id="@+id/row3_reset"
android:layout_width="48dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#B0A0A7" android:textSize="10sp" />
android:text="--" android:textColor="#7A6870" android:textSize="10sp" />
</LinearLayout>
<!-- DeepSeek 余额(未配置 DeepSeek 时整行隐藏,id 供 RemoteViews setViewVisibility) -->
@ -221,7 +221,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_deepseek_balance"
android:textColor="#8A7A82"
android:textColor="#7A6870"
android:textSize="11sp" />
<TextView
@ -229,7 +229,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="--"
android:textColor="#3D2C35"
android:textColor="#3A2B31"
android:textSize="11sp"
android:textStyle="bold"
android:layout_marginStart="4dp" />
@ -258,7 +258,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_brand"
android:textColor="#D4C4CC"
android:textColor="#7A6870"
android:textSize="9sp" />
</LinearLayout>

View File

@ -42,7 +42,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_auto_refresh"
android:textColor="#C4A0AB"
android:textColor="#7A6870"
android:textSize="10sp" />
</LinearLayout>
@ -64,7 +64,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_title_opencode"
android:textColor="#8A7A82"
android:textColor="#7A6870"
android:textSize="11sp" />
</LinearLayout>
@ -79,11 +79,11 @@
<TextView
android:layout_width="44dp" android:layout_height="wrap_content"
android:maxLines="1" android:ellipsize="end"
android:text="5h" android:textColor="#8A7A82" android:textSize="11sp" />
android:text="5h" android:textColor="#7A6870" android:textSize="11sp" />
<TextView
android:layout_width="38dp" android:layout_height="wrap_content"
android:text="42%" android:textColor="#3D2C35" android:textSize="12sp"
android:text="42%" android:textColor="#3A2B31" android:textSize="12sp"
android:textStyle="bold" android:gravity="end" />
<ProgressBar
@ -91,7 +91,7 @@
android:layout_width="0dp" android:layout_height="6dp"
android:layout_weight="1" android:layout_marginStart="8dp"
android:layout_marginEnd="8dp" android:max="100" android:progress="42"
android:progressTint="#FF85A2" />
android:progressTint="#C9436C" />
</LinearLayout>
<!-- 本周 -->
@ -105,11 +105,11 @@
<TextView
android:layout_width="44dp" android:layout_height="wrap_content"
android:maxLines="1" android:ellipsize="end"
android:text="@string/window_weekly" android:textColor="#8A7A82" android:textSize="11sp" />
android:text="@string/window_weekly" android:textColor="#7A6870" android:textSize="11sp" />
<TextView
android:layout_width="38dp" android:layout_height="wrap_content"
android:text="68%" android:textColor="#3D2C35" android:textSize="12sp"
android:text="68%" android:textColor="#3A2B31" android:textSize="12sp"
android:textStyle="bold" android:gravity="end" />
<ProgressBar
@ -131,11 +131,11 @@
<TextView
android:layout_width="44dp" android:layout_height="wrap_content"
android:maxLines="1" android:ellipsize="end"
android:text="@string/window_monthly" android:textColor="#8A7A82" android:textSize="11sp" />
android:text="@string/window_monthly" android:textColor="#7A6870" android:textSize="11sp" />
<TextView
android:layout_width="38dp" android:layout_height="wrap_content"
android:text="35%" android:textColor="#3D2C35" android:textSize="12sp"
android:text="35%" android:textColor="#3A2B31" android:textSize="12sp"
android:textStyle="bold" android:gravity="end" />
<ProgressBar
@ -143,7 +143,7 @@
android:layout_width="0dp" android:layout_height="6dp"
android:layout_weight="1" android:layout_marginStart="8dp"
android:layout_marginEnd="8dp" android:max="100" android:progress="35"
android:progressTint="#FF85A2" />
android:progressTint="#C9436C" />
</LinearLayout>
<!-- DeepSeek 余额 -->
@ -165,14 +165,14 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_deepseek_balance"
android:textColor="#8A7A82"
android:textColor="#7A6870"
android:textSize="11sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="¥18.00"
android:textColor="#3D2C35"
android:textColor="#3A2B31"
android:textSize="11sp"
android:textStyle="bold"
android:layout_marginStart="4dp" />

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 进度条颜色:<50% 粉色 / 50-80% 橙色 / >80% 玫红 -->
<color name="widget_bar_5h">#FF85A2</color>
<!-- 进度条颜色:<50% 玫瑰 / 50-80% 橙色 / >80% 玫红(与 App 内状态色一致) -->
<color name="widget_bar_5h">#C9436C</color>
<color name="widget_bar_weekly">#FFA726</color>
<color name="widget_bar_monthly">#E91E63</color>
<color name="widget_bar_monthly">#D64A6E</color>
</resources>

136
docs/签到核心请求.md Normal file
View File

@ -0,0 +1,136 @@
# WB(WorkBuddy)与 Trae 每日签到核心请求
> 本文档整理自仓库现有实现(源码为准):
> - `app/src/main/java/com/rainy/token/data/repository/TraeRepository.kt`
> - `app/src/main/java/com/rainy/token/data/repository/WorkBuddyRepository.kt`
>
> 其中 **WB = 腾讯 WorkBuddy**(非微博)。所有请求均为 `POST`,body 默认为 `{}`。
## 一、端点汇总
| 服务 | 用途 | URL | Body |
| --- | --- | --- | --- |
| Trae(CN) | 查询签到状态 | `https://api.trae.cn/trae/api/v2/ug/checkin_credits/status` | `{}` |
| Trae(CN) | 执行签到 | `https://api.trae.cn/trae/api/v2/ug/checkin_credits/claim` | `{}`(9074 时换 `{"req_source":2}` → `{"req_source":1}`) |
| WorkBuddy | 查询签到状态(优先) | `https://www.codebuddy.cn/v2/billing/meter/checkin-activity-status` | `{}` |
| WorkBuddy | 查询签到状态(兜底) | `https://www.codebuddy.cn/v2/billing/meter/checkin-status` | `{}` |
| WorkBuddy | 执行签到 | `https://www.codebuddy.cn/v2/billing/meter/daily-checkin` | `{}` |
| WorkBuddy | accessToken 刷新 | `https://copilot.tencent.com/v2/plugin/auth/token/refresh` | 头 `X-Refresh-Token` |
## 二、Trae 核心请求源码
认证头:`Authorization: Cloud-IDE-JWT <jwt>`,`x-device-id` 必须是账号绑定的官方客户端设备 ID。
```kotlin
// 官方签到接口路径
const val CHECKIN_STATUS_PATH = "/trae/api/v2/ug/checkin_credits/status"
const val CHECKIN_CLAIM_PATH = "/trae/api/v2/ug/checkin_credits/claim"
private fun checkinRequest(
jwt: String,
deviceId: String,
deviceSrc: String,
path: String,
bodyJson: String = "{}"
): Result<String> {
val requestId = java.util.UUID.randomUUID().toString()
val request = Request.Builder()
.url("https://api.trae.cn" + path)
.addHeader("Content-Type", "application/json")
.addHeader("Accept", "*/*")
.addHeader("User-Agent", "VSCode 1.107.1 (TRAE SOLO CN)")
.addHeader("Authorization", "Cloud-IDE-JWT " + jwt)
.addHeader("X-User-Region", "CN")
.addHeader("Accept-Language", "zh-CN")
.addHeader("Package-Type", "stable_cn")
.addHeader("X-Lgw-Req-Sdk-Type", "3")
.addHeader("X-Market-Client-Id", "VSCode 1.107.1")
.addHeader("X-Device-Brand", "83DG")
.addHeader("X-Device-Type", "windows")
.addHeader("X-OS-Version", "Windows 11 Pro")
.addHeader("App-Version", "0.1.61")
.addHeader("X-Request-Id", requestId)
.addHeader("X-TT-Trace-Id", ttTraceId(requestId))
.addHeader("X-Device-Id", deviceId)
.addHeader("Sec-Fetch-Dest", "empty")
.addHeader("Sec-Fetch-Mode", "no-cors")
.addHeader("Sec-Fetch-Site", "none")
.post(bodyJson.toRequestBody("application/json".toMediaType()))
.build()
// ...
}
```
### Trae 签到结果判定
- `code = 0 / 200` → **SUCCESS**(成功)
- `code = 1001 / 10001`,或文案含 `已签到 / 已经签到 / 明日再来 / 今日已完成 / 已领取 / already / checked / claimed` → **ALREADY**(今日已签)
- 文案含 `设备 / device / machine` → **DEVICE_BLOCKED**(设备级拦截)
- 文案含 `参与 / 稍后重试 / 火爆 / 繁忙 / 限流 / 排队 / too many / retry / busy / popular / later` → **RETRYABLE**(临时限流)
- 其余 → **FAILED**
### Trae 注意事项
- UA 必须是 `VSCode 1.107.1 (TRAE SOLO CN)`,这是签到走的 VSCode 插件进程身份;与 IDE 主进程 / Web 混用会触发风控。
- `X-Device-Id` 不可用固定 ClientID `en1oxy7wnw8j9n`(所有用户相同),会按设备风控拒绝。
- 仓库内置默认设备 ID:`ACCOUNT_BOUND_DEVICE_ID = "895482260673817"`,账号级,换设备需更新。
- INTL 区签到接口未实测,仓库实现直接不支持。
## 三、WorkBuddy(WB)核心请求源码
认证头:`Authorization: Bearer <accessToken>`。签到域名是 `www.codebuddy.cn`(余额主查询才是 `www.workbuddy.cn`)。
```kotlin
// 查询今日签到状态(两条路径依次 fallback,共享 token 轮换状态)
suspend fun fetchCheckinStatus(accountId: String? = null): Result<CheckinStatus> {
val body = authedPostFirstSuccessful(
credential,
accountId,
"/v2/billing/meter/checkin-activity-status",
"/v2/billing/meter/checkin-status"
) ?: return Result.failure(RepositoryError.Unknown())
return runCatching { parseCheckin(body) }...
}
// 执行签到
suspend fun checkIn(accountId: String? = null): Result<CheckinStatus> {
val body = when (val r = authedPost(credential, "/v2/billing/meter/daily-checkin")) {
is AuthedPostResult.Ok -> r.body
is AuthedPostResult.Failed -> r.body
// ...
}
// code=0 成功;code=10001 或文案含「已签/今日/already」→ 已签
}
// 带自动 refresh 一次的 POST 核心
private fun authedPost(credential: Credential.WorkBuddyCredential, path: String): AuthedPostResult {
var access = credential.accessToken
var refresh = credential.refreshToken
repeat(2) { attempt ->
val request = Request.Builder()
.url("https://www.codebuddy.cn" + path)
.addHeader("Authorization", "Bearer " + access)
.addHeader("Accept", "application/json")
.addHeader("Content-Type", "application/json")
.post("{}".toRequestBody(jsonMedia))
.build()
// 2xx → Ok
// 401/403 且第一次尝试 → tryRefesh(refresh) 换新 token 后重试
// refresh 请求头携带 X-Refresh-Token <refreshToken>
// 轮换出的新凭据必须先落库再用,否则旧 refreshToken 单次消费后失效
}
}
```
### WorkBuddy 签到结果判定
- `code = 0` → **SUCCESS**
- `code = 10001`,或文案含 `已签 / 已经签到 / 今日 / already / checked / claimed` → **ALREADY**(今日已签)
- 其余 → **FAILED**(HTTP 400 body 也可能携带签到业务语义,需透传解析,不能直接当网络错误丢弃)
## 四、通用约定
1. Trae 用 `Cloud-IDE-JWT`,WorkBuddy 用 `Bearer`,两者凭据类型不同,不能混用。
2. 两者签到都幂等:今日已签时服务端返回已签语义,客户端本地也会记录 `lastCheckinDate` 短路,避免重复请求。
3. 401/403:Trae 直接视为凭据失效;WorkBuddy 会尝试用 refreshToken 轮换一次。
4. 不要随机生成设备 ID(Trae)或篡改客户端头,否则会被风控(Trae 9074「参与用户太多」)。