update:直传bug修复
This commit is contained in:
parent
56d1f9af31
commit
76226dc111
19
YD_Event.Application/AppUpdate/AppReleaseUploadInputs.cs
Normal file
19
YD_Event.Application/AppUpdate/AppReleaseUploadInputs.cs
Normal file
@ -0,0 +1,19 @@
|
||||
namespace YD_Event.Application.AppUpdate;
|
||||
|
||||
public sealed class AppReleaseUploadTicketInput
|
||||
{
|
||||
public int VersionCode { get; set; }
|
||||
public long FileSize { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CompleteAppReleaseUploadInput
|
||||
{
|
||||
public string ObjectKey { get; set; } = string.Empty;
|
||||
public long FileSize { get; set; }
|
||||
public string VersionName { get; set; } = string.Empty;
|
||||
public int VersionCode { get; set; }
|
||||
public string CertificateSha256 { get; set; } = string.Empty;
|
||||
public string NotesZh { get; set; } = string.Empty;
|
||||
public string? NotesEn { get; set; }
|
||||
public bool IsMandatory { get; set; }
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using YD_Event.Application.Auth;
|
||||
using YD_Event.Application.Common;
|
||||
@ -24,18 +25,21 @@ public class AppUpdateController : IDynamicApiController
|
||||
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)
|
||||
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";
|
||||
}
|
||||
@ -111,6 +115,97 @@ public class AppUpdateController : IDynamicApiController
|
||||
.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)]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -15,6 +15,7 @@ public class OssService : ISingleton
|
||||
{
|
||||
private readonly OssConnectionOptions _options;
|
||||
private readonly IOss _ossClient;
|
||||
private readonly IOss _releaseClient;
|
||||
private readonly ILogger<OssService> _logger;
|
||||
private readonly AsyncPolicy _retryPolicy;
|
||||
|
||||
@ -26,6 +27,12 @@ public class OssService : ISingleton
|
||||
if (_options.Enable)
|
||||
{
|
||||
_ossClient = new OssClient(_options.Endpoint, _options.AccessKeyId, _options.AccessKeySecret);
|
||||
var secureEndpoint = _options.Endpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|
||||
? "https://" + _options.Endpoint[7..]
|
||||
: _options.Endpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
|
||||
? _options.Endpoint
|
||||
: "https://" + _options.Endpoint;
|
||||
_releaseClient = new OssClient(secureEndpoint, _options.AccessKeyId, _options.AccessKeySecret);
|
||||
}
|
||||
|
||||
// 定义重试策略:最多重试3次,每次间隔指数增长
|
||||
@ -107,7 +114,7 @@ public class OssService : ISingleton
|
||||
await _retryPolicy.ExecuteAsync(() => Task.Run(() =>
|
||||
{
|
||||
stream.Position = 0;
|
||||
_ossClient.PutObject(bucket, objectName, stream);
|
||||
_releaseClient.PutObject(bucket, objectName, stream);
|
||||
}));
|
||||
}
|
||||
|
||||
@ -207,7 +214,35 @@ public class OssService : ISingleton
|
||||
{
|
||||
if (!_options.Enable)
|
||||
throw new InvalidOperationException("OSS未启用,无法下载裁判端安装包。");
|
||||
return _ossClient.GetObject(bucket, objectName).Content;
|
||||
return _releaseClient.GetObject(bucket, objectName).Content;
|
||||
}
|
||||
|
||||
/// <summary>Issue a short-lived HTTPS PUT URL for browser-to-OSS upload.</summary>
|
||||
public Uri PresignPutToBucket(string bucket, string objectName, DateTime expiresAt)
|
||||
{
|
||||
if (!_options.Enable)
|
||||
throw new InvalidOperationException("OSS未启用,无法签发直传地址。");
|
||||
var request = new GeneratePresignedUriRequest(bucket, objectName, SignHttpMethod.Put)
|
||||
{
|
||||
Expiration = expiresAt,
|
||||
ContentType = "application/octet-stream"
|
||||
};
|
||||
return _releaseClient.GeneratePresignedUri(request);
|
||||
}
|
||||
|
||||
/// <summary>Promote a temporary upload without routing the APK through the app server.</summary>
|
||||
public void CopyObjectWithinBucket(string bucket, string sourceKey, string destinationKey)
|
||||
{
|
||||
if (!_options.Enable)
|
||||
throw new InvalidOperationException("OSS未启用,无法完成安装包上传。");
|
||||
_releaseClient.CopyObject(new CopyObjectRequest(bucket, sourceKey, bucket, destinationKey));
|
||||
}
|
||||
|
||||
public void DeleteObjectFromBucket(string bucket, string objectName)
|
||||
{
|
||||
if (!_options.Enable)
|
||||
throw new InvalidOperationException("OSS未启用,无法清理临时安装包。");
|
||||
_releaseClient.DeleteObject(bucket, objectName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user