Files
Academic-Affairs-System/src/Jiaowu.Api/Controllers/AppUpdatesController.cs
T
2026-07-29 10:34:33 +08:00

441 lines
16 KiB
C#

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