using System.Security.Cryptography;
using JiDangJia.Contracts;
using static JiDangJia.Server.ServerStore;
namespace JiDangJia.Server;
public sealed class AppReleaseStore
{
private readonly ServerStore store;private readonly string directory,packageId;
public AppReleaseStore(ServerStore store,IConfiguration configuration)
{
this.store=store;directory=Path.Combine(store.DataDirectory,"app-releases");Directory.CreateDirectory(directory);packageId=configuration["UpdatePackageId"]??ServerProduct.PackageId;
store.Write(c=>Exec(c,"CREATE TABLE IF NOT EXISTS AppReleases(id TEXT PRIMARY KEY,packageId TEXT NOT NULL,versionCode INTEGER NOT NULL UNIQUE,versionName TEXT NOT NULL,minimumVersionCode INTEGER NOT NULL,notes TEXT NOT NULL,size INTEGER NOT NULL,sha256 TEXT NOT NULL,publishedAt TEXT NOT NULL,enabled INTEGER NOT NULL DEFAULT 1)"));
}
private void Admin(Account actor)=>store.Read(c=>{if(Text(SafetyAccount(c,actor.Id),"role")!="admin")throw new CommunityException(403,"需要管理员权限");return 0;});
private static AppRelease From(Dictionary<string,object?> r)=>new(Text(r,"id"),Text(r,"packageId"),Convert.ToInt32(r["versionCode"]),Text(r,"versionName"),Convert.ToInt32(r["minimumVersionCode"]),Text(r,"notes"),Convert.ToInt64(r["size"]),Text(r,"sha256"),Text(r,"publishedAt"),Convert.ToInt32(r["enabled"])==1);
public AppRelease? Latest(int installed)=>store.Read(c=>Rows(c,"SELECT * FROM AppReleases WHERE enabled=1 AND versionCode>@p0 ORDER BY versionCode DESC LIMIT 1",Math.Max(0,installed)).Select(From).FirstOrDefault());
public List<AppRelease> Releases(Account actor){Admin(actor);return store.Read(c=>Rows(c,"SELECT * FROM AppReleases ORDER BY versionCode DESC").Select(From).ToList());}
public string Download(string id)
{
if(!Guid.TryParseExact(id,"N",out _))throw new CommunityException(404,"安装包不存在");
var row=store.Read(c=>Rows(c,"SELECT id FROM AppReleases WHERE id=@p0 AND enabled=1",id).FirstOrDefault());string path=Path.Combine(directory,id+".apk");
if(row is null||!File.Exists(path))throw new CommunityException(404,"该版本已撤回,请重新检查更新");return path;
}
public async Task<AppRelease> Publish(Account actor,Stream source,PublishReleaseRequest request,CancellationToken ct=default)
{
Admin(actor);string id=Guid.NewGuid().ToString("N"),temp=Path.Combine(directory,id+".upload"),final=Path.Combine(directory,id+".apk");bool saved=false;
try
{
long size=0;string sha;
using(var hash=IncrementalHash.CreateHash(HashAlgorithmName.SHA256))
{
await using var output=new FileStream(temp,FileMode.CreateNew,FileAccess.Write,FileShare.None,65536,true);var buffer=new byte[65536];int n;
while((n=await source.ReadAsync(buffer,ct))>0){size+=n;if(size>AppUpdateRules.MaxApkSize)throw new ArgumentException("APK 最大支持 250 MB");hash.AppendData(buffer,0,n);await output.WriteAsync(buffer.AsMemory(0,n),ct);}sha=Convert.ToHexStringLower(hash.GetHashAndReset());
}
var apk=ApkManifest.Read(temp);if(apk.PackageId!=packageId)throw new ArgumentException("APK 应用标识不匹配,请选择本后台对应 APP 的安装包");AppUpdateRules.Validate(request,apk.VersionCode);Admin(actor);ct.ThrowIfCancellationRequested();
var release=new AppRelease(id,apk.PackageId,apk.VersionCode,apk.VersionName,request.MinimumVersionCode,request.Notes.Trim(),size,sha,Now,true);
File.Move(temp,final);
store.Write(c=>
{
if(Rows(c,"SELECT id FROM AppReleases WHERE versionCode>=@p0",apk.VersionCode).Count>0)throw new ArgumentException("请发布更高版本号的 APK,已发布的版本不能覆盖");
Exec(c,"INSERT INTO AppReleases VALUES(@p0,@p1,@p2,@p3,@p4,@p5,@p6,@p7,@p8,1)",id,apk.PackageId,apk.VersionCode,apk.VersionName,request.MinimumVersionCode,release.Notes,size,sha,release.PublishedAt);
Exec(c,"INSERT INTO Audit VALUES(@p0,@p1,'APP_RELEASE',@p2,@p3,@p4)",Guid.NewGuid().ToString("N"),actor.Id,id,apk.VersionName+" / "+apk.VersionCode,Now);return 0;
});saved=true;return release;
}
catch(InvalidDataException){throw new ArgumentException("APK 安装包已损坏或格式无效");}
catch(OverflowException){throw new ArgumentException("APK 安装包格式无效");}
finally{if(File.Exists(temp))File.Delete(temp);if(!saved&&File.Exists(final))File.Delete(final);}
}
public void Withdraw(Account actor,string id)
{
Admin(actor);store.Write(c=>{if(Exec(c,"UPDATE AppReleases SET enabled=0 WHERE id=@p0 AND enabled=1",id)==0)throw new ArgumentException("版本不存在或已撤回");Exec(c,"INSERT INTO Audit VALUES(@p0,@p1,'APP_WITHDRAW',@p2,'',@p3)",Guid.NewGuid().ToString("N"),actor.Id,id,Now);return 0;});
}
public int CleanOld(Account actor)
{
Admin(actor);return store.Write(c=>
{
var current=Rows(c,"SELECT id FROM AppReleases WHERE enabled=1 ORDER BY versionCode DESC LIMIT 1").FirstOrDefault();
if(current is null)throw new ArgumentException("请先发布一个有效版本再清理旧安装包");
var old=Rows(c,"SELECT id FROM AppReleases WHERE id<>@p0",Text(current,"id"));int count=0;
foreach(var row in old){string id=Text(row,"id");if(!Guid.TryParseExact(id,"N",out _))continue;string path=Path.Combine(directory,id+".apk");if(File.Exists(path)){File.Delete(path);count++;}Exec(c,"UPDATE AppReleases SET enabled=0 WHERE id=@p0",id);}
Exec(c,"INSERT INTO Audit VALUES(@p0,@p1,'APP_RELEASE_CLEAN','',@p2,@p3)",Guid.NewGuid().ToString("N"),actor.Id,count.ToString(),Now);return count;
});
}
}
using System.Security.Cryptography;
using JiDangJia.Contracts;
using static JiDangJia.Server.ServerStore;
namespace JiDangJia.Server;
public sealed class AppReleaseStore
{
private readonly ServerStore store;private readonly string directory,packageId;
public AppReleaseStore(ServerStore store,IConfiguration configuration)
{
this.store=store;directory=Path.Combine(store.DataDirectory,"app-releases");Directory.CreateDirectory(directory);packageId=configuration["UpdatePackageId"]??ServerProduct.PackageId;
store.Write(c=>Exec(c,"CREATE TABLE IF NOT EXISTS AppReleases(id TEXT PRIMARY KEY,packageId TEXT NOT NULL,versionCode INTEGER NOT NULL UNIQUE,versionName TEXT NOT NULL,minimumVersionCode INTEGER NOT NULL,notes TEXT NOT NULL,size INTEGER NOT NULL,sha256 TEXT NOT NULL,publishedAt TEXT NOT NULL,enabled INTEGER NOT NULL DEFAULT 1)"));
}
private void Admin(Account actor)=>store.Read(c=>{if(Text(SafetyAccount(c,actor.Id),"role")!="admin")throw new CommunityException(403,"需要管理员权限");return 0;});
private static AppRelease From(Dictionary<string,object?> r)=>new(Text(r,"id"),Text(r,"packageId"),Convert.ToInt32(r["versionCode"]),Text(r,"versionName"),Convert.ToInt32(r["minimumVersionCode"]),Text(r,"notes"),Convert.ToInt64(r["size"]),Text(r,"sha256"),Text(r,"publishedAt"),Convert.ToInt32(r["enabled"])==1);
public AppRelease? Latest(int installed)=>store.Read(c=>Rows(c,"SELECT * FROM AppReleases WHERE enabled=1 AND versionCode>@p0 ORDER BY versionCode DESC LIMIT 1",Math.Max(0,installed)).Select(From).FirstOrDefault());
public List<AppRelease> Releases(Account actor){Admin(actor);return store.Read(c=>Rows(c,"SELECT * FROM AppReleases ORDER BY versionCode DESC").Select(From).ToList());}
public string Download(string id)
{
if(!Guid.TryParseExact(id,"N",out _))throw new CommunityException(404,"安装包不存在");
var row=store.Read(c=>Rows(c,"SELECT id FROM AppReleases WHERE id=@p0 AND enabled=1",id).FirstOrDefault());string path=Path.Combine(directory,id+".apk");
if(row is null||!File.Exists(path))throw new CommunityException(404,"该版本已撤回,请重新检查更新");return path;
}
public async Task<AppRelease> Publish(Account actor,Stream source,PublishReleaseRequest request,CancellationToken ct=default)
{
Admin(actor);string id=Guid.NewGuid().ToString("N"),temp=Path.Combine(directory,id+".upload"),final=Path.Combine(directory,id+".apk");bool saved=false;
try
{
long size=0;string sha;
using(var hash=IncrementalHash.CreateHash(HashAlgorithmName.SHA256))
{
await using var output=new FileStream(temp,FileMode.CreateNew,FileAccess.Write,FileShare.None,65536,true);var buffer=new byte[65536];int n;
while((n=await source.ReadAsync(buffer,ct))>0){size+=n;if(size>AppUpdateRules.MaxApkSize)throw new ArgumentException("APK 最大支持 250 MB");hash.AppendData(buffer,0,n);await output.WriteAsync(buffer.AsMemory(0,n),ct);}sha=Convert.ToHexStringLower(hash.GetHashAndReset());
}
var apk=ApkManifest.Read(temp);if(apk.PackageId!=packageId)throw new ArgumentException("APK 应用标识不匹配,请选择本后台对应 APP 的安装包");AppUpdateRules.Validate(request,apk.VersionCode);Admin(actor);ct.ThrowIfCancellationRequested();
var release=new AppRelease(id,apk.PackageId,apk.VersionCode,apk.VersionName,request.MinimumVersionCode,request.Notes.Trim(),size,sha,Now,true);
File.Move(temp,final);
store.Write(c=>
{
if(Rows(c,"SELECT id FROM AppReleases WHERE versionCode>=@p0",apk.VersionCode).Count>0)throw new ArgumentException("请发布更高版本号的 APK,已发布的版本不能覆盖");
Exec(c,"INSERT INTO AppReleases VALUES(@p0,@p1,@p2,@p3,@p4,@p5,@p6,@p7,@p8,1)",id,apk.PackageId,apk.VersionCode,apk.VersionName,request.MinimumVersionCode,release.Notes,size,sha,release.PublishedAt);
Exec(c,"INSERT INTO Audit VALUES(@p0,@p1,'APP_RELEASE',@p2,@p3,@p4)",Guid.NewGuid().ToString("N"),actor.Id,id,apk.VersionName+" / "+apk.VersionCode,Now);return 0;
});saved=true;return release;
}
catch(InvalidDataException){throw new ArgumentException("APK 安装包已损坏或格式无效");}
catch(OverflowException){throw new ArgumentException("APK 安装包格式无效");}
finally{if(File.Exists(temp))File.Delete(temp);if(!saved&&File.Exists(final))File.Delete(final);}
}
public void Withdraw(Account actor,string id)
{
Admin(actor);store.Write(c=>{if(Exec(c,"UPDATE AppReleases SET enabled=0 WHERE id=@p0 AND enabled=1",id)==0)throw new ArgumentException("版本不存在或已撤回");Exec(c,"INSERT INTO Audit VALUES(@p0,@p1,'APP_WITHDRAW',@p2,'',@p3)",Guid.NewGuid().ToString("N"),actor.Id,id,Now);return 0;});
}
public int CleanOld(Account actor)
{
Admin(actor);return store.Write(c=>
{
var current=Rows(c,"SELECT id FROM AppReleases WHERE enabled=1 ORDER BY versionCode DESC LIMIT 1").FirstOrDefault();
if(current is null)throw new ArgumentException("请先发布一个有效版本再清理旧安装包");
var old=Rows(c,"SELECT id FROM AppReleases WHERE id<>@p0",Text(current,"id"));int count=0;
foreach(var row in old){string id=Text(row,"id");if(!Guid.TryParseExact(id,"N",out _))continue;string path=Path.Combine(directory,id+".apk");if(File.Exists(path)){File.Delete(path);count++;}Exec(c,"UPDATE AppReleases SET enabled=0 WHERE id=@p0",id);}
Exec(c,"INSERT INTO Audit VALUES(@p0,@p1,'APP_RELEASE_CLEAN','',@p2,@p3)",Guid.NewGuid().ToString("N"),actor.Id,count.ToString(),Now);return count;
});
}
}