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:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -209,7 +209,8 @@
|
||||
"<td>" + (a.enabled ? "启用" : "禁用") + "</td>" +
|
||||
'<td class="actions">' +
|
||||
'<button class="btn btn-sm" data-a2="run" data-id="' + esc(a.id) + '">签到</button>' +
|
||||
(a.platform === "qoder" ? '<button class="btn btn-sm" data-a2="fixdomain" data-id="' + esc(a.id) + '" title="自动探测 Token 所属站点并切换">修复站点</button>' : "") +
|
||||
(a.platform === "qoder" ? '<button class="btn btn-sm" data-a2="fixdomain" data-id="' + esc(a.id) + '" title="自动探测 Token 所属站点并切换">修复站点</button>' +
|
||||
'<button class="btn btn-sm" data-a2="risk" data-id="' + esc(a.id) + '" title="重新生成设备风控身份(活动列表为空时使用)">刷新风控</button>' : "") +
|
||||
'<button class="btn btn-sm" data-a2="toggle" data-id="' + esc(a.id) + '">' + (a.enabled ? "禁用" : "启用") + "</button>" +
|
||||
'<button class="btn btn-sm btn-danger" data-a2="del" data-id="' + esc(a.id) + '">删除</button>' +
|
||||
"</td></tr>";
|
||||
@@ -219,6 +220,16 @@
|
||||
btn.addEventListener("click", function () {
|
||||
var id = btn.dataset.id, act = btn.dataset.a2;
|
||||
if (act === "run") runCheckin(id, btn);
|
||||
else if (act === "risk") {
|
||||
btn.disabled = true; btn.textContent = "生成中…";
|
||||
api("POST", "/accounts/refresh_risk", { id: id })
|
||||
.then(function (d) {
|
||||
if (d.ok) { toast("风控身份已更新", "ok"); refreshState(); }
|
||||
else { toast(d.error || "生成失败", "err"); }
|
||||
})
|
||||
.catch(function (e) { toast(e.message, "err"); })
|
||||
.finally(function () { btn.disabled = false; btn.textContent = "刷新风控"; });
|
||||
}
|
||||
else if (act === "fixdomain") {
|
||||
btn.disabled = true; btn.textContent = "探测中…";
|
||||
api("POST", "/accounts/fix_domain", { id: id })
|
||||
@@ -398,12 +409,52 @@
|
||||
var settings = null;
|
||||
var settingsDirty = false;
|
||||
|
||||
function loadUmidStatus() {
|
||||
api("GET", "/umid/status").then(function (d) {
|
||||
var el = $("umid-status");
|
||||
if (!el) return;
|
||||
if (!d.supported) {
|
||||
el.textContent = "当前 CPU 架构 " + d.cpu_arch + " 暂不支持自动生成(需 x86_64 / arm64)";
|
||||
} else if (d.installed) {
|
||||
el.textContent = "✅ 已安装 v" + d.version + "(" + d.arch + ",安装于 " + (d.installedAt || "-") + ")";
|
||||
} else {
|
||||
el.textContent = "⚠️ 未安装 —— 点击下方按钮下载(约 30MB)";
|
||||
}
|
||||
}).catch(function () {});
|
||||
}
|
||||
|
||||
var umidBtn = $("btn-umid-install");
|
||||
if (umidBtn) {
|
||||
umidBtn.addEventListener("click", function () {
|
||||
umidBtn.disabled = true;
|
||||
umidBtn.textContent = "下载中(约 1-3 分钟)…";
|
||||
$("umid-msg").textContent = "";
|
||||
$("umid-msg").className = "msg";
|
||||
api("POST", "/umid/install", { force: true })
|
||||
.then(function (d) {
|
||||
$("umid-msg").textContent = d.message || (d.ok ? "已安装" : "失败");
|
||||
$("umid-msg").className = "msg " + (d.ok ? "ok" : "err");
|
||||
toast(d.ok ? "风控组件已就绪" : (d.message || "下载失败"), d.ok ? "ok" : "err");
|
||||
loadUmidStatus();
|
||||
})
|
||||
.catch(function (e) {
|
||||
$("umid-msg").textContent = e.message;
|
||||
$("umid-msg").className = "msg err";
|
||||
})
|
||||
.finally(function () {
|
||||
umidBtn.disabled = false;
|
||||
umidBtn.textContent = "下载 / 重新获取风控组件";
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function openSettingsTab() {
|
||||
if (!settingsDirty) {
|
||||
settings = JSON.parse(JSON.stringify(
|
||||
(state && state.settings) || { schedule_times: [], webhook_url: "", notify_on: [] }));
|
||||
}
|
||||
renderSettings();
|
||||
loadUmidStatus();
|
||||
}
|
||||
|
||||
function renderSettings() {
|
||||
|
||||
@@ -81,6 +81,15 @@
|
||||
<button class="btn" id="btn-add-time">添加时间点</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h2>Qoder 风控身份(UMID)</h2>
|
||||
<p class="muted">Qoder 国际版只对带设备风控身份的请求下发签到活动。首次使用需下载官方组件(约 30MB,仅一次)。</p>
|
||||
<div id="umid-status" class="muted">检测中…</div>
|
||||
<div class="row">
|
||||
<button class="btn" id="btn-umid-install">下载 / 重新获取风控组件</button>
|
||||
</div>
|
||||
<div id="umid-msg" class="msg"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h2>消息通知(可选)</h2>
|
||||
<p class="muted">签到后向 Webhook 发送 POST JSON(<code>{"title","content","text","msgtype"}</code>),兼容企业微信/钉钉/飞书/Bark 等机器人。留空则不通知。</p>
|
||||
@@ -188,6 +197,9 @@
|
||||
<p><b>方式 C · 本机客户端提取</b></p>
|
||||
<p>在装了并登录 Qoder 客户端的 Windows 上运行社区工具 <code>01_extract.py</code>(GitHub: wallechfox/qoder-checkin),自动解密 <code>auth.v1.dat</code> 取出 Token 和设备标识。</p>
|
||||
<p class="muted">Token 有效期较短,过期后重新获取即可;填了 Refresh Token 的应用会自动续期。</p>
|
||||
<p><b>关于风控身份(重要)</b></p>
|
||||
<p>Qoder 服务端只对携带设备风控身份(<code>Cosy-MachineToken/Code/Type</code>)的请求下发「每天领 100 Credits」活动,否则活动列表为空。</p>
|
||||
<p>本应用会自动从官方 npm 包 <code>@qoder-ai/qodercli</code> 提取 UMID 组件并生成身份(首次约需下载 30MB,之后缓存复用)。可在「设置 → Qoder 风控身份」查看/重新获取。</p>
|
||||
</div>
|
||||
</details>
|
||||
<details class="help">
|
||||
|
||||
Reference in New Issue
Block a user