From 8eefc6cfd587c65a082f7ec9d8e642255f1bf9cb Mon Sep 17 00:00:00 2001 From: dekun Date: Fri, 17 Jul 2026 17:20:47 +0800 Subject: [PATCH] feat: accept CLIENT_API_KEY on /license page. Users can paste the key from the seller instead of editing license.env; it persists in license_state.json. Co-authored-by: Cursor --- docs/license.md | 24 ++++++----- lib/license/fastapi_gate.py | 10 +++-- lib/license/flask_gate.py | 6 ++- lib/license/license_lib.py | 66 ++++++++++++++++++++++++------ lib/license/templates/license.html | 10 ++++- license.env.example | 7 ++-- 6 files changed, 91 insertions(+), 32 deletions(-) diff --git a/docs/license.md b/docs/license.md index 2c164ec..003f241 100644 --- a/docs/license.md +++ b/docs/license.md @@ -7,8 +7,8 @@ 1. 部署并启动本系统(三所或中控任一页面)。 2. 浏览器打开 **`/license`**(未授权时会自动跳转)。 3. 复制页面上的 **设备 ID**。 -4. 联系微信 **dekun03** 购买(备注设备 ID),按约定支付 **USDT** 后获得激活码。 -5. 在 `/license` 粘贴激活码并兑换。 +4. 联系微信 **dekun03** 购买(备注设备 ID),按约定支付 **USDT** 后获得 **CLIENT_API_KEY** 与激活码。 +5. 在 `/license` 填写 **CLIENT_API_KEY** 与激活码并兑换(密钥会写入本地 `data/license_state.json`,下次可留空)。 ## 定价(整机授权) @@ -22,29 +22,33 @@ - 换机:旧设备立即失效,需换机码(向卖家申请)。 - USDT 收款地址由卖家提供(链与地址以当时通知为准)。 -## 环境变量 +## CLIENT_API_KEY(推荐) -在**仓库根目录**创建 **`license.env`**(勿提交 Git): +卖家在授权站后台 **系统设置** 可查看/复制 `CLIENT_API_KEY`,发给用户后填入软件 `/license` 页即可,**不必再手改 `license.env`**。 + +优先级:环境变量 `LICENSE_CLIENT_KEY` > 授权页保存的本地密钥。 + +## 环境变量(可选) + +在**仓库根目录**创建 **`license.env`**(勿提交 Git),可预先写好密钥: ```bash cd /opt/crypto_monitor_user # 或你的安装目录 cp license.env.example license.env -# 编辑 license.env:LICENSE_CLIENT_KEY 必须与授权站 CLIENT_API_KEY 完全一致 ``` ```env LICENSE_API_URL=https://sq.bz121.com -LICENSE_CLIENT_KEY=与授权站 CLIENT_API_KEY 相同 +# 可选:与授权站 CLIENT_API_KEY 相同;也可只在 /license 页填写 +# LICENSE_CLIENT_KEY= # 可选:断网宽限小时数(默认 72) # LICENSE_OFFLINE_GRACE_HOURS=72 # 本地调试可临时关闭门禁(生产勿开) # LICENSE_DISABLED=false ``` -改完后**重启** Flask / 中控进程。页面若提示未配置 `LICENSE_CLIENT_KEY`,就是根目录缺少有效的 `license.env`。 +改完环境变量后**重启** Flask / 中控进程。也可在各实例 `.env` 里写同样的 `LICENSE_*`(不要留空键)。 -也可在各实例 `.env` 里写同样的 `LICENSE_*`(不要留空键)。 - -许可状态保存在仓库根目录 **`data/license_state.json`**(三所 + 中控共用)。 +许可状态保存在仓库根目录 **`data/license_state.json`**(三所 + 中控共用,含本地保存的 `client_api_key`)。 系统约每 **3 天** 向授权站校验一次;明确过期或换机后旧设备将无法使用。 diff --git a/lib/license/fastapi_gate.py b/lib/license/fastapi_gate.py index e0c893d..d372d35 100644 --- a/lib/license/fastapi_gate.py +++ b/lib/license/fastapi_gate.py @@ -51,8 +51,11 @@ def install_license_middleware(app: FastAPI) -> None: data = await request.json() except Exception: data = {} - code = (data.get("code") if isinstance(data, dict) else "") or "" - return redeem_code(str(code)) + if not isinstance(data, dict): + data = {} + code = str(data.get("code") or "").strip() + ckey = str(data.get("client_api_key") or "").strip() + return redeem_code(code, client_api_key=ckey or None) @app.post("/api/license/validate") async def _license_validate_api(): @@ -65,7 +68,8 @@ def install_license_middleware(app: FastAPI) -> None: if request.method == "POST": form = await request.form() code = str(form.get("code") or "").strip() - result = redeem_code(code) + ckey = str(form.get("client_api_key") or "").strip() + result = redeem_code(code, client_api_key=ckey or None) if result.get("ok"): msg = result.get("message") or "激活成功" else: diff --git a/lib/license/flask_gate.py b/lib/license/flask_gate.py index b48cdfa..b4cbbcf 100644 --- a/lib/license/flask_gate.py +++ b/lib/license/flask_gate.py @@ -44,7 +44,8 @@ def install_license_gate(app: Flask) -> None: def _license_redeem_api(): data = request.get_json(silent=True) or {} code = (data.get("code") or request.form.get("code") or "").strip() - return jsonify(redeem_code(code)) + ckey = (data.get("client_api_key") or request.form.get("client_api_key") or "").strip() + return jsonify(redeem_code(code, client_api_key=ckey or None)) @app.post("/api/license/validate") def _license_validate_api(): @@ -56,7 +57,8 @@ def install_license_gate(app: Flask) -> None: err = "" if request.method == "POST": code = (request.form.get("code") or "").strip() - result = redeem_code(code) + ckey = (request.form.get("client_api_key") or "").strip() + result = redeem_code(code, client_api_key=ckey or None) if result.get("ok"): msg = result.get("message") or "激活成功" else: diff --git a/lib/license/license_lib.py b/lib/license/license_lib.py index 49942d5..2f8ea3a 100644 --- a/lib/license/license_lib.py +++ b/lib/license/license_lib.py @@ -63,7 +63,25 @@ def api_base_url() -> str: def client_key() -> str: _load_license_env() - return (os.getenv("LICENSE_CLIENT_KEY") or "").strip() + 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_API_KEY(写入 license_state.json)。""" + key = (key or "").strip() + if len(key) < 8: + return {"ok": False, "message": "CLIENT_API_KEY 太短"} + with _lock: + st = _read_state() + st["client_api_key"] = key + _write_state(st) + os.environ["LICENSE_CLIENT_KEY"] = key + return {"ok": True, "message": "CLIENT_API_KEY 已保存"} def offline_grace_hours() -> float: @@ -162,7 +180,7 @@ def _parse_expires_at(value: str | None) -> float | 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": "未配置 LICENSE_CLIENT_KEY。请在仓库根目录复制 license.env.example 为 license.env,填入与授权站相同的 CLIENT_API_KEY,然后重启服务。"} + return {"ok": False, "message": "未配置 CLIENT_API_KEY。请在软件授权页填写卖家提供的密钥,或在 license.env 中设置 LICENSE_CLIENT_KEY。"} url = f"{api_base_url()}{path}" payload = json.dumps(body).encode("utf-8") req = Request( @@ -192,10 +210,19 @@ def _http_json(method: str, path: str, body: dict[str, Any]) -> dict[str, Any]: return {"ok": False, "message": "授权服务返回非 JSON", "network_error": True} -def redeem_code(code: str) -> dict[str, Any]: +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": "请先填写 CLIENT_API_KEY(向卖家索取,与授权站后台「系统设置」中的密钥一致)", + } device_id = get_device_id() result = _http_json("POST", "/v1/redeem", {"device_id": device_id, "code": code}) if not result.get("ok"): @@ -206,15 +233,18 @@ def redeem_code(code: str) -> dict[str, Any]: } now = _now_ts() with _lock: - state = { - "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": "", - } + 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, @@ -294,6 +324,8 @@ def get_license_status(*, skip_remote: bool = False) -> dict[str, Any]: 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 { @@ -303,6 +335,8 @@ def get_license_status(*, skip_remote: bool = False) -> dict[str, Any]: "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")) @@ -318,6 +352,8 @@ def get_license_status(*, skip_remote: bool = False) -> dict[str, Any]: "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() @@ -332,6 +368,8 @@ def get_license_status(*, skip_remote: bool = False) -> dict[str, Any]: "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) @@ -352,6 +390,8 @@ def get_license_status(*, skip_remote: bool = False) -> dict[str, Any]: "device_id": device_id, "subscription_id": sub_id, "api_url": api_base_url(), + "has_client_key": bool(key), + "client_key_masked": key_mask, } return { @@ -365,6 +405,8 @@ def get_license_status(*, skip_remote: bool = False) -> dict[str, Any]: "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, } diff --git a/lib/license/templates/license.html b/lib/license/templates/license.html index 752b4cb..5fb59c5 100644 --- a/lib/license/templates/license.html +++ b/lib/license/templates/license.html @@ -43,7 +43,7 @@ font-family: ui-monospace, Consolas, monospace; font-size: 0.85rem; } - input[type=text] { + input[type=text], input[type=password] { width: 100%; padding: 10px 12px; border-radius: 8px; @@ -67,12 +67,13 @@ .err { color: #ff7b8a; margin: 10px 0; font-size: 0.9rem; } .meta { margin-top: 16px; font-size: 0.85rem; color: #9aa0b4; line-height: 1.6; } a { color: #7ec8ff; } + .hint { color: #6a7088; font-size: 0.78rem; margin-top: 4px; }

软件授权

-

复制下方设备 ID,联系微信 {{ wechat }} 购买激活码后粘贴兑换。授权站:{{ api_url }}

+

复制设备 ID,联系微信 {{ wechat }} 获取 CLIENT_API_KEY 与激活码。授权站:{{ api_url }}

{% if message %}

{{ message }}

{% endif %} {% if error %}

{{ error }}

{% endif %} @@ -83,6 +84,11 @@
+
+ + +
与授权站后台「系统设置」中的密钥一致,本地保存后下次可留空。
+
diff --git a/license.env.example b/license.env.example index 435d861..b7df8af 100644 --- a/license.env.example +++ b/license.env.example @@ -1,7 +1,8 @@ -# LICENSE_API_URL / LICENSE_CLIENT_KEY -# 复制为仓库根目录 license.env(已 gitignore),与授权站 CLIENT_API_KEY 保持一致。 +# LICENSE_API_URL / LICENSE_CLIENT_KEY(可选) +# 复制为仓库根目录 license.env(已 gitignore)。 +# CLIENT_API_KEY 也可直接在软件 /license 页填写,不必改本文件。 LICENSE_API_URL=https://sq.bz121.com -LICENSE_CLIENT_KEY=cm_user_sq_bz121_8f3a2c1d9e4b7a6f5d0e1b2c3a4f5e6 +# LICENSE_CLIENT_KEY= # LICENSE_OFFLINE_GRACE_HOURS=72 # LICENSE_DISABLED=false