update:直接上传阿里云apk

This commit is contained in:
ltx 2026-11-05 17:11:12 +08:00
parent 2fa9d1e29d
commit 56d1f9af31
2 changed files with 68 additions and 28 deletions

View File

@ -2,6 +2,7 @@ using System.Security.Cryptography;
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;
@ -14,21 +15,27 @@ 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 IOptionsMonitor<OssConnectionOptions> _ossOptions;
private readonly string _channel;
public AppUpdateController(ISqlSugarRepository<JudgeAppRelease> releases,
AuthManager auth, IIdGenerator ids, FileService files,
IOptionsMonitor<OssConnectionOptions> ossOptions)
IOptionsMonitor<OssConnectionOptions> ossOptions, OssService oss)
{
_releases = releases;
_auth = auth;
_ids = ids;
_files = files;
_oss = oss;
_ossOptions = ossOptions;
_channel = Environment.GetEnvironmentVariable("APP_UPDATE_CHANNEL")?.Trim().ToLowerInvariant() ?? "production";
}
@ -47,6 +54,7 @@ public class AppUpdateController : IDynamicApiController
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
};
}
@ -55,32 +63,25 @@ public class AppUpdateController : IDynamicApiController
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;
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;
}
// 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;
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>
@ -91,6 +92,14 @@ public class AppUpdateController : IDynamicApiController
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);
}
@ -127,6 +136,10 @@ public class AppUpdateController : IDynamicApiController
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
{
@ -142,6 +155,8 @@ public class AppUpdateController : IDynamicApiController
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,
@ -149,7 +164,7 @@ public class AppUpdateController : IDynamicApiController
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),
FileUrl = $"{RELEASE_OSS_DOMAIN}/{objectKey}",
ReleaseStatus = 0, CreatedUserId = _auth.UserId, CreatedTime = DateTime.UtcNow
};
await _releases.InsertAsync(release);

View File

@ -94,6 +94,23 @@ public class OssService : ISingleton
});
}
/// <summary>Upload an application release to a dedicated OSS bucket.</summary>
public async Task PutObjectToBucketAsync(string bucket, string objectName, Stream stream)
{
if (!_options.Enable)
throw new InvalidOperationException("OSS未启用,无法上传裁判端安装包。");
if (string.IsNullOrWhiteSpace(bucket) || string.IsNullOrWhiteSpace(objectName))
throw new ArgumentException("OSS Bucket 或对象路径无效。");
if (!stream.CanSeek || stream.Length > 200L * 1024 * 1024)
throw new ArgumentException("安装包文件流无效或超过 200MB。");
await _retryPolicy.ExecuteAsync(() => Task.Run(() =>
{
stream.Position = 0;
_ossClient.PutObject(bucket, objectName, stream);
}));
}
/// <summary>
/// 异步分片上传大文件
/// </summary>
@ -185,6 +202,14 @@ public class OssService : ISingleton
return ossObject.Content;
}
/// <summary>Read a release stored in a dedicated OSS bucket.</summary>
public Stream GetObjectFromBucket(string bucket, string objectName)
{
if (!_options.Enable)
throw new InvalidOperationException("OSS未启用,无法下载裁判端安装包。");
return _ossClient.GetObject(bucket, objectName).Content;
}
/// <summary>
/// 删除文件
/// </summary>
@ -209,4 +234,4 @@ public class OssService : ISingleton
return _ossClient.DoesObjectExist(_options.Bucket, objectName);
}
}
}