2026-11-05 16:05:45 +08:00

167 lines
7.8 KiB
C#

using System.Security.Cryptography;
using YD_Event.Application.Auth;
using YD_Event.Application.Common;
namespace YD_Event.Application.AppUpdate;
/// <summary>内部 Android 裁判端 APK 发布与升级检查。</summary>
[ApiDescriptionSettings("裁判端升级")]
[Route("api/app-updates")]
[Authorize]
public class AppUpdateController : IDynamicApiController
{
private const string PackageName = "com.yd.event.yd_pattern_scoring_system";
private const long MaxApkSize = 200L * 1024 * 1024;
private readonly ISqlSugarRepository<JudgeAppRelease> _releases;
private readonly AuthManager _auth;
private readonly IIdGenerator _ids;
private readonly FileService _files;
private readonly string _channel;
public AppUpdateController(ISqlSugarRepository<JudgeAppRelease> releases,
AuthManager auth, IIdGenerator ids, FileService files)
{
_releases = releases;
_auth = auth;
_ids = ids;
_files = files;
_channel = Environment.GetEnvironmentVariable("APP_UPDATE_CHANNEL")?.Trim().ToLowerInvariant() ?? "production";
}
/// <summary>登录前即可查询当前环境的最新 APK。</summary>
[HttpGet("latest")]
[AllowAnonymous]
public async Task<object> Latest()
{
var release = await _releases.AsQueryable()
.Where(x => x.Channel == _channel && x.ReleaseStatus == 1 && x.PackageName == PackageName)
.OrderByDescending(x => x.VersionCode).FirstAsync();
return release == null ? new { available = false } : new
{
available = true, release.Id, release.PackageName, release.VersionCode,
release.VersionName, release.NotesZh, release.NotesEn,
release.IsMandatory, release.FileSize, release.Sha256,
release.CertificateSha256, downloadUrl = $"/api/app-updates/download/{release.Id}",
release.PublishedTime
};
}
/// <summary>只允许下载当前环境已发布的版本,沿用现有文件服务流式返回。</summary>
[HttpGet("download/{id:long}")]
[AllowAnonymous]
public async Task<IActionResult> Download(long id)
{
var release = await _releases.GetFirstAsync(x => x.Id == id && x.Channel == _channel &&
x.PackageName == PackageName && x.ReleaseStatus == 1);
if (release == null || string.IsNullOrWhiteSpace(release.FileUrl)) return new NotFoundResult();
return _files.Download(release.FileUrl);
}
[HttpGet("admin/releases")]
public async Task<object> Releases()
{
EnsureAdmin();
return await _releases.AsQueryable().Where(x => x.Channel == _channel)
.OrderByDescending(x => x.VersionCode).ToListAsync();
}
[HttpPost("admin/upload")]
[DisplayName("上传裁判端 APK 草稿")]
[RequestSizeLimit(MaxApkSize + 1024 * 1024)]
[RequestFormLimits(MultipartBodyLengthLimit = MaxApkSize + 1024 * 1024)]
public async Task<JudgeAppRelease> Upload([FromForm] IFormFile file,
[FromForm] string versionName, [FromForm] int versionCode,
[FromForm] string certificateSha256, [FromForm] string notesZh,
[FromForm] string? notesEn = null,
[FromForm] bool isMandatory = false)
{
EnsureAdmin();
if (file == null || file.Length < 1024 || file.Length > MaxApkSize ||
!string.Equals(Path.GetExtension(file.FileName), ".apk", StringComparison.OrdinalIgnoreCase))
throw Oops.Bah("仅允许上传 1KB 至 200MB 的 APK");
if (string.IsNullOrWhiteSpace(notesZh)) throw Oops.Bah("请填写中文更新说明");
if (versionCode <= 0 || string.IsNullOrWhiteSpace(versionName) || versionName.Length > 64)
throw Oops.Bah("请填写有效的版本名和正整数版本号");
// 本阶段由超级管理员提供 build-info.json 中的元数据;后续再恢复 APK 内部解析与签名比对。
// 文件哈希仍在服务端计算,平板安装前仍会核对真实 APK 的包名、版本和签名。
var fingerprint = certificateSha256?.Trim().Replace(":", "").ToUpperInvariant();
if (fingerprint == null || !Regex.IsMatch(fingerprint, "^[0-9A-F]{64}$"))
throw Oops.Bah("请填写 64 位的公开签名证书 SHA-256 指纹");
var existing = await _releases.GetFirstAsync(x => x.Channel == _channel &&
x.PackageName == PackageName && x.VersionCode == versionCode);
if (existing != null) throw Oops.Bah("该版本号已存在,请使用新的递增版本号");
var temp = Path.Combine(Path.GetTempPath(), $"yd-app-release-{Guid.NewGuid():N}.apk");
try
{
await using (var output = System.IO.File.Create(temp))
await using (var source = file.OpenReadStream())
await source.CopyToAsync(output);
await using (var input = System.IO.File.OpenRead(temp))
{
var magic = new byte[4];
if (await input.ReadAsync(magic) != 4 || magic[0] != 0x50 || magic[1] != 0x4B || magic[2] != 0x03 || magic[3] != 0x04)
throw Oops.Bah("文件不是有效的 APK/ZIP");
}
await using var hashStream = System.IO.File.OpenRead(temp);
var sha = Convert.ToHexString(await SHA256.HashDataAsync(hashStream));
await using var uploadStream = System.IO.File.OpenRead(temp);
var release = new JudgeAppRelease
{
Id = _ids.NextId(), Channel = _channel, PackageName = PackageName,
VersionCode = versionCode, VersionName = versionName.Trim(),
NotesZh = notesZh?.Trim() ?? string.Empty, NotesEn = notesEn?.Trim() ?? string.Empty,
IsMandatory = isMandatory, FileSize = uploadStream.Length,
Sha256 = sha, CertificateSha256 = fingerprint,
FileUrl = await _files.UploadFileStreamAsync($"app-releases/{_channel}/{Guid.NewGuid():N}.apk", uploadStream),
ReleaseStatus = 0, CreatedUserId = _auth.UserId, CreatedTime = DateTime.UtcNow
};
await _releases.InsertAsync(release);
return release;
}
finally { if (System.IO.File.Exists(temp)) System.IO.File.Delete(temp); }
}
[HttpPost("admin/{id:long}/publish")]
public async Task<JudgeAppRelease> Publish(long id)
{
EnsureAdmin();
var release = await FindOwn(id);
if (release.ReleaseStatus != 0) throw Oops.Bah("只有草稿可以发布");
var latest = await _releases.AsQueryable()
.Where(x => x.Channel == _channel && x.PackageName == PackageName && x.ReleaseStatus != 0)
.OrderByDescending(x => x.VersionCode).FirstAsync();
if (latest != null && release.VersionCode <= latest.VersionCode)
throw Oops.Bah("版本号必须高于当前已发布版本");
release.ReleaseStatus = 1;
release.PublishedUserId = _auth.UserId;
release.PublishedTime = DateTime.UtcNow;
await _releases.UpdateAsync(release);
return release;
}
[HttpPost("admin/{id:long}/withdraw")]
public async Task<JudgeAppRelease> Withdraw(long id)
{
EnsureAdmin();
var release = await FindOwn(id);
if (release.ReleaseStatus != 1) throw Oops.Bah("只有已发布版本可以撤回");
release.ReleaseStatus = 2;
release.WithdrawnUserId = _auth.UserId;
release.WithdrawnTime = DateTime.UtcNow;
await _releases.UpdateAsync(release);
return release;
}
private void EnsureAdmin()
{
if (!_auth.IsSuperAdmin) throw Oops.Bah("仅超级管理员可管理裁判端版本");
}
private async Task<JudgeAppRelease> FindOwn(long id)
{
var release = await _releases.GetFirstAsync(x => x.Id == id && x.Channel == _channel);
return release ?? throw Oops.Bah("版本不存在");
}
}