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:
commit
90589301e9
14 files changed
+918
-90
No files matched your search
+9
-2
@@ -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/*)
|
||||
|
||||
@@ -14,6 +14,7 @@ on:
|
||||
paths:
|
||||
- "Dockerfile"
|
||||
- "Dockerfile.local"
|
||||
- "docker/**"
|
||||
- "docker-compose.yml"
|
||||
- "src/**"
|
||||
- ".github/workflows/docker.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)」会自动匹配并下载对应架构的更新包。
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user