feat: add account-scoped data synchronization

This commit is contained in:
2026-09-22 09:58:58 +08:00
parent ab9ff389f2
commit 72e040546a
37 changed files with 4252 additions and 159 deletions
@@ -0,0 +1,96 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using WxAgent.Core;
namespace WxAgent.Windows;
/// <summary>
/// Collects authorized messages directly from verified read-only database keys.
/// It never opens a WeChat window, changes the active account, or writes to the
/// source database; unavailable shards are returned as an explicit partial result.
/// </summary>
public sealed class DatabaseMessageSyncCollector : IRemoteDataCollector
{
private readonly string? keyFile;
private readonly int perChatLimit;
private readonly int overlapRows;
public DatabaseMessageSyncCollector(string? keyFile = null, int perChatLimit = 100, int overlapRows = 1)
{
if (perChatLimit is < 1 or > 500) throw new ArgumentOutOfRangeException(nameof(perChatLimit));
if (overlapRows is < 0 or > 100) throw new ArgumentOutOfRangeException(nameof(overlapRows));
this.keyFile = keyFile;
this.perChatLimit = perChatLimit;
this.overlapRows = overlapRows;
}
public async Task<RemoteDataCollection> CollectAsync(
string accountId,
IReadOnlyList<RemoteReportingScope> scopes,
RemoteDataSyncCheckpoint checkpoint,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(accountId))
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "A database sync account id is required.");
var accounts = await DatabaseKeyStore.LoadAsync(keyFile, cancellationToken).ConfigureAwait(false);
var account = accounts.SingleOrDefault(item => string.Equals(item.AccountRootFingerprint, accountId, StringComparison.OrdinalIgnoreCase));
if (account is null)
throw new WxAgentException(WxAgentErrorCode.DatabaseKeyNotFound, "The verified database account does not match the requested sync account.");
if (account.Databases.Any(database => database.Confidence != KeyBindingConfidence.PageHmacVerified))
throw new WxAgentException(WxAgentErrorCode.DatabaseOpenFailed, "The account contains a database key without page-1 HMAC verification.");
// The verified account fingerprint is the stable source generation for v1;
// a future key-cache version can deliberately rotate it for a rebind.
var sourceGeneration = account.AccountRootFingerprint;
var cursors = string.Equals(checkpoint.SourceGeneration, sourceGeneration, StringComparison.Ordinal)
? checkpoint.ConfirmedCursors
: new Dictionary<string, long>(StringComparer.Ordinal);
var chatScopes = scopes.DistinctBy(scope => $"{scope.ChatType}:{scope.ChatId}").ToArray();
var chatIds = chatScopes.Select(scope => scope.ChatId).Distinct(StringComparer.Ordinal).ToArray();
var page = await WechatMessageDbReader.ReadIncrementalAsync(
account.AccountRootPath, account.Databases, chatIds, cursors, perChatLimit, cancellationToken, overlapRows).ConfigureAwait(false);
var scopeByChat = chatScopes
.GroupBy(scope => scope.ChatId, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.First(), StringComparer.Ordinal);
var observedAt = DateTimeOffset.UtcNow;
var conversations = chatScopes.Select(scope => new RemoteSyncConversation(
scope.ChatId,
scope.ChatType,
scope.ChatId,
null,
"authorized-db-scope",
observedAt,
"observed")).ToArray();
var messages = page.Messages.Select(message =>
{
var scope = scopeByChat.GetValueOrDefault(message.ChatId)
?? new RemoteReportingScope(message.ChatId, ReportingChatType.Private);
var sourceMessageId = message.LocalId.ToString(CultureInfo.InvariantCulture);
var messageId = message.ServerId > 0
? $"{message.ChatId}:server:{message.ServerId.ToString(CultureInfo.InvariantCulture)}"
: $"{message.ChatId}:local:{sourceMessageId}";
var text = message.Content ?? string.Empty;
return new RemoteSyncMessage(
messageId,
message.ChatId,
scope.ChatType,
sourceMessageId,
message.IsSelf == true ? "outgoing" : "incoming",
message.Type.ToString(CultureInfo.InvariantCulture),
text,
message.Timestamp,
observedAt,
account.WechatVersion ?? "unknown",
HashMessage(messageId, text, message.Timestamp));
}).ToArray();
return new RemoteDataCollection(sourceGeneration, page.NextCursors, conversations, messages, page.IsComplete, page.NewMessageCount > 0, page.UnavailableDatabases);
}
private static string HashMessage(string messageId, string text, DateTimeOffset sourceTime)
{
var canonical = JsonSerializer.Serialize(new { messageId, text, sourceTime = sourceTime.ToUniversalTime().ToString("O") });
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical))).ToLowerInvariant();
}
}