Files
2026-07-17 20:12:09 +08:00

460 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""用户端许可客户端:对接 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"
# USDT 收款(与授权站 app/plans.py 保持一致;可用环境变量覆盖)
DEFAULT_USDT_ADDRESS = "TEqtozJKTEJ6qZcyvienpfyswaeAHXc8ks"
DEFAULT_USDT_NETWORK = "TRC20"
DEFAULT_WECHAT_ID = "dekun03"
_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 usdt_address() -> str:
_load_license_env()
return (os.getenv("LICENSE_USDT_ADDRESS") or DEFAULT_USDT_ADDRESS).strip()
def usdt_network() -> str:
_load_license_env()
return (os.getenv("LICENSE_USDT_NETWORK") or DEFAULT_USDT_NETWORK).strip() or DEFAULT_USDT_NETWORK
def wechat_id() -> str:
_load_license_env()
return (os.getenv("LICENSE_WECHAT_ID") or DEFAULT_WECHAT_ID).strip() or DEFAULT_WECHAT_ID
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:
"""稳定设备指纹,长度 1664,供 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")