using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using JiDangJia.Contracts;
using Microsoft.Data.Sqlite;
using SkiaSharp;
using static JiDangJia.Server.ServerStore;
namespace JiDangJia.Server;
// 认证、举报和互换证照独立保存,不进入社群审核出站 DTO,也没有公开文件路径。
public sealed class IdentityVault
{
public const int MaxDocumentSize=3*1024*1024;
private readonly ServerStore accounts;
private readonly IIdentityVerificationProvider verification;
private readonly object sync=new();
private readonly string db,directory;
private readonly byte[] key;
private static readonly JsonSerializerOptions JsonOptions=new(JsonSerializerDefaults.Web);
public IdentityVault(ServerStore accounts,IIdentityVerificationProvider verification)
{
this.accounts=accounts;this.verification=verification;directory=Path.Combine(accounts.DataDirectory,"private-vault");Directory.CreateDirectory(directory);db=Path.Combine(accounts.DataDirectory,"identity.db");
var keyFile=Path.Combine(accounts.DataDirectory,"private-vault.key");
if(!File.Exists(keyFile))
{
if(File.Exists(db)||Directory.EnumerateFiles(directory).Any())throw new InvalidOperationException("私密资料加密密钥缺失;请恢复原 private-vault.key,不能生成替代密钥");
try{using var file=new FileStream(keyFile,FileMode.CreateNew,FileAccess.Write,FileShare.None);file.Write(RandomNumberGenerator.GetBytes(32));}catch(IOException)when(File.Exists(keyFile)){}
}
key=File.ReadAllBytes(keyFile);if(key.Length!=32)throw new InvalidOperationException("私密资料加密密钥无效;请恢复原密钥,不能生成替代密钥");
Change(c=>Exec(c,"""
CREATE TABLE IF NOT EXISTS Documents(id TEXT PRIMARY KEY,ownerId TEXT NOT NULL,purpose TEXT NOT NULL,mime TEXT NOT NULL,nonce TEXT NOT NULL,hash TEXT NOT NULL,size INTEGER NOT NULL,createdAt TEXT NOT NULL,exchangeId TEXT NOT NULL DEFAULT '',UNIQUE(ownerId,nonce));
CREATE TABLE IF NOT EXISTS Identities(accountId TEXT PRIMARY KEY,revision INTEGER NOT NULL,status TEXT NOT NULL,kind TEXT NOT NULL,data TEXT NOT NULL,reason TEXT NOT NULL DEFAULT '',submittedAt TEXT NOT NULL,reviewedAt TEXT,reviewerId TEXT);
CREATE TABLE IF NOT EXISTS IdentityProviderChecks(accountId TEXT NOT NULL,revision INTEGER NOT NULL,data TEXT NOT NULL,checkedAt TEXT NOT NULL,PRIMARY KEY(accountId,revision));
CREATE TABLE IF NOT EXISTS Reports(id TEXT PRIMARY KEY,reporterId TEXT NOT NULL,reportedId TEXT NOT NULL,data TEXT NOT NULL,clientId TEXT NOT NULL,fingerprint TEXT NOT NULL,status TEXT NOT NULL DEFAULT 'OPEN',result TEXT NOT NULL DEFAULT '',createdAt TEXT NOT NULL,UNIQUE(reporterId,clientId));
CREATE TABLE IF NOT EXISTS Exchanges(id TEXT PRIMARY KEY,requesterId TEXT NOT NULL,peerId TEXT NOT NULL,requesterRevision INTEGER NOT NULL,peerRevision INTEGER NOT NULL,clientId TEXT NOT NULL,status TEXT NOT NULL DEFAULT 'PENDING',createdAt TEXT NOT NULL,confirmedAt TEXT,requesterCard TEXT,peerCard TEXT,UNIQUE(requesterId,clientId));
"""));
}
private SqliteConnection Open(){var c=new SqliteConnection(new SqliteConnectionStringBuilder{DataSource=db,Pooling=false}.ToString());c.Open();return c;}
private T Read<T>(Func<SqliteConnection,T> f){lock(sync){using var c=Open();return f(c);}}
private T Change<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;}}}
private byte[] Encrypt(byte[] bytes,string context)
{var nonce=RandomNumberGenerator.GetBytes(12);var cipher=new byte[bytes.Length];var tag=new byte[16];using var aes=new AesGcm(key,16);aes.Encrypt(nonce,bytes,cipher,tag,Encoding.UTF8.GetBytes(context));return nonce.Concat(tag).Concat(cipher).ToArray();}
private byte[] Decrypt(byte[] bytes,string context)
{if(bytes.Length<28)throw new InvalidOperationException("私密资料损坏");var plain=new byte[bytes.Length-28];using var aes=new AesGcm(key,16);aes.Decrypt(bytes.AsSpan(0,12),bytes.AsSpan(28),bytes.AsSpan(12,16),plain,Encoding.UTF8.GetBytes(context));return plain;}
private string Seal<T>(T value,string context)=>Convert.ToBase64String(Encrypt(JsonSerializer.SerializeToUtf8Bytes(value,JsonOptions),context));
private T Unseal<T>(string value,string context)=>JsonSerializer.Deserialize<T>(Decrypt(Convert.FromBase64String(value),context),JsonOptions)??throw new InvalidOperationException("私密资料无效");
private void Active(Account account)=>accounts.Read(c=>SafetyAccount(c,account.Id));
private void Admin(Account account)=>accounts.Read(c=>{if(Text(SafetyAccount(c,account.Id),"role")!="admin")throw new CommunityException(403,"需要管理员权限");return 0;});
private static string Clean(string? text,int max,string label,bool required=false)
{text=(text??"").Trim();if(text.Length>max||required&&text.Length==0||text.Any(ch=>char.IsControl(ch)&&ch is not ('\n' or '\r' or '\t')))throw new ArgumentException(label+"未填写或格式无效");return text;}
private static string Id(string? id){if(id is null||!Regex.IsMatch(id,"^[a-f0-9]{32}$"))throw new ArgumentException("资料标识无效");return id;}
private static string DocumentContext(Dictionary<string,object?> row)=>"document/"+Text(row,"id")+"/"+Text(row,"ownerId")+"/"+Text(row,"purpose");
public async Task<PrivateDocument> Upload(Account actor,HttpRequest request,CancellationToken ct)
{
Active(actor);string purpose=request.Headers["X-Document-Purpose"].ToString(),nonce=Id(request.Headers["X-Upload-Id"].ToString());if(purpose is not ("IDENTITY" or "REPORT"))throw new ArgumentException("资料用途无效");
if(request.ContentLength is >MaxDocumentSize)throw new CommunityException(413,"每张资料照片最多 3 MB");using var buffer=new MemoryStream();var chunk=new byte[65536];int n;while((n=await request.Body.ReadAsync(chunk,ct))>0){if(buffer.Length+n>MaxDocumentSize)throw new CommunityException(413,"每张资料照片最多 3 MB");buffer.Write(chunk,0,n);}var bytes=buffer.ToArray();
string mime=MediaInspector.Inspect(bytes,"IMAGE").Mime;using var codec=SKCodec.Create(new SKMemoryStream(bytes));if(codec is null||codec.Info.Width<200||codec.Info.Height<150)throw new ArgumentException("资料照片过小或损坏,请上传清晰照片");
var hash=Convert.ToHexString(SHA256.HashData(bytes));return Change(c=>
{
var old=Rows(c,"SELECT * FROM Documents WHERE ownerId=@p0 AND nonce=@p1",actor.Id,nonce).FirstOrDefault();if(old is not null){if(Text(old,"hash")!=hash||Text(old,"purpose")!=purpose)throw new CommunityException(409,"重复上传请求内容不同");return new PrivateDocument(Text(old,"id"),Text(old,"mime"));}
long total=Convert.ToInt64(Rows(c,"SELECT COALESCE(SUM(size),0) total FROM Documents WHERE ownerId=@p0",actor.Id)[0]["total"]);if(total+bytes.Length>128L*1024*1024)throw new CommunityException(413,"私密资料容量已满,请联系管理员");
string id=Guid.NewGuid().ToString("N"),path=Path.Combine(directory,id+".bin");var context=$"document/{id}/{actor.Id}/{purpose}";
try{File.WriteAllBytes(path,Encrypt(bytes,context));Exec(c,"INSERT INTO Documents(id,ownerId,purpose,mime,nonce,hash,size,createdAt) VALUES(@p0,@p1,@p2,@p3,@p4,@p5,@p6,@p7)",id,actor.Id,purpose,mime,nonce,hash,bytes.Length,Now);return new PrivateDocument(id,mime);}catch{File.Delete(path);throw;}
});
}
private static void Owned(SqliteConnection c,Account actor,string? id,string purpose)
{Id(id);if(Rows(c,"SELECT id FROM Documents WHERE id=@p0 AND ownerId=@p1 AND purpose=@p2 AND exchangeId=''",id,actor.Id,purpose).Count==0)throw new CommunityException(403,"只能使用自己上传的对应资料照片");}
public (byte[] Bytes,string Mime) Download(Account actor,string id)
{
Active(actor);bool admin=actor.Role=="admin";if(admin)Admin(actor);return Read(c=>
{
Id(id);var r=Rows(c,"SELECT * FROM Documents WHERE id=@p0",id).FirstOrDefault()??throw new CommunityException(404,"资料不存在");
var exchange=Text(r,"exchangeId");if(exchange.Length>0)
{
accounts.Read(a=>{SocialRead(a,actor);return 0;});
var e=Rows(c,"SELECT * FROM Exchanges WHERE id=@p0 AND status='CONFIRMED'",exchange).FirstOrDefault()??throw new CommunityException(404,"互换资料不可用");
if(actor.Id!=Text(e,"requesterId")&&actor.Id!=Text(e,"peerId"))throw new CommunityException(403,"只能读取双方确认的互换资料");
if(!CanSeeExchange(actor,e))throw new CommunityException(404,"此互换记录已清除");
}
else if(Text(r,"ownerId")!=actor.Id&&!admin)throw new CommunityException(403,"无权读取他人的原始认证或举报材料");
if(admin&&exchange.Length==0)AuditPrivate(actor,"PRIVATE_DOCUMENT_REVIEW",id);
return(Decrypt(File.ReadAllBytes(Path.Combine(directory,id+".bin")),DocumentContext(r)),Text(r,"mime"));
});
}
private void AuditPrivate(Account actor,string action,string target)=>accounts.Write(c=>Exec(c,"INSERT INTO Audit VALUES(@p0,@p1,@p2,@p3,'',@p4)",Guid.NewGuid().ToString("N"),actor.Id,action,target,Now));
private static bool ValidIdNumber(string text)
{
if(!Regex.IsMatch(text,"^[1-9][0-9]{16}[0-9X]$")||!DateTime.TryParseExact(text.Substring(6,8),"yyyyMMdd",CultureInfo.InvariantCulture,DateTimeStyles.None,out var birth)||birth>DateTime.Today)return false;
int[] weights=[7,9,10,5,8,4,2,1,6,3,7,9,10,5,8,4,2];return "10X98765432"[text.Take(17).Select((ch,i)=>(ch-'0')*weights[i]).Sum()%11]==text[^1];
}
public IdentityStatus Submit(Account actor,IdentityForm input)
{
Active(actor);if(!input.Consent)throw new ArgumentException("请明确同意提交认证资料供平台审核");if(input.Kind is not ("PERSONAL" or "ENTERPRISE"))throw new ArgumentException("认证身份无效");
var name=Clean(input.Name,64,"姓名 / 法人姓名",true);var number=Clean(input.IdNumber,18,"身份证号",true).ToUpperInvariant();if(!ValidIdNumber(number))throw new ArgumentException("身份证号格式或校验位不正确;格式校验不代表实名认证通过");
var phone=Clean(input.Phone,11,"电话",true);if(!Regex.IsMatch(phone,"^1[3-9][0-9]{9}$"))throw new ArgumentException("请填写有效的 11 位手机号");var company=Clean(input.CompanyName,120,"企业名",input.Kind=="ENTERPRISE");
var bank=Clean(input.BankNumber,32,"银行卡号",input.Kind=="ENTERPRISE");var bankName=Clean(input.BankName,120,"开户行",input.Kind=="ENTERPRISE");if(bank.Length>0&&!Regex.IsMatch(bank,"^[0-9]{12,32}$")||bank.Length>0&&bankName.Length==0||bank.Length==0&&bankName.Length>0)throw new ArgumentException("银行卡号和开户行请完整填写");
if(!string.IsNullOrEmpty(input.BankHolder)&&input.BankHolder.Trim()!=name)throw new ArgumentException("银行卡户名必须与本人 / 法人姓名一致");if(input.IdPhotos is null||input.IdPhotos.Count is <1 or >2||input.IdPhotos.Distinct().Count()!=input.IdPhotos.Count)throw new ArgumentException("请上传身份证人像面照片,可另附国徽面");
var form=input with{Name=name,IdNumber=number,Phone=phone,Address=Clean(input.Address,300,"地址"),CompanyName=company,BankNumber=bank,BankName=bankName,BankHolder=name,IdPhotos=input.IdPhotos.ToList(),BusinessPhoto=input.Kind=="ENTERPRISE"?input.BusinessPhoto:null};
Change(c=>
{
foreach(var id in form.IdPhotos)Owned(c,actor,id,"IDENTITY");if(form.Kind=="ENTERPRISE")Owned(c,actor,form.BusinessPhoto,"IDENTITY");
var old=Rows(c,"SELECT * FROM Identities WHERE accountId=@p0",actor.Id).FirstOrDefault();long revision=old is null?1:Convert.ToInt64(old["revision"])+1;
// 先撤销公开认证状态,任何部分提交失败均不会留下可写权限。
accounts.Write(a=>Exec(a,"UPDATE Accounts SET identityStatus='PENDING',identityKind=@p0 WHERE id=@p1",form.Kind,actor.Id));
Exec(c,"INSERT INTO Identities(accountId,revision,status,kind,data,submittedAt) VALUES(@p0,@p1,'PENDING',@p2,@p3,@p4) ON CONFLICT(accountId) DO UPDATE SET revision=excluded.revision,status='PENDING',kind=excluded.kind,data=excluded.data,reason='',submittedAt=excluded.submittedAt,reviewedAt=NULL,reviewerId=NULL",actor.Id,revision,form.Kind,Seal(form,$"identity/{actor.Id}/{revision}"),Now);return 0;
});return Status(actor);
}
public IdentityStatus Status(Account actor){Active(actor);return Read(c=>Status(c,actor.Id));}
private IdentityStatus Status(SqliteConnection c,string id)
{
var r=Rows(c,"SELECT * FROM Identities WHERE accountId=@p0",id).FirstOrDefault();var phone=accounts.Read(a=>SafetyAccount(a,id));
var value=Text(phone,"phone");if(value.Length==11)value=value[..3]+"****"+value[^4..];return new IdentityStatus(r is null?"NONE":Text(r,"status"),r is null?"PERSONAL":Text(r,"kind"),r is null?"":Text(r,"reason"),r is null?0:Convert.ToInt64(r["revision"]),value,Text(phone,"phoneMethod"));
}
public IdentityApplication OwnApplication(Account actor){Active(actor);return Read(c=>Application(c,actor.Id,actor.Username));}
private IdentityApplication Application(SqliteConnection c,string id,string username)
{var r=Rows(c,"SELECT * FROM Identities WHERE accountId=@p0",id).FirstOrDefault();var state=Status(c,id);return new IdentityApplication(id,username,state,r is null?null:Unseal<IdentityForm>(Text(r,"data"),$"identity/{id}/{r["revision"]}"),r is null?"":Text(r,"submittedAt"));}
public List<IdentityApplication> Applications(Account admin){Admin(admin);return Read(c=>Rows(c,"SELECT accountId FROM Identities ORDER BY submittedAt DESC LIMIT 100").Select(r=>{var id=Text(r,"accountId");var username=accounts.Read(a=>Text(SafetyAccount(a,id),"username"));return new IdentityApplication(id,username,Status(c,id),null,"");}).ToList());}
public IdentityApplication ReviewApplication(Account admin,string id){Admin(admin);AuditPrivate(admin,"IDENTITY_REVIEW",id);return Read(c=>Application(c,id,accounts.Read(a=>Text(SafetyAccount(a,id),"username"))));}
public async Task<IdentityVerificationAssessment> ProviderCheck(Account admin,string id,long revision,CancellationToken ct)
{
var application=ReviewApplication(admin,id);if(application.State.Revision!=revision)throw new CommunityException(409,"资料已修改,请刷新后核验");var form=application.Form??throw new CommunityException(404,"未提交认证资料");
var photos=new List<IdentityVerificationPhoto>();foreach(var photo in form.IdPhotos){var file=Download(admin,photo);photos.Add(new("ID",file.Mime,file.Bytes));}if(form.BusinessPhoto is { } business){var file=Download(admin,business);photos.Add(new("BUSINESS_LICENSE",file.Mime,file.Bytes));}
IdentityVerificationAssessment result;
try{result=await verification.Check(new(form.Kind,form.Name,form.IdNumber,form.CompanyName,form.Phone,photos),ct);if(result.Status is not ("MANUAL_REVIEW" or "MATCH" or "MISMATCH" or "UNAVAILABLE"))throw new InvalidOperationException("核验结论无效");}
catch(OperationCanceledException)when(ct.IsCancellationRequested){throw;}
catch{result=new("UNAVAILABLE","","","自动核验未完成,仍需人工审核。");}
result=result with{Provider=Clean(result.Provider,80,"核验服务"),Reference=Clean(result.Reference,200,"核验编号"),Reason=Clean(result.Reason,500,"核验说明")};
Change(c=>{var current=Rows(c,"SELECT revision FROM Identities WHERE accountId=@p0",id).First();if(Convert.ToInt64(current["revision"])!=revision)throw new CommunityException(409,"资料已修改,核验结果不适用于新申请");Exec(c,"INSERT INTO IdentityProviderChecks VALUES(@p0,@p1,@p2,@p3) ON CONFLICT(accountId,revision) DO UPDATE SET data=excluded.data,checkedAt=excluded.checkedAt",id,revision,Seal(result,$"identity-check/{id}/{revision}"),Now);return 0;});AuditPrivate(admin,"IDENTITY_PROVIDER_CHECK",id);return result;
}
public IdentityStatus Decide(Account admin,string id,IdentityDecision decision)
{
Admin(admin);string reason=Clean(decision.Reason,500,"审核说明",!decision.Approved);if(decision.Approved&&(!decision.DocumentsChecked||!decision.PhoneChecked))throw new ArgumentException("请确认已人工核验证件与手机号");
return Change(c=>
{
var r=Rows(c,"SELECT * FROM Identities WHERE accountId=@p0",id).FirstOrDefault()??throw new CommunityException(404,"认证申请不存在");if(Convert.ToInt64(r["revision"])!=decision.Revision)throw new CommunityException(409,"资料已被修改,请重新查看");if(Text(r,"status")!="PENDING")throw new CommunityException(409,"此申请已处理");var form=Unseal<IdentityForm>(Text(r,"data"),$"identity/{id}/{r["revision"]}");
accounts.Write(a=>{SafetyAccount(a,id);if(decision.Approved&&Rows(a,"SELECT id FROM Accounts WHERE phone=@p0 AND id<>@p1",form.Phone,id).Count>0)throw new CommunityException(409,"此手机号已绑定其他账号");Exec(a,"UPDATE Accounts SET identityStatus=@p0,identityKind=@p1,phone=CASE WHEN @p2=1 THEN @p3 ELSE phone END,phoneMethod=CASE WHEN @p2=1 AND phone=@p3 AND phoneMethod='SMS' THEN 'SMS' WHEN @p2=1 THEN 'MANUAL' ELSE phoneMethod END WHERE id=@p4",decision.Approved?"APPROVED":"REJECTED",form.Kind,decision.Approved?1:0,form.Phone,id);return 0;});
Exec(c,"UPDATE Identities SET status=@p0,reason=@p1,reviewedAt=@p2,reviewerId=@p3 WHERE accountId=@p4",decision.Approved?"APPROVED":"REJECTED",reason,Now,admin.Id,id);AuditPrivate(admin,"IDENTITY_MANUAL_DECISION",id);return Status(c,id);
});
}
public CommunityReport Report(Account actor,ReportRequest input)
{
Active(actor);var peer=Id(input.PeerId);if(peer==actor.Id)throw new ArgumentException("不能举报自己");accounts.Read(c=>SafetyAccount(c,peer));string text=Clean(input.Description,2000,"举报文字说明",true);if(input.Photos is null||input.Photos.Count is <1 or >5||input.Photos.Distinct().Count()!=input.Photos.Count)throw new ArgumentException("举报须附 1–5 张图片");var nonce=Id(input.ClientId);var fingerprint=Hash(JsonSerializer.Serialize(input with{Description=text},JsonOptions));
if(input.PostId is { } post)accounts.Read(c=>{if(Rows(c,"SELECT id FROM CommunityPosts WHERE id=@p0 AND authorId=@p1",post,peer).Count==0)throw new ArgumentException("举报发布内容与用户不匹配");return 0;});
return Change(c=>
{
foreach(var id in input.Photos)Owned(c,actor,id,"REPORT");var old=Rows(c,"SELECT * FROM Reports WHERE reporterId=@p0 AND clientId=@p1",actor.Id,nonce).FirstOrDefault();if(old is not null){if(Text(old,"fingerprint")!=fingerprint)throw new CommunityException(409,"重复举报请求内容不同");return ReportFrom(old);}
if(Rows(c,"SELECT id FROM Reports WHERE reporterId=@p0 AND createdAt>@p1",actor.Id,DateTime.UtcNow.AddDays(-1).ToString("O")).Count>=10)throw new CommunityException(429,"每天最多提交 10 次举报");
var report=new CommunityReport(Guid.NewGuid().ToString("N"),actor.Id,peer,text,input.Photos.ToList(),input.PostId,"OPEN","",Now);Exec(c,"INSERT INTO Reports(id,reporterId,reportedId,data,clientId,fingerprint,createdAt) VALUES(@p0,@p1,@p2,@p3,@p4,@p5,@p6)",report.Id,actor.Id,peer,Seal(report,"report/"+report.Id),nonce,fingerprint,report.CreatedAt);return report;
});
}
private CommunityReport ReportFrom(Dictionary<string,object?> r)=>Unseal<CommunityReport>(Text(r,"data"),"report/"+Text(r,"id")) with{Status=Text(r,"status"),Result=Text(r,"result")};
public List<CommunityReport> Reports(Account actor,bool admin){if(admin)Admin(actor);else Active(actor);return Read(c=>Rows(c,"SELECT * FROM Reports"+(admin?"":" WHERE reporterId=@p0")+" ORDER BY createdAt DESC LIMIT 100",admin?[]:[actor.Id]).Select(ReportFrom).ToList());}
public CommunityReport DecideReport(Account admin,string id,ReportDecision decision)
{Admin(admin);if(decision.Status is not ("RESOLVED" or "REJECTED"))throw new ArgumentException("举报处理状态无效");var result=Clean(decision.Result,500,"处理说明",true);return Change(c=>{if(Exec(c,"UPDATE Reports SET status=@p0,result=@p1 WHERE id=@p2 AND status='OPEN'",decision.Status,result,Id(id))==0)throw new CommunityException(409,"举报不存在或已处理");AuditPrivate(admin,"REPORT_DECISION",id);return ReportFrom(Rows(c,"SELECT * FROM Reports WHERE id=@p0",id)[0]);});}
private Dictionary<string,object?> Certified(SqliteConnection c,string id)
{var r=Rows(c,"SELECT * FROM Identities WHERE accountId=@p0 AND status='APPROVED'",id).FirstOrDefault()??throw new CommunityException(403,"双方均须完成实名认证后才能互换证照");return r;}
public CertificateExchange RequestExchange(Account actor,ExchangeRequest request)
{
if(!request.Consent)throw new ArgumentException("请明确同意向对方互换证照及银行卡资料");string peer=Id(request.PeerId),nonce=Id(request.ClientId);if(peer==actor.Id)throw new ArgumentException("请选择其他账户");accounts.Read(c=>{SocialWrite(c,actor,peer);return 0;});
var row=Change(c=>
{
var old=Rows(c,"SELECT * FROM Exchanges WHERE requesterId=@p0 AND clientId=@p1",actor.Id,nonce).FirstOrDefault();if(old is not null){if(Text(old,"peerId")!=peer)throw new CommunityException(409,"重复请求的对象不同");return old;}
var mine=Certified(c,actor.Id);var theirs=Certified(c,peer);if(Rows(c,"SELECT id FROM Exchanges WHERE status='PENDING' AND ((requesterId=@p0 AND peerId=@p1) OR (requesterId=@p1 AND peerId=@p0))",actor.Id,peer).Count>0)throw new CommunityException(409,"已有待确认的互换申请");var id=Guid.NewGuid().ToString("N");
Exec(c,"INSERT INTO Exchanges(id,requesterId,peerId,requesterRevision,peerRevision,clientId,createdAt) VALUES(@p0,@p1,@p2,@p3,@p4,@p5,@p6)",id,actor.Id,peer,mine["revision"],theirs["revision"],nonce,Now);
return Rows(c,"SELECT * FROM Exchanges WHERE id=@p0",id)[0];
});EnsureMessages(row);return Read(c=>ExchangeFrom(actor,row));
}
public CertificateExchange Exchange(Account actor,string id)
{
Active(actor);accounts.Read(c=>{SocialRead(c,actor);return 0;});var row=Read(c=>Rows(c,"SELECT * FROM Exchanges WHERE id=@p0",Id(id)).FirstOrDefault()??throw new CommunityException(404,"互换申请不存在"));
if(actor.Id!=Text(row,"requesterId")&&actor.Id!=Text(row,"peerId"))throw new CommunityException(403,"只能读取自己参与的互换申请");EnsureMessages(row);return Read(c=>ExchangeFrom(actor,row));
}
public void CancelPending(Account actor){Active(actor);Change(c=>Exec(c,"UPDATE Exchanges SET status='CANCELLED' WHERE status='PENDING' AND (requesterId=@p0 OR peerId=@p0)",actor.Id));}
// Private commit precedes public delivery. Deterministic nonces recover interrupted delivery without duplicates.
private void EnsureMessages(Dictionary<string,object?> row)
{
string requester=Text(row,"requesterId"),peer=Text(row,"peerId"),id=Text(row,"id");accounts.Write(c=>
{
// A late recovery must not resurrect a private card after either party cleared history.
if(Rows(c,"SELECT accountId FROM AccountChatClears WHERE (accountId=@p0 OR accountId=@p1) AND clearedAt>=@p2",requester,peer,Text(row,"status")=="CONFIRMED"?Text(row,"confirmedAt"):Text(row,"createdAt")).Count>0)return 0;
Exec(c,"INSERT OR IGNORE INTO ChatMessages(senderId,recipientId,content,clientId,createdAt,kind,exchangeId) VALUES(@p0,@p1,'申请互换证照;确认前不会发送身份证照片或银行卡资料',@p2,@p3,'IDENTITY_REQUEST',@p2)",requester,peer,id,Text(row,"createdAt"));
if(Text(row,"status")=="CONFIRMED")foreach(var (from,to) in new[]{(requester,peer),(peer,requester)})Exec(c,"INSERT OR IGNORE INTO ChatMessages(senderId,recipientId,content,clientId,createdAt,kind,exchangeId) VALUES(@p0,@p1,'已确认互换证照,仅用于本次交易核验',@p2,@p3,'CERTIFICATE',@p4)",from,to,Hash(id+from+"certificate")[..32],Text(row,"confirmedAt"),id);return 0;
});
}
private bool CanSeeExchange(Account actor,Dictionary<string,object?> e)=>accounts.Read(c=>Rows(c,"SELECT id FROM ChatMessages WHERE exchangeId=@p0 AND (senderId=@p1 OR recipientId=@p1) AND id>COALESCE((SELECT throughId FROM AccountChatClears WHERE accountId=@p1),0)",Text(e,"id"),actor.Id).Count>0);
private CertificateExchange ExchangeFrom(Account actor,Dictionary<string,object?> r)
{
string requester=Text(r,"requesterId"),peer=Text(r,"peerId"),id=Text(r,"id");if(actor.Id!=requester&&actor.Id!=peer)throw new CommunityException(403,"只能读取自己参与的互换申请");if(!CanSeeExchange(actor,r))throw new CommunityException(404,"此互换记录已清除");
bool complete=Text(r,"status")=="CONFIRMED";CertificateCard? a=complete?Unseal<CertificateCard>(Text(r,"requesterCard"),$"exchange/{id}/{requester}"):null,b=complete?Unseal<CertificateCard>(Text(r,"peerCard"),$"exchange/{id}/{peer}"):null;
return new CertificateExchange(id,requester,peer,Text(r,"status"),Text(r,"createdAt"),Text(r,"confirmedAt"),actor.Id==requester?a:b,actor.Id==requester?b:a);
}
public CertificateExchange DecideExchange(Account actor,string id,ExchangeDecision decision)
{
Active(actor);if(decision.Action is not ("CONFIRM" or "REJECT" or "CANCEL"))throw new ArgumentException("互换操作无效");var files=new List<string>();bool committed=false;
try{var result=Change(c=>
{
var r=Rows(c,"SELECT * FROM Exchanges WHERE id=@p0",Id(id)).FirstOrDefault()??throw new CommunityException(404,"申请不存在");string requester=Text(r,"requesterId"),peer=Text(r,"peerId");if(actor.Id!=requester&&actor.Id!=peer)throw new CommunityException(403,"只能操作双方互换申请");
if(Text(r,"status")!="PENDING")return r;if(decision.Action=="CANCEL"&&actor.Id!=requester||decision.Action!="CANCEL"&&actor.Id!=peer)throw new CommunityException(403,"只有对方能确认或拒绝申请,申请人可以取消");if(!CanSeeExchange(actor,r))throw new CommunityException(404,"此互换记录已清除");
if(decision.Action!="CONFIRM"){Exec(c,"UPDATE Exchanges SET status=@p0 WHERE id=@p1",decision.Action=="CANCEL"?"CANCELLED":"REJECTED",id);return Rows(c,"SELECT * FROM Exchanges WHERE id=@p0",id)[0];}
if(!CanSeeExchange(accounts.Read(a=>AccountFrom(SafetyAccount(a,requester))),r)||!CanSeeExchange(accounts.Read(a=>AccountFrom(SafetyAccount(a,peer))),r))throw new CommunityException(409,"一方已清除聊天,请取消后重新申请");if(!decision.Consent)throw new ArgumentException("确认互换须明确同意分享资料");accounts.Read(a=>{SocialWrite(a,actor,requester);return 0;});var first=Certified(c,requester);var second=Certified(c,peer);if(Convert.ToInt64(first["revision"])!=Convert.ToInt64(r["requesterRevision"])||Convert.ToInt64(second["revision"])!=Convert.ToInt64(r["peerRevision"]))throw new CommunityException(409,"认证资料已更新,请取消后重新申请互换");
var stamp=DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromHours(8)).ToString("yyyy-MM-dd HH:mm:ss zzz")+" 仅本次交易核验 其他无效";
CertificateCard Card(Dictionary<string,object?> identity,string owner)
{
var form=Unseal<IdentityForm>(Text(identity,"data"),$"identity/{owner}/{identity["revision"]}");string Copy(string doc)
{
var original=Rows(c,"SELECT * FROM Documents WHERE id=@p0 AND ownerId=@p1 AND purpose='IDENTITY'",doc,owner).First();var bytes=Decrypt(File.ReadAllBytes(Path.Combine(directory,doc+".bin")),DocumentContext(original));var marked=Watermark(bytes,stamp);var newId=Guid.NewGuid().ToString("N");string file=Path.Combine(directory,newId+".bin");files.Add(file);File.WriteAllBytes(file,Encrypt(marked,$"document/{newId}/{owner}/EXCHANGE"));
Exec(c,"INSERT INTO Documents(id,ownerId,purpose,mime,nonce,hash,size,createdAt,exchangeId) VALUES(@p0,@p1,'EXCHANGE','image/jpeg',@p0,@p2,@p3,@p4,@p5)",newId,owner,Convert.ToHexString(SHA256.HashData(marked)),marked.Length,Now,id);return newId;
}
return new CertificateCard(form.Name,form.Phone,form.Address,form.CompanyName,form.BankNumber,form.BankName,form.IdPhotos.Select(Copy).ToList(),form.BusinessPhoto is null?null:Copy(form.BusinessPhoto),stamp);
}
CertificateCard aCard=Card(first,requester),bCard=Card(second,peer);Exec(c,"UPDATE Exchanges SET status='CONFIRMED',confirmedAt=@p0,requesterCard=@p1,peerCard=@p2 WHERE id=@p3",Now,Seal(aCard,$"exchange/{id}/{requester}"),Seal(bCard,$"exchange/{id}/{peer}"),id);
AuditPrivate(actor,"CERTIFICATE_EXCHANGE",id);return Rows(c,"SELECT * FROM Exchanges WHERE id=@p0",id)[0];
});committed=true;EnsureMessages(result);return Read(c=>ExchangeFrom(actor,result));}catch{if(!committed)foreach(var file in files)File.Delete(file);throw;}
}
private static byte[] Watermark(byte[] source,string text)
{
using var bitmap=SKBitmap.Decode(source)??throw new ArgumentException("证件照片无法解码");using var image=SKSurface.Create(new SKImageInfo(bitmap.Width,bitmap.Height));var canvas=image.Canvas;canvas.DrawBitmap(bitmap,0,0);
string? fontFile=Environment.GetEnvironmentVariable("JDJ_WATERMARK_FONT");if(string.IsNullOrWhiteSpace(fontFile)&&OperatingSystem.IsWindows())fontFile=Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts),"msyh.ttc");
using var typeface=fontFile is not null&&File.Exists(fontFile)?SKTypeface.FromFile(fontFile):SKTypeface.FromFamilyName("Noto Sans CJK SC");if(typeface is null||!typeface.ContainsGlyphs("仅本次交易核验其他无效"))throw new InvalidOperationException("服务器缺少中文水印字体,请配置 JDJ_WATERMARK_FONT");
float size=Math.Clamp(bitmap.Width/35f,12,44);using var font=new SKFont(typeface,size);using var paint=new SKPaint{Color=new SKColor(135,45,45,135),IsAntialias=true};
for(float y=size*2;y<bitmap.Height;y+=size*5){canvas.DrawText(text,8,y,font,paint);}using var bottom=new SKPaint{Color=new SKColor(255,255,255,210)};canvas.DrawRect(0,bitmap.Height-size*2.5f,bitmap.Width,size*2.5f,bottom);paint.Color=new SKColor(110,25,25,230);
using var small=new SKFont(typeface,Math.Min(size,(bitmap.Width-16)/Math.Max(1,font.MeasureText(text))*size));canvas.DrawText(text,8,bitmap.Height-size*.7f,small,paint);using var snapshot=image.Snapshot();using var encoded=snapshot.Encode(SKEncodedImageFormat.Jpeg,88);return encoded.ToArray();
}
}
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using JiDangJia.Contracts;
using Microsoft.Data.Sqlite;
using SkiaSharp;
using static JiDangJia.Server.ServerStore;
namespace JiDangJia.Server;
// 认证、举报和互换证照独立保存,不进入社群审核出站 DTO,也没有公开文件路径。
public sealed class IdentityVault
{
public const int MaxDocumentSize=3*1024*1024;
private readonly ServerStore accounts;
private readonly IIdentityVerificationProvider verification;
private readonly object sync=new();
private readonly string db,directory;
private readonly byte[] key;
private static readonly JsonSerializerOptions JsonOptions=new(JsonSerializerDefaults.Web);
public IdentityVault(ServerStore accounts,IIdentityVerificationProvider verification)
{
this.accounts=accounts;this.verification=verification;directory=Path.Combine(accounts.DataDirectory,"private-vault");Directory.CreateDirectory(directory);db=Path.Combine(accounts.DataDirectory,"identity.db");
var keyFile=Path.Combine(accounts.DataDirectory,"private-vault.key");
if(!File.Exists(keyFile))
{
if(File.Exists(db)||Directory.EnumerateFiles(directory).Any())throw new InvalidOperationException("私密资料加密密钥缺失;请恢复原 private-vault.key,不能生成替代密钥");
try{using var file=new FileStream(keyFile,FileMode.CreateNew,FileAccess.Write,FileShare.None);file.Write(RandomNumberGenerator.GetBytes(32));}catch(IOException)when(File.Exists(keyFile)){}
}
key=File.ReadAllBytes(keyFile);if(key.Length!=32)throw new InvalidOperationException("私密资料加密密钥无效;请恢复原密钥,不能生成替代密钥");
Change(c=>Exec(c,"""
CREATE TABLE IF NOT EXISTS Documents(id TEXT PRIMARY KEY,ownerId TEXT NOT NULL,purpose TEXT NOT NULL,mime TEXT NOT NULL,nonce TEXT NOT NULL,hash TEXT NOT NULL,size INTEGER NOT NULL,createdAt TEXT NOT NULL,exchangeId TEXT NOT NULL DEFAULT '',UNIQUE(ownerId,nonce));
CREATE TABLE IF NOT EXISTS Identities(accountId TEXT PRIMARY KEY,revision INTEGER NOT NULL,status TEXT NOT NULL,kind TEXT NOT NULL,data TEXT NOT NULL,reason TEXT NOT NULL DEFAULT '',submittedAt TEXT NOT NULL,reviewedAt TEXT,reviewerId TEXT);
CREATE TABLE IF NOT EXISTS IdentityProviderChecks(accountId TEXT NOT NULL,revision INTEGER NOT NULL,data TEXT NOT NULL,checkedAt TEXT NOT NULL,PRIMARY KEY(accountId,revision));
CREATE TABLE IF NOT EXISTS Reports(id TEXT PRIMARY KEY,reporterId TEXT NOT NULL,reportedId TEXT NOT NULL,data TEXT NOT NULL,clientId TEXT NOT NULL,fingerprint TEXT NOT NULL,status TEXT NOT NULL DEFAULT 'OPEN',result TEXT NOT NULL DEFAULT '',createdAt TEXT NOT NULL,UNIQUE(reporterId,clientId));
CREATE TABLE IF NOT EXISTS Exchanges(id TEXT PRIMARY KEY,requesterId TEXT NOT NULL,peerId TEXT NOT NULL,requesterRevision INTEGER NOT NULL,peerRevision INTEGER NOT NULL,clientId TEXT NOT NULL,status TEXT NOT NULL DEFAULT 'PENDING',createdAt TEXT NOT NULL,confirmedAt TEXT,requesterCard TEXT,peerCard TEXT,UNIQUE(requesterId,clientId));
"""));
}
private SqliteConnection Open(){var c=new SqliteConnection(new SqliteConnectionStringBuilder{DataSource=db,Pooling=false}.ToString());c.Open();return c;}
private T Read<T>(Func<SqliteConnection,T> f){lock(sync){using var c=Open();return f(c);}}
private T Change<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;}}}
private byte[] Encrypt(byte[] bytes,string context)
{var nonce=RandomNumberGenerator.GetBytes(12);var cipher=new byte[bytes.Length];var tag=new byte[16];using var aes=new AesGcm(key,16);aes.Encrypt(nonce,bytes,cipher,tag,Encoding.UTF8.GetBytes(context));return nonce.Concat(tag).Concat(cipher).ToArray();}
private byte[] Decrypt(byte[] bytes,string context)
{if(bytes.Length<28)throw new InvalidOperationException("私密资料损坏");var plain=new byte[bytes.Length-28];using var aes=new AesGcm(key,16);aes.Decrypt(bytes.AsSpan(0,12),bytes.AsSpan(28),bytes.AsSpan(12,16),plain,Encoding.UTF8.GetBytes(context));return plain;}
private string Seal<T>(T value,string context)=>Convert.ToBase64String(Encrypt(JsonSerializer.SerializeToUtf8Bytes(value,JsonOptions),context));
private T Unseal<T>(string value,string context)=>JsonSerializer.Deserialize<T>(Decrypt(Convert.FromBase64String(value),context),JsonOptions)??throw new InvalidOperationException("私密资料无效");
private void Active(Account account)=>accounts.Read(c=>SafetyAccount(c,account.Id));
private void Admin(Account account)=>accounts.Read(c=>{if(Text(SafetyAccount(c,account.Id),"role")!="admin")throw new CommunityException(403,"需要管理员权限");return 0;});
private static string Clean(string? text,int max,string label,bool required=false)
{text=(text??"").Trim();if(text.Length>max||required&&text.Length==0||text.Any(ch=>char.IsControl(ch)&&ch is not ('\n' or '\r' or '\t')))throw new ArgumentException(label+"未填写或格式无效");return text;}
private static string Id(string? id){if(id is null||!Regex.IsMatch(id,"^[a-f0-9]{32}$"))throw new ArgumentException("资料标识无效");return id;}
private static string DocumentContext(Dictionary<string,object?> row)=>"document/"+Text(row,"id")+"/"+Text(row,"ownerId")+"/"+Text(row,"purpose");
public async Task<PrivateDocument> Upload(Account actor,HttpRequest request,CancellationToken ct)
{
Active(actor);string purpose=request.Headers["X-Document-Purpose"].ToString(),nonce=Id(request.Headers["X-Upload-Id"].ToString());if(purpose is not ("IDENTITY" or "REPORT"))throw new ArgumentException("资料用途无效");
if(request.ContentLength is >MaxDocumentSize)throw new CommunityException(413,"每张资料照片最多 3 MB");using var buffer=new MemoryStream();var chunk=new byte[65536];int n;while((n=await request.Body.ReadAsync(chunk,ct))>0){if(buffer.Length+n>MaxDocumentSize)throw new CommunityException(413,"每张资料照片最多 3 MB");buffer.Write(chunk,0,n);}var bytes=buffer.ToArray();
string mime=MediaInspector.Inspect(bytes,"IMAGE").Mime;using var codec=SKCodec.Create(new SKMemoryStream(bytes));if(codec is null||codec.Info.Width<200||codec.Info.Height<150)throw new ArgumentException("资料照片过小或损坏,请上传清晰照片");
var hash=Convert.ToHexString(SHA256.HashData(bytes));return Change(c=>
{
var old=Rows(c,"SELECT * FROM Documents WHERE ownerId=@p0 AND nonce=@p1",actor.Id,nonce).FirstOrDefault();if(old is not null){if(Text(old,"hash")!=hash||Text(old,"purpose")!=purpose)throw new CommunityException(409,"重复上传请求内容不同");return new PrivateDocument(Text(old,"id"),Text(old,"mime"));}
long total=Convert.ToInt64(Rows(c,"SELECT COALESCE(SUM(size),0) total FROM Documents WHERE ownerId=@p0",actor.Id)[0]["total"]);if(total+bytes.Length>128L*1024*1024)throw new CommunityException(413,"私密资料容量已满,请联系管理员");
string id=Guid.NewGuid().ToString("N"),path=Path.Combine(directory,id+".bin");var context=$"document/{id}/{actor.Id}/{purpose}";
try{File.WriteAllBytes(path,Encrypt(bytes,context));Exec(c,"INSERT INTO Documents(id,ownerId,purpose,mime,nonce,hash,size,createdAt) VALUES(@p0,@p1,@p2,@p3,@p4,@p5,@p6,@p7)",id,actor.Id,purpose,mime,nonce,hash,bytes.Length,Now);return new PrivateDocument(id,mime);}catch{File.Delete(path);throw;}
});
}
private static void Owned(SqliteConnection c,Account actor,string? id,string purpose)
{Id(id);if(Rows(c,"SELECT id FROM Documents WHERE id=@p0 AND ownerId=@p1 AND purpose=@p2 AND exchangeId=''",id,actor.Id,purpose).Count==0)throw new CommunityException(403,"只能使用自己上传的对应资料照片");}
public (byte[] Bytes,string Mime) Download(Account actor,string id)
{
Active(actor);bool admin=actor.Role=="admin";if(admin)Admin(actor);return Read(c=>
{
Id(id);var r=Rows(c,"SELECT * FROM Documents WHERE id=@p0",id).FirstOrDefault()??throw new CommunityException(404,"资料不存在");
var exchange=Text(r,"exchangeId");if(exchange.Length>0)
{
accounts.Read(a=>{SocialRead(a,actor);return 0;});
var e=Rows(c,"SELECT * FROM Exchanges WHERE id=@p0 AND status='CONFIRMED'",exchange).FirstOrDefault()??throw new CommunityException(404,"互换资料不可用");
if(actor.Id!=Text(e,"requesterId")&&actor.Id!=Text(e,"peerId"))throw new CommunityException(403,"只能读取双方确认的互换资料");
if(!CanSeeExchange(actor,e))throw new CommunityException(404,"此互换记录已清除");
}
else if(Text(r,"ownerId")!=actor.Id&&!admin)throw new CommunityException(403,"无权读取他人的原始认证或举报材料");
if(admin&&exchange.Length==0)AuditPrivate(actor,"PRIVATE_DOCUMENT_REVIEW",id);
return(Decrypt(File.ReadAllBytes(Path.Combine(directory,id+".bin")),DocumentContext(r)),Text(r,"mime"));
});
}
private void AuditPrivate(Account actor,string action,string target)=>accounts.Write(c=>Exec(c,"INSERT INTO Audit VALUES(@p0,@p1,@p2,@p3,'',@p4)",Guid.NewGuid().ToString("N"),actor.Id,action,target,Now));
private static bool ValidIdNumber(string text)
{
if(!Regex.IsMatch(text,"^[1-9][0-9]{16}[0-9X]$")||!DateTime.TryParseExact(text.Substring(6,8),"yyyyMMdd",CultureInfo.InvariantCulture,DateTimeStyles.None,out var birth)||birth>DateTime.Today)return false;
int[] weights=[7,9,10,5,8,4,2,1,6,3,7,9,10,5,8,4,2];return "10X98765432"[text.Take(17).Select((ch,i)=>(ch-'0')*weights[i]).Sum()%11]==text[^1];
}
public IdentityStatus Submit(Account actor,IdentityForm input)
{
Active(actor);if(!input.Consent)throw new ArgumentException("请明确同意提交认证资料供平台审核");if(input.Kind is not ("PERSONAL" or "ENTERPRISE"))throw new ArgumentException("认证身份无效");
var name=Clean(input.Name,64,"姓名 / 法人姓名",true);var number=Clean(input.IdNumber,18,"身份证号",true).ToUpperInvariant();if(!ValidIdNumber(number))throw new ArgumentException("身份证号格式或校验位不正确;格式校验不代表实名认证通过");
var phone=Clean(input.Phone,11,"电话",true);if(!Regex.IsMatch(phone,"^1[3-9][0-9]{9}$"))throw new ArgumentException("请填写有效的 11 位手机号");var company=Clean(input.CompanyName,120,"企业名",input.Kind=="ENTERPRISE");
var bank=Clean(input.BankNumber,32,"银行卡号",input.Kind=="ENTERPRISE");var bankName=Clean(input.BankName,120,"开户行",input.Kind=="ENTERPRISE");if(bank.Length>0&&!Regex.IsMatch(bank,"^[0-9]{12,32}$")||bank.Length>0&&bankName.Length==0||bank.Length==0&&bankName.Length>0)throw new ArgumentException("银行卡号和开户行请完整填写");
if(!string.IsNullOrEmpty(input.BankHolder)&&input.BankHolder.Trim()!=name)throw new ArgumentException("银行卡户名必须与本人 / 法人姓名一致");if(input.IdPhotos is null||input.IdPhotos.Count is <1 or >2||input.IdPhotos.Distinct().Count()!=input.IdPhotos.Count)throw new ArgumentException("请上传身份证人像面照片,可另附国徽面");
var form=input with{Name=name,IdNumber=number,Phone=phone,Address=Clean(input.Address,300,"地址"),CompanyName=company,BankNumber=bank,BankName=bankName,BankHolder=name,IdPhotos=input.IdPhotos.ToList(),BusinessPhoto=input.Kind=="ENTERPRISE"?input.BusinessPhoto:null};
Change(c=>
{
foreach(var id in form.IdPhotos)Owned(c,actor,id,"IDENTITY");if(form.Kind=="ENTERPRISE")Owned(c,actor,form.BusinessPhoto,"IDENTITY");
var old=Rows(c,"SELECT * FROM Identities WHERE accountId=@p0",actor.Id).FirstOrDefault();long revision=old is null?1:Convert.ToInt64(old["revision"])+1;
// 先撤销公开认证状态,任何部分提交失败均不会留下可写权限。
accounts.Write(a=>Exec(a,"UPDATE Accounts SET identityStatus='PENDING',identityKind=@p0 WHERE id=@p1",form.Kind,actor.Id));
Exec(c,"INSERT INTO Identities(accountId,revision,status,kind,data,submittedAt) VALUES(@p0,@p1,'PENDING',@p2,@p3,@p4) ON CONFLICT(accountId) DO UPDATE SET revision=excluded.revision,status='PENDING',kind=excluded.kind,data=excluded.data,reason='',submittedAt=excluded.submittedAt,reviewedAt=NULL,reviewerId=NULL",actor.Id,revision,form.Kind,Seal(form,$"identity/{actor.Id}/{revision}"),Now);return 0;
});return Status(actor);
}
public IdentityStatus Status(Account actor){Active(actor);return Read(c=>Status(c,actor.Id));}
private IdentityStatus Status(SqliteConnection c,string id)
{
var r=Rows(c,"SELECT * FROM Identities WHERE accountId=@p0",id).FirstOrDefault();var phone=accounts.Read(a=>SafetyAccount(a,id));
var value=Text(phone,"phone");if(value.Length==11)value=value[..3]+"****"+value[^4..];return new IdentityStatus(r is null?"NONE":Text(r,"status"),r is null?"PERSONAL":Text(r,"kind"),r is null?"":Text(r,"reason"),r is null?0:Convert.ToInt64(r["revision"]),value,Text(phone,"phoneMethod"));
}
public IdentityApplication OwnApplication(Account actor){Active(actor);return Read(c=>Application(c,actor.Id,actor.Username));}
private IdentityApplication Application(SqliteConnection c,string id,string username)
{var r=Rows(c,"SELECT * FROM Identities WHERE accountId=@p0",id).FirstOrDefault();var state=Status(c,id);return new IdentityApplication(id,username,state,r is null?null:Unseal<IdentityForm>(Text(r,"data"),$"identity/{id}/{r["revision"]}"),r is null?"":Text(r,"submittedAt"));}
public List<IdentityApplication> Applications(Account admin){Admin(admin);return Read(c=>Rows(c,"SELECT accountId FROM Identities ORDER BY submittedAt DESC LIMIT 100").Select(r=>{var id=Text(r,"accountId");var username=accounts.Read(a=>Text(SafetyAccount(a,id),"username"));return new IdentityApplication(id,username,Status(c,id),null,"");}).ToList());}
public IdentityApplication ReviewApplication(Account admin,string id){Admin(admin);AuditPrivate(admin,"IDENTITY_REVIEW",id);return Read(c=>Application(c,id,accounts.Read(a=>Text(SafetyAccount(a,id),"username"))));}
public async Task<IdentityVerificationAssessment> ProviderCheck(Account admin,string id,long revision,CancellationToken ct)
{
var application=ReviewApplication(admin,id);if(application.State.Revision!=revision)throw new CommunityException(409,"资料已修改,请刷新后核验");var form=application.Form??throw new CommunityException(404,"未提交认证资料");
var photos=new List<IdentityVerificationPhoto>();foreach(var photo in form.IdPhotos){var file=Download(admin,photo);photos.Add(new("ID",file.Mime,file.Bytes));}if(form.BusinessPhoto is { } business){var file=Download(admin,business);photos.Add(new("BUSINESS_LICENSE",file.Mime,file.Bytes));}
IdentityVerificationAssessment result;
try{result=await verification.Check(new(form.Kind,form.Name,form.IdNumber,form.CompanyName,form.Phone,photos),ct);if(result.Status is not ("MANUAL_REVIEW" or "MATCH" or "MISMATCH" or "UNAVAILABLE"))throw new InvalidOperationException("核验结论无效");}
catch(OperationCanceledException)when(ct.IsCancellationRequested){throw;}
catch{result=new("UNAVAILABLE","","","自动核验未完成,仍需人工审核。");}
result=result with{Provider=Clean(result.Provider,80,"核验服务"),Reference=Clean(result.Reference,200,"核验编号"),Reason=Clean(result.Reason,500,"核验说明")};
Change(c=>{var current=Rows(c,"SELECT revision FROM Identities WHERE accountId=@p0",id).First();if(Convert.ToInt64(current["revision"])!=revision)throw new CommunityException(409,"资料已修改,核验结果不适用于新申请");Exec(c,"INSERT INTO IdentityProviderChecks VALUES(@p0,@p1,@p2,@p3) ON CONFLICT(accountId,revision) DO UPDATE SET data=excluded.data,checkedAt=excluded.checkedAt",id,revision,Seal(result,$"identity-check/{id}/{revision}"),Now);return 0;});AuditPrivate(admin,"IDENTITY_PROVIDER_CHECK",id);return result;
}
public IdentityStatus Decide(Account admin,string id,IdentityDecision decision)
{
Admin(admin);string reason=Clean(decision.Reason,500,"审核说明",!decision.Approved);if(decision.Approved&&(!decision.DocumentsChecked||!decision.PhoneChecked))throw new ArgumentException("请确认已人工核验证件与手机号");
return Change(c=>
{
var r=Rows(c,"SELECT * FROM Identities WHERE accountId=@p0",id).FirstOrDefault()??throw new CommunityException(404,"认证申请不存在");if(Convert.ToInt64(r["revision"])!=decision.Revision)throw new CommunityException(409,"资料已被修改,请重新查看");if(Text(r,"status")!="PENDING")throw new CommunityException(409,"此申请已处理");var form=Unseal<IdentityForm>(Text(r,"data"),$"identity/{id}/{r["revision"]}");
accounts.Write(a=>{SafetyAccount(a,id);if(decision.Approved&&Rows(a,"SELECT id FROM Accounts WHERE phone=@p0 AND id<>@p1",form.Phone,id).Count>0)throw new CommunityException(409,"此手机号已绑定其他账号");Exec(a,"UPDATE Accounts SET identityStatus=@p0,identityKind=@p1,phone=CASE WHEN @p2=1 THEN @p3 ELSE phone END,phoneMethod=CASE WHEN @p2=1 AND phone=@p3 AND phoneMethod='SMS' THEN 'SMS' WHEN @p2=1 THEN 'MANUAL' ELSE phoneMethod END WHERE id=@p4",decision.Approved?"APPROVED":"REJECTED",form.Kind,decision.Approved?1:0,form.Phone,id);return 0;});
Exec(c,"UPDATE Identities SET status=@p0,reason=@p1,reviewedAt=@p2,reviewerId=@p3 WHERE accountId=@p4",decision.Approved?"APPROVED":"REJECTED",reason,Now,admin.Id,id);AuditPrivate(admin,"IDENTITY_MANUAL_DECISION",id);return Status(c,id);
});
}
public CommunityReport Report(Account actor,ReportRequest input)
{
Active(actor);var peer=Id(input.PeerId);if(peer==actor.Id)throw new ArgumentException("不能举报自己");accounts.Read(c=>SafetyAccount(c,peer));string text=Clean(input.Description,2000,"举报文字说明",true);if(input.Photos is null||input.Photos.Count is <1 or >5||input.Photos.Distinct().Count()!=input.Photos.Count)throw new ArgumentException("举报须附 1–5 张图片");var nonce=Id(input.ClientId);var fingerprint=Hash(JsonSerializer.Serialize(input with{Description=text},JsonOptions));
if(input.PostId is { } post)accounts.Read(c=>{if(Rows(c,"SELECT id FROM CommunityPosts WHERE id=@p0 AND authorId=@p1",post,peer).Count==0)throw new ArgumentException("举报发布内容与用户不匹配");return 0;});
return Change(c=>
{
foreach(var id in input.Photos)Owned(c,actor,id,"REPORT");var old=Rows(c,"SELECT * FROM Reports WHERE reporterId=@p0 AND clientId=@p1",actor.Id,nonce).FirstOrDefault();if(old is not null){if(Text(old,"fingerprint")!=fingerprint)throw new CommunityException(409,"重复举报请求内容不同");return ReportFrom(old);}
if(Rows(c,"SELECT id FROM Reports WHERE reporterId=@p0 AND createdAt>@p1",actor.Id,DateTime.UtcNow.AddDays(-1).ToString("O")).Count>=10)throw new CommunityException(429,"每天最多提交 10 次举报");
var report=new CommunityReport(Guid.NewGuid().ToString("N"),actor.Id,peer,text,input.Photos.ToList(),input.PostId,"OPEN","",Now);Exec(c,"INSERT INTO Reports(id,reporterId,reportedId,data,clientId,fingerprint,createdAt) VALUES(@p0,@p1,@p2,@p3,@p4,@p5,@p6)",report.Id,actor.Id,peer,Seal(report,"report/"+report.Id),nonce,fingerprint,report.CreatedAt);return report;
});
}
private CommunityReport ReportFrom(Dictionary<string,object?> r)=>Unseal<CommunityReport>(Text(r,"data"),"report/"+Text(r,"id")) with{Status=Text(r,"status"),Result=Text(r,"result")};
public List<CommunityReport> Reports(Account actor,bool admin){if(admin)Admin(actor);else Active(actor);return Read(c=>Rows(c,"SELECT * FROM Reports"+(admin?"":" WHERE reporterId=@p0")+" ORDER BY createdAt DESC LIMIT 100",admin?[]:[actor.Id]).Select(ReportFrom).ToList());}
public CommunityReport DecideReport(Account admin,string id,ReportDecision decision)
{Admin(admin);if(decision.Status is not ("RESOLVED" or "REJECTED"))throw new ArgumentException("举报处理状态无效");var result=Clean(decision.Result,500,"处理说明",true);return Change(c=>{if(Exec(c,"UPDATE Reports SET status=@p0,result=@p1 WHERE id=@p2 AND status='OPEN'",decision.Status,result,Id(id))==0)throw new CommunityException(409,"举报不存在或已处理");AuditPrivate(admin,"REPORT_DECISION",id);return ReportFrom(Rows(c,"SELECT * FROM Reports WHERE id=@p0",id)[0]);});}
private Dictionary<string,object?> Certified(SqliteConnection c,string id)
{var r=Rows(c,"SELECT * FROM Identities WHERE accountId=@p0 AND status='APPROVED'",id).FirstOrDefault()??throw new CommunityException(403,"双方均须完成实名认证后才能互换证照");return r;}
public CertificateExchange RequestExchange(Account actor,ExchangeRequest request)
{
if(!request.Consent)throw new ArgumentException("请明确同意向对方互换证照及银行卡资料");string peer=Id(request.PeerId),nonce=Id(request.ClientId);if(peer==actor.Id)throw new ArgumentException("请选择其他账户");accounts.Read(c=>{SocialWrite(c,actor,peer);return 0;});
var row=Change(c=>
{
var old=Rows(c,"SELECT * FROM Exchanges WHERE requesterId=@p0 AND clientId=@p1",actor.Id,nonce).FirstOrDefault();if(old is not null){if(Text(old,"peerId")!=peer)throw new CommunityException(409,"重复请求的对象不同");return old;}
var mine=Certified(c,actor.Id);var theirs=Certified(c,peer);if(Rows(c,"SELECT id FROM Exchanges WHERE status='PENDING' AND ((requesterId=@p0 AND peerId=@p1) OR (requesterId=@p1 AND peerId=@p0))",actor.Id,peer).Count>0)throw new CommunityException(409,"已有待确认的互换申请");var id=Guid.NewGuid().ToString("N");
Exec(c,"INSERT INTO Exchanges(id,requesterId,peerId,requesterRevision,peerRevision,clientId,createdAt) VALUES(@p0,@p1,@p2,@p3,@p4,@p5,@p6)",id,actor.Id,peer,mine["revision"],theirs["revision"],nonce,Now);
return Rows(c,"SELECT * FROM Exchanges WHERE id=@p0",id)[0];
});EnsureMessages(row);return Read(c=>ExchangeFrom(actor,row));
}
public CertificateExchange Exchange(Account actor,string id)
{
Active(actor);accounts.Read(c=>{SocialRead(c,actor);return 0;});var row=Read(c=>Rows(c,"SELECT * FROM Exchanges WHERE id=@p0",Id(id)).FirstOrDefault()??throw new CommunityException(404,"互换申请不存在"));
if(actor.Id!=Text(row,"requesterId")&&actor.Id!=Text(row,"peerId"))throw new CommunityException(403,"只能读取自己参与的互换申请");EnsureMessages(row);return Read(c=>ExchangeFrom(actor,row));
}
public void CancelPending(Account actor){Active(actor);Change(c=>Exec(c,"UPDATE Exchanges SET status='CANCELLED' WHERE status='PENDING' AND (requesterId=@p0 OR peerId=@p0)",actor.Id));}
// Private commit precedes public delivery. Deterministic nonces recover interrupted delivery without duplicates.
private void EnsureMessages(Dictionary<string,object?> row)
{
string requester=Text(row,"requesterId"),peer=Text(row,"peerId"),id=Text(row,"id");accounts.Write(c=>
{
// A late recovery must not resurrect a private card after either party cleared history.
if(Rows(c,"SELECT accountId FROM AccountChatClears WHERE (accountId=@p0 OR accountId=@p1) AND clearedAt>=@p2",requester,peer,Text(row,"status")=="CONFIRMED"?Text(row,"confirmedAt"):Text(row,"createdAt")).Count>0)return 0;
Exec(c,"INSERT OR IGNORE INTO ChatMessages(senderId,recipientId,content,clientId,createdAt,kind,exchangeId) VALUES(@p0,@p1,'申请互换证照;确认前不会发送身份证照片或银行卡资料',@p2,@p3,'IDENTITY_REQUEST',@p2)",requester,peer,id,Text(row,"createdAt"));
if(Text(row,"status")=="CONFIRMED")foreach(var (from,to) in new[]{(requester,peer),(peer,requester)})Exec(c,"INSERT OR IGNORE INTO ChatMessages(senderId,recipientId,content,clientId,createdAt,kind,exchangeId) VALUES(@p0,@p1,'已确认互换证照,仅用于本次交易核验',@p2,@p3,'CERTIFICATE',@p4)",from,to,Hash(id+from+"certificate")[..32],Text(row,"confirmedAt"),id);return 0;
});
}
private bool CanSeeExchange(Account actor,Dictionary<string,object?> e)=>accounts.Read(c=>Rows(c,"SELECT id FROM ChatMessages WHERE exchangeId=@p0 AND (senderId=@p1 OR recipientId=@p1) AND id>COALESCE((SELECT throughId FROM AccountChatClears WHERE accountId=@p1),0)",Text(e,"id"),actor.Id).Count>0);
private CertificateExchange ExchangeFrom(Account actor,Dictionary<string,object?> r)
{
string requester=Text(r,"requesterId"),peer=Text(r,"peerId"),id=Text(r,"id");if(actor.Id!=requester&&actor.Id!=peer)throw new CommunityException(403,"只能读取自己参与的互换申请");if(!CanSeeExchange(actor,r))throw new CommunityException(404,"此互换记录已清除");
bool complete=Text(r,"status")=="CONFIRMED";CertificateCard? a=complete?Unseal<CertificateCard>(Text(r,"requesterCard"),$"exchange/{id}/{requester}"):null,b=complete?Unseal<CertificateCard>(Text(r,"peerCard"),$"exchange/{id}/{peer}"):null;
return new CertificateExchange(id,requester,peer,Text(r,"status"),Text(r,"createdAt"),Text(r,"confirmedAt"),actor.Id==requester?a:b,actor.Id==requester?b:a);
}
public CertificateExchange DecideExchange(Account actor,string id,ExchangeDecision decision)
{
Active(actor);if(decision.Action is not ("CONFIRM" or "REJECT" or "CANCEL"))throw new ArgumentException("互换操作无效");var files=new List<string>();bool committed=false;
try{var result=Change(c=>
{
var r=Rows(c,"SELECT * FROM Exchanges WHERE id=@p0",Id(id)).FirstOrDefault()??throw new CommunityException(404,"申请不存在");string requester=Text(r,"requesterId"),peer=Text(r,"peerId");if(actor.Id!=requester&&actor.Id!=peer)throw new CommunityException(403,"只能操作双方互换申请");
if(Text(r,"status")!="PENDING")return r;if(decision.Action=="CANCEL"&&actor.Id!=requester||decision.Action!="CANCEL"&&actor.Id!=peer)throw new CommunityException(403,"只有对方能确认或拒绝申请,申请人可以取消");if(!CanSeeExchange(actor,r))throw new CommunityException(404,"此互换记录已清除");
if(decision.Action!="CONFIRM"){Exec(c,"UPDATE Exchanges SET status=@p0 WHERE id=@p1",decision.Action=="CANCEL"?"CANCELLED":"REJECTED",id);return Rows(c,"SELECT * FROM Exchanges WHERE id=@p0",id)[0];}
if(!CanSeeExchange(accounts.Read(a=>AccountFrom(SafetyAccount(a,requester))),r)||!CanSeeExchange(accounts.Read(a=>AccountFrom(SafetyAccount(a,peer))),r))throw new CommunityException(409,"一方已清除聊天,请取消后重新申请");if(!decision.Consent)throw new ArgumentException("确认互换须明确同意分享资料");accounts.Read(a=>{SocialWrite(a,actor,requester);return 0;});var first=Certified(c,requester);var second=Certified(c,peer);if(Convert.ToInt64(first["revision"])!=Convert.ToInt64(r["requesterRevision"])||Convert.ToInt64(second["revision"])!=Convert.ToInt64(r["peerRevision"]))throw new CommunityException(409,"认证资料已更新,请取消后重新申请互换");
var stamp=DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromHours(8)).ToString("yyyy-MM-dd HH:mm:ss zzz")+" 仅本次交易核验 其他无效";
CertificateCard Card(Dictionary<string,object?> identity,string owner)
{
var form=Unseal<IdentityForm>(Text(identity,"data"),$"identity/{owner}/{identity["revision"]}");string Copy(string doc)
{
var original=Rows(c,"SELECT * FROM Documents WHERE id=@p0 AND ownerId=@p1 AND purpose='IDENTITY'",doc,owner).First();var bytes=Decrypt(File.ReadAllBytes(Path.Combine(directory,doc+".bin")),DocumentContext(original));var marked=Watermark(bytes,stamp);var newId=Guid.NewGuid().ToString("N");string file=Path.Combine(directory,newId+".bin");files.Add(file);File.WriteAllBytes(file,Encrypt(marked,$"document/{newId}/{owner}/EXCHANGE"));
Exec(c,"INSERT INTO Documents(id,ownerId,purpose,mime,nonce,hash,size,createdAt,exchangeId) VALUES(@p0,@p1,'EXCHANGE','image/jpeg',@p0,@p2,@p3,@p4,@p5)",newId,owner,Convert.ToHexString(SHA256.HashData(marked)),marked.Length,Now,id);return newId;
}
return new CertificateCard(form.Name,form.Phone,form.Address,form.CompanyName,form.BankNumber,form.BankName,form.IdPhotos.Select(Copy).ToList(),form.BusinessPhoto is null?null:Copy(form.BusinessPhoto),stamp);
}
CertificateCard aCard=Card(first,requester),bCard=Card(second,peer);Exec(c,"UPDATE Exchanges SET status='CONFIRMED',confirmedAt=@p0,requesterCard=@p1,peerCard=@p2 WHERE id=@p3",Now,Seal(aCard,$"exchange/{id}/{requester}"),Seal(bCard,$"exchange/{id}/{peer}"),id);
AuditPrivate(actor,"CERTIFICATE_EXCHANGE",id);return Rows(c,"SELECT * FROM Exchanges WHERE id=@p0",id)[0];
});committed=true;EnsureMessages(result);return Read(c=>ExchangeFrom(actor,result));}catch{if(!committed)foreach(var file in files)File.Delete(file);throw;}
}
private static byte[] Watermark(byte[] source,string text)
{
using var bitmap=SKBitmap.Decode(source)??throw new ArgumentException("证件照片无法解码");using var image=SKSurface.Create(new SKImageInfo(bitmap.Width,bitmap.Height));var canvas=image.Canvas;canvas.DrawBitmap(bitmap,0,0);
string? fontFile=Environment.GetEnvironmentVariable("JDJ_WATERMARK_FONT");if(string.IsNullOrWhiteSpace(fontFile)&&OperatingSystem.IsWindows())fontFile=Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts),"msyh.ttc");
using var typeface=fontFile is not null&&File.Exists(fontFile)?SKTypeface.FromFile(fontFile):SKTypeface.FromFamilyName("Noto Sans CJK SC");if(typeface is null||!typeface.ContainsGlyphs("仅本次交易核验其他无效"))throw new InvalidOperationException("服务器缺少中文水印字体,请配置 JDJ_WATERMARK_FONT");
float size=Math.Clamp(bitmap.Width/35f,12,44);using var font=new SKFont(typeface,size);using var paint=new SKPaint{Color=new SKColor(135,45,45,135),IsAntialias=true};
for(float y=size*2;y<bitmap.Height;y+=size*5){canvas.DrawText(text,8,y,font,paint);}using var bottom=new SKPaint{Color=new SKColor(255,255,255,210)};canvas.DrawRect(0,bitmap.Height-size*2.5f,bitmap.Width,size*2.5f,bottom);paint.Color=new SKColor(110,25,25,230);
using var small=new SKFont(typeface,Math.Min(size,(bitmap.Width-16)/Math.Max(1,font.MeasureText(text))*size));canvas.DrawText(text,8,bitmap.Height-size*.7f,small,paint);using var snapshot=image.Snapshot();using var encoded=snapshot.Encode(SKEncodedImageFormat.Jpeg,88);return encoded.ToArray();
}
}