APP热更新
This commit is contained in:
1 parent
692b521453
commit
93997ea8b1
21 files changed
+8130
-4
No files matched your search
@@ -53,6 +53,9 @@ Jwt__ExpireMinutes=60
|
||||
|
||||
AllowedHosts=jiaowu.example.edu.cn
|
||||
Cors__Origins__0=https://jiaowu.example.edu.cn
|
||||
Cors__Origins__1=capacitor://localhost
|
||||
Cors__Origins__2=https://localhost
|
||||
Cors__Origins__3=http://localhost
|
||||
|
||||
# 二维码使用的公网根地址;反向代理部署时必须填写最终 HTTPS 地址。
|
||||
OfficialDocuments__InstitutionName=明序大学
|
||||
|
||||
@@ -67,6 +67,32 @@ npm run cap:open:android
|
||||
并记录签到设备摘要、IP、失败次数和异常频率,供任课教师在考勤明细中复核。Android
|
||||
最低版本为 API 26;相机和精确位置权限均按需申请。
|
||||
|
||||
### App 前端热更新
|
||||
|
||||
App 内置自建 OTA 更新器。它只更新 `dist` 中的 HTML、JavaScript、CSS 和静态资源;
|
||||
新增或升级 Capacitor 插件、修改原生权限、Android/iOS 工程或原生版本号时,仍必须
|
||||
重新构建并安装 App。首次启用更新器也需要发布一次包含更新插件的新 App,之后普通
|
||||
前端修复不再需要重新打包。
|
||||
|
||||
生成更新 ZIP:
|
||||
|
||||
```powershell
|
||||
Set-Location web
|
||||
npm ci
|
||||
npm run ota:package -- --version 1.0.1
|
||||
```
|
||||
|
||||
ZIP 会生成到 `.artifacts/app-updates`,根目录直接包含 `index.html`。使用
|
||||
SuperAdmin 进入“运维与审计 → App 前端热更新”,上传 ZIP,填写目标平台、通道和
|
||||
兼容的原生版本后先保存为草稿,再执行发布。当前 Android 工程的 `versionName` 为
|
||||
`1.0`,因此对应更新包的“兼容原生版本”应填写 `1.0`。
|
||||
|
||||
App 启动后向 `/api/app-updates/latest` 检查版本,在后台下载并校验服务端提供的
|
||||
SHA-256,下次启动时切换。新资源若未能成功启动,原生更新器会自动回滚。再次发布
|
||||
已归档版本即可回滚正式通道;不同原生版本、Android/iOS、测试/正式通道彼此隔离。
|
||||
更新版本元数据和 ZIP 保存在数据库中,部署新服务端版本前必须先执行
|
||||
`--migrate-only`。
|
||||
|
||||
## MySQL 8.4 生产部署
|
||||
|
||||
非 Development 环境只允许使用 MySQL。构建发布包与数据库配置相互独立:
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Data;
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.RegularExpressions;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/app-updates")]
|
||||
public sealed partial class AppUpdatesController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
private const long MaximumBundleBytes = 30 * 1024 * 1024;
|
||||
private const long MaximumExpandedBytes = 150 * 1024 * 1024;
|
||||
private const int MaximumZipEntries = 10_000;
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("app-updates")]
|
||||
[HttpGet("latest")]
|
||||
public async Task<ActionResult<AppUpdateCheckResponse>> GetLatest(
|
||||
[FromQuery, Required] string platform,
|
||||
[FromQuery, Required] string nativeVersion,
|
||||
[FromQuery] string channel = "production",
|
||||
[FromQuery] string? currentVersion = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryParsePlatform(platform, out var parsedPlatform))
|
||||
return ValidationProblem("平台必须为 android 或 ios。");
|
||||
if (!TryParseChannel(channel, out var parsedChannel))
|
||||
return ValidationProblem("更新通道必须为 production 或 staging。");
|
||||
|
||||
var normalizedNativeVersion = nativeVersion.Trim();
|
||||
if (!NativeVersionRegex().IsMatch(normalizedNativeVersion))
|
||||
return ValidationProblem("原生版本格式无效。");
|
||||
|
||||
var release = await db.AppUpdateReleases.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Platform == parsedPlatform &&
|
||||
x.Channel == parsedChannel &&
|
||||
x.NativeVersion == normalizedNativeVersion &&
|
||||
x.Status == AppUpdateReleaseStatus.Published)
|
||||
.OrderByDescending(x => x.PublishedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (release is null ||
|
||||
string.Equals(
|
||||
release.Version,
|
||||
currentVersion?.Trim(),
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Ok(new AppUpdateCheckResponse(
|
||||
false,
|
||||
release?.Version,
|
||||
normalizedNativeVersion,
|
||||
parsedPlatform,
|
||||
parsedChannel,
|
||||
null,
|
||||
release?.ReleaseNotes,
|
||||
release?.FileSize,
|
||||
release?.Sha256,
|
||||
release?.PublishedAt));
|
||||
}
|
||||
|
||||
return Ok(new AppUpdateCheckResponse(
|
||||
true,
|
||||
release.Version,
|
||||
release.NativeVersion,
|
||||
release.Platform,
|
||||
release.Channel,
|
||||
$"app-updates/releases/{release.Id}/bundle",
|
||||
release.ReleaseNotes,
|
||||
release.FileSize,
|
||||
release.Sha256,
|
||||
release.PublishedAt));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("app-updates")]
|
||||
[HttpGet("releases/{id:guid}/bundle")]
|
||||
public async Task<IActionResult> DownloadBundle(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var release = await db.AppUpdateReleases.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Id == id &&
|
||||
x.Status != AppUpdateReleaseStatus.Draft)
|
||||
.Select(x => new
|
||||
{
|
||||
x.BundleContent,
|
||||
x.FileName,
|
||||
x.Sha256
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (release is null) return NotFound();
|
||||
|
||||
Response.Headers.ETag = $"\"sha256-{release.Sha256}\"";
|
||||
Response.Headers.CacheControl = "public,max-age=31536000,immutable";
|
||||
return File(
|
||||
release.BundleContent,
|
||||
"application/zip",
|
||||
release.FileName,
|
||||
enableRangeProcessing: true);
|
||||
}
|
||||
|
||||
[Authorize(Roles = SystemRoles.SuperAdmin)]
|
||||
[HttpGet("releases")]
|
||||
public async Task<ActionResult<PagedResult<AppUpdateReleaseItem>>> GetReleases(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] string? platform = null,
|
||||
[FromQuery] string? channel = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (page < 1 || pageSize is < 1 or > 100)
|
||||
return ValidationProblem("页码必须大于零,每页数量必须在 1 到 100 之间。");
|
||||
|
||||
var query = db.AppUpdateReleases.AsNoTracking().AsQueryable();
|
||||
if (!string.IsNullOrWhiteSpace(platform))
|
||||
{
|
||||
if (!TryParsePlatform(platform, out var parsedPlatform))
|
||||
return ValidationProblem("平台必须为 android 或 ios。");
|
||||
query = query.Where(x => x.Platform == parsedPlatform);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(channel))
|
||||
{
|
||||
if (!TryParseChannel(channel, out var parsedChannel))
|
||||
return ValidationProblem("更新通道必须为 production 或 staging。");
|
||||
query = query.Where(x => x.Channel == parsedChannel);
|
||||
}
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var rows = await query
|
||||
.OrderByDescending(x => x.PublishedAt ?? x.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new AppUpdateReleaseItem(
|
||||
x.Id,
|
||||
x.Platform,
|
||||
x.Channel,
|
||||
x.Version,
|
||||
x.NativeVersion,
|
||||
x.Status,
|
||||
x.ReleaseNotes,
|
||||
x.FileName,
|
||||
x.FileSize,
|
||||
x.Sha256,
|
||||
x.CreatedByUserName,
|
||||
x.CreatedAt,
|
||||
x.PublishedByUserName,
|
||||
x.PublishedAt))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return Ok(new PagedResult<AppUpdateReleaseItem>(
|
||||
rows,
|
||||
total,
|
||||
page,
|
||||
pageSize));
|
||||
}
|
||||
|
||||
[Authorize(Roles = SystemRoles.SuperAdmin)]
|
||||
[Consumes("multipart/form-data")]
|
||||
[RequestSizeLimit(MaximumBundleBytes + 1024 * 1024)]
|
||||
[HttpPost("releases")]
|
||||
public async Task<ActionResult<AppUpdateReleaseItem>> UploadRelease(
|
||||
[FromForm] AppUpdateUploadRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryParsePlatform(request.Platform, out var platform))
|
||||
return ValidationProblem("平台必须为 android 或 ios。");
|
||||
if (!TryParseChannel(request.Channel, out var channel))
|
||||
return ValidationProblem("更新通道必须为 production 或 staging。");
|
||||
|
||||
var version = request.Version.Trim();
|
||||
var nativeVersion = request.NativeVersion.Trim();
|
||||
if (!ReleaseVersionRegex().IsMatch(version))
|
||||
return ValidationProblem(
|
||||
"热更新版本必须使用语义版本,例如 1.0.1 或 1.0.1-beta.1。");
|
||||
if (!NativeVersionRegex().IsMatch(nativeVersion))
|
||||
return ValidationProblem("原生版本格式无效,例如 1.0 或 1.0.0。");
|
||||
if (request.ReleaseNotes?.Trim().Length > 1000)
|
||||
return ValidationProblem("更新说明不能超过 1000 字。");
|
||||
if (request.Bundle.Length is <= 0 or > MaximumBundleBytes)
|
||||
return ValidationProblem("更新包必须大于 0 字节且不超过 30 MB。");
|
||||
|
||||
var exists = await db.AppUpdateReleases.AsNoTracking()
|
||||
.AnyAsync(
|
||||
x =>
|
||||
x.Platform == platform &&
|
||||
x.Channel == channel &&
|
||||
x.NativeVersion == nativeVersion &&
|
||||
x.Version == version,
|
||||
cancellationToken);
|
||||
if (exists)
|
||||
return Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "更新版本已存在",
|
||||
Detail = "同一平台、通道和原生版本下不能重复上传相同版本。",
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
|
||||
await using var source = request.Bundle.OpenReadStream();
|
||||
await using var buffer = new MemoryStream(
|
||||
checked((int)request.Bundle.Length));
|
||||
await source.CopyToAsync(buffer, cancellationToken);
|
||||
var content = buffer.ToArray();
|
||||
var archiveError = ValidateArchive(content);
|
||||
if (archiveError is not null) return ValidationProblem(archiveError);
|
||||
|
||||
var fileName = Path.GetFileName(request.Bundle.FileName.Trim());
|
||||
if (string.IsNullOrWhiteSpace(fileName) || fileName.Length > 180)
|
||||
fileName = $"jiaowu-web-{version}.zip";
|
||||
|
||||
var release = new AppUpdateRelease
|
||||
{
|
||||
Platform = platform,
|
||||
Channel = channel,
|
||||
Version = version,
|
||||
NativeVersion = nativeVersion,
|
||||
ReleaseNotes = NullIfWhiteSpace(request.ReleaseNotes),
|
||||
FileName = fileName,
|
||||
FileSize = content.LongLength,
|
||||
Sha256 = Convert.ToHexString(
|
||||
SHA256.HashData(content))
|
||||
.ToLowerInvariant(),
|
||||
BundleContent = content,
|
||||
CreatedByUserName = User.Identity?.Name ?? "unknown"
|
||||
};
|
||||
db.AppUpdateReleases.Add(release);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return CreatedAtAction(
|
||||
nameof(GetReleases),
|
||||
new { id = release.Id },
|
||||
ToItem(release));
|
||||
}
|
||||
|
||||
[Authorize(Roles = SystemRoles.SuperAdmin)]
|
||||
[HttpPost("releases/{id:guid}/publish")]
|
||||
public async Task<ActionResult<AppUpdateReleaseItem>> PublishRelease(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var strategy = db.Database.CreateExecutionStrategy();
|
||||
var published = await strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(
|
||||
IsolationLevel.Serializable,
|
||||
cancellationToken);
|
||||
var target = await db.AppUpdateReleases
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (target is null) return null;
|
||||
|
||||
var currentlyPublished = await db.AppUpdateReleases
|
||||
.Where(x =>
|
||||
x.Id != target.Id &&
|
||||
x.Platform == target.Platform &&
|
||||
x.Channel == target.Channel &&
|
||||
x.NativeVersion == target.NativeVersion &&
|
||||
x.Status == AppUpdateReleaseStatus.Published)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var release in currentlyPublished)
|
||||
release.Status = AppUpdateReleaseStatus.Archived;
|
||||
|
||||
target.Status = AppUpdateReleaseStatus.Published;
|
||||
target.PublishedAt = DateTime.UtcNow;
|
||||
target.PublishedByUserName = User.Identity?.Name ?? "unknown";
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return ToItem(target);
|
||||
});
|
||||
|
||||
return published is null ? NotFound() : Ok(published);
|
||||
}
|
||||
|
||||
[Authorize(Roles = SystemRoles.SuperAdmin)]
|
||||
[HttpDelete("releases/{id:guid}")]
|
||||
public async Task<IActionResult> DeleteRelease(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var release = await db.AppUpdateReleases
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (release is null) return NotFound();
|
||||
if (release.Status == AppUpdateReleaseStatus.Published)
|
||||
return Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "正式版本不能删除",
|
||||
Detail = "请先发布另一个兼容版本,再删除已归档的更新包。",
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
|
||||
db.AppUpdateReleases.Remove(release);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private static string? ValidateArchive(byte[] content)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = new MemoryStream(content, writable: false);
|
||||
using var archive = new ZipArchive(
|
||||
stream,
|
||||
ZipArchiveMode.Read,
|
||||
leaveOpen: false);
|
||||
if (archive.Entries.Count is 0 or > MaximumZipEntries)
|
||||
return $"更新包文件数量必须在 1 到 {MaximumZipEntries} 之间。";
|
||||
|
||||
long expandedBytes = 0;
|
||||
var hasRootIndex = false;
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
var normalized = entry.FullName.Replace('\\', '/');
|
||||
if (normalized.StartsWith('/') ||
|
||||
normalized.Split('/').Any(part => part == ".."))
|
||||
{
|
||||
return "更新包包含不安全的文件路径。";
|
||||
}
|
||||
if (string.Equals(
|
||||
normalized,
|
||||
"index.html",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
hasRootIndex = true;
|
||||
}
|
||||
expandedBytes = checked(expandedBytes + entry.Length);
|
||||
if (expandedBytes > MaximumExpandedBytes)
|
||||
return "更新包解压后的总大小不能超过 150 MB。";
|
||||
}
|
||||
|
||||
return hasRootIndex
|
||||
? null
|
||||
: "更新包根目录必须包含 index.html;请直接压缩 dist 目录中的内容。";
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is InvalidDataException or IOException or OverflowException)
|
||||
{
|
||||
return "更新包不是有效的 ZIP 文件,或文件结构已损坏。";
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParsePlatform(
|
||||
string value,
|
||||
out AppUpdatePlatform platform) =>
|
||||
Enum.TryParse(value.Trim(), ignoreCase: true, out platform) &&
|
||||
Enum.IsDefined(platform);
|
||||
|
||||
private static bool TryParseChannel(
|
||||
string value,
|
||||
out AppUpdateChannel channel) =>
|
||||
Enum.TryParse(value.Trim(), ignoreCase: true, out channel) &&
|
||||
Enum.IsDefined(channel);
|
||||
|
||||
private static string? NullIfWhiteSpace(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static AppUpdateReleaseItem ToItem(AppUpdateRelease release) =>
|
||||
new(
|
||||
release.Id,
|
||||
release.Platform,
|
||||
release.Channel,
|
||||
release.Version,
|
||||
release.NativeVersion,
|
||||
release.Status,
|
||||
release.ReleaseNotes,
|
||||
release.FileName,
|
||||
release.FileSize,
|
||||
release.Sha256,
|
||||
release.CreatedByUserName,
|
||||
release.CreatedAt,
|
||||
release.PublishedByUserName,
|
||||
release.PublishedAt);
|
||||
|
||||
[GeneratedRegex(
|
||||
@"^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$",
|
||||
RegexOptions.CultureInvariant)]
|
||||
private static partial Regex ReleaseVersionRegex();
|
||||
|
||||
[GeneratedRegex(
|
||||
@"^\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$",
|
||||
RegexOptions.CultureInvariant)]
|
||||
private static partial Regex NativeVersionRegex();
|
||||
}
|
||||
|
||||
public sealed class AppUpdateUploadRequest
|
||||
{
|
||||
[Required]
|
||||
public required IFormFile Bundle { get; init; }
|
||||
|
||||
[Required, MaxLength(20)]
|
||||
public required string Platform { get; init; }
|
||||
|
||||
[Required, MaxLength(20)]
|
||||
public required string Channel { get; init; }
|
||||
|
||||
[Required, MaxLength(40)]
|
||||
public required string Version { get; init; }
|
||||
|
||||
[Required, MaxLength(40)]
|
||||
public required string NativeVersion { get; init; }
|
||||
|
||||
[MaxLength(1000)]
|
||||
public string? ReleaseNotes { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AppUpdateCheckResponse(
|
||||
bool Available,
|
||||
string? Version,
|
||||
string NativeVersion,
|
||||
AppUpdatePlatform Platform,
|
||||
AppUpdateChannel Channel,
|
||||
string? DownloadUrl,
|
||||
string? ReleaseNotes,
|
||||
long? FileSize,
|
||||
string? Sha256,
|
||||
DateTime? PublishedAt);
|
||||
|
||||
public sealed record AppUpdateReleaseItem(
|
||||
Guid Id,
|
||||
AppUpdatePlatform Platform,
|
||||
AppUpdateChannel Channel,
|
||||
string Version,
|
||||
string NativeVersion,
|
||||
AppUpdateReleaseStatus Status,
|
||||
string? ReleaseNotes,
|
||||
string FileName,
|
||||
long FileSize,
|
||||
string Sha256,
|
||||
string CreatedByUserName,
|
||||
DateTime CreatedAt,
|
||||
string? PublishedByUserName,
|
||||
DateTime? PublishedAt);
|
||||
Loaded 3 of 21 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user