feat: 管理后台新增接入文档页——Rust/Vue/React/CLI/后端代理接入示例

This commit is contained in:
Logan.Li committed 2026-09-14 21:08:39 +08:00
1 parent 2f0cb71fc2
commit 059bbb833a
9 files changed
+1167 -8

No files matched your search

+11 -1
View File
@@ -9,10 +9,11 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"axios": "^1.6.5", "axios": "^1.7.9",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"echarts": "^5.4.3", "echarts": "^5.4.3",
"element-plus": "^2.5.0", "element-plus": "^2.5.0",
"highlight.js": "^11.12.0",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"vue": "^3.4.0", "vue": "^3.4.0",
"vue-echarts": "^6.6.8", "vue-echarts": "^6.6.8",
@@ -1786,6 +1787,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/highlight.js": {
"version": "11.12.0",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.12.0.tgz",
"integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/https-proxy-agent": { "node_modules/https-proxy-agent": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+8 -7
View File
@@ -9,19 +9,20 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.2.5",
"pinia": "^2.1.7",
"element-plus": "^2.5.0",
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.9", "axios": "^1.7.9",
"dayjs": "^1.11.10",
"echarts": "^5.4.3", "echarts": "^5.4.3",
"element-plus": "^2.5.0",
"highlight.js": "^11.12.0",
"pinia": "^2.1.7",
"vue": "^3.4.0",
"vue-echarts": "^6.6.8", "vue-echarts": "^6.6.8",
"dayjs": "^1.11.10" "vue-router": "^4.2.5"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.3", "@vitejs/plugin-vue": "^5.0.3",
"vite": "^5.0.11", "sass": "^1.69.7",
"sass": "^1.69.7" "vite": "^5.0.11"
} }
} }
+5
View File
@@ -68,6 +68,11 @@
<el-icon><Setting /></el-icon>系统设置 <el-icon><Setting /></el-icon>系统设置
</el-menu-item> </el-menu-item>
</el-sub-menu> </el-sub-menu>
<el-menu-item index="/integration">
<el-icon><Notebook /></el-icon>
<template #title>接入文档</template>
</el-menu-item>
</el-menu> </el-menu>
</el-aside> </el-aside>
+6
View File
@@ -71,6 +71,12 @@ const routes = [
component: () => import('@/views/Logs.vue'), component: () => import('@/views/Logs.vue'),
meta: { title: '操作日志', icon: 'Document' }, meta: { title: '操作日志', icon: 'Document' },
}, },
{
path: 'integration',
name: 'Integration',
component: () => import('@/views/Integration.vue'),
meta: { title: '接入文档', icon: 'Notebook' },
},
{ {
path: 'settings', path: 'settings',
name: 'Settings', name: 'Settings',
+223
View File
@@ -0,0 +1,223 @@
<!--
* @file Integration.vue
* @description 接入文档:桌面 / Web 前端 / CLI / 后端代理接入授权服务器的参考实现,
* 含 API 契约、验签规范、宽限期与容灾说明。参考 opsx-verify 的文档页模式。 // Logan.Li
-->
<template>
<div>
<el-card shadow="never">
<el-tabs v-model="tab" class="doc-tabs">
<!-- ============ 概览 ============ -->
<el-tab-pane label="概览 · API 契约" name="overview">
<el-alert type="success" :closable="false" show-icon class="mb"
title="接入三步:① 拿到产品序列号(后台或购买页) ② 客户端调 activate 激活并保存 activation_token ③ 每周调 verify 周检(验签 sig + 时钟校验)" />
<h3 class="h3">接入模型</h3>
<pre class="flow">{{ flowText }}</pre>
<h3 class="h3">客户端 API(无需认证,按 IP 限流)</h3>
<el-table :data="apis" stripe size="small">
<el-table-column prop="method" label="方法" width="70" />
<el-table-column prop="path" label="路径" min-width="230" />
<el-table-column prop="who" label="调用方" width="90" />
<el-table-column prop="desc" label="说明" min-width="300" />
</el-table>
<h3 class="h3">验证/激活响应关键字段</h3>
<el-table :data="fields" stripe size="small">
<el-table-column prop="name" label="字段" width="150" />
<el-table-column prop="desc" label="说明" min-width="420" />
</el-table>
<h3 class="h3">响应签名(防假服务器 / 防篡改)</h3>
<div class="code-wrap">
<el-button class="copy-btn" size="small" @click="copy(CANONICAL_SPEC, 'spec')">{{ copied === 'spec' ? '已复制' : '复制' }}</el-button>
<pre class="code">{{ CANONICAL_SPEC }}</pre>
</div>
<h3 class="h3">安全规则</h3>
<ol class="steps">
<li><b>宽限期</b>:商业过期 15 天内 <code>valid=true, in_grace=true</code> 照常放行并提示续费;<b>吊销/暂停无宽限</b>,立即失效。</li>
<li><b>时钟回拨</b>:本机时间不得早于最近一次 <code>server_time</code>(容忍 5 分钟),否则判定需要联网重验。</li>
<li><b>激活令牌</b>:<code>activation_token</code> 仅在激活时下发一次,必须持久保存;解绑(deactivate)必须携带,仅凭序列号无法解绑他人设备。</li>
<li><b>离线容忍</b>:连续 30 天联系不上全部节点时要求联网重验一次(防"屏蔽授权服务器=永久有效")。</li>
<li><b>多端点容灾</b>:域名列表按序尝试(国内 licence.rlwj.cn → 香港 l123.91ai.io),单节点超时 8 秒切换。</li>
<li><b>换座限制</b>:单序列号 24 小时内新设备激活有上限(默认 5),超出提示联系管理员。</li>
</ol>
<h3 class="h3">当前节点与公钥</h3>
<p class="tip" v-for="e in API_ENDPOINTS" :key="e">节点:<code>{{ e }}</code></p>
<p class="tip">验签/离线公钥(hex):<code>{{ publicKey || '加载中…' }}</code>
<el-button link type="primary" size="small" @click="loadPublicKey">刷新</el-button></p>
</el-tab-pane>
<!-- ============ Rust Tauri ============ -->
<el-tab-pane label="桌面 · Rust (Tauri)" name="rust">
<p class="tip">桌面工具推荐形态:Ed25519 验签 + 多端点容灾 + 周检调度 + 激活令牌本地存储(钥匙串/文件)。</p>
<div class="code-wrap">
<el-button class="copy-btn" size="small" @click="copy(snippets.rustTauri, 'rust')">{{ copied === 'rust' ? '已复制' : '复制' }}</el-button>
<pre class="code"><code class="hljs" v-html="hl(snippets.rustTauri, 'rust')"></code></pre>
</div>
</el-tab-pane>
<!-- ============ Vue 3 ============ -->
<el-tab-pane label="前端 · Vue 3" name="vue">
<p class="tip">Web 端激活页 + 周检组件;浏览器场景令牌存 localStorage,桌面 WebView 场景建议桥接本地文件。</p>
<div class="code-wrap">
<el-button class="copy-btn" size="small" @click="copy(snippets.vue3, 'vue')">{{ copied === 'vue' ? '已复制' : '复制' }}</el-button>
<pre class="code"><code class="hljs" v-html="hl(snippets.vue3, 'xml')"></code></pre>
</div>
</el-tab-pane>
<!-- ============ React ============ -->
<el-tab-pane label="前端 · React" name="react">
<p class="tip">useLicense Hook + 激活组件,与 Vue 版能力对等(验签 / 容灾 / 宽限期提示 / 令牌保存)。</p>
<div class="code-wrap">
<el-button class="copy-btn" size="small" @click="copy(snippets.react, 'react')">{{ copied === 'react' ? '已复制' : '复制' }}</el-button>
<pre class="code"><code class="hljs" v-html="hl(snippets.react, 'javascript')"></code></pre>
</div>
</el-tab-pane>
<!-- ============ CLI ============ -->
<el-tab-pane label="CLI 命令行工具" name="cli">
<el-radio-group v-model="cliKind" class="mb" size="small">
<el-radio-button value="go">Go(零依赖单文件)</el-radio-button>
<el-radio-button value="py">Python (click)</el-radio-button>
</el-radio-group>
<p class="tip">子命令:activate / verify / heartbeat / deactivate / devices;状态文件 ~/.opsx/license.json 保存令牌(0600 权限)。</p>
<div class="code-wrap">
<el-button class="copy-btn" size="small" @click="copy(cliCode, 'cli')">{{ copied === 'cli' ? '已复制' : '复制' }}</el-button>
<pre class="code"><code class="hljs" v-html="hl(cliCode, cliKind === 'go' ? 'go' : 'python')"></code></pre>
</div>
</el-tab-pane>
<!-- ============ 后端代理 ============ -->
<el-tab-pane label="Web 平台后端代理" name="backend">
<el-radio-group v-model="beKind" class="mb" size="small">
<el-radio-button value="go">Go (gin)</el-radio-button>
<el-radio-button value="py">Python (FastAPI)</el-radio-button>
<el-radio-button value="node">Node.js (Express)</el-radio-button>
<el-radio-button value="java">Java (Spring)</el-radio-button>
</el-radio-group>
<p class="tip">场景:自有 Web 平台的关键接口在服务端代理验证授权(序列号不暴露给浏览器,带 5 分钟缓存降低授权服务器压力)。宽限期内视同有效。</p>
<div class="code-wrap">
<el-button class="copy-btn" size="small" @click="copy(beCode, 'be')">{{ copied === 'be' ? '已复制' : '复制' }}</el-button>
<pre class="code"><code class="hljs" v-html="hl(beCode, beLang)"></code></pre>
</div>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</template>
<script setup>
// 接入文档页:片段来自 ./integration/snippets,高亮按需注册控制包体 // Logan.Li
import { computed, ref, onMounted } from 'vue'
import hljs from 'highlight.js/lib/core'
import hljsGo from 'highlight.js/lib/languages/go'
import hljsRust from 'highlight.js/lib/languages/rust'
import hljsJava from 'highlight.js/lib/languages/java'
import hljsPython from 'highlight.js/lib/languages/python'
import hljsXml from 'highlight.js/lib/languages/xml'
import hljsJavascript from 'highlight.js/lib/languages/javascript'
import 'highlight.js/styles/github-dark.css'
import { clientSnippets, cliSnippets, backendSnippets, API_ENDPOINTS, CANONICAL_SPEC } from './integration/snippets/index.js'
import request from '@/api/request'
hljs.registerLanguage('go', hljsGo)
hljs.registerLanguage('rust', hljsRust)
hljs.registerLanguage('java', hljsJava)
hljs.registerLanguage('python', hljsPython)
hljs.registerLanguage('xml', hljsXml)
hljs.registerLanguage('javascript', hljsJavascript)
const tab = ref('overview')
const cliKind = ref('go')
const beKind = ref('go')
const copied = ref('')
const publicKey = ref('')
const snippets = { ...clientSnippets }
const cliCode = computed(() => (cliKind.value === 'go' ? cliSnippets.cliGo : cliSnippets.cliPython))
const beCode = computed(() => ({
go: backendSnippets.backendGo,
py: backendSnippets.backendPython,
node: backendSnippets.backendNode,
java: backendSnippets.backendJava,
}[beKind.value]))
const beLang = computed(() => ({ go: 'go', py: 'python', node: 'javascript', java: 'java' }[beKind.value]))
// 语法高亮(源码为本仓库静态片段,v-html 无注入风险) // Logan.Li
const hl = (code, lang) => hljs.highlight(code, { language: lang }).value
// loadPublicKey 拉取当前部署的验签公钥,免去接入方翻配置 // Logan.Li
const loadPublicKey = async () => {
try {
const res = await request.get('/licenses/offline/public-key')
if (res.code === 0) publicKey.value = res.data.public_key_hex
} catch { /* 保持展示 .env 中记录的公钥 */ publicKey.value = '108375d485c58a1a738f93c2cbb838f76331de981ee5da13d90a1f280672131d' }
}
onMounted(loadPublicKey)
// copy 复制代码片段 // Logan.Li
const copy = async (text, key) => {
await navigator.clipboard.writeText(text)
copied.value = key
setTimeout(() => { copied.value = '' }, 1200)
}
const flowText = `┌──────────┐ ① activate(序列号, 设备ID) ┌──────────────────┐
│ 客户端 │ ────────────────────────────▶ │ 授权服务器(双节点) │
│ 桌面/CLI │ ◀──────────────────────────── │ 国内 + 香港容灾 │
│ Web前端 │ activation_token + sig └──────────────────┘
└────┬─────┘
│ ② 每周 verify / heartbeat(多端点按序尝试,超时8s切换)
│ 验签 sig → 通过则按 valid / in_grace 放行
│ ③ 本机解绑 deactivate(须携带 activation_token)
▼
离线证 LICOFF-:Ed25519 自包含验证,绑设备ID,最长365天,不依赖网络`
const apis = [
{ method: 'POST', path: '/api/v1/license/verify', who: '客户端', desc: '周检验证:返回 valid / in_grace / sig / server_time' },
{ method: 'POST', path: '/api/v1/license/activate', who: '客户端', desc: '激活占座:新设备下发一次性 activation_token' },
{ method: 'POST', path: '/api/v1/license/deactivate', who: '客户端', desc: '解绑本机:必须携带 activation_token' },
{ method: 'POST', path: '/api/v1/license/heartbeat', who: '客户端', desc: '活跃上报:刷新 last_seen(后台"在线"依据)' },
{ method: 'POST', path: '/api/v1/license/devices', who: '客户端', desc: '本序列号的已绑定设备列表' },
{ method: 'GET', path: '/api/v1/products/code/:code', who: '购买页', desc: '产品信息与定价(展示用)' },
{ method: 'POST', path: '/api/v1/orders · /checkout · /purchase/claim', who: '购买页', desc: '下单 / 支付 / 支付完成领取序列号' },
]
const fields = [
{ name: 'valid', desc: '是否有效。宽限期内为 true(配合 in_grace 判断提示续费)' },
{ name: 'status', desc: 'active / inactive / expired / revoked / suspended;吊销与暂停无宽限' },
{ name: 'expires_at / expires_at_unix', desc: '到期时间(字符串展示用;unix 为签名字段,直接回用免时区换算)' },
{ name: 'days_left / grace_days_left', desc: '剩余天数 / 宽限剩余天数' },
{ name: 'max_devices / plan_type', desc: '最大设备数 / 套餐类型(trial | monthly | yearly | perpetual)' },
{ name: 'in_grace', desc: '宽限期标记:商业过期 15 天内为 true,照常放行' },
{ name: 'server_time', desc: '服务端时间(unix 秒):客户端本地时钟不得早于历史最大值(防回拨)' },
{ name: 'sig', desc: 'Ed25519 签名(hex):验签失败按未授权处理(假服务器/篡改)' },
{ name: 'activation_token', desc: '仅 activate 响应:一次性下发的设备令牌,解绑必须携带,须持久保存' },
]
</script>
<style scoped>
.doc-tabs { background: var(--el-bg-color); border-radius: 4px; }
.mb { margin-bottom: 16px; }
.h3 { margin: 20px 0 8px; font-size: 15px; font-weight: 600; }
.steps { line-height: 1.9; padding-left: 20px; font-size: 13px; }
.steps code, .tip code { background: var(--el-fill-color); padding: 1px 6px; border-radius: 3px; }
.flow {
padding: 12px;
background: #2f3530;
color: #cfe0d4;
border-radius: 8px;
font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace;
white-space: pre;
overflow: auto;
}
.tip { color: var(--el-text-color-secondary); font-size: 13px; line-height: 1.7; }
.code-wrap { position: relative; margin: 12px 0 16px; }
.copy-btn { position: absolute; right: 8px; top: 8px; z-index: 1; }
.code { margin: 0; padding: 16px; background: #1e2227; border-radius: 8px; white-space: pre; overflow: auto; max-height: 620px; }
.code code.hljs { display: block; background: transparent; padding: 0; font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; }
</style>
@@ -0,0 +1,293 @@
/**
* @file backend.js
* @description 接入文档 · Web 平台后端代理示例(Go gin / Python FastAPI / Node Express / Java Spring)。
* 场景:自有 Web 平台在用户请求关键接口时代理验证授权(服务端到服务端,密钥不暴露给浏览器)。 // Logan.Li
*/
const backendGo = `// license_proxy.go —— Go gin 中间件:关键接口代理验证授权(含 5 分钟缓存)
package main
import (
"bytes"
"crypto/ed25519"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
const (
verifyURL = "https://licence.rlwj.cn/api/v1/license/verify" // 备用节点 l123.91ai.io
publicKeyHex = "108375d4...替换为实际公钥" // 验签公钥
cacheTTL = 5 * time.Minute
)
type cacheEntry struct {
valid bool
inGrace bool
expiresAt time.Time
}
var (
cache = sync.Map{} // license_key -> cacheEntry
netClient = &http.Client{Timeout: 8 * time.Second}
)
// verifySig Ed25519 验签(防劫持/篡改)
func verifySig(r map[string]interface{}) bool {
get := func(k string) interface{} { v, _ := r[k]; return v }
msg := fmt.Sprintf("v1|%v|%v|%v|%v|%v|%v|%v|%v",
get("license_key"), get("valid"), get("status"), get("expires_at_unix"),
get("server_time"), get("in_grace"), get("max_devices"), get("plan_type"))
pub, _ := hex.DecodeString(publicKeyHex)
sig, _ := hex.DecodeString(fmt.Sprintf("%v", get("sig")))
if len(sig) != ed25519.SignatureSize {
return false
}
return ed25519.Verify(ed25519.PublicKey(pub), []byte(msg), sig)
}
// LicenseGate 授权门禁中间间:请求头 X-License-Key 携带序列号
func LicenseGate() gin.HandlerFunc {
return func(c *gin.Context) {
key := c.GetHeader("X-License-Key")
if key == "" {
c.AbortWithStatusJSON(http.StatusPaymentRequired, gin.H{"message": "缺少授权序列号"})
return
}
// 1) 缓存命中(5 分钟内同一序列号不重复打授权服务器)
if v, ok := cache.Load(key); ok {
e := v.(cacheEntry)
if time.Since(e.expiresAt) < cacheTTL && (e.valid || e.inGrace) {
c.Next()
return
}
}
// 2) 代理验证(浏览器只跟自有后端通信,序列号不出域)
payload, _ := json.Marshal(map[string]interface{}{
"license_key": key, "device_id": "web-" + c.ClientIP(),
})
req, _ := http.NewRequest("POST", verifyURL, bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, err := netClient.Do(req)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadGateway, gin.H{"message": "授权服务不可达"})
return
}
defer resp.Body.Close()
var out map[string]interface{}
if json.NewDecoder(resp.Body).Decode(&out) != nil || !verifySig(out) {
c.AbortWithStatusJSON(http.StatusBadGateway, gin.H{"message": "授权响应验签失败"})
return
}
valid, _ := out["valid"].(bool)
grace, _ := out["in_grace"].(bool)
cache.Store(key, cacheEntry{valid: valid, inGrace: grace, expiresAt: time.Now()})
if !valid && !grace {
msg, _ := out["message"].(string)
c.AbortWithStatusJSON(http.StatusPaymentRequired, gin.H{"message": msg})
return
}
c.Next()
}
}
// 使用:r.POST("/api/export", LicenseGate(), handler)`
const backendPython = `# license_proxy.py —— FastAPI 依赖注入:关键接口代理验证(含缓存)
# 依赖: pip install fastapi httpx cryptography
import time
from typing import Optional
import httpx
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from fastapi import Depends, Header, HTTPException
VERIFY_URL = "https://licence.rlwj.cn/api/v1/license/verify" # 备用: l123.91ai.io
PUBLIC_KEY_HEX = "108375d4...替换为实际公钥"
CACHE_TTL = 300 # 秒
_cache: dict[str, tuple[bool, float]] = {} # key -> (ok, cached_at)
_pk = Ed25519PublicKey.from_public_bytes(bytes.fromhex(PUBLIC_KEY_HEX))
def verify_sig(resp: dict) -> bool:
msg = "v1|{}|{}|{}|{}|{}|{}|{}|{}".format(
resp.get("license_key"), str(resp.get("valid")).lower(), resp.get("status"),
resp.get("expires_at_unix"), resp.get("server_time"), str(resp.get("in_grace")).lower(),
resp.get("max_devices"), resp.get("plan_type"),
).encode()
try:
_pk.verify(bytes.fromhex(resp["sig"]), msg)
return True
except Exception:
return False
async def license_gate(x_license_key: Optional[str] = Header(default=None)):
"""FastAPI 依赖:请求头携带序列号,代理验证后放行(宽限期内视同有效)"""
if not x_license_key:
raise HTTPException(402, "缺少授权序列号")
hit = _cache.get(x_license_key)
if hit and time.time() - hit[1] < CACHE_TTL and hit[0]:
return
try:
async with httpx.AsyncClient(timeout=8) as client:
r = await client.post(VERIFY_URL, json={
"license_key": x_license_key, "device_id": "web-proxy",
})
out = r.json()
except httpx.HTTPError:
raise HTTPException(502, "授权服务不可达")
if out.get("sig") and not verify_sig(out):
raise HTTPException(502, "授权响应验签失败")
ok = bool(out.get("valid") or out.get("in_grace"))
_cache[x_license_key] = (ok, time.time())
if not ok:
raise HTTPException(402, out.get("message", "未授权"))
# 使用:
# @app.post("/api/export", dependencies=[Depends(license_gate)])
# async def export(...): ...`
const backendNode = `// license-proxy.js —— Express 中间件:关键接口代理验证(含缓存)
// 依赖: npm i express
const crypto = require('crypto')
const VERIFY_URL = 'https://licence.rlwj.cn/api/v1/license/verify' // 备用: l123.91ai.io
const PUBLIC_KEY_HEX = '108375d4...替换为实际公钥'
const CACHE_TTL = 300 * 1000
const cache = new Map() // license_key -> { ok, at }
// Ed25519 原始 32 字节公钥 → SPKI DER(Node 需要)
const spki = Buffer.concat([
Buffer.from('302a300506032b6570032100', 'hex'),
Buffer.from(PUBLIC_KEY_HEX, 'hex'),
])
const publicKey = crypto.createPublicKey({ key: spki, format: 'der', type: 'spki' })
function verifySig(r) {
const msg = 'v1|' + r.license_key + '|' + r.valid + '|' + r.status + '|' +
r.expires_at_unix + '|' + r.server_time + '|' + r.in_grace + '|' +
r.max_devices + '|' + r.plan_type
return crypto.verify(null, Buffer.from(msg), publicKey, Buffer.from(r.sig, 'hex'))
}
async function licenseGate(req, res, next) {
const key = req.get('X-License-Key')
if (!key) return res.status(402).json({ message: '缺少授权序列号' })
const hit = cache.get(key)
if (hit && Date.now() - hit.at < CACHE_TTL && hit.ok) return next()
let out
try {
const r = await fetch(VERIFY_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ license_key: key, device_id: 'web-proxy' }),
signal: AbortSignal.timeout(8000),
})
out = await r.json()
} catch {
return res.status(502).json({ message: '授权服务不可达' })
}
if (out.sig && !verifySig(out)) return res.status(502).json({ message: '授权响应验签失败' })
const ok = Boolean(out.valid || out.in_grace)
cache.set(key, { ok, at: Date.now() })
if (!ok) return res.status(402).json({ message: out.message || '未授权' })
next()
}
// 使用: app.post('/api/export', licenseGate, handler)`
const backendJava = `// LicenseGate.java —— Spring Boot 拦截器:关键接口代理验证(含 Caffeine 风格简易缓存)
// JDK 15+(内置 Ed25519);依赖: spring-boot-starter-web
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.*;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.servlet.HandlerInterceptor;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class LicenseGate implements HandlerInterceptor {
private static final String VERIFY_URL = "https://licence.rlwj.cn/api/v1/license/verify"; // 备用: l123.91ai.io
private static final String PUBLIC_KEY_HEX = "108375d4...替换为实际公钥";
private static final long CACHE_TTL_MS = 300_000;
private static final ObjectMapper JSON = new ObjectMapper();
private static final RestTemplate HTTP = new RestTemplate();
private static final Map<String, long[]> CACHE = new ConcurrentHashMap<>(); // key -> [ok, at]
/** 验签公钥(Ed25519 原始 32 字节 → SPKI DER 前缀) */
private static PublicKey publicKey() throws Exception {
byte[] raw = hex(PUBLIC_KEY_HEX);
ByteBuffer spki = ByteBuffer.allocate(12 + raw.length)
.put(hex("302a300506032b6570032100")).put(raw);
return KeyFactory.getInstance("Ed25519")
.generatePublic(new X509EncodedKeySpec(spki.array()));
}
private static byte[] hex(String s) {
byte[] out = new byte[s.length() / 2];
for (int i = 0; i < out.length; i++)
out[i] = (byte) Integer.parseInt(s.substring(i * 2, i * 2 + 2), 16);
return out;
}
@Override
public boolean preHandle(jakarta.servlet.http.HttpServletRequest req,
jakarta.servlet.http.HttpServletResponse resp, Object handler) throws Exception {
String key = req.getHeader("X-License-Key");
if (key == null || key.isBlank()) { resp.sendError(402, "缺少授权序列号"); return false; }
long[] hit = CACHE.get(key);
if (hit != null && hit[0] == 1 && System.currentTimeMillis() - hit[1] < CACHE_TTL_MS) return true;
HttpHeaders h = new HttpHeaders();
h.setContentType(MediaType.APPLICATION_JSON);
String body = "{\\"license_key\\":\\"" + key + "\\",\\"device_id\\":\\"web-proxy\\"}";
JsonNode out;
try {
ResponseEntity<String> r = HTTP.postForEntity(VERIFY_URL, new HttpEntity<>(body, h), String.class);
out = JSON.readTree(r.getBody());
} catch (Exception e) { resp.sendError(502, "授权服务不可达"); return false; }
// 验签:v1|key|valid|status|expires_unix|server_time|in_grace|max_devices|plan_type
if (out.hasNonNull("sig")) {
String msg = "v1|" + out.path("license_key").asText() + "|" + out.path("valid").asBoolean() + "|"
+ out.path("status").asText() + "|" + out.path("expires_at_unix").asLong() + "|"
+ out.path("server_time").asLong() + "|" + out.path("in_grace").asBoolean() + "|"
+ out.path("max_devices").asInt() + "|" + out.path("plan_type").asText();
Signature sig = Signature.getInstance("Ed25519");
sig.initVerify(publicKey());
sig.update(msg.getBytes(StandardCharsets.UTF_8));
if (!sig.verify(hex(out.path("sig").asText()))) {
resp.sendError(502, "授权响应验签失败"); return false;
}
}
boolean ok = out.path("valid").asBoolean() || out.path("in_grace").asBoolean();
CACHE.put(key, new long[]{ ok ? 1 : 0, System.currentTimeMillis() });
if (!ok) { resp.sendError(402, out.path("message").asText("未授权")); return false; }
return true;
}
}
// 注册: registry.addInterceptor(new LicenseGate()).addPathPatterns("/api/export/**");`
export const backendSnippets = { backendGo, backendPython, backendNode, backendJava }
@@ -0,0 +1,277 @@
/**
* @file cli.js
* @description 接入文档 · CLI 命令行工具示例(Go 标准库 / Python click)。
* 覆盖 activate / verify / heartbeat / deactivate 全流程,状态文件保存激活令牌。 // Logan.Li
*/
const cliGo = `// cli.go —— 单文件 CLI(Go 标准库实现,无第三方依赖)
// 用法: cli.go activate <序列号> | verify | heartbeat | deactivate | devices
package main
import (
"bytes"
"crypto/ed25519"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"time"
)
var endpoints = []string{
"https://licence.rlwj.cn",
"https://l123.91ai.io",
}
// 验签公钥(与离线证公钥相同,向管理员索取)
var publicKeyHex = "108375d4...替换为实际公钥"
type state struct {
LicenseKey string \`json:"license_key"\`
DeviceID string \`json:"device_id"\`
Token string \`json:"activation_token"\`
LastServerTime int64 \`json:"last_server_time"\`
LastOK int64 \`json:"last_ok"\` // 最近一次周检成功时间
}
func statePath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".opsx", "license.json")
}
func loadState() state {
var s state
if b, err := os.ReadFile(statePath()); err == nil {
json.Unmarshal(b, &s)
}
return s
}
func saveState(s state) {
os.MkdirAll(filepath.Dir(statePath()), 0700)
b, _ := json.MarshalIndent(s, "", " ")
os.WriteFile(statePath(), b, 0600) // 含激活令牌,权限收紧
}
// postJson 多端点容灾请求
func postJson(path string, body map[string]interface{}) map[string]interface{} {
b, _ := json.Marshal(body)
for _, base := range endpoints {
req, _ := http.NewRequest("POST", base+"/api/v1"+path, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
cl := &http.Client{Timeout: 8 * time.Second}
resp, err := cl.Do(req)
if err != nil {
continue
}
var out map[string]interface{}
if json.NewDecoder(resp.Body).Decode(&out) == nil {
resp.Body.Close()
return out
}
resp.Body.Close()
}
fmt.Println("[错误] 所有授权节点不可达")
os.Exit(2)
return nil
}
// verifySig Ed25519 验签(防假服务器)
func verifySig(r map[string]interface{}) bool {
get := func(k string) interface{} { v, _ := r[k]; return v }
msg := fmt.Sprintf("v1|%v|%v|%v|%v|%v|%v|%v|%v",
get("license_key"), get("valid"), get("status"), get("expires_at_unix"),
get("server_time"), get("in_grace"), get("max_devices"), get("plan_type"))
pub, _ := hex.DecodeString(publicKeyHex)
sig, _ := hex.DecodeString(fmt.Sprintf("%v", get("sig")))
if len(sig) != ed25519.SignatureSize {
return false
}
return ed25519.Verify(ed25519.PublicKey(pub), []byte(msg), sig)
}
func main() {
if len(os.Args) < 2 {
fmt.Println("用法: cli [activate <key>|verify|heartbeat|deactivate|devices]")
return
}
s := loadState()
switch os.Args[1] {
case "activate":
if len(os.Args) < 3 {
fmt.Println("用法: activate <序列号>")
return
}
hostname, _ := os.Hostname()
out := postJson("/license/activate", map[string]interface{}{
"license_key": os.Args[2], "device_id": s.DeviceID, "device_name": hostname,
})
data, _ := out["data"].(map[string]interface{})
if code, _ := out["code"].(float64); code != 0 {
fmt.Println("[激活失败]", out["message"])
return
}
s.LicenseKey = os.Args[2]
if t, ok := data["activation_token"].(string); ok {
s.Token = t // 令牌仅此一次下发,保存用于解绑
}
saveState(s)
fmt.Println("[激活成功] 到期:", data["expires_at"], "设备数:", data["max_devices"])
case "verify":
out := postJson("/license/verify", map[string]interface{}{
"license_key": s.LicenseKey, "device_id": s.DeviceID,
})
if out["sig"] != nil && !verifySig(out) {
fmt.Println("[验签失败] 响应不可信")
os.Exit(1)
}
if st, _ := out["server_time"].(float64); st > 0 {
s.LastServerTime = int64(st)
}
if valid, _ := out["valid"].(bool); valid {
s.LastOK = time.Now().Unix()
saveState(s)
if grace, _ := out["in_grace"].(bool); grace {
fmt.Printf("[宽限期] 剩余 %.0f 天,请续费\\n", out["grace_days_left"])
} else {
fmt.Println("[验证通过] 剩余", out["days_left"], "天")
}
} else {
fmt.Println("[未授权]", out["message"])
os.Exit(1)
}
case "heartbeat":
out := postJson("/license/heartbeat", map[string]interface{}{
"license_key": s.LicenseKey, "device_id": s.DeviceID,
})
if code, _ := out["code"].(float64); code == 0 {
fmt.Println("[心跳成功]")
}
case "deactivate":
out := postJson("/license/deactivate", map[string]interface{}{
"license_key": s.LicenseKey, "device_id": s.DeviceID, "activation_token": s.Token,
})
fmt.Println(out["message"])
case "devices":
out := postJson("/license/devices", map[string]interface{}{"license_key": s.LicenseKey})
b, _ := json.MarshalIndent(out["data"], "", " ")
fmt.Println(string(b))
}
}`
const cliPython = `# cli.py —— Python CLI(依赖: pip install click requests cryptography)
# 用法: python cli.py activate <序列号> | verify | heartbeat | deactivate
import json, os, sys, time
import click, requests
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
ENDPOINTS = ["https://licence.rlwj.cn", "https://l123.91ai.io"]
PUBLIC_KEY_HEX = "108375d4...替换为实际公钥"
STATE_PATH = os.path.expanduser("~/.opsx/license.json")
def load_state():
try:
with open(STATE_PATH) as f:
return json.load(f)
except FileNotFoundError:
return {"license_key": "", "device_id": os.uname().nodename, "activation_token": "", "last_server_time": 0}
def save_state(s):
os.makedirs(os.path.dirname(STATE_PATH), exist_ok=True)
with open(STATE_PATH, "w") as f:
json.dump(s, f, indent=2)
os.chmod(STATE_PATH, 0o600) # 含激活令牌,权限收紧
def post_json(path, body):
"""多端点容灾请求"""
for base in ENDPOINTS:
try:
r = requests.post(base + "/api/v1" + path, json=body, timeout=8)
return r.json()
except requests.RequestException:
continue
click.echo("[错误] 所有授权节点不可达"); sys.exit(2)
def verify_sig(resp):
"""Ed25519 验签(防假服务器 / 响应篡改)"""
msg = "v1|{}|{}|{}|{}|{}|{}|{}|{}".format(
resp.get("license_key"), str(resp.get("valid")).lower(), resp.get("status"),
resp.get("expires_at_unix"), resp.get("server_time"), str(resp.get("in_grace")).lower(),
resp.get("max_devices"), resp.get("plan_type"),
).encode()
pub = Ed25519PublicKey.from_public_bytes(bytes.fromhex(PUBLIC_KEY_HEX))
try:
pub.verify(bytes.fromhex(resp["sig"]), msg)
return True
except Exception:
return False
@click.group()
def cli():
pass
@cli.command()
@click.argument("key")
def activate(key):
"""激活序列号(首次占座,保存激活令牌)"""
s = load_state()
out = post_json("/license/activate", {"license_key": key, "device_id": s["device_id"]})
if out.get("code") != 0:
click.echo("[激活失败] " + str(out.get("message"))); return
s["license_key"] = key
s["activation_token"] = out["data"].get("activation_token", "") # 仅此一次下发
save_state(s)
click.echo("[激活成功] 到期 {} / 设备数 {}".format(out["data"]["expires_at"], out["data"]["max_devices"]))
@cli.command()
def verify():
"""周检验证(验签 + 时钟回拨检测)"""
s = load_state()
out = post_json("/license/verify", {"license_key": s["license_key"], "device_id": s["device_id"]})
if out.get("sig") and not verify_sig(out):
click.echo("[验签失败] 响应不可信"); sys.exit(1)
if out.get("server_time", 0) and time.time() + 300 < s.get("last_server_time", 0):
click.echo("[时钟异常] 本机时间早于上次服务端时间,请校准时钟"); sys.exit(1)
if out.get("server_time"):
s["last_server_time"] = out["server_time"]; save_state(s)
if out.get("valid"):
if out.get("in_grace"):
click.echo("[宽限期] 剩余 {} 天,请续费".format(out.get("grace_days_left")))
else:
click.echo("[验证通过] 剩余 {} 天".format(out.get("days_left")))
else:
click.echo("[未授权] " + str(out.get("message"))); sys.exit(1)
@cli.command()
def heartbeat():
"""活跃上报"""
s = load_state()
out = post_json("/license/heartbeat", {"license_key": s["license_key"], "device_id": s["device_id"]})
click.echo("[心跳成功]" if out.get("code") == 0 else str(out.get("message")))
@cli.command()
def deactivate():
"""解绑本机(必须携带激活令牌)"""
s = load_state()
out = post_json("/license/deactivate", {
"license_key": s["license_key"], "device_id": s["device_id"],
"activation_token": s["activation_token"],
})
click.echo(str(out.get("message")))
if __name__ == "__main__":
cli()`
export const cliSnippets = { cliGo, cliPython }
@@ -0,0 +1,335 @@
/**
* @file client.js
* @description 接入文档 · 客户端示例(Rust Tauri 桌面 / Vue3 / React)。
* 均为可整段复制的最小可用实现:多端点容灾 + Ed25519 验签 + 宽限期处理 + 激活令牌保存。 // Logan.Li
*/
// 两台授权节点按序尝试,任一可用即可(域名容灾) // Logan.Li
export const API_ENDPOINTS = ['https://licence.rlwj.cn', 'https://l123.91ai.io']
// 生产验签公钥(与离线证公钥相同;后台 系统设置→离线公钥 可核对) // Logan.Li
export const PUBLIC_KEY_HEX = '108375d485c58a1a738f93c2cbb838f76331de981ee5da13d90a1f280672131d'
// 验签原文说明(表格展示用) // Logan.Li
export const CANONICAL_SPEC = `sig 字段的验签原文(按顺序竖线拼接,UTF-8):
"v1|" + license_key + "|" + valid + "|" + status + "|"
+ expires_at_unix + "|" + server_time + "|"
+ in_grace + "|" + max_devices + "|" + plan_type
· bool 为小写 true / false
· expires_at_unix / server_time 直接取响应同名字段(无需时区换算)
· Ed25519 公钥 = 离线证公钥(后台可查),sig 为 hex 编码
· 验签失败 = 假服务器或响应被篡改,按"未授权"处理`
const rustTauri = `// ===== Cargo.toml =====
// [dependencies]
// reqwest = { version = "0.12", features = ["json"] }
// ed25519-dalek = "2"
// hex = "0.4"
// serde = { version = "1", features = ["derive"] }
// tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use std::time::{SystemTime, UNIX_EPOCH};
/// 授权节点(多端点容灾,按序尝试)
const ENDPOINTS: &[&str] = &[
"https://licence.rlwj.cn",
"https://l123.91ai.io",
];
/// 验签公钥(hex,与离线证公钥相同,向管理员索取)
const PUBLIC_KEY_HEX: &str = "108375d4...替换为实际公钥";
#[derive(serde::Deserialize, Debug, Clone, Default)]
#[serde(default)]
pub struct VerifyResp {
pub valid: bool,
pub license_key: String,
pub status: String,
pub expires_at_unix: i64,
pub max_devices: i32,
pub plan_type: String,
pub in_grace: bool,
pub grace_days_left: i32,
pub server_time: i64,
pub sig: String,
pub message: String,
}
/// 验签:签名覆盖 v1|key|valid|status|expires_unix|server_time|in_grace|max_devices|plan_type
pub fn verify_sig(r: &VerifyResp) -> bool {
let Ok(pk) = hex::decode(PUBLIC_KEY_HEX) else { return false };
let Ok(pk) = VerifyingKey::from_bytes(&pk.try_into().unwrap()) else { return false };
let Ok(sig) = hex::decode(&r.sig) else { return false };
let Ok(sig) = Signature::from_slice(&sig) else { return false };
let msg = format!(
"v1|{}|{}|{}|{}|{}|{}|{}|{}",
r.license_key, r.valid, r.status, r.expires_at_unix,
r.server_time, r.in_grace, r.max_devices, r.plan_type
);
pk.verify(msg.as_bytes(), &sig).is_ok()
}
/// 多端点容灾请求:任一节点成功即返回
pub async fn post_json(path: &str, body: &serde_json::Value) -> Option<serde_json::Value> {
for base in ENDPOINTS {
let url = format!("{}/api/v1{}", base, path);
if let Ok(resp) = reqwest::Client::new()
.post(&url)
.timeout(std::time::Duration::from_secs(8))
.json(body)
.send()
.await
{
if let Ok(v) = resp.json::<serde_json::Value>().await {
return Some(v);
}
}
}
None
}
/// 周检:验证 + 验签 + 时钟回拨检测。返回 true 才放行功能。
/// offline_tolerance_days: 连续联系不上服务器的容忍上限(建议 30 天)
pub async fn weekly_check(
license_key: &str,
device_id: &str,
last_server_time: &mut i64,
offline_tolerance_days: i64,
) -> bool {
let body = serde_json::json!({
"license_key": license_key,
"device_id": device_id,
});
let Some(v) = post_json("/license/verify", &body).await else {
// 全部节点不可达:离线容忍期内放行(缓存机制由调用方持久化 last_ok 时间)
return within_offline_tolerance(offline_tolerance_days);
};
let Ok(resp) = serde_json::from_value::<VerifyResp>(v) else { return false };
// 1) 假服务器 / 篡改检测
if !resp.sig.is_empty() && !verify_sig(&resp) { return false; }
// 2) 时钟回拨检测:本机时间不得早于上次见过的服务端时间(容忍 5 分钟)
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
if *last_server_time > 0 && now + 300 < *last_server_time { return false; }
*last_server_time = resp.server_time;
// 3) 吊销/暂停无宽限立即失效;商业过期有 15 天宽限(服务端已判定,in_grace=true 照常放行)
resp.valid
}
fn within_offline_tolerance(_days: i64) -> bool {
// 实现要点:持久化"最近一次周检成功时间",超过容忍天数则要求联网重验
// 示例从简,返回 false 提示联网;实际产品建议返回缓存判定结果
false
}
/// 激活:新设备首次占座,保存返回的 activation_token(后续解绑必须携带)
pub async fn activate(license_key: &str, device_id: &str) -> Option<String> {
let body = serde_json::json!({
"license_key": license_key,
"device_id": device_id,
"device_name": whoami::fallible() // 可选,便于后台识别设备
});
let v = post_json("/license/activate", &body).await?;
v.get("data")?.get("activation_token")?.as_str().map(String::from)
}
/// 解绑本机:必须带激活时下发的令牌(防偷座:仅有序列号解不了绑)
pub async fn deactivate(license_key: &str, device_id: &str, token: &str) -> bool {
let body = serde_json::json!({
"license_key": license_key,
"device_id": device_id,
"activation_token": token,
});
post_json("/license/deactivate", &body)
.await
.and_then(|v| v.get("code").and_then(|c| c.as_i64()))
.map(|c| c == 0)
.unwrap_or(false)
}`
const vue3 = `<!-- Vue 3 授权激活/周检组件(Web 端或 Tauri 页面通用) -->
<!-- 依赖: npm i @noble/curves -->
<script setup>
import { ref, onMounted } from 'vue'
import { ed25519 } from '@noble/curves/ed25519'
const ENDPOINTS = ['https://licence.rlwj.cn', 'https://l123.91ai.io']
const PUBLIC_KEY_HEX = '108375d4...替换为实际公钥'
const STORE_KEY = 'opsx.license'
const licenseKey = ref('')
const state = ref({ valid: false, in_grace: false, grace_days_left: 0, message: '' })
// 多端点容灾请求
async function postJson(path, body) {
for (const base of ENDPOINTS) {
try {
const r = await fetch(base + '/api/v1' + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(8000),
})
return await r.json()
} catch { /* 尝试下一节点 */ }
}
return null
}
function hexToBytes(h) {
const out = new Uint8Array(h.length / 2)
for (let i = 0; i < out.length; i++) out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16)
return out
}
// Ed25519 验签(防假服务器 / 响应篡改)
function verifySig(r) {
if (!r.sig) return false
const msg = new TextEncoder().encode(
'v1|' + r.license_key + '|' + r.valid + '|' + r.status + '|' +
r.expires_at_unix + '|' + r.server_time + '|' + r.in_grace + '|' +
r.max_devices + '|' + r.plan_type
)
return ed25519.verify(hexToBytes(r.sig), msg, hexToBytes(PUBLIC_KEY_HEX))
}
const deviceId = () => localStorage.getItem('opsx.device_id') || crypto.randomUUID()
async function activate() {
const v = await postJson('/license/activate', {
license_key: licenseKey.value.trim(),
device_id: deviceId(),
device_name: navigator.platform,
})
if (!v || v.code !== 0) { state.value.message = v?.message || '激活失败(所有节点不可达)'; return }
// 激活令牌仅下发一次,持久保存(浏览器场景存 localStorage,桌面场景存本地文件/钥匙串)
localStorage.setItem(STORE_KEY, JSON.stringify({
key: licenseKey.value.trim(),
token: v.data.activation_token || '',
last_server_time: v.data.server_time || 0,
}))
await verify()
}
async function verify() {
const saved = JSON.parse(localStorage.getItem(STORE_KEY) || 'null')
if (!saved) { state.value.message = '未激活'; return }
const v = await postJson('/license/verify', { license_key: saved.key, device_id: deviceId() })
if (!v) { state.value.message = '节点不可达,离线容忍期内可继续使用'; state.value.valid = true; return }
if (v.sig && !verifySig(v)) { state.value.valid = false; state.value.message = '验签失败'; return }
saved.last_server_time = v.server_time || saved.last_server_time
localStorage.setItem(STORE_KEY, JSON.stringify(saved))
state.value = {
valid: v.valid,
in_grace: v.in_grace || false,
grace_days_left: v.grace_days_left || 0,
message: v.message,
}
}
onMounted(verify)
</script>
<template>
<div class="lic">
<div v-if="!state.valid && !state.in_grace">
<input v-model="licenseKey" placeholder="请输入序列号 LIC-…" />
<button @click="activate">激活</button>
</div>
<p v-if="state.in_grace" class="warn">已过期,宽限期剩余 {{ state.grace_days_left }} 天,请续费</p>
<p>{{ state.message }}</p>
</div>
</template>`
const react = `// React 授权 hook + 激活组件(依赖: npm i @noble/curves)
import { useState, useEffect, useCallback } from 'react'
import { ed25519 } from '@noble/curves/ed25519'
const ENDPOINTS = ['https://licence.rlwj.cn', 'https://l123.91ai.io']
const PUBLIC_KEY_HEX = '108375d4...替换为实际公钥'
const STORE_KEY = 'opsx.license'
async function postJson(path, body) {
for (const base of ENDPOINTS) {
try {
const r = await fetch(base + '/api/v1' + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(8000),
})
return await r.json()
} catch { /* 尝试下一节点 */ }
}
return null
}
const hexToBytes = (h) => Uint8Array.from(h.match(/.{2}/g, (c) => parseInt(c, 16)))
const deviceId = () => localStorage.getItem('opsx.device_id') || crypto.randomUUID()
// 验签:签名原文 v1|key|valid|status|expires_unix|server_time|in_grace|max_devices|plan_type
function verifySig(r) {
if (!r.sig) return false
const msg = new TextEncoder().encode(
'v1|' + r.license_key + '|' + r.valid + '|' + r.status + '|' +
r.expires_at_unix + '|' + r.server_time + '|' + r.in_grace + '|' +
r.max_devices + '|' + r.plan_type
)
return ed25519.verify(hexToBytes(r.sig), msg, hexToBytes(PUBLIC_KEY_HEX))
}
export function useLicense() {
const [state, setState] = useState({ valid: false, in_grace: false, grace_days_left: 0, message: '检查中…' })
const activate = useCallback(async (key) => {
const v = await postJson('/license/activate', {
license_key: key.trim(), device_id: deviceId(), device_name: navigator.platform,
})
if (!v || v.code !== 0) { setState((s) => ({ ...s, message: v?.message || '激活失败' })); return }
// 激活令牌仅下发一次,必须持久保存(解绑时携带)
localStorage.setItem(STORE_KEY, JSON.stringify({
key: key.trim(), token: v.data.activation_token || '', last_server_time: v.data.server_time || 0,
}))
verify()
}, [])
const verify = useCallback(async () => {
const saved = JSON.parse(localStorage.getItem(STORE_KEY) || 'null')
if (!saved) { setState((s) => ({ ...s, message: '未激活' })); return }
const v = await postJson('/license/verify', { license_key: saved.key, device_id: deviceId() })
if (!v) { setState((s) => ({ ...s, valid: true, message: '节点不可达,离线容忍期内可用' })); return }
if (v.sig && !verifySig(v)) { setState({ valid: false, message: '验签失败' }); return }
localStorage.setItem(STORE_KEY, JSON.stringify({ ...saved, last_server_time: v.server_time || 0 }))
setState({
valid: v.valid, in_grace: v.in_grace || false,
grace_days_left: v.grace_days_left || 0, message: v.message,
})
}, [])
useEffect(() => { verify() }, [verify])
return { state, activate, verify }
}
// 使用:<LicenseGate>{(lic) => lic.state.valid ? <App/> : <ActivateForm/>}</LicenseGate>
export function ActivateForm() {
const { state, activate } = useLicense()
const [key, setKey] = useState('')
if (state.in_grace) return <p className="warn">已过期,宽限期剩 {state.grace_days_left} 天</p>
if (!state.valid) {
return (
<div>
<input value={key} onChange={(e) => setKey(e.target.value)} placeholder="序列号 LIC-…" />
<button onClick={() => activate(key)}>激活</button>
<p>{state.message}</p>
</div>
)
}
return null
}`
export const clientSnippets = { rustTauri, vue3, react }
@@ -0,0 +1,9 @@
/**
* @file index.js
* @description 接入文档片段聚合出口:客户端(桌面/前端)、CLI、后端代理三类。 // Logan.Li
*/
import { clientSnippets, API_ENDPOINTS, PUBLIC_KEY_HEX, CANONICAL_SPEC } from './client.js'
import { cliSnippets } from './cli.js'
import { backendSnippets } from './backend.js'
export { clientSnippets, cliSnippets, backendSnippets, API_ENDPOINTS, PUBLIC_KEY_HEX, CANONICAL_SPEC }