30a2efce3f
Align redeem/validate UX with per-code tokens from the auth server. Co-authored-by: Cursor <cursoragent@cursor.com>
440 lines
14 KiB
Python
440 lines
14 KiB
Python
"""用户端许可客户端:对接 https://sq.bz121.com (crypto_monitor_web)。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import platform
|
||
import threading
|
||
import time
|
||
import uuid
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from typing import Any
|
||
from urllib.error import HTTPError, URLError
|
||
from urllib.request import Request, urlopen
|
||
|
||
from lib.paths import REPO_ROOT
|
||
|
||
DEFAULT_API_URL = "https://sq.bz121.com"
|
||
VALIDATE_INTERVAL_SEC = 3 * 24 * 3600 # 每 3 天
|
||
DEFAULT_OFFLINE_GRACE_HOURS = 72
|
||
STATE_FILENAME = "license_state.json"
|
||
LICENSE_ENV_NAME = "license.env"
|
||
|
||
_lock = threading.RLock()
|
||
_cached_device_id: str | None = None
|
||
_env_loaded = False
|
||
|
||
|
||
def _load_license_env() -> None:
|
||
global _env_loaded
|
||
if _env_loaded:
|
||
return
|
||
_env_loaded = True
|
||
path = REPO_ROOT / LICENSE_ENV_NAME
|
||
if not path.is_file():
|
||
return
|
||
try:
|
||
text = path.read_text(encoding="utf-8")
|
||
except OSError:
|
||
return
|
||
for line in text.splitlines():
|
||
raw = line.strip()
|
||
if not raw or raw.startswith("#") or "=" not in raw:
|
||
continue
|
||
key, value = raw.split("=", 1)
|
||
key = key.strip().lstrip("\ufeff")
|
||
if not key:
|
||
continue
|
||
val = value.strip().strip('"').strip("'")
|
||
# 仅在未设置或为空时写入,避免空 LICENSE_CLIENT_KEY= 挡住 license.env
|
||
if (os.environ.get(key) or "").strip():
|
||
continue
|
||
os.environ[key] = val
|
||
|
||
|
||
def api_base_url() -> str:
|
||
_load_license_env()
|
||
url = (os.getenv("LICENSE_API_URL") or DEFAULT_API_URL).strip().rstrip("/")
|
||
return url or DEFAULT_API_URL
|
||
|
||
|
||
def client_key() -> str:
|
||
_load_license_env()
|
||
env_key = (os.getenv("LICENSE_CLIENT_KEY") or "").strip()
|
||
if env_key:
|
||
return env_key
|
||
with _lock:
|
||
st = _read_state()
|
||
return (st.get("client_api_key") or "").strip()
|
||
|
||
|
||
def set_client_api_key(key: str) -> dict[str, Any]:
|
||
"""保存用户在授权页填写的客户密钥 / client token(写入 license_state.json)。"""
|
||
key = (key or "").strip()
|
||
if len(key) < 8:
|
||
return {"ok": False, "message": "客户密钥太短"}
|
||
with _lock:
|
||
st = _read_state()
|
||
st["client_api_key"] = key
|
||
_write_state(st)
|
||
os.environ["LICENSE_CLIENT_KEY"] = key
|
||
return {"ok": True, "message": "客户密钥已保存"}
|
||
|
||
|
||
def offline_grace_hours() -> float:
|
||
_load_license_env()
|
||
raw = (os.getenv("LICENSE_OFFLINE_GRACE_HOURS") or str(DEFAULT_OFFLINE_GRACE_HOURS)).strip()
|
||
try:
|
||
return max(0.0, float(raw))
|
||
except ValueError:
|
||
return float(DEFAULT_OFFLINE_GRACE_HOURS)
|
||
|
||
|
||
def state_path() -> Path:
|
||
_load_license_env()
|
||
override = (os.getenv("LICENSE_STATE_PATH") or "").strip()
|
||
if override:
|
||
p = Path(override)
|
||
if not p.is_absolute():
|
||
p = REPO_ROOT / p
|
||
p.parent.mkdir(parents=True, exist_ok=True)
|
||
return p
|
||
data = REPO_ROOT / "data"
|
||
data.mkdir(parents=True, exist_ok=True)
|
||
return data / STATE_FILENAME
|
||
|
||
|
||
def get_device_id() -> str:
|
||
"""稳定设备指纹,长度 16–64,供 redeem/validate 使用。"""
|
||
global _cached_device_id
|
||
if _cached_device_id:
|
||
return _cached_device_id
|
||
parts = [
|
||
platform.node() or "",
|
||
platform.system() or "",
|
||
platform.machine() or "",
|
||
hex(uuid.getnode()),
|
||
]
|
||
try:
|
||
machine_id = Path("/etc/machine-id")
|
||
if machine_id.is_file():
|
||
parts.append(machine_id.read_text(encoding="utf-8").strip())
|
||
except OSError:
|
||
pass
|
||
try:
|
||
win_id = Path(os.environ.get("SystemRoot", r"C:\Windows")) / "System32" / "drivers" / "etc"
|
||
parts.append(str(win_id.resolve()))
|
||
except OSError:
|
||
pass
|
||
digest = hashlib.sha256("|".join(parts).encode("utf-8", errors="ignore")).hexdigest()
|
||
_cached_device_id = digest[:40]
|
||
return _cached_device_id
|
||
|
||
|
||
def _read_state() -> dict[str, Any]:
|
||
path = state_path()
|
||
if not path.is_file():
|
||
return {}
|
||
try:
|
||
data = json.loads(path.read_text(encoding="utf-8"))
|
||
return data if isinstance(data, dict) else {}
|
||
except (OSError, json.JSONDecodeError):
|
||
return {}
|
||
|
||
|
||
def _write_state(data: dict[str, Any]) -> None:
|
||
path = state_path()
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp = path.with_suffix(".tmp")
|
||
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
tmp.replace(path)
|
||
|
||
|
||
def _now_ts() -> float:
|
||
return time.time()
|
||
|
||
|
||
def _parse_expires_at(value: str | None) -> float | None:
|
||
if not value:
|
||
return None
|
||
s = value.strip()
|
||
if not s:
|
||
return None
|
||
try:
|
||
if s.endswith("Z"):
|
||
s = s[:-1] + "+00:00"
|
||
dt = datetime.fromisoformat(s)
|
||
if dt.tzinfo is None:
|
||
# 授权站按北京时间存,无 tz 时按 UTC+8
|
||
from datetime import timedelta, timezone as _tz
|
||
|
||
dt = dt.replace(tzinfo=_tz(timedelta(hours=8)))
|
||
return dt.timestamp()
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def _http_json(method: str, path: str, body: dict[str, Any]) -> dict[str, Any]:
|
||
key = client_key()
|
||
if not key:
|
||
return {"ok": False, "message": "未配置客户密钥。请在软件授权页填写卖家提供的客户密钥(与激活码一起发放)。"}
|
||
url = f"{api_base_url()}{path}"
|
||
payload = json.dumps(body).encode("utf-8")
|
||
req = Request(
|
||
url,
|
||
data=payload,
|
||
method=method.upper(),
|
||
headers={
|
||
"Content-Type": "application/json",
|
||
"X-Client-Key": key,
|
||
"User-Agent": "crypto_monitor_user/license",
|
||
},
|
||
)
|
||
try:
|
||
with urlopen(req, timeout=20) as resp:
|
||
raw = resp.read().decode("utf-8", errors="replace")
|
||
data = json.loads(raw) if raw else {}
|
||
return data if isinstance(data, dict) else {"ok": False, "message": "响应格式错误"}
|
||
except HTTPError as e:
|
||
try:
|
||
detail = e.read().decode("utf-8", errors="replace")
|
||
except Exception:
|
||
detail = str(e)
|
||
return {"ok": False, "message": f"HTTP {e.code}: {detail[:200]}", "network_error": True}
|
||
except (URLError, TimeoutError, OSError) as e:
|
||
return {"ok": False, "message": f"无法连接授权服务: {e}", "network_error": True}
|
||
except json.JSONDecodeError:
|
||
return {"ok": False, "message": "授权服务返回非 JSON", "network_error": True}
|
||
|
||
|
||
def redeem_code(code: str, client_api_key: str | None = None) -> dict[str, Any]:
|
||
code = (code or "").strip().upper().replace(" ", "")
|
||
if len(code) < 8:
|
||
return {"ok": False, "message": "激活码无效"}
|
||
if client_api_key is not None and str(client_api_key).strip():
|
||
saved = set_client_api_key(str(client_api_key))
|
||
if not saved.get("ok"):
|
||
return saved
|
||
if not client_key():
|
||
return {
|
||
"ok": False,
|
||
"message": "请先填写客户密钥(与激活码一起由卖家提供;续费时可留空沿用本地已保存的密钥)",
|
||
}
|
||
device_id = get_device_id()
|
||
result = _http_json("POST", "/v1/redeem", {"device_id": device_id, "code": code})
|
||
if not result.get("ok"):
|
||
return {
|
||
"ok": False,
|
||
"message": result.get("message") or "兑换失败",
|
||
"network_error": bool(result.get("network_error")),
|
||
}
|
||
now = _now_ts()
|
||
with _lock:
|
||
state = _read_state()
|
||
state.update(
|
||
{
|
||
"device_id": device_id,
|
||
"subscription_id": result.get("subscription_id"),
|
||
"plan": result.get("plan"),
|
||
"expires_at": result.get("expires_at"),
|
||
"last_ok_at": now,
|
||
"last_validate_at": now,
|
||
"last_reason": "",
|
||
}
|
||
)
|
||
_write_state(state)
|
||
return {
|
||
"ok": True,
|
||
"message": result.get("message") or "激活成功",
|
||
"subscription_id": result.get("subscription_id"),
|
||
"plan": result.get("plan"),
|
||
"expires_at": result.get("expires_at"),
|
||
"device_id": device_id,
|
||
}
|
||
|
||
|
||
def validate_license(*, force: bool = False) -> dict[str, Any]:
|
||
"""联网校验;更新本地状态。force=True 忽略 3 天间隔。"""
|
||
with _lock:
|
||
state = _read_state()
|
||
sub_id = (state.get("subscription_id") or "").strip()
|
||
if not sub_id:
|
||
return {"ok": True, "valid": False, "reason": "not_activated", "message": "尚未激活"}
|
||
|
||
last_v = float(state.get("last_validate_at") or 0)
|
||
if not force and last_v and (_now_ts() - last_v) < VALIDATE_INTERVAL_SEC:
|
||
return get_license_status()
|
||
|
||
device_id = get_device_id()
|
||
result = _http_json(
|
||
"POST",
|
||
"/v1/validate",
|
||
{"device_id": device_id, "subscription_id": sub_id},
|
||
)
|
||
now = _now_ts()
|
||
if result.get("network_error"):
|
||
# 断网:在宽限期内沿用本地到期日
|
||
status = get_license_status(skip_remote=True)
|
||
status["message"] = result.get("message") or "网络异常,使用本地宽限"
|
||
status["network_error"] = True
|
||
with _lock:
|
||
st = _read_state()
|
||
st["last_network_error_at"] = now
|
||
st["last_reason"] = "network_error"
|
||
_write_state(st)
|
||
return status
|
||
|
||
valid = bool(result.get("valid"))
|
||
reason = (result.get("reason") or "").strip()
|
||
with _lock:
|
||
st = _read_state()
|
||
st["device_id"] = device_id
|
||
st["last_validate_at"] = now
|
||
if valid:
|
||
st["last_ok_at"] = now
|
||
st["last_reason"] = ""
|
||
if result.get("expires_at"):
|
||
st["expires_at"] = result.get("expires_at")
|
||
if result.get("plan"):
|
||
st["plan"] = result.get("plan")
|
||
else:
|
||
st["last_reason"] = reason or "invalid"
|
||
if reason in ("expired", "device_revoked", "subscription_not_found", "subscription_inactive"):
|
||
# 明确失效:保留字段便于页面展示,但 valid=False
|
||
pass
|
||
_write_state(st)
|
||
|
||
return {
|
||
"ok": True,
|
||
"valid": valid,
|
||
"reason": reason if not valid else "",
|
||
"expires_at": result.get("expires_at") or state.get("expires_at"),
|
||
"plan": result.get("plan") or state.get("plan"),
|
||
"device_id": device_id,
|
||
"subscription_id": sub_id,
|
||
"message": "有效" if valid else (reason or "无效"),
|
||
}
|
||
|
||
|
||
def get_license_status(*, skip_remote: bool = False) -> dict[str, Any]:
|
||
"""本地状态(可触发到期判断);不主动联网除非 skip_remote=False 且到期需刷新。"""
|
||
with _lock:
|
||
state = _read_state()
|
||
device_id = get_device_id()
|
||
key = client_key()
|
||
key_mask = (key[:4] + "…" + key[-4:]) if len(key) >= 12 else (("已配置" if key else ""))
|
||
sub_id = (state.get("subscription_id") or "").strip()
|
||
if not sub_id:
|
||
return {
|
||
"ok": True,
|
||
"valid": False,
|
||
"reason": "not_activated",
|
||
"message": "尚未激活",
|
||
"device_id": device_id,
|
||
"api_url": api_base_url(),
|
||
"has_client_key": bool(key),
|
||
"client_key_masked": key_mask,
|
||
}
|
||
|
||
exp_ts = _parse_expires_at(state.get("expires_at"))
|
||
now = _now_ts()
|
||
if exp_ts is not None and now > exp_ts:
|
||
return {
|
||
"ok": True,
|
||
"valid": False,
|
||
"reason": "expired",
|
||
"message": "许可已过期",
|
||
"expires_at": state.get("expires_at"),
|
||
"plan": state.get("plan"),
|
||
"device_id": device_id,
|
||
"subscription_id": sub_id,
|
||
"api_url": api_base_url(),
|
||
"has_client_key": bool(key),
|
||
"client_key_masked": key_mask,
|
||
}
|
||
|
||
reason = (state.get("last_reason") or "").strip()
|
||
if reason in ("expired", "device_revoked", "subscription_not_found", "subscription_inactive"):
|
||
return {
|
||
"ok": True,
|
||
"valid": False,
|
||
"reason": reason,
|
||
"message": reason,
|
||
"expires_at": state.get("expires_at"),
|
||
"plan": state.get("plan"),
|
||
"device_id": device_id,
|
||
"subscription_id": sub_id,
|
||
"api_url": api_base_url(),
|
||
"has_client_key": bool(key),
|
||
"client_key_masked": key_mask,
|
||
}
|
||
|
||
last_ok = float(state.get("last_ok_at") or 0)
|
||
grace = offline_grace_hours() * 3600
|
||
if last_ok and grace > 0 and (now - last_ok) > grace:
|
||
# 超过离线宽限且长时间未成功校验
|
||
last_v = float(state.get("last_validate_at") or 0)
|
||
if last_v and (now - last_v) >= VALIDATE_INTERVAL_SEC and not skip_remote:
|
||
return validate_license(force=True)
|
||
if (now - last_ok) > grace:
|
||
return {
|
||
"ok": True,
|
||
"valid": False,
|
||
"reason": "offline_grace_exceeded",
|
||
"message": "超过离线宽限期,请联网校验",
|
||
"expires_at": state.get("expires_at"),
|
||
"plan": state.get("plan"),
|
||
"device_id": device_id,
|
||
"subscription_id": sub_id,
|
||
"api_url": api_base_url(),
|
||
"has_client_key": bool(key),
|
||
"client_key_masked": key_mask,
|
||
}
|
||
|
||
return {
|
||
"ok": True,
|
||
"valid": True,
|
||
"reason": "",
|
||
"message": "已授权",
|
||
"expires_at": state.get("expires_at"),
|
||
"plan": state.get("plan"),
|
||
"device_id": device_id,
|
||
"subscription_id": sub_id,
|
||
"last_ok_at": last_ok or None,
|
||
"api_url": api_base_url(),
|
||
"has_client_key": bool(key),
|
||
"client_key_masked": key_mask,
|
||
}
|
||
|
||
|
||
def is_license_valid() -> bool:
|
||
status = get_license_status()
|
||
if status.get("valid"):
|
||
# 到期需周期性联网
|
||
last_v = 0.0
|
||
with _lock:
|
||
st = _read_state()
|
||
last_v = float(st.get("last_validate_at") or 0)
|
||
if last_v and (_now_ts() - last_v) >= VALIDATE_INTERVAL_SEC:
|
||
status = validate_license(force=True)
|
||
return bool(status.get("valid"))
|
||
# 未激活或已失效:尝试若有 subscription 则强制校验一次(避免本地过期标志陈旧)
|
||
with _lock:
|
||
st = _read_state()
|
||
if (st.get("subscription_id") or "").strip() and status.get("reason") not in (
|
||
"not_activated",
|
||
"expired",
|
||
"device_revoked",
|
||
):
|
||
status = validate_license(force=True)
|
||
return bool(status.get("valid"))
|
||
return False
|
||
|
||
|
||
def ensure_license_or_raise() -> None:
|
||
if not is_license_valid():
|
||
raise RuntimeError("license_required")
|