diff --git a/.github/scripts/lib/comment.ts b/.github/scripts/lib/comment.ts
index 27fb2ce9..15065267 100644
--- a/.github/scripts/lib/comment.ts
+++ b/.github/scripts/lib/comment.ts
@@ -24,6 +24,10 @@ function escapeCodeBlock (text: string): string {
return text.replace(/```/g, '\\`\\`\\`');
}
+function getTimeString (): string {
+ return new Date().toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
+}
+
// ============== 状态图标 ==============
export function getStatusIcon (status: BuildStatus): string {
@@ -34,30 +38,66 @@ export function getStatusIcon (status: BuildStatus): string {
return '⏳ 构建中...';
case 'cancelled':
return '⚪ 已取消';
- default:
+ case 'failure':
return '❌ 失败';
+ default:
+ return '❓ 未知';
+ }
+}
+
+function getStatusEmoji (status: BuildStatus): string {
+ switch (status) {
+ case 'success': return '✅';
+ case 'pending': return '⏳';
+ case 'cancelled': return '⚪';
+ case 'failure': return '❌';
+ default: return '❓';
}
}
// ============== 构建中评论 ==============
export function generateBuildingComment (prSha: string, targets: string[]): string {
- const time = new Date().toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
+ const time = getTimeString();
+ const shortSha = formatSha(prSha);
const lines: string[] = [
COMMENT_MARKER,
- '## 🔨 构建状态',
'',
- '| 构建目标 | 状态 |',
- '| :--- | :--- |',
- ...targets.map(name => `| ${name} | ⏳ 构建中... |`),
+ '
',
+ '',
+ '# 🔨 NapCat 构建中',
+ '',
+ '',
+ '',
+ '
',
'',
'---',
'',
- `📝 **提交**: \`${formatSha(prSha)}\``,
- `🕐 **开始时间**: ${time}`,
+ '## 📦 构建目标',
'',
- '> 构建进行中,请稍候...',
+ '| 包名 | 状态 | 说明 |',
+ '| :--- | :---: | :--- |',
+ ...targets.map(name => `| \`${name}\` | ⏳ | 正在构建... |`),
+ '',
+ '---',
+ '',
+ '## 📋 构建信息',
+ '',
+ `| 项目 | 值 |`,
+ `| :--- | :--- |`,
+ `| 📝 提交 | \`${shortSha}\` |`,
+ `| 🕐 开始时间 | ${time} |`,
+ '',
+ '---',
+ '',
+ '',
+ '',
+ '> ⏳ **构建进行中,请稍候...**',
+ '>',
+ '> 构建完成后将自动更新此评论',
+ '',
+ '
',
];
return lines.join('\n');
@@ -69,60 +109,123 @@ export function generateResultComment (
targets: BuildTarget[],
prSha: string,
runId: string,
- repository: string
+ repository: string,
+ version?: string
): string {
- // 链接到 run 详情页,页面底部有 Artifacts 下载区域
const runUrl = `https://github.com/${repository}/actions/runs/${runId}`;
+ const shortSha = formatSha(prSha);
+ const time = getTimeString();
const allSuccess = targets.every(t => t.status === 'success');
const anyCancelled = targets.some(t => t.status === 'cancelled');
+ const anyFailure = targets.some(t => t.status === 'failure');
- const headerIcon = allSuccess
- ? '✅ 构建成功'
- : anyCancelled
- ? '⚪ 构建已取消'
- : '❌ 构建失败';
+ // 状态徽章
+ let statusBadge: string;
+ let headerTitle: string;
+ if (allSuccess) {
+ statusBadge = '';
+ headerTitle = '# ✅ NapCat 构建成功';
+ } else if (anyCancelled && !anyFailure) {
+ statusBadge = '';
+ headerTitle = '# ⚪ NapCat 构建已取消';
+ } else {
+ statusBadge = '';
+ headerTitle = '# ❌ NapCat 构建失败';
+ }
const downloadLink = (target: BuildTarget) => {
if (target.status !== 'success') return '—';
if (target.downloadUrl) {
- return `[📦 下载](${target.downloadUrl})`;
+ return `[📥 下载](${target.downloadUrl})`;
}
- // 回退到 run 详情页
- return `[📦 下载](${runUrl}#artifacts)`;
+ return `[📥 下载](${runUrl}#artifacts)`;
};
const lines: string[] = [
COMMENT_MARKER,
- `## ${headerIcon}`,
'',
- '| 构建目标 | 状态 | 下载 |',
- '| :--- | :--- | :--- |',
- ...targets.map(t => `| ${t.name} | ${getStatusIcon(t.status)} | ${downloadLink(t)} |`),
+ '',
+ '',
+ headerTitle,
+ '',
+ statusBadge,
+ '',
+ '
',
'',
'---',
'',
- `📝 **提交**: \`${formatSha(prSha)}\``,
- `🔗 **构建日志**: [查看详情](${runUrl})`,
+ '## 📦 构建产物',
+ '',
+ '| 包名 | 状态 | 下载 |',
+ '| :--- | :---: | :---: |',
+ ...targets.map(t => `| \`${t.name}\` | ${getStatusEmoji(t.status)} ${t.status === 'success' ? '成功' : t.status === 'failure' ? '失败' : t.status === 'cancelled' ? '已取消' : '未知'} | ${downloadLink(t)} |`),
+ '',
+ '---',
+ '',
+ '## 📋 构建信息',
+ '',
+ `| 项目 | 值 |`,
+ `| :--- | :--- |`,
+ ...(version ? [`| 🏷️ 版本号 | \`${version}\` |`] : []),
+ `| 📝 提交 | \`${shortSha}\` |`,
+ `| 🔗 构建日志 | [查看详情](${runUrl}) |`,
+ `| 🕐 完成时间 | ${time} |`,
];
// 添加错误详情
const failedTargets = targets.filter(t => t.status === 'failure' && t.error);
if (failedTargets.length > 0) {
- lines.push('', '---', '', '## ⚠️ 错误详情');
+ lines.push('', '---', '', '## ⚠️ 错误详情', '');
for (const target of failedTargets) {
- lines.push('', `### ${target.name} 构建错误`, '```', escapeCodeBlock(target.error!), '```');
+ lines.push(
+ ``,
+ `🔴 ${target.name} 构建错误
`,
+ '',
+ '```',
+ escapeCodeBlock(target.error!),
+ '```',
+ '',
+ ' ',
+ ''
+ );
}
}
// 添加底部提示
+ lines.push('---', '');
if (allSuccess) {
- lines.push('', '> 🎉 所有构建均已成功完成,可点击上方下载链接获取构建产物进行测试。');
- } else if (anyCancelled) {
- lines.push('', '> ⚪ 构建已被取消,可能是由于新的提交触发了新的构建。');
+ lines.push(
+ '',
+ '',
+ '> 🎉 **所有构建均已成功完成!**',
+ '>',
+ '> 点击上方下载链接获取构建产物进行测试',
+ '',
+ '
'
+ );
+ } else if (anyCancelled && !anyFailure) {
+ lines.push(
+ '',
+ '',
+ '> ⚪ **构建已被取消**',
+ '>',
+ '> 可能是由于新的提交触发了新的构建',
+ '',
+ '
'
+ );
} else {
- lines.push('', '> ⚠️ 部分构建失败,请查看上方错误详情或点击构建日志查看完整输出。');
+ lines.push(
+ '',
+ '',
+ '> ⚠️ **部分构建失败**',
+ '>',
+ '> 请查看上方错误详情或点击构建日志查看完整输出',
+ '',
+ '
'
+ );
}
return lines.join('\n');
}
+
diff --git a/.github/scripts/pr-build-result.ts b/.github/scripts/pr-build-result.ts
index c8308fe1..3af0ef9c 100644
--- a/.github/scripts/pr-build-result.ts
+++ b/.github/scripts/pr-build-result.ts
@@ -6,6 +6,7 @@
* - PR_NUMBER: PR 编号
* - PR_SHA: PR 提交 SHA
* - RUN_ID: GitHub Actions Run ID
+ * - NAPCAT_VERSION: 构建版本号
* - FRAMEWORK_STATUS: Framework 构建状态
* - FRAMEWORK_ERROR: Framework 构建错误信息
* - SHELL_STATUS: Shell 构建状态
@@ -30,6 +31,7 @@ async function main (): Promise {
const prNumber = parseInt(getEnv('PR_NUMBER', true), 10);
const prSha = getEnv('PR_SHA') || 'unknown';
const runId = getEnv('RUN_ID', true);
+ const version = getEnv('NAPCAT_VERSION') || '';
const { owner, repo } = getRepository();
const frameworkStatus = parseStatus(getEnv('FRAMEWORK_STATUS'));
@@ -39,6 +41,7 @@ async function main (): Promise {
console.log(`PR: #${prNumber}`);
console.log(`SHA: ${prSha}`);
+ console.log(`Version: ${version}`);
console.log(`Run: ${runId}`);
console.log(`Framework: ${frameworkStatus}${frameworkError ? ` (${frameworkError})` : ''}`);
console.log(`Shell: ${shellStatus}${shellError ? ` (${shellError})` : ''}\n`);
@@ -76,7 +79,7 @@ async function main (): Promise {
},
];
- const comment = generateResultComment(targets, prSha, runId, repository);
+ const comment = generateResultComment(targets, prSha, runId, repository, version);
await github.createOrUpdateComment(owner, repo, prNumber, comment, COMMENT_MARKER);
}
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index d3b6c420..a138a608 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -13,11 +13,27 @@ jobs:
steps:
- name: Clone Main Repository
uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # 需要完整历史来获取 tags
- name: Use Node.js 20.X
uses: actions/setup-node@v4
with:
node-version: 20.x
+ - name: Generate Version
+ run: |
+ # 获取最近的 release tag (格式: vX.X.X)
+ LATEST_TAG=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "v0.0.0")
+ # 去掉 v 前缀
+ BASE_VERSION="${LATEST_TAG#v}"
+ SHORT_SHA="${GITHUB_SHA::7}"
+ VERSION="${BASE_VERSION}-main.${{ github.run_number }}+${SHORT_SHA}"
+ echo "NAPCAT_VERSION=${VERSION}" >> $GITHUB_ENV
+ echo "Latest tag: ${LATEST_TAG}"
+ echo "Build version: ${VERSION}"
- name: Build NapCat.Framework
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ NAPCAT_VERSION: ${{ env.NAPCAT_VERSION }}
run: |
npm i -g pnpm
pnpm i
@@ -39,11 +55,27 @@ jobs:
steps:
- name: Clone Main Repository
uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # 需要完整历史来获取 tags
- name: Use Node.js 20.X
uses: actions/setup-node@v4
with:
node-version: 20.x
+ - name: Generate Version
+ run: |
+ # 获取最近的 release tag (格式: vX.X.X)
+ LATEST_TAG=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "v0.0.0")
+ # 去掉 v 前缀
+ BASE_VERSION="${LATEST_TAG#v}"
+ SHORT_SHA="${GITHUB_SHA::7}"
+ VERSION="${BASE_VERSION}-main.${{ github.run_number }}+${SHORT_SHA}"
+ echo "NAPCAT_VERSION=${VERSION}" >> $GITHUB_ENV
+ echo "Latest tag: ${LATEST_TAG}"
+ echo "Build version: ${VERSION}"
- name: Build NapCat.Shell
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ NAPCAT_VERSION: ${{ env.NAPCAT_VERSION }}
run: |
npm i -g pnpm
pnpm i
diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml
index b631af87..5ec44812 100644
--- a/.github/workflows/pr-build.yml
+++ b/.github/workflows/pr-build.yml
@@ -126,6 +126,7 @@ jobs:
outputs:
status: ${{ steps.build.outcome }} # 构建结果:success/failure
error: ${{ steps.build.outputs.error }} # 错误信息(如有)
+ version: ${{ steps.version.outputs.version }} # 构建版本号
steps:
# 【安全】先从 base 分支 checkout 构建脚本
# 这样即使 PR 中修改了脚本,也不会被执行
@@ -143,16 +144,36 @@ jobs:
repository: ${{ needs.check-build.outputs.pr_head_repo }}
ref: ${{ needs.check-build.outputs.pr_sha }}
path: workspace
+ fetch-depth: 0 # 需要完整历史来获取 tags
- name: Setup Node.js 24
uses: actions/setup-node@v4
with:
node-version: 24
+ # 获取最新 release tag 并生成版本号
+ - name: Generate Version
+ id: version
+ working-directory: workspace
+ run: |
+ # 获取最近的 release tag (格式: vX.X.X)
+ LATEST_TAG=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "v0.0.0")
+ # 去掉 v 前缀
+ BASE_VERSION="${LATEST_TAG#v}"
+ SHORT_SHA="${{ needs.check-build.outputs.pr_sha }}"
+ SHORT_SHA="${SHORT_SHA::7}"
+ VERSION="${BASE_VERSION}-pr.${{ needs.check-build.outputs.pr_number }}.${{ github.run_number }}+${SHORT_SHA}"
+ echo "NAPCAT_VERSION=${VERSION}" >> $GITHUB_ENV
+ echo "Latest tag: ${LATEST_TAG}"
+ echo "Build version: ${VERSION}"
+
# 执行构建,使用 base 分支的脚本处理 workspace 中的代码
- name: Build
id: build
working-directory: workspace
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ NAPCAT_VERSION: ${{ env.NAPCAT_VERSION }}
run: node --experimental-strip-types ../_scripts/.github/scripts/pr-build-run.ts framework
continue-on-error: true # 允许失败,后续更新评论时处理
@@ -177,6 +198,8 @@ jobs:
outputs:
status: ${{ steps.build.outcome }} # 构建结果:success/failure
error: ${{ steps.build.outputs.error }} # 错误信息(如有)
+ version: ${{ steps.version.outputs.version }} # 构建版本号
+ version: ${{ steps.version.outputs.version }} # 构建版本号
steps:
# 【安全】先从 base 分支 checkout 构建脚本
- name: Checkout scripts from base
@@ -193,16 +216,37 @@ jobs:
repository: ${{ needs.check-build.outputs.pr_head_repo }}
ref: ${{ needs.check-build.outputs.pr_sha }}
path: workspace
+ fetch-depth: 0 # 需要完整历史来获取 tags
- name: Setup Node.js 24
uses: actions/setup-node@v4
with:
node-version: 24
+ # 获取最新 release tag 并生成版本号
+ - name: Generate Version
+ id: version
+ working-directory: workspace
+ run: |
+ # 获取最近的 release tag (格式: vX.X.X)
+ LATEST_TAG=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "v0.0.0")
+ # 去掉 v 前缀
+ BASE_VERSION="${LATEST_TAG#v}"
+ SHORT_SHA="${{ needs.check-build.outputs.pr_sha }}"
+ SHORT_SHA="${SHORT_SHA::7}"
+ VERSION="${BASE_VERSION}-pr.${{ needs.check-build.outputs.pr_number }}.${{ github.run_number }}+${SHORT_SHA}"
+ echo "NAPCAT_VERSION=${VERSION}" >> $GITHUB_ENV
+ echo "version=${VERSION}" >> $GITHUB_OUTPUT
+ echo "Latest tag: ${LATEST_TAG}"
+ echo "Build version: ${VERSION}"
+
# 执行构建
- name: Build
id: build
working-directory: workspace
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ NAPCAT_VERSION: ${{ env.NAPCAT_VERSION }}
run: node --experimental-strip-types ../_scripts/.github/scripts/pr-build-run.ts shell
continue-on-error: true
@@ -244,6 +288,8 @@ jobs:
PR_NUMBER: ${{ needs.check-build.outputs.pr_number }}
PR_SHA: ${{ needs.check-build.outputs.pr_sha }}
RUN_ID: ${{ github.run_id }}
+ # 构建版本号
+ NAPCAT_VERSION: ${{ needs.build-framework.outputs.version || needs.build-shell.outputs.version || '' }}
# 获取构建状态,如果 job 被跳过则标记为 cancelled
FRAMEWORK_STATUS: ${{ needs.build-framework.outputs.status || 'cancelled' }}
FRAMEWORK_ERROR: ${{ needs.build-framework.outputs.error }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 6ecc1f04..4f4980b1 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -4,13 +4,16 @@ on:
workflow_dispatch:
push:
tags:
- - '*'
+ - 'v[0-9]*.[0-9]*.[0-9]*'
+ - 'v[0-9]*.[0-9]*.[0-9]*-*'
+ - 'v[0-9]*.[0-9]*.[0-9]*+*'
+ - 'v[0-9]*.[0-9]*.[0-9]*-*+*'
permissions: write-all
env:
OPENROUTER_API_URL: https://91vip.futureppo.top/v1/chat/completions
- OPENROUTER_MODEL: "kimi-k2-0905-turbo"
+ OPENROUTER_MODEL: "Antigravity/gemini-3-flash-preview"
RELEASE_NAME: "NapCat"
jobs:
@@ -24,6 +27,8 @@ jobs:
with:
node-version: 20.x
- name: Build NapCat.Framework
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npm i -g pnpm
pnpm i
@@ -49,6 +54,8 @@ jobs:
with:
node-version: 20.x
- name: Build NapCat.Shell
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npm i -g pnpm
pnpm i
@@ -171,7 +178,7 @@ jobs:
- name: Generate release note via OpenRouter
env:
- OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
+ OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
OPENROUTER_API_URL: ${{ env.OPENROUTER_API_URL }}
OPENROUTER_MODEL: ${{ env.OPENROUTER_MODEL }}
GITHUB_OWNER: "NapNeKo" # 替换成你的 repo owner
@@ -233,7 +240,7 @@ jobs:
# 调用 OpenRouter
if RESPONSE=$(curl -s -X POST "$OPENROUTER_API_URL" \
- -H "Authorization: Bearer $OPENROUTER_API_KEY" \
+ -H "Authorization: Bearer $OPENAI_KEY" \
-H "Content-Type: application/json" \
-d "$BODY"); then
echo "=== raw response ==="
diff --git a/packages/napcat-common/src/helper.ts b/packages/napcat-common/src/helper.ts
index 89cf38a8..f3b3d5ad 100644
--- a/packages/napcat-common/src/helper.ts
+++ b/packages/napcat-common/src/helper.ts
@@ -2,7 +2,11 @@ import path from 'node:path';
import fs from 'fs';
import os from 'node:os';
import { QQVersionConfigType, QQLevel } from './types';
-import { RequestUtil } from './request';
+import { compareSemVer } from './version';
+import { getAllGitHubTags as getAllTagsFromMirror } from './mirror';
+
+// 导出 compareSemVer 供其他模块使用
+export { compareSemVer } from './version';
export async function solveProblem any> (func: T, ...args: Parameters): Promise | undefined> {
return new Promise | undefined>((resolve) => {
@@ -213,56 +217,19 @@ export function parseAppidFromMajor (nodeMajor: string): string | undefined {
return undefined;
}
-const baseUrl = 'https://github.com/NapNeko/NapCatQQ.git/info/refs?service=git-upload-pack';
-const urls = [
- 'https://j.1win.ggff.net/' + baseUrl,
- 'https://git.yylx.win/' + baseUrl,
- 'https://ghfile.geekertao.top/' + baseUrl,
- 'https://gh-proxy.net/' + baseUrl,
- 'https://ghm.078465.xyz/' + baseUrl,
- 'https://gitproxy.127731.xyz/' + baseUrl,
- 'https://jiashu.1win.eu.org/' + baseUrl,
- baseUrl,
-];
+// ============== GitHub Tags 获取 ==============
+// 使用 mirror 模块统一管理镜像
-async function testUrl (url: string): Promise {
- try {
- await PromiseTimer(RequestUtil.HttpGetText(url), 5000);
- return true;
- } catch {
- return false;
- }
-}
-
-async function findAvailableUrl (): Promise {
- for (const url of urls) {
- if (await testUrl(url)) {
- return url;
- }
- }
- return null;
-}
-
-export async function getAllTags (): Promise {
- const availableUrl = await findAvailableUrl();
- if (!availableUrl) {
- throw new Error('No available URL for fetching tags');
- }
- const raw = await RequestUtil.HttpGetText(availableUrl);
- return raw
- .split('\n')
- .map(line => {
- const match = line.match(/refs\/tags\/(.+)$/);
- return match ? match[1] : null;
- })
- .filter(tag => tag !== null && !tag!.endsWith('^{}')) as string[];
+export async function getAllTags (): Promise<{ tags: string[], mirror: string; }> {
+ return getAllTagsFromMirror('NapNeko', 'NapCatQQ');
}
export async function getLatestTag (): Promise {
- const tags = await getAllTags();
+ const { tags } = await getAllTags();
- tags.sort((a, b) => compareVersion(a, b));
+ // 使用 SemVer 规范排序
+ tags.sort((a, b) => compareSemVer(a, b));
const latest = tags.at(-1);
if (!latest) {
@@ -271,22 +238,3 @@ export async function getLatestTag (): Promise {
// 去掉开头的 v
return latest.replace(/^v/, '');
}
-
-
-function compareVersion (a: string, b: string): number {
- const normalize = (v: string) =>
- v.replace(/^v/, '') // 去掉开头的 v
- .split('.')
- .map(n => parseInt(n) || 0);
-
- const pa = normalize(a);
- const pb = normalize(b);
- const len = Math.max(pa.length, pb.length);
-
- for (let i = 0; i < len; i++) {
- const na = pa[i] || 0;
- const nb = pb[i] || 0;
- if (na !== nb) return na - nb;
- }
- return 0;
-}
diff --git a/packages/napcat-common/src/mirror.ts b/packages/napcat-common/src/mirror.ts
new file mode 100644
index 00000000..17342a63
--- /dev/null
+++ b/packages/napcat-common/src/mirror.ts
@@ -0,0 +1,898 @@
+/**
+ * GitHub 镜像配置模块
+ * 提供统一的镜像源管理,支持复杂网络环境
+ *
+ * 镜像源测试时间: 2026-01-03
+ * 测试通过: 55/61 完全可用
+ */
+
+import https from 'https';
+import http from 'http';
+import { RequestUtil } from './request';
+import { PromiseTimer } from './helper';
+
+// ============== 镜像源列表 ==============
+
+/**
+ * GitHub 文件加速镜像
+ * 用于加速 release assets 下载
+ * 按延迟排序,优先使用快速镜像
+ *
+ * 测试时间: 2026-01-03
+ * 镜像支持 301/302 重定向
+ * 懒加载测速:首次使用时自动测速,缓存 30 分钟
+ */
+export const GITHUB_FILE_MIRRORS = [
+ // 延迟 < 800ms 的最快镜像
+ 'https://github.chenc.dev/', // 666ms
+ 'https://ghproxy.cfd/', // 719ms - 支持重定向
+ 'https://github.tbedu.top/', // 760ms
+ 'https://ghps.cc/', // 768ms
+ 'https://gh.llkk.cc/', // 774ms
+ 'https://ghproxy.cc/', // 777ms
+ 'https://gh.monlor.com/', // 779ms
+ 'https://cdn.akaere.online/', // 784ms
+ // 延迟 800-1000ms 的快速镜像
+ 'https://gh.idayer.com/', // 869ms
+ 'https://gh-proxy.net/', // 885ms
+ 'https://ghpxy.hwinzniej.top/', // 890ms
+ 'https://github-proxy.memory-echoes.cn/', // 896ms
+ 'https://git.yylx.win/', // 917ms
+ 'https://gitproxy.mrhjx.cn/', // 950ms
+ 'https://jiashu.1win.eu.org/', // 954ms
+ 'https://ghproxy.cn/', // 981ms
+ // 延迟 1000-1500ms 的中速镜像
+ 'https://gh.fhjhy.top/', // 1014ms
+ 'https://gp.zkitefly.eu.org/', // 1015ms
+ 'https://gh-proxy.com/', // 1022ms
+ 'https://hub.gitmirror.com/', // 1027ms
+ 'https://ghfile.geekertao.top/', // 1029ms
+ 'https://j.1lin.dpdns.org/', // 1037ms
+ 'https://ghproxy.imciel.com/', // 1047ms
+ 'https://github-proxy.teach-english.tech/', // 1047ms
+ 'https://gh.927223.xyz/', // 1071ms
+ 'https://github.ednovas.xyz/', // 1099ms
+ 'https://ghf.xn--eqrr82bzpe.top/',// 1122ms
+ 'https://gh.dpik.top/', // 1131ms
+ 'https://gh.jasonzeng.dev/', // 1139ms
+ 'https://gh.xxooo.cf/', // 1157ms
+ 'https://gh.bugdey.us.kg/', // 1228ms
+ 'https://ghm.078465.xyz/', // 1289ms
+ 'https://j.1win.ggff.net/', // 1329ms
+ 'https://tvv.tw/', // 1393ms
+ 'https://gh.chjina.com/', // 1446ms
+ 'https://gitproxy.127731.xyz/', // 1458ms
+ // 延迟 1500-2500ms 的较慢镜像
+ 'https://gh.inkchills.cn/', // 1617ms
+ 'https://ghproxy.cxkpro.top/', // 1651ms
+ 'https://gh.sixyin.com/', // 1686ms
+ 'https://github.geekery.cn/', // 1734ms
+ 'https://git.669966.xyz/', // 1824ms
+ 'https://gh.5050net.cn/', // 1858ms
+ 'https://gh.felicity.ac.cn/', // 1903ms
+ 'https://gh.ddlc.top/', // 2056ms
+ 'https://cf.ghproxy.cc/', // 2058ms
+ 'https://gitproxy.click/', // 2068ms
+ 'https://github.dpik.top/', // 2313ms
+ 'https://gh.zwnes.xyz/', // 2434ms
+ 'https://ghp.keleyaa.com/', // 2440ms
+ 'https://gh.wsmdn.dpdns.org/', // 2744ms
+ // 延迟 > 2500ms 的慢速镜像(作为备用)
+ 'https://ghproxy.monkeyray.net/', // 3023ms
+ 'https://fastgit.cc/', // 3369ms
+ 'https://cdn.gh-proxy.com/', // 3394ms
+ 'https://gh.catmak.name/', // 4119ms
+ 'https://gh.noki.icu/', // 5990ms
+ '', // 原始 URL(无镜像)
+];
+
+/**
+ * GitHub API 镜像
+ * 用于访问 GitHub API(作为备选方案)
+ * 注:优先使用非 API 方法,减少对 API 的依赖
+ *
+ * 经测试,大部分代理镜像不支持 API 转发
+ * 建议使用 getLatestReleaseTag 等方法避免 API 调用
+ */
+export const GITHUB_API_MIRRORS = [
+ 'https://api.github.com',
+ // 目前没有可用的公共 API 代理镜像
+];
+
+/**
+ * GitHub Raw 镜像
+ * 用于访问 raw.githubusercontent.com
+ * 注:大多数通用代理也支持 raw 文件加速
+ */
+export const GITHUB_RAW_MIRRORS = [
+ 'https://raw.githubusercontent.com',
+ // 测试确认支持 raw 文件的镜像
+ 'https://github.chenc.dev/https://raw.githubusercontent.com',
+ 'https://ghproxy.cfd/https://raw.githubusercontent.com',
+ 'https://gh.llkk.cc/https://raw.githubusercontent.com',
+ 'https://ghproxy.cc/https://raw.githubusercontent.com',
+ 'https://gh-proxy.net/https://raw.githubusercontent.com',
+];
+
+// ============== 镜像配置接口 ==============
+
+export interface MirrorConfig {
+ /** 文件下载镜像(用于 release assets) */
+ fileMirrors: string[];
+ /** API 镜像 */
+ apiMirrors: string[];
+ /** Raw 文件镜像 */
+ rawMirrors: string[];
+ /** 超时时间(毫秒) */
+ timeout: number;
+ /** 是否启用镜像 */
+ enabled: boolean;
+ /** 自定义镜像(优先使用) */
+ customMirror?: string;
+}
+
+// ============== 默认配置 ==============
+
+const defaultConfig: MirrorConfig = {
+ fileMirrors: GITHUB_FILE_MIRRORS,
+ apiMirrors: GITHUB_API_MIRRORS,
+ rawMirrors: GITHUB_RAW_MIRRORS,
+ timeout: 10000, // 10秒超时,平衡速度和可靠性
+ enabled: true,
+ customMirror: undefined,
+};
+
+let currentConfig: MirrorConfig = { ...defaultConfig };
+
+// ============== 懒加载镜像测速缓存 ==============
+
+interface MirrorTestResult {
+ mirror: string;
+ latency: number;
+ success: boolean;
+}
+
+// 缓存的快速镜像列表(按延迟排序)
+let cachedFastMirrors: string[] | null = null;
+// 测速是否正在进行
+let mirrorTestingPromise: Promise | null = null;
+// 缓存过期时间(30分钟)
+const MIRROR_CACHE_TTL = 30 * 60 * 1000;
+let cacheTimestamp: number = 0;
+
+/**
+ * 测试单个镜像的延迟(使用 HEAD 请求测试实际文件)
+ * 测试一个小型的实际 release 文件,确保镜像支持文件下载
+ */
+async function testMirrorLatency (mirror: string, timeout: number = 5000): Promise {
+ // 使用一个实际存在的小文件来测试(README 或小型 release asset)
+ // 用 HEAD 请求,不下载实际内容
+ const testUrl = 'https://github.com/NapNeko/NapCatQQ/releases/latest';
+ const url = buildMirrorUrl(testUrl, mirror);
+ const start = Date.now();
+
+ return new Promise((resolve) => {
+ try {
+ const urlObj = new URL(url);
+ const isHttps = urlObj.protocol === 'https:';
+ const client = isHttps ? https : http;
+
+ const req = client.request({
+ hostname: urlObj.hostname,
+ port: urlObj.port || (isHttps ? 443 : 80),
+ path: urlObj.pathname + urlObj.search,
+ method: 'HEAD',
+ timeout,
+ headers: {
+ 'User-Agent': 'NapCat-Mirror-Test',
+ },
+ }, (res) => {
+ const statusCode = res.statusCode || 0;
+ // 2xx 或 3xx 都算成功(3xx 说明镜像工作正常,会重定向)
+ const isValid = statusCode >= 200 && statusCode < 400;
+ resolve({
+ mirror,
+ latency: Date.now() - start,
+ success: isValid,
+ });
+ });
+
+ req.on('error', () => {
+ resolve({
+ mirror,
+ latency: Infinity,
+ success: false,
+ });
+ });
+
+ req.on('timeout', () => {
+ req.destroy();
+ resolve({
+ mirror,
+ latency: Infinity,
+ success: false,
+ });
+ });
+
+ req.end();
+ } catch {
+ resolve({
+ mirror,
+ latency: Infinity,
+ success: false,
+ });
+ }
+ });
+}
+
+/**
+ * 懒加载获取快速镜像列表
+ * 第一次调用时会进行测速,后续使用缓存
+ */
+export async function getFastMirrors (forceRefresh: boolean = false): Promise {
+ // 检查缓存是否有效
+ const now = Date.now();
+ if (!forceRefresh && cachedFastMirrors && (now - cacheTimestamp) < MIRROR_CACHE_TTL) {
+ return cachedFastMirrors;
+ }
+
+ // 如果已经在测速中,等待结果
+ if (mirrorTestingPromise) {
+ return mirrorTestingPromise;
+ }
+
+ // 开始测速
+ mirrorTestingPromise = performMirrorTest();
+
+ try {
+ const result = await mirrorTestingPromise;
+ cachedFastMirrors = result;
+ cacheTimestamp = now;
+ return result;
+ } finally {
+ mirrorTestingPromise = null;
+ }
+}
+
+/**
+ * 执行镜像测速
+ * 并行测试所有镜像,返回按延迟排序的可用镜像列表
+ */
+async function performMirrorTest (): Promise {
+ // 开始镜像测速
+
+ const timeout = 8000; // 测速超时 8 秒
+
+ // 并行测试所有镜像
+ const mirrors = currentConfig.fileMirrors.filter(m => m);
+ const results = await Promise.all(
+ mirrors.map(m => testMirrorLatency(m, timeout))
+ );
+
+ // 过滤成功的镜像并按延迟排序
+ const successfulMirrors = results
+ .filter(r => r.success)
+ .sort((a, b) => a.latency - b.latency)
+ .map(r => r.mirror);
+
+
+
+ // 至少返回原始 URL
+ if (successfulMirrors.length === 0) {
+ return [''];
+ }
+
+ return successfulMirrors;
+}
+
+/**
+ * 清除镜像缓存,强制下次重新测速
+ */
+export function clearMirrorCache (): void {
+ cachedFastMirrors = null;
+ cacheTimestamp = 0;
+
+}
+
+/**
+ * 获取缓存状态
+ */
+export function getMirrorCacheStatus (): { cached: boolean; count: number; age: number; } {
+ return {
+ cached: cachedFastMirrors !== null,
+ count: cachedFastMirrors?.length ?? 0,
+ age: cachedFastMirrors ? Date.now() - cacheTimestamp : 0,
+ };
+}
+
+// ============== 配置管理 ==============
+
+/**
+ * 获取当前镜像配置
+ */
+export function getMirrorConfig (): MirrorConfig {
+ return { ...currentConfig };
+}
+
+/**
+ * 更新镜像配置
+ */
+export function setMirrorConfig (config: Partial): void {
+ currentConfig = { ...currentConfig, ...config };
+}
+
+/**
+ * 重置为默认配置
+ */
+export function resetMirrorConfig (): void {
+ currentConfig = { ...defaultConfig };
+}
+
+/**
+ * 设置自定义镜像(优先级最高)
+ */
+export function setCustomMirror (mirror: string): void {
+ currentConfig.customMirror = mirror;
+}
+
+// ============== URL 工具函数 ==============
+
+/**
+ * 构建镜像 URL
+ * @param originalUrl 原始 URL
+ * @param mirror 镜像前缀
+ */
+export function buildMirrorUrl (originalUrl: string, mirror: string): string {
+ if (!mirror) return originalUrl;
+ // 如果镜像已经包含完整域名,直接拼接
+ if (mirror.endsWith('/')) {
+ return mirror + originalUrl;
+ }
+ return mirror + '/' + originalUrl;
+}
+
+/**
+ * 测试 URL 是否可用(HTTP GET)
+ * @param url 要测试的 URL
+ * @param timeout 超时时间
+ */
+export async function testUrl (url: string, timeout: number = 5000): Promise {
+ try {
+ await PromiseTimer(RequestUtil.HttpGetText(url), timeout);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * 测试 URL 是否可用(HTTP HEAD,更快)
+ * 验证:状态码、Content-Type、Content-Length
+ */
+export async function testUrlHead (url: string, timeout: number = 5000): Promise {
+ return new Promise((resolve) => {
+ const urlObj = new URL(url);
+ const isHttps = urlObj.protocol === 'https:';
+ const client = isHttps ? https : http;
+
+ const req = client.request({
+ hostname: urlObj.hostname,
+ port: urlObj.port || (isHttps ? 443 : 80),
+ path: urlObj.pathname + urlObj.search,
+ method: 'HEAD',
+ timeout,
+ headers: {
+ 'User-Agent': 'NapCat-Mirror-Test',
+ },
+ }, (res) => {
+ const statusCode = res.statusCode || 0;
+ const contentType = (res.headers['content-type'] as string) || '';
+ const contentLength = parseInt((res.headers['content-length'] as string) || '0', 10);
+
+ // 验证条件:
+ // 1. 状态码 2xx 或 3xx
+ // 2. Content-Type 不应该是 text/html(表示错误页面)
+ // 3. 对于 .zip 文件,Content-Length 应该 > 1MB(避免获取到错误页面)
+ const isValidStatus = statusCode >= 200 && statusCode < 400;
+ const isNotHtmlError = !contentType.includes('text/html');
+ const isValidSize = url.endsWith('.zip') ? contentLength > 1024 * 1024 : true;
+
+ resolve(isValidStatus && isNotHtmlError && isValidSize);
+ });
+
+ req.on('error', () => resolve(false));
+ req.on('timeout', () => {
+ req.destroy();
+ resolve(false);
+ });
+ req.end();
+ });
+}
+
+/**
+ * 详细验证 URL 响应
+ * 返回验证结果和详细信息
+ */
+export interface UrlValidationResult {
+ valid: boolean;
+ statusCode?: number;
+ contentType?: string;
+ contentLength?: number;
+ error?: string;
+}
+
+export async function validateUrl (url: string, timeout: number = 5000): Promise {
+ return new Promise((resolve) => {
+ const urlObj = new URL(url);
+ const isHttps = urlObj.protocol === 'https:';
+ const client = isHttps ? https : http;
+
+ const req = client.request({
+ hostname: urlObj.hostname,
+ port: urlObj.port || (isHttps ? 443 : 80),
+ path: urlObj.pathname + urlObj.search,
+ method: 'HEAD',
+ timeout,
+ headers: {
+ 'User-Agent': 'NapCat-Mirror-Test',
+ },
+ }, (res) => {
+ const statusCode = res.statusCode || 0;
+ const contentType = (res.headers['content-type'] as string) || '';
+ const contentLength = parseInt((res.headers['content-length'] as string) || '0', 10);
+
+ // 验证条件
+ const isValidStatus = statusCode >= 200 && statusCode < 400;
+ const isNotHtmlError = !contentType.includes('text/html');
+ const isValidSize = url.endsWith('.zip') ? contentLength > 1024 * 1024 : true;
+
+ if (!isValidStatus) {
+ resolve({
+ valid: false,
+ statusCode,
+ contentType,
+ contentLength,
+ error: `HTTP ${statusCode}`,
+ });
+ } else if (!isNotHtmlError) {
+ resolve({
+ valid: false,
+ statusCode,
+ contentType,
+ contentLength,
+ error: '返回了 HTML 页面而非文件',
+ });
+ } else if (!isValidSize) {
+ resolve({
+ valid: false,
+ statusCode,
+ contentType,
+ contentLength,
+ error: `文件过小 (${contentLength} bytes),可能是错误页面`,
+ });
+ } else {
+ resolve({
+ valid: true,
+ statusCode,
+ contentType,
+ contentLength,
+ });
+ }
+ });
+
+ req.on('error', (e: Error) => resolve({
+ valid: false,
+ error: e.message,
+ }));
+ req.on('timeout', () => {
+ req.destroy();
+ resolve({
+ valid: false,
+ error: 'Timeout',
+ });
+ });
+ req.end();
+ });
+}
+
+// ============== 查找可用 URL ==============
+
+/**
+ * 查找可用的下载 URL
+ * 使用懒加载的快速镜像列表
+ * @param originalUrl 原始 GitHub URL
+ * @param options 选项
+ */
+export async function findAvailableDownloadUrl (
+ originalUrl: string,
+ options: {
+ mirrors?: string[];
+ timeout?: number;
+ customMirror?: string;
+ testMethod?: 'head' | 'get';
+ /** 是否使用详细验证(验证 Content-Type 和 Content-Length) */
+ validateContent?: boolean;
+ /** 期望的最小文件大小(字节),用于验证 */
+ minFileSize?: number;
+ /** 是否使用懒加载的快速镜像列表 */
+ useFastMirrors?: boolean;
+ } = {}
+): Promise {
+ const {
+ timeout = currentConfig.timeout,
+ customMirror = currentConfig.customMirror,
+ testMethod = 'head',
+ validateContent = true, // 默认启用内容验证
+ minFileSize,
+ useFastMirrors = true, // 默认使用快速镜像列表
+ } = options;
+
+ // 获取镜像列表
+ let mirrors = options.mirrors;
+ if (!mirrors) {
+ if (useFastMirrors) {
+ // 使用懒加载的快速镜像列表
+ mirrors = await getFastMirrors();
+ } else {
+ mirrors = currentConfig.fileMirrors;
+ }
+ }
+
+ // 使用增强验证或简单测试
+ const testWithValidation = async (url: string): Promise => {
+ if (validateContent) {
+ const result = await validateUrl(url, timeout);
+ // 额外检查文件大小
+ if (result.valid && minFileSize && result.contentLength && result.contentLength < minFileSize) {
+ return false;
+ }
+ return result.valid;
+ }
+ return testMethod === 'head' ? testUrlHead(url, timeout) : testUrl(url, timeout);
+ };
+
+ // 1. 如果设置了自定义镜像,优先使用
+ if (customMirror) {
+ const customUrl = buildMirrorUrl(originalUrl, customMirror);
+ if (await testWithValidation(customUrl)) {
+ return customUrl;
+ }
+ }
+
+ // 2. 先测试原始 URL
+ if (await testWithValidation(originalUrl)) {
+ return originalUrl;
+ }
+
+ // 3. 测试镜像源(已按延迟排序)
+ let testedCount = 0;
+ for (const mirror of mirrors) {
+ if (!mirror) continue; // 跳过空字符串
+ const mirrorUrl = buildMirrorUrl(originalUrl, mirror);
+ testedCount++;
+ if (await testWithValidation(mirrorUrl)) {
+ return mirrorUrl;
+ }
+ }
+
+ throw new Error(`所有下载源都不可用(已测试 ${testedCount} 个镜像),请检查网络连接或配置自定义镜像`);
+}
+
+// ============== 版本和 Release 相关(减少 API 依赖) ==============
+
+/**
+ * 语义化版本正则(简化版,用于排序)
+ */
+const SEMVER_REGEX = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-.]+))?(?:\+([0-9A-Za-z-.]+))?$/;
+
+/**
+ * 解析语义化版本号
+ */
+function parseSemVerSimple (version: string): { major: number; minor: number; patch: number; prerelease: string; } | null {
+ const match = version.match(SEMVER_REGEX);
+ if (!match) return null;
+ return {
+ major: parseInt(match[1] ?? '0', 10),
+ minor: parseInt(match[2] ?? '0', 10),
+ patch: parseInt(match[3] ?? '0', 10),
+ prerelease: match[4] || '',
+ };
+}
+
+/**
+ * 比较两个版本号
+ */
+function compareSemVerSimple (a: string, b: string): number {
+ const pa = parseSemVerSimple(a);
+ const pb = parseSemVerSimple(b);
+ if (!pa && !pb) return 0;
+ if (!pa) return -1;
+ if (!pb) return 1;
+
+ if (pa.major !== pb.major) return pa.major - pb.major;
+ if (pa.minor !== pb.minor) return pa.minor - pb.minor;
+ if (pa.patch !== pb.patch) return pa.patch - pb.patch;
+
+ // 预发布版本排在正式版本前面
+ if (pa.prerelease && !pb.prerelease) return -1;
+ if (!pa.prerelease && pb.prerelease) return 1;
+
+ return pa.prerelease.localeCompare(pb.prerelease);
+}
+
+/**
+ * 从 tags 列表中获取最新的 release tag
+ * 不依赖 GitHub API
+ */
+export async function getLatestReleaseTag (owner: string, repo: string): Promise {
+ const result = await getAllGitHubTags(owner, repo);
+
+ // 过滤出符合 semver 的 tags
+ const releaseTags = result.tags.filter(tag => SEMVER_REGEX.test(tag));
+
+ if (releaseTags.length === 0) {
+ throw new Error('未找到有效的 release tag');
+ }
+
+ // 按版本号排序,取最新的
+ releaseTags.sort(compareSemVerSimple);
+ const latest = releaseTags[releaseTags.length - 1];
+
+ if (!latest) {
+ throw new Error('未找到有效的 release tag');
+ }
+
+ return latest;
+}
+
+/**
+ * 直接构建 GitHub release 下载 URL
+ * 不需要调用 API,直接基于 tag 和 asset 名称构建
+ */
+export function buildReleaseDownloadUrl (
+ owner: string,
+ repo: string,
+ tag: string,
+ assetName: string
+): string {
+ return `https://github.com/${owner}/${repo}/releases/download/${tag}/${assetName}`;
+}
+
+/**
+ * 获取 GitHub release 信息(优先使用非 API 方法)
+ *
+ * 策略:
+ * 1. 先通过 git refs 获取 tags
+ * 2. 直接构建下载 URL,不依赖 API
+ * 3. 仅当需要 changelog 时才使用 API
+ */
+export async function getGitHubRelease (
+ owner: string,
+ repo: string,
+ tag: string = 'latest',
+ options: {
+ /** 需要获取的 asset 名称列表 */
+ assetNames?: string[];
+ /** 是否需要获取 changelog(需要调用 API) */
+ fetchChangelog?: boolean;
+ } = {}
+): Promise<{
+ tag_name: string;
+ assets: Array<{
+ name: string;
+ browser_download_url: string;
+ }>;
+ body?: string;
+}> {
+ const { assetNames = [], fetchChangelog = false } = options;
+
+ // 1. 获取实际的 tag 名称
+ let actualTag: string;
+ if (tag === 'latest') {
+ actualTag = await getLatestReleaseTag(owner, repo);
+ } else {
+ actualTag = tag;
+ }
+
+ // 2. 构建 assets 列表(不需要 API)
+ const assets = assetNames.map(name => ({
+ name,
+ browser_download_url: buildReleaseDownloadUrl(owner, repo, actualTag, name),
+ }));
+
+ // 3. 如果不需要 changelog 且有 assetNames,直接返回
+ if (!fetchChangelog && assetNames.length > 0) {
+ return {
+ tag_name: actualTag,
+ assets,
+ body: undefined,
+ };
+ }
+
+ // 4. 需要更多信息时,尝试调用 API(作为备选)
+ const endpoint = `https://api.github.com/repos/${owner}/${repo}/releases/tags/${actualTag}`;
+
+ for (const apiBase of currentConfig.apiMirrors) {
+ try {
+ const url = endpoint.replace('https://api.github.com', apiBase);
+ const response = await PromiseTimer(
+ RequestUtil.HttpGetJson(url, 'GET', undefined, {
+ 'User-Agent': 'NapCat',
+ 'Accept': 'application/vnd.github.v3+json',
+ }),
+ currentConfig.timeout
+ );
+ return response;
+ } catch {
+ continue;
+ }
+ }
+
+ // 5. API 全部失败,但如果有 assetNames,仍然返回构建的 URL
+ if (assetNames.length > 0) {
+ return {
+ tag_name: actualTag,
+ assets,
+ body: undefined,
+ };
+ }
+
+ throw new Error('无法获取 release 信息,所有 API 源都不可用');
+}
+
+// ============== Tags 缓存 ==============
+
+interface TagsCache {
+ tags: string[];
+ mirror: string;
+ timestamp: number;
+}
+
+// 缓存 tags 结果(5 分钟有效)
+const TAGS_CACHE_TTL = 5 * 60 * 1000;
+const tagsCache: Map = new Map();
+
+/**
+ * 获取所有 GitHub tags(带缓存)
+ * 使用懒加载的快速镜像列表,按测速延迟排序依次尝试
+ */
+export async function getAllGitHubTags (owner: string, repo: string): Promise<{ tags: string[], mirror: string; }> {
+ const cacheKey = `${owner}/${repo}`;
+
+ // 检查缓存
+ const cached = tagsCache.get(cacheKey);
+ if (cached && (Date.now() - cached.timestamp) < TAGS_CACHE_TTL) {
+ return { tags: cached.tags, mirror: cached.mirror };
+ }
+
+ const baseUrl = `https://github.com/${owner}/${repo}.git/info/refs?service=git-upload-pack`;
+
+ // 解析 tags 的辅助函数
+ const parseTags = (raw: string): string[] => {
+ return raw
+ .split('\n')
+ .map((line: string) => {
+ const match = line.match(/refs\/tags\/(.+)$/);
+ return match ? match[1] : undefined;
+ })
+ .filter((tag): tag is string => tag !== undefined && !tag.endsWith('^{}'));
+ };
+
+ // 尝试从 URL 获取 tags
+ const fetchFromUrl = async (url: string): Promise => {
+ try {
+ const raw = await PromiseTimer(
+ RequestUtil.HttpGetText(url),
+ currentConfig.timeout
+ );
+
+ // 检查返回内容是否有效(不是 HTML 错误页面)
+ if (raw.includes(' 0) {
+ return tags;
+ }
+ return null;
+ } catch {
+ return null;
+ }
+ };
+
+ // 获取快速镜像列表(懒加载,首次调用会测速,已按延迟排序)
+ let fastMirrors: string[] = [];
+ try {
+ fastMirrors = await getFastMirrors();
+ } catch (e) {
+ // 忽略错误,继续使用空列表
+ }
+
+ // 构建 URL 列表(快速镜像 + 原始 URL)
+ const mirrorUrls = fastMirrors.filter(m => m).map(m => ({ url: buildMirrorUrl(baseUrl, m), mirror: m }));
+ mirrorUrls.push({ url: baseUrl, mirror: 'github.com' }); // 添加原始 URL
+
+ // 按顺序尝试每个镜像(已按延迟排序),成功即返回
+ for (const { url, mirror } of mirrorUrls) {
+ const tags = await fetchFromUrl(url);
+ if (tags && tags.length > 0) {
+ // 缓存结果
+ tagsCache.set(cacheKey, { tags, mirror, timestamp: Date.now() });
+ return { tags, mirror };
+ }
+ }
+
+ // 如果快速镜像都失败,回退到原始镜像列表
+ const allMirrors = currentConfig.fileMirrors.filter(m => m);
+ for (const mirror of allMirrors) {
+ // 跳过已经尝试过的镜像
+ if (fastMirrors.includes(mirror)) continue;
+
+ const url = buildMirrorUrl(baseUrl, mirror);
+ const tags = await fetchFromUrl(url);
+ if (tags && tags.length > 0) {
+ // 缓存结果
+ tagsCache.set(cacheKey, { tags, mirror, timestamp: Date.now() });
+ return { tags, mirror };
+ }
+ }
+
+ throw new Error('无法获取 tags,所有源都不可用');
+}
+
+// ============== Action Artifacts 支持 ==============
+
+export interface ActionArtifact {
+ id: number;
+ name: string;
+ size_in_bytes: number;
+ created_at: string;
+ expires_at: string;
+ archive_download_url: string;
+}
+
+/**
+ * 获取 GitHub Action 最新运行的 artifacts
+ * 用于下载 nightly/dev 版本
+ */
+export async function getLatestActionArtifacts (
+ owner: string,
+ repo: string,
+ workflow: string = 'build.yml',
+ branch: string = 'main'
+): Promise {
+ const endpoint = `https://api.github.com/repos/${owner}/${repo}/actions/workflows/${workflow}/runs?branch=${branch}&status=success&per_page=1`;
+
+ try {
+ const runsResponse = await RequestUtil.HttpGetJson<{
+ workflow_runs: Array<{ id: number; }>;
+ }>(endpoint, 'GET', undefined, {
+ 'User-Agent': 'NapCat',
+ 'Accept': 'application/vnd.github.v3+json',
+ });
+
+ const workflowRuns = runsResponse.workflow_runs;
+ if (!workflowRuns || workflowRuns.length === 0) {
+ throw new Error('No successful workflow runs found');
+ }
+
+ const firstRun = workflowRuns[0];
+ if (!firstRun) {
+ throw new Error('No workflow run found');
+ }
+ const runId = firstRun.id;
+ const artifactsEndpoint = `https://api.github.com/repos/${owner}/${repo}/actions/runs/${runId}/artifacts`;
+
+ const artifactsResponse = await RequestUtil.HttpGetJson<{
+ artifacts: ActionArtifact[];
+ }>(artifactsEndpoint, 'GET', undefined, {
+ 'User-Agent': 'NapCat',
+ 'Accept': 'application/vnd.github.v3+json',
+ });
+
+ return artifactsResponse.artifacts || [];
+ } catch {
+ return [];
+ }
+}
diff --git a/packages/napcat-common/src/request.ts b/packages/napcat-common/src/request.ts
index 7deed22f..3383f729 100644
--- a/packages/napcat-common/src/request.ts
+++ b/packages/napcat-common/src/request.ts
@@ -3,11 +3,11 @@ import http from 'node:http';
export class RequestUtil {
// 适用于获取服务器下发cookies时获取,仅GET
- static async HttpsGetCookies (url: string): Promise<{ [key: string]: string }> {
+ static async HttpsGetCookies (url: string): Promise<{ [key: string]: string; }> {
const client = url.startsWith('https') ? https : http;
return new Promise((resolve, reject) => {
const req = client.get(url, (res) => {
- const cookies: { [key: string]: string } = {};
+ const cookies: { [key: string]: string; } = {};
res.on('data', () => { }); // Necessary to consume the stream
res.on('end', () => {
@@ -27,7 +27,7 @@ export class RequestUtil {
});
}
- private static async handleRedirect (res: http.IncomingMessage, url: string, cookies: { [key: string]: string }): Promise<{ [key: string]: string }> {
+ private static async handleRedirect (res: http.IncomingMessage, url: string, cookies: { [key: string]: string; }): Promise<{ [key: string]: string; }> {
if (res.statusCode === 301 || res.statusCode === 302) {
if (res.headers.location) {
const redirectUrl = new URL(res.headers.location, url);
@@ -39,7 +39,7 @@ export class RequestUtil {
return cookies;
}
- private static extractCookies (setCookieHeaders: string[], cookies: { [key: string]: string }) {
+ private static extractCookies (setCookieHeaders: string[], cookies: { [key: string]: string; }) {
setCookieHeaders.forEach((cookie) => {
const parts = cookie.split(';')[0]?.split('=');
if (parts) {
@@ -53,9 +53,10 @@ export class RequestUtil {
}
// 请求和回复都是JSON data传原始内容 自动编码json
- static async HttpGetJson(url: string, method: string = 'GET', data?: any, headers: {
- [key: string]: string
- } = {}, isJsonRet: boolean = true, isArgJson: boolean = true): Promise {
+ // 支持 301/302 重定向(最多 5 次)
+ static async HttpGetJson (url: string, method: string = 'GET', data?: any, headers: {
+ [key: string]: string;
+ } = {}, isJsonRet: boolean = true, isArgJson: boolean = true, maxRedirects: number = 5): Promise {
const option = new URL(url);
const protocol = url.startsWith('https://') ? https : http;
const options = {
@@ -71,6 +72,20 @@ export class RequestUtil {
// },
return new Promise((resolve, reject) => {
const req = protocol.request(options, (res: http.IncomingMessage) => {
+ // 处理重定向
+ if ((res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307 || res.statusCode === 308) && res.headers.location) {
+ if (maxRedirects <= 0) {
+ reject(new Error('Too many redirects'));
+ return;
+ }
+ const redirectUrl = new URL(res.headers.location, url).href;
+ // 递归跟随重定向
+ this.HttpGetJson(redirectUrl, method, data, headers, isJsonRet, isArgJson, maxRedirects - 1)
+ .then(resolve)
+ .catch(reject);
+ return;
+ }
+
let responseBody = '';
res.on('data', (chunk: string | Buffer) => {
responseBody += chunk.toString();
@@ -109,7 +124,7 @@ export class RequestUtil {
}
// 请求返回都是原始内容
- static async HttpGetText (url: string, method: string = 'GET', data?: any, headers: { [key: string]: string } = {}) {
+ static async HttpGetText (url: string, method: string = 'GET', data?: any, headers: { [key: string]: string; } = {}) {
return this.HttpGetJson(url, method, data, headers, false, false);
}
}
diff --git a/packages/napcat-common/src/version.ts b/packages/napcat-common/src/version.ts
index e798ba8a..5dd34e8f 100644
--- a/packages/napcat-common/src/version.ts
+++ b/packages/napcat-common/src/version.ts
@@ -1,2 +1,118 @@
// @ts-ignore
-export const napCatVersion = (typeof import.meta?.env !== 'undefined' && import.meta.env.VITE_NAPCAT_VERSION) || 'alpha';
+export const napCatVersion = (typeof import.meta?.env !== 'undefined' && import.meta.env.VITE_NAPCAT_VERSION) || '1.0.0-dev';
+
+/**
+ * SemVer 2.0 正则表达式
+ * 格式: 主版本号.次版本号.修订号[-先行版本号][+版本编译信息]
+ * 参考: https://semver.org/lang/zh-CN/
+ */
+const SEMVER_REGEX = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
+
+export interface SemVerInfo {
+ valid: boolean;
+ normalized: string;
+ major: number;
+ minor: number;
+ patch: number;
+ prerelease: string | null;
+ buildmetadata: string | null;
+}
+
+/**
+ * 解析并验证版本号是否符合 SemVer 2.0 规范
+ * @param version - 版本字符串 (支持 v 前缀)
+ * @returns SemVer 解析结果
+ */
+export function parseSemVer (version: string | undefined | null): SemVerInfo {
+ if (!version || typeof version !== 'string') {
+ return { valid: false, normalized: '1.0.0-dev', major: 1, minor: 0, patch: 0, prerelease: 'dev', buildmetadata: null };
+ }
+
+ const match = version.trim().match(SEMVER_REGEX);
+ if (match) {
+ const major = parseInt(match[1]!, 10);
+ const minor = parseInt(match[2]!, 10);
+ const patch = parseInt(match[3]!, 10);
+ const prerelease = match[4] || null;
+ const buildmetadata = match[5] || null;
+
+ // 构建标准化版本号(不带 v 前缀)
+ let normalized = `${major}.${minor}.${patch}`;
+ if (prerelease) normalized += `-${prerelease}`;
+ if (buildmetadata) normalized += `+${buildmetadata}`;
+
+ return { valid: true, normalized, major, minor, patch, prerelease, buildmetadata };
+ }
+ return { valid: false, normalized: '1.0.0-dev', major: 1, minor: 0, patch: 0, prerelease: 'dev', buildmetadata: null };
+}
+
+/**
+ * 验证版本号是否符合 SemVer 2.0 规范
+ * @param version - 版本字符串
+ * @returns 是否有效
+ */
+export function isValidSemVer (version: string | undefined | null): boolean {
+ return parseSemVer(version).valid;
+}
+
+/**
+ * 比较两个 SemVer 版本号
+ * @param v1 - 版本号1
+ * @param v2 - 版本号2
+ * @returns -1 (v1 < v2), 0 (v1 == v2), 1 (v1 > v2)
+ */
+export function compareSemVer (v1: string, v2: string): -1 | 0 | 1 {
+ const a = parseSemVer(v1);
+ const b = parseSemVer(v2);
+
+ if (!a.valid || !b.valid) {
+ return 0;
+ }
+
+ // 比较主版本号
+ if (a.major !== b.major) return a.major > b.major ? 1 : -1;
+ // 比较次版本号
+ if (a.minor !== b.minor) return a.minor > b.minor ? 1 : -1;
+ // 比较修订号
+ if (a.patch !== b.patch) return a.patch > b.patch ? 1 : -1;
+
+ // 有先行版本号的版本优先级较低
+ if (a.prerelease && !b.prerelease) return -1;
+ if (!a.prerelease && b.prerelease) return 1;
+
+ // 两者都有先行版本号时,按字典序比较
+ if (a.prerelease && b.prerelease) {
+ const aParts = a.prerelease.split('.');
+ const bParts = b.prerelease.split('.');
+ const len = Math.max(aParts.length, bParts.length);
+
+ for (let i = 0; i < len; i++) {
+ const aPart = aParts[i];
+ const bPart = bParts[i];
+
+ if (aPart === undefined) return -1;
+ if (bPart === undefined) return 1;
+
+ const aNum = /^\d+$/.test(aPart) ? parseInt(aPart, 10) : NaN;
+ const bNum = /^\d+$/.test(bPart) ? parseInt(bPart, 10) : NaN;
+
+ // 数字 vs 数字
+ if (!isNaN(aNum) && !isNaN(bNum)) {
+ if (aNum !== bNum) return aNum > bNum ? 1 : -1;
+ continue;
+ }
+ // 数字优先级低于字符串
+ if (!isNaN(aNum)) return -1;
+ if (!isNaN(bNum)) return 1;
+ // 字符串 vs 字符串
+ if (aPart !== bPart) return aPart > bPart ? 1 : -1;
+ }
+ }
+
+ return 0;
+}
+
+/**
+ * 获取解析后的当前版本信息
+ */
+export const napCatVersionInfo = parseSemVer(napCatVersion);
diff --git a/packages/napcat-vite/vite-plugin-version.js b/packages/napcat-vite/vite-plugin-version.js
index 2091d5dd..4136500f 100644
--- a/packages/napcat-vite/vite-plugin-version.js
+++ b/packages/napcat-vite/vite-plugin-version.js
@@ -6,8 +6,49 @@ import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
+/**
+ * SemVer 2.0 正则表达式
+ * 格式: 主版本号.次版本号.修订号[-先行版本号][+版本编译信息]
+ * 参考: https://semver.org/lang/zh-CN/
+ */
+const SEMVER_REGEX = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
+
+/**
+ * Validate version format according to SemVer 2.0 specification
+ * @param {string} version - The version string to validate (with or without 'v' prefix)
+ * @returns {{ valid: boolean, normalized: string, major: number, minor: number, patch: number, prerelease: string|null, buildmetadata: string|null }}
+ */
+function validateVersion (version) {
+ if (!version || typeof version !== 'string') {
+ return { valid: false, normalized: '1.0.0-dev', major: 1, minor: 0, patch: 0, prerelease: 'dev', buildmetadata: null };
+ }
+
+ const match = version.trim().match(SEMVER_REGEX);
+ if (match) {
+ const major = parseInt(match[1], 10);
+ const minor = parseInt(match[2], 10);
+ const patch = parseInt(match[3], 10);
+ const prerelease = match[4] || null;
+ const buildmetadata = match[5] || null;
+
+ // 构建标准化版本号(不带 v 前缀)
+ let normalized = `${major}.${minor}.${patch}`;
+ if (prerelease) normalized += `-${prerelease}`;
+ if (buildmetadata) normalized += `+${buildmetadata}`;
+
+ return { valid: true, normalized, major, minor, patch, prerelease, buildmetadata };
+ }
+ return { valid: false, normalized: '1.0.0-dev', major: 1, minor: 0, patch: 0, prerelease: 'dev', buildmetadata: null };
+}
+
/**
* NapCat Vite Plugin: fetches latest GitHub tag (not release) and injects into import.meta.env
+ *
+ * 版本号来源优先级:
+ * 1. 环境变量 NAPCAT_VERSION (用于 CI 构建)
+ * 2. 缓存的 GitHub tag
+ * 3. 从 GitHub API 获取最新 tag
+ * 4. 兆底版本号: 1.0.0-dev
*/
export default function vitePluginNapcatVersion () {
const pluginDir = path.resolve(__dirname, 'dist');
@@ -16,6 +57,9 @@ export default function vitePluginNapcatVersion () {
const repo = 'NapCatQQ';
const maxAgeMs = 24 * 60 * 60 * 1000; // cache 1 day
const githubToken = process.env.GITHUB_TOKEN;
+ // CI 构建时可通过环境变量直接指定版本号
+ const envVersion = process.env.NAPCAT_VERSION;
+ const fallbackVersion = '1.0.0-dev';
fs.mkdirSync(pluginDir, { recursive: true });
@@ -58,7 +102,14 @@ export default function vitePluginNapcatVersion () {
try {
const json = JSON.parse(data);
if (Array.isArray(json) && json[0]?.name) {
- resolve(json[0].name.replace(/^v/, ''));
+ const tagName = json[0].name;
+ const { valid, normalized } = validateVersion(tagName);
+ if (valid) {
+ resolve(normalized);
+ } else {
+ console.warn(`[vite-plugin-napcat-version] Invalid tag format: ${tagName}, expected vX.X.X`);
+ reject(new Error(`Invalid tag format: ${tagName}, expected vX.X.X`));
+ }
} else reject(new Error('Invalid GitHub tag response'));
} catch (e) {
reject(e);
@@ -71,6 +122,17 @@ export default function vitePluginNapcatVersion () {
}
async function getVersion () {
+ // 优先使用环境变量指定的版本号 (CI 构建)
+ if (envVersion) {
+ const { valid, normalized } = validateVersion(envVersion);
+ if (valid) {
+ console.log(`[vite-plugin-napcat-version] Using version from NAPCAT_VERSION env: ${normalized}`);
+ return normalized;
+ } else {
+ console.warn(`[vite-plugin-napcat-version] Invalid NAPCAT_VERSION format: ${envVersion}, falling back to fetch`);
+ }
+ }
+
const cached = readCache();
if (cached) return cached;
try {
@@ -79,7 +141,7 @@ export default function vitePluginNapcatVersion () {
return tag;
} catch (e) {
console.warn('[vite-plugin-napcat-version] Failed to fetch tag:', e.message);
- return cached ?? '0.0.0';
+ return cached ?? fallbackVersion;
}
}
@@ -115,3 +177,6 @@ export default function vitePluginNapcatVersion () {
},
};
}
+
+// Export validateVersion for external use
+export { validateVersion };
diff --git a/packages/napcat-webui-backend/src/api/BaseInfo.ts b/packages/napcat-webui-backend/src/api/BaseInfo.ts
index 11e5bfd6..6b4f2282 100644
--- a/packages/napcat-webui-backend/src/api/BaseInfo.ts
+++ b/packages/napcat-webui-backend/src/api/BaseInfo.ts
@@ -3,7 +3,8 @@ import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
import { sendSuccess } from '@/napcat-webui-backend/src/utils/response';
import { WebUiConfig } from '@/napcat-webui-backend/index';
-import { getLatestTag } from 'napcat-common/src/helper';
+import { getLatestTag, getAllTags, compareSemVer } from 'napcat-common/src/helper';
+import { getLatestActionArtifacts } from '@/napcat-common/src/mirror';
export const GetNapCatVersion: RequestHandler = (_, res) => {
const data = WebUiDataRuntime.GetNapCatVersion();
@@ -15,7 +16,121 @@ export const getLatestTagHandler: RequestHandler = async (_, res) => {
const latestTag = await getLatestTag();
sendSuccess(res, latestTag);
} catch (error) {
- res.status(500).json({ error: 'Failed to fetch latest tag' });
+ res.status(500).json({ error: 'Failed to fetch latest tag', details: (error as Error).message });
+ }
+};
+
+/**
+ * 版本信息接口
+ */
+export interface VersionInfo {
+ tag: string;
+ type: 'release' | 'prerelease' | 'action';
+ /** Action artifact 专用字段 */
+ artifactId?: number;
+ artifactName?: string;
+ createdAt?: string;
+ expiresAt?: string;
+ size?: number;
+}
+
+/**
+ * 获取所有可用的版本(release + action artifacts)
+ * 支持分页
+ */
+export const getAllReleasesHandler: RequestHandler = async (req, res) => {
+ try {
+ const page = parseInt(req.query['page'] as string) || 1;
+ const pageSize = parseInt(req.query['pageSize'] as string) || 20;
+ const includeActions = req.query['includeActions'] !== 'false';
+ const typeFilter = req.query['type'] as string | undefined; // 'release' | 'action' | 'all'
+ const searchQuery = (req.query['search'] as string || '').toLowerCase().trim();
+
+ let tags: string[] = [];
+ let usedMirror = '';
+ try {
+ const result = await getAllTags();
+ tags = result.tags;
+ usedMirror = result.mirror;
+ } catch {
+ // 如果获取 tags 失败,返回空列表而不是抛出错误
+ tags = [];
+ }
+
+ // 解析版本信息
+ const versions: VersionInfo[] = tags.map(tag => {
+ // 检查是否是预发布版本
+ const isPrerelease = /-(alpha|beta|rc|dev|pre|snapshot)/i.test(tag);
+ return {
+ tag,
+ type: isPrerelease ? 'prerelease' : 'release',
+ };
+ });
+
+ // 使用语义化版本排序(最新的在前)
+ versions.sort((a, b) => -compareSemVer(a.tag, b.tag));
+
+ // 获取 Action Artifacts(如果请求)
+ let actionVersions: VersionInfo[] = [];
+ if (includeActions) {
+ try {
+ const artifacts = await getLatestActionArtifacts('NapNeko', 'NapCatQQ', 'build.yml', 'main');
+ actionVersions = artifacts
+ .filter(a => a.name.includes('NapCat'))
+ .map(a => ({
+ tag: `action-${a.id}`,
+ type: 'action' as const,
+ artifactId: a.id,
+ artifactName: a.name,
+ createdAt: a.created_at,
+ expiresAt: a.expires_at,
+ size: a.size_in_bytes,
+ }));
+ } catch {
+ // 忽略 action artifacts 获取失败
+ }
+ }
+
+ // 合并版本列表(action 在最前面)
+ let allVersions = [...actionVersions, ...versions];
+
+ // 按类型过滤
+ if (typeFilter && typeFilter !== 'all') {
+ if (typeFilter === 'release') {
+ allVersions = allVersions.filter(v => v.type === 'release' || v.type === 'prerelease');
+ } else if (typeFilter === 'action') {
+ allVersions = allVersions.filter(v => v.type === 'action');
+ }
+ }
+
+ // 搜索过滤
+ if (searchQuery) {
+ allVersions = allVersions.filter(v => {
+ const tagMatch = v.tag.toLowerCase().includes(searchQuery);
+ const nameMatch = v.artifactName?.toLowerCase().includes(searchQuery);
+ return tagMatch || nameMatch;
+ });
+ }
+
+ // 分页
+ const total = allVersions.length;
+ const totalPages = Math.ceil(total / pageSize);
+ const start = (page - 1) * pageSize;
+ const end = start + pageSize;
+ const paginatedVersions = allVersions.slice(start, end);
+
+ sendSuccess(res, {
+ versions: paginatedVersions,
+ pagination: {
+ page,
+ pageSize,
+ total,
+ totalPages,
+ },
+ mirror: usedMirror
+ });
+ } catch (error) {
+ res.status(500).json({ error: 'Failed to fetch releases' });
}
};
diff --git a/packages/napcat-webui-backend/src/api/UpdateNapCat.ts b/packages/napcat-webui-backend/src/api/UpdateNapCat.ts
index 24f4d192..8dc358bd 100644
--- a/packages/napcat-webui-backend/src/api/UpdateNapCat.ts
+++ b/packages/napcat-webui-backend/src/api/UpdateNapCat.ts
@@ -8,14 +8,17 @@ import { webUiPathWrapper } from '../../index';
import { NapCatPathWrapper } from '@/napcat-common/src/path';
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
import { NapCatCoreWorkingEnv } from '@/napcat-webui-backend/src/types';
+import {
+ getGitHubRelease,
+ findAvailableDownloadUrl
+} from '@/napcat-common/src/mirror';
-interface Release {
- tag_name: string;
- assets: Array<{
- name: string;
- browser_download_url: string;
- }>;
- body?: string;
+// 更新请求体接口
+interface UpdateRequestBody {
+ /** 要更新到的版本 tag,如 "v4.9.9",不传则更新到最新版本 */
+ targetVersion?: string;
+ /** 是否强制更新(即使是降级也更新) */
+ force?: boolean;
}
// 更新配置文件接口
@@ -69,74 +72,7 @@ function scanFilesRecursively (dirPath: string, basePath: string = dirPath): Arr
return files;
}
-// 镜像源列表(参考ffmpeg下载实现)
-const mirrorUrls = [
- 'https://j.1win.ggff.net/',
- 'https://git.yylx.win/',
- 'https://ghfile.geekertao.top/',
- 'https://gh-proxy.net/',
- 'https://ghm.078465.xyz/',
- 'https://gitproxy.127731.xyz/',
- 'https://jiashu.1win.eu.org/',
- '', // 原始URL
-];
-
-/**
- * 测试URL是否可用
- */
-async function testUrl (url: string): Promise {
- return new Promise((resolve) => {
- const req = https.get(url, { timeout: 5000 }, (res) => {
- const statusCode = res.statusCode || 0;
- if (statusCode >= 200 && statusCode < 300) {
- req.destroy();
- resolve(true);
- } else {
- req.destroy();
- resolve(false);
- }
- });
-
- req.on('error', () => resolve(false));
- req.on('timeout', () => {
- req.destroy();
- resolve(false);
- });
- });
-}
-
-/**
- * 构建镜像URL
- */
-function buildMirrorUrl (originalUrl: string, mirror: string): string {
- if (!mirror) return originalUrl;
- return mirror + originalUrl;
-}
-
-/**
- * 查找可用的下载URL
- */
-async function findAvailableUrl (originalUrl: string): Promise {
- console.log('Testing download URLs...');
-
- // 先测试原始URL
- if (await testUrl(originalUrl)) {
- console.log('Using original URL:', originalUrl);
- return originalUrl;
- }
-
- // 测试镜像源
- for (const mirror of mirrorUrls) {
- const mirrorUrl = buildMirrorUrl(originalUrl, mirror);
- console.log('Testing mirror:', mirrorUrl);
- if (await testUrl(mirrorUrl)) {
- console.log('Using mirror URL:', mirrorUrl);
- return mirrorUrl;
- }
- }
-
- throw new Error('所有下载源都不可用');
-}
+// 注:镜像配置已迁移到 @/napcat-common/src/mirror 模块统一管理
/**
* 下载文件(带进度和重试)
@@ -184,24 +120,73 @@ async function downloadFile (url: string, dest: string): Promise {
});
}
-export const UpdateNapCatHandler: RequestHandler = async (_req, res) => {
+export const UpdateNapCatHandler: RequestHandler = async (req, res) => {
try {
- // 获取最新release信息
- const latestRelease = await getLatestRelease() as Release;
+ // 从请求体获取目标版本(可选)
+ const { targetVersion, force } = req.body as UpdateRequestBody;
+
+ // 确定要下载的文件名
const ReleaseName = WebUiDataRuntime.getWorkingEnv() === NapCatCoreWorkingEnv.Framework ? 'NapCat.Framework.zip' : 'NapCat.Shell.zip';
- const shellZipAsset = latestRelease.assets.find(asset => asset.name === ReleaseName);
+
+ // 确定目标版本 tag
+ // 如果指定了版本,使用指定版本;否则使用 'latest'
+ const targetTag = targetVersion || 'latest';
+ console.log(`[NapCat Update] Target version: ${targetTag}`);
+
+ // 使用 mirror 模块获取 release 信息(不依赖 API)
+ // 通过 assetNames 参数直接构建下载 URL,避免调用 GitHub API
+ const release = await getGitHubRelease('NapNeko', 'NapCatQQ', targetTag, {
+ assetNames: [ReleaseName, 'NapCat.Framework.zip', 'NapCat.Shell.zip'],
+ fetchChangelog: false, // 不需要 changelog,避免 API 调用
+ });
+
+ const shellZipAsset = release.assets.find(asset => asset.name === ReleaseName);
if (!shellZipAsset) {
throw new Error(`未找到${ReleaseName}文件`);
}
+ // 检查是否需要强制更新(降级警告)
+ const currentVersion = WebUiDataRuntime.GetNapCatVersion();
+ console.log(`[NapCat Update] Current version: ${currentVersion}, Target version: ${release.tag_name}`);
+
+ if (!force && currentVersion) {
+ // 简单的版本比较(可选的降级保护)
+ const parseVersion = (v: string): [number, number, number] => {
+ const match = v.match(/^v?(\d+)\.(\d+)\.(\d+)/);
+ if (!match) return [0, 0, 0];
+ return [parseInt(match[1] || '0'), parseInt(match[2] || '0'), parseInt(match[3] || '0')];
+ };
+ const [currMajor, currMinor, currPatch] = parseVersion(currentVersion);
+ const [targetMajor, targetMinor, targetPatch] = parseVersion(release.tag_name);
+
+ const isDowngrade =
+ targetMajor < currMajor ||
+ (targetMajor === currMajor && targetMinor < currMinor) ||
+ (targetMajor === currMajor && targetMinor === currMinor && targetPatch < currPatch);
+
+ if (isDowngrade) {
+ console.log(`[NapCat Update] Downgrade from ${currentVersion} to ${release.tag_name}, force=${force}`);
+ // 不阻止降级,只是记录日志
+ }
+ }
+
+ console.log(`[NapCat Update] Updating to version: ${release.tag_name}`);
+
// 创建临时目录
const tempDir = path.join(webUiPathWrapper.binaryPath, './temp');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
- // 查找可用的下载URL
- const downloadUrl = await findAvailableUrl(shellZipAsset.browser_download_url);
+ // 使用 mirror 模块查找可用的下载 URL
+ // 启用内容验证,确保返回的是有效文件而非错误页面
+ const downloadUrl = await findAvailableDownloadUrl(shellZipAsset.browser_download_url, {
+ validateContent: true, // 验证 Content-Type 和状态码
+ minFileSize: 1024 * 1024, // 最小 1MB,确保不是错误页面
+ timeout: 10000, // 10秒超时
+ });
+
+ console.log(`[NapCat Update] Using download URL: ${downloadUrl}`);
// 下载zip
const zipPath = path.join(tempDir, 'napcat-latest.zip');
@@ -264,10 +249,10 @@ export const UpdateNapCatHandler: RequestHandler = async (_req, res) => {
// 如果有替换失败的文件,创建更新配置文件
if (failedFiles.length > 0) {
const updateConfig: UpdateConfig = {
- version: latestRelease.tag_name,
+ version: release.tag_name,
updateTime: new Date().toISOString(),
files: failedFiles,
- changelog: latestRelease.body || ''
+ changelog: release.body || ''
};
// 保存更新配置文件
@@ -283,7 +268,7 @@ export const UpdateNapCatHandler: RequestHandler = async (_req, res) => {
sendSuccess(res, {
status: 'completed',
message,
- newVersion: latestRelease.tag_name,
+ newVersion: release.tag_name,
failedFilesCount: failedFiles.length
});
@@ -298,28 +283,7 @@ export const UpdateNapCatHandler: RequestHandler = async (_req, res) => {
}
};
-async function getLatestRelease (): Promise {
- return new Promise((resolve, reject) => {
- https.get('https://api.github.com/repos/NapNeko/NapCatQQ/releases/latest', {
- headers: { 'User-Agent': 'NapCat-WebUI' }
- }, (res) => {
- let data = '';
- res.on('data', chunk => data += chunk);
- res.on('end', () => {
- try {
- const release = JSON.parse(data) as Release;
- console.log('Release info:', {
- tag_name: release.tag_name,
- assets: release.assets?.map(a => ({ name: a.name, url: a.browser_download_url }))
- });
- resolve(release);
- } catch (e) {
- reject(e);
- }
- });
- }).on('error', reject);
- });
-}
+// 注:getLatestRelease 已移除,现在使用 mirror 模块的 getGitHubRelease
/**
* 应用待处理的更新(在应用启动时调用)
diff --git a/packages/napcat-webui-backend/src/router/Base.ts b/packages/napcat-webui-backend/src/router/Base.ts
index 42469ee0..a12016b3 100644
--- a/packages/napcat-webui-backend/src/router/Base.ts
+++ b/packages/napcat-webui-backend/src/router/Base.ts
@@ -1,5 +1,5 @@
import { Router } from 'express';
-import { GetThemeConfigHandler, GetNapCatVersion, QQVersionHandler, SetThemeConfigHandler, getLatestTagHandler } from '../api/BaseInfo';
+import { GetThemeConfigHandler, GetNapCatVersion, QQVersionHandler, SetThemeConfigHandler, getLatestTagHandler, getAllReleasesHandler } from '../api/BaseInfo';
import { StatusRealTimeHandler } from '@/napcat-webui-backend/src/api/Status';
import { GetProxyHandler } from '../api/Proxy';
@@ -8,6 +8,7 @@ const router = Router();
router.get('/QQVersion', QQVersionHandler);
router.get('/GetNapCatVersion', GetNapCatVersion);
router.get('/getLatestTag', getLatestTagHandler);
+router.get('/getAllReleases', getAllReleasesHandler);
router.get('/GetSysStatusRealTime', StatusRealTimeHandler);
router.get('/proxy', GetProxyHandler);
router.get('/Theme', GetThemeConfigHandler);
diff --git a/packages/napcat-webui-frontend/src/components/modal.tsx b/packages/napcat-webui-frontend/src/components/modal.tsx
index 92bea277..51e116b6 100644
--- a/packages/napcat-webui-frontend/src/components/modal.tsx
+++ b/packages/napcat-webui-frontend/src/components/modal.tsx
@@ -10,18 +10,19 @@ import {
import React from 'react';
export interface ModalProps {
- content: React.ReactNode
- title?: React.ReactNode
- size?: React.ComponentProps['size']
- scrollBehavior?: React.ComponentProps['scrollBehavior']
- onClose?: () => void
- onConfirm?: () => void
- onCancel?: () => void
- backdrop?: 'opaque' | 'blur' | 'transparent'
- showCancel?: boolean
- dismissible?: boolean
- confirmText?: string
- cancelText?: string
+ content: React.ReactNode;
+ title?: React.ReactNode;
+ size?: React.ComponentProps['size'];
+ scrollBehavior?: React.ComponentProps['scrollBehavior'];
+ onClose?: () => void;
+ onConfirm?: () => void;
+ onCancel?: () => void;
+ backdrop?: 'opaque' | 'blur' | 'transparent';
+ showCancel?: boolean;
+ dismissible?: boolean;
+ confirmText?: string;
+ cancelText?: string;
+ hideFooter?: boolean;
}
const Modal: React.FC = React.memo((props) => {
@@ -33,6 +34,7 @@ const Modal: React.FC = React.memo((props) => {
dismissible,
confirmText = '确定',
cancelText = '取消',
+ hideFooter = false,
onClose,
onConfirm,
onCancel,
@@ -62,29 +64,31 @@ const Modal: React.FC = React.memo((props) => {
{title}
)}
{content}
-
- {showCancel && (
+ {!hideFooter && (
+
+ {showCancel && (
+
+ )}
- )}
-
-
+
+ )}
>
)}
diff --git a/packages/napcat-webui-frontend/src/components/system_info.tsx b/packages/napcat-webui-frontend/src/components/system_info.tsx
index 5b432be9..a9aa2ee4 100644
--- a/packages/napcat-webui-frontend/src/components/system_info.tsx
+++ b/packages/napcat-webui-frontend/src/components/system_info.tsx
@@ -3,17 +3,24 @@ import { Card, CardBody, CardHeader } from '@heroui/card';
import { Chip } from '@heroui/chip';
import { Spinner } from '@heroui/spinner';
import { Tooltip } from '@heroui/tooltip';
-import { useLocalStorage } from '@uidotdev/usehooks';
+import { Select, SelectItem } from '@heroui/select';
+import { Switch } from '@heroui/switch';
+import { Pagination } from '@heroui/pagination';
+import { Tabs, Tab } from '@heroui/tabs';
+import { Input } from '@heroui/input';
+import { useLocalStorage, useDebounce } from '@uidotdev/usehooks';
import { useRequest } from 'ahooks';
import clsx from 'clsx';
import { FaCircleInfo, FaQq } from 'react-icons/fa6';
-import { IoLogoChrome, IoLogoOctocat } from 'react-icons/io';
+import { IoLogoChrome, IoLogoOctocat, IoSearch } from 'react-icons/io5';
import { RiMacFill } from 'react-icons/ri';
-import { useState } from 'react';
+import { useState, useCallback } from 'react';
import key from '@/const/key';
import WebUIManager from '@/controllers/webui_manager';
import useDialog from '@/hooks/use-dialog';
+import Modal from '@/components/modal';
+import { hasNewVersion, compareVersion } from '@/utils/version';
export interface SystemInfoItemProps {
@@ -22,6 +29,8 @@ export interface SystemInfoItemProps {
value?: React.ReactNode;
endContent?: React.ReactNode;
hasBackground?: boolean;
+ onClick?: () => void;
+ clickable?: boolean;
}
const SystemInfoItem: React.FC = ({
@@ -30,14 +39,20 @@ const SystemInfoItem: React.FC = ({
icon,
endContent,
hasBackground = false,
+ onClick,
+ clickable = false,
}) => {
return (
-
+
{icon}
{title}
{
-// const { currentVersion } = props;
-// const dialog = useDialog();
-// const { data: releaseData, error } = useRequest(() =>
-// request.get
(
-// 'https://api.github.com/repos/NapNeko/NapCatQQ/releases'
-// )
-// );
-
-// if (error) {
-// return (
-//
-//
-//
-// );
-// }
-
-// const latestVersion = releaseData?.data?.[0]?.tag_name;
-
-// if (!latestVersion || !currentVersion) {
-// return null;
-// }
-
-// if (compareVersion(latestVersion, currentVersion) <= 0) {
-// return null;
-// }
-
-// const middleVersions: GithubRelease[] = [];
-
-// for (let i = 0; i < releaseData.data.length; i++) {
-// const versionInfo = releaseData.data[i];
-// if (compareVersion(versionInfo.tag_name, currentVersion) > 0) {
-// middleVersions.push(versionInfo);
-// } else {
-// break;
-// }
-// }
-
-// const AISummaryComponent = () => {
-// const {
-// data: aiSummaryData,
-// loading: aiSummaryLoading,
-// error: aiSummaryError,
-// run: runAiSummary,
-// } = useRequest(
-// (version) =>
-// request.get>(
-// `https://release.nc.152710.xyz/?version=${version}`,
-// {
-// timeout: 30000,
-// }
-// ),
-// {
-// manual: true,
-// }
-// );
-
-// useEffect(() => {
-// runAiSummary(currentVersion);
-// }, [currentVersion, runAiSummary]);
-
-// if (aiSummaryLoading) {
-// return (
-//
-//
-//
-// );
-// }
-// if (aiSummaryError) {
-// return AI 摘要获取失败
;
-// }
-// return {aiSummaryData?.data.data};
-// };
-
-// return (
-//
-//
-//
-// );
-// };
-
// 更新状态类型
type UpdateStatus = 'idle' | 'updating' | 'success' | 'error';
@@ -213,18 +79,29 @@ const UpdateDialogContent: React.FC<{
errorMessage?: string;
}> = ({ currentVersion, latestVersion, status, errorMessage }) => {
return (
-
- {/* 版本信息 */}
-
-
-
当前版本
-
+
+ {/* 版本对比 */}
+
+
+ 当前版本
+
v{currentVersion}
-
-
最新版本
-
v{latestVersion}
+
+
+
+
+ 最新版本
+
+ v{latestVersion}
+
@@ -300,7 +177,8 @@ const NewVersionTip = (props: NewVersionTipProps) => {
});
const [updateStatus, setUpdateStatus] = useState
('idle');
- if (error || !latestVersion || !currentVersion || latestVersion === currentVersion) {
+ // 使用 SemVer 规范比较版本号
+ if (error || !latestVersion || !currentVersion || !hasNewVersion(currentVersion, latestVersion)) {
return null;
}
@@ -380,11 +258,381 @@ const NewVersionTip = (props: NewVersionTipProps) => {
);
};
+// 版本信息类型
+interface VersionInfo {
+ tag: string;
+ type: 'release' | 'prerelease' | 'action';
+ artifactId?: number;
+ artifactName?: string;
+ createdAt?: string;
+ expiresAt?: string;
+ size?: number;
+}
+
+// 版本选择对话框内容
+interface VersionSelectDialogProps {
+ currentVersion: string;
+ onClose: () => void;
+}
+
+const VersionSelectDialogContent: React.FC = ({
+ currentVersion,
+ onClose,
+}) => {
+ const dialog = useDialog();
+ const [selectedVersion, setSelectedVersion] = useState(null);
+ const [forceUpdate, setForceUpdate] = useState(false);
+ const [updateStatus, setUpdateStatus] = useState('idle');
+ const [errorMessage, setErrorMessage] = useState('');
+ const [currentPage, setCurrentPage] = useState(1);
+ const [activeTab, setActiveTab] = useState<'release' | 'action'>('release');
+ const [searchQuery, setSearchQuery] = useState('');
+ const debouncedSearch = useDebounce(searchQuery, 300);
+ const pageSize = 15;
+
+ // 获取所有可用版本(带分页、过滤和搜索)
+ const { data: releasesData, loading: releasesLoading, error: releasesError } = useRequest(
+ () => WebUIManager.getAllReleases({
+ page: currentPage,
+ pageSize,
+ includeActions: true,
+ type: activeTab,
+ search: debouncedSearch
+ }),
+ {
+ refreshDeps: [currentPage, activeTab, debouncedSearch],
+ }
+ );
+
+ // 版本列表已在后端过滤,直接使用
+ const filteredVersions = releasesData?.versions || [];
+
+ // 检查是否是降级(使用语义化版本比较)
+ const isDowngrade = useCallback((targetTag: string): boolean => {
+ if (!currentVersion || !targetTag) return false;
+ // Action 版本不算降级
+ if (targetTag.startsWith('action-')) return false;
+ return compareVersion(targetTag, currentVersion) < 0;
+ }, [currentVersion]);
+
+ const selectedVersionTag = selectedVersion?.tag || '';
+ const isSelectedDowngrade = isDowngrade(selectedVersionTag);
+
+ const handleUpdate = async () => {
+ if (!selectedVersion) return;
+
+ if (isSelectedDowngrade && !forceUpdate) {
+ dialog.confirm({
+ title: '确认降级',
+ content: (
+
+
+ 您正在尝试从 v{currentVersion} 降级到 {selectedVersionTag}
+
+
+ 降级可能导致配置不兼容或功能丢失,请确认您了解相关风险。
+
+
+ ),
+ confirmText: '确认降级',
+ cancelText: '取消',
+ onConfirm: () => performUpdate(true),
+ });
+ return;
+ }
+
+ await performUpdate(forceUpdate);
+ };
+
+ const performUpdate = async (force: boolean) => {
+ if (!selectedVersion) return;
+ setUpdateStatus('updating');
+ setErrorMessage('');
+
+ try {
+ await WebUIManager.UpdateNapCatToVersion(selectedVersionTag, force);
+ setUpdateStatus('success');
+ } catch (err) {
+ console.error('Update failed:', err);
+ const errMsg = err instanceof Error ? err.message : '未知错误';
+ setErrorMessage(errMsg);
+ setUpdateStatus('error');
+ }
+ };
+
+ // 处理分页变化
+ const handlePageChange = (page: number) => {
+ setCurrentPage(page);
+ };
+
+ if (updateStatus === 'success') {
+ return (
+
+
+
+
+ 更新到 {selectedVersionTag} 完成
+
+
+ 请重启 NapCat 以应用新版本
+
+
+
+ );
+ }
+
+ if (updateStatus === 'error') {
+ return (
+
+
+
+
+ 更新失败
+
+
+ {errorMessage || '请稍后重试'}
+
+
+
+ );
+ }
+
+ if (updateStatus === 'updating') {
+ return (
+
+
+
+
+ 正在更新到 {selectedVersionTag}...
+
+
+ 请耐心等待,更新可能需要几分钟
+
+
+
+ );
+ }
+
+ const pagination = releasesData?.pagination;
+
+ return (
+
+ {/* 当前版本 */}
+
+
+ 当前版本:
+
+ v{currentVersion}
+
+
+ {releasesData?.mirror && (
+
+
+ 镜像: {releasesData.mirror}
+
+ )}
+
+
+ {/* 版本类型切换 */}
+
{
+ setActiveTab(key as 'release' | 'action');
+ setCurrentPage(1);
+ setSelectedVersion(null);
+ setSearchQuery('');
+ }}
+ size='sm'
+ color='primary'
+ variant='underlined'
+ classNames={{
+ tabList: 'gap-4',
+ }}
+ >
+
+
+
+
+ {/* 搜索框 */}
+
{
+ setSearchQuery(value);
+ setCurrentPage(1);
+ setSelectedVersion(null);
+ }}
+ startContent={
}
+ isClearable
+ onClear={() => setSearchQuery('')}
+ classNames={{
+ inputWrapper: 'h-9',
+ }}
+ />
+
+ {/* 版本选择 */}
+
+
+
+ {releasesData?.pagination && (
+
+ 共 {releasesData.pagination.total} 个版本
+
+ )}
+
+ {releasesLoading ? (
+
+
+ 加载版本列表...
+
+ ) : releasesError ? (
+
+ 加载版本列表失败: {releasesError.message}
+
+ ) : filteredVersions.length === 0 ? (
+
+ {searchQuery ? `未找到匹配 "${searchQuery}" 的版本` : '暂无可用版本'}
+
+ ) : (
+
+ )}
+
+
+ {/* Action 版本提示 */}
+ {activeTab === 'action' && (
+
+
+ 临时版本来自 GitHub Actions 构建,可能不稳定,适合测试新功能。
+ {selectedVersion?.expiresAt && (
+
+ 此版本将于 {new Date(selectedVersion.expiresAt).toLocaleDateString()} 过期
+
+ )}
+
+
+ )}
+
+ {/* 降级警告 */}
+ {selectedVersion && isSelectedDowngrade && (
+
+
+
+
+
+ 版本降级警告
+
+
+ 降级到旧版本可能导致配置不兼容或功能丢失
+
+
+
+
+
+
+ 我了解风险,确认降级
+
+
+
+ )}
+
+ {/* 分页 */}
+ {pagination && pagination.totalPages > 1 && (
+
+ )}
+
+ {/* 操作按钮 */}
+
+
+
+
+
+ );
+};
+
interface NapCatVersionProps {
hasBackground?: boolean;
}
const NapCatVersion: React.FC = ({ hasBackground = false }) => {
+ const [isVersionModalOpen, setIsVersionModalOpen] = useState(false);
const {
data: packageData,
loading: packageLoading,
@@ -397,26 +645,55 @@ const NapCatVersion: React.FC = ({ hasBackground = false })
const currentVersion = packageData?.version;
+ // 点击版本号时显示版本选择对话框
+ const handleVersionClick = useCallback(() => {
+ if (!currentVersion) return;
+ setIsVersionModalOpen(true);
+ }, [currentVersion]);
+
return (
- }
- hasBackground={hasBackground}
- value={
- packageError
- ? (
- `错误:${packageError.message}`
- )
- : packageLoading
+ <>
+ }
+ hasBackground={hasBackground}
+ value={
+ packageError
? (
-
+ `错误:${packageError.message}`
)
- : (
- currentVersion
- )
- }
- endContent={}
- />
+ : packageLoading
+ ? (
+
+ )
+ : (
+
+
+ {currentVersion}
+
+
+ )
+ }
+ endContent={}
+ />
+ {isVersionModalOpen && (
+ setIsVersionModalOpen(false)}
+ content={
+ setIsVersionModalOpen(false)}
+ />
+ }
+ />
+ )}
+ >
);
};
diff --git a/packages/napcat-webui-frontend/src/controllers/webui_manager.ts b/packages/napcat-webui-frontend/src/controllers/webui_manager.ts
index d5f7b223..c9aa30b1 100644
--- a/packages/napcat-webui-frontend/src/controllers/webui_manager.ts
+++ b/packages/napcat-webui-frontend/src/controllers/webui_manager.ts
@@ -54,11 +54,68 @@ export default class WebUIManager {
return data.data;
}
+ /**
+ * 版本信息接口
+ */
+ static readonly VersionTypes = {
+ RELEASE: 'release',
+ PRERELEASE: 'prerelease',
+ ACTION: 'action',
+ } as const;
+
+ /**
+ * 获取所有可用的版本列表(支持分页、过滤和搜索)
+ */
+ public static async getAllReleases (options: {
+ page?: number;
+ pageSize?: number;
+ includeActions?: boolean;
+ type?: 'release' | 'action' | 'all';
+ search?: string;
+ } = {}) {
+ const { page = 1, pageSize = 20, includeActions = true, type = 'all', search = '' } = options;
+ const { data } = await serverRequest.get;
+ pagination: {
+ page: number;
+ pageSize: number;
+ total: number;
+ totalPages: number;
+ };
+ mirror?: string;
+ }>>('/base/getAllReleases', {
+ params: { page, pageSize, includeActions, type, search },
+ });
+ return data.data;
+ }
+
public static async UpdateNapCat () {
const { data } = await serverRequest.post>(
'/UpdateNapCat/update',
{},
- { timeout: 60000 } // 1分钟超时
+ { timeout: 120000 } // 2分钟超时
+ );
+ return data;
+ }
+
+ /**
+ * 更新到指定版本
+ * @param targetVersion 目标版本 tag,如 "v4.9.9" 或 "action-123456"
+ * @param force 是否强制更新(允许降级)
+ */
+ public static async UpdateNapCatToVersion (targetVersion: string, force: boolean = false) {
+ const { data } = await serverRequest.post>(
+ '/UpdateNapCat/update',
+ { targetVersion, force },
+ { timeout: 120000 } // 2分钟超时
);
return data;
}
diff --git a/packages/napcat-webui-frontend/src/utils/request.ts b/packages/napcat-webui-frontend/src/utils/request.ts
index 7c5bf6fc..59484c93 100644
--- a/packages/napcat-webui-frontend/src/utils/request.ts
+++ b/packages/napcat-webui-frontend/src/utils/request.ts
@@ -3,7 +3,7 @@ import axios from 'axios';
import key from '@/const/key';
export const serverRequest = axios.create({
- timeout: 5000,
+ timeout: 30000, // 30秒,获取版本列表可能较慢
});
export const request = axios.create({
diff --git a/packages/napcat-webui-frontend/src/utils/version.ts b/packages/napcat-webui-frontend/src/utils/version.ts
index 4d53afeb..12099df2 100644
--- a/packages/napcat-webui-frontend/src/utils/version.ts
+++ b/packages/napcat-webui-frontend/src/utils/version.ts
@@ -1,22 +1,59 @@
/**
- * 版本号转为数字
+ * SemVer 2.0 正则表达式
+ * 格式: 主版本号.次版本号.修订号[-先行版本号][+版本编译信息]
+ * 参考: https://semver.org/lang/zh-CN/
+ */
+const SEMVER_REGEX = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
+
+interface SemVerInfo {
+ valid: boolean;
+ normalized: string;
+ major: number;
+ minor: number;
+ patch: number;
+ prerelease: string | null;
+ buildmetadata: string | null;
+}
+
+/**
+ * 解析版本号
+ * @param version 版本字符串
+ * @returns SemVer 解析结果
+ */
+export const parseSemVer = (version: string | undefined | null): SemVerInfo => {
+ if (!version || typeof version !== 'string') {
+ return { valid: false, normalized: '1.0.0-dev', major: 1, minor: 0, patch: 0, prerelease: 'dev', buildmetadata: null };
+ }
+
+ const match = version.trim().match(SEMVER_REGEX);
+ if (match) {
+ const major = parseInt(match[1]!, 10);
+ const minor = parseInt(match[2]!, 10);
+ const patch = parseInt(match[3]!, 10);
+ const prerelease = match[4] || null;
+ const buildmetadata = match[5] || null;
+
+ let normalized = `${major}.${minor}.${patch}`;
+ if (prerelease) normalized += `-${prerelease}`;
+ if (buildmetadata) normalized += `+${buildmetadata}`;
+
+ return { valid: true, normalized, major, minor, patch, prerelease, buildmetadata };
+ }
+ return { valid: false, normalized: '1.0.0-dev', major: 1, minor: 0, patch: 0, prerelease: 'dev', buildmetadata: null };
+};
+
+/**
+ * 版本号转为数字 (兼容旧代码)
* @param version 版本号
* @returns 版本号数字
*/
export const versionToNumber = (version: string): number => {
- const finalVersionString = version.replace(/^v/, '');
-
- const versionArray = finalVersionString.split('.');
- const versionNumber =
- parseInt(versionArray[2]) +
- parseInt(versionArray[1]) * 100 +
- parseInt(versionArray[0]) * 10000;
-
- return versionNumber;
+ const info = parseSemVer(version);
+ return info.patch + info.minor * 100 + info.major * 10000;
};
/**
- * 比较版本号
+ * 比较版本号 (SemVer 2.0 规范)
* @param version1 版本号1
* @param version2 版本号2
* @returns 比较结果
@@ -24,13 +61,73 @@ export const versionToNumber = (version: string): number => {
* 1: version1 > version2
* -1: version1 < version2
*/
-export const compareVersion = (version1: string, version2: string): number => {
- const versionNumber1 = versionToNumber(version1);
- const versionNumber2 = versionToNumber(version2);
+export const compareVersion = (version1: string, version2: string): -1 | 0 | 1 => {
+ const a = parseSemVer(version1);
+ const b = parseSemVer(version2);
- if (versionNumber1 === versionNumber2) {
+ if (!a.valid || !b.valid) {
return 0;
}
- return versionNumber1 > versionNumber2 ? 1 : -1;
+ // 比较主版本号
+ if (a.major !== b.major) return a.major > b.major ? 1 : -1;
+ // 比较次版本号
+ if (a.minor !== b.minor) return a.minor > b.minor ? 1 : -1;
+ // 比较修订号
+ if (a.patch !== b.patch) return a.patch > b.patch ? 1 : -1;
+
+ // 有先行版本号的版本优先级较低
+ if (a.prerelease && !b.prerelease) return -1;
+ if (!a.prerelease && b.prerelease) return 1;
+
+ // 两者都有先行版本号时,按规则比较
+ if (a.prerelease && b.prerelease) {
+ const aParts = a.prerelease.split('.');
+ const bParts = b.prerelease.split('.');
+ const len = Math.max(aParts.length, bParts.length);
+
+ for (let i = 0; i < len; i++) {
+ const aPart = aParts[i];
+ const bPart = bParts[i];
+
+ if (aPart === undefined) return -1;
+ if (bPart === undefined) return 1;
+
+ const aNum = /^\d+$/.test(aPart) ? parseInt(aPart, 10) : NaN;
+ const bNum = /^\d+$/.test(bPart) ? parseInt(bPart, 10) : NaN;
+
+ // 数字 vs 数字
+ if (!isNaN(aNum) && !isNaN(bNum)) {
+ if (aNum !== bNum) return aNum > bNum ? 1 : -1;
+ continue;
+ }
+ // 数字优先级低于字符串
+ if (!isNaN(aNum)) return -1;
+ if (!isNaN(bNum)) return 1;
+ // 字符串 vs 字符串
+ if (aPart !== bPart) return aPart > bPart ? 1 : -1;
+ }
+ }
+
+ return 0;
+};
+
+/**
+ * 判断是否有新版本可用
+ * 只比较正式版本 (不带先行版本号的)
+ * 当前版本是先行版本时,与相同基础版本的正式版相比认为需要更新
+ * @param currentVersion 当前版本
+ * @param latestVersion 最新版本 (release tag)
+ * @returns 是否有新版本
+ */
+export const hasNewVersion = (currentVersion: string, latestVersion: string): boolean => {
+ const current = parseSemVer(currentVersion);
+ const latest = parseSemVer(latestVersion);
+
+ if (!current.valid || !latest.valid) {
+ return false;
+ }
+
+ // 使用 compareVersion 比较
+ return compareVersion(latestVersion, currentVersion) > 0;
};