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

204 lines
9.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Security.Cryptography;
using Microsoft.Extensions.Options;
using YD_Event.Application.Auth;
using YD_Event.Application.Common;
using YD_Event.Core.Options;
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 IOptionsMonitor<OssConnectionOptions> _ossOptions;
private readonly string _channel;
public AppUpdateController(ISqlSugarRepository<JudgeAppRelease> releases,
AuthManager auth, IIdGenerator ids, FileService files,
IOptionsMonitor<OssConnectionOptions> ossOptions)
{
_releases = releases;
_auth = auth;
_ids = ids;
_files = files;
_ossOptions = ossOptions;
_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 = DownloadUrlFor(release),
release.PublishedTime
};
}
/// <summary>OSS 已启用时直连阿里云/CDN;配置缺失或不安全时仍使用受控下载接口。</summary>
private string DownloadUrlFor(JudgeAppRelease release)
{
var fallback = $"/api/app-updates/download/{release.Id}";
// Direct delivery is opt-in: a private bucket without CDN/public-read
// must continue using the existing server-side OSS stream.
var cdn = Environment.GetEnvironmentVariable("APP_UPDATE_DIRECT_DOWNLOAD_BASE_URL");
if (string.IsNullOrWhiteSpace(cdn)) return fallback;
var oss = _ossOptions.CurrentValue;
if (!oss.Enable || string.IsNullOrWhiteSpace(oss.Domain) ||
!Uri.TryCreate(oss.Domain.TrimEnd('/') + "/", UriKind.Absolute, out var ossBase) ||
ossBase.Scheme != Uri.UriSchemeHttps ||
!Uri.TryCreate(release.FileUrl, UriKind.Absolute, out var fileUri) ||
fileUri.Scheme != Uri.UriSchemeHttps ||
!fileUri.AbsoluteUri.StartsWith(ossBase.AbsoluteUri, StringComparison.OrdinalIgnoreCase))
return fallback;
// FileUrl comes from the configured OSS domain at upload time. Never turn
// an arbitrary stored URL into an untrusted redirect/download host.
var objectKey = fileUri.AbsoluteUri[ossBase.AbsoluteUri.Length..];
if (!objectKey.StartsWith($"app-releases/{_channel}/", StringComparison.Ordinal) ||
objectKey.Contains("..", StringComparison.Ordinal) ||
!objectKey.EndsWith(".apk", StringComparison.OrdinalIgnoreCase))
return fallback;
if (!Uri.TryCreate(cdn.TrimEnd('/') + "/", UriKind.Absolute, out var cdnBase) ||
cdnBase.Scheme != Uri.UriSchemeHttps || !string.IsNullOrEmpty(cdnBase.UserInfo) ||
!string.IsNullOrEmpty(cdnBase.Query) || !string.IsNullOrEmpty(cdnBase.Fragment))
return fallback;
return new Uri(cdnBase, objectKey).AbsoluteUri;
}
/// <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("版本不存在");
}
}