2026-11-05 17:54:19 +08:00

314 lines
16 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.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;
/// <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 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<JudgeAppRelease> _releases;
private readonly AuthManager _auth;
private readonly IIdGenerator _ids;
private readonly FileService _files;
private readonly OssService _oss;
private readonly ILogger<AppUpdateController> _logger;
private readonly IOptionsMonitor<OssConnectionOptions> _ossOptions;
private readonly string _channel;
public AppUpdateController(ISqlSugarRepository<JudgeAppRelease> releases,
AuthManager auth, IIdGenerator ids, FileService files,
IOptionsMonitor<OssConnectionOptions> ossOptions, OssService oss,
ILogger<AppUpdateController> logger)
{
_releases = releases;
_auth = auth;
_ids = ids;
_files = files;
_oss = oss;
_logger = logger;
_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),
fallbackDownloadUrl = $"/api/app-updates/download/{release.Id}",
release.PublishedTime
};
}
/// <summary>OSS 已启用时直连阿里云/CDN;配置缺失或不安全时仍使用受控下载接口。</summary>
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);
}
/// <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();
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<object> Releases()
{
EnsureAdmin();
return await _releases.AsQueryable().Where(x => x.Channel == _channel)
.OrderByDescending(x => x.VersionCode).ToListAsync();
}
/// <summary>签发只允许超级管理员使用的短时效 OSS 直传地址;请求体不包含 APK。</summary>
[HttpPost("admin/upload-ticket")]
public async Task<object> 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" };
}
/// <summary>从 OSS 校验直传文件,复制到不可由临时签名覆盖的正式路径,再建立草稿。</summary>
[HttpPost("admin/upload-complete")]
public async Task<JudgeAppRelease> 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<string> 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<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("该版本号已存在,请使用新的递增版本号");
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<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("版本不存在");
}
}