From 3da0f0d8069b133bd2e785ad79f215efe764a01b Mon Sep 17 00:00:00 2001 From: ltx <1006158920@qq.com> Date: Thu, 5 Nov 2026 15:00:57 +0800 Subject: [PATCH] =?UTF-8?q?update=EF=BC=9A=E8=A3=81=E5=88=A4=E7=AB=AFapp?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E6=8E=A7=E5=88=B6&=E8=BF=9C=E7=A8=8B?= =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AppUpdate/AppUpdateController.cs | 207 ++++++++++++++++++ YD_Event.Core/Entities/JudgeAppRelease.cs | 26 +++ 二期需求/dbo.sql | 30 +++ 二期需求/上线/dbo_add.sql | 29 +++ 4 files changed, 292 insertions(+) create mode 100644 YD_Event.Application/AppUpdate/AppUpdateController.cs create mode 100644 YD_Event.Core/Entities/JudgeAppRelease.cs diff --git a/YD_Event.Application/AppUpdate/AppUpdateController.cs b/YD_Event.Application/AppUpdate/AppUpdateController.cs new file mode 100644 index 0000000..ba1dbbc --- /dev/null +++ b/YD_Event.Application/AppUpdate/AppUpdateController.cs @@ -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; + +/// 内部 Android 裁判端 APK 发布与升级检查。 +[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 _releases; + private readonly AuthManager _auth; + private readonly IIdGenerator _ids; + private readonly FileService _files; + private readonly string _channel; + + public AppUpdateController(ISqlSugarRepository releases, + AuthManager auth, IIdGenerator ids, FileService files) + { + _releases = releases; + _auth = auth; + _ids = ids; + _files = files; + _channel = Environment.GetEnvironmentVariable("APP_UPDATE_CHANNEL")?.Trim().ToLowerInvariant() ?? "production"; + } + + /// 登录前即可查询当前环境的最新 APK。 + [HttpGet("latest")] + [AllowAnonymous] + public async Task 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 + }; + } + + /// 只允许下载当前环境已发布的版本,文件由现有对象存储分发。 + [HttpGet("download/{id:long}")] + [AllowAnonymous] + public async Task 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 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 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 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 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 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 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; + } +} diff --git a/YD_Event.Core/Entities/JudgeAppRelease.cs b/YD_Event.Core/Entities/JudgeAppRelease.cs new file mode 100644 index 0000000..aa19470 --- /dev/null +++ b/YD_Event.Core/Entities/JudgeAppRelease.cs @@ -0,0 +1,26 @@ +namespace YD_Event.Core.Entities; + +/// Android 裁判端发布包。撤回仅停止新更新,历史记录保留。 +[SugarTable("JudgeAppRelease")] +public class JudgeAppRelease : Entity +{ + [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!; + /// 0=草稿,1=已发布,2=已撤回。 + 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; } +} diff --git a/二期需求/dbo.sql b/二期需求/dbo.sql index 6dbdbfb..bd74c71 100644 --- a/二期需求/dbo.sql +++ b/二期需求/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'二期数据库部署成功,事务已提交。'; diff --git a/二期需求/上线/dbo_add.sql b/二期需求/上线/dbo_add.sql index f77a368..283bc38 100644 --- a/二期需求/上线/dbo_add.sql +++ b/二期需求/上线/dbo_add.sql @@ -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