release(main): 发布 v1.31.0(TData 导入导出优化 + Docker 一键更新 + CI/CD 完整化)

本次版本主要更新:
- ✨ TData 导入能力完善:支持完整导入 tdata 压缩包并修复会话校验链路,导入后可用性更高。
- ✨ TData 导出链路优化:支持 Telethon/TData 双格式导出,修复官方客户端登录兼容问题,优先已授权 DCSession,补齐 user_id 并修复导出缓存复用导致旧包重复下载。
- ✨ Bot 频道异常治理:新增频道删除、频道状态检测(正常/异常)与异常筛选,解决机器人被踢/频道失效后的脏数据残留。
- ✨ Docker 一键更新能力:支持面板内检测 Release 并执行一键更新,自动下载匹配架构更新包到 /data/app-current,更新后自动重启生效。
- ⚡ 更新交互统一:更新入口统一到左上角版本弹窗,支持检测到新版本后自动提醒、确认更新与 Latest Release Notes 展示。
- ⚡ CI/CD 工作流补齐:新增 Docker 自动构建推送(GHCR),新增 Release 自动发布与 changelog,自动上传 linux-x64/linux-arm64 更新包资产。
- ⚡ 部署链路优化:docker-compose 默认优先拉取 GHCR 远程镜像,文档改为 compose 单路径,稳定版/开发版切换更直接。
- 📚 文档与版本发布:README 增加 v1.31 更新内容并同步更新入口说明;项目版本号统一升级至 1.31.0。
This commit is contained in:
meoacgx committed 2026-03-03 03:19:36 +08:00
commit 90589301e9
14 files changed
+918 -90

No files matched your search

+9 -2
View File
@@ -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/*)
+1
View File
@@ -14,6 +14,7 @@ on:
paths:
- "Dockerfile"
- "Dockerfile.local"
- "docker/**"
- "docker-compose.yml"
- "src/**"
- ".github/workflows/docker.yml"
+56 -1
View File
@@ -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)」会自动匹配并下载对应架构的更新包。
+4 -4
View File
@@ -5,10 +5,10 @@
<!-- 主版本: 重大变更/不兼容更新 -->
<!-- 次版本: 新功能/兼容性更新 -->
<!-- 修订号: Bug修复/小改进 -->
<Version>1.30.0</Version>
<AssemblyVersion>1.30.0.0</AssemblyVersion>
<FileVersion>1.30.0.0</FileVersion>
<InformationalVersion>1.30.0</InformationalVersion>
<Version>1.31.0</Version>
<AssemblyVersion>1.31.0.0</AssemblyVersion>
<FileVersion>1.31.0.0</FileVersion>
<InformationalVersion>1.31.0</InformationalVersion>
<!-- 项目元信息 -->
<Authors>Telegram Panel Contributors</Authors>
+3 -1
View File
@@ -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"]
+51 -55
View File
@@ -17,16 +17,17 @@
👥 <b><a href="https://t.me/vpsbbq">站长交流群</a></b>
</p>
## 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/<owner>/telegram-panel:latest
# 查看日志
docker compose logs -f
# 开发版
docker pull ghcr.io/<owner>/telegram-panel:dev-latest
# 更新到当前 .env 指定的镜像版本
docker compose pull
docker compose up -d
# 指定版本
docker pull ghcr.io/<owner>/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 资产,则一键更新会提示不可用。
## 截图
+2 -3
View File
@@ -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:
+35
View File
@@ -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"
@@ -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
<MudDialog>
<DialogContent>
@@ -9,6 +11,9 @@
<MudText Typo="Typo.body2">
<strong>版本:</strong>@VersionService.Version
</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">
<strong>运行环境:</strong>@(_updateInfo?.IsDocker == true ? "Docker" : "非 Docker")
</MudText>
<MudLink Href="https://github.com/moeacgx/Telegram-Panel"
Target="_blank"
rel="noopener noreferrer">
@@ -19,15 +24,34 @@
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.subtitle2">检查更新</MudText>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Disabled="@_checking" OnClick="ForceRefresh">
@if (_checking)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
}
立即检查
</MudButton>
<MudStack Row="true" Spacing="1">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Disabled="@_checking" OnClick="ForceRefresh">
@if (_checking)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
}
立即检查
</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Warning"
Disabled="@(!CanApply || _updating)"
OnClick="ApplyUpdate">
@if (_updating)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
}
一键更新并重启
</MudButton>
</MudStack>
</MudStack>
@if (AutoPrompt && _updateInfo is { Success: true, UpdateAvailable: true })
{
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="true">
检测到新版本,是否现在执行一键更新?
</MudAlert>
}
@if (_updateInfo == null)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">正在检查…</MudText>
@@ -38,9 +62,9 @@
检查失败:@_updateInfo.Error
</MudAlert>
}
else if (_updateInfo.Source == "disabled")
else if (!_updateInfo.Enabled)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">已禁用检查更新</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">已禁用自动更新</MudText>
}
else if (_updateInfo.UpdateAvailable)
{
@@ -54,12 +78,18 @@
}
</MudAlert>
@if (!string.IsNullOrWhiteSpace(_updateInfo.Notes))
@if (!string.IsNullOrWhiteSpace(_updateInfo.AssetName))
{
<MudText Typo="Typo.subtitle2">更新说明(节选)</MudText>
<MudPaper Outlined="true" Class="pa-3" Style="max-height: 240px; overflow: auto;">
<MudText Typo="Typo.body2" Style="white-space: pre-wrap; user-select: text;">@_updateInfo.Notes</MudText>
</MudPaper>
<MudText Typo="Typo.caption" Color="Color.Secondary">
匹配更新包:@_updateInfo.AssetName
</MudText>
}
@if (!string.IsNullOrWhiteSpace(_updateInfo.BlockedReason))
{
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="true">
无法一键更新:@_updateInfo.BlockedReason
</MudAlert>
}
}
else
@@ -69,10 +99,18 @@
</MudText>
}
@if (!string.IsNullOrWhiteSpace(_updateInfo?.Notes))
{
<MudText Typo="Typo.subtitle2">Latest Release Notes</MudText>
<MudPaper Outlined="true" Class="pa-3" Style="max-height: 260px; overflow: auto;">
<MudText Typo="Typo.body2" Style="white-space: pre-wrap; user-select: text;">@_updateInfo.Notes</MudText>
</MudPaper>
}
@if (_updateInfo != null)
{
<MudText Typo="Typo.caption" Color="Color.Secondary">
最近检查:@_updateInfo.CheckedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")(来源:@_updateInfo.Source)
最近检查:@_updateInfo.CheckedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")
</MudText>
}
</MudStack>
@@ -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();
@@ -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<TelegramPanel.Web.Components.Dialogs.VersionInfoDialog>("版本信息", new DialogParameters(), options);
await Task.CompletedTask;
var parameters = new DialogParameters
{
[nameof(TelegramPanel.Web.Components.Dialogs.VersionInfoDialog.AutoPrompt)] = autoPrompt
};
var dialog = DialogService.Show<TelegramPanel.Web.Components.Dialogs.VersionInfoDialog>("版本信息", 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
{
+2
View File
@@ -409,7 +409,9 @@ builder.Services.AddScoped<ChannelAdminDefaultsService>();
builder.Services.AddScoped<ChannelAdminPresetsService>();
builder.Services.AddScoped<ChannelInvitePresetsService>();
builder.Services.Configure<UpdateCheckOptions>(builder.Configuration.GetSection("UpdateCheck"));
builder.Services.Configure<SelfUpdateOptions>(builder.Configuration.GetSection("SelfUpdate"));
builder.Services.AddSingleton<UpdateCheckService>();
builder.Services.AddSingleton<AppSelfUpdateService>();
builder.Services.Configure<PanelTimeZoneOptions>(builder.Configuration.GetSection("System"));
builder.Services.AddSingleton<PanelTimeZoneService>();
builder.Services.AddHostedService<BatchTaskBackgroundService>();
@@ -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<UpdateCheckOptions> _updateOptions;
private readonly IOptionsMonitor<SelfUpdateOptions> _selfUpdateOptions;
private readonly IWebHostEnvironment _environment;
private readonly AppRestartService _restartService;
private readonly ILogger<AppSelfUpdateService> _logger;
private readonly SemaphoreSlim _applyLock = new(1, 1);
public AppSelfUpdateService(
IHttpClientFactory httpClientFactory,
IMemoryCache cache,
IOptionsMonitor<UpdateCheckOptions> updateOptions,
IOptionsMonitor<SelfUpdateOptions> selfUpdateOptions,
IWebHostEnvironment environment,
AppRestartService restartService,
ILogger<AppSelfUpdateService> logger)
{
_httpClientFactory = httpClientFactory;
_cache = cache;
_updateOptions = updateOptions;
_selfUpdateOptions = selfUpdateOptions;
_environment = environment;
_restartService = restartService;
_logger = logger;
}
public bool IsDockerEnvironment => IsRunningInDocker();
public async Task<AppSelfUpdateInfo> 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<AppSelfUpdateApplyResult> 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<GitHubReleaseDto?> 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<GitHubReleaseDto>(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<GitHubAssetDto>? 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<SimpleVersion>
{
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<GitHubAssetDto>? 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
};
}
@@ -0,0 +1,44 @@
namespace TelegramPanel.Web.Services;
public sealed class SelfUpdateOptions
{
/// <summary>
/// 是否启用应用内一键更新
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// 仅允许在 Docker 容器内执行更新
/// </summary>
public bool DockerOnly { get; set; } = true;
/// <summary>
/// 更新工作根目录。为空时优先使用 /data,不存在则回退到 ContentRoot。
/// </summary>
public string WorkRootPath { get; set; } = "";
/// <summary>
/// 更新包下载与解压的临时目录名(位于 WorkRootPath 下)
/// </summary>
public string WorkDirectoryName { get; set; } = "self-update";
/// <summary>
/// 当前版本目录名(位于 WorkRootPath 下)
/// </summary>
public string CurrentDirectoryName { get; set; } = "app-current";
/// <summary>
/// 旧版本备份目录名(位于 WorkRootPath 下)
/// </summary>
public string BackupDirectoryName { get; set; } = "app-previous";
/// <summary>
/// 下载更新包超时秒数
/// </summary>
public int DownloadTimeoutSeconds { get; set; } = 300;
/// <summary>
/// 更新完成后,触发应用重启的延迟秒数
/// </summary>
public int RestartDelaySeconds { get; set; } = 2;
}
@@ -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; }
}