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
+68 -22
View File
@@ -177,6 +177,62 @@ def install(force=False):
# 调用
# ---------------------------------------------------------------------------
def run_runtime_info(env_code, account=""):
"""以指定 env 直接运行 runtime-info,返回 (完整输出 JSON, error)。
env_code: 0=国内版 ONLINE,3=国际版(客户端默认),4=国际版(CLI 默认)。
返回的 JSON 除 machineToken/Code/Type 外通常还有 vmInfo 等字段。
"""
info = installed()
if not info:
return None, "未安装 runtime-info(请先在设置里下载)"
try:
stdin_data = json.dumps({"account": str(account or "")}) + "\n"
p = subprocess.run([BIN_PATH, str(env_code), "--account-stdin"],
input=stdin_data, capture_output=True, text=True, timeout=30)
if p.returncode != 0 or not p.stdout.strip():
return None, "runtime-info 执行失败(退出码 %s)%s" % (p.returncode, (p.stderr or "")[:120])
line = p.stdout.strip().split("\n")[0]
data = json.loads(line)
if not isinstance(data, dict):
return None, "runtime-info 输出不是 JSON 对象"
return data, None
except subprocess.TimeoutExpired:
return None, "runtime-info 执行超时"
except Exception as e:
return None, "runtime-info 调用异常:%s" % e
def run_runtime_info_raw(args, stdin_data=None):
"""按给定 argv / stdin 调用 runtime-info,返回 (JSON, error)。
用途:对照不同的 env 取值与「是否带 --account-stdin」(客户端 Linux 分支
只用 `runtime-info <env>`,完全不喂 stdin)。
"""
info = installed()
if not info:
return None, "未安装 runtime-info(请先在设置里下载)"
try:
argv = [BIN_PATH] + [str(a) for a in args]
if stdin_data is None:
p = subprocess.run(argv, stdin=subprocess.DEVNULL,
capture_output=True, text=True, timeout=30)
else:
p = subprocess.run(argv, input=stdin_data,
capture_output=True, text=True, timeout=30)
if p.returncode != 0 or not p.stdout.strip():
return None, "退出码 %s %s" % (p.returncode, (p.stderr or "")[:160])
line = p.stdout.strip().split("\n")[0]
data = json.loads(line)
if not isinstance(data, dict):
return None, "输出不是 JSON 对象"
return data, None
except subprocess.TimeoutExpired:
return None, "超时"
except Exception as e:
return None, "异常:%s" % e
def risk_identity(provider="qoder", account="", force_install=False):
"""生成设备风控身份。返回 ({"machineToken","machineCode","machineType"}, error).
@@ -188,28 +244,18 @@ def risk_identity(provider="qoder", account="", force_install=False):
if not ok:
return None, msg
info = installed()
env = str(RISK_ENV.get(provider, 0))
try:
stdin_data = json.dumps({"account": str(account or "")}) + "\n"
p = subprocess.run([BIN_PATH, env, "--account-stdin"],
input=stdin_data, capture_output=True, text=True, timeout=30)
if p.returncode != 0 or not p.stdout.strip():
return None, "runtime-info 执行失败(退出码 %s)%s" % (p.returncode, (p.stderr or "")[:120])
line = p.stdout.strip().split("\n")[0]
data = json.loads(line)
out = {}
for k in ("machineToken", "machineCode", "machineType"):
v = data.get(k)
if not isinstance(v, str) or not v.strip():
return None, "runtime-info 输出缺少 %s" % k
out[k] = v.strip()
if data.get("vmInfo"):
out["vmInfo"] = data["vmInfo"]
return out, None
except subprocess.TimeoutExpired:
return None, "runtime-info 执行超时"
except Exception as e:
return None, "runtime-info 调用异常:%s" % e
data, err = run_runtime_info(RISK_ENV.get(provider, 0), account)
if err:
return None, err
out = {}
for k in ("machineToken", "machineCode", "machineType"):
v = data.get(k)
if not isinstance(v, str) or not v.strip():
return None, "runtime-info 输出缺少 %s" % k
out[k] = v.strip()
if data.get("vmInfo"):
out["vmInfo"] = data["vmInfo"]
return out, None
def status():