授权体系落地:30 天试用 + LIC- 在线 / LICOFF- 离线激活 + /pricing 定价页

借鉴 Yaak 授权模型 + 复用自家 OpsXShell 生产级授权协议(opsx-licence 服务端):
- Rust services/license:30 天试用自首次启动自动开始;LIC- 在线激活/周检/解绑
  (双节点容灾 + 响应 Ed25519 签名校验 + 时钟回拨检测 + 30 天离线容忍);
  LICOFF- 离线证本地自验(产品码 opsxapi + 设备绑定 + ≤1 年期限)
- 前端 src/license.ts + 活动栏授权徽章(试用 N 天/已授权/未授权)+
  设置→授权页(状态横幅/激活表单/解绑/定价链接带 ?intent= 追踪)
- /pricing 独立定价页(三站同结构):30 天试用条款、在线/离线激活说明三卡、
  年付↔终身切换、授权 FAQ、独立开发者声明;导航全部接入
- opsxDB 个人 ¥69/年·¥199 终身 / 团队 ¥69 / 私有化 ¥6000(与 API 定位错开)
- GUI 实测:试用 30 天徽章渲染正常;浏览器态授权页正确显示仅桌面端提示
This commit is contained in:
logan committed 2026-10-16 03:27:45 +08:00
1 parent d7a9503042
commit c304b9f251
19 files changed
+888 -7

No files matched your search

+28
View File
@@ -1,5 +1,6 @@
use crate::{
error::AppError,
services::license,
models::{HttpResult, HttpSpec, WsSpec},
services::{
http,
@@ -42,3 +43,30 @@ pub async fn ws_send(
pub async fn ws_close(state: State<'_, WsState>, session_id: String) -> Result<(), AppError> {
websocket::close(&state, session_id).await
}
/* ---------------- 授权(试用 / 在线 LIC- / 离线 LICOFF-) ---------------- */
#[tauri::command]
pub fn license_status(app: AppHandle) -> Result<license::LicenseStatus, String> {
license::read_status(&app)
}
#[tauri::command]
pub fn license_activate(
app: AppHandle,
license_key: String,
email: Option<String>,
) -> Result<license::LicenseStatus, String> {
license::activate(&app, &license_key, email.as_deref())
}
#[tauri::command]
pub fn license_verify(app: AppHandle) -> Result<license::LicenseStatus, String> {
license::verify(&app)
}
#[tauri::command]
pub fn license_deactivate(app: AppHandle) -> Result<license::LicenseStatus, String> {
license::deactivate(&app)
}
+2 -1
View File
@@ -11,7 +11,8 @@ pub fn run() {
.plugin(tauri_plugin_opener::init())
.manage(WsState::default())
.invoke_handler(tauri::generate_handler![
http_send, ws_connect, ws_send, ws_close
http_send, ws_connect, ws_send, ws_close,
license_status, license_activate, license_verify, license_deactivate
])
.run(tauri::generate_context!())
.expect("error while running OpsxAPI desktop");
+434
View File
@@ -0,0 +1,434 @@
//! OpsxAPI 授权:30 天全功能试用 + LIC- 在线授权 / LICOFF- 离线许可证。
//! 协议与 opsx-licence 服务端、OpsXShell 客户端保持同源:
//! - 在线激活 POST /v1/license/activate(双节点容灾)
//! - 在线验证 POST /v1/license/verify(响应带 Ed25519 sig,防假服务器)
//! - 离线证 LICOFF-1.{payload}.{sig} 自包含验签(绑定 product=opsxapi 与 device_id)
//! - 在线证连续 30 天无法联网验证则降级;离线证不受此限制
//! 试用策略(借鉴 Yaak):首次启动自动开始 30 天;到期后个人非商业可继续用,
//! 商用需授权——v1 不做硬功能锁,仅状态提示。
use base64::Engine;
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{fs, path::PathBuf, time::{Duration, SystemTime, UNIX_EPOCH}};
use tauri::{AppHandle, Manager};
use uuid::Uuid;
/// opsx-licence API 根(须以 /api 结尾)。双节点容灾:主 + 备。
const DEFAULT_API_BASES: &[&str] = &["https://l123.91ai.io/api", "https://licence.rlwj.cn/api"];
const HTTP_TIMEOUT: Duration = Duration::from_secs(15);
/// 在线证离线容忍上限:连续 30 天联系不上全部节点则要求联网重验
const OFFLINE_TOLERANCE_SECS: u64 = 30 * 86_400;
/// 时钟回拨容忍
const CLOCK_SKEW_TOLERANCE_SECS: u64 = 300;
/// 离线证验签公钥(与服务端 OFFLINE_LICENSE_SEED 同源;与 OpsXShell 客户端一致)
const OFFLINE_LICENSE_PUBLIC_KEY_HEX: &str =
"108375d485c58a1a738f93c2cbb838f76331de981ee5da13d90a1f280672131d";
/// 本产品码:离线证 claims.pid 必须匹配
const PRODUCT_CODE: &str = "opsxapi";
/// 试用时长:30 天
const TRIAL_SECS: u64 = 30 * 86_400;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct StoredLicense {
#[serde(default)]
license_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
email: Option<String>,
#[serde(default = "default_plan")]
plan: String,
#[serde(default)]
activated: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
expires_at: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
plan_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
max_devices: Option<u32>,
#[serde(default)]
last_verified_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
activation_token: Option<String>,
#[serde(default)]
last_server_time: u64,
}
fn default_plan() -> String {
"licensed".to_string()
}
/// 前端可见状态:phase 驱动 UI(试用倒计时 / 已授权 / 试用结束)
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LicenseStatus {
pub phase: String, // trialing | trial_ended | active
pub days_left: i64,
pub device_id: String,
pub activated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub plan_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_devices: Option<u32>,
pub trial_end: u64,
}
fn now_secs() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
fn data_dir(app: &AppHandle) -> Result<PathBuf, String> {
app.path().app_data_dir().map_err(|e| format!("无法确定应用数据目录: {e}"))
}
fn device_id_path(app: &AppHandle) -> Result<PathBuf, String> { Ok(data_dir(app)?.join("device.id")) }
fn license_path(app: &AppHandle) -> Result<PathBuf, String> { Ok(data_dir(app)?.join("license.opsx")) }
fn trial_path(app: &AppHandle) -> Result<PathBuf, String> { Ok(data_dir(app)?.join("trial.opsx")) }
fn ensure_parent(path: &PathBuf) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("创建数据目录失败: {e}"))?;
}
Ok(())
}
pub fn get_or_create_device_id(app: &AppHandle) -> Result<String, String> {
let path = device_id_path(app)?;
if path.exists() {
if let Ok(id) = fs::read_to_string(&path) {
let t = id.trim().to_string();
if !t.is_empty() { return Ok(t); }
}
}
ensure_parent(&path)?;
let id = Uuid::new_v4().to_string();
fs::write(&path, &id).map_err(|e| format!("写入设备 ID 失败: {e}"))?;
Ok(id)
}
/// 首次启动时间(试用起点);文件缺失则写入当前时间——即「首次启动自动开始试用」。
fn trial_start(app: &AppHandle) -> Result<u64, String> {
let path = trial_path(app)?;
if path.exists() {
if let Ok(raw) = fs::read_to_string(&path) {
if let Ok(n) = raw.trim().parse::<u64>() {
if n > 0 { return Ok(n); }
}
}
}
ensure_parent(&path)?;
let now = now_secs();
fs::write(&path, now.to_string()).map_err(|e| format!("写入试用起点失败: {e}"))?;
Ok(now)
}
fn read_stored(path: &PathBuf) -> Result<Option<StoredLicense>, String> {
if !path.exists() { return Ok(None); }
let raw = fs::read_to_string(path).map_err(|e| format!("读取许可证失败: {e}"))?;
let t = raw.trim();
if t.is_empty() || !t.starts_with('{') { return Ok(None); }
serde_json::from_str(t).map_err(|e| format!("许可证文件损坏: {e}")).map(Some)
}
fn write_stored(path: &PathBuf, stored: &StoredLicense) -> Result<(), String> {
ensure_parent(path)?;
let json = serde_json::to_string_pretty(stored).map_err(|e| format!("序列化许可证失败: {e}"))?;
fs::write(path, json).map_err(|e| format!("保存许可证失败: {e}"))
}
fn is_offline_license_key(k: &str) -> bool { k.trim().starts_with("LICOFF-") }
fn reject_legacy_key(k: &str) -> Result<(), String> {
let t = k.trim();
if t.is_empty() { return Err("请输入许可证密钥".into()); }
if t.starts_with("opsx1.") || t.contains(".opsx1.") {
return Err("不支持旧版 opsx1 许可证,请使用 LIC- / LICOFF- 授权码".into());
}
if t.starts_with("MII") {
return Err("看起来像私钥,请粘贴 LIC- / LICOFF- 许可证密钥".into());
}
Ok(())
}
#[derive(Debug, Deserialize)]
struct OfflineClaims {
#[serde(default)] v: i32,
#[serde(rename = "pid")] product_code: String,
#[serde(rename = "did")] device_id: String,
#[serde(default)] email: Option<String>,
#[serde(rename = "pt")] plan_type: String,
iat: i64,
exp: i64,
#[serde(rename = "md", default)] max_devices: i32,
}
fn offline_verifying_key() -> Result<VerifyingKey, String> {
let bytes = hex::decode(OFFLINE_LICENSE_PUBLIC_KEY_HEX).map_err(|_| "离线验签公钥配置错误".to_string())?;
VerifyingKey::from_bytes(bytes.as_slice().try_into().map_err(|_| "公钥长度错误".to_string())?)
.map_err(|_| "离线验签公钥无效".to_string())
}
/// 离线证本地自验:Ed25519 签名 + 产品码 + 设备绑定 + 有效期上限(≤1 年,长期授权走在线)。
fn verify_offline_serial(serial: &str, device_id: &str) -> Result<OfflineClaims, String> {
let body = serial.trim().strip_prefix("LICOFF-1.")
.ok_or("不是合法的离线序列号(需 LICOFF-1. 前缀)")?;
let (payload_b64, sig_b64) = body.rsplit_once('.').ok_or("离线序列号格式错误")?;
let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
let sig = Signature::from_slice(&engine.decode(sig_b64).map_err(|_| "签名无法解码".to_string())?)
.map_err(|_| "签名无效".to_string())?;
offline_verifying_key()?
.verify(payload_b64.as_bytes(), &sig)
.map_err(|_| "离线序列号签名校验失败".to_string())?;
let claims: OfflineClaims = serde_json::from_slice(
&engine.decode(payload_b64).map_err(|_| "载荷无法解码".to_string())?,
).map_err(|_| "载荷无法解析".to_string())?;
if claims.v != 0 && claims.v != 1 { return Err("不支持的离线证版本".into()); }
if claims.product_code != PRODUCT_CODE { return Err("离线证产品不匹配".into()); }
if claims.device_id != device_id { return Err("离线证与当前设备不匹配".into()); }
let plan = claims.plan_type.as_str();
if plan != "trial" && plan != "yearly" { return Err("离线证计划无效(仅体验/包年)".into()); }
let now = now_secs() as i64;
if claims.exp <= now { return Err("离线许可证已过期".to_string()); }
if claims.exp - claims.iat > 365 * 86400 + 3600 { return Err("离线许可证有效期非法".to_string()); }
Ok(claims)
}
fn api_bases() -> Vec<String> {
match std::env::var("OPSX_LICENSE_API_BASE") {
Ok(raw) if !raw.trim().is_empty() => {
let mut b = raw.trim().trim_end_matches('/').to_string();
if !b.ends_with("/api") { b = format!("{b}/api"); }
vec![b]
}
_ => DEFAULT_API_BASES.iter().map(|s| s.to_string()).collect(),
}
}
fn http_client() -> Result<reqwest::blocking::Client, String> {
reqwest::blocking::Client::builder()
.timeout(HTTP_TIMEOUT)
.user_agent("OpsxAPI/1")
.build()
.map_err(|e| format!("创建 HTTP 客户端失败: {e}"))
}
fn try_post_json(url: &str, body: &Value) -> Result<Value, String> {
let resp = http_client()?.post(url).json(body).send().map_err(|_| "许可证服务请求失败".to_string())?;
let status = resp.status();
let text = resp.text().map_err(|_| "读取响应失败".to_string())?;
if text.trim().is_empty() { return Err(format!("许可证服务返回空响应 ({status})")); }
serde_json::from_str(&text).map_err(|_| format!("许可证服务响应无法解析 ({status})"))
}
fn post_json(path: &str, body: Value) -> Result<Value, String> {
let mut last = String::from("无可用授权节点");
for base in api_bases() {
match try_post_json(&format!("{base}{path}"), &body) {
Ok(v) => return Ok(v),
Err(e) => last = e,
}
}
Err(last)
}
fn env_code(v: &Value) -> Option<i64> { v.get("code").and_then(|x| x.as_i64()) }
fn env_msg(v: &Value) -> Option<String> {
v.get("message").and_then(|x| x.as_str()).map(|s| s.to_string()).filter(|s| !s.is_empty())
}
fn env_data<'a>(v: &'a Value) -> &'a Value { v.get("data").unwrap_or(v) }
fn j_str(v: &Value, k: &str) -> Option<String> {
v.get(k).and_then(|x| x.as_str()).map(|s| s.to_string()).filter(|s| !s.is_empty())
}
fn j_i64(v: &Value, k: &str) -> Option<i64> {
v.get(k).and_then(|x| x.as_i64().or_else(|| x.as_f64().map(|f| f as i64)))
}
/// 在线响应签名校验(与 opsx-licence guard.go 同源签名串;旧版无 sig 字段时放行)。
fn verify_response_sig(payload: &Value) -> Result<(), String> {
let sig_hex = payload.get("sig").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
if sig_hex.is_empty() { return Ok(()); }
let sig = Signature::from_slice(&hex::decode(&sig_hex).map_err(|_| "响应签名无法解码".to_string())?)
.map_err(|_| "响应签名无效".to_string())?;
let msg = format!(
"v1|{}|{}|{}|{}|{}|{}|{}|{}",
j_str(payload, "license_key").unwrap_or_default(),
payload.get("valid").and_then(|v| v.as_bool()).unwrap_or(false),
j_str(payload, "status").unwrap_or_default(),
j_i64(payload, "expires_at_unix").unwrap_or(0),
j_i64(payload, "server_time").unwrap_or(0),
payload.get("in_grace").and_then(|v| v.as_bool()).unwrap_or(false),
j_i64(payload, "max_devices").unwrap_or(0),
j_str(payload, "plan_type").unwrap_or_default(),
);
offline_verifying_key()?.verify(msg.as_bytes(), &sig).map_err(|_| "响应签名校验失败".to_string())
}
struct RemoteInfo {
expires_at: Option<u64>,
plan_type: Option<String>,
max_devices: Option<u32>,
server_time: u64,
activation_token: Option<String>,
}
fn parse_remote(data: &Value) -> RemoteInfo {
let server_time = j_i64(data, "server_time").filter(|t| *t > 0).unwrap_or_else(|| now_secs() as i64) as u64;
let expires_at = j_i64(data, "expires_at_unix").filter(|u| *u > 0).map(|u| u as u64)
.or_else(|| j_i64(data, "days_left").filter(|d| *d >= 0).map(|d| now_secs() + d as u64 * 86_400));
RemoteInfo {
expires_at,
plan_type: j_str(data, "plan_type"),
max_devices: j_i64(data, "max_devices").map(|n| n.max(0) as u32),
server_time,
activation_token: j_str(data, "activation_token"),
}
}
fn build_status(app: &AppHandle, stored: Option<&StoredLicense>) -> Result<LicenseStatus, String> {
let device_id = get_or_create_device_id(app)?;
let trial_end = trial_start(app)? + TRIAL_SECS;
let now = now_secs();
if let Some(s) = stored {
if s.activated && !s.license_key.trim().is_empty() {
let expired = s.expires_at.map(|e| e > 0 && e < now).unwrap_or(false);
let offline_overdue = !is_offline_license_key(&s.license_key)
&& s.last_verified_at > 0
&& now > s.last_verified_at + OFFLINE_TOLERANCE_SECS;
if !expired && !offline_overdue {
return Ok(LicenseStatus {
phase: "active".into(),
days_left: s.expires_at.map(|e| ((e as i64 - now as i64) / 86_400).max(0)).unwrap_or(i64::MAX / 4),
device_id, activated: true,
expires_at: s.expires_at,
email: s.email.clone(),
plan_type: s.plan_type.clone(),
max_devices: s.max_devices,
trial_end,
});
}
}
}
// 未激活:试用状态(借鉴 Yaak——到期后个人非商业可继续,仅提示商用需授权)
let trialing = now < trial_end;
Ok(LicenseStatus {
phase: if trialing { "trialing".into() } else { "trial_ended".into() },
days_left: ((trial_end as i64 - now as i64) / 86_400).max(0),
device_id, activated: false,
expires_at: None, email: None, plan_type: None, max_devices: None,
trial_end,
})
}
pub fn read_status(app: &AppHandle) -> Result<LicenseStatus, String> {
let stored = read_stored(&license_path(app)?)?;
build_status(app, stored.as_ref())
}
pub fn activate(app: &AppHandle, license_key: &str, email: Option<&str>) -> Result<LicenseStatus, String> {
reject_legacy_key(license_key)?;
let device_id = get_or_create_device_id(app)?;
let key = license_key.trim().to_string();
let path = license_path(app)?;
// 时钟回拨检测
if let Some(stored) = read_stored(&path)? {
if now_secs() + CLOCK_SKEW_TOLERANCE_SECS < stored.last_server_time {
return Err("本机时钟异常(早于最近一次服务器时间),请校准系统时间后重试".into());
}
}
let stored = if is_offline_license_key(&key) {
// 离线激活:本地验签,完全无网络
let claims = verify_offline_serial(&key, &device_id)?;
StoredLicense {
license_key: key.clone(),
email: email.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).or(claims.email.clone()),
plan: default_plan(), activated: true,
expires_at: Some(claims.exp as u64),
plan_type: Some(claims.plan_type),
max_devices: Some(claims.max_devices.max(1) as u32),
last_verified_at: now_secs(),
activation_token: None,
last_server_time: 0,
}
} else {
// 在线授权:激活 + 立即验证一次(拿准 expires/features)
let body = serde_json::json!({
"license_key": key, "device_id": device_id, "device_name": "OpsxAPI",
});
let value = post_json("/v1/license/activate", body)?;
if let Some(code) = env_code(&value) {
if code != 0 { return Err(env_msg(&value).unwrap_or_else(|| "激活失败".into())); }
}
let data = env_data(&value).clone();
verify_response_sig(&data)?;
let info = parse_remote(&data);
StoredLicense {
license_key: key.clone(),
email: email.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()),
plan: default_plan(), activated: true,
expires_at: info.expires_at,
plan_type: info.plan_type,
max_devices: info.max_devices,
last_verified_at: now_secs(),
activation_token: info.activation_token,
last_server_time: info.server_time,
}
};
write_stored(&path, &stored)?;
read_status(app)
}
/// 周期性在线验证(前端每周调用一次);网络不可达时在容忍期内保留缓存。
pub fn verify(app: &AppHandle) -> Result<LicenseStatus, String> {
let path = license_path(app)?;
let stored = match read_stored(&path)? { Some(s) => s, None => return read_status(app) };
if !stored.activated || is_offline_license_key(&stored.license_key) { return read_status(app); }
let device_id = get_or_create_device_id(app)?;
let body = serde_json::json!({ "license_key": stored.license_key, "device_id": device_id });
match post_json("/v1/license/verify", body) {
Ok(value) => {
let payload = if value.get("valid").is_some() { value.clone() }
else if env_code(&value) == Some(0) { env_data(&value).clone() }
else { return read_status(app); };
let valid = payload.get("valid").and_then(|v| v.as_bool()).unwrap_or(true);
if !valid {
let _ = fs::remove_file(&path); // 服务器明确拒绝:吊销/无效 → 回到未激活
return read_status(app);
}
verify_response_sig(&payload)?;
let info = parse_remote(&payload);
let mut next = stored.clone();
if info.expires_at.is_some() { next.expires_at = info.expires_at; }
if info.plan_type.is_some() { next.plan_type = info.plan_type; }
if info.max_devices.is_some() { next.max_devices = info.max_devices; }
next.last_verified_at = now_secs();
next.last_server_time = info.server_time;
write_stored(&path, &next)?;
}
Err(_) => { /* 网络不可达:容忍期内保留 */ }
}
read_status(app)
}
pub fn deactivate(app: &AppHandle) -> Result<LicenseStatus, String> {
let path = license_path(app)?;
if let Some(stored) = read_stored(&path)? {
if stored.activated && !is_offline_license_key(&stored.license_key) {
let device_id = get_or_create_device_id(app)?;
let mut body = serde_json::json!({
"license_key": stored.license_key, "device_id": device_id,
});
if let Some(t) = stored.activation_token.as_ref().filter(|t| !t.trim().is_empty()) {
body["activation_token"] = Value::String(t.trim().to_string());
}
let _ = post_json("/v1/license/deactivate", body);
}
}
let _ = fs::remove_file(&path);
read_status(app)
}
+1
View File
@@ -1,4 +1,5 @@
pub mod http;
pub mod license;
pub mod proxy;
mod validation;
pub mod websocket;