exflower-license-server 1.3.1 → 1.4.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/Dockerfile +4 -1
- package/README.md +120 -7
- package/admin/index.html +48 -1
- package/db.js +6 -0
- package/index.js +3 -0
- package/package.json +1 -1
- package/routes.js +121 -9
package/Dockerfile
CHANGED
|
@@ -13,7 +13,10 @@
|
|
|
13
13
|
FROM node:18-alpine
|
|
14
14
|
|
|
15
15
|
# better-sqlite3 原生模块编译工具(alpine/musl 无预编译二进制)
|
|
16
|
-
|
|
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
|
@@ -2,6 +2,90 @@
|
|
|
2
2
|
|
|
3
3
|
ExFlower 在线授权验证服务。管理 License Key、企业授权、实例追踪、模板分发。
|
|
4
4
|
|
|
5
|
+
## 私有化自助部署手册(客户 IT 照此执行)
|
|
6
|
+
|
|
7
|
+
前提:一台 Linux 服务器(1C1G 即可),已装 Docker;防火墙放行 4001(或反代用的 80/443);能访问 `https://license.exflower.pro`(每日激活复核)。
|
|
8
|
+
|
|
9
|
+
### 第 1 步:获取部署文件
|
|
10
|
+
|
|
11
|
+
只需 `Dockerfile` 与 `docker-compose.yml` 两个文件(镜像构建时会自动从 npm 拉取服务本体,无需完整源码):
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm pack exflower-license-server
|
|
15
|
+
tar xzf exflower-license-server-*.tgz package/Dockerfile package/docker-compose.yml --strip-components=1
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
### 第 2 步:构建镜像
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# 指定版本构建;国内服务器建议加 npm 镜像源参数
|
|
22
|
+
docker build --build-arg VERSION=1.3.2 \
|
|
23
|
+
--build-arg NPM_REGISTRY=https://registry.npmmirror.com \
|
|
24
|
+
-t exflower/license-server:1.3.2 .
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### 第 3 步:生成管理员 Token(请自行保管,厂商不需要知道)
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# 生成随机 Token —— 这就是管理后台的登录凭证,只显示这一次,立即妥善保存
|
|
31
|
+
TOKEN=$(openssl rand -hex 24) && echo "Admin Token: $TOKEN"
|
|
32
|
+
|
|
33
|
+
# 计算哈希(部署配置里只放哈希,不落明文)
|
|
34
|
+
echo -n "$TOKEN" | sha256sum | cut -d' ' -f1 # macOS 用: shasum -a 256
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### 第 4 步:启动服务
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
docker run -d --name exflower-license-server --restart unless-stopped \
|
|
41
|
+
-p 4001:4001 \
|
|
42
|
+
-v license-data:/app/data \
|
|
43
|
+
-e NODE_ENV=production \
|
|
44
|
+
-e PORT=4001 \
|
|
45
|
+
-e LICENSE_DATA_DIR=/app/data \
|
|
46
|
+
-e ADMIN_TOKEN_HASH=<第3步算出的哈希> \
|
|
47
|
+
exflower/license-server:1.3.2
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### 第 5 步:验证 + 激活(必做)
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
# 健康检查
|
|
54
|
+
curl http://127.0.0.1:4001/health
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
1. 浏览器打开 `http://<服务器IP>:4001/admin`,用第 3 步的 Token 登录
|
|
58
|
+
2. 进入「中央激活」,输入**厂商签发的 customer_key**(格式 `EXFC-XXXXXX-…`,向厂商索取)并激活
|
|
59
|
+
3. ⚠️ **未激活前所有客户端接口一律 403**,激活后立即恢复;之后每日自动复核,中央短暂不可达不影响服务
|
|
60
|
+
|
|
61
|
+
### 第 6 步:发放激活码
|
|
62
|
+
|
|
63
|
+
管理台「License 管理」→ 新建 License(备注用途、选有效期与实例数)→ 完整 Key **仅显示一次**,复制交付给终端用户在客户端填写。客户端「授权服务器地址」填 `http://<服务器IP>:4001`(配置了域名反代则填域名)。
|
|
64
|
+
|
|
65
|
+
### 升级版本
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
docker build --build-arg VERSION=<新版本> -t exflower/license-server:<新版本> .
|
|
69
|
+
docker rm -f exflower-license-server
|
|
70
|
+
# 用与第 4 步完全相同的参数重新 docker run(数据在命名卷 license-data 里,不受影响)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
⚠️ 不要使用 `docker compose down -v`——`-v` 会连数据卷一起删除。
|
|
74
|
+
|
|
75
|
+
### (可选)HTTPS 反代
|
|
76
|
+
|
|
77
|
+
以 Caddy 为例(`Caddyfile` 一行搞定,自动签发证书):
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
license.example.com {
|
|
81
|
+
reverse_proxy 127.0.0.1:4001
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
⚠️ 境内服务器挂域名须已完成 ICP 备案,否则 80/443 会被云厂商拦截。
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
5
89
|
## 快速开始
|
|
6
90
|
|
|
7
91
|
### 方式一:npm 全局安装(推荐)
|
|
@@ -12,7 +96,7 @@ npm install -g exflower-license-server
|
|
|
12
96
|
|
|
13
97
|
# 查看版本
|
|
14
98
|
exflower-license-server --version
|
|
15
|
-
# 输出: 1.
|
|
99
|
+
# 输出: 1.3.2
|
|
16
100
|
|
|
17
101
|
# 运行
|
|
18
102
|
exflower-license-server
|
|
@@ -23,7 +107,19 @@ ADMIN_TOKEN=your-secret-token PORT=4001 exflower-license-server
|
|
|
23
107
|
|
|
24
108
|
### 方式二:Docker
|
|
25
109
|
|
|
110
|
+
镜像构建直接从 npm registry 拉取已发布版本(构建机无需源码):
|
|
111
|
+
|
|
26
112
|
```bash
|
|
113
|
+
# 构建(默认 latest;指定版本用 --build-arg VERSION=x.y.z)
|
|
114
|
+
docker build -t exflower/license-server:latest .
|
|
115
|
+
|
|
116
|
+
# 国内构建机走镜像源(npm registry 与 alpine apk 源都可换)
|
|
117
|
+
docker build \
|
|
118
|
+
--build-arg VERSION=1.3.2 \
|
|
119
|
+
--build-arg NPM_REGISTRY=https://registry.npmmirror.com \
|
|
120
|
+
--build-arg APK_MIRROR=mirrors.cloud.tencent.com \
|
|
121
|
+
-t exflower/license-server:1.3.2 .
|
|
122
|
+
|
|
27
123
|
# 使用 docker-compose(推荐)
|
|
28
124
|
docker-compose up -d
|
|
29
125
|
|
|
@@ -174,7 +270,7 @@ sudo systemctl enable --now exflower-license-server
|
|
|
174
270
|
- **License 管理**:创建 License 时填写备注(如"市场部")并选择有效期(1/3/6 个月、1 年、2 年、永久);列表显示剩余天数(<30 天标黄、过期标红);支持编辑(改有效期/配额)、暂停/恢复、删除、**重置 Key**
|
|
175
271
|
- **品牌(白标)**:全局工作台名称设置(见下方"白标品牌名")
|
|
176
272
|
- **实例监控**:查看已激活的实例列表
|
|
177
|
-
-
|
|
273
|
+
- **模板管理**:按类型页签组织(agent/excard/doc/资料图谱),卡片式列表,支持分类管理与分类过滤、按 License 定向分发(多选),查看导入统计
|
|
178
274
|
- **轻应用分发**:上传 `.exfapp` 包并按 License 隔离分发;客户端「轻应用」页可浏览/下载/导入(上限 5GB,含图谱的包注意部署机磁盘)
|
|
179
275
|
- **客户端更新**:上传各平台更新包(mac 传 **zip**、win 传 NSIS **exe**),客户端启动自动检查 + 帮助菜单手动检查,electron-updater 静默下载安装(每个包 ~700MB+,注意部署机磁盘余量)
|
|
180
276
|
|
|
@@ -206,6 +302,7 @@ sudo systemctl enable --now exflower-license-server
|
|
|
206
302
|
| `/api/license/heartbeat` | POST | 心跳上报(响应含 `brand_name`) |
|
|
207
303
|
| `/api/templates` | GET | 获取模板列表(需 `x-license-key`) |
|
|
208
304
|
| `/api/templates/:id` | GET | 获取模板详情(需 `x-license-key`) |
|
|
305
|
+
| `/api/templates/:id/download` | GET | 下载资料图谱模板 `.exflib`(需 `x-license-key`) |
|
|
209
306
|
| `/api/packages` | GET | 轻应用包列表,按公司隔离(需 `x-license-key`) |
|
|
210
307
|
| `/api/packages/:id/download` | GET | 下载轻应用包(需 `x-license-key`) |
|
|
211
308
|
| `/api/updates/latest.yml` | GET | Windows 更新清单(electron-updater,需 `x-license-key`) |
|
|
@@ -224,6 +321,11 @@ sudo systemctl enable --now exflower-license-server
|
|
|
224
321
|
| `/api/admin/instances/:id` | DELETE | 删除实例 |
|
|
225
322
|
| `/api/admin/templates` | GET/POST | 模板列表/创建 |
|
|
226
323
|
| `/api/admin/templates/:id` | PATCH/DELETE | 更新/删除模板 |
|
|
324
|
+
| `/api/admin/templates/library` | POST | 资料图谱模板上传(multipart,字段 `file`,`.exflib`) |
|
|
325
|
+
| `/api/admin/templates/doc-upload` | POST | 知识库文档包上传(v1.3.2 起;multipart,字段 `file`,zip 内含 `README.md` + `assets/`) |
|
|
326
|
+
| `/api/admin/template-categories` | GET/POST | 模板分类列表/创建(按类型分组,按 `sort_order` 排序返回) |
|
|
327
|
+
| `/api/admin/template-categories/:id` | PATCH/DELETE | 分类改名/删除 |
|
|
328
|
+
| `/api/admin/template-categories/order` | PUT | 分类手动排序(v1.3.2 起;body `{type, ids:[...]}`,客户端按此顺序展示分类) |
|
|
227
329
|
| `/api/admin/imports` | GET | 导入统计(支持 `?template_id=` / `?company_id=` 过滤) |
|
|
228
330
|
| `/api/admin/packages` | GET/POST | 轻应用包列表/上传(multipart,字段 `file`+`company_id`) |
|
|
229
331
|
| `/api/admin/packages/:id` | DELETE | 删除轻应用包 |
|
|
@@ -234,16 +336,20 @@ sudo systemctl enable --now exflower-license-server
|
|
|
234
336
|
|
|
235
337
|
## 模板系统
|
|
236
338
|
|
|
237
|
-
|
|
339
|
+
模板用于向客户端分发预设配置。管理后台支持 `agent`、`excard`、`doc`、`library`(资料图谱)四种类型的模板。
|
|
340
|
+
|
|
341
|
+
> **workflow 类型暂不开放**:上传/下载形态待重新设计,管理台与客户端均已隐藏入口;API 与存量数据保留,重新开放前不要清理 workflow 数据。
|
|
238
342
|
|
|
239
343
|
### 上传方式(v1.0.7 起)
|
|
240
344
|
|
|
241
345
|
管理后台根据类型提供不同的上传方式:
|
|
242
346
|
|
|
243
347
|
- **agent / excard / doc**:**Markdown 原生上传**。直接上传客户端导出的 `.md` 文件(或粘贴 Markdown 文本),后台自动解析 frontmatter 预填模板名称并生成 `content_json`,无需手写 JSON。
|
|
244
|
-
- **
|
|
348
|
+
- **doc 附加方式(v1.3.2 起)**:可改传一个 **zip 文档包**(根目录 `README.md` + `assets/` 附件目录),与客户端「发布到模板库」的产物格式一致;名称留空自动取 README 的一级标题。选了 zip 则以包内容为准。
|
|
349
|
+
- **library**:上传客户端导出的 `.exflib` 包(multipart `POST /api/admin/templates/library`,字段 `file`),服务端解包校验 manifest(`format: exflower-library`)并记录文件大小与 sha256;客户端经 `GET /api/templates/:id/download` 下载导入。
|
|
350
|
+
- **workflow**(暂不开放):以 JSON 描述步骤结构,上传框预填骨架示例。
|
|
245
351
|
|
|
246
|
-
通过 API 创建模板时,`content_json` 与 `content_md` 至少提供一个(`workflow` 类型必须提供 `content_json
|
|
352
|
+
通过 API 创建模板时,`content_json` 与 `content_md` 至少提供一个(`workflow` 类型必须提供 `content_json`;`library` 类型走专用上传端点)。
|
|
247
353
|
|
|
248
354
|
### 通用字段
|
|
249
355
|
|
|
@@ -412,9 +518,16 @@ node scripts/seed-templates.js \
|
|
|
412
518
|
- 导入的模板均为**全局模板**(所有 License 可见)。
|
|
413
519
|
- 脚本遵守 admin 接口限速(60 次/分钟),遇到 429 自动退避重试。
|
|
414
520
|
|
|
415
|
-
##
|
|
521
|
+
## 发布新版本(厂商侧)
|
|
416
522
|
|
|
417
523
|
```bash
|
|
418
|
-
|
|
524
|
+
# 1. 提升 package.json 版本号
|
|
525
|
+
# 2. 确认 files 白名单覆盖全部运行时文件(activation.js / seed-templates.js /
|
|
526
|
+
# central-public.pem / seeds/ 缺一个都会发出启动即崩的包)
|
|
527
|
+
npm pack --dry-run # 检查 Tarball Contents
|
|
528
|
+
# 3. 发布(npm 已下线 Classic Token,用勾了 bypass 2FA 的 Granular Token)
|
|
529
|
+
npm publish
|
|
419
530
|
```
|
|
420
531
|
|
|
532
|
+
Docker 镜像构建参数见上方「方式二:Docker」。
|
|
533
|
+
|
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 (
|
|
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 (
|
|
@@ -236,6 +239,9 @@ db.exec(`
|
|
|
236
239
|
CREATE INDEX IF NOT EXISTS idx_client_versions_platform ON client_versions(platform, is_latest)
|
|
237
240
|
`);
|
|
238
241
|
|
|
242
|
+
// 幂等列迁移:CDN 远程地址(有值 = 文件在中央 CDN,本地不落盘;PLAN-CDN-DISTRIBUTION)
|
|
243
|
+
try { db.exec('ALTER TABLE client_versions ADD COLUMN remote_url TEXT'); } catch { /* 已存在 */ };
|
|
244
|
+
|
|
239
245
|
// ── Server-level settings(品牌等全局配置,私有化部署 = 一个组织) ──
|
|
240
246
|
db.exec(`
|
|
241
247
|
CREATE TABLE IF NOT EXISTS settings (
|
package/index.js
CHANGED
|
@@ -54,6 +54,9 @@ setupRoutes(app);
|
|
|
54
54
|
// ── 中央激活调度:已配置 customer_key 则启动即复核,之后每日复核(失败 1 小时重试) ──
|
|
55
55
|
require('./activation').startScheduler();
|
|
56
56
|
|
|
57
|
+
// ── 客户端版本 manifest 同步(启动即拉一次 + 每小时兜底;失败维持本地) ──
|
|
58
|
+
require('./updateSync').startUpdateSync();
|
|
59
|
+
|
|
57
60
|
app.listen(PORT, () => {
|
|
58
61
|
const tokenLine = ADMIN_TOKEN_HASH
|
|
59
62
|
? 'Admin Token: (hashed via ADMIN_TOKEN_HASH)'
|
package/package.json
CHANGED
package/routes.js
CHANGED
|
@@ -10,6 +10,7 @@ const unzipper = require('unzipper');
|
|
|
10
10
|
const { queryAll, queryGet, queryRun, getSetting, setSetting, DATA_DIR } = require('./db');
|
|
11
11
|
const { generateLicenseKey, generateId } = require('./utils');
|
|
12
12
|
const activation = require('./activation');
|
|
13
|
+
const { ensureFresh } = require('./updateSync');
|
|
13
14
|
|
|
14
15
|
const DEFAULT_ADMIN_TOKEN = 'exflower-admin-2026';
|
|
15
16
|
// ADMIN_TOKEN_HASH(sha256 hex)优先;否则用明文 ADMIN_TOKEN。二选一。
|
|
@@ -154,13 +155,21 @@ function buildLatestYml(mac) {
|
|
|
154
155
|
// version 取各行里语义最新(简单按创建时间最新的一行)
|
|
155
156
|
rows.sort((a, b) => (a.created_at < b.created_at ? 1 : -1));
|
|
156
157
|
const esc = (s) => String(s).replace(/'/g, "''");
|
|
158
|
+
// 本地文件就绪(存在且大小匹配)→ 本地相对路径(客户端局域网速度);
|
|
159
|
+
// 否则 remote_url(CDN)兜底——后台下载完成后下一轮自动切回本地
|
|
160
|
+
const fileUrl = (r) => {
|
|
161
|
+
if (r.file_path && fs.existsSync(r.file_path) && fs.statSync(r.file_path).size === r.file_size) {
|
|
162
|
+
return `files/${r.platform}/${esc(r.file_name)}`;
|
|
163
|
+
}
|
|
164
|
+
return r.remote_url || `files/${r.platform}/${esc(r.file_name)}`;
|
|
165
|
+
};
|
|
157
166
|
const lines = [`version: ${rows[0].version}`, 'files:'];
|
|
158
167
|
for (const r of rows) {
|
|
159
|
-
lines.push(` - url: '
|
|
168
|
+
lines.push(` - url: '${fileUrl(r)}'`);
|
|
160
169
|
lines.push(` sha512: '${r.sha512}'`);
|
|
161
170
|
lines.push(` size: ${r.file_size}`);
|
|
162
171
|
}
|
|
163
|
-
lines.push(`path: '
|
|
172
|
+
lines.push(`path: '${fileUrl(rows[0])}'`);
|
|
164
173
|
lines.push(`sha512: '${rows[0].sha512}'`);
|
|
165
174
|
lines.push(`releaseDate: '${new Date(rows[0].created_at.replace(' ', 'T') + 'Z').toISOString()}'`);
|
|
166
175
|
return lines.join('\n') + '\n';
|
|
@@ -574,8 +583,12 @@ function setupRoutes(app) {
|
|
|
574
583
|
sql += ' ORDER BY type, name';
|
|
575
584
|
|
|
576
585
|
const templates = queryAll(sql, params);
|
|
586
|
+
// 分类手动排序下发:客户端按服务端顺序展示分类筛选(各类型全量,体积小)
|
|
587
|
+
const orderRows = queryAll('SELECT type, name FROM template_categories ORDER BY sort_order ASC, created_at ASC');
|
|
588
|
+
const categoryOrder = {};
|
|
589
|
+
for (const r of orderRows) (categoryOrder[r.type] = categoryOrder[r.type] || []).push(r.name);
|
|
577
590
|
const hint = upgradeHint(req);
|
|
578
|
-
res.json({ success: true, templates, ...(hint ? { upgrade_hint: hint } : {}) });
|
|
591
|
+
res.json({ success: true, templates, category_order: categoryOrder, ...(hint ? { upgrade_hint: hint } : {}) });
|
|
579
592
|
});
|
|
580
593
|
|
|
581
594
|
// Import summary (must come before /:id to avoid matching 'import-summary' as an id)
|
|
@@ -697,7 +710,7 @@ function setupRoutes(app) {
|
|
|
697
710
|
`;
|
|
698
711
|
const params = [];
|
|
699
712
|
if (type) { sql += ' WHERE c.type = ?'; params.push(type); }
|
|
700
|
-
sql += ' ORDER BY c.created_at ASC';
|
|
713
|
+
sql += ' ORDER BY c.sort_order ASC, c.created_at ASC';
|
|
701
714
|
const cats = queryAll(sql, params);
|
|
702
715
|
// 轻应用分类的计数走 app_packages(模板计数查 templates 永远是 0)
|
|
703
716
|
if (type === 'app') {
|
|
@@ -718,10 +731,25 @@ function setupRoutes(app) {
|
|
|
718
731
|
const dup = queryGet('SELECT id FROM template_categories WHERE type = ? AND name = ?', [type, name]);
|
|
719
732
|
if (dup) return res.status(409).json({ success: false, message: '该类型下分类已存在' });
|
|
720
733
|
const id = generateId();
|
|
721
|
-
|
|
734
|
+
const maxOrder = queryGet('SELECT MAX(sort_order) AS m FROM template_categories WHERE type = ?', [type])?.m ?? -1;
|
|
735
|
+
queryRun('INSERT INTO template_categories (id, type, name, sort_order) VALUES (?, ?, ?, ?)', [id, type, name, maxOrder + 1]);
|
|
722
736
|
res.json({ success: true, category: { id, type, name } });
|
|
723
737
|
});
|
|
724
738
|
|
|
739
|
+
// 手动排序:按 ids 数组顺序重排该类型下分类(未列入的保持相对顺序排在末尾)
|
|
740
|
+
app.put('/api/admin/template-categories/order', requireAdmin, (req, res) => {
|
|
741
|
+
const { type, ids } = req.body || {};
|
|
742
|
+
if (!type || !Array.isArray(ids)) {
|
|
743
|
+
return res.status(400).json({ success: false, message: 'type 与 ids 必填' });
|
|
744
|
+
}
|
|
745
|
+
const rows = queryAll('SELECT id FROM template_categories WHERE type = ? ORDER BY sort_order ASC, created_at ASC', [type]);
|
|
746
|
+
const rest = rows.map(r => r.id).filter(id => !ids.includes(id));
|
|
747
|
+
[...ids, ...rest].forEach((id, i) => {
|
|
748
|
+
queryRun('UPDATE template_categories SET sort_order = ? WHERE id = ? AND type = ?', [i + 1, id, type]);
|
|
749
|
+
});
|
|
750
|
+
res.json({ success: true });
|
|
751
|
+
});
|
|
752
|
+
|
|
725
753
|
app.patch('/api/admin/template-categories/:id', requireAdmin, (req, res) => {
|
|
726
754
|
const cat = queryGet('SELECT * FROM template_categories WHERE id = ?', [req.params.id]);
|
|
727
755
|
if (!cat) return res.status(404).json({ success: false, message: 'Category not found' });
|
|
@@ -984,6 +1012,85 @@ function setupRoutes(app) {
|
|
|
984
1012
|
}
|
|
985
1013
|
});
|
|
986
1014
|
|
|
1015
|
+
// ── Admin: 知识库文档 zip 上传(README.md + assets/,与客户端发布产物的存储契约一致) ──
|
|
1016
|
+
const docZipUpload = multer({
|
|
1017
|
+
storage: multer.memoryStorage(),
|
|
1018
|
+
limits: { fileSize: 200 * 1024 * 1024 }
|
|
1019
|
+
});
|
|
1020
|
+
const DOC_ASSET_MIMES = {
|
|
1021
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
1022
|
+
'.gif': 'image/gif', '.svg': 'image/svg+xml', '.webp': 'image/webp',
|
|
1023
|
+
'.pdf': 'application/pdf', '.md': 'text/markdown', '.txt': 'text/plain',
|
|
1024
|
+
};
|
|
1025
|
+
app.post('/api/admin/templates/doc-upload', requireAdmin, docZipUpload.single('file'), async (req, res) => {
|
|
1026
|
+
try {
|
|
1027
|
+
if (!req.file) return res.status(400).json({ success: false, message: 'file is required' });
|
|
1028
|
+
|
|
1029
|
+
let zip;
|
|
1030
|
+
try {
|
|
1031
|
+
zip = await unzipper.Open.buffer(req.file.buffer);
|
|
1032
|
+
} catch {
|
|
1033
|
+
return res.status(400).json({ success: false, message: '无效的 zip 包' });
|
|
1034
|
+
}
|
|
1035
|
+
const readme = zip.files.find(f => f.type === 'File' && f.path.toLowerCase() === 'readme.md');
|
|
1036
|
+
if (!readme) {
|
|
1037
|
+
return res.status(400).json({ success: false, message: '无效的文档包:根目录缺少 README.md' });
|
|
1038
|
+
}
|
|
1039
|
+
const contentMd = (await readme.buffer()).toString('utf8');
|
|
1040
|
+
|
|
1041
|
+
const assets = [];
|
|
1042
|
+
for (const f of zip.files) {
|
|
1043
|
+
if (f.type !== 'File' || !f.path.startsWith('assets/')) continue;
|
|
1044
|
+
const filename = path.basename(f.path);
|
|
1045
|
+
if (!filename) continue;
|
|
1046
|
+
const buf = await f.buffer();
|
|
1047
|
+
assets.push({
|
|
1048
|
+
filename,
|
|
1049
|
+
mimeType: DOC_ASSET_MIMES[path.extname(filename).toLowerCase()] || 'application/octet-stream',
|
|
1050
|
+
data: buf.toString('base64'),
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const { company_id, category, version } = req.body || {};
|
|
1055
|
+
let shareIds = null;
|
|
1056
|
+
if (req.body?.company_ids) {
|
|
1057
|
+
try { shareIds = JSON.parse(req.body.company_ids).filter(Boolean); } catch { shareIds = null; }
|
|
1058
|
+
}
|
|
1059
|
+
if (company_id && !shareIds) {
|
|
1060
|
+
const company = queryGet('SELECT id FROM companies WHERE id = ?', [company_id]);
|
|
1061
|
+
if (!company) return res.status(404).json({ success: false, message: 'Company not found' });
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
const h1 = contentMd.match(/^#\s+(.+)$/m)?.[1]?.trim();
|
|
1065
|
+
const name = (req.body?.name || '').trim() || h1 || '未命名文档';
|
|
1066
|
+
const id = generateId();
|
|
1067
|
+
queryRun(
|
|
1068
|
+
`INSERT INTO templates (id, company_id, type, name, description, content_json, content_md, tags, category, version, created_at, updated_at)
|
|
1069
|
+
VALUES (?, ?, 'doc', ?, ?, ?, ?, '[]', ?, ?, datetime('now'), datetime('now'))`,
|
|
1070
|
+
[
|
|
1071
|
+
id,
|
|
1072
|
+
shareIds ? null : (company_id || null),
|
|
1073
|
+
name,
|
|
1074
|
+
(req.body?.description || '').trim(),
|
|
1075
|
+
JSON.stringify({ assets }),
|
|
1076
|
+
contentMd,
|
|
1077
|
+
category || null,
|
|
1078
|
+
(version || '').trim() || '1.0',
|
|
1079
|
+
]
|
|
1080
|
+
);
|
|
1081
|
+
if (shareIds && shareIds.length) {
|
|
1082
|
+
const err = setTemplateCompanies(id, shareIds);
|
|
1083
|
+
if (err) {
|
|
1084
|
+
queryRun('DELETE FROM templates WHERE id = ?', [id]);
|
|
1085
|
+
return res.status(404).json({ success: false, message: err });
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
res.json({ success: true, template: { id, name, assets: assets.length } });
|
|
1089
|
+
} catch (err) {
|
|
1090
|
+
res.status(500).json({ success: false, message: err.message });
|
|
1091
|
+
}
|
|
1092
|
+
});
|
|
1093
|
+
|
|
987
1094
|
// ── Template Import Log (client-facing) ──
|
|
988
1095
|
app.post('/api/templates/:id/import-log', (req, res) => {
|
|
989
1096
|
const licenseKey = req.headers['x-license-key'];
|
|
@@ -1209,16 +1316,18 @@ function setupRoutes(app) {
|
|
|
1209
1316
|
});
|
|
1210
1317
|
|
|
1211
1318
|
// ── Client Updates: client-facing (electron-updater generic provider) ──
|
|
1212
|
-
app.get('/api/updates/latest.yml', (req, res) => {
|
|
1319
|
+
app.get('/api/updates/latest.yml', async (req, res) => {
|
|
1213
1320
|
if (!companyByLicenseKey(req, res)) return;
|
|
1321
|
+
await ensureFresh(); // manifest 过期则先同步中央(失败不阻塞)
|
|
1214
1322
|
const yml = buildLatestYml(false);
|
|
1215
1323
|
if (!yml) return res.status(404).json({ success: false, message: 'No win update available' });
|
|
1216
1324
|
res.setHeader('Content-Type', 'text/yaml; charset=utf-8');
|
|
1217
1325
|
res.send(yml);
|
|
1218
1326
|
});
|
|
1219
1327
|
|
|
1220
|
-
app.get('/api/updates/latest-mac.yml', (req, res) => {
|
|
1328
|
+
app.get('/api/updates/latest-mac.yml', async (req, res) => {
|
|
1221
1329
|
if (!companyByLicenseKey(req, res)) return;
|
|
1330
|
+
await ensureFresh();
|
|
1222
1331
|
const yml = buildLatestYml(true);
|
|
1223
1332
|
if (!yml) return res.status(404).json({ success: false, message: 'No mac update available' });
|
|
1224
1333
|
res.setHeader('Content-Type', 'text/yaml; charset=utf-8');
|
|
@@ -1232,7 +1341,10 @@ function setupRoutes(app) {
|
|
|
1232
1341
|
'SELECT * FROM client_versions WHERE platform = ? AND file_name = ?',
|
|
1233
1342
|
[platform, filename]
|
|
1234
1343
|
);
|
|
1235
|
-
if (!row
|
|
1344
|
+
if (!row) return res.status(404).json({ success: false, message: 'Update file not found' });
|
|
1345
|
+
if (!fs.existsSync(row.file_path)) {
|
|
1346
|
+
// 本地无文件但有 CDN 地址 → 302 兜底(对齐 ensureFresh 未覆盖的旧 yml 场景)
|
|
1347
|
+
if (row.remote_url) return res.redirect(302, row.remote_url);
|
|
1236
1348
|
return res.status(404).json({ success: false, message: 'Update file not found' });
|
|
1237
1349
|
}
|
|
1238
1350
|
res.setHeader('Content-Type', 'application/octet-stream');
|
|
@@ -1243,7 +1355,7 @@ function setupRoutes(app) {
|
|
|
1243
1355
|
// ── Client Updates: admin ──
|
|
1244
1356
|
app.get('/api/admin/updates', requireAdmin, (req, res) => {
|
|
1245
1357
|
const updates = queryAll(
|
|
1246
|
-
'SELECT id, platform, version, file_name, file_size, sha512, release_notes, is_latest, created_at FROM client_versions ORDER BY created_at DESC'
|
|
1358
|
+
'SELECT id, platform, version, file_name, file_size, sha512, release_notes, is_latest, remote_url, created_at FROM client_versions ORDER BY created_at DESC'
|
|
1247
1359
|
);
|
|
1248
1360
|
res.json({ success: true, updates });
|
|
1249
1361
|
});
|