using System.Diagnostics; using System.Security.Cryptography; using YD_Event.Application.Auth; using YD_Event.Application.Common; namespace YD_Event.Application.AppUpdate; /// 内部 Android 裁判端 APK 发布与升级检查。 [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 _releases; private readonly AuthManager _auth; private readonly IIdGenerator _ids; private readonly FileService _files; private readonly string _channel; public AppUpdateController(ISqlSugarRepository releases, AuthManager auth, IIdGenerator ids, FileService files) { _releases = releases; _auth = auth; _ids = ids; _files = files; _channel = Environment.GetEnvironmentVariable("APP_UPDATE_CHANNEL")?.Trim().ToLowerInvariant() ?? "production"; } /// 登录前即可查询当前环境的最新 APK。 [HttpGet("latest")] [AllowAnonymous] public async Task 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 }; } /// 只允许下载当前环境已发布的版本,文件由现有对象存储分发。 [HttpGet("download/{id:long}")] [AllowAnonymous] public async Task 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 new RedirectResult(release.FileUrl); } [HttpGet("admin/releases")] public async Task 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 Upload([FromForm] IFormFile file, [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("请填写中文更新说明"); 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"); } var metadata = await InspectApk(temp); if (metadata.PackageName != PackageName || metadata.VersionCode <= 0) throw Oops.Bah("APK 包名或版本号不正确"); var expected = Environment.GetEnvironmentVariable("APP_RELEASE_CERT_SHA256")?.Trim().ToUpperInvariant(); if (expected == null || !Regex.IsMatch(expected, "^[0-9A-F]{64}$")) throw Oops.Bah("服务器未配置正式签名指纹,禁止发布"); if (metadata.CertificateSha256 != expected) throw Oops.Bah("APK 签名与正式签名不一致"); var existing = await _releases.GetFirstAsync(x => x.Channel == _channel && x.PackageName == PackageName && x.VersionCode == metadata.VersionCode); if (existing != null) throw Oops.Bah("该版本号已存在,请使用新的递增版本号"); 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 = metadata.VersionCode, VersionName = metadata.VersionName, NotesZh = notesZh?.Trim() ?? string.Empty, NotesEn = notesEn?.Trim() ?? string.Empty, IsMandatory = isMandatory, FileSize = uploadStream.Length, Sha256 = sha, CertificateSha256 = metadata.CertificateSha256, 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 Publish(long id) { EnsureAdmin(); var release = await FindOwn(id); if (release.ReleaseStatus != 0) throw Oops.Bah("只有草稿可以发布"); if (_channel == "production" && (!Uri.TryCreate(release.FileUrl, UriKind.Absolute, out var fileUri) || fileUri.Scheme != Uri.UriSchemeHttps)) throw Oops.Bah("正式环境的 APK 下载地址必须为 HTTPS"); 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 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 FindOwn(long id) { var release = await _releases.GetFirstAsync(x => x.Id == id && x.Channel == _channel); return release ?? throw Oops.Bah("版本不存在"); } private static async Task<(string PackageName, int VersionCode, string VersionName, string CertificateSha256)> InspectApk(string path) { var directory = Environment.GetEnvironmentVariable("ANDROID_BUILD_TOOLS_DIR"); if (string.IsNullOrWhiteSpace(directory)) throw Oops.Bah("服务器未配置 Android build-tools,禁止盲目发布"); var aapt = Path.Combine(directory, OperatingSystem.IsWindows() ? "aapt.exe" : "aapt"); var signer = Path.Combine(directory, OperatingSystem.IsWindows() ? "apksigner.bat" : "apksigner"); var badging = await RunTool(aapt, "dump", "badging", path); var match = Regex.Match(badging, "package: name='([^']+)' versionCode='([0-9]+)' versionName='([^']*)'"); if (!match.Success) throw Oops.Bah("无法读取 APK 包名及版本"); var result = await RunTool(signer, "verify", "--print-certs", path); var digest = Regex.Match(result, "certificate SHA-256 digest: ([0-9a-fA-F]{64})"); if (!digest.Success) throw Oops.Bah("无法验证 APK 签名"); return (match.Groups[1].Value, int.Parse(match.Groups[2].Value), match.Groups[3].Value, digest.Groups[1].Value.ToUpperInvariant()); } private static async Task RunTool(string executable, params string[] arguments) { if (!System.IO.File.Exists(executable)) throw Oops.Bah("Android build-tools 不完整"); ProcessStartInfo start; if (OperatingSystem.IsWindows() && executable.EndsWith(".bat", StringComparison.OrdinalIgnoreCase)) { start = new ProcessStartInfo("cmd.exe") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; start.ArgumentList.Add("/d"); start.ArgumentList.Add("/c"); start.ArgumentList.Add("\"" + executable + "\" " + string.Join(" ", arguments.Select(x => "\"" + x.Replace("\"", "") + "\""))); } else { start = new ProcessStartInfo(executable) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; foreach (var argument in arguments) start.ArgumentList.Add(argument); } using var process = Process.Start(start) ?? throw Oops.Bah("无法启动 APK 检查工具"); var output = process.StandardOutput.ReadToEndAsync(); var error = process.StandardError.ReadToEndAsync(); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); try { await process.WaitForExitAsync(timeout.Token); } catch (OperationCanceledException) { process.Kill(entireProcessTree: true); throw Oops.Bah("APK 检查超时"); } if (process.ExitCode != 0) throw Oops.Bah("APK 检查失败:" + (await error).Trim()); return await output; } }