using System.Security.Cryptography; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using YD_Event.Application.Auth; using YD_Event.Application.Common; using YD_Event.Core.Infrastructure.Aliyun.Oss; using YD_Event.Core.Options; 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 const string RELEASE_OSS_BUCKET = "yuedong-wechatapplet"; private const string RELEASE_OSS_DOMAIN = "https://yuedong-wechatapplet.oss-cn-shanghai.aliyuncs.com"; // Keep the directory spelling requested for the existing OSS layout. private const string RELEASE_OSS_PREFIX = "app-realse/"; private readonly ISqlSugarRepository _releases; private readonly AuthManager _auth; private readonly IIdGenerator _ids; private readonly FileService _files; private readonly OssService _oss; private readonly ILogger _logger; private readonly IOptionsMonitor _ossOptions; private readonly string _channel; public AppUpdateController(ISqlSugarRepository releases, AuthManager auth, IIdGenerator ids, FileService files, IOptionsMonitor ossOptions, OssService oss, ILogger logger) { _releases = releases; _auth = auth; _ids = ids; _files = files; _oss = oss; _logger = logger; _ossOptions = ossOptions; _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 = DownloadUrlFor(release), fallbackDownloadUrl = $"/api/app-updates/download/{release.Id}", release.PublishedTime }; } /// OSS 已启用时直连阿里云/CDN;配置缺失或不安全时仍使用受控下载接口。 private string DownloadUrlFor(JudgeAppRelease release) { var fallback = $"/api/app-updates/download/{release.Id}"; if (TryGetReleaseObjectKey(release.FileUrl, out _)) return release.FileUrl; // Earlier releases may reside on the server or a different bucket. // Never rewrite their URL to the dedicated release bucket. return fallback; } private static bool TryGetReleaseObjectKey(string fileUrl, out string objectKey) { objectKey = string.Empty; if (!Uri.TryCreate(fileUrl, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps || !string.IsNullOrEmpty(uri.UserInfo) || !string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment)) return false; var baseUrl = RELEASE_OSS_DOMAIN + "/"; if (!uri.AbsoluteUri.StartsWith(baseUrl + RELEASE_OSS_PREFIX, StringComparison.OrdinalIgnoreCase)) return false; objectKey = uri.AbsoluteUri[baseUrl.Length..]; return !objectKey.Contains("..", StringComparison.Ordinal) && objectKey.EndsWith(".apk", StringComparison.OrdinalIgnoreCase); } /// 只允许下载当前环境已发布的版本,沿用现有文件服务流式返回。 [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(); if (TryGetReleaseObjectKey(release.FileUrl, out var objectKey)) { var stream = _oss.GetObjectFromBucket(RELEASE_OSS_BUCKET, objectKey); return new FileStreamResult(stream, "application/vnd.android.package-archive") { FileDownloadName = Path.GetFileName(objectKey) }; } return _files.Download(release.FileUrl); } [HttpGet("admin/releases")] public async Task Releases() { EnsureAdmin(); return await _releases.AsQueryable().Where(x => x.Channel == _channel) .OrderByDescending(x => x.VersionCode).ToListAsync(); } /// 签发只允许超级管理员使用的短时效 OSS 直传地址;请求体不包含 APK。 [HttpPost("admin/upload-ticket")] public async Task CreateUploadTicket([FromBody] AppReleaseUploadTicketInput input) { EnsureAdmin(); if (!_ossOptions.CurrentValue.Enable) throw Oops.Bah("裁判端 APK OSS 未启用,请先配置阿里云对象存储"); if (!Regex.IsMatch(_channel, "^[a-z0-9-]{1,32}$")) throw Oops.Bah("APP_UPDATE_CHANNEL 配置无效"); if (input.VersionCode <= 0 || input.FileSize < 1024 || input.FileSize > MaxApkSize) throw Oops.Bah("APK 文件大小或版本号无效(最大 200MB)"); if (await _releases.IsAnyAsync(x => x.Channel == _channel && x.PackageName == PackageName && x.VersionCode == input.VersionCode)) throw Oops.Bah("该版本号已存在,请使用新的递增版本号"); var objectKey = $"{RELEASE_OSS_PREFIX}staging/{_channel}/{_auth.UserId}/{input.VersionCode}/{Guid.NewGuid():N}.apk"; var expiresAt = DateTime.UtcNow.AddMinutes(30); var uploadUrl = _oss.PresignPutToBucket(RELEASE_OSS_BUCKET, objectKey, expiresAt); return new { uploadUrl = uploadUrl.AbsoluteUri, objectKey, expiresAt, contentType = "application/octet-stream" }; } /// 从 OSS 校验直传文件,复制到不可由临时签名覆盖的正式路径,再建立草稿。 [HttpPost("admin/upload-complete")] public async Task CompleteUpload([FromBody] CompleteAppReleaseUploadInput input) { EnsureAdmin(); if (!_ossOptions.CurrentValue.Enable) throw Oops.Bah("裁判端 APK OSS 未启用"); if (input.FileSize < 1024 || input.FileSize > MaxApkSize || input.VersionCode <= 0 || string.IsNullOrWhiteSpace(input.VersionName) || input.VersionName.Length > 64 || string.IsNullOrWhiteSpace(input.NotesZh) || input.NotesZh.Length > 2000 || (input.NotesEn?.Length ?? 0) > 2000) throw Oops.Bah("请核对 APK 大小、版本号、版本名和更新说明"); var fingerprint = input.CertificateSha256?.Trim().Replace(":", "").ToUpperInvariant(); if (fingerprint == null || !Regex.IsMatch(fingerprint, "^[0-9A-F]{64}$")) throw Oops.Bah("请填写 64 位的公开签名证书 SHA-256 指纹"); var expectedPrefix = $"{RELEASE_OSS_PREFIX}staging/{_channel}/{_auth.UserId}/{input.VersionCode}/"; if (string.IsNullOrWhiteSpace(input.ObjectKey) || !input.ObjectKey.StartsWith(expectedPrefix, StringComparison.Ordinal) || !Regex.IsMatch(input.ObjectKey[expectedPrefix.Length..], "^[0-9a-f]{32}\\.apk$")) throw Oops.Bah("上传凭据不属于当前管理员或版本号"); var finalKey = $"{RELEASE_OSS_PREFIX}{_channel}/{Path.GetFileName(input.ObjectKey)}"; var fileUrl = $"{RELEASE_OSS_DOMAIN}/{finalKey}"; var existing = await _releases.GetFirstAsync(x => x.Channel == _channel && x.PackageName == PackageName && x.VersionCode == input.VersionCode); if (existing != null) { if (existing.FileUrl == fileUrl) return existing; throw Oops.Bah("该版本号已存在,请使用新的递增版本号"); } _oss.CopyObjectWithinBucket(RELEASE_OSS_BUCKET, input.ObjectKey, finalKey); var sha = await HashAndValidateReleaseAsync(finalKey, input.FileSize); var release = new JudgeAppRelease { Id = _ids.NextId(), Channel = _channel, PackageName = PackageName, VersionCode = input.VersionCode, VersionName = input.VersionName.Trim(), NotesZh = input.NotesZh.Trim(), NotesEn = input.NotesEn?.Trim() ?? string.Empty, IsMandatory = input.IsMandatory, FileSize = input.FileSize, Sha256 = sha, CertificateSha256 = fingerprint, FileUrl = fileUrl, ReleaseStatus = 0, CreatedUserId = _auth.UserId, CreatedTime = DateTime.UtcNow }; await _releases.InsertAsync(release); try { _oss.DeleteObjectFromBucket(RELEASE_OSS_BUCKET, input.ObjectKey); } catch (Exception error) { _logger.LogWarning(error, "无法清理直传暂存对象 {ObjectKey}", input.ObjectKey); } return release; } private async Task HashAndValidateReleaseAsync(string objectKey, long expectedSize) { await using var stream = _oss.GetObjectFromBucket(RELEASE_OSS_BUCKET, objectKey); using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); var buffer = new byte[81920]; var header = new byte[4]; var headerLength = 0; long size = 0; int read; while ((read = await stream.ReadAsync(buffer)) > 0) { if (size + read > expectedSize) throw Oops.Bah("OSS 文件大小与所选 APK 不一致"); for (var i = 0; i < read && headerLength < header.Length; i++) header[headerLength++] = buffer[i]; hash.AppendData(buffer, 0, read); size += read; } if (size != expectedSize || headerLength != 4 || header[0] != 0x50 || header[1] != 0x4B || header[2] != 0x03 || header[3] != 0x04) throw Oops.Bah("OSS 文件不完整或不是有效的 APK/ZIP"); return Convert.ToHexString(hash.GetHashAndReset()); } [HttpPost("admin/upload")] [DisplayName("上传裁判端 APK 草稿")] [RequestSizeLimit(MaxApkSize + 1024 * 1024)] [RequestFormLimits(MultipartBodyLengthLimit = MaxApkSize + 1024 * 1024)] public async Task 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("该版本号已存在,请使用新的递增版本号"); if (!_ossOptions.CurrentValue.Enable) throw Oops.Bah("裁判端 APK OSS 未启用,请先配置阿里云对象存储"); if (!Regex.IsMatch(_channel, "^[a-z0-9-]{1,32}$")) throw Oops.Bah("APP_UPDATE_CHANNEL 配置无效"); 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 objectKey = $"{RELEASE_OSS_PREFIX}{_channel}/{Guid.NewGuid():N}.apk"; await _oss.PutObjectToBucketAsync(RELEASE_OSS_BUCKET, objectKey, uploadStream); 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 = $"{RELEASE_OSS_DOMAIN}/{objectKey}", 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("只有草稿可以发布"); 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("版本不存在"); } }