using Microsoft.Data.Sqlite;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using JiDangJia.Contracts;
namespace JiDangJia.Server;
public sealed record Account(string Id,string Username,string Name,string Role,bool Active,string Phone="",string PhoneMethod="");
public sealed partial class ServerStore
{
private readonly object sync=new();
private readonly string file;
public string DataDirectory {get;}
public ServerStore(string dataDirectory)
{
DataDirectory=PrivateStorage.OutsideArtifacts(dataDirectory);
Directory.CreateDirectory(DataDirectory);file=Path.Combine(DataDirectory,"accounts.db");
using var c=Open();ServerProduct.Claim(c);Exec(c,"""
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS Accounts(id TEXT PRIMARY KEY,username TEXT NOT NULL UNIQUE,name TEXT NOT NULL,passwordHash TEXT NOT NULL,role TEXT NOT NULL DEFAULT 'user',active INTEGER NOT NULL DEFAULT 1,createdAt TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS Sessions(tokenHash TEXT PRIMARY KEY,accountId TEXT NOT NULL,expiresAt TEXT NOT NULL,createdAt TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS idx_sessions_account ON Sessions(accountId);
CREATE TABLE IF NOT EXISTS Audit(id TEXT PRIMARY KEY,actor TEXT,action TEXT NOT NULL,target TEXT,detail TEXT,createdAt TEXT NOT NULL);
""");
#if !COMMUNITY_APP
Exec(c,"""
CREATE TABLE IF NOT EXISTS Cards(id TEXT PRIMARY KEY,codeHash TEXT NOT NULL UNIQUE,maskedCode TEXT NOT NULL,batch TEXT NOT NULL,days INTEGER NOT NULL,active INTEGER NOT NULL DEFAULT 1,expiresAt TEXT,usedBy TEXT,usedDevice TEXT,usedAt TEXT,createdAt TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS Licenses(id TEXT PRIMARY KEY,accountId TEXT NOT NULL,deviceId TEXT NOT NULL,expiresAt TEXT NOT NULL,active INTEGER NOT NULL DEFAULT 1,updatedAt TEXT NOT NULL,UNIQUE(accountId,deviceId));
""");
#endif
InitializeSafety(c);
InitializeLegal(c);
if(Rows(c,"SELECT id FROM Accounts WHERE role='admin'").Count==0)
{
var username=Environment.GetEnvironmentVariable("JDJ_ADMIN_USERNAME")??"admin";
var password=Environment.GetEnvironmentVariable("JDJ_ADMIN_PASSWORD")??"Jdj!"+Convert.ToHexString(RandomNumberGenerator.GetBytes(10));
ValidatePassword(password);
Exec(c,"INSERT INTO Accounts(id,username,name,passwordHash,role,createdAt) VALUES(@p0,@p1,@p2,@p3,'admin',@p4)",Guid.NewGuid().ToString("N"),username,"系统管理员",PasswordHash(password),Now);
File.WriteAllText(Path.Combine(dataDirectory,"initial-admin.txt"),$"{ServerProduct.Name}管理员初始账户\n账号:{username}\n密码:{password}\n服务器地址固定在 APP 源码中。使用以上账户登录,然后进入:设置 → 管理员工具 → 进入管理后台。\n首次登录后请修改密码。\n",Encoding.UTF8);
}
}
public static string Now=>DateTime.UtcNow.ToString("O");
private SqliteConnection Open(){var c=new SqliteConnection(new SqliteConnectionStringBuilder {DataSource=file,Pooling=false}.ToString());c.Open();return c;}
public T Read<T>(Func<SqliteConnection,T> f){lock(sync){using var c=Open();return f(c);}}
public T Write<T>(Func<SqliteConnection,T> f){lock(sync){using var c=Open();Exec(c,"BEGIN IMMEDIATE");try{var result=f(c);Exec(c,"COMMIT");return result;}catch{Exec(c,"ROLLBACK");throw;}}}
public static List<Dictionary<string,object?>> Rows(SqliteConnection c,string sql,params object?[] args)
{using var command=Command(c,sql,args);using var reader=command.ExecuteReader();var rows=new List<Dictionary<string,object?>>();while(reader.Read()){var row=new Dictionary<string,object?>();for(int i=0;i<reader.FieldCount;i++)row[reader.GetName(i)]=reader.IsDBNull(i)?null:reader.GetValue(i);rows.Add(row);}return rows;}
public static int Exec(SqliteConnection c,string sql,params object?[] args){using var command=Command(c,sql,args);return command.ExecuteNonQuery();}
private static SqliteCommand Command(SqliteConnection c,string sql,object?[] args){var command=c.CreateCommand();command.CommandText=sql;for(int i=0;i<args.Length;i++)command.Parameters.AddWithValue("@p"+i,args[i]??DBNull.Value);return command;}
public static string Text(Dictionary<string,object?> r,string key)=>r.GetValueOrDefault(key)?.ToString()??"";
public static string Hash(string value)=>Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)));
public static string PasswordHash(string password)
{var salt=RandomNumberGenerator.GetBytes(16);var hash=Rfc2898DeriveBytes.Pbkdf2(password,salt,210000,HashAlgorithmName.SHA256,32);return "210000:"+Convert.ToBase64String(salt)+":"+Convert.ToBase64String(hash);}
public static bool VerifyPassword(string password,string encoded)
{try{var p=encoded.Split(':');var hash=Rfc2898DeriveBytes.Pbkdf2(password,Convert.FromBase64String(p[1]),int.Parse(p[0]),HashAlgorithmName.SHA256,32);return CryptographicOperations.FixedTimeEquals(hash,Convert.FromBase64String(p[2]));}catch{return false;}}
internal static Account AccountFrom(Dictionary<string,object?> r)=>new(Text(r,"id"),Text(r,"username"),Text(r,"name"),Text(r,"role"),Text(r,"active")=="1",Text(r,"phone"),Text(r,"phoneMethod"));
public Account Register(string username,string password,string name,LegalConsent? consent=null)
{
username=(username??"").Trim().ToLowerInvariant();name??="";
if(!System.Text.RegularExpressions.Regex.IsMatch(username,"^[a-z0-9_+@.\\-]{4,64}$"))throw new ArgumentException("账号需为 4 至 64 位字母、数字或邮箱");ValidatePassword(password);if(name.Trim().Length is <1 or >64)throw new ArgumentException("请输入姓名(最多 64 字)");
return Write(c=>{var policy=consent is null?null:ValidateLegal(c,consent);if(Rows(c,"SELECT id FROM Accounts WHERE username=@p0",username).Count>0)throw new ArgumentException("此账号已注册");var id=Guid.NewGuid().ToString("N");Exec(c,"INSERT INTO Accounts(id,username,name,passwordHash,role,createdAt) VALUES(@p0,@p1,@p2,@p3,'user',@p4)",id,username,name.Trim(),PasswordHash(password),Now);if(policy is not null)SaveLegalAcceptance(c,id,"REGISTER",consent!,policy);Audit(c,id,"REGISTER",id,username);return new Account(id,username,name,"user",true);});
}
public static void ValidatePassword(string password){if(password is null||password.Length is <8 or >128)throw new ArgumentException("密码应为 8 至 128 位");}
public (Account account,string token) Login(string username,string password,LegalConsent? consent=null)
{
if(string.IsNullOrWhiteSpace(username)||string.IsNullOrEmpty(password)||username.Length>64||password.Length>128)throw new UnauthorizedAccessException("账号或密码错误");
return Write(c=>
{
var row=Rows(c,"SELECT * FROM Accounts WHERE username=@p0",username.Trim().ToLowerInvariant()).FirstOrDefault();
if(row is null){VerifyPassword(password,"210000:AAAAAAAAAAAAAAAAAAAAAA==:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");throw new UnauthorizedAccessException("账号或密码错误");}
if(Text(row,"active")!="1"||!VerifyPassword(password,Text(row,"passwordHash")))throw new UnauthorizedAccessException("账号或密码错误,或账户已停用");
if(consent is not null){var policy=ValidateLegal(c,consent);SaveLegalAcceptance(c,Text(row,"id"),"LOGIN",consent,policy);}
var token=Convert.ToHexString(RandomNumberGenerator.GetBytes(32));var account=AccountFrom(row);
Exec(c,"DELETE FROM Sessions WHERE expiresAt<@p0",Now);Exec(c,"INSERT INTO Sessions VALUES(@p0,@p1,@p2,@p3)",Hash(token),account.Id,DateTime.UtcNow.AddDays(7).ToString("O"),Now);Audit(c,account.Id,"LOGIN",account.Id,"");return (account,token);
});
}
public Account? Session(string? token)
{if(string.IsNullOrWhiteSpace(token)||token.Length!=64)return null;return Read(c=>{var row=Rows(c,"SELECT a.* FROM Accounts a JOIN Sessions s ON s.accountId=a.id WHERE s.tokenHash=@p0 AND s.expiresAt>@p1 AND a.active=1",Hash(token),Now).FirstOrDefault();return row is null?null:AccountFrom(row);});}
public void Logout(string token)=>Write(c=>Exec(c,"DELETE FROM Sessions WHERE tokenHash=@p0",Hash(token)));
public void ChangePassword(Account account,string oldPassword,string password)
{ValidatePassword(password);Write(c=>{var row=Rows(c,"SELECT passwordHash FROM Accounts WHERE id=@p0",account.Id).First();if(!VerifyPassword(oldPassword,Text(row,"passwordHash")))throw new ArgumentException("原密码错误");Exec(c,"UPDATE Accounts SET passwordHash=@p0 WHERE id=@p1",PasswordHash(password),account.Id);Exec(c,"DELETE FROM Sessions WHERE accountId=@p0",account.Id);Audit(c,account.Id,"PASSWORD_CHANGE",account.Id,"");return 0;});}
#if ACCOUNTING_APP
public List<Dictionary<string,object?>> Accounts()=>Read(c=>Rows(c,"SELECT id,username,name,role,active,createdAt,phone,phoneMethod FROM Accounts ORDER BY createdAt DESC"));
#else
public List<Dictionary<string,object?>> Accounts()=>Read(c=>{var rows=Rows(c,"SELECT id,username,name,role,active,createdAt,phone,phoneMethod,identityStatus,identityKind,communityBanned,communityBanReason,communityBanUntil FROM Accounts ORDER BY createdAt DESC");foreach(var row in rows)row["communityBanned"]=CommunityMuted(row)?1:0;return rows;});
#endif
#if !COMMUNITY_APP
public List<Dictionary<string,object?>> Cards()=>Read(c=>Rows(c,"SELECT c.id,c.maskedCode,c.batch,c.days,c.active,c.expiresAt,c.usedBy,c.usedDevice,c.usedAt,c.createdAt,c.permanent,c.fixedUntil,a.username usedUsername FROM Cards c LEFT JOIN Accounts a ON a.id=c.usedBy ORDER BY c.createdAt DESC"));
public List<Dictionary<string,object?>> Licenses()=>Read(c=>Rows(c,"SELECT l.*,a.username,a.name FROM Licenses l JOIN Accounts a ON a.id=l.accountId ORDER BY l.updatedAt DESC"));
#endif
public List<Dictionary<string,object?>> Audits()=>Read(c=>Rows(c,"SELECT u.username actorName,a.* FROM Audit a LEFT JOIN Accounts u ON u.id=a.actor ORDER BY a.createdAt DESC LIMIT 200"));
#if !COMMUNITY_APP
public List<object> Issue(Account actor,int count,int days,string batch,int validDays,bool permanent=false,DateTimeOffset? until=null)
{
RequireAdmin(actor);batch??="";
if(count is <1 or >1000 || (!permanent&&until is null&&days is <1 or >36500) || validDays is <1 or >3650 || batch.Length>64)throw new ArgumentException("数量 1–1000,自定义授权天数 1–36500,兑换有效天数 1–3650,批次最多 64 字");
if(permanent&&until is not null||until is not null&&(until<=DateTimeOffset.UtcNow||until>DateTimeOffset.UtcNow.AddYears(100)))throw new ArgumentException("自定义到期日应在未来,且不能与永久同时选择");
return Write(c=>{var list=new List<object>();for(int i=0;i<count;i++){var code="JDJ-"+Convert.ToHexString(RandomNumberGenerator.GetBytes(12));var id=Guid.NewGuid().ToString("N");Exec(c,"INSERT INTO Cards(id,codeHash,maskedCode,batch,days,expiresAt,createdAt,permanent,fixedUntil) VALUES(@p0,@p1,@p2,@p3,@p4,@p5,@p6,@p7,@p8)",id,Hash(code),code[..8]+"••••"+code[^4..],batch,permanent||until is not null?0:days,DateTime.UtcNow.AddDays(validDays).ToString("O"),Now,permanent?1:0,until?.UtcDateTime.ToString("O"));list.Add(new{id,code,days=permanent||until is not null?0:days,batch,permanent,until});}Audit(c,actor.Id,"CARD_ISSUE",batch,JsonSerializer.Serialize(new{count,days,validDays,permanent,until}));return list;});
}
public Dictionary<string,object?> License(Account account,string device)
{
ValidateDevice(device);
if(account.Role=="admin")return new(){["active"]=true,["expiresAt"]=DateTime.UtcNow.AddDays(7).ToString("O"),["deviceId"]=device};
return Read(c=>License(c,account.Id,device));
}
private static Dictionary<string,object?> License(SqliteConnection c,string accountId,string device)
{
var row=Rows(c,"SELECT * FROM Licenses WHERE accountId=@p0 AND deviceId=@p1",accountId,device).FirstOrDefault();
bool active=row is not null && Text(row,"active")=="1"&&DateTime.TryParse(Text(row,"expiresAt"),out var expires)&&expires.ToUniversalTime()>DateTime.UtcNow;
return new(){["active"]=active,["expiresAt"]=row is null?null:row["expiresAt"],["deviceId"]=device,["permanent"]=row is not null&&Text(row,"permanent")=="1"};
}
public Dictionary<string,object?> Activate(Account account,string device,string code)
{
ValidateDevice(device);code=(code??"").Trim().ToUpperInvariant();if(code.Length!=28||!code.StartsWith("JDJ-"))throw new ArgumentException("卡密格式不正确");
return Write(c=>
{
var card=Rows(c,"SELECT * FROM Cards WHERE codeHash=@p0",Hash(code)).FirstOrDefault()??throw new ArgumentException("卡密不存在");
if(Text(card,"active")!="1")throw new ArgumentException("卡密已停用");
if(Text(card,"usedBy").Length>0){if(Text(card,"usedBy")==account.Id && Text(card,"usedDevice")==device)return License(c,account.Id,device);throw new ArgumentException("卡密已被使用");}
if(DateTime.Parse(Text(card,"expiresAt")).ToUniversalTime()<=DateTime.UtcNow)throw new ArgumentException("卡密已过兑换有效期");
var old=Rows(c,"SELECT * FROM Licenses WHERE accountId=@p0 AND deviceId=@p1",account.Id,device).FirstOrDefault();
DateTime until=old is not null&&Text(old,"active")=="1"?DateTime.Parse(Text(old,"expiresAt")).ToUniversalTime():DateTime.UtcNow;if(until<DateTime.UtcNow)until=DateTime.UtcNow;
bool permanent=Text(card,"permanent")=="1"||old is not null&&Text(old,"active")=="1"&&Text(old,"permanent")=="1";
if(permanent)until=new DateTime(9999,12,31,0,0,0,DateTimeKind.Utc);
else if(DateTime.TryParse(Text(card,"fixedUntil"),out var fixedUntil)){fixedUntil=fixedUntil.ToUniversalTime();if(fixedUntil<=DateTime.UtcNow)throw new ArgumentException("此卡密的授权到期日已过");if(fixedUntil>until)until=fixedUntil;}
else until=until.AddDays(Convert.ToInt32(card["days"]));
Exec(c,"INSERT INTO Licenses(id,accountId,deviceId,expiresAt,active,updatedAt,permanent) VALUES(@p0,@p1,@p2,@p3,1,@p4,@p5) ON CONFLICT(accountId,deviceId) DO UPDATE SET expiresAt=excluded.expiresAt,active=1,updatedAt=excluded.updatedAt,permanent=excluded.permanent",Guid.NewGuid().ToString("N"),account.Id,device,until.ToString("O"),Now,permanent?1:0);
Exec(c,"UPDATE Cards SET usedBy=@p0,usedDevice=@p1,usedAt=@p2 WHERE id=@p3 AND usedBy IS NULL",account.Id,device,Now,Text(card,"id"));Audit(c,account.Id,"CARD_ACTIVATE",Text(card,"id"),device);return License(c,account.Id,device);
});
}
#endif
public static void ValidateDevice(string device){if(device is null||device.Length is <16 or >128 || !device.All(c=>char.IsAsciiLetterOrDigit(c)||c is '-' or '_'))throw new ArgumentException("设备标识无效");}
public void SetAccount(Account actor,string id,bool active)
{RequireAdmin(actor);if(id==actor.Id&&!active)throw new ArgumentException("不能停用当前管理员");Write(c=>{var account=Rows(c,"SELECT role FROM Accounts WHERE id=@p0",id).FirstOrDefault()??throw new ArgumentException("账号不存在");if(Text(account,"role")=="admin")throw new ArgumentException("管理员账户不可停用");Exec(c,"UPDATE Accounts SET active=@p0 WHERE id=@p1",active?1:0,id);if(!active)Exec(c,"DELETE FROM Sessions WHERE accountId=@p0",id);Audit(c,actor.Id,"ACCOUNT_STATUS",id,active.ToString());return 0;});}
#if !COMMUNITY_APP
public void SetCard(Account actor,string id,bool active){RequireAdmin(actor);Write(c=>{if(Exec(c,"UPDATE Cards SET active=@p0 WHERE id=@p1",active?1:0,id)==0)throw new ArgumentException("卡密不存在");Audit(c,actor.Id,"CARD_STATUS",id,active.ToString());return 0;});}
public void SetLicense(Account actor,string id,bool active){RequireAdmin(actor);Write(c=>{if(Exec(c,"UPDATE Licenses SET active=@p0,updatedAt=@p1 WHERE id=@p2",active?1:0,Now,id)==0)throw new ArgumentException("授权不存在");Audit(c,actor.Id,"LICENSE_STATUS",id,active.ToString());return 0;});}
#endif
public void ResetUser(Account actor,string id,string password)
{RequireAdmin(actor);ValidatePassword(password);Write(c=>{var row=Rows(c,"SELECT role FROM Accounts WHERE id=@p0",id).FirstOrDefault()??throw new ArgumentException("账户不存在");if(Text(row,"role")=="admin")throw new ArgumentException("管理员请使用修改密码功能");Exec(c,"UPDATE Accounts SET passwordHash=@p0 WHERE id=@p1",PasswordHash(password),id);Exec(c,"DELETE FROM Sessions WHERE accountId=@p0",id);Audit(c,actor.Id,"PASSWORD_RESET",id,"");return 0;});}
private void RequireAdmin(Account actor){if(!Read(c=>Rows(c,"SELECT id FROM Accounts WHERE id=@p0 AND role='admin' AND active=1",actor.Id).Count>0))throw new UnauthorizedAccessException("需要管理员权限");}
private static void Audit(SqliteConnection c,string actor,string action,string target,string detail)=>Exec(c,"INSERT INTO Audit VALUES(@p0,@p1,@p2,@p3,@p4,@p5)",Guid.NewGuid().ToString("N"),actor,action,target,detail,Now);
}
using Microsoft.Data.Sqlite;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using JiDangJia.Contracts;
namespace JiDangJia.Server;
public sealed record Account(string Id,string Username,string Name,string Role,bool Active,string Phone="",string PhoneMethod="");
public sealed partial class ServerStore
{
private readonly object sync=new();
private readonly string file;
public string DataDirectory {get;}
public ServerStore(string dataDirectory)
{
DataDirectory=PrivateStorage.OutsideArtifacts(dataDirectory);
Directory.CreateDirectory(DataDirectory);file=Path.Combine(DataDirectory,"accounts.db");
using var c=Open();ServerProduct.Claim(c);Exec(c,"""
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS Accounts(id TEXT PRIMARY KEY,username TEXT NOT NULL UNIQUE,name TEXT NOT NULL,passwordHash TEXT NOT NULL,role TEXT NOT NULL DEFAULT 'user',active INTEGER NOT NULL DEFAULT 1,createdAt TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS Sessions(tokenHash TEXT PRIMARY KEY,accountId TEXT NOT NULL,expiresAt TEXT NOT NULL,createdAt TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS idx_sessions_account ON Sessions(accountId);
CREATE TABLE IF NOT EXISTS Audit(id TEXT PRIMARY KEY,actor TEXT,action TEXT NOT NULL,target TEXT,detail TEXT,createdAt TEXT NOT NULL);
""");
#if !COMMUNITY_APP
Exec(c,"""
CREATE TABLE IF NOT EXISTS Cards(id TEXT PRIMARY KEY,codeHash TEXT NOT NULL UNIQUE,maskedCode TEXT NOT NULL,batch TEXT NOT NULL,days INTEGER NOT NULL,active INTEGER NOT NULL DEFAULT 1,expiresAt TEXT,usedBy TEXT,usedDevice TEXT,usedAt TEXT,createdAt TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS Licenses(id TEXT PRIMARY KEY,accountId TEXT NOT NULL,deviceId TEXT NOT NULL,expiresAt TEXT NOT NULL,active INTEGER NOT NULL DEFAULT 1,updatedAt TEXT NOT NULL,UNIQUE(accountId,deviceId));
""");
#endif
InitializeSafety(c);
InitializeLegal(c);
if(Rows(c,"SELECT id FROM Accounts WHERE role='admin'").Count==0)
{
var username=Environment.GetEnvironmentVariable("JDJ_ADMIN_USERNAME")??"admin";
var password=Environment.GetEnvironmentVariable("JDJ_ADMIN_PASSWORD")??"Jdj!"+Convert.ToHexString(RandomNumberGenerator.GetBytes(10));
ValidatePassword(password);
Exec(c,"INSERT INTO Accounts(id,username,name,passwordHash,role,createdAt) VALUES(@p0,@p1,@p2,@p3,'admin',@p4)",Guid.NewGuid().ToString("N"),username,"系统管理员",PasswordHash(password),Now);
File.WriteAllText(Path.Combine(dataDirectory,"initial-admin.txt"),$"{ServerProduct.Name}管理员初始账户\n账号:{username}\n密码:{password}\n服务器地址固定在 APP 源码中。使用以上账户登录,然后进入:设置 → 管理员工具 → 进入管理后台。\n首次登录后请修改密码。\n",Encoding.UTF8);
}
}
public static string Now=>DateTime.UtcNow.ToString("O");
private SqliteConnection Open(){var c=new SqliteConnection(new SqliteConnectionStringBuilder {DataSource=file,Pooling=false}.ToString());c.Open();return c;}
public T Read<T>(Func<SqliteConnection,T> f){lock(sync){using var c=Open();return f(c);}}
public T Write<T>(Func<SqliteConnection,T> f){lock(sync){using var c=Open();Exec(c,"BEGIN IMMEDIATE");try{var result=f(c);Exec(c,"COMMIT");return result;}catch{Exec(c,"ROLLBACK");throw;}}}
public static List<Dictionary<string,object?>> Rows(SqliteConnection c,string sql,params object?[] args)
{using var command=Command(c,sql,args);using var reader=command.ExecuteReader();var rows=new List<Dictionary<string,object?>>();while(reader.Read()){var row=new Dictionary<string,object?>();for(int i=0;i<reader.FieldCount;i++)row[reader.GetName(i)]=reader.IsDBNull(i)?null:reader.GetValue(i);rows.Add(row);}return rows;}
public static int Exec(SqliteConnection c,string sql,params object?[] args){using var command=Command(c,sql,args);return command.ExecuteNonQuery();}
private static SqliteCommand Command(SqliteConnection c,string sql,object?[] args){var command=c.CreateCommand();command.CommandText=sql;for(int i=0;i<args.Length;i++)command.Parameters.AddWithValue("@p"+i,args[i]??DBNull.Value);return command;}
public static string Text(Dictionary<string,object?> r,string key)=>r.GetValueOrDefault(key)?.ToString()??"";
public static string Hash(string value)=>Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)));
public static string PasswordHash(string password)
{var salt=RandomNumberGenerator.GetBytes(16);var hash=Rfc2898DeriveBytes.Pbkdf2(password,salt,210000,HashAlgorithmName.SHA256,32);return "210000:"+Convert.ToBase64String(salt)+":"+Convert.ToBase64String(hash);}
public static bool VerifyPassword(string password,string encoded)
{try{var p=encoded.Split(':');var hash=Rfc2898DeriveBytes.Pbkdf2(password,Convert.FromBase64String(p[1]),int.Parse(p[0]),HashAlgorithmName.SHA256,32);return CryptographicOperations.FixedTimeEquals(hash,Convert.FromBase64String(p[2]));}catch{return false;}}
internal static Account AccountFrom(Dictionary<string,object?> r)=>new(Text(r,"id"),Text(r,"username"),Text(r,"name"),Text(r,"role"),Text(r,"active")=="1",Text(r,"phone"),Text(r,"phoneMethod"));
public Account Register(string username,string password,string name,LegalConsent? consent=null)
{
username=(username??"").Trim().ToLowerInvariant();name??="";
if(!System.Text.RegularExpressions.Regex.IsMatch(username,"^[a-z0-9_+@.\\-]{4,64}$"))throw new ArgumentException("账号需为 4 至 64 位字母、数字或邮箱");ValidatePassword(password);if(name.Trim().Length is <1 or >64)throw new ArgumentException("请输入姓名(最多 64 字)");
return Write(c=>{var policy=consent is null?null:ValidateLegal(c,consent);if(Rows(c,"SELECT id FROM Accounts WHERE username=@p0",username).Count>0)throw new ArgumentException("此账号已注册");var id=Guid.NewGuid().ToString("N");Exec(c,"INSERT INTO Accounts(id,username,name,passwordHash,role,createdAt) VALUES(@p0,@p1,@p2,@p3,'user',@p4)",id,username,name.Trim(),PasswordHash(password),Now);if(policy is not null)SaveLegalAcceptance(c,id,"REGISTER",consent!,policy);Audit(c,id,"REGISTER",id,username);return new Account(id,username,name,"user",true);});
}
public static void ValidatePassword(string password){if(password is null||password.Length is <8 or >128)throw new ArgumentException("密码应为 8 至 128 位");}
public (Account account,string token) Login(string username,string password,LegalConsent? consent=null)
{
if(string.IsNullOrWhiteSpace(username)||string.IsNullOrEmpty(password)||username.Length>64||password.Length>128)throw new UnauthorizedAccessException("账号或密码错误");
return Write(c=>
{
var row=Rows(c,"SELECT * FROM Accounts WHERE username=@p0",username.Trim().ToLowerInvariant()).FirstOrDefault();
if(row is null){VerifyPassword(password,"210000:AAAAAAAAAAAAAAAAAAAAAA==:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");throw new UnauthorizedAccessException("账号或密码错误");}
if(Text(row,"active")!="1"||!VerifyPassword(password,Text(row,"passwordHash")))throw new UnauthorizedAccessException("账号或密码错误,或账户已停用");
if(consent is not null){var policy=ValidateLegal(c,consent);SaveLegalAcceptance(c,Text(row,"id"),"LOGIN",consent,policy);}
var token=Convert.ToHexString(RandomNumberGenerator.GetBytes(32));var account=AccountFrom(row);
Exec(c,"DELETE FROM Sessions WHERE expiresAt<@p0",Now);Exec(c,"INSERT INTO Sessions VALUES(@p0,@p1,@p2,@p3)",Hash(token),account.Id,DateTime.UtcNow.AddDays(7).ToString("O"),Now);Audit(c,account.Id,"LOGIN",account.Id,"");return (account,token);
});
}
public Account? Session(string? token)
{if(string.IsNullOrWhiteSpace(token)||token.Length!=64)return null;return Read(c=>{var row=Rows(c,"SELECT a.* FROM Accounts a JOIN Sessions s ON s.accountId=a.id WHERE s.tokenHash=@p0 AND s.expiresAt>@p1 AND a.active=1",Hash(token),Now).FirstOrDefault();return row is null?null:AccountFrom(row);});}
public void Logout(string token)=>Write(c=>Exec(c,"DELETE FROM Sessions WHERE tokenHash=@p0",Hash(token)));
public void ChangePassword(Account account,string oldPassword,string password)
{ValidatePassword(password);Write(c=>{var row=Rows(c,"SELECT passwordHash FROM Accounts WHERE id=@p0",account.Id).First();if(!VerifyPassword(oldPassword,Text(row,"passwordHash")))throw new ArgumentException("原密码错误");Exec(c,"UPDATE Accounts SET passwordHash=@p0 WHERE id=@p1",PasswordHash(password),account.Id);Exec(c,"DELETE FROM Sessions WHERE accountId=@p0",account.Id);Audit(c,account.Id,"PASSWORD_CHANGE",account.Id,"");return 0;});}
#if ACCOUNTING_APP
public List<Dictionary<string,object?>> Accounts()=>Read(c=>Rows(c,"SELECT id,username,name,role,active,createdAt,phone,phoneMethod FROM Accounts ORDER BY createdAt DESC"));
#else
public List<Dictionary<string,object?>> Accounts()=>Read(c=>{var rows=Rows(c,"SELECT id,username,name,role,active,createdAt,phone,phoneMethod,identityStatus,identityKind,communityBanned,communityBanReason,communityBanUntil FROM Accounts ORDER BY createdAt DESC");foreach(var row in rows)row["communityBanned"]=CommunityMuted(row)?1:0;return rows;});
#endif
#if !COMMUNITY_APP
public List<Dictionary<string,object?>> Cards()=>Read(c=>Rows(c,"SELECT c.id,c.maskedCode,c.batch,c.days,c.active,c.expiresAt,c.usedBy,c.usedDevice,c.usedAt,c.createdAt,c.permanent,c.fixedUntil,a.username usedUsername FROM Cards c LEFT JOIN Accounts a ON a.id=c.usedBy ORDER BY c.createdAt DESC"));
public List<Dictionary<string,object?>> Licenses()=>Read(c=>Rows(c,"SELECT l.*,a.username,a.name FROM Licenses l JOIN Accounts a ON a.id=l.accountId ORDER BY l.updatedAt DESC"));
#endif
public List<Dictionary<string,object?>> Audits()=>Read(c=>Rows(c,"SELECT u.username actorName,a.* FROM Audit a LEFT JOIN Accounts u ON u.id=a.actor ORDER BY a.createdAt DESC LIMIT 200"));
#if !COMMUNITY_APP
public List<object> Issue(Account actor,int count,int days,string batch,int validDays,bool permanent=false,DateTimeOffset? until=null)
{
RequireAdmin(actor);batch??="";
if(count is <1 or >1000 || (!permanent&&until is null&&days is <1 or >36500) || validDays is <1 or >3650 || batch.Length>64)throw new ArgumentException("数量 1–1000,自定义授权天数 1–36500,兑换有效天数 1–3650,批次最多 64 字");
if(permanent&&until is not null||until is not null&&(until<=DateTimeOffset.UtcNow||until>DateTimeOffset.UtcNow.AddYears(100)))throw new ArgumentException("自定义到期日应在未来,且不能与永久同时选择");
return Write(c=>{var list=new List<object>();for(int i=0;i<count;i++){var code="JDJ-"+Convert.ToHexString(RandomNumberGenerator.GetBytes(12));var id=Guid.NewGuid().ToString("N");Exec(c,"INSERT INTO Cards(id,codeHash,maskedCode,batch,days,expiresAt,createdAt,permanent,fixedUntil) VALUES(@p0,@p1,@p2,@p3,@p4,@p5,@p6,@p7,@p8)",id,Hash(code),code[..8]+"••••"+code[^4..],batch,permanent||until is not null?0:days,DateTime.UtcNow.AddDays(validDays).ToString("O"),Now,permanent?1:0,until?.UtcDateTime.ToString("O"));list.Add(new{id,code,days=permanent||until is not null?0:days,batch,permanent,until});}Audit(c,actor.Id,"CARD_ISSUE",batch,JsonSerializer.Serialize(new{count,days,validDays,permanent,until}));return list;});
}
public Dictionary<string,object?> License(Account account,string device)
{
ValidateDevice(device);
if(account.Role=="admin")return new(){["active"]=true,["expiresAt"]=DateTime.UtcNow.AddDays(7).ToString("O"),["deviceId"]=device};
return Read(c=>License(c,account.Id,device));
}
private static Dictionary<string,object?> License(SqliteConnection c,string accountId,string device)
{
var row=Rows(c,"SELECT * FROM Licenses WHERE accountId=@p0 AND deviceId=@p1",accountId,device).FirstOrDefault();
bool active=row is not null && Text(row,"active")=="1"&&DateTime.TryParse(Text(row,"expiresAt"),out var expires)&&expires.ToUniversalTime()>DateTime.UtcNow;
return new(){["active"]=active,["expiresAt"]=row is null?null:row["expiresAt"],["deviceId"]=device,["permanent"]=row is not null&&Text(row,"permanent")=="1"};
}
public Dictionary<string,object?> Activate(Account account,string device,string code)
{
ValidateDevice(device);code=(code??"").Trim().ToUpperInvariant();if(code.Length!=28||!code.StartsWith("JDJ-"))throw new ArgumentException("卡密格式不正确");
return Write(c=>
{
var card=Rows(c,"SELECT * FROM Cards WHERE codeHash=@p0",Hash(code)).FirstOrDefault()??throw new ArgumentException("卡密不存在");
if(Text(card,"active")!="1")throw new ArgumentException("卡密已停用");
if(Text(card,"usedBy").Length>0){if(Text(card,"usedBy")==account.Id && Text(card,"usedDevice")==device)return License(c,account.Id,device);throw new ArgumentException("卡密已被使用");}
if(DateTime.Parse(Text(card,"expiresAt")).ToUniversalTime()<=DateTime.UtcNow)throw new ArgumentException("卡密已过兑换有效期");
var old=Rows(c,"SELECT * FROM Licenses WHERE accountId=@p0 AND deviceId=@p1",account.Id,device).FirstOrDefault();
DateTime until=old is not null&&Text(old,"active")=="1"?DateTime.Parse(Text(old,"expiresAt")).ToUniversalTime():DateTime.UtcNow;if(until<DateTime.UtcNow)until=DateTime.UtcNow;
bool permanent=Text(card,"permanent")=="1"||old is not null&&Text(old,"active")=="1"&&Text(old,"permanent")=="1";
if(permanent)until=new DateTime(9999,12,31,0,0,0,DateTimeKind.Utc);
else if(DateTime.TryParse(Text(card,"fixedUntil"),out var fixedUntil)){fixedUntil=fixedUntil.ToUniversalTime();if(fixedUntil<=DateTime.UtcNow)throw new ArgumentException("此卡密的授权到期日已过");if(fixedUntil>until)until=fixedUntil;}
else until=until.AddDays(Convert.ToInt32(card["days"]));
Exec(c,"INSERT INTO Licenses(id,accountId,deviceId,expiresAt,active,updatedAt,permanent) VALUES(@p0,@p1,@p2,@p3,1,@p4,@p5) ON CONFLICT(accountId,deviceId) DO UPDATE SET expiresAt=excluded.expiresAt,active=1,updatedAt=excluded.updatedAt,permanent=excluded.permanent",Guid.NewGuid().ToString("N"),account.Id,device,until.ToString("O"),Now,permanent?1:0);
Exec(c,"UPDATE Cards SET usedBy=@p0,usedDevice=@p1,usedAt=@p2 WHERE id=@p3 AND usedBy IS NULL",account.Id,device,Now,Text(card,"id"));Audit(c,account.Id,"CARD_ACTIVATE",Text(card,"id"),device);return License(c,account.Id,device);
});
}
#endif
public static void ValidateDevice(string device){if(device is null||device.Length is <16 or >128 || !device.All(c=>char.IsAsciiLetterOrDigit(c)||c is '-' or '_'))throw new ArgumentException("设备标识无效");}
public void SetAccount(Account actor,string id,bool active)
{RequireAdmin(actor);if(id==actor.Id&&!active)throw new ArgumentException("不能停用当前管理员");Write(c=>{var account=Rows(c,"SELECT role FROM Accounts WHERE id=@p0",id).FirstOrDefault()??throw new ArgumentException("账号不存在");if(Text(account,"role")=="admin")throw new ArgumentException("管理员账户不可停用");Exec(c,"UPDATE Accounts SET active=@p0 WHERE id=@p1",active?1:0,id);if(!active)Exec(c,"DELETE FROM Sessions WHERE accountId=@p0",id);Audit(c,actor.Id,"ACCOUNT_STATUS",id,active.ToString());return 0;});}
#if !COMMUNITY_APP
public void SetCard(Account actor,string id,bool active){RequireAdmin(actor);Write(c=>{if(Exec(c,"UPDATE Cards SET active=@p0 WHERE id=@p1",active?1:0,id)==0)throw new ArgumentException("卡密不存在");Audit(c,actor.Id,"CARD_STATUS",id,active.ToString());return 0;});}
public void SetLicense(Account actor,string id,bool active){RequireAdmin(actor);Write(c=>{if(Exec(c,"UPDATE Licenses SET active=@p0,updatedAt=@p1 WHERE id=@p2",active?1:0,Now,id)==0)throw new ArgumentException("授权不存在");Audit(c,actor.Id,"LICENSE_STATUS",id,active.ToString());return 0;});}
#endif
public void ResetUser(Account actor,string id,string password)
{RequireAdmin(actor);ValidatePassword(password);Write(c=>{var row=Rows(c,"SELECT role FROM Accounts WHERE id=@p0",id).FirstOrDefault()??throw new ArgumentException("账户不存在");if(Text(row,"role")=="admin")throw new ArgumentException("管理员请使用修改密码功能");Exec(c,"UPDATE Accounts SET passwordHash=@p0 WHERE id=@p1",PasswordHash(password),id);Exec(c,"DELETE FROM Sessions WHERE accountId=@p0",id);Audit(c,actor.Id,"PASSWORD_RESET",id,"");return 0;});}
private void RequireAdmin(Account actor){if(!Read(c=>Rows(c,"SELECT id FROM Accounts WHERE id=@p0 AND role='admin' AND active=1",actor.Id).Count>0))throw new UnauthorizedAccessException("需要管理员权限");}
private static void Audit(SqliteConnection c,string actor,string action,string target,string detail)=>Exec(c,"INSERT INTO Audit VALUES(@p0,@p1,@p2,@p3,@p4,@p5)",Guid.NewGuid().ToString("N"),actor,action,target,detail,Now);
}