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:
@@ -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}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user