From 56d1f9af31d99a17f796d5f6be40118437b2104c Mon Sep 17 00:00:00 2001 From: ltx <1006158920@qq.com> Date: Thu, 5 Nov 2026 17:11:12 +0800 Subject: [PATCH] =?UTF-8?q?update=EF=BC=9A=E7=9B=B4=E6=8E=A5=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E9=98=BF=E9=87=8C=E4=BA=91apk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AppUpdate/AppUpdateController.cs | 69 +++++++++++-------- .../Infrastructure/Aliyun/Oss/OssService.cs | 27 +++++++- 2 files changed, 68 insertions(+), 28 deletions(-) diff --git a/YD_Event.Application/AppUpdate/AppUpdateController.cs b/YD_Event.Application/AppUpdate/AppUpdateController.cs index 6b8d8b7..a119ff8 100644 --- a/YD_Event.Application/AppUpdate/AppUpdateController.cs +++ b/YD_Event.Application/AppUpdate/AppUpdateController.cs @@ -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 _releases; private readonly AuthManager _auth; private readonly IIdGenerator _ids; private readonly FileService _files; + private readonly OssService _oss; private readonly IOptionsMonitor _ossOptions; private readonly string _channel; public AppUpdateController(ISqlSugarRepository releases, AuthManager auth, IIdGenerator ids, FileService files, - IOptionsMonitor ossOptions) + IOptionsMonitor 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); } /// 只允许下载当前环境已发布的版本,沿用现有文件服务流式返回。 @@ -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); diff --git a/YD_Event.Core/Infrastructure/Aliyun/Oss/OssService.cs b/YD_Event.Core/Infrastructure/Aliyun/Oss/OssService.cs index 07bece9..b279f93 100644 --- a/YD_Event.Core/Infrastructure/Aliyun/Oss/OssService.cs +++ b/YD_Event.Core/Infrastructure/Aliyun/Oss/OssService.cs @@ -94,6 +94,23 @@ public class OssService : ISingleton }); } + /// Upload an application release to a dedicated OSS bucket. + 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); + })); + } + /// /// 异步分片上传大文件 /// @@ -185,6 +202,14 @@ public class OssService : ISingleton return ossObject.Content; } + /// Read a release stored in a dedicated OSS bucket. + public Stream GetObjectFromBucket(string bucket, string objectName) + { + if (!_options.Enable) + throw new InvalidOperationException("OSS未启用,无法下载裁判端安装包。"); + return _ossClient.GetObject(bucket, objectName).Content; + } + /// /// 删除文件 /// @@ -209,4 +234,4 @@ public class OssService : ISingleton return _ossClient.DoesObjectExist(_options.Bucket, objectName); } -} \ No newline at end of file +}