exflower-license-server 1.0.0 → 1.0.2

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 CHANGED
@@ -1,15 +1,19 @@
1
1
  # ExFlower License Server
2
2
 
3
- ExFlower 在线授权验证服务。管理 License Key、企业授权、实例追踪。
3
+ ExFlower 在线授权验证服务。管理 License Key、企业授权、实例追踪、模板分发。
4
4
 
5
5
  ## 快速开始
6
6
 
7
- ### 方式一:npm 全局安装
7
+ ### 方式一:npm 全局安装(推荐)
8
8
 
9
9
  ```bash
10
10
  # 安装
11
11
  npm install -g exflower-license-server
12
12
 
13
+ # 查看版本
14
+ exflower-license-server --version
15
+ # 输出: 1.0.2
16
+
13
17
  # 运行
14
18
  exflower-license-server
15
19
 
@@ -38,6 +42,81 @@ npm install
38
42
  npm start
39
43
  ```
40
44
 
45
+ ## 管理员 Token 管理
46
+
47
+ ### 默认 Token
48
+
49
+ 首次启动时,默认管理员 Token 为 **`exflower-admin-2026`**。
50
+
51
+ **⚠️ 生产环境必须修改默认 Token。**
52
+
53
+ ### 临时修改(当前会话)
54
+
55
+ 启动命令前设置环境变量:
56
+
57
+ ```bash
58
+ # Linux / macOS
59
+ ADMIN_TOKEN=your-secure-token exflower-license-server
60
+
61
+ # Windows (PowerShell)
62
+ $env:ADMIN_TOKEN="your-secure-token"; exflower-license-server
63
+ ```
64
+
65
+ ### 永久修改(推荐)
66
+
67
+ 创建启动脚本 `start.sh`:
68
+
69
+ ```bash
70
+ #!/bin/bash
71
+ export ADMIN_TOKEN="your-secure-token"
72
+ export PORT=4001
73
+ exflower-license-server
74
+ ```
75
+
76
+ 或使用 systemd 服务(见下方"生产部署")。
77
+
78
+ ### 验证 Token 是否生效
79
+
80
+ 启动后查看控制台输出:
81
+
82
+ ```
83
+ ╔══════════════════════════════════════════════════╗
84
+ ║ ExFlower License Server ║
85
+ ║ Port: 4001 ║
86
+ ║ Admin Dashboard: http://localhost:4001/admin ║
87
+ ║ Admin Token: your-secure-token ║
88
+ ╚══════════════════════════════════════════════════╝
89
+ ```
90
+
91
+ Token 必须与管理后台登录时输入的一致。
92
+
93
+ ### 生产部署(systemd)
94
+
95
+ 创建 `/etc/systemd/system/exflower-license-server.service`:
96
+
97
+ ```ini
98
+ [Unit]
99
+ Description=ExFlower License Server
100
+ After=network.target
101
+
102
+ [Service]
103
+ Type=simple
104
+ Environment="ADMIN_TOKEN=your-secure-token"
105
+ Environment="PORT=4001"
106
+ ExecStart=/usr/bin/exflower-license-server
107
+ Restart=always
108
+
109
+ [Install]
110
+ WantedBy=multi-user.target
111
+ ```
112
+
113
+ 启用并启动:
114
+
115
+ ```bash
116
+ sudo systemctl daemon-reload
117
+ sudo systemctl enable --now exflower-license-server
118
+ ```
119
+
41
120
  ## 环境变量
42
121
 
43
122
  | 变量 | 默认值 | 说明 |
@@ -51,15 +130,44 @@ npm start
51
130
 
52
131
  启动后访问 `http://localhost:4001/admin`,使用 `ADMIN_TOKEN` 登录。
53
132
 
133
+ 管理后台功能:
134
+ - **企业管理**:创建/编辑/禁用/删除公司,设置授权配额
135
+ - **实例管理**:查看已激活的实例列表
136
+ - **模板管理**:上传/编辑/删除模板(agent/crew/excard/workflow),支持按公司分发,查看导入统计(次数/公司/实例)
137
+
54
138
  ## API 端点
55
139
 
140
+ ### 客户端接口
141
+
56
142
  | 端点 | 方法 | 说明 |
57
143
  |------|------|------|
58
144
  | `/health` | GET | 健康检查 |
59
145
  | `/api/license/verify` | POST | 验证 License Key |
60
146
  | `/api/license/heartbeat` | POST | 心跳上报 |
147
+ | `/api/templates` | GET | 获取模板列表(需 `x-license-key`) |
148
+ | `/api/templates/:id` | GET | 获取模板详情(需 `x-license-key`) |
149
+
150
+ ### 管理后台接口
151
+
152
+ | 端点 | 方法 | 说明 |
153
+ |------|------|------|
61
154
  | `/api/admin/companies` | GET/POST | 企业管理 |
155
+ | `/api/admin/companies/:id` | PATCH/DELETE | 更新/删除公司 |
62
156
  | `/api/admin/instances` | GET | 实例列表 |
157
+ | `/api/admin/instances/:id` | DELETE | 删除实例 |
158
+ | `/api/admin/templates` | GET/POST | 模板列表/创建 |
159
+ | `/api/admin/templates/:id` | PATCH/DELETE | 更新/删除模板 |
160
+ | `/api/admin/imports` | GET | 导入统计(支持 `?template_id=` / `?company_id=` 过滤) |
161
+
162
+ 所有管理接口需在 Header 中携带 `x-admin-token`。
163
+
164
+ ## 模板系统
165
+
166
+ 模板用于向客户端分发预设配置:
167
+
168
+ - **类型**:`agent` / `crew` / `excard` / `workflow`
169
+ - **范围**:`company_id = NULL` 为全局模板(所有公司可见);指定 `company_id` 则仅该公司可见
170
+ - **内容**:`content_json`(结构化数据)+ `content_md`(Markdown 说明)
63
171
 
64
172
  ## 构建 Docker 镜像
65
173
 
@@ -67,6 +175,3 @@ npm start
67
175
  docker build -t exflower/license-server:latest .
68
176
  ```
69
177
 
70
- ## License
71
-
72
- MIT
package/admin/index.html CHANGED
@@ -284,6 +284,7 @@
284
284
  <th>名称</th>
285
285
  <th>公司</th>
286
286
  <th>版本</th>
287
+ <th>导入次数</th>
287
288
  <th>创建时间</th>
288
289
  <th>操作</th>
289
290
  </tr>
@@ -293,9 +294,30 @@
293
294
  </div>
294
295
  </div>
295
296
 
297
+ <div class="modal-overlay" id="import-detail-modal">
298
+ <div class="modal">
299
+ <h3 id="import-detail-title">导入记录</h3>
300
+ <table>
301
+ <thead>
302
+ <tr>
303
+ <th>公司</th>
304
+ <th>实例ID</th>
305
+ <th>模板名称</th>
306
+ <th>导入时间</th>
307
+ </tr>
308
+ </thead>
309
+ <tbody id="import-detail-body"></tbody>
310
+ </table>
311
+ <div class="modal-actions">
312
+ <button class="btn" onclick="document.getElementById('import-detail-modal').classList.remove('show')">关闭</button>
313
+ </div>
314
+ </div>
315
+ </div>
316
+
296
317
  <div class="modal-overlay" id="template-modal">
297
318
  <div class="modal">
298
319
  <h3 id="modal-title">上传模板</h3>
320
+ <input type="hidden" id="tmpl-id">
299
321
  <div class="form-group">
300
322
  <label>类型</label>
301
323
  <select id="tmpl-type">
@@ -313,6 +335,10 @@
313
335
  <label>描述</label>
314
336
  <input type="text" id="tmpl-desc" placeholder="简短描述">
315
337
  </div>
338
+ <div class="form-group">
339
+ <label>版本</label>
340
+ <input type="text" id="tmpl-version" placeholder="1.0">
341
+ </div>
316
342
  <div class="form-group">
317
343
  <label>所属公司 (留空 = 全局模板)</label>
318
344
  <select id="tmpl-company">
@@ -460,21 +486,39 @@
460
486
  loadStats();
461
487
  }
462
488
 
489
+ let templateImportCounts = {};
490
+
463
491
  async function loadTemplates() {
464
- const res = await api('GET', '/api/admin/templates');
492
+ const [res, importsRes] = await Promise.all([
493
+ api('GET', '/api/admin/templates'),
494
+ api('GET', '/api/admin/imports')
495
+ ]);
465
496
  const tbody = document.getElementById('templates-body');
466
497
  tbody.innerHTML = '';
467
498
 
499
+ // Build import count map
500
+ templateImportCounts = {};
501
+ for (const imp of importsRes.imports || []) {
502
+ const tid = imp.template_id;
503
+ if (tid) {
504
+ templateImportCounts[tid] = (templateImportCounts[tid] || 0) + 1;
505
+ }
506
+ }
507
+
468
508
  for (const t of res.templates || []) {
469
509
  const tr = document.createElement('tr');
470
510
  const typeBadge = `badge template-type-${t.type}`;
511
+ const importCount = templateImportCounts[t.id] || 0;
471
512
  tr.innerHTML = `
472
513
  <td><span class="badge ${typeBadge}">${t.type}</span></td>
473
514
  <td><strong>${t.name}</strong><br><span style="color:#86868b;font-size:11px">${t.description || ''}</span></td>
474
515
  <td>${t.company_id ? t.company_id.slice(0, 8) + '...' : '全局'}</td>
475
516
  <td>${t.version || '1.0'}</td>
517
+ <td>${importCount > 0 ? `<strong style="color:#0071e3">${importCount}</strong>` : '0'}</td>
476
518
  <td>${t.created_at ? t.created_at.split('T')[0] : '-'}</td>
477
519
  <td>
520
+ <button class="btn btn-small btn-primary" onclick="editTemplate('${t.id}')">编辑</button>
521
+ <button class="btn btn-small" style="background:#34c759;color:#fff;" onclick="showImportDetails('${t.id}', '${t.name.replace(/'/g, "\\'")}')">查看导入</button>
478
522
  <button class="btn btn-small btn-danger" onclick="deleteTemplate('${t.id}')">删除</button>
479
523
  </td>
480
524
  `;
@@ -496,23 +540,56 @@
496
540
 
497
541
  function openTemplateModal() {
498
542
  document.getElementById('modal-title').textContent = '上传模板';
543
+ document.getElementById('tmpl-id').value = '';
499
544
  document.getElementById('tmpl-type').value = 'agent';
500
545
  document.getElementById('tmpl-name').value = '';
501
546
  document.getElementById('tmpl-desc').value = '';
547
+ document.getElementById('tmpl-version').value = '';
502
548
  document.getElementById('tmpl-json').value = '';
503
549
  document.getElementById('tmpl-md').value = '';
504
550
  populateCompanySelect();
505
551
  document.getElementById('template-modal').classList.add('show');
506
552
  }
507
553
 
554
+ async function editTemplate(id) {
555
+ const res = await api('GET', `/api/admin/templates?type=`);
556
+ const t = (res.templates || []).find(x => x.id === id);
557
+ if (!t) return toast('模板未找到');
558
+
559
+ document.getElementById('modal-title').textContent = '编辑模板';
560
+ document.getElementById('tmpl-id').value = t.id;
561
+ document.getElementById('tmpl-type').value = t.type;
562
+ document.getElementById('tmpl-name').value = t.name || '';
563
+ document.getElementById('tmpl-desc').value = t.description || '';
564
+ document.getElementById('tmpl-version').value = t.version || '';
565
+ await populateCompanySelect();
566
+ document.getElementById('tmpl-company').value = t.company_id || '';
567
+
568
+ let jsonStr = '';
569
+ if (t.content_json) {
570
+ try {
571
+ const parsed = typeof t.content_json === 'string' ? JSON.parse(t.content_json) : t.content_json;
572
+ jsonStr = JSON.stringify(parsed, null, 2);
573
+ } catch {
574
+ jsonStr = String(t.content_json);
575
+ }
576
+ }
577
+ document.getElementById('tmpl-json').value = jsonStr;
578
+ document.getElementById('tmpl-md').value = t.content_md || '';
579
+ document.getElementById('template-modal').classList.add('show');
580
+ }
581
+
508
582
  function closeTemplateModal() {
509
583
  document.getElementById('template-modal').classList.remove('show');
584
+ document.getElementById('tmpl-id').value = '';
510
585
  }
511
586
 
512
587
  async function saveTemplate() {
588
+ const id = document.getElementById('tmpl-id').value;
513
589
  const type = document.getElementById('tmpl-type').value;
514
590
  const name = document.getElementById('tmpl-name').value.trim();
515
591
  const desc = document.getElementById('tmpl-desc').value.trim();
592
+ const version = document.getElementById('tmpl-version').value.trim() || undefined;
516
593
  const companyId = document.getElementById('tmpl-company').value || undefined;
517
594
  const jsonRaw = document.getElementById('tmpl-json').value.trim();
518
595
  const md = document.getElementById('tmpl-md').value.trim() || undefined;
@@ -527,17 +604,31 @@
527
604
  return toast('JSON 格式错误: ' + e.message);
528
605
  }
529
606
 
530
- await api('POST', '/api/admin/templates', {
531
- company_id: companyId,
532
- type,
533
- name,
534
- description: desc,
535
- content_json: contentJson,
536
- content_md: md,
537
- });
538
-
539
- closeTemplateModal();
540
- toast('模板上传成功');
607
+ if (id) {
608
+ // Edit existing
609
+ const payload = {
610
+ type, name, description: desc, content_json: contentJson,
611
+ };
612
+ if (version) payload.version = version;
613
+ if (companyId !== undefined) payload.company_id = companyId;
614
+ if (md !== undefined) payload.content_md = md;
615
+ await api('PATCH', `/api/admin/templates/${id}`, payload);
616
+ closeTemplateModal();
617
+ toast('模板更新成功');
618
+ } else {
619
+ // Create new
620
+ await api('POST', '/api/admin/templates', {
621
+ company_id: companyId,
622
+ type,
623
+ name,
624
+ description: desc,
625
+ version,
626
+ content_json: contentJson,
627
+ content_md: md,
628
+ });
629
+ closeTemplateModal();
630
+ toast('模板上传成功');
631
+ }
541
632
  loadTemplates();
542
633
  }
543
634
 
@@ -548,6 +639,33 @@
548
639
  loadTemplates();
549
640
  }
550
641
 
642
+ async function showImportDetails(templateId, templateName) {
643
+ const res = await api('GET', `/api/admin/imports?template_id=${encodeURIComponent(templateId)}`);
644
+ const tbody = document.getElementById('import-detail-body');
645
+ tbody.innerHTML = '';
646
+
647
+ const imports = res.imports || [];
648
+ if (imports.length === 0) {
649
+ const tr = document.createElement('tr');
650
+ tr.innerHTML = `<td colspan="4" style="text-align:center;color:#86868b;padding:20px;">暂无导入记录</td>`;
651
+ tbody.appendChild(tr);
652
+ } else {
653
+ for (const imp of imports) {
654
+ const tr = document.createElement('tr');
655
+ tr.innerHTML = `
656
+ <td>${imp.company_name || '-'}</td>
657
+ <td><code>${imp.instance_id || '-'}</code></td>
658
+ <td>${imp.template_name || '-'}</td>
659
+ <td>${imp.imported_at ? imp.imported_at.split('T').join(' ') : '-'}</td>
660
+ `;
661
+ tbody.appendChild(tr);
662
+ }
663
+ }
664
+
665
+ document.getElementById('import-detail-title').textContent = `「${templateName}」导入记录 (${imports.length}次)`;
666
+ document.getElementById('import-detail-modal').classList.add('show');
667
+ }
668
+
551
669
  function showTab(name) {
552
670
  document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
553
671
  event.target.classList.add('active');
package/db.js CHANGED
@@ -80,6 +80,24 @@ db.exec(`
80
80
  CREATE INDEX IF NOT EXISTS idx_templates_type ON templates(type)
81
81
  `);
82
82
 
83
+ db.exec(`
84
+ CREATE TABLE IF NOT EXISTS template_imports (
85
+ id TEXT PRIMARY KEY,
86
+ template_id TEXT NOT NULL,
87
+ company_id TEXT NOT NULL,
88
+ instance_id TEXT,
89
+ template_name TEXT,
90
+ template_type TEXT,
91
+ imported_at DATETIME DEFAULT CURRENT_TIMESTAMP
92
+ )
93
+ `);
94
+ db.exec(`
95
+ CREATE INDEX IF NOT EXISTS idx_imports_template ON template_imports(template_id)
96
+ `);
97
+ db.exec(`
98
+ CREATE INDEX IF NOT EXISTS idx_imports_company ON template_imports(company_id)
99
+ `);
100
+
83
101
  // Seed demo data if empty
84
102
  const count = db.prepare('SELECT COUNT(*) as c FROM companies').get().c;
85
103
  if (count === 0) {
package/index.js CHANGED
@@ -14,8 +14,17 @@
14
14
  const express = require('express');
15
15
  const cors = require('cors');
16
16
  const path = require('path');
17
+ const fs = require('fs');
17
18
  const { setupRoutes } = require('./routes');
18
19
 
20
+ const args = process.argv.slice(2);
21
+ if (args.includes('--version') || args.includes('-v')) {
22
+ const pkgPath = path.join(__dirname, 'package.json');
23
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
24
+ console.log(pkg.version);
25
+ process.exit(0);
26
+ }
27
+
19
28
  const app = express();
20
29
  const PORT = process.env.PORT || 4001;
21
30
  const ADMIN_TOKEN = process.env.ADMIN_TOKEN || 'exflower-admin-2026';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "exflower-license-server",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "ExFlower License Server - 在线授权验证服务",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -33,6 +33,5 @@
33
33
  "exflower",
34
34
  "authorization"
35
35
  ],
36
- "author": "ExFlower",
37
- "license": "MIT"
36
+ "author": "ExFlower"
38
37
  }
package/routes.js CHANGED
@@ -254,6 +254,29 @@ function setupRoutes(app) {
254
254
  res.json({ success: true, templates });
255
255
  });
256
256
 
257
+ // Import summary (must come before /:id to avoid matching 'import-summary' as an id)
258
+ app.get('/api/templates/import-summary', (req, res) => {
259
+ const licenseKey = req.headers['x-license-key'];
260
+ if (!licenseKey) {
261
+ return res.status(401).json({ success: false, message: 'Missing x-license-key header' });
262
+ }
263
+ const company = queryGet('SELECT id FROM companies WHERE license_key = ?', [licenseKey]);
264
+ if (!company) {
265
+ return res.status(404).json({ success: false, message: 'Invalid license key' });
266
+ }
267
+
268
+ const rows = queryAll(
269
+ `SELECT template_id, COUNT(*) as count, MIN(imported_at) as first_import, MAX(imported_at) as last_import
270
+ FROM template_imports WHERE company_id = ? GROUP BY template_id`,
271
+ [company.id]
272
+ );
273
+ const map = {};
274
+ for (const r of rows) {
275
+ map[r.template_id] = { count: r.count, first_import: r.first_import, last_import: r.last_import };
276
+ }
277
+ res.json({ success: true, imports: map });
278
+ });
279
+
257
280
  // Get a single template (full content)
258
281
  app.get('/api/templates/:id', (req, res) => {
259
282
  const licenseKey = req.headers['x-license-key'];
@@ -363,7 +386,7 @@ function setupRoutes(app) {
363
386
  return res.status(400).json({ success: false, message: 'No fields to update' });
364
387
  }
365
388
 
366
- updates.push('updated_at = datetime("now")');
389
+ updates.push("updated_at = datetime('now')");
367
390
  queryRun(`UPDATE templates SET ${updates.join(', ')} WHERE id = ?`, [...params, req.params.id]);
368
391
 
369
392
  res.json({ success: true });
@@ -374,6 +397,69 @@ function setupRoutes(app) {
374
397
  res.json({ success: true });
375
398
  });
376
399
 
400
+ // ── Template Import Log (client-facing) ──
401
+ app.post('/api/templates/:id/import-log', (req, res) => {
402
+ const licenseKey = req.headers['x-license-key'];
403
+ if (!licenseKey) {
404
+ return res.status(401).json({ success: false, message: 'Missing x-license-key header' });
405
+ }
406
+ const company = queryGet('SELECT id FROM companies WHERE license_key = ?', [licenseKey]);
407
+ if (!company) {
408
+ return res.status(404).json({ success: false, message: 'Invalid license key' });
409
+ }
410
+
411
+ const { instance_id, template_name, template_type } = req.body;
412
+ const id = generateId();
413
+ queryRun(
414
+ `INSERT INTO template_imports (id, template_id, company_id, instance_id, template_name, template_type)
415
+ VALUES (?, ?, ?, ?, ?, ?)`,
416
+ [id, req.params.id, company.id, instance_id || null, template_name || '', template_type || '']
417
+ );
418
+ res.json({ success: true });
419
+ });
420
+
421
+ app.get('/api/templates/:id/import-status', (req, res) => {
422
+ const licenseKey = req.headers['x-license-key'];
423
+ if (!licenseKey) {
424
+ return res.status(401).json({ success: false, message: 'Missing x-license-key header' });
425
+ }
426
+ const company = queryGet('SELECT id FROM companies WHERE license_key = ?', [licenseKey]);
427
+ if (!company) {
428
+ return res.status(404).json({ success: false, message: 'Invalid license key' });
429
+ }
430
+
431
+ const stats = queryGet(
432
+ `SELECT COUNT(*) as count, MIN(imported_at) as first_import, MAX(imported_at) as last_import
433
+ FROM template_imports WHERE template_id = ? AND company_id = ?`,
434
+ [req.params.id, company.id]
435
+ );
436
+ res.json({ success: true, imported: stats.count > 0, count: stats.count, first_import: stats.first_import, last_import: stats.last_import });
437
+ });
438
+
439
+ // ── Admin: Import Stats ──
440
+ app.get('/api/admin/imports', requireAdmin, (req, res) => {
441
+ const { template_id, company_id } = req.query;
442
+ let sql = `
443
+ SELECT i.*, c.name as company_name, t.name as template_name
444
+ FROM template_imports i
445
+ JOIN companies c ON i.company_id = c.id
446
+ LEFT JOIN templates t ON i.template_id = t.id
447
+ WHERE 1=1
448
+ `;
449
+ const params = [];
450
+ if (template_id) { sql += ' AND i.template_id = ?'; params.push(template_id); }
451
+ if (company_id) { sql += ' AND i.company_id = ?'; params.push(company_id); }
452
+ sql += ' ORDER BY i.imported_at DESC LIMIT 500';
453
+
454
+ const imports = queryAll(sql, params);
455
+ // Aggregate stats
456
+ const stats = queryGet(`
457
+ SELECT COUNT(*) as total_imports, COUNT(DISTINCT template_id) as unique_templates, COUNT(DISTINCT company_id) as unique_companies
458
+ FROM template_imports
459
+ `);
460
+ res.json({ success: true, imports, stats });
461
+ });
462
+
377
463
  // ── Health ──
378
464
  app.get('/health', (req, res) => {
379
465
  const stats = queryGet(`