Files
fnos-agent-checkin/AgentCheckin/app/backend/api.py
T
cbb c263edc3e7 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 前端,全部通过
2026-10-19 12:04:44 +08:00

421 lines
14 KiB
Python

#!/usr/bin/env python3
"""AgentCheckin CGI API server (invoked by app/ui/index.cgi).
All endpoints are JSON. The fnOS web server validates the NAS login session
before calling the CGI, so no extra auth is implemented here.
"""
import json
import os
import sys
import time
import urllib.parse
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from common import (load_accounts, load_settings, save_settings, load_state,
save_state, log, fmt_now, now_ts, PID_FILE, TRIGGER_FILE,
ensure_dirs, mask_token, public_account)
import engine
import workbuddy
MAX_BODY = 256 * 1024
OAUTH_TTL = 10 * 60 # seconds a login link stays valid
# ---------------------------------------------------------------------------
# CGI plumbing
# ---------------------------------------------------------------------------
def cgi_env():
return {
"method": os.environ.get("REQUEST_METHOD", "GET").upper(),
"uri": os.environ.get("REQUEST_URI", ""),
"query": os.environ.get("QUERY_STRING", ""),
}
def read_body():
try:
length = int(os.environ.get("CONTENT_LENGTH") or 0)
except ValueError:
length = 0
if length <= 0:
return {}
if length > MAX_BODY:
return None
raw = sys.stdin.buffer.read(length)
try:
data = json.loads(raw.decode("utf-8"))
return data if isinstance(data, dict) else {}
except (ValueError, json.JSONDecodeError):
return {}
def respond(obj, status="200 OK"):
body = json.dumps(obj, ensure_ascii=False)
out = sys.stdout
out.write("Status: %s\r\n" % status)
out.write("Content-Type: application/json; charset=utf-8\r\n")
out.write("Cache-Control: no-store\r\n")
out.write("Content-Length: %d\r\n" % len(body.encode("utf-8")))
out.write("\r\n")
out.write(body)
def error(msg, status="400 Bad Request"):
respond({"error": msg}, status)
def route_path(uri):
path = uri.split("?", 1)[0]
idx = path.find("index.cgi")
if idx >= 0:
path = path[idx + len("index.cgi"):]
return path.rstrip("/") or "/"
def query_params(qs):
return {k: v[0] for k, v in urllib.parse.parse_qs(qs, keep_blank_values=True).items()}
def daemon_alive():
try:
with open(PID_FILE) as f:
pid = int(f.read().strip())
os.kill(pid, 0)
return True
except (OSError, ValueError):
return False
# ---------------------------------------------------------------------------
# Handlers
# ---------------------------------------------------------------------------
def h_state(_params, _body):
data = engine.dashboard()
data["daemon_alive"] = daemon_alive()
return data
def h_checkin(_params, body):
account_id = body.get("account_id")
accounts = load_accounts()
if account_id and not any(a.get("id") == account_id for a in accounts):
return ("error", "账号不存在")
if daemon_alive():
ensure_dirs()
with open(TRIGGER_FILE, "w") as f:
f.write(json.dumps({"account_id": account_id, "ts": now_ts()}))
return {"ok": True, "mode": "trigger", "message": "已触发签到,守护进程正在执行"}
# daemon not running: run synchronously as a fallback
results = engine.run_all(account_id=account_id, reason="manual-cgi")
return {"ok": True, "mode": "sync", "results": results}
def h_accounts(_params, _body):
return {"accounts": [public_account(a) for a in load_accounts()]}
def h_add_wb_manual(_params, body):
token = (body.get("access_token") or "").strip()
if not token:
return ("error", "access_token 不能为空")
if len(token) > 8192:
return ("error", "access_token 过长")
acc = engine.add_workbuddy_manual(
name=(body.get("name") or "").strip()[:60],
access_token=token,
refresh_token=(body.get("refresh_token") or "").strip(),
domain=(body.get("domain") or "codebuddy.cn").strip(),
)
return {"ok": True, "account": public_account(acc)}
def h_add_trae(_params, body):
token = (body.get("access_token") or "").strip()
if not token:
return ("error", "access_token 不能为空")
if len(token) > 8192:
return ("error", "access_token 过长")
acc = engine.add_trae(
name=(body.get("name") or "").strip()[:60],
access_token=token,
refresh_token=(body.get("refresh_token") or "").strip(),
device_id_str=(body.get("device_id") or "").strip(),
)
return {"ok": True, "account": public_account(acc)}
def h_add_qoder(_params, body):
token = (body.get("access_token") or "").strip()
if not token:
return ("error", "access_token 不能为空")
if len(token) > 8192:
return ("error", "access_token 过长")
device = body.get("device") or {}
if not isinstance(device, dict):
return ("error", "device 必须是对象")
acc = engine.add_qoder(
name=(body.get("name") or "").strip()[:60],
access_token=token,
refresh_token=(body.get("refresh_token") or "").strip(),
device=device,
domain=(body.get("domain") or "openapi.qoder.com.cn").strip(),
)
return {"ok": True, "account": public_account(acc)}
def h_update_account(_params, body):
acc_id = body.get("id")
if not acc_id:
return ("error", "缺少 id")
fields = {}
if "name" in body:
fields["name"] = str(body["name"])[:60]
if "enabled" in body:
fields["enabled"] = bool(body["enabled"])
acc = engine.update_account(acc_id, **fields)
if not acc:
return ("error", "账号不存在")
return {"ok": True, "account": public_account(acc)}
def h_fix_qoder_domain(_params, body):
"""Probe both Qoder sites with the account token and switch to the one
that authenticates (the two sites' tokens are not interchangeable)."""
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
probe = _q.probe_domains(acc)
for dom, r in probe.items():
if r.get("auth"):
acc.setdefault("auth", {})["domain"] = dom
engine.save_accounts(accounts)
log("qoder account %s domain switched to %s" % (acc_id, dom))
return {"ok": True, "domain": dom, "probe": probe}
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:
return ("error", "缺少 id")
ok = engine.delete_account(acc_id)
if not ok:
return ("error", "账号不存在")
return {"ok": True}
def h_test_account(_params, body):
acc_id = body.get("id")
if not acc_id:
return ("error", "缺少 id")
return engine.test_account(acc_id)
def h_wb_oauth_begin(_params, _body):
state, login_url, err = workbuddy.begin_oauth()
if err:
return ("error", err)
st = load_state()
sessions = st.setdefault("oauth_sessions", {})
# drop expired sessions
now = now_ts()
for k in list(sessions.keys()):
if now - sessions[k].get("created", 0) > OAUTH_TTL:
del sessions[k]
sessions[state] = {"created": now}
save_state(st)
log("workbuddy oauth session started")
return {"ok": True, "state": state, "login_url": login_url,
"ttl": OAUTH_TTL}
def h_wb_oauth_poll(params, _body):
state = params.get("state", "")
if not state:
return ("error", "缺少 state")
st = load_state()
sessions = st.get("oauth_sessions") or {}
sess = sessions.get(state)
if not sess:
return ("error", "授权会话不存在或已过期,请重新生成链接")
if now_ts() - sess.get("created", 0) > OAUTH_TTL:
del sessions[state]
save_state(st)
return ("error", "授权链接已过期,请重新生成")
status, payload, err = workbuddy.poll_oauth(state)
if status == "done":
acc = engine.add_workbuddy_oauth(payload)
del sessions[state]
save_state(st)
return {"ok": True, "status": "done", "account": public_account(acc)}
return {"ok": True, "status": "waiting"}
def h_settings_get(_params, _body):
return {"settings": load_settings()}
def h_settings_post(_params, body):
settings = load_settings()
if "schedule_times" in body:
times = body["schedule_times"]
if not isinstance(times, list):
return ("error", "schedule_times 必须是数组")
clean = []
for t in times:
t = str(t)
if len(t) == 5 and t[2] == ":" and t[:2].isdigit() and t[3:].isdigit():
hh, mm = int(t[:2]), int(t[3:])
if 0 <= hh < 24 and 0 <= mm < 60:
clean.append(t)
if not clean:
return ("error", "至少需要一个有效的签到时间(HH:MM)")
settings["schedule_times"] = sorted(set(clean))
if "webhook_url" in body:
url = str(body["webhook_url"]).strip()
if url and not url.startswith(("http://", "https://")):
return ("error", "webhook_url 必须以 http(s):// 开头")
settings["webhook_url"] = url[:500]
if "webhook_type" in body:
wt = str(body["webhook_type"])
if wt in engine.WEBHOOK_TYPES:
settings["webhook_type"] = wt
else:
return ("error", "不支持的 webhook_type")
if "notify_on" in body:
nf = body["notify_on"]
if isinstance(nf, list):
settings["notify_on"] = [n for n in nf if n in ("success", "fail")]
save_settings(settings)
return {"ok": True, "settings": settings}
def h_logs(params, _body):
try:
lines = int(params.get("lines", "200"))
except ValueError:
lines = 200
lines = max(10, min(lines, 2000))
from common import LOG_FILE
if not os.path.exists(LOG_FILE):
return {"lines": []}
try:
with open(LOG_FILE, encoding="utf-8") as f:
content = f.read().splitlines()
return {"lines": content[-lines:]}
except OSError as e:
return {"lines": [], "error": str(e)}
def h_debug(_params, _body):
"""Diagnostics: resolved paths, env, daemon liveness (no secrets)."""
import common as _c
info = {
"data_dir": _c.DATA_DIR,
"accounts_file": _c.ACCOUNTS_FILE,
"state_file": _c.STATE_FILE,
"pid_file": _c.PID_FILE,
"log_file": _c.LOG_FILE,
"env": {
"TRIM_PKGVAR": os.environ.get("TRIM_PKGVAR", "<unset>"),
"TRIM_APPDEST": os.environ.get("TRIM_APPDEST", "<unset>"),
"AGENTCHECKIN_DEV": os.environ.get("AGENTCHECKIN_DEV", "<unset>"),
},
"file": __file__,
}
info["state_file_exists"] = os.path.exists(_c.STATE_FILE)
if info["state_file_exists"]:
st = _c.load_state()
info["state_daemon"] = st.get("daemon")
info["state_last_run"] = st.get("last_run")
info["pid_file_exists"] = os.path.exists(_c.PID_FILE)
info["daemon_alive"] = daemon_alive()
return info
ROUTES = {
("GET", "/api/state"): h_state,
("GET", "/api/debug"): h_debug,
("POST", "/api/checkin"): h_checkin,
("GET", "/api/accounts"): h_accounts,
("POST", "/api/accounts/add_wb_manual"): h_add_wb_manual,
("POST", "/api/accounts/add_trae"): h_add_trae,
("POST", "/api/accounts/add_qoder"): h_add_qoder,
("POST", "/api/accounts/update"): h_update_account,
("POST", "/api/accounts/delete"): h_delete_account,
("POST", "/api/accounts/fix_domain"): h_fix_qoder_domain,
("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,
}
def main():
env = cgi_env()
path = route_path(env["uri"])
params = query_params(env["query"])
handler = ROUTES.get((env["method"], path))
if not handler:
return error("not found: %s %s" % (env["method"], path), "404 Not Found")
body = read_body() if env["method"] == "POST" else {}
if body is None:
return error("payload too large")
try:
result = handler(params, body)
except Exception as e:
log("api error on %s %s: %s" % (env["method"], path, e), "ERROR")
return error("internal error: %s" % e, "500 Internal Server Error")
if isinstance(result, tuple) and len(result) == 2 and result[0] == "error":
return error(result[1])
respond(result if isinstance(result, dict) else {"data": result})
if __name__ == "__main__":
main()