2026-11-05 15:00:57 +08:00
|
|
|
|
using System.Security.Cryptography;
|
2026-11-05 17:54:19 +08:00
|
|
|
|
using Microsoft.Extensions.Logging;
|
2026-11-05 16:39:39 +08:00
|
|
|
|
using Microsoft.Extensions.Options;
|
2026-11-05 15:00:57 +08:00
|
|
|
|
using YD_Event.Application.Auth;
|
|
|
|
|
|
using YD_Event.Application.Common;
|
2026-11-05 17:11:12 +08:00
|
|
|
|
using YD_Event.Core.Infrastructure.Aliyun.Oss;
|
2026-11-05 16:39:39 +08:00
|
|
|
|
using YD_Event.Core.Options;
|
2026-11-05 15:00:57 +08:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-11-05 17:11:12 +08:00
|
|
|
|
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/";
|
2026-11-05 15:00:57 +08:00
|
|
|
|
private readonly ISqlSugarRepository<JudgeAppRelease> _releases;
|
|
|
|
|
|
private readonly AuthManager _auth;
|
|
|
|
|
|
private readonly IIdGenerator _ids;
|
|
|
|
|
|
private readonly FileService _files;
|
2026-11-05 17:11:12 +08:00
|
|
|
|
private readonly OssService _oss;
|
2026-11-05 17:54:19 +08:00
|
|
|
|
private readonly ILogger<AppUpdateController> _logger;
|
2026-11-05 16:39:39 +08:00
|
|
|
|
private readonly IOptionsMonitor<OssConnectionOptions> _ossOptions;
|
2026-11-05 15:00:57 +08:00
|
|
|
|
private readonly string _channel;
|
|
|
|
|
|
|
|
|
|
|
|
public AppUpdateController(ISqlSugarRepository<JudgeAppRelease> releases,
|
2026-11-05 16:39:39 +08:00
|
|
|
|
AuthManager auth, IIdGenerator ids, FileService files,
|
2026-11-05 17:54:19 +08:00
|
|
|
|
IOptionsMonitor<OssConnectionOptions> ossOptions, OssService oss,
|
|
|
|
|
|
ILogger<AppUpdateController> logger)
|
2026-11-05 15:00:57 +08:00
|
|
|
|
{
|
|
|
|
|
|
_releases = releases;
|
|
|
|
|
|
_auth = auth;
|
|
|
|
|
|
_ids = ids;
|
|
|
|
|
|
_files = files;
|
2026-11-05 17:11:12 +08:00
|
|
|
|
_oss = oss;
|
2026-11-05 17:54:19 +08:00
|
|
|
|
_logger = logger;
|
2026-11-05 16:39:39 +08:00
|
|
|
|
_ossOptions = ossOptions;
|
2026-11-05 15:00:57 +08:00
|
|
|
|
_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,
|
2026-11-05 16:39:39 +08:00
|
|
|
|
release.CertificateSha256, downloadUrl = DownloadUrlFor(release),
|
2026-11-05 17:11:12 +08:00
|
|
|
|
fallbackDownloadUrl = $"/api/app-updates/download/{release.Id}",
|
2026-11-05 15:00:57 +08:00
|
|
|
|
release.PublishedTime
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-05 16:39:39 +08:00
|
|
|
|
/// <summary>OSS 已启用时直连阿里云/CDN;配置缺失或不安全时仍使用受控下载接口。</summary>
|
|
|
|
|
|
private string DownloadUrlFor(JudgeAppRelease release)
|
|
|
|
|
|
{
|
|
|
|
|
|
var fallback = $"/api/app-updates/download/{release.Id}";
|
2026-11-05 17:11:12 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-11-05 16:39:39 +08:00
|
|
|
|
|
2026-11-05 17:11:12 +08:00
|
|
|
|
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);
|
2026-11-05 16:39:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-05 16:05:45 +08:00
|
|
|
|
/// <summary>只允许下载当前环境已发布的版本,沿用现有文件服务流式返回。</summary>
|
2026-11-05 15:00:57 +08:00
|
|
|
|
[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();
|
2026-11-05 17:11:12 +08:00
|
|
|
|
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)
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-11-05 16:05:45 +08:00
|
|
|
|
return _files.Download(release.FileUrl);
|
2026-11-05 15:00:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
[HttpGet("admin/releases")]
|
|
|
|
|
|
public async Task<object> Releases()
|
|
|
|
|
|
{
|
|
|
|
|
|
EnsureAdmin();
|
|
|
|
|
|
return await _releases.AsQueryable().Where(x => x.Channel == _channel)
|
|
|
|
|
|
.OrderByDescending(x => x.VersionCode).ToListAsync();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-05 17:54:19 +08:00
|
|
|
|
/// <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());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-05 15:00:57 +08:00
|
|
|
|
[HttpPost("admin/upload")]
|
|
|
|
|
|
[DisplayName("上传裁判端 APK 草稿")]
|
|
|
|
|
|
[RequestSizeLimit(MaxApkSize + 1024 * 1024)]
|
|
|
|
|
|
[RequestFormLimits(MultipartBodyLengthLimit = MaxApkSize + 1024 * 1024)]
|
|
|
|
|
|
public async Task<JudgeAppRelease> Upload([FromForm] IFormFile file,
|
2026-11-05 16:05:45 +08:00
|
|
|
|
[FromForm] string versionName, [FromForm] int versionCode,
|
|
|
|
|
|
[FromForm] string certificateSha256, [FromForm] string notesZh,
|
|
|
|
|
|
[FromForm] string? notesEn = null,
|
2026-11-05 15:00:57 +08:00
|
|
|
|
[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("请填写中文更新说明");
|
2026-11-05 16:05:45 +08:00
|
|
|
|
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("该版本号已存在,请使用新的递增版本号");
|
2026-11-05 17:11:12 +08:00
|
|
|
|
if (!_ossOptions.CurrentValue.Enable)
|
|
|
|
|
|
throw Oops.Bah("裁判端 APK OSS 未启用,请先配置阿里云对象存储");
|
|
|
|
|
|
if (!Regex.IsMatch(_channel, "^[a-z0-9-]{1,32}$"))
|
|
|
|
|
|
throw Oops.Bah("APP_UPDATE_CHANNEL 配置无效");
|
2026-11-05 15:00:57 +08:00
|
|
|
|
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);
|
2026-11-05 17:11:12 +08:00
|
|
|
|
var objectKey = $"{RELEASE_OSS_PREFIX}{_channel}/{Guid.NewGuid():N}.apk";
|
|
|
|
|
|
await _oss.PutObjectToBucketAsync(RELEASE_OSS_BUCKET, objectKey, uploadStream);
|
2026-11-05 15:00:57 +08:00
|
|
|
|
var release = new JudgeAppRelease
|
|
|
|
|
|
{
|
|
|
|
|
|
Id = _ids.NextId(), Channel = _channel, PackageName = PackageName,
|
2026-11-05 16:05:45 +08:00
|
|
|
|
VersionCode = versionCode, VersionName = versionName.Trim(),
|
2026-11-05 15:00:57 +08:00
|
|
|
|
NotesZh = notesZh?.Trim() ?? string.Empty, NotesEn = notesEn?.Trim() ?? string.Empty,
|
|
|
|
|
|
IsMandatory = isMandatory, FileSize = uploadStream.Length,
|
2026-11-05 16:05:45 +08:00
|
|
|
|
Sha256 = sha, CertificateSha256 = fingerprint,
|
2026-11-05 17:11:12 +08:00
|
|
|
|
FileUrl = $"{RELEASE_OSS_DOMAIN}/{objectKey}",
|
2026-11-05 15:00:57 +08:00
|
|
|
|
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("版本不存在");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
}
|