update:裁判端app版本控制&远程更新
This commit is contained in:
parent
22007f557c
commit
3da0f0d806
207
YD_Event.Application/AppUpdate/AppUpdateController.cs
Normal file
207
YD_Event.Application/AppUpdate/AppUpdateController.cs
Normal file
@ -0,0 +1,207 @@
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using YD_Event.Application.Auth;
|
||||
using YD_Event.Application.Common;
|
||||
|
||||
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;
|
||||
private readonly ISqlSugarRepository<JudgeAppRelease> _releases;
|
||||
private readonly AuthManager _auth;
|
||||
private readonly IIdGenerator _ids;
|
||||
private readonly FileService _files;
|
||||
private readonly string _channel;
|
||||
|
||||
public AppUpdateController(ISqlSugarRepository<JudgeAppRelease> releases,
|
||||
AuthManager auth, IIdGenerator ids, FileService files)
|
||||
{
|
||||
_releases = releases;
|
||||
_auth = auth;
|
||||
_ids = ids;
|
||||
_files = files;
|
||||
_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,
|
||||
release.CertificateSha256, downloadUrl = $"/api/app-updates/download/{release.Id}",
|
||||
release.PublishedTime
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>只允许下载当前环境已发布的版本,文件由现有对象存储分发。</summary>
|
||||
[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();
|
||||
return new RedirectResult(release.FileUrl);
|
||||
}
|
||||
|
||||
[HttpGet("admin/releases")]
|
||||
public async Task<object> Releases()
|
||||
{
|
||||
EnsureAdmin();
|
||||
return await _releases.AsQueryable().Where(x => x.Channel == _channel)
|
||||
.OrderByDescending(x => x.VersionCode).ToListAsync();
|
||||
}
|
||||
|
||||
[HttpPost("admin/upload")]
|
||||
[DisplayName("上传裁判端 APK 草稿")]
|
||||
[RequestSizeLimit(MaxApkSize + 1024 * 1024)]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = MaxApkSize + 1024 * 1024)]
|
||||
public async Task<JudgeAppRelease> Upload([FromForm] IFormFile file,
|
||||
[FromForm] string notesZh, [FromForm] string? notesEn = null,
|
||||
[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("请填写中文更新说明");
|
||||
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");
|
||||
}
|
||||
var metadata = await InspectApk(temp);
|
||||
if (metadata.PackageName != PackageName || metadata.VersionCode <= 0)
|
||||
throw Oops.Bah("APK 包名或版本号不正确");
|
||||
var expected = Environment.GetEnvironmentVariable("APP_RELEASE_CERT_SHA256")?.Trim().ToUpperInvariant();
|
||||
if (expected == null || !Regex.IsMatch(expected, "^[0-9A-F]{64}$"))
|
||||
throw Oops.Bah("服务器未配置正式签名指纹,禁止发布");
|
||||
if (metadata.CertificateSha256 != expected) throw Oops.Bah("APK 签名与正式签名不一致");
|
||||
var existing = await _releases.GetFirstAsync(x => x.Channel == _channel && x.PackageName == PackageName && x.VersionCode == metadata.VersionCode);
|
||||
if (existing != null) throw Oops.Bah("该版本号已存在,请使用新的递增版本号");
|
||||
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 release = new JudgeAppRelease
|
||||
{
|
||||
Id = _ids.NextId(), Channel = _channel, PackageName = PackageName,
|
||||
VersionCode = metadata.VersionCode, VersionName = metadata.VersionName,
|
||||
NotesZh = notesZh?.Trim() ?? string.Empty, NotesEn = notesEn?.Trim() ?? string.Empty,
|
||||
IsMandatory = isMandatory, FileSize = uploadStream.Length,
|
||||
Sha256 = sha, CertificateSha256 = metadata.CertificateSha256,
|
||||
FileUrl = await _files.UploadFileStreamAsync($"app-releases/{_channel}/{Guid.NewGuid():N}.apk", uploadStream),
|
||||
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("只有草稿可以发布");
|
||||
if (_channel == "production" &&
|
||||
(!Uri.TryCreate(release.FileUrl, UriKind.Absolute, out var fileUri) || fileUri.Scheme != Uri.UriSchemeHttps))
|
||||
throw Oops.Bah("正式环境的 APK 下载地址必须为 HTTPS");
|
||||
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("版本不存在");
|
||||
}
|
||||
|
||||
private static async Task<(string PackageName, int VersionCode, string VersionName, string CertificateSha256)> InspectApk(string path)
|
||||
{
|
||||
var directory = Environment.GetEnvironmentVariable("ANDROID_BUILD_TOOLS_DIR");
|
||||
if (string.IsNullOrWhiteSpace(directory)) throw Oops.Bah("服务器未配置 Android build-tools,禁止盲目发布");
|
||||
var aapt = Path.Combine(directory, OperatingSystem.IsWindows() ? "aapt.exe" : "aapt");
|
||||
var signer = Path.Combine(directory, OperatingSystem.IsWindows() ? "apksigner.bat" : "apksigner");
|
||||
var badging = await RunTool(aapt, "dump", "badging", path);
|
||||
var match = Regex.Match(badging, "package: name='([^']+)' versionCode='([0-9]+)' versionName='([^']*)'");
|
||||
if (!match.Success) throw Oops.Bah("无法读取 APK 包名及版本");
|
||||
var result = await RunTool(signer, "verify", "--print-certs", path);
|
||||
var digest = Regex.Match(result, "certificate SHA-256 digest: ([0-9a-fA-F]{64})");
|
||||
if (!digest.Success) throw Oops.Bah("无法验证 APK 签名");
|
||||
return (match.Groups[1].Value, int.Parse(match.Groups[2].Value), match.Groups[3].Value, digest.Groups[1].Value.ToUpperInvariant());
|
||||
}
|
||||
|
||||
private static async Task<string> RunTool(string executable, params string[] arguments)
|
||||
{
|
||||
if (!System.IO.File.Exists(executable)) throw Oops.Bah("Android build-tools 不完整");
|
||||
ProcessStartInfo start;
|
||||
if (OperatingSystem.IsWindows() && executable.EndsWith(".bat", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
start = new ProcessStartInfo("cmd.exe") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
|
||||
start.ArgumentList.Add("/d");
|
||||
start.ArgumentList.Add("/c");
|
||||
start.ArgumentList.Add("\"" + executable + "\" " + string.Join(" ", arguments.Select(x => "\"" + x.Replace("\"", "") + "\"")));
|
||||
}
|
||||
else
|
||||
{
|
||||
start = new ProcessStartInfo(executable) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
|
||||
foreach (var argument in arguments) start.ArgumentList.Add(argument);
|
||||
}
|
||||
using var process = Process.Start(start) ?? throw Oops.Bah("无法启动 APK 检查工具");
|
||||
var output = process.StandardOutput.ReadToEndAsync();
|
||||
var error = process.StandardError.ReadToEndAsync();
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
try { await process.WaitForExitAsync(timeout.Token); }
|
||||
catch (OperationCanceledException) { process.Kill(entireProcessTree: true); throw Oops.Bah("APK 检查超时"); }
|
||||
if (process.ExitCode != 0) throw Oops.Bah("APK 检查失败:" + (await error).Trim());
|
||||
return await output;
|
||||
}
|
||||
}
|
||||
26
YD_Event.Core/Entities/JudgeAppRelease.cs
Normal file
26
YD_Event.Core/Entities/JudgeAppRelease.cs
Normal file
@ -0,0 +1,26 @@
|
||||
namespace YD_Event.Core.Entities;
|
||||
|
||||
/// <summary>Android 裁判端发布包。撤回仅停止新更新,历史记录保留。</summary>
|
||||
[SugarTable("JudgeAppRelease")]
|
||||
public class JudgeAppRelease : Entity<long>
|
||||
{
|
||||
[SugarColumn(Length = 32)] public string Channel { get; set; } = "production";
|
||||
[SugarColumn(Length = 160)] public string PackageName { get; set; } = null!;
|
||||
public int VersionCode { get; set; }
|
||||
[SugarColumn(Length = 64)] public string VersionName { get; set; } = null!;
|
||||
[SugarColumn(Length = 2000)] public string NotesZh { get; set; } = string.Empty;
|
||||
[SugarColumn(Length = 2000)] public string NotesEn { get; set; } = string.Empty;
|
||||
public bool IsMandatory { get; set; }
|
||||
public long FileSize { get; set; }
|
||||
[SugarColumn(Length = 64)] public string Sha256 { get; set; } = null!;
|
||||
[SugarColumn(Length = 64)] public string CertificateSha256 { get; set; } = null!;
|
||||
[SugarColumn(Length = 1000)] public string FileUrl { get; set; } = null!;
|
||||
/// <summary>0=草稿,1=已发布,2=已撤回。</summary>
|
||||
public int ReleaseStatus { get; set; }
|
||||
public long CreatedUserId { get; set; }
|
||||
public DateTime CreatedTime { get; set; }
|
||||
public long? PublishedUserId { get; set; }
|
||||
public DateTime? PublishedTime { get; set; }
|
||||
public long? WithdrawnUserId { get; set; }
|
||||
public DateTime? WithdrawnTime { get; set; }
|
||||
}
|
||||
30
二期需求/dbo.sql
30
二期需求/dbo.sql
@ -1652,6 +1652,36 @@ SELECT r.RuleCode,COUNT(i.Id) AS ItemCount FROM dbo.ScoreRule r LEFT JOIN dbo.Sc
|
||||
(SELECT COUNT(*) FROM sys.foreign_keys f WHERE f.parent_object_id IN(SELECT OBJECT_ID(N'dbo.'+QUOTENAME(Name)) FROM @ExpectedTables)) AS Phase2ForeignKeyCount,
|
||||
(SELECT COUNT(*) FROM #RuleSeed) AS RuleSeedCount,(SELECT COUNT(*) FROM #ItemSeed) AS RuleItemSeedCount;
|
||||
|
||||
/* 裁判端正式签名 APK 发布表:与增量脚本保持一致。 */
|
||||
IF OBJECT_ID(N'dbo.JudgeAppRelease', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.JudgeAppRelease (
|
||||
Id BIGINT NOT NULL CONSTRAINT PK_JudgeAppRelease PRIMARY KEY,
|
||||
Channel NVARCHAR(32) NOT NULL, PackageName NVARCHAR(160) NOT NULL,
|
||||
VersionCode INT NOT NULL, VersionName NVARCHAR(64) NOT NULL,
|
||||
NotesZh NVARCHAR(2000) NOT NULL CONSTRAINT DF_JudgeAppRelease_NotesZh DEFAULT(N''),
|
||||
NotesEn NVARCHAR(2000) NOT NULL CONSTRAINT DF_JudgeAppRelease_NotesEn DEFAULT(N''),
|
||||
IsMandatory BIT NOT NULL CONSTRAINT DF_JudgeAppRelease_IsMandatory DEFAULT(0),
|
||||
FileSize BIGINT NOT NULL, Sha256 NVARCHAR(64) NOT NULL,
|
||||
CertificateSha256 NVARCHAR(64) NOT NULL, FileUrl NVARCHAR(1000) NOT NULL,
|
||||
ReleaseStatus INT NOT NULL CONSTRAINT DF_JudgeAppRelease_Status DEFAULT(0),
|
||||
CreatedUserId BIGINT NOT NULL, CreatedTime DATETIME2(3) NOT NULL,
|
||||
PublishedUserId BIGINT NULL, PublishedTime DATETIME2(3) NULL,
|
||||
WithdrawnUserId BIGINT NULL, WithdrawnTime DATETIME2(3) NULL,
|
||||
CONSTRAINT CK_JudgeAppRelease_Status CHECK (ReleaseStatus IN (0,1,2)),
|
||||
CONSTRAINT CK_JudgeAppRelease_Size CHECK (FileSize > 0),
|
||||
CONSTRAINT CK_JudgeAppRelease_Version CHECK (VersionCode > 0)
|
||||
);
|
||||
END;
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID(N'dbo.JudgeAppRelease') AND name = N'UX_JudgeAppRelease_ChannelPackageVersion')
|
||||
CREATE UNIQUE INDEX UX_JudgeAppRelease_ChannelPackageVersion ON dbo.JudgeAppRelease(Channel, PackageName, VersionCode);
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID(N'dbo.JudgeAppRelease') AND name = N'IX_JudgeAppRelease_Latest')
|
||||
CREATE INDEX IX_JudgeAppRelease_Latest ON dbo.JudgeAppRelease(Channel, PackageName, ReleaseStatus, VersionCode DESC);
|
||||
IF EXISTS (SELECT 1 FROM sys.database_principals WHERE name = N'yd_phase2_app_rw')
|
||||
GRANT SELECT, INSERT, UPDATE ON dbo.JudgeAppRelease TO yd_phase2_app_rw;
|
||||
IF EXISTS (SELECT 1 FROM sys.database_principals WHERE name = N'yd_phase2_app_ro')
|
||||
GRANT SELECT ON dbo.JudgeAppRelease TO yd_phase2_app_ro;
|
||||
|
||||
PRINT N'[9/9] 提交事务';
|
||||
COMMIT TRANSACTION;
|
||||
PRINT N'二期数据库部署成功,事务已提交。';
|
||||
|
||||
@ -576,6 +576,35 @@ BEGIN TRY
|
||||
END;
|
||||
|
||||
|
||||
IF OBJECT_ID(N'dbo.JudgeAppRelease', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.JudgeAppRelease (
|
||||
Id BIGINT NOT NULL CONSTRAINT PK_JudgeAppRelease PRIMARY KEY,
|
||||
Channel NVARCHAR(32) NOT NULL, PackageName NVARCHAR(160) NOT NULL,
|
||||
VersionCode INT NOT NULL, VersionName NVARCHAR(64) NOT NULL,
|
||||
NotesZh NVARCHAR(2000) NOT NULL CONSTRAINT DF_JudgeAppRelease_NotesZh DEFAULT(N''),
|
||||
NotesEn NVARCHAR(2000) NOT NULL CONSTRAINT DF_JudgeAppRelease_NotesEn DEFAULT(N''),
|
||||
IsMandatory BIT NOT NULL CONSTRAINT DF_JudgeAppRelease_IsMandatory DEFAULT(0),
|
||||
FileSize BIGINT NOT NULL, Sha256 NVARCHAR(64) NOT NULL,
|
||||
CertificateSha256 NVARCHAR(64) NOT NULL, FileUrl NVARCHAR(1000) NOT NULL,
|
||||
ReleaseStatus INT NOT NULL CONSTRAINT DF_JudgeAppRelease_Status DEFAULT(0),
|
||||
CreatedUserId BIGINT NOT NULL, CreatedTime DATETIME2(3) NOT NULL,
|
||||
PublishedUserId BIGINT NULL, PublishedTime DATETIME2(3) NULL,
|
||||
WithdrawnUserId BIGINT NULL, WithdrawnTime DATETIME2(3) NULL,
|
||||
CONSTRAINT CK_JudgeAppRelease_Status CHECK (ReleaseStatus IN (0,1,2)),
|
||||
CONSTRAINT CK_JudgeAppRelease_Size CHECK (FileSize > 0),
|
||||
CONSTRAINT CK_JudgeAppRelease_Version CHECK (VersionCode > 0)
|
||||
);
|
||||
END;
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID(N'dbo.JudgeAppRelease') AND name = N'UX_JudgeAppRelease_ChannelPackageVersion')
|
||||
CREATE UNIQUE INDEX UX_JudgeAppRelease_ChannelPackageVersion ON dbo.JudgeAppRelease(Channel, PackageName, VersionCode);
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID(N'dbo.JudgeAppRelease') AND name = N'IX_JudgeAppRelease_Latest')
|
||||
CREATE INDEX IX_JudgeAppRelease_Latest ON dbo.JudgeAppRelease(Channel, PackageName, ReleaseStatus, VersionCode DESC);
|
||||
IF EXISTS (SELECT 1 FROM sys.database_principals WHERE name = N'yd_phase2_app_rw')
|
||||
GRANT SELECT, INSERT, UPDATE ON dbo.JudgeAppRelease TO yd_phase2_app_rw;
|
||||
IF EXISTS (SELECT 1 FROM sys.database_principals WHERE name = N'yd_phase2_app_ro')
|
||||
GRANT SELECT ON dbo.JudgeAppRelease TO yd_phase2_app_ro;
|
||||
|
||||
COMMIT TRANSACTION;
|
||||
PRINT N'二期 ADD 脚本执行成功。';
|
||||
END TRY
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user