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 <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-17 17:20:47 +08:00
parent 863da10d1f
commit 8eefc6cfd5
6 changed files with 91 additions and 32 deletions
+14 -10
View File
@@ -7,8 +7,8 @@
1. 部署并启动本系统(三所或中控任一页面)。 1. 部署并启动本系统(三所或中控任一页面)。
2. 浏览器打开 **`/license`**(未授权时会自动跳转)。 2. 浏览器打开 **`/license`**(未授权时会自动跳转)。
3. 复制页面上的 **设备 ID** 3. 复制页面上的 **设备 ID**
4. 联系微信 **dekun03** 购买(备注设备 ID),按约定支付 **USDT** 后获得激活码。 4. 联系微信 **dekun03** 购买(备注设备 ID),按约定支付 **USDT** 后获得 **CLIENT_API_KEY**激活码。
5.`/license` 粘贴激活码并兑换 5.`/license` 填写 **CLIENT_API_KEY** 与激活码并兑换(密钥会写入本地 `data/license_state.json`,下次可留空)
## 定价(整机授权) ## 定价(整机授权)
@@ -22,29 +22,33 @@
- 换机:旧设备立即失效,需换机码(向卖家申请)。 - 换机:旧设备立即失效,需换机码(向卖家申请)。
- USDT 收款地址由卖家提供(链与地址以当时通知为准)。 - USDT 收款地址由卖家提供(链与地址以当时通知为准)。
## 环境变量 ## CLIENT_API_KEY(推荐)
在**仓库根目录**创建 **`license.env`**(勿提交 Git): 卖家在授权站后台 **系统设置** 可查看/复制 `CLIENT_API_KEY`,发给用户后填入软件 `/license` 页即可,**不必再手改 `license.env`**
优先级:环境变量 `LICENSE_CLIENT_KEY` > 授权页保存的本地密钥。
## 环境变量(可选)
在**仓库根目录**创建 **`license.env`**(勿提交 Git),可预先写好密钥:
```bash ```bash
cd /opt/crypto_monitor_user # 或你的安装目录 cd /opt/crypto_monitor_user # 或你的安装目录
cp license.env.example license.env cp license.env.example license.env
# 编辑 license.envLICENSE_CLIENT_KEY 必须与授权站 CLIENT_API_KEY 完全一致
``` ```
```env ```env
LICENSE_API_URL=https://sq.bz121.com LICENSE_API_URL=https://sq.bz121.com
LICENSE_CLIENT_KEY=与授权站 CLIENT_API_KEY 相同 # 可选:与授权站 CLIENT_API_KEY 相同;也可只在 /license 页填写
# LICENSE_CLIENT_KEY=
# 可选:断网宽限小时数(默认 72 # 可选:断网宽限小时数(默认 72
# LICENSE_OFFLINE_GRACE_HOURS=72 # LICENSE_OFFLINE_GRACE_HOURS=72
# 本地调试可临时关闭门禁(生产勿开) # 本地调试可临时关闭门禁(生产勿开)
# LICENSE_DISABLED=false # LICENSE_DISABLED=false
``` ```
改完后**重启** Flask / 中控进程。页面若提示未配置 `LICENSE_CLIENT_KEY`,就是根目录缺少有效的 `license.env` 改完环境变量后**重启** Flask / 中控进程。也可在各实例 `.env` 里写同样的 `LICENSE_*`(不要留空键)
也可在各实例 `.env` 里写同样的 `LICENSE_*`(不要留空键)。 许可状态保存在仓库根目录 **`data/license_state.json`**(三所 + 中控共用,含本地保存的 `client_api_key`)。
许可状态保存在仓库根目录 **`data/license_state.json`**(三所 + 中控共用)。
系统约每 **3 天** 向授权站校验一次;明确过期或换机后旧设备将无法使用。 系统约每 **3 天** 向授权站校验一次;明确过期或换机后旧设备将无法使用。
+7 -3
View File
@@ -51,8 +51,11 @@ def install_license_middleware(app: FastAPI) -> None:
data = await request.json() data = await request.json()
except Exception: except Exception:
data = {} data = {}
code = (data.get("code") if isinstance(data, dict) else "") or "" if not isinstance(data, dict):
return redeem_code(str(code)) 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") @app.post("/api/license/validate")
async def _license_validate_api(): async def _license_validate_api():
@@ -65,7 +68,8 @@ def install_license_middleware(app: FastAPI) -> None:
if request.method == "POST": if request.method == "POST":
form = await request.form() form = await request.form()
code = str(form.get("code") or "").strip() 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"): if result.get("ok"):
msg = result.get("message") or "激活成功" msg = result.get("message") or "激活成功"
else: else:
+4 -2
View File
@@ -44,7 +44,8 @@ def install_license_gate(app: Flask) -> None:
def _license_redeem_api(): def _license_redeem_api():
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
code = (data.get("code") or request.form.get("code") or "").strip() 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") @app.post("/api/license/validate")
def _license_validate_api(): def _license_validate_api():
@@ -56,7 +57,8 @@ def install_license_gate(app: Flask) -> None:
err = "" err = ""
if request.method == "POST": if request.method == "POST":
code = (request.form.get("code") or "").strip() 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"): if result.get("ok"):
msg = result.get("message") or "激活成功" msg = result.get("message") or "激活成功"
else: else:
+54 -12
View File
@@ -63,7 +63,25 @@ def api_base_url() -> str:
def client_key() -> str: def client_key() -> str:
_load_license_env() _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: 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]: def _http_json(method: str, path: str, body: dict[str, Any]) -> dict[str, Any]:
key = client_key() key = client_key()
if not 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}" url = f"{api_base_url()}{path}"
payload = json.dumps(body).encode("utf-8") payload = json.dumps(body).encode("utf-8")
req = Request( 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} 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(" ", "") code = (code or "").strip().upper().replace(" ", "")
if len(code) < 8: if len(code) < 8:
return {"ok": False, "message": "激活码无效"} 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() device_id = get_device_id()
result = _http_json("POST", "/v1/redeem", {"device_id": device_id, "code": code}) result = _http_json("POST", "/v1/redeem", {"device_id": device_id, "code": code})
if not result.get("ok"): if not result.get("ok"):
@@ -206,15 +233,18 @@ def redeem_code(code: str) -> dict[str, Any]:
} }
now = _now_ts() now = _now_ts()
with _lock: with _lock:
state = { state = _read_state()
"device_id": device_id, state.update(
"subscription_id": result.get("subscription_id"), {
"plan": result.get("plan"), "device_id": device_id,
"expires_at": result.get("expires_at"), "subscription_id": result.get("subscription_id"),
"last_ok_at": now, "plan": result.get("plan"),
"last_validate_at": now, "expires_at": result.get("expires_at"),
"last_reason": "", "last_ok_at": now,
} "last_validate_at": now,
"last_reason": "",
}
)
_write_state(state) _write_state(state)
return { return {
"ok": True, "ok": True,
@@ -294,6 +324,8 @@ def get_license_status(*, skip_remote: bool = False) -> dict[str, Any]:
with _lock: with _lock:
state = _read_state() state = _read_state()
device_id = get_device_id() 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() sub_id = (state.get("subscription_id") or "").strip()
if not sub_id: if not sub_id:
return { return {
@@ -303,6 +335,8 @@ def get_license_status(*, skip_remote: bool = False) -> dict[str, Any]:
"message": "尚未激活", "message": "尚未激活",
"device_id": device_id, "device_id": device_id,
"api_url": api_base_url(), "api_url": api_base_url(),
"has_client_key": bool(key),
"client_key_masked": key_mask,
} }
exp_ts = _parse_expires_at(state.get("expires_at")) 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, "device_id": device_id,
"subscription_id": sub_id, "subscription_id": sub_id,
"api_url": api_base_url(), "api_url": api_base_url(),
"has_client_key": bool(key),
"client_key_masked": key_mask,
} }
reason = (state.get("last_reason") or "").strip() 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, "device_id": device_id,
"subscription_id": sub_id, "subscription_id": sub_id,
"api_url": api_base_url(), "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) 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, "device_id": device_id,
"subscription_id": sub_id, "subscription_id": sub_id,
"api_url": api_base_url(), "api_url": api_base_url(),
"has_client_key": bool(key),
"client_key_masked": key_mask,
} }
return { return {
@@ -365,6 +405,8 @@ def get_license_status(*, skip_remote: bool = False) -> dict[str, Any]:
"subscription_id": sub_id, "subscription_id": sub_id,
"last_ok_at": last_ok or None, "last_ok_at": last_ok or None,
"api_url": api_base_url(), "api_url": api_base_url(),
"has_client_key": bool(key),
"client_key_masked": key_mask,
} }
+8 -2
View File
@@ -43,7 +43,7 @@
font-family: ui-monospace, Consolas, monospace; font-family: ui-monospace, Consolas, monospace;
font-size: 0.85rem; font-size: 0.85rem;
} }
input[type=text] { input[type=text], input[type=password] {
width: 100%; width: 100%;
padding: 10px 12px; padding: 10px 12px;
border-radius: 8px; border-radius: 8px;
@@ -67,12 +67,13 @@
.err { color: #ff7b8a; margin: 10px 0; font-size: 0.9rem; } .err { color: #ff7b8a; margin: 10px 0; font-size: 0.9rem; }
.meta { margin-top: 16px; font-size: 0.85rem; color: #9aa0b4; line-height: 1.6; } .meta { margin-top: 16px; font-size: 0.85rem; color: #9aa0b4; line-height: 1.6; }
a { color: #7ec8ff; } a { color: #7ec8ff; }
.hint { color: #6a7088; font-size: 0.78rem; margin-top: 4px; }
</style> </style>
</head> </head>
<body> <body>
<div class="box"> <div class="box">
<h1>软件授权</h1> <h1>软件授权</h1>
<p class="muted">复制下方设备 ID,联系微信 <strong>{{ wechat }}</strong> 购买激活码后粘贴兑换。授权站:{{ api_url }}</p> <p class="muted">复制设备 ID,联系微信 <strong>{{ wechat }}</strong> 获取 <strong>CLIENT_API_KEY</strong> 与激活码。授权站:{{ api_url }}</p>
{% if message %}<p class="ok">{{ message }}</p>{% endif %} {% if message %}<p class="ok">{{ message }}</p>{% endif %}
{% if error %}<p class="err">{{ error }}</p>{% endif %} {% if error %}<p class="err">{{ error }}</p>{% endif %}
@@ -83,6 +84,11 @@
</div> </div>
<form method="post" action="/license"> <form method="post" action="/license">
<div class="row">
<label>CLIENT_API_KEY</label>
<input type="text" name="client_api_key" placeholder="{% if status.has_client_key %}已保存 {{ status.client_key_masked }},可留空或填写新密钥{% else %}粘贴卖家提供的 CLIENT_API_KEY{% endif %}" autocomplete="off" {% if not status.has_client_key %}required{% endif %}>
<div class="hint">与授权站后台「系统设置」中的密钥一致,本地保存后下次可留空。</div>
</div>
<div class="row"> <div class="row">
<label>激活码</label> <label>激活码</label>
<input type="text" name="code" placeholder="粘贴激活码" autocomplete="off" required> <input type="text" name="code" placeholder="粘贴激活码" autocomplete="off" required>
+4 -3
View File
@@ -1,7 +1,8 @@
# LICENSE_API_URL / LICENSE_CLIENT_KEY # LICENSE_API_URL / LICENSE_CLIENT_KEY(可选)
# 复制为仓库根目录 license.env(已 gitignore,与授权站 CLIENT_API_KEY 保持一致 # 复制为仓库根目录 license.env(已 gitignore)。
# CLIENT_API_KEY 也可直接在软件 /license 页填写,不必改本文件。
LICENSE_API_URL=https://sq.bz121.com LICENSE_API_URL=https://sq.bz121.com
LICENSE_CLIENT_KEY=cm_user_sq_bz121_8f3a2c1d9e4b7a6f5d0e1b2c3a4f5e6 # LICENSE_CLIENT_KEY=
# LICENSE_OFFLINE_GRACE_HOURS=72 # LICENSE_OFFLINE_GRACE_HOURS=72
# LICENSE_DISABLED=false # LICENSE_DISABLED=false