Skip to content

Commit a3031a9

Browse files
committed
feat: TRAE 签到接入(此前「credential does not support checkin」)
M1.5 只实现了 CodeBuddy 的签到,TRAE 的签到端点从未接入—— 界面上给所有凭证都渲染了「签到」按钮,但 TRAE 点了必然报 「credential does not support checkin」。这是「按钮与能力不匹配」。 按原实现接入(trae2api-web 的 CheckinStatus/CheckinClaim): - 先查 /trae/api/v2/ug/checkin_credits/status(checked_in/credits/enable) - 未签且 enable=true 才调 claim;checked_in=true 视为成功(already_checked_in) - enable=false → 明确提示「当前账号不可签到」 - 请求头走 UgHeaders(Authorization + X-User-Region: CN + X-Device-Id) 顺带统一:CheckinResult 从 codebuddy/checkin.py 上移到 provider/base.py, 两个 provider 共用同一类型(此前 TRAE 缺签到正是这个类型归属混乱的表现)。 CheckinTask 无需改动——它只依赖 provider.checkin/checkin_scope 协议。 后端 600 测试 / 覆盖 100%。真实凭证验证: TRAE 签到 ok=true,额度探测随之刷新(4500 → 4499.92)。
1 parent 0a05ea6 commit a3031a9

6 files changed

Lines changed: 199 additions & 21 deletions

File tree

‎src/provider/base.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,17 @@ def health_score(quota: Quota | None) -> int | None:
7474
return max(0, min(100, round(remaining / quota.total * 100)))
7575

7676

77+
@dataclass(slots=True)
78+
class CheckinResult:
79+
"""签到结果。already_checked_in 表示当日已签(不算错误)。"""
80+
81+
ok: bool
82+
credit: float | None = None
83+
code: int | None = None
84+
message: str = ""
85+
already_checked_in: bool = False
86+
87+
7788
@dataclass(slots=True)
7889
class Model:
7990
id: str

‎src/provider/codebuddy/checkin.py‎

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,26 +9,17 @@
99
from __future__ import annotations
1010

1111
import math
12-
from dataclasses import dataclass
1312
from typing import Any
1413

1514
import httpx
1615

16+
from ...provider.base import CheckinResult
1717
from .client import build_headers
1818
from .credential import CodeBuddyCredential
1919
from .events import UpstreamProtocolViolation
2020
from .headers import EP_DAILY_CHECKIN
2121

2222

23-
@dataclass(slots=True)
24-
class CheckinResult:
25-
ok: bool
26-
credit: float | None = None
27-
code: int | None = None
28-
message: str = ""
29-
already_checked_in: bool = False
30-
31-
3223
def parse_checkin_response(body: Any) -> CheckinResult:
3324
"""严格按上游语义解析:code=0 且 credit 为有限数值才算成功。"""
3425
if not isinstance(body, dict):

‎src/provider/trae/client.py‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import httpx
1414

1515
from ...engine.sse import iter_frames
16-
from ...provider.base import AuthSession, ErrKind, Event, Model, Quota
16+
from ...provider.base import AuthSession, CheckinResult, ErrKind, Event, Model, Quota
1717
from . import events as trae_events
1818
from .callback import (
1919
CallbackInfo,
@@ -270,6 +270,21 @@ async def fetch_quota(self, credential: TraeCredential) -> Quota:
270270
used += float(pack_used) if isinstance(pack_used, (int, float)) else 0.0
271271
return Quota(remaining=max(0.0, limit - used), total=limit, probed_at=int(time.time()))
272272

273+
async def fetch_checkin_status(self, credential: TraeCredential) -> dict[str, Any]:
274+
"""checkin_credits/status:checked_in / credits / enable。"""
275+
data = await self._post_json(
276+
f"{self.ug_host}{trae_events.EP_CHECKIN_STATUS}", {}, ug_headers(credential))
277+
return {
278+
"checked_in": bool(data.get("checked_in")),
279+
"credits": data.get("credits"),
280+
"enable": bool(data.get("enable")),
281+
}
282+
283+
async def claim_checkin(self, credential: TraeCredential) -> dict[str, Any] | None:
284+
"""checkin_credits/claim:领取当日积分。"""
285+
return await self._post_json(
286+
f"{self.ug_host}{trae_events.EP_CHECKIN_CLAIM}", {}, ug_headers(credential))
287+
273288
async def refresh_token(self, credential: TraeCredential) -> TraeCredential:
274289
"""ExchangeToken;失败不改写原凭证字段。"""
275290
host = credential.api_host or self.oauth_host
@@ -372,6 +387,25 @@ async def aclose(self) -> None:
372387
"""释放内部 HTTP 连接池。"""
373388
await self.client.aclose()
374389

390+
async def checkin(self, credential_data: dict) -> CheckinResult:
391+
"""TRAE 签到:先查状态,未签且可签才领取。
392+
393+
上游没有独立的「已签到」错误码,status.checked_in 就是已签语义。
394+
"""
395+
credential = TraeCredential.from_dict(credential_data)
396+
status = await self.client.fetch_checkin_status(credential)
397+
if status["checked_in"]:
398+
return CheckinResult(ok=True, credit=None, message="今天已签到",
399+
already_checked_in=True)
400+
if not status["enable"]:
401+
return CheckinResult(ok=False, message="当前账号不可签到")
402+
await self.client.claim_checkin(credential)
403+
return CheckinResult(ok=True, credit=None)
404+
405+
def checkin_scope(self, credential_data: dict) -> str:
406+
"""同上游账号的多凭证共享一次签到。"""
407+
return f"trae|{credential_data.get('uid', '')}"
408+
375409
# ------------------------------------------------- callback 轨道(Q17=C)
376410

377411
def parse_login_url(self, raw_url: str) -> CallbackInfo:

‎src/provider/trae/events.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
EP_MODELS = "/api/ide/v1/get_detail_param"
2929
EP_EXCHANGE = "/cloudide/api/v3/trae/oauth/ExchangeToken"
3030
EP_USER_INFO = "/cloudide/api/v3/trae/GetUserInfo"
31+
EP_CHECKIN_STATUS = "/trae/api/v2/ug/checkin_credits/status"
32+
EP_CHECKIN_CLAIM = "/trae/api/v2/ug/checkin_credits/claim"
3133

3234

3335
class UpstreamProtocolViolation(ValueError):

‎tests/test_m15_operations.py‎

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -797,14 +797,45 @@ async def test_checkin_dedupes_same_upstream_account(repo):
797797
assert provider.checkin_calls == 1
798798

799799

800-
async def test_checkin_skips_disabled_and_unsupported(repo):
800+
async def test_checkin_skips_disabled(repo):
801801
credentials, _db = repo
802802
credential_id = credentials.add(provider="codebuddy", credential_data={"bearer_token": "a"})
803803
credentials.save_error(credential_id, _disabled_outcome())
804-
credentials.add(provider="trae", credential_data={"accessToken": "t"})
805-
task = CheckinTask(credentials, {"codebuddy": ProbeProvider(), "trae": TraeProvider()})
804+
task = CheckinTask(credentials, {"codebuddy": ProbeProvider()})
806805
report = await task.run_once()
807806
assert report.attempted == 0
807+
assert report.skipped == 1
808+
809+
810+
async def test_checkin_supports_both_providers(repo):
811+
"""TRAE 与 CodeBuddy 都实现签到后,任务要能覆盖两个上游。"""
812+
credentials, _db = repo
813+
credentials.add(provider="codebuddy", credential_data={"bearer_token": "a",
814+
"account_uid": "cb"})
815+
credentials.add(provider="trae", credential_data={"accessToken": "t", "uid": "tr"})
816+
817+
class TraeCheckinProvider:
818+
id = "trae"
819+
820+
def __init__(self) -> None:
821+
self.calls = 0
822+
823+
async def checkin(self, _data):
824+
self.calls += 1
825+
from src.provider.base import CheckinResult
826+
827+
return CheckinResult(ok=True, credit=None, message="今天已签到",
828+
already_checked_in=True)
829+
830+
def checkin_scope(self, data):
831+
return f"trae|{data.get('uid', '')}"
832+
833+
trae = TraeCheckinProvider()
834+
task = CheckinTask(credentials, {"codebuddy": ProbeProvider(), "trae": trae})
835+
report = await task.run_once()
836+
assert report.attempted == 2
837+
assert report.succeeded == 2
838+
assert trae.calls == 1
808839

809840

810841
def _disabled_outcome():
@@ -2079,7 +2110,6 @@ def test_schedule_probe_returns_early_when_credential_unreadable(admin_client):
20792110

20802111
def test_trae_start_auth_builds_login_url_with_public_callback():
20812112
from src.main import resolve_public_callback_url
2082-
from src.provider.trae.client import TraeProvider
20832113

20842114
settings = Settings(_env_file=None, APP_SECRET="s", PUBLIC_BASE_URL="https://gw.example")
20852115
provider = TraeProvider()
@@ -2096,7 +2126,7 @@ async def test_trae_complete_callback_exchanges_token():
20962126
"""回调链接必须真的换 token,而不是只存 refreshToken。"""
20972127
import httpx as _httpx
20982128

2099-
from src.provider.trae.client import TraeClient, TraeProvider
2129+
from src.provider.trae.client import TraeClient
21002130

21012131
def handler(request: _httpx.Request) -> _httpx.Response:
21022132
if request.url.path.endswith("ExchangeToken"):
@@ -2123,7 +2153,6 @@ def handler(request: _httpx.Request) -> _httpx.Response:
21232153

21242154

21252155
async def test_trae_complete_callback_rejects_bad_state():
2126-
from src.provider.trae.client import TraeProvider
21272156
from src.provider.trae.events import UpstreamProtocolViolation
21282157

21292158
provider = TraeProvider()
@@ -2135,7 +2164,7 @@ def test_authorize_completes_trae_login_end_to_end(tmp_path):
21352164
"""完整闭环:start → 浏览器回调 → 凭证入库 → 立即探测。"""
21362165
import httpx as _httpx
21372166

2138-
from src.provider.trae.client import TraeClient, TraeProvider
2167+
from src.provider.trae.client import TraeClient
21392168

21402169
def handler(request: _httpx.Request) -> _httpx.Response:
21412170
if request.url.path.endswith("ExchangeToken"):
@@ -2214,7 +2243,7 @@ async def test_complete_callback_does_not_use_refresh_token_as_access_token():
22142243
"""ExchangeToken 失败时不得把 refreshToken 当 accessToken 塞进池子。"""
22152244
import httpx as _httpx
22162245

2217-
from src.provider.trae.client import TraeClient, TraeProvider
2246+
from src.provider.trae.client import TraeClient
22182247
from src.provider.trae.events import UpstreamProtocolViolation
22192248

22202249
def handler(request: _httpx.Request) -> _httpx.Response:
@@ -2236,7 +2265,7 @@ def handler(request: _httpx.Request) -> _httpx.Response:
22362265
async def test_complete_callback_raises_when_no_token_available():
22372266
import httpx as _httpx
22382267

2239-
from src.provider.trae.client import TraeClient, TraeProvider
2268+
from src.provider.trae.client import TraeClient
22402269
from src.provider.trae.events import UpstreamProtocolViolation
22412270

22422271
def handler(_request: _httpx.Request) -> _httpx.Response:
@@ -2398,7 +2427,7 @@ def test_authorize_accepts_real_trae_callback_without_state(tmp_path):
23982427
"""
23992428
import httpx as _httpx
24002429

2401-
from src.provider.trae.client import TraeClient, TraeProvider
2430+
from src.provider.trae.client import TraeClient
24022431

24032432
def handler(request: _httpx.Request) -> _httpx.Response:
24042433
if request.url.path.endswith("ExchangeToken"):

‎tests/test_m1a_gaps.py‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,3 +654,114 @@ def test_solo_headers_include_uid_when_present():
654654
assert headers["X-Uid"] == "u-9"
655655
no_uid = solo_headers(TraeCredential(access_token="a"))
656656
assert "X-Uid" not in no_uid
657+
658+
659+
# ------------------------------------------- TRAE 签到(三态 + 真实请求)
660+
661+
def _trae_client(handler) -> TraeClient:
662+
import httpx as _httpx
663+
664+
transport = _httpx.MockTransport(handler)
665+
return TraeClient(stream_client=_httpx.AsyncClient(transport=transport, timeout=None),
666+
short_client=_httpx.AsyncClient(transport=transport, timeout=None))
667+
668+
669+
async def test_trae_checkin_status_and_claim_use_ug_headers():
670+
"""签到两端点必须走 ug_headers(含 X-User-Region),且用 POST。"""
671+
seen: list[tuple[str, dict]] = []
672+
673+
def handler(request: httpx.Request) -> httpx.Response:
674+
seen.append((request.url.path, dict(request.headers)))
675+
if request.url.path.endswith("checkin_credits/status"):
676+
return httpx.Response(200, json={"checked_in": False, "credits": 0,
677+
"enable": True})
678+
return httpx.Response(200, json={"credits": 200})
679+
680+
client = _trae_client(handler)
681+
status = await client.fetch_checkin_status(TraeCredential(access_token="a", device_id="d"))
682+
assert status == {"checked_in": False, "credits": 0, "enable": True}
683+
684+
claim = await client.claim_checkin(TraeCredential(access_token="a", device_id="d"))
685+
assert claim == {"credits": 200}
686+
687+
assert len(seen) == 2
688+
for path, headers in seen:
689+
assert "checkin_credits" in path
690+
# httpx 内部存储全小写
691+
assert headers.get("x-user-region") == "CN"
692+
assert headers.get("authorization") == "Cloud-IDE-JWT a"
693+
694+
695+
async def test_trae_provider_checkin_already_is_success():
696+
"""已签到(status.checked_in=true)→ ok=true + already_checked_in。"""
697+
from src.provider.trae.client import TraeProvider
698+
699+
async def handler(_request: httpx.Request) -> httpx.Response:
700+
return httpx.Response(200, json={"checked_in": True, "credits": 0, "enable": True})
701+
702+
provider = TraeProvider(client=_trae_client(handler))
703+
result = await provider.checkin({"accessToken": "a"})
704+
assert result.ok is True and result.already_checked_in is True
705+
assert result.message == "今天已签到"
706+
707+
708+
async def test_trae_provider_checkin_disabled_reports_not_ok():
709+
"""status.enable=false → 不可签到。"""
710+
from src.provider.trae.client import TraeProvider
711+
712+
async def handler(_request: httpx.Request) -> httpx.Response:
713+
return httpx.Response(200, json={"checked_in": False, "credits": 0, "enable": False})
714+
715+
provider = TraeProvider(client=_trae_client(handler))
716+
result = await provider.checkin({"accessToken": "a"})
717+
assert result.ok is False
718+
719+
720+
async def test_trae_provider_checkin_claims_when_eligible():
721+
"""未签且可签 → 调 claim 并成功。"""
722+
paths: list[str] = []
723+
724+
def handler(request: httpx.Request) -> httpx.Response:
725+
paths.append(request.url.path)
726+
if request.url.path.endswith("checkin_credits/status"):
727+
return httpx.Response(200, json={"checked_in": False, "credits": 0,
728+
"enable": True})
729+
return httpx.Response(200, json={"credits": 500})
730+
731+
from src.provider.trae.client import TraeProvider
732+
733+
provider = TraeProvider(client=_trae_client(handler))
734+
result = await provider.checkin({"accessToken": "a"})
735+
assert result.ok is True and result.already_checked_in is False
736+
assert any(p.endswith("checkin_credits/claim") for p in paths)
737+
738+
739+
def test_trae_checkin_scope_uses_uid():
740+
from src.provider.trae.client import TraeProvider
741+
742+
provider = TraeProvider()
743+
assert provider.checkin_scope({"uid": "u1"}) == "trae|u1"
744+
assert provider.checkin_scope({}) == "trae|"
745+
746+
747+
async def test_checkin_task_skips_provider_without_scope(tmp_path):
748+
"""provider 没有 checkin_scope 时跳过(background 148-150)。"""
749+
from src.db.conn import Database
750+
from src.db.crypto import CredentialCipher
751+
from src.db.migrate import apply_schema
752+
from src.db.repo import CredentialRepository
753+
754+
db = Database(tmp_path / "ns.sqlite3")
755+
apply_schema(db.connect())
756+
credentials = CredentialRepository(db, CredentialCipher("s"))
757+
credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"})
758+
759+
from src.tasks.background import CheckinTask
760+
761+
class NoScope:
762+
id = "codebuddy"
763+
764+
task = CheckinTask(credentials, {"codebuddy": NoScope()})
765+
report = await task.run_once()
766+
assert report.skipped == 1
767+
db.close()

0 commit comments

Comments
 (0)