update:直接上传阿里云apk
This commit is contained in:
parent
2fa9d1e29d
commit
56d1f9af31
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user