diff --git a/.env.example b/.env.example
index 9355180..32da1c4 100644
--- a/.env.example
+++ b/.env.example
@@ -5,10 +5,17 @@
# 2) 按需填写你的值
#
# Compose 会自动读取当前目录下的 .env 用于变量替换:
-# docker compose up -d --build
+# docker compose pull
+# docker compose up -d
#
# 也可以显式指定:
-# docker compose --env-file .env up -d --build
+# docker compose --env-file .env pull
+# docker compose --env-file .env up -d
+
+# 镜像标签(默认稳定版 latest)
+# 开发版可改为:
+# TP_IMAGE=ghcr.io/moeacgx/telegram-panel:dev-latest
+TP_IMAGE=ghcr.io/moeacgx/telegram-panel:latest
# Webhook 模式(生产环境可选)
# - true: 使用 Webhook(需要公网 HTTPS + 反向代理转发 /api/bot/webhook/*)
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 3d7050a..d323fe5 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -14,6 +14,7 @@ on:
paths:
- "Dockerfile"
- "Dockerfile.local"
+ - "docker/**"
- "docker-compose.yml"
- "src/**"
- ".github/workflows/docker.yml"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index d9668b0..9d98850 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -33,6 +33,54 @@ jobs:
fi
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
+ - name: Checkout source at tag
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ steps.vars.outputs.tag }}
+
+ - name: Set up .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 8.0.x
+
+ - name: Restore
+ run: dotnet restore TelegramPanel.sln
+
+ - name: Publish Linux update packages
+ shell: bash
+ run: |
+ set -euo pipefail
+ dotnet publish src/TelegramPanel.Web/TelegramPanel.Web.csproj \
+ -c Release \
+ -r linux-x64 \
+ --self-contained false \
+ -p:UseAppHost=false \
+ -o artifacts/linux-x64
+
+ dotnet publish src/TelegramPanel.Web/TelegramPanel.Web.csproj \
+ -c Release \
+ -r linux-arm64 \
+ --self-contained false \
+ -p:UseAppHost=false \
+ -o artifacts/linux-arm64
+
+ - name: Pack release assets
+ id: pack
+ shell: bash
+ run: |
+ set -euo pipefail
+ TAG="${{ steps.vars.outputs.tag }}"
+ mkdir -p dist
+
+ X64_ZIP="telegram-panel-${TAG}-linux-x64.zip"
+ ARM64_ZIP="telegram-panel-${TAG}-linux-arm64.zip"
+
+ (cd artifacts/linux-x64 && zip -rq "../../dist/${X64_ZIP}" .)
+ (cd artifacts/linux-arm64 && zip -rq "../../dist/${ARM64_ZIP}" .)
+
+ echo "x64=dist/${X64_ZIP}" >> "$GITHUB_OUTPUT"
+ echo "arm64=dist/${ARM64_ZIP}" >> "$GITHUB_OUTPUT"
+
- name: Create GitHub Release with auto changelog
uses: softprops/action-gh-release@v2
with:
@@ -40,6 +88,9 @@ jobs:
generate_release_notes: true
append_body: true
prerelease: ${{ contains(steps.vars.outputs.tag, '-') }}
+ files: |
+ ${{ steps.pack.outputs.x64 }}
+ ${{ steps.pack.outputs.arm64 }}
body: |
## Docker 镜像
@@ -47,5 +98,9 @@ jobs:
docker pull ghcr.io/${{ github.repository }}:${{ steps.vars.outputs.tag }}
```
- > 提示:该版本对应的多架构镜像由 `Docker Build & Publish` 工作流自动构建并推送。
+ ## 应用内一键更新资产
+ - `${{ steps.pack.outputs.x64 }}`
+ - `${{ steps.pack.outputs.arm64 }}`
+
+ > 提示:面板内「系统设置 -> 应用更新(Docker)」会自动匹配并下载对应架构的更新包。
diff --git a/Directory.Build.props b/Directory.Build.props
index 85792e6..ec8f0a7 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -5,10 +5,10 @@
- 1.30.0
- 1.30.0.0
- 1.30.0.0
- 1.30.0
+ 1.31.0
+ 1.31.0.0
+ 1.31.0.0
+ 1.31.0
Telegram Panel Contributors
diff --git a/Dockerfile b/Dockerfile
index 645657c..86ec230 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -32,8 +32,10 @@ RUN mkdir -p /data /data/sessions /data/logs \
COPY --from=build /app/publish .
COPY --from=tdata-runtime /usr/local /usr/local
COPY --from=tdata-runtime /opt/telegram-panel-tdata-runtime /app/tdata-runtime
+COPY docker/entrypoint.sh /entrypoint.sh
+RUN chmod +x /entrypoint.sh
RUN node -v && npm -v
-ENTRYPOINT ["dotnet", "TelegramPanel.Web.dll"]
+ENTRYPOINT ["/entrypoint.sh"]
diff --git a/README.md b/README.md
index d435814..86aecb7 100644
--- a/README.md
+++ b/README.md
@@ -17,16 +17,17 @@
👥 站长交流群
-## v1.30 重要更新
+## v1.31 重要更新
本次版本主要更新:
-- ✨ 废号判定补全:`AUTH_KEY_DUPLICATED`(Session 冲突)与 `SESSION_REVOKED`(Session 已撤销)计入废号(影响“只看废号”筛选与清理逻辑)
-- ✨ 一键清理筛选废号:账号页在勾选“只看废号”后提供“清理废号(筛选)”,按当前筛选结果直接批量清理
-- ⚡ 自动同步调度优化:记录上次自动同步时间,避免重启即跑一轮导致限流
-- ⚡ 批量任务配置落地:支持保存默认间隔/最大并发/重试开关,并默认更保守(降低风控风险)
-- 🐛 日志降噪与限流降速:减少刷屏日志与高频请求
-- 📚 新增文档站:更易维护与检索(https://moeacgx.github.io/Telegram-Panel/)
+- ✨ TData 导入完善:支持完整导入 tdata 压缩包,并补全导入链路会话校验,减少导入后不可用情况
+- ✨ TData 导出优化:支持 Telethon/TData 双格式导出;修复官方客户端登录兼容问题;优先已授权 DCSession;补齐 `user_id` 写入;修复旧包缓存导致重复下载
+- ✨ Bot 频道异常治理:支持删除失效频道、频道状态检测(正常/异常)和异常筛选,降低被踢/封禁后脏数据残留
+- ✨ Docker 一键更新:支持面板内检测 Release 并一键更新,自动下载匹配架构包、部署到 `/data/app-current` 并重启生效
+- ⚡ 更新入口统一:左上角版本弹窗集成自动检测、更新确认与 Latest Release Notes 展示(移除设置页重复入口)
+- ⚡ CI/CD 完整化:新增 Docker 自动构建推送(GHCR)与 Release 自动发布/自动 changelog/更新包资产上传
+- 📚 部署文档重排:Docker 部署流程精简为 compose 单路径(稳定版/开发版切换更直观)
## 功能概览
@@ -55,77 +56,72 @@
### Docker 一键部署(推荐)
-🐳 面向小白:`git clone` → `docker compose up` → 浏览器打开 → 登录改密码 → 配置 ApiId/ApiHash。
+环境要求:Docker(Windows 推荐 Docker Desktop + WSL2;Linux 直接装 Docker Engine)
-### 环境要求
-
-Docker(Windows 推荐 Docker Desktop + WSL2;Linux 直接装 Docker Engine)
-
-### 启动
+#### 第一步:准备项目
```bash
git clone https://github.com/moeacgx/Telegram-Panel
cd Telegram-Panel
-docker compose up -d --build
+cp .env.example .env
```
-启动后访问:`http://localhost:5000`
+#### 第二步:选择镜像版本
-### 默认后台账号(首次登录)
+默认是稳定版(无需改动):
+
+```bash
+TP_IMAGE=ghcr.io/moeacgx/telegram-panel:latest
+```
+
+如果你要开发版,改 `.env` 为:
+
+```bash
+TP_IMAGE=ghcr.io/moeacgx/telegram-panel:dev-latest
+```
+
+#### 第三步:启动
+
+```bash
+docker compose pull
+docker compose up -d
+```
+
+访问:`http://localhost:5000`
+
+#### 默认后台账号(首次登录)
用户名:`admin`
密码:`admin123`
登录后到「修改密码」页面改掉即可。
-> 更完整的安装、更新、导入与生产部署建议:见 https://moeacgx.github.io/Telegram-Panel/ 。
-
-## CI 自动构建镜像(GitHub Actions)
-
-已内置工作流:`.github/workflows/docker.yml`
-
-触发规则:
-- 推送到 `main` / `dev`:自动构建并推送镜像
-- 推送 tag(如 `v1.2.3`):自动构建并推送版本镜像
-- PR 到 `main` / `dev`:只构建验证,不推送
-
-镜像仓库(GHCR):
-- `ghcr.io/<你的GitHub用户名或组织>/telegram-panel`
-
-标签规则:
-- `main` 分支:`latest`、`main`、`sha-xxxxxxx`
-- `dev` 分支:`dev`、`dev-latest`、`sha-xxxxxxx`
-- `v*` tag:例如 `v1.2.3`
-
-拉取示例:
+#### 常用命令
```bash
-# 稳定版
-docker pull ghcr.io//telegram-panel:latest
+# 查看日志
+docker compose logs -f
-# 开发版
-docker pull ghcr.io//telegram-panel:dev-latest
+# 更新到当前 .env 指定的镜像版本
+docker compose pull
+docker compose up -d
-# 指定版本
-docker pull ghcr.io//telegram-panel:v1.2.3
+# 重启 / 停止
+docker compose restart
+docker compose down
```
-## 自动发布与 Changelog
+## Docker 一键更新(面板内)
-已内置工作流:`.github/workflows/release.yml`
+面板已支持在 Docker 部署场景下一键更新(左上角版本号 -> 版本信息弹窗):
-触发规则:
-- 推送 `v*` tag(如 `v1.2.3`)后,自动创建 GitHub Release
-- Release 内容自动生成 changelog(基于提交/PR),并读取 `.github/release.yml` 分类规则
+1. 点击“检查更新”,读取 GitHub 最新 Release。
+2. 点击“一键更新并重启”,自动下载对应架构的 Linux 更新包到 `/data/app-current`。
+3. 程序触发重启后,容器会优先从 `/data/app-current` 启动新版本(无需手动 `docker compose pull`)。
-发布示例:
-
-```bash
-git checkout main
-git pull --ff-only origin main
-git tag v1.2.3
-git push origin v1.2.3
-```
+说明:
+- 当前仅支持 Docker 容器内执行一键更新。
+- 更新资产依赖 `release.yml` 工作流产物;若 Release 没有 `linux-x64/linux-arm64` zip 资产,则一键更新会提示不可用。
## 截图
diff --git a/docker-compose.yml b/docker-compose.yml
index ec2570e..9db4c8d 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,8 +1,7 @@
services:
telegram-panel:
- build:
- context: .
- dockerfile: ${TP_DOCKERFILE:-Dockerfile}
+ image: ${TP_IMAGE:-ghcr.io/moeacgx/telegram-panel:latest}
+ pull_policy: always
container_name: telegram-panel
restart: unless-stopped
ports:
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
new file mode 100644
index 0000000..4e493a4
--- /dev/null
+++ b/docker/entrypoint.sh
@@ -0,0 +1,35 @@
+#!/usr/bin/env sh
+set -eu
+
+APP_ENTRY="TelegramPanel.Web.dll"
+DEFAULT_APP_DIR="/app"
+UPDATED_APP_DIR="/data/app-current"
+
+mkdir -p /data /data/sessions /data/logs
+if [ ! -f /data/appsettings.local.json ]; then
+ printf '{}' > /data/appsettings.local.json
+fi
+
+APP_DIR="$DEFAULT_APP_DIR"
+if [ -f "$UPDATED_APP_DIR/$APP_ENTRY" ]; then
+ APP_DIR="$UPDATED_APP_DIR"
+fi
+
+# 运行目录下日志统一指向 /data/logs,避免更新目录轮换后日志丢失。
+if [ -e "$APP_DIR/logs" ] && [ ! -L "$APP_DIR/logs" ]; then
+ rm -rf "$APP_DIR/logs"
+fi
+if [ ! -e "$APP_DIR/logs" ]; then
+ ln -s /data/logs "$APP_DIR/logs" || true
+fi
+
+# 面板保存的本地配置统一使用 /data/appsettings.local.json
+if [ -e "$APP_DIR/appsettings.local.json" ] && [ ! -L "$APP_DIR/appsettings.local.json" ]; then
+ rm -f "$APP_DIR/appsettings.local.json"
+fi
+if [ ! -e "$APP_DIR/appsettings.local.json" ]; then
+ ln -s /data/appsettings.local.json "$APP_DIR/appsettings.local.json" || true
+fi
+
+cd "$APP_DIR"
+exec dotnet "$APP_ENTRY"
diff --git a/src/TelegramPanel.Web/Components/Dialogs/VersionInfoDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/VersionInfoDialog.razor
index f889872..cf57097 100644
--- a/src/TelegramPanel.Web/Components/Dialogs/VersionInfoDialog.razor
+++ b/src/TelegramPanel.Web/Components/Dialogs/VersionInfoDialog.razor
@@ -1,6 +1,8 @@
@namespace TelegramPanel.Web.Components.Dialogs
@using TelegramPanel.Web.Services
-@inject UpdateCheckService UpdateCheck
+@inject AppSelfUpdateService SelfUpdate
+@inject ISnackbar Snackbar
+@inject IDialogService DialogService
@@ -9,6 +11,9 @@
版本:@VersionService.Version
+
+ 运行环境:@(_updateInfo?.IsDocker == true ? "Docker" : "非 Docker")
+
@@ -19,15 +24,34 @@
检查更新
-
- @if (_checking)
- {
-
- }
- 立即检查
-
+
+
+ @if (_checking)
+ {
+
+ }
+ 立即检查
+
+
+ @if (_updating)
+ {
+
+ }
+ 一键更新并重启
+
+
+ @if (AutoPrompt && _updateInfo is { Success: true, UpdateAvailable: true })
+ {
+
+ 检测到新版本,是否现在执行一键更新?
+
+ }
+
@if (_updateInfo == null)
{
正在检查…
@@ -38,9 +62,9 @@
检查失败:@_updateInfo.Error
}
- else if (_updateInfo.Source == "disabled")
+ else if (!_updateInfo.Enabled)
{
- 已禁用检查更新
+ 已禁用自动更新
}
else if (_updateInfo.UpdateAvailable)
{
@@ -54,12 +78,18 @@
}
- @if (!string.IsNullOrWhiteSpace(_updateInfo.Notes))
+ @if (!string.IsNullOrWhiteSpace(_updateInfo.AssetName))
{
- 更新说明(节选)
-
- @_updateInfo.Notes
-
+
+ 匹配更新包:@_updateInfo.AssetName
+
+ }
+
+ @if (!string.IsNullOrWhiteSpace(_updateInfo.BlockedReason))
+ {
+
+ 无法一键更新:@_updateInfo.BlockedReason
+
}
}
else
@@ -69,10 +99,18 @@
}
+ @if (!string.IsNullOrWhiteSpace(_updateInfo?.Notes))
+ {
+ Latest Release Notes
+
+ @_updateInfo.Notes
+
+ }
+
@if (_updateInfo != null)
{
- 最近检查:@_updateInfo.CheckedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")(来源:@_updateInfo.Source)
+ 最近检查:@_updateInfo.CheckedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")
}
@@ -85,13 +123,24 @@
@code
{
[CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!;
+ [Parameter] public bool AutoPrompt { get; set; }
private bool _checking;
- private UpdateCheckInfo? _updateInfo;
+ private bool _updating;
+ private AppSelfUpdateInfo? _updateInfo;
+
+ private bool CanApply =>
+ _updateInfo is
+ {
+ Success: true,
+ Enabled: true,
+ UpdateAvailable: true,
+ CanApply: true
+ };
protected override async Task OnInitializedAsync()
{
- _updateInfo = await UpdateCheck.GetLatestAsync(forceRefresh: false);
+ _updateInfo = await SelfUpdate.CheckLatestAsync(forceRefresh: false);
}
private async Task ForceRefresh()
@@ -99,7 +148,7 @@
_checking = true;
try
{
- _updateInfo = await UpdateCheck.GetLatestAsync(forceRefresh: true);
+ _updateInfo = await SelfUpdate.CheckLatestAsync(forceRefresh: true);
}
finally
{
@@ -107,6 +156,51 @@
}
}
+ private async Task ApplyUpdate()
+ {
+ if (_updating)
+ return;
+
+ if (_updateInfo == null || !CanApply)
+ {
+ Snackbar.Add(_updateInfo?.BlockedReason ?? "当前条件不满足一键更新", Severity.Warning);
+ return;
+ }
+
+ var target = _updateInfo.LatestTag ?? _updateInfo.LatestVersion ?? "最新版本";
+ bool? ok = await DialogService.ShowMessageBox(
+ "确认一键更新",
+ $"将升级到 {target},并在部署后自动重启服务。是否继续?",
+ yesText: "继续",
+ cancelText: "取消");
+
+ if (ok != true)
+ return;
+
+ _updating = true;
+ try
+ {
+ var result = await SelfUpdate.ApplyLatestAsync();
+ if (result.Success)
+ {
+ Snackbar.Add(result.Message, Severity.Success);
+ MudDialog.Close(DialogResult.Ok(true));
+ return;
+ }
+
+ Snackbar.Add(result.Message, Severity.Error);
+ _updateInfo = await SelfUpdate.CheckLatestAsync(forceRefresh: true);
+ }
+ catch (Exception ex)
+ {
+ Snackbar.Add($"执行更新异常:{ex.Message}", Severity.Error);
+ }
+ finally
+ {
+ _updating = false;
+ }
+ }
+
private void Close()
{
MudDialog.Close();
diff --git a/src/TelegramPanel.Web/Components/Layout/MainLayout.razor b/src/TelegramPanel.Web/Components/Layout/MainLayout.razor
index d57eb10..13c85ae 100644
--- a/src/TelegramPanel.Web/Components/Layout/MainLayout.razor
+++ b/src/TelegramPanel.Web/Components/Layout/MainLayout.razor
@@ -50,12 +50,13 @@
@using TelegramPanel.Web.Services
@inject UiPreferencesService UiPreferences
@inject IDialogService DialogService
-@inject UpdateCheckService UpdateCheck
+@inject AppSelfUpdateService SelfUpdate
@code {
private bool _drawerOpen = true;
private bool _isDarkMode = true;
- private UpdateCheckInfo? _updateInfo;
+ private AppSelfUpdateInfo? _updateInfo;
+ private bool _autoPromptShown;
private MudTheme _theme = new()
{
@@ -99,6 +100,11 @@
}
private async Task ShowVersionInfo()
+ {
+ await ShowVersionInfo(autoPrompt: false);
+ }
+
+ private async Task ShowVersionInfo(bool autoPrompt)
{
var options = new DialogOptions
{
@@ -107,16 +113,31 @@
FullWidth = true
};
- DialogService.Show("版本信息", new DialogParameters(), options);
- await Task.CompletedTask;
+ var parameters = new DialogParameters
+ {
+ [nameof(TelegramPanel.Web.Components.Dialogs.VersionInfoDialog.AutoPrompt)] = autoPrompt
+ };
+
+ var dialog = DialogService.Show("版本信息", parameters, options);
+ await dialog.Result;
+
+ // 弹窗可能已触发更新/重启,关闭后刷新一次顶部状态
+ _updateInfo = await SelfUpdate.CheckLatestAsync(forceRefresh: true);
+ await InvokeAsync(StateHasChanged);
}
private async Task LoadUpdateInfoAsync()
{
try
{
- _updateInfo = await UpdateCheck.GetLatestAsync(forceRefresh: false);
+ _updateInfo = await SelfUpdate.CheckLatestAsync(forceRefresh: false);
await InvokeAsync(StateHasChanged);
+
+ if (_updateInfo is { Success: true, UpdateAvailable: true } && !_autoPromptShown)
+ {
+ _autoPromptShown = true;
+ await InvokeAsync(async () => await ShowVersionInfo(autoPrompt: true));
+ }
}
catch
{
diff --git a/src/TelegramPanel.Web/Program.cs b/src/TelegramPanel.Web/Program.cs
index b190113..c155e40 100644
--- a/src/TelegramPanel.Web/Program.cs
+++ b/src/TelegramPanel.Web/Program.cs
@@ -409,7 +409,9 @@ builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.Configure(builder.Configuration.GetSection("UpdateCheck"));
+builder.Services.Configure(builder.Configuration.GetSection("SelfUpdate"));
builder.Services.AddSingleton();
+builder.Services.AddSingleton();
builder.Services.Configure(builder.Configuration.GetSection("System"));
builder.Services.AddSingleton();
builder.Services.AddHostedService();
diff --git a/src/TelegramPanel.Web/Services/AppSelfUpdateService.cs b/src/TelegramPanel.Web/Services/AppSelfUpdateService.cs
new file mode 100644
index 0000000..085b912
--- /dev/null
+++ b/src/TelegramPanel.Web/Services/AppSelfUpdateService.cs
@@ -0,0 +1,564 @@
+using System.IO.Compression;
+using System.Net;
+using System.Runtime.InteropServices;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Options;
+
+namespace TelegramPanel.Web.Services;
+
+public sealed class AppSelfUpdateService
+{
+ private const string CacheKeyPrefix = "self-update:";
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
+
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly IMemoryCache _cache;
+ private readonly IOptionsMonitor _updateOptions;
+ private readonly IOptionsMonitor _selfUpdateOptions;
+ private readonly IWebHostEnvironment _environment;
+ private readonly AppRestartService _restartService;
+ private readonly ILogger _logger;
+ private readonly SemaphoreSlim _applyLock = new(1, 1);
+
+ public AppSelfUpdateService(
+ IHttpClientFactory httpClientFactory,
+ IMemoryCache cache,
+ IOptionsMonitor updateOptions,
+ IOptionsMonitor selfUpdateOptions,
+ IWebHostEnvironment environment,
+ AppRestartService restartService,
+ ILogger logger)
+ {
+ _httpClientFactory = httpClientFactory;
+ _cache = cache;
+ _updateOptions = updateOptions;
+ _selfUpdateOptions = selfUpdateOptions;
+ _environment = environment;
+ _restartService = restartService;
+ _logger = logger;
+ }
+
+ public bool IsDockerEnvironment => IsRunningInDocker();
+
+ public async Task CheckLatestAsync(bool forceRefresh = false, CancellationToken cancellationToken = default)
+ {
+ var currentVersion = VersionService.Version;
+ var updateOptions = _updateOptions.CurrentValue;
+ var selfUpdateOptions = _selfUpdateOptions.CurrentValue;
+ var isDocker = IsRunningInDocker();
+
+ if (!selfUpdateOptions.Enabled)
+ {
+ return AppSelfUpdateInfo.Disabled(currentVersion, isDocker, "自动更新功能未启用");
+ }
+
+ var repo = (updateOptions.Repository ?? string.Empty).Trim();
+ if (string.IsNullOrWhiteSpace(repo) || !repo.Contains('/'))
+ {
+ return AppSelfUpdateInfo.Failed(currentVersion, isDocker, "UpdateCheck:Repository 配置无效(应为 owner/repo)");
+ }
+
+ if (!TryGetAssetKeyword(RuntimeInformation.ProcessArchitecture, out var assetKeyword, out var archLabel))
+ {
+ return AppSelfUpdateInfo.Failed(currentVersion, isDocker, $"当前架构不支持自动更新:{RuntimeInformation.ProcessArchitecture}");
+ }
+
+ var cacheKey = $"{CacheKeyPrefix}{repo}:{assetKeyword}";
+ if (!forceRefresh && _cache.TryGetValue(cacheKey, out var cachedObj) && cachedObj is AppSelfUpdateInfo cached)
+ return cached;
+
+ AppSelfUpdateInfo result;
+ try
+ {
+ var release = await FetchLatestReleaseAsync(repo, cancellationToken);
+ if (release == null || string.IsNullOrWhiteSpace(release.TagName))
+ {
+ result = AppSelfUpdateInfo.Failed(currentVersion, isDocker, "仓库未找到可用 Release,无法执行一键更新");
+ }
+ else
+ {
+ var latestVersion = ParseVersionFromTag(release.TagName);
+ if (latestVersion == null)
+ {
+ result = AppSelfUpdateInfo.Failed(currentVersion, isDocker, $"解析版本失败(tag={release.TagName})");
+ }
+ else
+ {
+ var updateAvailable = CompareVersions(latestVersion.Value, currentVersion) > 0;
+ var matchedAsset = PickAsset(release.Assets, assetKeyword);
+ var blockedReason = ResolveBlockedReason(
+ updateAvailable: updateAvailable,
+ assetFound: matchedAsset != null,
+ isDocker: isDocker,
+ dockerOnly: selfUpdateOptions.DockerOnly,
+ archLabel: archLabel);
+
+ var notes = NormalizeNotes(release.Body);
+
+ result = new AppSelfUpdateInfo
+ {
+ Success = true,
+ Enabled = true,
+ CurrentVersion = currentVersion,
+ LatestTag = release.TagName,
+ LatestVersion = latestVersion.Value.ToString(),
+ UpdateAvailable = updateAvailable,
+ Url = release.HtmlUrl,
+ PublishedAt = release.PublishedAt,
+ Notes = notes,
+ IsDocker = isDocker,
+ AssetName = matchedAsset?.Name,
+ AssetSizeBytes = matchedAsset?.Size,
+ AssetDownloadUrl = matchedAsset?.BrowserDownloadUrl,
+ CanApply = blockedReason == null,
+ BlockedReason = blockedReason,
+ CheckedAtUtc = DateTimeOffset.UtcNow
+ };
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Self update check failed");
+ result = AppSelfUpdateInfo.Failed(currentVersion, isDocker, ex.Message);
+ }
+
+ var cacheMinutes = updateOptions.CacheMinutes <= 0 ? 30 : updateOptions.CacheMinutes;
+ _cache.Set(cacheKey, result, new MemoryCacheEntryOptions
+ {
+ AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(cacheMinutes)
+ });
+
+ return result;
+ }
+
+ public async Task ApplyLatestAsync(CancellationToken cancellationToken = default)
+ {
+ var options = _selfUpdateOptions.CurrentValue;
+ if (!options.Enabled)
+ return AppSelfUpdateApplyResult.Failed("自动更新功能未启用");
+
+ if (options.DockerOnly && !IsRunningInDocker())
+ return AppSelfUpdateApplyResult.Failed("当前不是 Docker 运行环境,已禁止一键更新");
+
+ if (!await _applyLock.WaitAsync(0, cancellationToken))
+ return AppSelfUpdateApplyResult.Failed("已有更新任务在执行,请稍后重试");
+
+ try
+ {
+ var check = await CheckLatestAsync(forceRefresh: true, cancellationToken);
+ if (!check.Success)
+ return AppSelfUpdateApplyResult.Failed($"检查更新失败:{check.Error}");
+
+ if (!check.UpdateAvailable)
+ return AppSelfUpdateApplyResult.Failed($"当前已是最新版本(v{check.CurrentVersion})");
+
+ if (!check.CanApply || string.IsNullOrWhiteSpace(check.AssetDownloadUrl) || string.IsNullOrWhiteSpace(check.AssetName))
+ {
+ var reason = check.BlockedReason ?? "未找到匹配当前架构的更新包";
+ return AppSelfUpdateApplyResult.Failed(reason);
+ }
+
+ var workRoot = ResolveWorkRoot(options);
+ Directory.CreateDirectory(workRoot);
+
+ var workspaceDir = Path.Combine(workRoot, options.WorkDirectoryName.Trim().Length == 0 ? "self-update" : options.WorkDirectoryName.Trim());
+ var currentDir = Path.Combine(workRoot, options.CurrentDirectoryName.Trim().Length == 0 ? "app-current" : options.CurrentDirectoryName.Trim());
+ var backupDir = Path.Combine(workRoot, options.BackupDirectoryName.Trim().Length == 0 ? "app-previous" : options.BackupDirectoryName.Trim());
+ Directory.CreateDirectory(workspaceDir);
+
+ var stageDir = Path.Combine(workspaceDir, $"stage-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}");
+ var packageFileName = $"pkg-{Guid.NewGuid():N}-{SanitizeFileName(check.AssetName)}";
+ var packagePath = Path.Combine(workspaceDir, packageFileName);
+
+ try
+ {
+ await DownloadFileAsync(check.AssetDownloadUrl, packagePath, options.DownloadTimeoutSeconds, cancellationToken);
+
+ Directory.CreateDirectory(stageDir);
+ ZipFile.ExtractToDirectory(packagePath, stageDir, overwriteFiles: true);
+
+ var entryDll = Path.Combine(stageDir, "TelegramPanel.Web.dll");
+ if (!File.Exists(entryDll))
+ return AppSelfUpdateApplyResult.Failed("更新包结构无效:缺少 TelegramPanel.Web.dll");
+
+ PromoteCurrentDirectory(stageDir, currentDir, backupDir);
+ stageDir = string.Empty;
+
+ var restartDelaySeconds = options.RestartDelaySeconds;
+ if (restartDelaySeconds <= 0) restartDelaySeconds = 2;
+ _restartService.RequestRestart(TimeSpan.FromSeconds(restartDelaySeconds), $"self-update {check.LatestTag}");
+
+ _logger.LogWarning(
+ "Self update applied successfully. current={CurrentVersion} target={TargetTag} asset={AssetName}",
+ check.CurrentVersion,
+ check.LatestTag,
+ check.AssetName);
+
+ return AppSelfUpdateApplyResult.Succeeded(
+ $"更新包已部署:{check.AssetName},服务将在约 {restartDelaySeconds} 秒后重启",
+ check.LatestTag,
+ check.LatestVersion);
+ }
+ finally
+ {
+ TryDeleteFile(packagePath);
+ if (!string.IsNullOrWhiteSpace(stageDir))
+ TryDeleteDirectory(stageDir);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Apply self update failed");
+ return AppSelfUpdateApplyResult.Failed($"更新失败:{ex.Message}");
+ }
+ finally
+ {
+ _applyLock.Release();
+ }
+ }
+
+ private async Task FetchLatestReleaseAsync(string repo, CancellationToken cancellationToken)
+ {
+ var url = $"https://api.github.com/repos/{repo}/releases/latest";
+ var client = CreateGitHubClient();
+
+ using var resp = await client.GetAsync(url, cancellationToken);
+ if (resp.StatusCode == HttpStatusCode.NotFound)
+ return null;
+
+ resp.EnsureSuccessStatusCode();
+ var json = await resp.Content.ReadAsStringAsync(cancellationToken);
+ return JsonSerializer.Deserialize(json, JsonOptions);
+ }
+
+ private async Task DownloadFileAsync(
+ string url,
+ string path,
+ int timeoutSeconds,
+ CancellationToken cancellationToken)
+ {
+ var timeout = timeoutSeconds <= 0 ? 300 : timeoutSeconds;
+
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ cts.CancelAfter(TimeSpan.FromSeconds(timeout));
+
+ var client = _httpClientFactory.CreateClient();
+ client.DefaultRequestHeaders.UserAgent.ParseAdd("Telegram-Panel");
+ client.DefaultRequestHeaders.Accept.ParseAdd("application/octet-stream");
+
+ using var resp = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cts.Token);
+ resp.EnsureSuccessStatusCode();
+
+ await using var source = await resp.Content.ReadAsStreamAsync(cts.Token);
+ await using var destination = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, 81920, FileOptions.SequentialScan);
+ await source.CopyToAsync(destination, cts.Token);
+ }
+
+ private HttpClient CreateGitHubClient()
+ {
+ var client = _httpClientFactory.CreateClient();
+ client.DefaultRequestHeaders.UserAgent.ParseAdd("Telegram-Panel");
+ client.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json");
+ client.DefaultRequestHeaders.TryAddWithoutValidation("X-GitHub-Api-Version", "2022-11-28");
+ return client;
+ }
+
+ private string ResolveWorkRoot(SelfUpdateOptions options)
+ {
+ var configured = (options.WorkRootPath ?? string.Empty).Trim();
+ if (!string.IsNullOrWhiteSpace(configured))
+ {
+ if (Path.IsPathRooted(configured))
+ return configured;
+
+ return Path.GetFullPath(Path.Combine(_environment.ContentRootPath, configured));
+ }
+
+ if (Directory.Exists("/data"))
+ return "/data";
+
+ return _environment.ContentRootPath;
+ }
+
+ private static void PromoteCurrentDirectory(string stageDir, string currentDir, string backupDir)
+ {
+ if (Directory.Exists(backupDir))
+ Directory.Delete(backupDir, recursive: true);
+
+ var movedCurrent = false;
+ try
+ {
+ if (Directory.Exists(currentDir))
+ {
+ Directory.Move(currentDir, backupDir);
+ movedCurrent = true;
+ }
+
+ Directory.Move(stageDir, currentDir);
+ }
+ catch
+ {
+ if (movedCurrent && Directory.Exists(backupDir) && !Directory.Exists(currentDir))
+ {
+ Directory.Move(backupDir, currentDir);
+ }
+
+ throw;
+ }
+ }
+
+ private static void TryDeleteDirectory(string path)
+ {
+ try
+ {
+ if (Directory.Exists(path))
+ Directory.Delete(path, recursive: true);
+ }
+ catch
+ {
+ // ignore
+ }
+ }
+
+ private static void TryDeleteFile(string path)
+ {
+ try
+ {
+ if (File.Exists(path))
+ File.Delete(path);
+ }
+ catch
+ {
+ // ignore
+ }
+ }
+
+ private static string SanitizeFileName(string fileName)
+ {
+ var invalidChars = Path.GetInvalidFileNameChars();
+ var buffer = fileName.ToCharArray();
+ for (var i = 0; i < buffer.Length; i++)
+ {
+ if (invalidChars.Contains(buffer[i]))
+ buffer[i] = '_';
+ }
+
+ return new string(buffer);
+ }
+
+ private static string? NormalizeNotes(string? body)
+ {
+ if (string.IsNullOrWhiteSpace(body))
+ return null;
+
+ var notes = body.Trim();
+ if (notes.Length > 2000)
+ notes = notes[..2000].Trim() + "\n…";
+
+ return notes;
+ }
+
+ private static GitHubAssetDto? PickAsset(IReadOnlyList? assets, string assetKeyword)
+ {
+ if (assets == null || assets.Count == 0)
+ return null;
+
+ return assets
+ .Where(x => !string.IsNullOrWhiteSpace(x.Name))
+ .Where(x => x.Name!.Contains(assetKeyword, StringComparison.OrdinalIgnoreCase))
+ .Where(x => x.Name!.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
+ .Where(x => !string.IsNullOrWhiteSpace(x.BrowserDownloadUrl))
+ .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
+ .FirstOrDefault();
+ }
+
+ private static string? ResolveBlockedReason(bool updateAvailable, bool assetFound, bool isDocker, bool dockerOnly, string archLabel)
+ {
+ if (!updateAvailable)
+ return "当前已是最新版本";
+ if (!assetFound)
+ return $"未找到匹配架构 {archLabel} 的更新包";
+ if (dockerOnly && !isDocker)
+ return "当前不是 Docker 运行环境";
+ return null;
+ }
+
+ private static bool TryGetAssetKeyword(Architecture architecture, out string keyword, out string archLabel)
+ {
+ switch (architecture)
+ {
+ case Architecture.X64:
+ keyword = "linux-x64";
+ archLabel = "linux-x64";
+ return true;
+ case Architecture.Arm64:
+ keyword = "linux-arm64";
+ archLabel = "linux-arm64";
+ return true;
+ default:
+ keyword = string.Empty;
+ archLabel = architecture.ToString();
+ return false;
+ }
+ }
+
+ private static bool IsRunningInDocker()
+ {
+ var dotnetInContainer = Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER");
+ if (string.Equals(dotnetInContainer, "true", StringComparison.OrdinalIgnoreCase))
+ return true;
+
+ return File.Exists("/.dockerenv");
+ }
+
+ private static SimpleVersion? ParseVersionFromTag(string? tagName)
+ {
+ if (string.IsNullOrWhiteSpace(tagName))
+ return null;
+
+ var s = tagName.Trim();
+ if (s.StartsWith("v", StringComparison.OrdinalIgnoreCase))
+ s = s[1..];
+
+ var dash = s.IndexOf('-');
+ if (dash > 0) s = s[..dash];
+
+ var plus = s.IndexOf('+');
+ if (plus > 0) s = s[..plus];
+
+ var parts = s.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ if (parts.Length < 3)
+ return null;
+
+ if (!int.TryParse(parts[0], out var major)) return null;
+ if (!int.TryParse(parts[1], out var minor)) return null;
+ if (!int.TryParse(parts[2], out var patch)) return null;
+ return new SimpleVersion(major, minor, patch);
+ }
+
+ private static int CompareVersions(SimpleVersion latest, string current)
+ {
+ var cur = ParseVersionFromTag(current) ?? new SimpleVersion(0, 0, 0);
+ return latest.CompareTo(cur);
+ }
+
+ private readonly record struct SimpleVersion(int Major, int Minor, int Patch) : IComparable
+ {
+ public int CompareTo(SimpleVersion other)
+ {
+ var r = Major.CompareTo(other.Major);
+ if (r != 0) return r;
+ r = Minor.CompareTo(other.Minor);
+ if (r != 0) return r;
+ return Patch.CompareTo(other.Patch);
+ }
+
+ public override string ToString() => $"{Major}.{Minor}.{Patch}";
+ }
+
+ private sealed class GitHubReleaseDto
+ {
+ [JsonPropertyName("tag_name")]
+ public string? TagName { get; set; }
+
+ [JsonPropertyName("html_url")]
+ public string? HtmlUrl { get; set; }
+
+ [JsonPropertyName("published_at")]
+ public DateTimeOffset? PublishedAt { get; set; }
+
+ [JsonPropertyName("body")]
+ public string? Body { get; set; }
+
+ [JsonPropertyName("assets")]
+ public List? Assets { get; set; }
+ }
+
+ private sealed class GitHubAssetDto
+ {
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ [JsonPropertyName("browser_download_url")]
+ public string? BrowserDownloadUrl { get; set; }
+
+ [JsonPropertyName("size")]
+ public long Size { get; set; }
+ }
+}
+
+public sealed class AppSelfUpdateInfo
+{
+ public bool Success { get; init; }
+ public string? Error { get; init; }
+ public bool Enabled { get; init; }
+
+ public string CurrentVersion { get; init; } = "Unknown";
+ public string? LatestVersion { get; init; }
+ public string? LatestTag { get; init; }
+ public bool UpdateAvailable { get; init; }
+ public string? Url { get; init; }
+ public DateTimeOffset? PublishedAt { get; init; }
+ public string? Notes { get; init; }
+ public DateTimeOffset CheckedAtUtc { get; init; } = DateTimeOffset.UtcNow;
+
+ public bool IsDocker { get; init; }
+ public bool CanApply { get; init; }
+ public string? BlockedReason { get; init; }
+
+ public string? AssetName { get; init; }
+ public long? AssetSizeBytes { get; init; }
+ public string? AssetDownloadUrl { get; init; }
+
+ public static AppSelfUpdateInfo Disabled(string currentVersion, bool isDocker, string? reason = null) => new()
+ {
+ Success = true,
+ Enabled = false,
+ CurrentVersion = currentVersion,
+ IsDocker = isDocker,
+ UpdateAvailable = false,
+ CanApply = false,
+ BlockedReason = reason,
+ CheckedAtUtc = DateTimeOffset.UtcNow
+ };
+
+ public static AppSelfUpdateInfo Failed(string currentVersion, bool isDocker, string error) => new()
+ {
+ Success = false,
+ Enabled = true,
+ Error = error,
+ CurrentVersion = currentVersion,
+ IsDocker = isDocker,
+ UpdateAvailable = false,
+ CanApply = false,
+ CheckedAtUtc = DateTimeOffset.UtcNow
+ };
+}
+
+public sealed class AppSelfUpdateApplyResult
+{
+ public bool Success { get; init; }
+ public string Message { get; init; } = string.Empty;
+ public bool RestartScheduled { get; init; }
+ public string? LatestTag { get; init; }
+ public string? LatestVersion { get; init; }
+
+ public static AppSelfUpdateApplyResult Failed(string message) => new()
+ {
+ Success = false,
+ Message = message,
+ RestartScheduled = false
+ };
+
+ public static AppSelfUpdateApplyResult Succeeded(string message, string? latestTag, string? latestVersion) => new()
+ {
+ Success = true,
+ Message = message,
+ RestartScheduled = true,
+ LatestTag = latestTag,
+ LatestVersion = latestVersion
+ };
+}
diff --git a/src/TelegramPanel.Web/Services/SelfUpdateOptions.cs b/src/TelegramPanel.Web/Services/SelfUpdateOptions.cs
new file mode 100644
index 0000000..a447d82
--- /dev/null
+++ b/src/TelegramPanel.Web/Services/SelfUpdateOptions.cs
@@ -0,0 +1,44 @@
+namespace TelegramPanel.Web.Services;
+
+public sealed class SelfUpdateOptions
+{
+ ///
+ /// 是否启用应用内一键更新
+ ///
+ public bool Enabled { get; set; } = true;
+
+ ///
+ /// 仅允许在 Docker 容器内执行更新
+ ///
+ public bool DockerOnly { get; set; } = true;
+
+ ///
+ /// 更新工作根目录。为空时优先使用 /data,不存在则回退到 ContentRoot。
+ ///
+ public string WorkRootPath { get; set; } = "";
+
+ ///
+ /// 更新包下载与解压的临时目录名(位于 WorkRootPath 下)
+ ///
+ public string WorkDirectoryName { get; set; } = "self-update";
+
+ ///
+ /// 当前版本目录名(位于 WorkRootPath 下)
+ ///
+ public string CurrentDirectoryName { get; set; } = "app-current";
+
+ ///
+ /// 旧版本备份目录名(位于 WorkRootPath 下)
+ ///
+ public string BackupDirectoryName { get; set; } = "app-previous";
+
+ ///
+ /// 下载更新包超时秒数
+ ///
+ public int DownloadTimeoutSeconds { get; set; } = 300;
+
+ ///
+ /// 更新完成后,触发应用重启的延迟秒数
+ ///
+ public int RestartDelaySeconds { get; set; } = 2;
+}
diff --git a/src/TelegramPanel.Web/Services/UpdateCheckService.cs b/src/TelegramPanel.Web/Services/UpdateCheckService.cs
index 1eed80a..d036139 100644
--- a/src/TelegramPanel.Web/Services/UpdateCheckService.cs
+++ b/src/TelegramPanel.Web/Services/UpdateCheckService.cs
@@ -1,5 +1,6 @@
using System.Net;
using System.Text.Json;
+using System.Text.Json.Serialization;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
@@ -199,9 +200,16 @@ public sealed class UpdateCheckService
private sealed class GitHubReleaseDto
{
+ [JsonPropertyName("tag_name")]
public string? TagName { get; set; }
+
+ [JsonPropertyName("html_url")]
public string? HtmlUrl { get; set; }
+
+ [JsonPropertyName("published_at")]
public DateTimeOffset? PublishedAt { get; set; }
+
+ [JsonPropertyName("body")]
public string? Body { get; set; }
}