using JiDangJia.Contracts;
using Microsoft.Data.Sqlite;
using System.Text.Json;
using static JiDangJia.Server.ServerStore;
namespace JiDangJia.Server;
public sealed class ContentStore
{
private readonly ServerStore store;
public ContentStore(ServerStore store)
{
this.store=store;
store.Write(c=>
{
Exec(c,"CREATE TABLE IF NOT EXISTS AppContent(id INTEGER PRIMARY KEY CHECK(id=1),revision INTEGER NOT NULL,ads TEXT NOT NULL,communityUrl TEXT NOT NULL,updatedAt TEXT NOT NULL)");
if(!Rows(c,"PRAGMA table_info(AppContent)").Any(r=>Text(r,"name")=="support"))
{
Exec(c,"ALTER TABLE AppContent ADD COLUMN support TEXT NOT NULL DEFAULT '[]'");
Exec(c,"UPDATE AppContent SET support=@p0",JsonSerializer.Serialize(SupportCatalog.Defaults));
}
if(!Rows(c,"PRAGMA table_info(AppContent)").Any(r=>Text(r,"name")=="about"))Exec(c,"ALTER TABLE AppContent ADD COLUMN about TEXT NOT NULL DEFAULT ''");
#if COMMUNITY_APP
var ads=new List<Advertisement>();
#else
var ads=new List<Advertisement>{new("welcome","让每一笔生意\n清楚,有据","寄当家 · 您的门店经营助手","","",true,0),new("assets","安心管理资产\n从容经营每一天","客户 · 业务 · 库存 · 财务","","",true,1)};
#endif
Exec(c,"INSERT OR IGNORE INTO AppContent(id,revision,ads,communityUrl,updatedAt,support) VALUES(1,1,@p0,'',@p1,@p2)",JsonSerializer.Serialize(ads),Now,JsonSerializer.Serialize(SupportCatalog.Defaults));
#if !COMMUNITY_APP
var support=JsonSerializer.Deserialize<List<SupportEntry>>(Text(Rows(c,"SELECT support FROM AppContent WHERE id=1")[0],"support"))??[];bool added=false;
foreach(var entry in new[]{new SupportEntry("purchase-contact","扫码联系客服购买","扫描客服二维码咨询并购买卡密。","","","",true,10),new SupportEntry("invoice-contact","需要发票","申请发票请添加客服,提供订单和开票资料。","","","",true,11)})
if(support.All(e=>e.Id!=entry.Id)){support.Add(entry);added=true;}
if(added)Exec(c,"UPDATE AppContent SET support=@p0,revision=revision+1,updatedAt=@p1",JsonSerializer.Serialize(support),Now);
#endif
return 0;
});
}
private static void Actor(SqliteConnection c,Account actor,bool admin)
{
var row=Rows(c,"SELECT role FROM Accounts WHERE id=@p0 AND active=1",actor.Id).FirstOrDefault()??throw new CommunityException(401,"账户已停用,请重新登录");
if(admin && Text(row,"role")!="admin")throw new CommunityException(403,"只有服务器管理员可以管理全局内容及客服入口");
}
private static AppContent Read(SqliteConnection c,bool admin)
{
var row=Rows(c,"SELECT * FROM AppContent WHERE id=1").Single();
var ads=JsonSerializer.Deserialize<List<Advertisement>>(Text(row,"ads"))??[];
var support=JsonSerializer.Deserialize<List<SupportEntry>>(Text(row,"support"))??[];
if(!admin)ads=ads.Where(a=>a.Enabled).ToList();
if(!admin)support=support.Where(a=>a.Enabled).ToList();
var about=string.IsNullOrWhiteSpace(Text(row,"about"))?new AboutInfo(ServerProduct.Id=="community"?"寄当家社群为同行提供图文交流、供需发布、服务展示与私信沟通。":"寄当家为门店提供客户档案、业务、库存和财务管理,帮助经营者清楚掌握每一笔往来。","待填写","待填写",""):JsonSerializer.Deserialize<AboutInfo>(Text(row,"about"));
return new(Convert.ToInt64(row["revision"]),ads.OrderBy(a=>a.SortOrder).ThenBy(a=>a.Id).ToList(),Text(row,"communityUrl"),Text(row,"updatedAt"),support.OrderBy(a=>a.SortOrder).ThenBy(a=>a.Id).ToList(),about);
}
public AppContent Public()=>store.Read(c=>Read(c,false) with{Ads=[],CommunityUrl=""});
public AppContent Get(Account actor,bool admin=false)=>store.Read(c=>{Actor(c,actor,admin);return Read(c,admin);});
private static string Url(string? value)
{
value=(value??"").Trim();if(value.Length==0)return "";
if(value.Length>2048 || !Uri.TryCreate(value,UriKind.Absolute,out var url) || url.Scheme!="https" || url.UserInfo.Length>0)throw new ArgumentException("跳转地址和社群地址须为 HTTPS 链接");
return value;
}
private static string Image(string? value)
{
value??="";if(value.Length==0)return "";
var comma=value.IndexOf(',');var header=comma>=0?value[..comma]:"";
if(header is not ("data:image/jpeg;base64" or "data:image/png;base64") || value.Length>1_400_000)throw new ArgumentException("图片应为 JPEG 或 PNG,且不能超过 1 MB");
byte[] bytes;try{bytes=Convert.FromBase64String(value[(comma+1)..]);}catch(FormatException){throw new ArgumentException("图片数据无效");}
bool valid=header.Contains("jpeg")?bytes.Length>=3&&bytes[0]==0xff&&bytes[1]==0xd8&&bytes[2]==0xff:bytes.Length>=8&&bytes.AsSpan(0,8).SequenceEqual(new byte[]{137,80,78,71,13,10,26,10});
if(!valid || bytes.Length>1_048_576)throw new ArgumentException("图片内容或大小无效");return value;
}
public AppContent Update(Account actor,UpdateContentRequest request)=>store.Write(c=>
{
Actor(c,actor,true);var current=Read(c,true);
if(current.Revision!=request.ExpectedRevision)throw new CommunityException(409,"后台内容已被更新,请刷新后再修改");
if(request.Ads is null || request.Ads.Count>20)throw new ArgumentException("最多可配置 20 个广告位");
var ids=new HashSet<string>();var ads=new List<Advertisement>();
foreach(var ad in request.Ads)
{
if(ad is null || string.IsNullOrWhiteSpace(ad.Id) || ad.Id.Length>64 || !ad.Id.All(ch=>char.IsAsciiLetterOrDigit(ch)||ch is '-' or '_') || !ids.Add(ad.Id))throw new ArgumentException("广告编号无效或重复");
var title=(ad.Title??"").Trim();var subtitle=(ad.Subtitle??"").Trim();
if(title.Length is <1 or >100 || subtitle.Length>200 || ad.SortOrder is <0 or >9999)throw new ArgumentException("广告标题为 1 至 100 字,副标题最多 200 字,排序为 0 至 9999");
ads.Add(ad with{Title=title,Subtitle=subtitle,Image=Image(ad.Image),Link=Url(ad.Link)});
}
if(ads.Sum(a=>(long)a.Image.Length)>5_500_000)throw new ArgumentException("广告图片总大小超限,请压缩图片或减少广告数量");
Exec(c,"UPDATE AppContent SET revision=@p0,ads=@p1,communityUrl=@p2,updatedAt=@p3 WHERE id=1",current.Revision+1,JsonSerializer.Serialize(ads),Url(request.CommunityUrl),Now);
Exec(c,"INSERT INTO Audit(id,actor,action,target,detail,createdAt) VALUES(@p0,@p1,'CONTENT_UPDATE','AppContent',@p2,@p3)",Guid.NewGuid().ToString("N"),actor.Id,$"更新全局广告 {ads.Count} 条及社群链接",Now);
return Read(c,true);
});
public AppContent UpdateSupport(Account actor,UpdateSupportRequest request)=>store.Write(c=>
{
Actor(c,actor,true);var current=Read(c,true);
if(current.Revision!=request.ExpectedRevision)throw new CommunityException(409,"后台内容已被更新,请刷新后再修改");
if(request.Entries is null || request.Entries.Count>12)throw new ArgumentException("最多可配置 12 个客服与反馈入口");
var ids=new HashSet<string>();var entries=new List<SupportEntry>();
foreach(var entry in request.Entries)
{
if(entry is null || string.IsNullOrWhiteSpace(entry.Id) || entry.Id.Length>64 || !entry.Id.All(ch=>char.IsAsciiLetterOrDigit(ch)||ch is '-' or '_') || !ids.Add(entry.Id))throw new ArgumentException("客服入口编号无效或重复");
var title=(entry.Title??"").Trim();var description=(entry.Description??"").Trim();var phone=(entry.Phone??"").Trim();
if(title.Length is <1 or >60 || description.Length>1000 || entry.SortOrder is <0 or >9999)throw new ArgumentException("入口标题为 1 至 60 字,说明最多 1000 字,排序为 0 至 9999");
if(phone.Length>40 || phone.Length>0&&(!phone.Any(char.IsAsciiDigit)||!phone.All(ch=>char.IsAsciiDigit(ch)||ch is '+' or '-' or '(' or ')' or ' ')))throw new ArgumentException("请填写有效的客服电话");
entries.Add(entry with{Title=title,Description=description,Phone=phone,QrImage=Image(entry.QrImage),Link=Url(entry.Link)});
}
if(entries.Sum(e=>(long)e.QrImage.Length)>5_500_000)throw new ArgumentException("二维码图片总大小超限,请压缩图片或减少入口数量");
#if !COMMUNITY_APP
foreach(var fixedEntry in (current.Support??[]).Where(e=>e.Id is "purchase-contact" or "invoice-contact"))
{
if(entries.All(e=>e.Id!=fixedEntry.Id))entries.Add(fixedEntry);
int index=entries.FindIndex(e=>e.Id==fixedEntry.Id);entries[index]=entries[index] with{Enabled=true};
}
#endif
if(entries.Count>12||entries.Sum(e=>(long)e.QrImage.Length)>5_500_000)throw new ArgumentException("客服入口最多 12 个(含固定入口),二维码总大小不能超过 5.5 MB");
Exec(c,"UPDATE AppContent SET revision=@p0,support=@p1,updatedAt=@p2 WHERE id=1",current.Revision+1,JsonSerializer.Serialize(entries),Now);
Exec(c,"INSERT INTO Audit(id,actor,action,target,detail,createdAt) VALUES(@p0,@p1,'SUPPORT_UPDATE','AppContent',@p2,@p3)",Guid.NewGuid().ToString("N"),actor.Id,$"更新客服与反馈入口 {entries.Count} 条",Now);
return Read(c,true);
});
public AppContent UpdateAbout(Account actor,UpdateAboutRequest request)=>store.Write(c=>
{
Actor(c,actor,true);var current=Read(c,true);if(current.Revision!=request.ExpectedRevision)throw new CommunityException(409,"内容已更新,请刷新后重试");
var value=request.About??throw new ArgumentException("请填写关于我们");
string Clean(string? text,int max){text=(text??"").Trim();if(text.Length>max||text.Any(ch=>char.IsControl(ch)&&ch is not '\n' and not '\r' and not '\t'))throw new ArgumentException("内容过长或含无效字符");return text;}
var about=new AboutInfo(Clean(value.Introduction,5000),Clean(value.Operator,200),Clean(value.Copyright,300),Clean(value.IcpNumber,100));
Exec(c,"UPDATE AppContent SET about=@p0,revision=revision+1,updatedAt=@p1 WHERE id=1",JsonSerializer.Serialize(about),Now);
Exec(c,"INSERT INTO Audit(id,actor,action,target,detail,createdAt) VALUES(@p0,@p1,'ABOUT_UPDATE','AppContent','更新关于我们与备案号',@p2)",Guid.NewGuid().ToString("N"),actor.Id,Now);return Read(c,true);
});
}
using JiDangJia.Contracts;
using Microsoft.Data.Sqlite;
using System.Text.Json;
using static JiDangJia.Server.ServerStore;
namespace JiDangJia.Server;
public sealed class ContentStore
{
private readonly ServerStore store;
public ContentStore(ServerStore store)
{
this.store=store;
store.Write(c=>
{
Exec(c,"CREATE TABLE IF NOT EXISTS AppContent(id INTEGER PRIMARY KEY CHECK(id=1),revision INTEGER NOT NULL,ads TEXT NOT NULL,communityUrl TEXT NOT NULL,updatedAt TEXT NOT NULL)");
if(!Rows(c,"PRAGMA table_info(AppContent)").Any(r=>Text(r,"name")=="support"))
{
Exec(c,"ALTER TABLE AppContent ADD COLUMN support TEXT NOT NULL DEFAULT '[]'");
Exec(c,"UPDATE AppContent SET support=@p0",JsonSerializer.Serialize(SupportCatalog.Defaults));
}
if(!Rows(c,"PRAGMA table_info(AppContent)").Any(r=>Text(r,"name")=="about"))Exec(c,"ALTER TABLE AppContent ADD COLUMN about TEXT NOT NULL DEFAULT ''");
#if COMMUNITY_APP
var ads=new List<Advertisement>();
#else
var ads=new List<Advertisement>{new("welcome","让每一笔生意\n清楚,有据","寄当家 · 您的门店经营助手","","",true,0),new("assets","安心管理资产\n从容经营每一天","客户 · 业务 · 库存 · 财务","","",true,1)};
#endif
Exec(c,"INSERT OR IGNORE INTO AppContent(id,revision,ads,communityUrl,updatedAt,support) VALUES(1,1,@p0,'',@p1,@p2)",JsonSerializer.Serialize(ads),Now,JsonSerializer.Serialize(SupportCatalog.Defaults));
#if !COMMUNITY_APP
var support=JsonSerializer.Deserialize<List<SupportEntry>>(Text(Rows(c,"SELECT support FROM AppContent WHERE id=1")[0],"support"))??[];bool added=false;
foreach(var entry in new[]{new SupportEntry("purchase-contact","扫码联系客服购买","扫描客服二维码咨询并购买卡密。","","","",true,10),new SupportEntry("invoice-contact","需要发票","申请发票请添加客服,提供订单和开票资料。","","","",true,11)})
if(support.All(e=>e.Id!=entry.Id)){support.Add(entry);added=true;}
if(added)Exec(c,"UPDATE AppContent SET support=@p0,revision=revision+1,updatedAt=@p1",JsonSerializer.Serialize(support),Now);
#endif
return 0;
});
}
private static void Actor(SqliteConnection c,Account actor,bool admin)
{
var row=Rows(c,"SELECT role FROM Accounts WHERE id=@p0 AND active=1",actor.Id).FirstOrDefault()??throw new CommunityException(401,"账户已停用,请重新登录");
if(admin && Text(row,"role")!="admin")throw new CommunityException(403,"只有服务器管理员可以管理全局内容及客服入口");
}
private static AppContent Read(SqliteConnection c,bool admin)
{
var row=Rows(c,"SELECT * FROM AppContent WHERE id=1").Single();
var ads=JsonSerializer.Deserialize<List<Advertisement>>(Text(row,"ads"))??[];
var support=JsonSerializer.Deserialize<List<SupportEntry>>(Text(row,"support"))??[];
if(!admin)ads=ads.Where(a=>a.Enabled).ToList();
if(!admin)support=support.Where(a=>a.Enabled).ToList();
var about=string.IsNullOrWhiteSpace(Text(row,"about"))?new AboutInfo(ServerProduct.Id=="community"?"寄当家社群为同行提供图文交流、供需发布、服务展示与私信沟通。":"寄当家为门店提供客户档案、业务、库存和财务管理,帮助经营者清楚掌握每一笔往来。","待填写","待填写",""):JsonSerializer.Deserialize<AboutInfo>(Text(row,"about"));
return new(Convert.ToInt64(row["revision"]),ads.OrderBy(a=>a.SortOrder).ThenBy(a=>a.Id).ToList(),Text(row,"communityUrl"),Text(row,"updatedAt"),support.OrderBy(a=>a.SortOrder).ThenBy(a=>a.Id).ToList(),about);
}
public AppContent Public()=>store.Read(c=>Read(c,false) with{Ads=[],CommunityUrl=""});
public AppContent Get(Account actor,bool admin=false)=>store.Read(c=>{Actor(c,actor,admin);return Read(c,admin);});
private static string Url(string? value)
{
value=(value??"").Trim();if(value.Length==0)return "";
if(value.Length>2048 || !Uri.TryCreate(value,UriKind.Absolute,out var url) || url.Scheme!="https" || url.UserInfo.Length>0)throw new ArgumentException("跳转地址和社群地址须为 HTTPS 链接");
return value;
}
private static string Image(string? value)
{
value??="";if(value.Length==0)return "";
var comma=value.IndexOf(',');var header=comma>=0?value[..comma]:"";
if(header is not ("data:image/jpeg;base64" or "data:image/png;base64") || value.Length>1_400_000)throw new ArgumentException("图片应为 JPEG 或 PNG,且不能超过 1 MB");
byte[] bytes;try{bytes=Convert.FromBase64String(value[(comma+1)..]);}catch(FormatException){throw new ArgumentException("图片数据无效");}
bool valid=header.Contains("jpeg")?bytes.Length>=3&&bytes[0]==0xff&&bytes[1]==0xd8&&bytes[2]==0xff:bytes.Length>=8&&bytes.AsSpan(0,8).SequenceEqual(new byte[]{137,80,78,71,13,10,26,10});
if(!valid || bytes.Length>1_048_576)throw new ArgumentException("图片内容或大小无效");return value;
}
public AppContent Update(Account actor,UpdateContentRequest request)=>store.Write(c=>
{
Actor(c,actor,true);var current=Read(c,true);
if(current.Revision!=request.ExpectedRevision)throw new CommunityException(409,"后台内容已被更新,请刷新后再修改");
if(request.Ads is null || request.Ads.Count>20)throw new ArgumentException("最多可配置 20 个广告位");
var ids=new HashSet<string>();var ads=new List<Advertisement>();
foreach(var ad in request.Ads)
{
if(ad is null || string.IsNullOrWhiteSpace(ad.Id) || ad.Id.Length>64 || !ad.Id.All(ch=>char.IsAsciiLetterOrDigit(ch)||ch is '-' or '_') || !ids.Add(ad.Id))throw new ArgumentException("广告编号无效或重复");
var title=(ad.Title??"").Trim();var subtitle=(ad.Subtitle??"").Trim();
if(title.Length is <1 or >100 || subtitle.Length>200 || ad.SortOrder is <0 or >9999)throw new ArgumentException("广告标题为 1 至 100 字,副标题最多 200 字,排序为 0 至 9999");
ads.Add(ad with{Title=title,Subtitle=subtitle,Image=Image(ad.Image),Link=Url(ad.Link)});
}
if(ads.Sum(a=>(long)a.Image.Length)>5_500_000)throw new ArgumentException("广告图片总大小超限,请压缩图片或减少广告数量");
Exec(c,"UPDATE AppContent SET revision=@p0,ads=@p1,communityUrl=@p2,updatedAt=@p3 WHERE id=1",current.Revision+1,JsonSerializer.Serialize(ads),Url(request.CommunityUrl),Now);
Exec(c,"INSERT INTO Audit(id,actor,action,target,detail,createdAt) VALUES(@p0,@p1,'CONTENT_UPDATE','AppContent',@p2,@p3)",Guid.NewGuid().ToString("N"),actor.Id,$"更新全局广告 {ads.Count} 条及社群链接",Now);
return Read(c,true);
});
public AppContent UpdateSupport(Account actor,UpdateSupportRequest request)=>store.Write(c=>
{
Actor(c,actor,true);var current=Read(c,true);
if(current.Revision!=request.ExpectedRevision)throw new CommunityException(409,"后台内容已被更新,请刷新后再修改");
if(request.Entries is null || request.Entries.Count>12)throw new ArgumentException("最多可配置 12 个客服与反馈入口");
var ids=new HashSet<string>();var entries=new List<SupportEntry>();
foreach(var entry in request.Entries)
{
if(entry is null || string.IsNullOrWhiteSpace(entry.Id) || entry.Id.Length>64 || !entry.Id.All(ch=>char.IsAsciiLetterOrDigit(ch)||ch is '-' or '_') || !ids.Add(entry.Id))throw new ArgumentException("客服入口编号无效或重复");
var title=(entry.Title??"").Trim();var description=(entry.Description??"").Trim();var phone=(entry.Phone??"").Trim();
if(title.Length is <1 or >60 || description.Length>1000 || entry.SortOrder is <0 or >9999)throw new ArgumentException("入口标题为 1 至 60 字,说明最多 1000 字,排序为 0 至 9999");
if(phone.Length>40 || phone.Length>0&&(!phone.Any(char.IsAsciiDigit)||!phone.All(ch=>char.IsAsciiDigit(ch)||ch is '+' or '-' or '(' or ')' or ' ')))throw new ArgumentException("请填写有效的客服电话");
entries.Add(entry with{Title=title,Description=description,Phone=phone,QrImage=Image(entry.QrImage),Link=Url(entry.Link)});
}
if(entries.Sum(e=>(long)e.QrImage.Length)>5_500_000)throw new ArgumentException("二维码图片总大小超限,请压缩图片或减少入口数量");
#if !COMMUNITY_APP
foreach(var fixedEntry in (current.Support??[]).Where(e=>e.Id is "purchase-contact" or "invoice-contact"))
{
if(entries.All(e=>e.Id!=fixedEntry.Id))entries.Add(fixedEntry);
int index=entries.FindIndex(e=>e.Id==fixedEntry.Id);entries[index]=entries[index] with{Enabled=true};
}
#endif
if(entries.Count>12||entries.Sum(e=>(long)e.QrImage.Length)>5_500_000)throw new ArgumentException("客服入口最多 12 个(含固定入口),二维码总大小不能超过 5.5 MB");
Exec(c,"UPDATE AppContent SET revision=@p0,support=@p1,updatedAt=@p2 WHERE id=1",current.Revision+1,JsonSerializer.Serialize(entries),Now);
Exec(c,"INSERT INTO Audit(id,actor,action,target,detail,createdAt) VALUES(@p0,@p1,'SUPPORT_UPDATE','AppContent',@p2,@p3)",Guid.NewGuid().ToString("N"),actor.Id,$"更新客服与反馈入口 {entries.Count} 条",Now);
return Read(c,true);
});
public AppContent UpdateAbout(Account actor,UpdateAboutRequest request)=>store.Write(c=>
{
Actor(c,actor,true);var current=Read(c,true);if(current.Revision!=request.ExpectedRevision)throw new CommunityException(409,"内容已更新,请刷新后重试");
var value=request.About??throw new ArgumentException("请填写关于我们");
string Clean(string? text,int max){text=(text??"").Trim();if(text.Length>max||text.Any(ch=>char.IsControl(ch)&&ch is not '\n' and not '\r' and not '\t'))throw new ArgumentException("内容过长或含无效字符");return text;}
var about=new AboutInfo(Clean(value.Introduction,5000),Clean(value.Operator,200),Clean(value.Copyright,300),Clean(value.IcpNumber,100));
Exec(c,"UPDATE AppContent SET about=@p0,revision=revision+1,updatedAt=@p1 WHERE id=1",JsonSerializer.Serialize(about),Now);
Exec(c,"INSERT INTO Audit(id,actor,action,target,detail,createdAt) VALUES(@p0,@p1,'ABOUT_UPDATE','AppContent','更新关于我们与备案号',@p2)",Guid.NewGuid().ToString("N"),actor.Id,Now);return Read(c,true);
});
}