using JiDangJia.Contracts;
using Microsoft.Data.Sqlite;
using System.Text;
namespace JiDangJia.Server;
public sealed partial class ServerStore
{
private void InitializeLegal(SqliteConnection c)
{
Exec(c,"""
CREATE TABLE IF NOT EXISTS LegalDocuments(key TEXT NOT NULL,version INTEGER NOT NULL,title TEXT NOT NULL,content TEXT NOT NULL,contentSha256 TEXT NOT NULL,publishedAt TEXT NOT NULL,publishedBy TEXT NOT NULL,changeNote TEXT NOT NULL,sourceFileName TEXT NOT NULL,PRIMARY KEY(key,version));
CREATE TABLE IF NOT EXISTS LegalCurrent(key TEXT PRIMARY KEY,version INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS LegalAcceptances(id INTEGER PRIMARY KEY AUTOINCREMENT,accountId TEXT NOT NULL,event TEXT NOT NULL,userAgreementVersion INTEGER NOT NULL,privacyPolicyVersion INTEGER NOT NULL,chinaMobileVersion INTEGER NOT NULL,userAgreementHash TEXT NOT NULL,privacyPolicyHash TEXT NOT NULL,chinaMobileHash TEXT NOT NULL,chinaMobileUrl TEXT NOT NULL,deviceId TEXT NOT NULL,acceptedAt TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS idx_legal_acceptances_account ON LegalAcceptances(accountId,id DESC);
""");
bool refresh=Rows(c,"SELECT name FROM sqlite_master WHERE type='table' AND name='ProductLegalRefresh'").Count>0&&Rows(c,"SELECT id FROM ProductLegalRefresh").Count>0;
foreach(var item in new[]{(LegalPolicyRules.UserAgreement,"用户协议","Legal.UserAgreement"),(LegalPolicyRules.PrivacyPolicy,"隐私政策","Legal.PrivacyPolicy")})
{
using var stream=typeof(ServerStore).Assembly.GetManifestResourceStream(item.Item3)??throw new InvalidOperationException("缺少协议草稿资源");
using var reader=new StreamReader(stream,Encoding.UTF8);var content=LegalPolicyRules.Normalize(reader.ReadToEnd());
#if COMMUNITY_APP
var existing=Rows(c,"SELECT d.* FROM LegalDocuments d JOIN LegalCurrent p ON p.key=d.key AND p.version=d.version WHERE d.key=@p0",item.Item1).FirstOrDefault();
// Preserve operator uploads and old receipts when replacing an outdated built-in draft.
bool freeRefresh=!refresh&&existing is not null&&Text(existing,"publishedBy")=="SYSTEM"&&!string.Equals(Text(existing,"contentSha256"),Hash(content),StringComparison.OrdinalIgnoreCase);
#else
bool freeRefresh=false;
#endif
long version=refresh||freeRefresh?Convert.ToInt64(Rows(c,"SELECT COALESCE(MAX(version),0)+1 version FROM LegalDocuments WHERE key=@p0",item.Item1)[0]["version"]):1;
Exec(c,"INSERT OR IGNORE INTO LegalDocuments VALUES(@p0,@p1,@p2,@p3,@p4,@p5,'SYSTEM',@p6,@p7)",item.Item1,version,item.Item2,content,Hash(content),Now,refresh?"独立应用协议;请重新阅读同意":"首版草稿;待填写信息由运营者完善后发布",item.Item2+".txt");
if(refresh||freeRefresh)Exec(c,"INSERT INTO LegalCurrent VALUES(@p0,@p1) ON CONFLICT(key) DO UPDATE SET version=excluded.version",item.Item1,version);
else Exec(c,"INSERT OR IGNORE INTO LegalCurrent VALUES(@p0,1)",item.Item1);
}
if(refresh)Exec(c,"DELETE FROM ProductLegalRefresh");
}
private static LegalDocument LegalFrom(Dictionary<string,object?> r)=>new(Text(r,"key"),Text(r,"title"),Convert.ToInt64(r["version"]),Text(r,"content"),Text(r,"contentSha256").ToLowerInvariant(),Text(r,"publishedAt"),Text(r,"publishedBy"),Text(r,"changeNote"),Text(r,"sourceFileName"));
private static LegalDocument MobileTerms()=>new(LegalPolicyRules.ChinaMobile,"中国移动认证服务条款",1,LegalPolicyRules.ChinaMobileNotice,Hash(LegalPolicyRules.ChinaMobileNotice).ToLowerInvariant(),"2026-10-11","SYSTEM","官方条款链接;当前未接入认证服务","",LegalPolicyRules.ChinaMobileUrl);
private static LegalPolicies CurrentLegal(SqliteConnection c)
{
var documents=Rows(c,"SELECT d.* FROM LegalDocuments d JOIN LegalCurrent p ON p.key=d.key AND p.version=d.version").Select(LegalFrom).ToDictionary(d=>d.Key);
return new(documents[LegalPolicyRules.UserAgreement],documents[LegalPolicyRules.PrivacyPolicy],MobileTerms());
}
public LegalPolicies Legal()=>Read(CurrentLegal);
private static LegalPolicies ValidateLegal(SqliteConnection c,LegalConsent consent)
{
if(!LegalPolicyRules.AllAccepted(consent))throw new CommunityException(428,"请分别勾选同意用户协议、隐私政策和中国移动认证服务条款");
var policy=CurrentLegal(c);if(!policy.Matches(consent))throw new CommunityException(409,"协议版本已更新,请重新阅读并勾选同意");
if((consent.DeviceId??"").Length>256)throw new ArgumentException("同意记录设备标识无效");
return policy;
}
private static void SaveLegalAcceptance(SqliteConnection c,string accountId,string action,LegalConsent consent,LegalPolicies policy)
{
Exec(c,"INSERT INTO LegalAcceptances(accountId,event,userAgreementVersion,privacyPolicyVersion,chinaMobileVersion,userAgreementHash,privacyPolicyHash,chinaMobileHash,chinaMobileUrl,deviceId,acceptedAt) VALUES(@p0,@p1,@p2,@p3,@p4,@p5,@p6,@p7,@p8,@p9,@p10)",accountId,action,policy.UserAgreement.Version,policy.PrivacyPolicy.Version,policy.ChinaMobile.Version,policy.UserAgreement.ContentSha256,policy.PrivacyPolicy.ContentSha256,policy.ChinaMobile.ContentSha256,policy.ChinaMobile.ExternalUrl,consent.DeviceId??"",Now);
}
public LegalAcceptanceStatus LegalStatus(Account account)=>Read(c=>
{
var row=Rows(c,"SELECT * FROM LegalAcceptances WHERE accountId=@p0 ORDER BY id DESC LIMIT 1",account.Id).FirstOrDefault();
if(row is null)return new LegalAcceptanceStatus(false,0,0,0,"");
var user=Convert.ToInt64(row["userAgreementVersion"]);var privacy=Convert.ToInt64(row["privacyPolicyVersion"]);var mobile=Convert.ToInt64(row["chinaMobileVersion"]);
return new LegalAcceptanceStatus(CurrentLegal(c).Matches(new(user,privacy,mobile,true,true,true)),user,privacy,mobile,Text(row,"acceptedAt"));
});
public LegalDocument PublishLegal(Account actor,string key,PublishLegalRequest request)
{
RequireAdmin(actor);if(!LegalPolicyRules.Editable(key))throw new ArgumentException("仅可修改本平台用户协议和隐私政策");
var content=LegalPolicyRules.Normalize(request.Content);var title=(request.Title??"").Trim();var note=(request.ChangeNote??"").Trim();var filename=(request.SourceFileName??"").Trim();
if(content.Length is <100 or >LegalPolicyRules.MaxCharacters||content.Any(ch=>char.IsControl(ch)&&ch is not ('\n' or '\t')))throw new ArgumentException("协议正文需为 100–60000 字符,不能含无效控制字符");
if(title.Length is <2 or >80||note.Length is <1 or >500)throw new ArgumentException("请输入标题(2–80 字)和变更说明(1–500 字)");
if(filename.Length>120||filename.IndexOfAny(['/', '\\'])>=0||filename.Any(char.IsControl))throw new ArgumentException("文件名无效,请使用文件名称而非本机路径");
return Write(c=>
{
var current=key==LegalPolicyRules.UserAgreement?CurrentLegal(c).UserAgreement:CurrentLegal(c).PrivacyPolicy;
if(request.ExpectedVersion!=current.Version)throw new CommunityException(409,"其他管理员已更新此协议,请重新加载后编辑");
if(current.Content==content&¤t.Title==title)return current;
var version=checked(current.Version+1);var stamp=Now;
Exec(c,"INSERT INTO LegalDocuments VALUES(@p0,@p1,@p2,@p3,@p4,@p5,@p6,@p7,@p8)",key,version,title,content,Hash(content),stamp,actor.Id,note,filename);
Exec(c,"UPDATE LegalCurrent SET version=@p0 WHERE key=@p1",version,key);Audit(c,actor.Id,"LEGAL_PUBLISH",key,"版本 "+version+":"+note);
return LegalFrom(Rows(c,"SELECT * FROM LegalDocuments WHERE key=@p0 AND version=@p1",key,version)[0]);
});
}
public List<LegalVersionInfo> LegalHistory(Account actor,string key,long before=0)
{
RequireAdmin(actor);if(!LegalPolicyRules.Editable(key))throw new ArgumentException("协议类型无效");
return Read(c=>Rows(c,"SELECT * FROM LegalDocuments WHERE key=@p0 AND (@p1=0 OR version<@p1) ORDER BY version DESC LIMIT 40",key,before).Select(r=>new LegalVersionInfo(key,Text(r,"title"),Convert.ToInt64(r["version"]),Text(r,"contentSha256").ToLowerInvariant(),Text(r,"publishedAt"),Text(r,"changeNote"),Text(r,"sourceFileName"))).ToList());
}
public LegalDocument LegalVersion(Account actor,string key,long version)
{
RequireAdmin(actor);if(!LegalPolicyRules.Editable(key))throw new ArgumentException("协议类型无效");
return Read(c=>Rows(c,"SELECT * FROM LegalDocuments WHERE key=@p0 AND version=@p1",key,version).Select(LegalFrom).FirstOrDefault()??throw new CommunityException(404,"协议版本不存在"));
}
}
using JiDangJia.Contracts;
using Microsoft.Data.Sqlite;
using System.Text;
namespace JiDangJia.Server;
public sealed partial class ServerStore
{
private void InitializeLegal(SqliteConnection c)
{
Exec(c,"""
CREATE TABLE IF NOT EXISTS LegalDocuments(key TEXT NOT NULL,version INTEGER NOT NULL,title TEXT NOT NULL,content TEXT NOT NULL,contentSha256 TEXT NOT NULL,publishedAt TEXT NOT NULL,publishedBy TEXT NOT NULL,changeNote TEXT NOT NULL,sourceFileName TEXT NOT NULL,PRIMARY KEY(key,version));
CREATE TABLE IF NOT EXISTS LegalCurrent(key TEXT PRIMARY KEY,version INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS LegalAcceptances(id INTEGER PRIMARY KEY AUTOINCREMENT,accountId TEXT NOT NULL,event TEXT NOT NULL,userAgreementVersion INTEGER NOT NULL,privacyPolicyVersion INTEGER NOT NULL,chinaMobileVersion INTEGER NOT NULL,userAgreementHash TEXT NOT NULL,privacyPolicyHash TEXT NOT NULL,chinaMobileHash TEXT NOT NULL,chinaMobileUrl TEXT NOT NULL,deviceId TEXT NOT NULL,acceptedAt TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS idx_legal_acceptances_account ON LegalAcceptances(accountId,id DESC);
""");
bool refresh=Rows(c,"SELECT name FROM sqlite_master WHERE type='table' AND name='ProductLegalRefresh'").Count>0&&Rows(c,"SELECT id FROM ProductLegalRefresh").Count>0;
foreach(var item in new[]{(LegalPolicyRules.UserAgreement,"用户协议","Legal.UserAgreement"),(LegalPolicyRules.PrivacyPolicy,"隐私政策","Legal.PrivacyPolicy")})
{
using var stream=typeof(ServerStore).Assembly.GetManifestResourceStream(item.Item3)??throw new InvalidOperationException("缺少协议草稿资源");
using var reader=new StreamReader(stream,Encoding.UTF8);var content=LegalPolicyRules.Normalize(reader.ReadToEnd());
#if COMMUNITY_APP
var existing=Rows(c,"SELECT d.* FROM LegalDocuments d JOIN LegalCurrent p ON p.key=d.key AND p.version=d.version WHERE d.key=@p0",item.Item1).FirstOrDefault();
// Preserve operator uploads and old receipts when replacing an outdated built-in draft.
bool freeRefresh=!refresh&&existing is not null&&Text(existing,"publishedBy")=="SYSTEM"&&!string.Equals(Text(existing,"contentSha256"),Hash(content),StringComparison.OrdinalIgnoreCase);
#else
bool freeRefresh=false;
#endif
long version=refresh||freeRefresh?Convert.ToInt64(Rows(c,"SELECT COALESCE(MAX(version),0)+1 version FROM LegalDocuments WHERE key=@p0",item.Item1)[0]["version"]):1;
Exec(c,"INSERT OR IGNORE INTO LegalDocuments VALUES(@p0,@p1,@p2,@p3,@p4,@p5,'SYSTEM',@p6,@p7)",item.Item1,version,item.Item2,content,Hash(content),Now,refresh?"独立应用协议;请重新阅读同意":"首版草稿;待填写信息由运营者完善后发布",item.Item2+".txt");
if(refresh||freeRefresh)Exec(c,"INSERT INTO LegalCurrent VALUES(@p0,@p1) ON CONFLICT(key) DO UPDATE SET version=excluded.version",item.Item1,version);
else Exec(c,"INSERT OR IGNORE INTO LegalCurrent VALUES(@p0,1)",item.Item1);
}
if(refresh)Exec(c,"DELETE FROM ProductLegalRefresh");
}
private static LegalDocument LegalFrom(Dictionary<string,object?> r)=>new(Text(r,"key"),Text(r,"title"),Convert.ToInt64(r["version"]),Text(r,"content"),Text(r,"contentSha256").ToLowerInvariant(),Text(r,"publishedAt"),Text(r,"publishedBy"),Text(r,"changeNote"),Text(r,"sourceFileName"));
private static LegalDocument MobileTerms()=>new(LegalPolicyRules.ChinaMobile,"中国移动认证服务条款",1,LegalPolicyRules.ChinaMobileNotice,Hash(LegalPolicyRules.ChinaMobileNotice).ToLowerInvariant(),"2026-10-11","SYSTEM","官方条款链接;当前未接入认证服务","",LegalPolicyRules.ChinaMobileUrl);
private static LegalPolicies CurrentLegal(SqliteConnection c)
{
var documents=Rows(c,"SELECT d.* FROM LegalDocuments d JOIN LegalCurrent p ON p.key=d.key AND p.version=d.version").Select(LegalFrom).ToDictionary(d=>d.Key);
return new(documents[LegalPolicyRules.UserAgreement],documents[LegalPolicyRules.PrivacyPolicy],MobileTerms());
}
public LegalPolicies Legal()=>Read(CurrentLegal);
private static LegalPolicies ValidateLegal(SqliteConnection c,LegalConsent consent)
{
if(!LegalPolicyRules.AllAccepted(consent))throw new CommunityException(428,"请分别勾选同意用户协议、隐私政策和中国移动认证服务条款");
var policy=CurrentLegal(c);if(!policy.Matches(consent))throw new CommunityException(409,"协议版本已更新,请重新阅读并勾选同意");
if((consent.DeviceId??"").Length>256)throw new ArgumentException("同意记录设备标识无效");
return policy;
}
private static void SaveLegalAcceptance(SqliteConnection c,string accountId,string action,LegalConsent consent,LegalPolicies policy)
{
Exec(c,"INSERT INTO LegalAcceptances(accountId,event,userAgreementVersion,privacyPolicyVersion,chinaMobileVersion,userAgreementHash,privacyPolicyHash,chinaMobileHash,chinaMobileUrl,deviceId,acceptedAt) VALUES(@p0,@p1,@p2,@p3,@p4,@p5,@p6,@p7,@p8,@p9,@p10)",accountId,action,policy.UserAgreement.Version,policy.PrivacyPolicy.Version,policy.ChinaMobile.Version,policy.UserAgreement.ContentSha256,policy.PrivacyPolicy.ContentSha256,policy.ChinaMobile.ContentSha256,policy.ChinaMobile.ExternalUrl,consent.DeviceId??"",Now);
}
public LegalAcceptanceStatus LegalStatus(Account account)=>Read(c=>
{
var row=Rows(c,"SELECT * FROM LegalAcceptances WHERE accountId=@p0 ORDER BY id DESC LIMIT 1",account.Id).FirstOrDefault();
if(row is null)return new LegalAcceptanceStatus(false,0,0,0,"");
var user=Convert.ToInt64(row["userAgreementVersion"]);var privacy=Convert.ToInt64(row["privacyPolicyVersion"]);var mobile=Convert.ToInt64(row["chinaMobileVersion"]);
return new LegalAcceptanceStatus(CurrentLegal(c).Matches(new(user,privacy,mobile,true,true,true)),user,privacy,mobile,Text(row,"acceptedAt"));
});
public LegalDocument PublishLegal(Account actor,string key,PublishLegalRequest request)
{
RequireAdmin(actor);if(!LegalPolicyRules.Editable(key))throw new ArgumentException("仅可修改本平台用户协议和隐私政策");
var content=LegalPolicyRules.Normalize(request.Content);var title=(request.Title??"").Trim();var note=(request.ChangeNote??"").Trim();var filename=(request.SourceFileName??"").Trim();
if(content.Length is <100 or >LegalPolicyRules.MaxCharacters||content.Any(ch=>char.IsControl(ch)&&ch is not ('\n' or '\t')))throw new ArgumentException("协议正文需为 100–60000 字符,不能含无效控制字符");
if(title.Length is <2 or >80||note.Length is <1 or >500)throw new ArgumentException("请输入标题(2–80 字)和变更说明(1–500 字)");
if(filename.Length>120||filename.IndexOfAny(['/', '\\'])>=0||filename.Any(char.IsControl))throw new ArgumentException("文件名无效,请使用文件名称而非本机路径");
return Write(c=>
{
var current=key==LegalPolicyRules.UserAgreement?CurrentLegal(c).UserAgreement:CurrentLegal(c).PrivacyPolicy;
if(request.ExpectedVersion!=current.Version)throw new CommunityException(409,"其他管理员已更新此协议,请重新加载后编辑");
if(current.Content==content&¤t.Title==title)return current;
var version=checked(current.Version+1);var stamp=Now;
Exec(c,"INSERT INTO LegalDocuments VALUES(@p0,@p1,@p2,@p3,@p4,@p5,@p6,@p7,@p8)",key,version,title,content,Hash(content),stamp,actor.Id,note,filename);
Exec(c,"UPDATE LegalCurrent SET version=@p0 WHERE key=@p1",version,key);Audit(c,actor.Id,"LEGAL_PUBLISH",key,"版本 "+version+":"+note);
return LegalFrom(Rows(c,"SELECT * FROM LegalDocuments WHERE key=@p0 AND version=@p1",key,version)[0]);
});
}
public List<LegalVersionInfo> LegalHistory(Account actor,string key,long before=0)
{
RequireAdmin(actor);if(!LegalPolicyRules.Editable(key))throw new ArgumentException("协议类型无效");
return Read(c=>Rows(c,"SELECT * FROM LegalDocuments WHERE key=@p0 AND (@p1=0 OR version<@p1) ORDER BY version DESC LIMIT 40",key,before).Select(r=>new LegalVersionInfo(key,Text(r,"title"),Convert.ToInt64(r["version"]),Text(r,"contentSha256").ToLowerInvariant(),Text(r,"publishedAt"),Text(r,"changeNote"),Text(r,"sourceFileName"))).ToList());
}
public LegalDocument LegalVersion(Account actor,string key,long version)
{
RequireAdmin(actor);if(!LegalPolicyRules.Editable(key))throw new ArgumentException("协议类型无效");
return Read(c=>Rows(c,"SELECT * FROM LegalDocuments WHERE key=@p0 AND version=@p1",key,version).Select(LegalFrom).FirstOrDefault()??throw new CommunityException(404,"协议版本不存在"));
}
}