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; } }