feat(qoder): 弄清国际版每日 100 Credits 的「按设备下发」规则 + 一键设备授权登录

排查结论(本机对真实接口做了 30+ 组对照实验)
- GET /sash/api/v1/me/campaigns 只返回一条常驻 VIEW_DETAILS 广告
  (act-20260901-493,BOGO 订阅推广,claimStatus=CLAIMED 与签到无关)
- 无论 Cosy-MachineToken/Code/Type 用 env 0/3/4/prod、裸调用/带 stdin、
  是否附 Cosy-MachineId/OS/Hostname/Origin/Referer/X-*、是否带 clientType,
  都拿不到 CLAIM_BEFIT 轮次;账号本人 balances/achievements 正常
- 三个社区实现(qoder2api-hub、dsh-connect-qoder、CreditDaddy)一致:
  每日轮次按「登录过官方客户端的那台设备」下发,NAS 侧另造的设备看不到它

修复
- _benefits() 改为只认 actionType=CLAIM_BEFIT(不再把 VIEW_DETAILS 当签到),
  窗口过期也排除;claimStatus 未知按可领处理(领取接口幂等)
- 两段式取活动:先裸取拿 uid → 按 uid 生成风控身份 → 再带设备身份重取
- 状态文案改为「未下发每日签到活动」而不是误导性的「今日已领取」

新功能
- Qoder 一键登录(OAuth 设备授权 / PKCE S256):应用内点按钮 → 浏览器确认一次,
  Token 直接绑定本机设备;同 uid 已存在则更新凭证而不是新增重复账号
- 手工导入桌面端设备身份接口 /api/qoder/import_device
- 诊断接口:/api/qoder/{probe,experiment,growth,matrix,desktop}

测试:39 后端 + 25 E2E + 9 前端全绿
This commit is contained in:
AgentCheckin
2026-10-19 12:49:06 +08:00
parent c263edc3e7
commit 556e45d642
11 changed files with 943 additions and 70 deletions
+187
View File
@@ -347,6 +347,185 @@ def h_logs(params, _body):
return {"lines": [], "error": str(e)}
def h_qoder_probe(_params, body):
"""诊断:转储 Qoder 接口原始响应(含一次幂等领取)。"""
import qoder as _q
acc_id = body.get("id")
acc = None
for a in load_accounts():
if a.get("id") == acc_id:
acc = a
break
if acc is None:
return ("error", "账号不存在")
if acc.get("platform") != "qoder":
return ("error", "该账号不是 Qoder")
return _q.raw_probe(acc)
def h_qoder_experiment(_params, body):
"""诊断:定位「每日领 100 Credits」活动下发条件的对照实验(只读)。"""
import qoder as _q
acc = None
for a in load_accounts():
if a.get("id") == body.get("id"):
acc = a
break
if acc is None:
return ("error", "账号不存在")
if acc.get("platform") != "qoder":
return ("error", "该账号不是 Qoder")
return _q.checkin_experiment(acc)
def h_qoder_growth(_params, body):
"""诊断:扒活动 iframe 页面/JS,定位每日签到真实接口。"""
import qoder as _q
acc = None
for a in load_accounts():
if a.get("id") == body.get("id"):
acc = a
break
if acc is None:
return ("error", "账号不存在")
return _q.growth_probe(acc)
def h_qoder_matrix(_params, body):
"""诊断:umid 调用方式 × 请求头组合的对照矩阵。"""
import qoder as _q
acc = None
for a in load_accounts():
if a.get("id") == body.get("id"):
acc = a
break
if acc is None:
return ("error", "账号不存在")
return _q.identity_matrix_probe(acc)
def h_qoder_desktop(_params, body):
"""诊断:模拟官方桌面端出站头,寻找能拿到 CLAIM_BENEFIT 的组合。"""
import qoder as _q
acc = None
for a in load_accounts():
if a.get("id") == body.get("id"):
acc = a
break
if acc is None:
return ("error", "账号不存在")
return _q.desktop_emulation_probe(acc)
def h_qoder_login_start(_params, body):
"""发起 Qoder OAuth 设备授权登录(一键登录)。"""
import qoder as _q
realm = body.get("realm") or "intl"
if realm not in ("intl", "cn"):
return ("error", "realm 只能是 intl / cn")
info = _q.device_login_start(realm)
st = load_state()
logins = st.setdefault("qoder_logins", {})
now = now_ts()
for k in list(logins.keys()):
if now - logins[k].get("created", 0) > OAUTH_TTL:
del logins[k]
state = "qd%d" % int(now * 1000)
info["created"] = now
logins[state] = info
save_state(st)
log("qoder device login started (realm=%s)" % realm)
return {"ok": True, "state": state, "auth_url": info["auth_url"],
"realm": realm, "ttl": OAUTH_TTL}
def h_qoder_login_poll(params, body):
"""轮询 Qoder 设备授权结果,成功后直接入池。"""
import qoder as _q
state = params.get("state", "")
st = load_state()
info = (st.get("qoder_logins") or {}).get(state)
if not info:
return ("error", "登录会话不存在或已过期,请重新发起")
res = _q.device_login_poll(info.get("realm", "intl"), info.get("verifier", ""),
info.get("nonce", ""), info.get("machine_id", ""))
if res.get("status") != "ok":
return res
dev = {"machineId": info.get("machine_id", "")}
uid = res.get("uid") or ""
name = (body or {}).get("name") or ("Qoder %s"
% ("国内版" if info.get("realm") == "cn" else "国际版"))
# 同一个 uid 已经存在则更新凭证(把设备重新绑到本机),不新增重复账号
existing = None
for a in load_accounts():
if a.get("platform") == "qoder" and uid and a.get("uid") == uid:
existing = a
break
if existing is not None:
accounts = load_accounts()
acc = existing
for a in accounts:
if a.get("id") == existing["id"]:
a.setdefault("auth", {})["accessToken"] = res["accessToken"]
a["auth"]["refreshToken"] = res["refreshToken"]
a["auth"]["domain"] = res["domain"]
a["auth"]["expiresAt"] = res.get("expiresAt") or 0
dev2 = dict(a.get("device") or {})
dev2.update({k: v for k, v in dev.items() if v})
a["device"] = dev2
a["uid"] = uid
a["need_reauth"] = False
acc = a
engine.save_accounts(accounts)
log("qoder device login refreshed %s (uid=%s)"
% (existing["id"], uid[:8]))
msg = "登录成功:已把账号重新绑定到本机设备"
else:
acc = engine.add_qoder(
name=name,
access_token=res["accessToken"],
refresh_token=res["refreshToken"],
device={k: v for k, v in dev.items() if v},
domain=res["domain"],
)
if uid:
accounts = load_accounts()
for a in accounts:
if a.get("id") == acc["id"]:
a["uid"] = uid
engine.save_accounts(accounts)
msg = "登录成功,已加入账号列表"
st = load_state()
(st.get("qoder_logins") or {}).pop(state, None)
save_state(st)
log("qoder device login ok -> %s" % acc["id"])
return {"status": "ok", "account": public_account(acc),
"message": "登录成功,已加入账号列表"}
def h_qoder_import_device(_params, body):
"""导入桌面客户端的设备身份(machineToken/Code/Type)。"""
acc_id = body.get("id")
if not any(a.get("id") == acc_id for a in load_accounts()):
return ("error", "账号不存在")
tok = (body.get("machineToken") or "").strip()
code = (body.get("machineCode") or "").strip()
mtype = (body.get("machineType") or "").strip()
if not (tok and code and mtype):
return ("error", "machineToken / machineCode / machineType 都不能为空")
accounts = load_accounts()
for a in accounts:
if a.get("id") == acc_id:
dev = dict(a.get("device") or {})
dev.update({"machineToken": tok, "machineCode": code,
"machineType": mtype, "uid": a.get("uid") or ""})
a["device"] = dev
break
engine.save_accounts(accounts)
log("qoder account %s device identity imported" % acc_id)
return {"ok": True, "message": "已导入桌面端设备身份,下次签到会使用它"}
def h_debug(_params, _body):
"""Diagnostics: resolved paths, env, daemon liveness (no secrets)."""
import common as _c
@@ -390,6 +569,14 @@ ROUTES = {
("GET", "/api/umid/status"): h_umid_status,
("POST", "/api/umid/install"): h_umid_install,
("POST", "/api/accounts/refresh_risk"): h_qoder_refresh_risk,
("POST", "/api/qoder/probe"): h_qoder_probe,
("POST", "/api/qoder/experiment"): h_qoder_experiment,
("POST", "/api/qoder/growth"): h_qoder_growth,
("POST", "/api/qoder/matrix"): h_qoder_matrix,
("POST", "/api/qoder/desktop"): h_qoder_desktop,
("POST", "/api/qoder/login/start"): h_qoder_login_start,
("GET", "/api/qoder/login/poll"): h_qoder_login_poll,
("POST", "/api/qoder/import_device"): h_qoder_import_device,
("GET", "/api/settings"): h_settings_get,
("POST", "/api/settings"): h_settings_post,
("GET", "/api/logs"): h_logs,