feat(qoder): 适配国际版每日签到 — UMID 设备风控身份 + 国际版活动 schema

核心发现(实测):
- Qoder 国际版服务端只对带 Cosy-MachineToken/Code/Type 的请求下发签到活动,
  否则 campaigns 返回空数组(国内版无此限制)
- 国际版活动 actionType=VIEW_DETAILS(国内版 CLAIM_BENEFIT),claimStatus 一致
- 活动窗口 2026-09-01~10-31,每日 10:00 (UTC+8) 刷新,领取后 30 天有效

实现:
- 新增 umid.py:从官方 npm 包 @qoder-ai/qodercli 下载并提取内嵌 UMID 二进制
  (x86_64/arm64 ELF),试跑校验后缓存到 @appdata/AgentCheckin/umid/
- qoder.py:签到前自动确保风控身份,支持国际版/国内版双 schema 活动解析
- API:/api/umid/status、/api/umid/install、/api/accounts/refresh_risk
- 前端:设置页风控身份面板(下载/状态)、账号列表「刷新风控」按钮
- 诊断:测连接返回 risk_identity / campaigns_debug(原始字段)

验证:NAS 实测 Qoder 国际版从「活动列表为空」变为「今日已领取(75 积分)」
测试:37 后端 + 25 E2E + 9 前端,全部通过
This commit is contained in:
cbb
2026-10-19 12:04:44 +08:00
parent a3b84d5ce2
commit c263edc3e7
10 changed files with 484 additions and 17 deletions
+34
View File
@@ -203,6 +203,37 @@ def h_fix_qoder_domain(_params, body):
return {"ok": False, "probe": probe,
"error": "两个站点均无法认证(Token 可能已过期),请重新获取 Token"}
def h_umid_status(_params, _body):
"""Qoder 风控身份(UMID runtime-info)安装状态。"""
import umid as _u
return _u.status()
def h_umid_install(_params, _body):
"""下载官方 qodercli 并提取 runtime-info(约 30MB,耗时 1-3 分钟)。"""
import umid as _u
ok, msg = _u.install(force=bool((_body or {}).get("force")))
return {"ok": ok, "message": msg, "status": _u.status()}
def h_qoder_refresh_risk(_params, body):
"""为某个 Qoder 账号重新生成风控身份。"""
acc_id = body.get("id")
if not acc_id:
return ("error", "缺少 id")
accounts = load_accounts()
acc = engine.find_account(accounts, acc_id)
if not acc or acc.get("platform") != "qoder":
return ("error", "账号不存在或不是 Qoder 账号")
import qoder as _q
tok, code, mtype, err = _q.risk_identity(acc, force=True)
if err:
return {"ok": False, "error": err}
engine.save_accounts([acc if a.get("id") == acc_id else a for a in accounts])
return {"ok": True, "machineToken": tok[:12] + "…", "machineCode": code, "machineType": mtype}
def h_delete_account(_params, body):
acc_id = body.get("id")
if not acc_id:
@@ -356,6 +387,9 @@ ROUTES = {
("POST", "/api/accounts/test"): h_test_account,
("POST", "/api/wb_oauth_begin"): h_wb_oauth_begin,
("GET", "/api/wb_oauth_poll"): h_wb_oauth_poll,
("GET", "/api/umid/status"): h_umid_status,
("POST", "/api/umid/install"): h_umid_install,
("POST", "/api/accounts/refresh_risk"): h_qoder_refresh_risk,
("GET", "/api/settings"): h_settings_get,
("POST", "/api/settings"): h_settings_post,
("GET", "/api/logs"): h_logs,
+9
View File
@@ -326,6 +326,14 @@ def test_account(account_id):
if ok:
out["refresh"] = msg
# 探测两个站点,判断 token 属于哪个域(两站 Token 不互通)
# 风控身份(国际版拿活动的前提)
tok, code, mtype, rerr = qoder.risk_identity(acc)
out["risk_identity"] = {
"ok": bool(tok),
"error": rerr,
"machineToken": (tok[:12] + "…") if tok else None,
}
_reload(acc)
probe = qoder.probe_domains(acc)
out["domain_probe"] = probe
current = (acc.get("auth") or {}).get("domain")
@@ -338,6 +346,7 @@ def test_account(account_id):
if ok_domain and ok_domain != current:
out["hint"] = "Token 属于 %s,但当前域名是 %s,请切换" % (ok_domain, current)
campaigns, err = qoder.query_campaigns(acc)
out["campaigns_debug"] = qoder.campaigns_debug(acc)
if err:
out["error"] = err
else:
+115 -8
View File
@@ -26,6 +26,7 @@ import json
import time
from common import http_post_json, http_get, log, now_ts
import umid
CN_BASE = "https://openapi.qoder.com.cn"
GLOBAL_BASE = "https://openapi.qoder.sh"
@@ -35,16 +36,18 @@ PATH_REFRESH = "/api/v1/deviceToken/refresh"
REFRESH_MARGIN = 72 * 3600 # refresh 72h before expiry (client behaviour)
# 客户端默认版本(社区实现实测值:缺 Cosy-Version 时 campaigns 可能返回空数组)
DEFAULT_VERSION = "0.3.4"
DEVICE_DEFAULTS = {
"clientType": "10",
"version": "", # 不默认发送,用户可在设备标识里按客户端实际版本填
"version": DEFAULT_VERSION,
"machineOS": "",
"machineHostname": "",
"machineId": "",
"machineToken": "",
"machineCode": "",
"machineType": "",
"version": "",
}
@@ -72,10 +75,30 @@ def device_headers(account):
}
def risk_identity(account, force=False):
"""获取(并缓存到账号)设备风控身份 Cosy-MachineToken/Code/Type。
Qoder 服务端(尤其国际版)只对带风控身份的请求下发「每天领 100 Credits」活动。
优先用账号 device 块里已存的;缺失时调用本机 runtime-info 现场生成。
"""
dev = account.get("device") or {}
if not force and dev.get("machineToken") and dev.get("machineCode") and dev.get("machineType"):
return dev["machineToken"], dev["machineCode"], dev["machineType"], None
provider = "qoder" if is_global(account.get("auth") or {}) else "qoder-cn"
ident, err = umid.risk_identity(provider, account.get("uid") or "")
if err:
return None, None, None, err
dev = dict(dev)
dev.update({"machineToken": ident["machineToken"],
"machineCode": ident["machineCode"],
"machineType": ident["machineType"]})
account["device"] = dev
return dev["machineToken"], dev["machineCode"], dev["machineType"], None
def _headers(account):
h = {"Accept": "application/json", "Accept-Language": "zh-CN", "User-Agent": "Qoder"}
# only send device headers that actually have values (empty values can
# trip server-side device validation)
# 设备标识:有值才发(空值可能触发服务端设备校验)
for k, v in device_headers(account).items():
if v:
h[k] = v
@@ -205,19 +228,96 @@ def query_campaigns(account):
return (campaigns if isinstance(campaigns, list) else []), None
def campaigns_debug(account):
"""诊断:返回活动接口的原始响应摘要(不含 token)。"""
data, status, err = _api(account, PATH_CAMPAIGNS + "?forceRefresh=true", "GET")
out = {"status": status, "error": err}
if isinstance(data, dict):
cs = data.get("campaigns")
out["campaign_count"] = len(cs) if isinstance(cs, list) else None
if isinstance(cs, list) and cs:
out["campaigns"] = [describe_campaign(c) for c in cs[:10]]
# 原始字段摘要(便于排查 schema 差异)
raw = {}
for k, v in cs[0].items():
if isinstance(v, (str, int, float, bool)) or v is None:
raw[k] = (str(v)[:60] if isinstance(v, str) else v)
elif isinstance(v, dict):
raw[k] = {kk: (str(vv)[:40] if isinstance(vv, str) else vv) for kk, vv in list(v.items())[:8]}
elif isinstance(v, list):
raw[k] = "[%d items]" % len(v)
out["campaign_raw_fields"] = raw
else:
out["raw_keys"] = list(data.keys())[:10]
return out
def _benefits(campaigns):
"""CLAIM_BENEFIT campaigns; prefer kind == CREDITS when the field exists."""
"""CLAIM_BENEFIT campaigns(活动/签到坑位)。
注意:真实数据里 benefit 可能只有 kind 没有 amount(活动信息在别处),
所以只按 actionType 过滤;kind 明确非 CREDITS 时才排除。
"""
out = []
for c in campaigns:
if not (isinstance(c, dict) and c.get("actionType") == "CLAIM_BENEFIT"):
if not isinstance(c, dict):
continue
action = c.get("actionType")
claim_status = c.get("claimStatus")
benefit = c.get("benefit")
if isinstance(benefit, dict) and benefit.get("kind") and benefit["kind"] != "CREDITS":
kind = benefit.get("kind") if isinstance(benefit, dict) else None
# 判定规则(已实测):
# 国内版:actionType == CLAIM_BENEFIT 且 claimStatus 为 CLAIMABLE/CLAIMED
# 国际版:actionType 为 VIEW_DETAILS(点「领取」按钮才领),claimStatus 同样有效
is_benefit_action = action in ("CLAIM_BENEFIT", "VIEW_DETAILS")
has_claim_state = claim_status in ("CLAIMABLE", "CLAIMED")
if not (is_benefit_action or (action is None and has_claim_state)):
continue
if not has_claim_state:
continue
# 只排除明确非积分权益(如实物/优惠券)
if kind and kind not in ("CREDITS",):
continue
out.append(c)
return out
def describe_campaign(c):
"""Human-readable campaign summary (title / amount / window / countdown).
Mirrors what the client shows in the activity card: 每天领 100 Credits,
每日 10:00 (UTC+8) 刷新, 领取后 30 天有效, plus a countdown to next reset.
"""
b = c.get("benefit") or {}
amount = b.get("amount")
kind = b.get("kind") or "CREDITS"
title = c.get("campaignName") or c.get("title") or c.get("campaignKey") or ""
if title.startswith("act-"):
title = "每日签到活动"
info = {"campaignId": c.get("campaignId"), "campaignKey": c.get("campaignKey"),
"title": title, "amount": amount, "kind": kind,
"actionType": c.get("actionType"),
"claimStatus": c.get("claimStatus")}
# 有效期
for src_key, dst_key in (("startAt", "startAt"), ("endAt", "endAt")):
if c.get(src_key):
info[dst_key] = c[src_key]
# 刷新周期(每日 10:00 UTC+8 等)
for key in ("refreshCron", "refreshTime", "resetAt", "resetTime", "nextRefreshAt",
"dailyRefreshTime", "refreshPeriod"):
if c.get(key) is not None:
info[key] = c[key]
# 领取后有效期
for key in ("validDays", "benefitValidDays", "expireDays", "validityDays"):
if c.get(key) is not None:
info["validDays"] = c[key]
# 倒计时(服务端可能直接给剩余秒数/毫秒)
for key in ("countdownMs", "countdown", "remainingMs", "remainingSeconds", "nextClaimInMs"):
if c.get(key) is not None:
info["countdownMs"] = c[key]
return info
USAGE_PATH = "/sash/api/v2/me/usage"
@@ -302,6 +402,11 @@ def checkin_once(account):
if err:
return {"status": "fail", "message": err, "points": 0}
# Qoder 服务端只对带风控身份的请求下发签到活动——先确保身份就绪
tok, code, mtype, rerr = risk_identity(account)
if rerr:
log("qoder risk identity unavailable: %s" % rerr, "WARN")
balance, berr = query_balance(account)
if berr == "no_session":
return {"status": "no_session",
@@ -342,7 +447,9 @@ def checkin_once(account):
return {"status": "already", "message": "今日已领取", "points": 0,
"credits": balance}
if not benefits:
return {"status": "already", "message": "活动列表为空(活动结束或未开放)", "points": 0}
# 没有任何可识别活动:可能是活动未下发/已结束,也可能是 schema 变化
return {"status": "already",
"message": "未发现可领取活动(活动未下发/已结束,或需刷新风控身份)", "points": 0}
return {"status": "retry_later", "message": "活动未下发,下个时段再试", "points": 0}
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""Qoder UMID 设备风控身份(runtime-info)管理器。
背景:Qoder 服务端只向携带 Cosy-MachineToken / Cosy-MachineCode /
Cosy-MachineType 的请求下发「每天领 100 Credits」活动(社区实测,国际版尤其严格)。
这三个值由 Qoder 官方 UMID 程序 runtime-info 生成——桌面客户端自带,
官方 CLI 包 @qoder-ai/qodercli 也把各平台二进制以 base64 内嵌在 bundle/qodercli.js 中。
本模块在 NAS(Linux x86_64 / arm64)上下载官方 npm 包 → 提取对应架构的 ELF →
试跑一次确认可产出身份 → 缓存到应用数据目录(umid/)。
调用方式与客户端一致:
runtime-info <env> [--account-stdin],stdin 传入 {"account": "<uid>"}
env: 国际版 4(SINGAPORE)/ 国内版 0(ONLINE)
第一行输出 JSON:{"machineToken","machineCode","machineType","vmInfo"...}
安全性:只从官方 npm registry 下载官方包,二进制仅本机使用,不上传任何信息。
"""
import base64
import json
import os
import re
import stat
import subprocess
import sys
import time
import urllib.request
import ssl
from common import DATA_DIR, log, USER_AGENT
PKG = "@qoder-ai/qodercli"
REGISTRIES = [
"https://registry.npmmirror.com",
"https://registry.npmjs.org",
]
BUNDLE_ENTRY = "package/bundle/qodercli.js"
# ELF e_machine: x86_64=62, arm64=183
ELF_MACHINE = {"x86_64": 62, "aarch64": 183, "arm64": 183}
RISK_ENV = {"qoder": 4, "qoder-cn": 0} # 国际版 / 国内版
_SSL_CTX = ssl.create_default_context()
_SSL_CTX.check_hostname = False
_SSL_CTX.verify_mode = ssl.CERT_NONE
CACHE_DIR = os.path.join(DATA_DIR, "umid")
BIN_PATH = os.path.join(CACHE_DIR, "runtime-info")
MANIFEST = os.path.join(CACHE_DIR, "manifest.json")
# ---------------------------------------------------------------------------
# 缓存
# ---------------------------------------------------------------------------
def installed():
"""已安装的 runtime-info:{path, version, arch, sha256, installedAt} 或 None."""
try:
m = json.load(open(MANIFEST, encoding="utf-8"))
if not os.path.isfile(BIN_PATH) or m.get("arch") != _arch():
return None
return dict(m, path=BIN_PATH)
except (OSError, ValueError):
return None
def _arch():
machine = os.uname().machine
return {"x86_64": "x86_64", "amd64": "x86_64",
"aarch64": "aarch64", "arm64": "aarch64"}.get(machine, machine)
# ---------------------------------------------------------------------------
# 下载 / 提取
# ---------------------------------------------------------------------------
def _fetch(url, timeout=180, binary=True):
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=timeout, context=_SSL_CTX) as r:
return r.read()
def _registry_meta(registry):
meta = json.loads(_fetch(registry + "/" + PKG.replace("/", "%2F") + "/latest", 30).decode())
return meta
def _extract_tar_entry(tar, wanted):
"""最小 tar 解析:按名字取一个普通文件内容。"""
off = 0
long_name = None
while off + 512 <= len(tar):
h = tar[off:off + 512]
if not any(h):
break
name = h[:100].split(b"\0")[0].decode("utf-8", "replace")
size = int((h[124:136].split(b"\0")[0].strip() or b"0"), 8)
typeflag = h[156:157]
prefix = h[345:500].split(b"\0")[0].decode("utf-8", "replace")
full = (prefix + "/" + name) if prefix else name
if typeflag == b"L": # GNU long name
long_name = tar[off + 512:off + 512 + size].split(b"\0")[0].decode("utf-8", "replace")
elif typeflag in (b"0", b"\0"):
real = long_name or full
long_name = None
if real == wanted:
return tar[off + 512:off + 512 + size]
off += 512 + ((size + 511) // 512) * 512
return None
def _find_elf(bundle, arch):
"""在 bundle 里找指定架构的 ELF(base64 内嵌)。"""
want = ELF_MACHINE.get(arch)
if not want:
raise RuntimeError("不支持的 CPU 架构:%s" % arch)
for m in re.finditer(rb"[A-Za-z0-9+/]{100000,}", bundle):
try:
raw = base64.b64decode(m.group(0))
except Exception:
continue
if raw[:4] != b"\x7fELF" or len(raw) < 18:
continue
machine = int.from_bytes(raw[18:20], "little")
if machine == want:
return raw
raise RuntimeError("官方包中未找到 %s 架构的 UMID 二进制" % arch)
def install(force=False):
"""下载官方 qodercli 包并提取 runtime-info。返回 (ok, message)."""
if not force and installed():
return True, "已安装"
os.makedirs(CACHE_DIR, exist_ok=True)
arch = _arch()
if arch not in ELF_MACHINE:
return False, "NAS CPU 架构 %s 暂不支持(仅 x86_64 / arm64)" % arch
last_err = None
for registry in REGISTRIES:
try:
log("umid: fetching %s from %s" % (PKG, registry), "INFO")
meta = _registry_meta(registry)
version = meta.get("version", "")
tarball = (meta.get("dist") or {}).get("tarball") or ""
if not tarball:
raise RuntimeError("registry 未返回 tarball")
log("umid: downloading %s %s" % (PKG, version), "INFO")
tgz = _fetch(tarball, timeout=300)
# gzip 解包
import gzip
tar = gzip.decompress(tgz)
bundle = _extract_tar_entry(tar, BUNDLE_ENTRY)
if not bundle:
raise RuntimeError("包内未找到 %s" % BUNDLE_ENTRY)
elf = _find_elf(bundle, arch)
tmp = BIN_PATH + ".tmp"
with open(tmp, "wb") as f:
f.write(elf)
os.chmod(tmp, os.stat(tmp).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
os.replace(tmp, BIN_PATH)
import hashlib
sha = hashlib.sha256(elf).hexdigest()
json.dump({"version": version, "arch": arch, "sha256": sha,
"installedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
"registry": registry},
open(MANIFEST, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
log("umid: installed %s (arch=%s sha256=%s)" % (version, arch, sha[:12]), "INFO")
return True, "已安装 %s(%s)" % (version, arch)
except Exception as e: # 尝试下一个 registry
last_err = "%s: %s" % (registry, e)
log("umid: registry %s failed: %s" % (registry, e), "WARN")
return False, "下载失败(%s)" % last_err
# ---------------------------------------------------------------------------
# 调用
# ---------------------------------------------------------------------------
def risk_identity(provider="qoder", account="", force_install=False):
"""生成设备风控身份。返回 ({"machineToken","machineCode","machineType"}, error).
provider: "qoder"(国际版 env=4)/ "qoder-cn"(国内版 env=0)
"""
info = installed()
if not info or force_install:
ok, msg = install(force=force_install)
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
def status():
"""供 UI 展示的状态。"""
info = installed()
return {
"installed": bool(info),
"path": BIN_PATH,
"version": (info or {}).get("version"),
"arch": (info or {}).get("arch"),
"installedAt": (info or {}).get("installedAt"),
"supported": _arch() in ELF_MACHINE,
"cpu_arch": _arch(),
}