@nietzsci/clavis 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +127 -0
- package/dist/index.d.mts +291 -0
- package/dist/index.d.ts +291 -0
- package/dist/index.js +662 -0
- package/dist/index.mjs +617 -0
- package/examples/browser-example.html +87 -0
- package/examples/node-example.ts +67 -0
- package/package.json +28 -0
- package/src/client.ts +594 -0
- package/src/crypto.ts +84 -0
- package/src/errors.ts +46 -0
- package/src/fingerprint.ts +46 -0
- package/src/hmac.ts +67 -0
- package/src/index.ts +33 -0
- package/src/storage.ts +90 -0
- package/src/types.ts +114 -0
- package/tests/sdk.test.ts +548 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clavis SDK — Node.js 使用示例
|
|
3
|
+
*
|
|
4
|
+
* 运行:npx tsx examples/node-example.ts
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Clavis } from "../src/index";
|
|
8
|
+
|
|
9
|
+
async function main() {
|
|
10
|
+
// 1. 初始化 SDK
|
|
11
|
+
const clavis = new Clavis({
|
|
12
|
+
appId: "nc1a2b3c4d5e6f7g8h",
|
|
13
|
+
appSecret: "your-app-secret-from-dashboard",
|
|
14
|
+
publicKey: "your-ed25519-public-key-base64",
|
|
15
|
+
baseUrl: "http://100.70.90.125:3100",
|
|
16
|
+
autoHeartbeat: true,
|
|
17
|
+
heartbeatInterval: 3600,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// 2. 获取机器码
|
|
21
|
+
const machineCode = Clavis.getMachineCode();
|
|
22
|
+
console.log("机器码:", machineCode);
|
|
23
|
+
|
|
24
|
+
// 3. 获取设备信息
|
|
25
|
+
const deviceInfo = Clavis.getDeviceInfo();
|
|
26
|
+
console.log("设备信息:", deviceInfo);
|
|
27
|
+
|
|
28
|
+
// 4. 监听授权状态变化
|
|
29
|
+
const unsubscribe = clavis.onStatusChange((status) => {
|
|
30
|
+
console.log("授权状态变化:", status);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// 5. 到期前 7 天提醒
|
|
34
|
+
clavis.onExpiring(7, () => {
|
|
35
|
+
console.log("警告: 授权即将在 7 天内到期!");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
// 6. 激活授权码
|
|
40
|
+
const result = await clavis.activate(
|
|
41
|
+
"XXXXX-XXXXX-XXXXX-XXXXX-XXXXX",
|
|
42
|
+
machineCode
|
|
43
|
+
);
|
|
44
|
+
console.log("激活结果:", result);
|
|
45
|
+
|
|
46
|
+
// 7. 离线验证(不需要网络)
|
|
47
|
+
const status = clavis.verifyOffline(machineCode);
|
|
48
|
+
console.log("离线验证:", status);
|
|
49
|
+
|
|
50
|
+
// 8. 检查功能权限
|
|
51
|
+
if (clavis.hasFeature("dark_mode")) {
|
|
52
|
+
console.log("已授权: 深色模式");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 9. 混合验证(优先离线,失败则在线)
|
|
56
|
+
const checkResult = await clavis.checkLicense(machineCode);
|
|
57
|
+
console.log("授权状态:", checkResult);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
console.error("操作失败:", err);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 10. 清理
|
|
63
|
+
unsubscribe();
|
|
64
|
+
clavis.destroy();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
main().catch(console.error);
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nietzsci/clavis",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Clavis 软件授权 SDK — 离线验证 + HMAC 签名 + 设备指纹",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.mjs",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsup src/index.ts --format cjs,esm --dts",
|
|
17
|
+
"test": "vitest run"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"tweetnacl": "^1.0.3",
|
|
21
|
+
"tweetnacl-util": "^0.15.1"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"tsup": "^8.0.0",
|
|
25
|
+
"typescript": "^5.0.0",
|
|
26
|
+
"vitest": "^2.0.0"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Clavis 主类
|
|
3
|
+
// ============================================================
|
|
4
|
+
|
|
5
|
+
import crypto from "crypto";
|
|
6
|
+
import type {
|
|
7
|
+
ClavisConfig,
|
|
8
|
+
ClavisConfigResolved,
|
|
9
|
+
ActivateResult,
|
|
10
|
+
LicenseStatus,
|
|
11
|
+
HeartbeatResult,
|
|
12
|
+
PricingPlan,
|
|
13
|
+
TokenPayload,
|
|
14
|
+
ApiResponse,
|
|
15
|
+
CacheData,
|
|
16
|
+
} from "./types";
|
|
17
|
+
import { ClavisError, ErrorCodes } from "./errors";
|
|
18
|
+
import { verifyOfflineToken } from "./crypto";
|
|
19
|
+
import { signRequest, hashIdentity } from "./hmac";
|
|
20
|
+
import { getMachineCode, getDeviceInfo } from "./fingerprint";
|
|
21
|
+
import { FileStorage } from "./storage";
|
|
22
|
+
|
|
23
|
+
type StatusChangeCallback = (status: LicenseStatus) => void;
|
|
24
|
+
|
|
25
|
+
export class Clavis {
|
|
26
|
+
private config: ClavisConfigResolved;
|
|
27
|
+
private storage: FileStorage;
|
|
28
|
+
private heartbeatTimer?: ReturnType<typeof setInterval>;
|
|
29
|
+
private statusListeners: StatusChangeCallback[] = [];
|
|
30
|
+
private expiryListeners: { days: number; callback: () => void; fired: boolean }[] = [];
|
|
31
|
+
private lastStatus: LicenseStatus | null = null;
|
|
32
|
+
private currentLicenseKey: string | null = null;
|
|
33
|
+
private currentIdentityHash: string | null = null;
|
|
34
|
+
|
|
35
|
+
constructor(config: ClavisConfig) {
|
|
36
|
+
if (!config.appId || !config.appSecret || !config.publicKey) {
|
|
37
|
+
throw new ClavisError(
|
|
38
|
+
ErrorCodes.MISSING_CONFIG,
|
|
39
|
+
"appId, appSecret, publicKey 为必填项"
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
this.config = {
|
|
44
|
+
appId: config.appId,
|
|
45
|
+
appSecret: config.appSecret,
|
|
46
|
+
publicKey: config.publicKey,
|
|
47
|
+
baseUrl: (config.baseUrl || "https://c.nietzsci.com").replace(/\/+$/, ""),
|
|
48
|
+
storagePath: config.storagePath || process.cwd(),
|
|
49
|
+
offlineGraceDays: config.offlineGraceDays ?? 7,
|
|
50
|
+
heartbeatInterval: config.heartbeatInterval ?? 3600,
|
|
51
|
+
autoHeartbeat: config.autoHeartbeat ?? true,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
this.storage = new FileStorage(this.config.storagePath, this.config.appId);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ===== 静态工具方法 =====
|
|
58
|
+
|
|
59
|
+
/** 生成设备机器码 */
|
|
60
|
+
static getMachineCode(appSecret?: string): string {
|
|
61
|
+
return getMachineCode(appSecret);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 获取设备信息 */
|
|
65
|
+
static getDeviceInfo() {
|
|
66
|
+
return getDeviceInfo();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ===== 激活 =====
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 激活授权码(需要联网)
|
|
73
|
+
*
|
|
74
|
+
* @param licenseKey 授权码,格式 XXXXX-XXXXX-XXXXX-XXXXX-XXXXX
|
|
75
|
+
* @param identity 设备标识(机器码或自定义字符串),SDK 会自动 HMAC 哈希
|
|
76
|
+
*/
|
|
77
|
+
async activate(
|
|
78
|
+
licenseKey: string,
|
|
79
|
+
identity: string
|
|
80
|
+
): Promise<ActivateResult> {
|
|
81
|
+
// 1. 生成 identity hash
|
|
82
|
+
const identityHash = hashIdentity(identity, this.config.appSecret);
|
|
83
|
+
|
|
84
|
+
// 2. 构造请求体
|
|
85
|
+
const body = JSON.stringify({
|
|
86
|
+
license_key: licenseKey,
|
|
87
|
+
identity_hash: identityHash,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// 3. 发送签名请求
|
|
91
|
+
const data = await this.apiPost<{
|
|
92
|
+
offline_token: string;
|
|
93
|
+
tier: string;
|
|
94
|
+
features: string[];
|
|
95
|
+
expires_at: string | null;
|
|
96
|
+
}>("/api/v1/sdk/activate", body);
|
|
97
|
+
|
|
98
|
+
// 4. 存储 offline token 到本地
|
|
99
|
+
this.storage.save({
|
|
100
|
+
offlineToken: data.offline_token,
|
|
101
|
+
lastVerifiedAt: Math.floor(Date.now() / 1000),
|
|
102
|
+
tokenVersion: 0, // 激活时不知道具体版本,从 token 解析
|
|
103
|
+
licenseKey,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// 5. 记录当前 license 信息
|
|
107
|
+
this.currentLicenseKey = licenseKey;
|
|
108
|
+
this.currentIdentityHash = identityHash;
|
|
109
|
+
|
|
110
|
+
// 6. 启动自动心跳
|
|
111
|
+
if (this.config.autoHeartbeat) {
|
|
112
|
+
this.startHeartbeat(licenseKey, identity);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const result: ActivateResult = {
|
|
116
|
+
success: true,
|
|
117
|
+
tier: data.tier,
|
|
118
|
+
features: data.features,
|
|
119
|
+
expiresAt: data.expires_at,
|
|
120
|
+
offlineToken: data.offline_token,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// 7. 通知状态变化
|
|
124
|
+
this.notifyStatusChange(this.buildStatusFromActivate(result, false));
|
|
125
|
+
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ===== 验证 =====
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 检查授权状态(优先离线,失败则在线)
|
|
133
|
+
*/
|
|
134
|
+
async checkLicense(identity: string): Promise<LicenseStatus> {
|
|
135
|
+
// 1. 尝试离线验证
|
|
136
|
+
const offlineResult = this.verifyOffline(identity);
|
|
137
|
+
if (offlineResult.valid) return offlineResult;
|
|
138
|
+
|
|
139
|
+
// 2. 离线失败则尝试在线验证
|
|
140
|
+
const cached = this.storage.load();
|
|
141
|
+
if (!cached?.licenseKey) {
|
|
142
|
+
return offlineResult; // 没有缓存,返回离线失败结果
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return this.verifyOnline(cached.licenseKey, identity);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 纯离线验证(零网络请求)
|
|
150
|
+
*
|
|
151
|
+
* @param identity 可选的设备标识,用于 identity_hash 匹配校验
|
|
152
|
+
*/
|
|
153
|
+
verifyOffline(identity?: string): LicenseStatus {
|
|
154
|
+
const invalid: LicenseStatus = {
|
|
155
|
+
valid: false,
|
|
156
|
+
tier: "",
|
|
157
|
+
features: [],
|
|
158
|
+
expiresAt: null,
|
|
159
|
+
daysRemaining: null,
|
|
160
|
+
isOffline: true,
|
|
161
|
+
isTrial: false,
|
|
162
|
+
inGracePeriod: false,
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// 1. 读取本地缓存
|
|
166
|
+
const cached = this.storage.load();
|
|
167
|
+
if (!cached?.offlineToken) {
|
|
168
|
+
return { ...invalid, tier: "none" };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 2. Ed25519 公钥验签
|
|
172
|
+
const payload = verifyOfflineToken(
|
|
173
|
+
cached.offlineToken,
|
|
174
|
+
this.config.publicKey
|
|
175
|
+
);
|
|
176
|
+
if (!payload) {
|
|
177
|
+
return { ...invalid, tier: "invalid_signature" };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 3. 检查 app_id 匹配
|
|
181
|
+
if (payload.app_id !== this.config.appId) {
|
|
182
|
+
return { ...invalid, tier: "app_mismatch" };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 4. 检查 identity_hash 匹配
|
|
186
|
+
if (identity) {
|
|
187
|
+
const expectedHash = hashIdentity(identity, this.config.appSecret);
|
|
188
|
+
if (payload.identity_hash !== expectedHash) {
|
|
189
|
+
return { ...invalid, tier: "identity_mismatch" };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const now = Math.floor(Date.now() / 1000);
|
|
194
|
+
|
|
195
|
+
// 5. 时钟回拨检测
|
|
196
|
+
if (cached.lastVerifiedAt > now + 60) {
|
|
197
|
+
// 允许 60 秒误差
|
|
198
|
+
return { ...invalid, tier: "clock_drift" };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 6. 检查授权到期
|
|
202
|
+
const isExpired =
|
|
203
|
+
payload.expires_at !== null && payload.expires_at < now;
|
|
204
|
+
const inGracePeriod = isExpired && payload.grace_until > now;
|
|
205
|
+
const graceExpired = isExpired && payload.grace_until <= now;
|
|
206
|
+
|
|
207
|
+
if (graceExpired) {
|
|
208
|
+
return {
|
|
209
|
+
...invalid,
|
|
210
|
+
tier: payload.tier,
|
|
211
|
+
features: payload.features,
|
|
212
|
+
expiresAt: payload.expires_at
|
|
213
|
+
? new Date(payload.expires_at * 1000).toISOString()
|
|
214
|
+
: null,
|
|
215
|
+
daysRemaining: 0,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 7. 计算剩余天数
|
|
220
|
+
let daysRemaining: number | null = null;
|
|
221
|
+
if (payload.expires_at !== null) {
|
|
222
|
+
daysRemaining = Math.ceil((payload.expires_at - now) / 86400);
|
|
223
|
+
if (daysRemaining < 0) daysRemaining = 0;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 8. 更新 lastVerifiedAt
|
|
227
|
+
this.storage.save({
|
|
228
|
+
...cached,
|
|
229
|
+
lastVerifiedAt: now,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const status: LicenseStatus = {
|
|
233
|
+
valid: true,
|
|
234
|
+
tier: payload.tier,
|
|
235
|
+
features: payload.features,
|
|
236
|
+
expiresAt: payload.expires_at
|
|
237
|
+
? new Date(payload.expires_at * 1000).toISOString()
|
|
238
|
+
: null,
|
|
239
|
+
daysRemaining,
|
|
240
|
+
isOffline: true,
|
|
241
|
+
isTrial: payload.tier === "trial",
|
|
242
|
+
inGracePeriod,
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
// 通知状态变化
|
|
246
|
+
this.notifyStatusChange(status);
|
|
247
|
+
|
|
248
|
+
// 检查到期提醒
|
|
249
|
+
this.checkExpiryReminders(daysRemaining);
|
|
250
|
+
|
|
251
|
+
return status;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* 在线验证(备用)
|
|
256
|
+
*/
|
|
257
|
+
async verifyOnline(
|
|
258
|
+
licenseKey: string,
|
|
259
|
+
identity: string
|
|
260
|
+
): Promise<LicenseStatus> {
|
|
261
|
+
const identityHash = hashIdentity(identity, this.config.appSecret);
|
|
262
|
+
|
|
263
|
+
const body = JSON.stringify({
|
|
264
|
+
license_key: licenseKey,
|
|
265
|
+
identity_hash: identityHash,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
const data = await this.apiPost<{
|
|
270
|
+
valid: boolean;
|
|
271
|
+
status: string;
|
|
272
|
+
tier: string;
|
|
273
|
+
features: string[];
|
|
274
|
+
expires_at: string | null;
|
|
275
|
+
days_remaining: number | null;
|
|
276
|
+
offline_token?: string;
|
|
277
|
+
}>("/api/v1/sdk/verify", body);
|
|
278
|
+
|
|
279
|
+
// 如果服务端返回了新的 offline_token,更新缓存
|
|
280
|
+
if (data.offline_token) {
|
|
281
|
+
this.storage.save({
|
|
282
|
+
offlineToken: data.offline_token,
|
|
283
|
+
lastVerifiedAt: Math.floor(Date.now() / 1000),
|
|
284
|
+
tokenVersion: 0,
|
|
285
|
+
licenseKey,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const status: LicenseStatus = {
|
|
290
|
+
valid: data.valid,
|
|
291
|
+
tier: data.tier,
|
|
292
|
+
features: data.features,
|
|
293
|
+
expiresAt: data.expires_at,
|
|
294
|
+
daysRemaining: data.days_remaining,
|
|
295
|
+
isOffline: false,
|
|
296
|
+
isTrial: data.tier === "trial",
|
|
297
|
+
inGracePeriod: false,
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
this.notifyStatusChange(status);
|
|
301
|
+
return status;
|
|
302
|
+
} catch (err) {
|
|
303
|
+
// 在线验证也失败,返回离线验证的结果
|
|
304
|
+
return {
|
|
305
|
+
valid: false,
|
|
306
|
+
tier: "",
|
|
307
|
+
features: [],
|
|
308
|
+
expiresAt: null,
|
|
309
|
+
daysRemaining: null,
|
|
310
|
+
isOffline: true,
|
|
311
|
+
isTrial: false,
|
|
312
|
+
inGracePeriod: false,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* 检查是否有某个功能
|
|
319
|
+
*/
|
|
320
|
+
hasFeature(featureName: string): boolean {
|
|
321
|
+
const cached = this.storage.load();
|
|
322
|
+
if (!cached?.offlineToken) return false;
|
|
323
|
+
|
|
324
|
+
const payload = verifyOfflineToken(
|
|
325
|
+
cached.offlineToken,
|
|
326
|
+
this.config.publicKey
|
|
327
|
+
);
|
|
328
|
+
if (!payload) return false;
|
|
329
|
+
|
|
330
|
+
return payload.features.includes(featureName);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ===== 支付 =====
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* 获取定价方案列表
|
|
337
|
+
*/
|
|
338
|
+
async getPricingPlans(): Promise<PricingPlan[]> {
|
|
339
|
+
const url = `${this.config.baseUrl}/api/v1/public/app-info?app_id=${encodeURIComponent(this.config.appId)}`;
|
|
340
|
+
|
|
341
|
+
const response = await fetch(url);
|
|
342
|
+
if (!response.ok) {
|
|
343
|
+
throw new ClavisError(
|
|
344
|
+
ErrorCodes.API_ERROR,
|
|
345
|
+
`获取定价方案失败: ${response.status}`
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const json = (await response.json()) as ApiResponse<{
|
|
350
|
+
pricing: PricingPlan[];
|
|
351
|
+
}>;
|
|
352
|
+
|
|
353
|
+
if (!json.success || !json.data) {
|
|
354
|
+
throw new ClavisError(
|
|
355
|
+
ErrorCodes.API_ERROR,
|
|
356
|
+
json.error?.message || "获取定价方案失败"
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return json.data.pricing || [];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* 获取支付页面 URL
|
|
365
|
+
*/
|
|
366
|
+
getPaymentUrl(planId: string, identity?: string): string {
|
|
367
|
+
let url = `${this.config.baseUrl}/activate/${this.config.appId}?plan=${encodeURIComponent(planId)}`;
|
|
368
|
+
if (identity) {
|
|
369
|
+
url += `&identity=${encodeURIComponent(identity)}`;
|
|
370
|
+
}
|
|
371
|
+
return url;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ===== 生命周期 =====
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* 解绑当前设备(需要联网)
|
|
378
|
+
*/
|
|
379
|
+
async deactivate(licenseKey: string, identity: string): Promise<void> {
|
|
380
|
+
const identityHash = hashIdentity(identity, this.config.appSecret);
|
|
381
|
+
|
|
382
|
+
const body = JSON.stringify({
|
|
383
|
+
license_key: licenseKey,
|
|
384
|
+
identity_hash: identityHash,
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
await this.apiPost("/api/v1/sdk/deactivate", body);
|
|
388
|
+
|
|
389
|
+
// 清除本地缓存
|
|
390
|
+
this.storage.clear();
|
|
391
|
+
this.stopHeartbeat();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* 心跳(通常自动调用)
|
|
396
|
+
*/
|
|
397
|
+
async heartbeat(
|
|
398
|
+
licenseKey: string,
|
|
399
|
+
identity: string
|
|
400
|
+
): Promise<HeartbeatResult> {
|
|
401
|
+
const identityHash = hashIdentity(identity, this.config.appSecret);
|
|
402
|
+
|
|
403
|
+
const body = JSON.stringify({
|
|
404
|
+
license_key: licenseKey,
|
|
405
|
+
identity_hash: identityHash,
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
const data = await this.apiPost<{
|
|
409
|
+
server_timestamp: number;
|
|
410
|
+
status: string;
|
|
411
|
+
offline_token?: string;
|
|
412
|
+
}>("/api/v1/sdk/heartbeat", body);
|
|
413
|
+
|
|
414
|
+
// 如果服务端返回了新的 offline_token,更新缓存
|
|
415
|
+
if (data.offline_token) {
|
|
416
|
+
this.storage.save({
|
|
417
|
+
offlineToken: data.offline_token,
|
|
418
|
+
lastVerifiedAt: Math.floor(Date.now() / 1000),
|
|
419
|
+
tokenVersion: 0,
|
|
420
|
+
licenseKey,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return {
|
|
425
|
+
serverTimestamp: data.server_timestamp,
|
|
426
|
+
status: data.status,
|
|
427
|
+
offlineToken: data.offline_token,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* 监听授权状态变化
|
|
433
|
+
* @returns 取消监听函数
|
|
434
|
+
*/
|
|
435
|
+
onStatusChange(callback: StatusChangeCallback): () => void {
|
|
436
|
+
this.statusListeners.push(callback);
|
|
437
|
+
return () => {
|
|
438
|
+
this.statusListeners = this.statusListeners.filter(
|
|
439
|
+
(cb) => cb !== callback
|
|
440
|
+
);
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* 到期前提醒
|
|
446
|
+
* @param daysBeforeExpiry 到期前多少天触发
|
|
447
|
+
* @returns 取消监听函数
|
|
448
|
+
*/
|
|
449
|
+
onExpiring(daysBeforeExpiry: number, callback: () => void): () => void {
|
|
450
|
+
const listener = { days: daysBeforeExpiry, callback, fired: false };
|
|
451
|
+
this.expiryListeners.push(listener);
|
|
452
|
+
return () => {
|
|
453
|
+
this.expiryListeners = this.expiryListeners.filter(
|
|
454
|
+
(l) => l !== listener
|
|
455
|
+
);
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* 销毁(清理心跳定时器和监听器)
|
|
461
|
+
*/
|
|
462
|
+
destroy(): void {
|
|
463
|
+
this.stopHeartbeat();
|
|
464
|
+
this.statusListeners = [];
|
|
465
|
+
this.expiryListeners = [];
|
|
466
|
+
this.lastStatus = null;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ===== 内部方法 =====
|
|
470
|
+
|
|
471
|
+
/** 发送带 HMAC 签名的 POST 请求 */
|
|
472
|
+
private async apiPost<T>(path: string, body: string): Promise<T> {
|
|
473
|
+
const headers = signRequest({
|
|
474
|
+
method: "POST",
|
|
475
|
+
path,
|
|
476
|
+
body,
|
|
477
|
+
appId: this.config.appId,
|
|
478
|
+
appSecret: this.config.appSecret,
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
const url = `${this.config.baseUrl}${path}`;
|
|
482
|
+
|
|
483
|
+
let response: Response;
|
|
484
|
+
try {
|
|
485
|
+
response = await fetch(url, {
|
|
486
|
+
method: "POST",
|
|
487
|
+
headers: {
|
|
488
|
+
...headers,
|
|
489
|
+
"Content-Type": "application/json",
|
|
490
|
+
},
|
|
491
|
+
body,
|
|
492
|
+
});
|
|
493
|
+
} catch (err) {
|
|
494
|
+
throw new ClavisError(
|
|
495
|
+
ErrorCodes.NETWORK_ERROR,
|
|
496
|
+
`网络请求失败: ${err instanceof Error ? err.message : String(err)}`
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const json = (await response.json()) as ApiResponse<T>;
|
|
501
|
+
|
|
502
|
+
if (!response.ok || !json.success) {
|
|
503
|
+
const code = json.error?.code || ErrorCodes.API_ERROR;
|
|
504
|
+
const message = json.error?.message || `API 请求失败: ${response.status}`;
|
|
505
|
+
throw new ClavisError(code, message);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return json.data as T;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** 启动自动心跳 */
|
|
512
|
+
private startHeartbeat(licenseKey: string, identity: string): void {
|
|
513
|
+
this.stopHeartbeat();
|
|
514
|
+
this.heartbeatTimer = setInterval(async () => {
|
|
515
|
+
try {
|
|
516
|
+
await this.heartbeat(licenseKey, identity);
|
|
517
|
+
} catch {
|
|
518
|
+
// 心跳失败静默处理,不影响主流程
|
|
519
|
+
}
|
|
520
|
+
}, this.config.heartbeatInterval * 1000);
|
|
521
|
+
|
|
522
|
+
// 允许 Node.js 进程正常退出
|
|
523
|
+
if (this.heartbeatTimer && typeof this.heartbeatTimer === "object" && "unref" in this.heartbeatTimer) {
|
|
524
|
+
(this.heartbeatTimer as NodeJS.Timeout).unref();
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** 停止心跳 */
|
|
529
|
+
private stopHeartbeat(): void {
|
|
530
|
+
if (this.heartbeatTimer) {
|
|
531
|
+
clearInterval(this.heartbeatTimer);
|
|
532
|
+
this.heartbeatTimer = undefined;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** 通知状态变化 */
|
|
537
|
+
private notifyStatusChange(status: LicenseStatus): void {
|
|
538
|
+
// 只在状态变化时通知
|
|
539
|
+
if (
|
|
540
|
+
this.lastStatus === null ||
|
|
541
|
+
this.lastStatus.valid !== status.valid ||
|
|
542
|
+
this.lastStatus.tier !== status.tier
|
|
543
|
+
) {
|
|
544
|
+
this.lastStatus = status;
|
|
545
|
+
for (const cb of this.statusListeners) {
|
|
546
|
+
try {
|
|
547
|
+
cb(status);
|
|
548
|
+
} catch {
|
|
549
|
+
// 回调异常不影响 SDK
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** 检查到期提醒 */
|
|
556
|
+
private checkExpiryReminders(daysRemaining: number | null): void {
|
|
557
|
+
if (daysRemaining === null) return;
|
|
558
|
+
|
|
559
|
+
for (const listener of this.expiryListeners) {
|
|
560
|
+
if (!listener.fired && daysRemaining <= listener.days) {
|
|
561
|
+
listener.fired = true;
|
|
562
|
+
try {
|
|
563
|
+
listener.callback();
|
|
564
|
+
} catch {
|
|
565
|
+
// 回调异常不影响 SDK
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** 从激活结果构造 LicenseStatus */
|
|
572
|
+
private buildStatusFromActivate(
|
|
573
|
+
result: ActivateResult,
|
|
574
|
+
isOffline: boolean
|
|
575
|
+
): LicenseStatus {
|
|
576
|
+
let daysRemaining: number | null = null;
|
|
577
|
+
if (result.expiresAt) {
|
|
578
|
+
const expiresMs = new Date(result.expiresAt).getTime();
|
|
579
|
+
daysRemaining = Math.ceil((expiresMs - Date.now()) / (86400 * 1000));
|
|
580
|
+
if (daysRemaining < 0) daysRemaining = 0;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
return {
|
|
584
|
+
valid: result.success,
|
|
585
|
+
tier: result.tier,
|
|
586
|
+
features: result.features,
|
|
587
|
+
expiresAt: result.expiresAt,
|
|
588
|
+
daysRemaining,
|
|
589
|
+
isOffline,
|
|
590
|
+
isTrial: result.tier === "trial",
|
|
591
|
+
inGracePeriod: false,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
}
|