exflower-license-server 1.3.1 → 1.3.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/Dockerfile CHANGED
@@ -13,7 +13,10 @@
13
13
  FROM node:18-alpine
14
14
 
15
15
  # better-sqlite3 原生模块编译工具(alpine/musl 无预编译二进制)
16
- RUN apk add --no-cache python3 make g++
16
+ # apk 源切腾讯云镜像(默认 dl-cdn 国内不可达);海外机构建可 --build-arg APK_MIRROR=dl-cdn.alpinelinux.org
17
+ ARG APK_MIRROR=mirrors.cloud.tencent.com
18
+ RUN sed -i "s|dl-cdn.alpinelinux.org|${APK_MIRROR}|g" /etc/apk/repositories \
19
+ && apk add --no-cache python3 make g++
17
20
 
18
21
  # 从 npm registry 全局安装已发布版本(国内机构建机可换 npmmirror)
19
22
  ARG VERSION=latest
package/README.md CHANGED
@@ -12,7 +12,7 @@ npm install -g exflower-license-server
12
12
 
13
13
  # 查看版本
14
14
  exflower-license-server --version
15
- # 输出: 1.0.7
15
+ # 输出: 1.3.1
16
16
 
17
17
  # 运行
18
18
  exflower-license-server
@@ -23,7 +23,19 @@ ADMIN_TOKEN=your-secret-token PORT=4001 exflower-license-server
23
23
 
24
24
  ### 方式二:Docker
25
25
 
26
+ 镜像构建直接从 npm registry 拉取已发布版本(构建机无需源码):
27
+
26
28
  ```bash
29
+ # 构建(默认 latest;指定版本用 --build-arg VERSION=x.y.z)
30
+ docker build -t exflower/license-server:latest .
31
+
32
+ # 国内构建机走镜像源(npm registry 与 alpine apk 源都可换)
33
+ docker build \
34
+ --build-arg VERSION=1.3.1 \
35
+ --build-arg NPM_REGISTRY=https://registry.npmmirror.com \
36
+ --build-arg APK_MIRROR=mirrors.cloud.tencent.com \
37
+ -t exflower/license-server:1.3.1 .
38
+
27
39
  # 使用 docker-compose(推荐)
28
40
  docker-compose up -d
29
41
 
@@ -174,7 +186,7 @@ sudo systemctl enable --now exflower-license-server
174
186
  - **License 管理**:创建 License 时填写备注(如"市场部")并选择有效期(1/3/6 个月、1 年、2 年、永久);列表显示剩余天数(<30 天标黄、过期标红);支持编辑(改有效期/配额)、暂停/恢复、删除、**重置 Key**
175
187
  - **品牌(白标)**:全局工作台名称设置(见下方"白标品牌名")
176
188
  - **实例监控**:查看已激活的实例列表
177
- - **模板管理**:按类型选择上传方式(见下方"模板系统"),支持按 License 分发,查看导入统计
189
+ - **模板管理**:按类型页签组织(agent/excard/doc/资料图谱),卡片式列表,支持分类管理与分类过滤、按 License 定向分发(多选),查看导入统计
178
190
  - **轻应用分发**:上传 `.exfapp` 包并按 License 隔离分发;客户端「轻应用」页可浏览/下载/导入(上限 5GB,含图谱的包注意部署机磁盘)
179
191
  - **客户端更新**:上传各平台更新包(mac 传 **zip**、win 传 NSIS **exe**),客户端启动自动检查 + 帮助菜单手动检查,electron-updater 静默下载安装(每个包 ~700MB+,注意部署机磁盘余量)
180
192
 
@@ -206,6 +218,7 @@ sudo systemctl enable --now exflower-license-server
206
218
  | `/api/license/heartbeat` | POST | 心跳上报(响应含 `brand_name`) |
207
219
  | `/api/templates` | GET | 获取模板列表(需 `x-license-key`) |
208
220
  | `/api/templates/:id` | GET | 获取模板详情(需 `x-license-key`) |
221
+ | `/api/templates/:id/download` | GET | 下载资料图谱模板 `.exflib`(需 `x-license-key`) |
209
222
  | `/api/packages` | GET | 轻应用包列表,按公司隔离(需 `x-license-key`) |
210
223
  | `/api/packages/:id/download` | GET | 下载轻应用包(需 `x-license-key`) |
211
224
  | `/api/updates/latest.yml` | GET | Windows 更新清单(electron-updater,需 `x-license-key`) |
@@ -224,6 +237,9 @@ sudo systemctl enable --now exflower-license-server
224
237
  | `/api/admin/instances/:id` | DELETE | 删除实例 |
225
238
  | `/api/admin/templates` | GET/POST | 模板列表/创建 |
226
239
  | `/api/admin/templates/:id` | PATCH/DELETE | 更新/删除模板 |
240
+ | `/api/admin/templates/library` | POST | 资料图谱模板上传(multipart,字段 `file`,`.exflib`) |
241
+ | `/api/admin/template-categories` | GET/POST | 模板分类列表/创建(按类型分组) |
242
+ | `/api/admin/template-categories/:id` | PATCH/DELETE | 分类改名/删除 |
227
243
  | `/api/admin/imports` | GET | 导入统计(支持 `?template_id=` / `?company_id=` 过滤) |
228
244
  | `/api/admin/packages` | GET/POST | 轻应用包列表/上传(multipart,字段 `file`+`company_id`) |
229
245
  | `/api/admin/packages/:id` | DELETE | 删除轻应用包 |
@@ -234,16 +250,19 @@ sudo systemctl enable --now exflower-license-server
234
250
 
235
251
  ## 模板系统
236
252
 
237
- 模板用于向客户端分发预设配置。管理后台支持创建 `agent`、`excard`、`workflow`、`doc` 四种类型的模板。
253
+ 模板用于向客户端分发预设配置。管理后台支持 `agent`、`excard`、`doc`、`library`(资料图谱)四种类型的模板。
254
+
255
+ > **workflow 类型暂不开放**:上传/下载形态待重新设计,管理台与客户端均已隐藏入口;API 与存量数据保留,重新开放前不要清理 workflow 数据。
238
256
 
239
257
  ### 上传方式(v1.0.7 起)
240
258
 
241
259
  管理后台根据类型提供不同的上传方式:
242
260
 
243
261
  - **agent / excard / doc**:**Markdown 原生上传**。直接上传客户端导出的 `.md` 文件(或粘贴 Markdown 文本),后台自动解析 frontmatter 预填模板名称并生成 `content_json`,无需手写 JSON。
244
- - **workflow**:仍以 JSON 描述步骤结构(步骤间的 excardId 引用和分支跳转无法由 Markdown 表达),上传框预填骨架示例。
262
+ - **library**:上传客户端导出的 `.exflib` 包(multipart `POST /api/admin/templates/library`,字段 `file`),服务端解包校验 manifest(`format: exflower-library`)并记录文件大小与 sha256;客户端经 `GET /api/templates/:id/download` 下载导入。
263
+ - **workflow**(暂不开放):以 JSON 描述步骤结构,上传框预填骨架示例。
245
264
 
246
- 通过 API 创建模板时,`content_json` 与 `content_md` 至少提供一个(`workflow` 类型必须提供 `content_json`)。
265
+ 通过 API 创建模板时,`content_json` 与 `content_md` 至少提供一个(`workflow` 类型必须提供 `content_json`;`library` 类型走专用上传端点)。
247
266
 
248
267
  ### 通用字段
249
268
 
@@ -412,9 +431,16 @@ node scripts/seed-templates.js \
412
431
  - 导入的模板均为**全局模板**(所有 License 可见)。
413
432
  - 脚本遵守 admin 接口限速(60 次/分钟),遇到 429 自动退避重试。
414
433
 
415
- ## 构建 Docker 镜像
434
+ ## 发布新版本(厂商侧)
416
435
 
417
436
  ```bash
418
- docker build -t exflower/license-server:latest .
437
+ # 1. 提升 package.json 版本号
438
+ # 2. 确认 files 白名单覆盖全部运行时文件(activation.js / seed-templates.js /
439
+ # central-public.pem / seeds/ 缺一个都会发出启动即崩的包)
440
+ npm pack --dry-run # 检查 Tarball Contents
441
+ # 3. 发布(npm 已下线 Classic Token,用勾了 bypass 2FA 的 Granular Token)
442
+ npm publish
419
443
  ```
420
444
 
445
+ Docker 镜像构建参数见上方「方式二:Docker」。
446
+
package/admin/index.html CHANGED
@@ -415,6 +415,10 @@
415
415
  <label>Markdown 文件(直接上传客户端导出的 .md)</label>
416
416
  <input type="file" id="tmpl-md-file" accept=".md,.markdown,text/markdown,text/plain" onchange="onTemplateFileSelected(event)">
417
417
  </div>
418
+ <div id="tmpl-doc-zip-group" class="hidden">
419
+ <label>文档 zip 包(含 README.md + assets/;选择了则以包为准,下方 Markdown 忽略)</label>
420
+ <input type="file" id="tmpl-doc-zip" accept=".zip,application/zip">
421
+ </div>
418
422
  <div id="tmpl-json-group">
419
423
  <label>内容 (JSON) <span class="muted" id="tmpl-json-hint"></span></label>
420
424
  <textarea id="tmpl-json" rows="8"></textarea>
@@ -967,12 +971,15 @@
967
971
  tbody.innerHTML = '<tr><td colspan="3" class="muted" style="text-align:center;padding:16px">暂无分类</td></tr>';
968
972
  return;
969
973
  }
970
- for (const c of categoriesCache) {
974
+ for (let i = 0; i < categoriesCache.length; i++) {
975
+ const c = categoriesCache[i];
971
976
  const tr = document.createElement('tr');
972
977
  tr.innerHTML = `
973
978
  <td><strong>${c.name}</strong></td>
974
979
  <td>${c.template_count}</td>
975
980
  <td>
981
+ <button class="btn-sm btn-plain" ${i === 0 ? 'disabled' : ''} onclick="moveCategory('${c.id}', -1)" title="上移">↑</button>
982
+ <button class="btn-sm btn-plain" ${i === categoriesCache.length - 1 ? 'disabled' : ''} onclick="moveCategory('${c.id}', 1)" title="下移">↓</button>
976
983
  <button class="btn-sm btn-plain" onclick="renameCategory('${c.id}')">改名</button>
977
984
  <button class="btn-sm btn-danger" onclick="deleteCategory('${c.id}', '${c.name.replace(/'/g, "\\'")}')">删除</button>
978
985
  </td>
@@ -981,6 +988,18 @@
981
988
  }
982
989
  }
983
990
 
991
+ async function moveCategory(id, dir) {
992
+ const idx = categoriesCache.findIndex(c => c.id === id);
993
+ const swap = idx + dir;
994
+ if (idx < 0 || swap < 0 || swap >= categoriesCache.length) return;
995
+ const ids = categoriesCache.map(c => c.id);
996
+ [ids[idx], ids[swap]] = [ids[swap], ids[idx]];
997
+ const res = await api('PUT', '/api/admin/template-categories/order', { type: currentAssetType, ids });
998
+ if (!res.success) return toast(res.message || '排序失败');
999
+ await loadCategories();
1000
+ renderCategoryList();
1001
+ }
1002
+
984
1003
  async function renameCategory(id) {
985
1004
  const cat = categoriesCache.find(c => c.id === id);
986
1005
  if (!cat) return;
@@ -1144,6 +1163,9 @@
1144
1163
  const isMd = MD_TYPES.includes(type);
1145
1164
  document.getElementById('tmpl-json-group').classList.toggle('hidden', isMd);
1146
1165
  document.getElementById('tmpl-md-file-group').classList.toggle('hidden', !isMd);
1166
+ // doc zip 上传只在新建时提供(编辑模式只改元信息与 md)
1167
+ const isDocCreate = type === 'doc' && !document.getElementById('tmpl-id').value;
1168
+ document.getElementById('tmpl-doc-zip-group').classList.toggle('hidden', !isDocCreate);
1147
1169
  document.getElementById('tmpl-md-label').textContent = isMd
1148
1170
  ? 'Markdown 内容(上传文件后可编辑)'
1149
1171
  : 'Markdown 内容 (可选)';
@@ -1220,6 +1242,7 @@
1220
1242
  document.getElementById('tmpl-json').value = '';
1221
1243
  document.getElementById('tmpl-md').value = '';
1222
1244
  document.getElementById('tmpl-md-file').value = '';
1245
+ document.getElementById('tmpl-doc-zip').value = '';
1223
1246
  populateCompanySelect();
1224
1247
  onTemplateTypeChange();
1225
1248
  document.getElementById('template-modal').classList.add('show');
@@ -1267,6 +1290,7 @@
1267
1290
  document.getElementById('tmpl-json').value = jsonStr;
1268
1291
  document.getElementById('tmpl-md').value = t.content_md || '';
1269
1292
  document.getElementById('tmpl-md-file').value = '';
1293
+ document.getElementById('tmpl-doc-zip').value = '';
1270
1294
  onTemplateTypeChange();
1271
1295
  document.getElementById('template-modal').classList.add('show');
1272
1296
  }
@@ -1289,6 +1313,29 @@
1289
1313
 
1290
1314
  if (!name) return toast('请输入模板名称');
1291
1315
 
1316
+ // 知识库文档 + 选了 zip 包:走 zip 上传通道(携带 assets),以包内 README.md 为内容
1317
+ const docZip = type === 'doc' && !id ? document.getElementById('tmpl-doc-zip').files[0] : null;
1318
+ if (docZip) {
1319
+ const fd = new FormData();
1320
+ fd.append('file', docZip);
1321
+ fd.append('name', name);
1322
+ fd.append('description', desc);
1323
+ if (version) fd.append('version', version);
1324
+ if (category !== undefined) fd.append('category', category);
1325
+ fd.append('company_ids', JSON.stringify(companyIds));
1326
+ const res = await fetch('/api/admin/templates/doc-upload', {
1327
+ method: 'POST',
1328
+ headers: { 'x-admin-token': ADMIN_TOKEN },
1329
+ body: fd
1330
+ });
1331
+ const data = await res.json();
1332
+ if (!data.success) return toast(data.message || '上传失败');
1333
+ closeTemplateModal();
1334
+ toast('文档包上传成功');
1335
+ loadTemplates();
1336
+ return;
1337
+ }
1338
+
1292
1339
  let contentJson, contentMd;
1293
1340
  if (type === 'workflow') {
1294
1341
  if (!jsonRaw) return toast('请输入 JSON 内容');
package/db.js CHANGED
@@ -205,6 +205,9 @@ db.exec(`
205
205
  // 幂等列迁移:轻应用分类(受管分类 template_categories type='app')
206
206
  try { db.exec('ALTER TABLE app_packages ADD COLUMN category TEXT'); } catch { /* 已存在 */ };
207
207
 
208
+ // 幂等列迁移:分类手动排序(0 序并列时按 created_at,保持存量行为)
209
+ try { db.exec('ALTER TABLE template_categories ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0'); } catch { /* 已存在 */ };
210
+
208
211
  // ── 轻应用定向分享(多 License 可见;语义同 template_companies) ──
209
212
  db.exec(`
210
213
  CREATE TABLE IF NOT EXISTS package_companies (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "exflower-license-server",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
4
4
  "description": "ExFlower License Server - 在线授权验证服务",
5
5
  "main": "index.js",
6
6
  "bin": {
package/routes.js CHANGED
@@ -574,8 +574,12 @@ function setupRoutes(app) {
574
574
  sql += ' ORDER BY type, name';
575
575
 
576
576
  const templates = queryAll(sql, params);
577
+ // 分类手动排序下发:客户端按服务端顺序展示分类筛选(各类型全量,体积小)
578
+ const orderRows = queryAll('SELECT type, name FROM template_categories ORDER BY sort_order ASC, created_at ASC');
579
+ const categoryOrder = {};
580
+ for (const r of orderRows) (categoryOrder[r.type] = categoryOrder[r.type] || []).push(r.name);
577
581
  const hint = upgradeHint(req);
578
- res.json({ success: true, templates, ...(hint ? { upgrade_hint: hint } : {}) });
582
+ res.json({ success: true, templates, category_order: categoryOrder, ...(hint ? { upgrade_hint: hint } : {}) });
579
583
  });
580
584
 
581
585
  // Import summary (must come before /:id to avoid matching 'import-summary' as an id)
@@ -697,7 +701,7 @@ function setupRoutes(app) {
697
701
  `;
698
702
  const params = [];
699
703
  if (type) { sql += ' WHERE c.type = ?'; params.push(type); }
700
- sql += ' ORDER BY c.created_at ASC';
704
+ sql += ' ORDER BY c.sort_order ASC, c.created_at ASC';
701
705
  const cats = queryAll(sql, params);
702
706
  // 轻应用分类的计数走 app_packages(模板计数查 templates 永远是 0)
703
707
  if (type === 'app') {
@@ -718,10 +722,25 @@ function setupRoutes(app) {
718
722
  const dup = queryGet('SELECT id FROM template_categories WHERE type = ? AND name = ?', [type, name]);
719
723
  if (dup) return res.status(409).json({ success: false, message: '该类型下分类已存在' });
720
724
  const id = generateId();
721
- queryRun('INSERT INTO template_categories (id, type, name) VALUES (?, ?, ?)', [id, type, name]);
725
+ const maxOrder = queryGet('SELECT MAX(sort_order) AS m FROM template_categories WHERE type = ?', [type])?.m ?? -1;
726
+ queryRun('INSERT INTO template_categories (id, type, name, sort_order) VALUES (?, ?, ?, ?)', [id, type, name, maxOrder + 1]);
722
727
  res.json({ success: true, category: { id, type, name } });
723
728
  });
724
729
 
730
+ // 手动排序:按 ids 数组顺序重排该类型下分类(未列入的保持相对顺序排在末尾)
731
+ app.put('/api/admin/template-categories/order', requireAdmin, (req, res) => {
732
+ const { type, ids } = req.body || {};
733
+ if (!type || !Array.isArray(ids)) {
734
+ return res.status(400).json({ success: false, message: 'type 与 ids 必填' });
735
+ }
736
+ const rows = queryAll('SELECT id FROM template_categories WHERE type = ? ORDER BY sort_order ASC, created_at ASC', [type]);
737
+ const rest = rows.map(r => r.id).filter(id => !ids.includes(id));
738
+ [...ids, ...rest].forEach((id, i) => {
739
+ queryRun('UPDATE template_categories SET sort_order = ? WHERE id = ? AND type = ?', [i + 1, id, type]);
740
+ });
741
+ res.json({ success: true });
742
+ });
743
+
725
744
  app.patch('/api/admin/template-categories/:id', requireAdmin, (req, res) => {
726
745
  const cat = queryGet('SELECT * FROM template_categories WHERE id = ?', [req.params.id]);
727
746
  if (!cat) return res.status(404).json({ success: false, message: 'Category not found' });
@@ -984,6 +1003,85 @@ function setupRoutes(app) {
984
1003
  }
985
1004
  });
986
1005
 
1006
+ // ── Admin: 知识库文档 zip 上传(README.md + assets/,与客户端发布产物的存储契约一致) ──
1007
+ const docZipUpload = multer({
1008
+ storage: multer.memoryStorage(),
1009
+ limits: { fileSize: 200 * 1024 * 1024 }
1010
+ });
1011
+ const DOC_ASSET_MIMES = {
1012
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
1013
+ '.gif': 'image/gif', '.svg': 'image/svg+xml', '.webp': 'image/webp',
1014
+ '.pdf': 'application/pdf', '.md': 'text/markdown', '.txt': 'text/plain',
1015
+ };
1016
+ app.post('/api/admin/templates/doc-upload', requireAdmin, docZipUpload.single('file'), async (req, res) => {
1017
+ try {
1018
+ if (!req.file) return res.status(400).json({ success: false, message: 'file is required' });
1019
+
1020
+ let zip;
1021
+ try {
1022
+ zip = await unzipper.Open.buffer(req.file.buffer);
1023
+ } catch {
1024
+ return res.status(400).json({ success: false, message: '无效的 zip 包' });
1025
+ }
1026
+ const readme = zip.files.find(f => f.type === 'File' && f.path.toLowerCase() === 'readme.md');
1027
+ if (!readme) {
1028
+ return res.status(400).json({ success: false, message: '无效的文档包:根目录缺少 README.md' });
1029
+ }
1030
+ const contentMd = (await readme.buffer()).toString('utf8');
1031
+
1032
+ const assets = [];
1033
+ for (const f of zip.files) {
1034
+ if (f.type !== 'File' || !f.path.startsWith('assets/')) continue;
1035
+ const filename = path.basename(f.path);
1036
+ if (!filename) continue;
1037
+ const buf = await f.buffer();
1038
+ assets.push({
1039
+ filename,
1040
+ mimeType: DOC_ASSET_MIMES[path.extname(filename).toLowerCase()] || 'application/octet-stream',
1041
+ data: buf.toString('base64'),
1042
+ });
1043
+ }
1044
+
1045
+ const { company_id, category, version } = req.body || {};
1046
+ let shareIds = null;
1047
+ if (req.body?.company_ids) {
1048
+ try { shareIds = JSON.parse(req.body.company_ids).filter(Boolean); } catch { shareIds = null; }
1049
+ }
1050
+ if (company_id && !shareIds) {
1051
+ const company = queryGet('SELECT id FROM companies WHERE id = ?', [company_id]);
1052
+ if (!company) return res.status(404).json({ success: false, message: 'Company not found' });
1053
+ }
1054
+
1055
+ const h1 = contentMd.match(/^#\s+(.+)$/m)?.[1]?.trim();
1056
+ const name = (req.body?.name || '').trim() || h1 || '未命名文档';
1057
+ const id = generateId();
1058
+ queryRun(
1059
+ `INSERT INTO templates (id, company_id, type, name, description, content_json, content_md, tags, category, version, created_at, updated_at)
1060
+ VALUES (?, ?, 'doc', ?, ?, ?, ?, '[]', ?, ?, datetime('now'), datetime('now'))`,
1061
+ [
1062
+ id,
1063
+ shareIds ? null : (company_id || null),
1064
+ name,
1065
+ (req.body?.description || '').trim(),
1066
+ JSON.stringify({ assets }),
1067
+ contentMd,
1068
+ category || null,
1069
+ (version || '').trim() || '1.0',
1070
+ ]
1071
+ );
1072
+ if (shareIds && shareIds.length) {
1073
+ const err = setTemplateCompanies(id, shareIds);
1074
+ if (err) {
1075
+ queryRun('DELETE FROM templates WHERE id = ?', [id]);
1076
+ return res.status(404).json({ success: false, message: err });
1077
+ }
1078
+ }
1079
+ res.json({ success: true, template: { id, name, assets: assets.length } });
1080
+ } catch (err) {
1081
+ res.status(500).json({ success: false, message: err.message });
1082
+ }
1083
+ });
1084
+
987
1085
  // ── Template Import Log (client-facing) ──
988
1086
  app.post('/api/templates/:id/import-log', (req, res) => {
989
1087
  const licenseKey = req.headers['x-license-key'];