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
+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}