feat: cache contacts and add forced sync
Build web service image / build (push) Successful in 1m13s

This commit is contained in:
2026-09-23 11:17:43 +08:00
parent cc2321aa86
commit bfa0059d3a
13 changed files with 708 additions and 121 deletions
@@ -168,12 +168,15 @@ public sealed class RemoteControlClient : IDisposable
public Task<RemoteDataSyncStatus> GetDataSyncStatusAsync(
string accountId,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
string streamKey = "messages")
{
RemoteAgentOptions.ValidateIdentifier(accountId, "accountId", 200);
if (streamKey is not ("messages" or "contacts"))
throw new ArgumentOutOfRangeException(nameof(streamKey));
return SendAuthenticatedAsync<RemoteDataSyncStatus>(
HttpMethod.Get,
$"/v1/nodes/{Escape(_options.NodeId!)}/data/accounts/{Escape(accountId)}/sync-status",
$"/v1/nodes/{Escape(_options.NodeId!)}/data/accounts/{Escape(accountId)}/sync-status?stream_key={Escape(streamKey)}",
null,
cancellationToken);
}
+38 -5
View File
@@ -24,7 +24,21 @@ public sealed record RemoteSyncBatch(
[property: JsonPropertyName("payload_hash")] string PayloadHash,
[property: JsonPropertyName("coverage_state")] string CoverageState,
[property: JsonPropertyName("conversations")] IReadOnlyList<RemoteSyncConversation> Conversations,
[property: JsonPropertyName("messages")] IReadOnlyList<RemoteSyncMessage> Messages);
[property: JsonPropertyName("messages")] IReadOnlyList<RemoteSyncMessage> Messages)
{
[JsonPropertyName("contacts")]
public IReadOnlyList<RemoteSyncContact> Contacts { get; init; } = [];
[JsonPropertyName("contacts_snapshot_id")]
public string? ContactsSnapshotId { get; init; }
}
public sealed record RemoteSyncContact(
[property: JsonPropertyName("chat_id")] string ChatId,
[property: JsonPropertyName("chat_type")] ReportingChatType ChatType,
[property: JsonPropertyName("display_name")] string DisplayName,
[property: JsonPropertyName("remark")] string Remark,
[property: JsonPropertyName("observed_at")] DateTimeOffset ObservedAt);
public sealed record RemoteSyncConversation(
[property: JsonPropertyName("chat_id")] string ChatId,
@@ -276,14 +290,19 @@ public static class RemoteDataBatchAuthorization
ReportingAuthorization.Check(config, batch.AccountId, conversation.ChatId, conversation.ChatType, ReportingDataType.TaskResult).Allowed).ToArray();
var messages = batch.Messages.Where(message =>
ReportingAuthorization.Check(config, batch.AccountId, message.ChatId, message.ChatType, ReportingDataType.Message).Allowed).ToArray();
var dropped = conversations.Length != batch.Conversations.Count || messages.Length != batch.Messages.Count;
var contacts = batch.Contacts.Where(contact =>
ReportingAuthorization.Check(config, batch.AccountId, contact.ChatId, contact.ChatType, ReportingDataType.TaskResult).Allowed).ToArray();
var dropped = conversations.Length != batch.Conversations.Count || messages.Length != batch.Messages.Count || contacts.Length != batch.Contacts.Count;
reason = dropped ? "SomeRecordsDroppedByReporting" : "Authorized";
if (conversations.Length == 0 && messages.Length == 0 && (batch.Conversations.Count > 0 || batch.Messages.Count > 0))
if (conversations.Length == 0 && messages.Length == 0 && contacts.Length == 0 && (batch.Conversations.Count > 0 || batch.Messages.Count > 0 || batch.Contacts.Count > 0))
{
reason = "AllRecordsDroppedByReporting";
return null;
}
return dropped ? batch with { CoverageState = "partial", Conversations = conversations, Messages = messages } : batch;
if (!dropped)
return batch;
var filteredBatch = batch with { CoverageState = "partial", Conversations = conversations, Messages = messages, Contacts = contacts };
return filteredBatch with { PayloadHash = ComputePayloadHash(filteredBatch) };
}
}
@@ -440,7 +459,21 @@ public sealed class RemoteDataBatchQueue
{
foreach (var value in new[] { batch.NodeId, batch.AccountId, batch.BatchId, batch.SourceGeneration, batch.StreamKey, batch.PayloadHash })
RemoteAgentOptions.ValidateIdentifier(value, "sync batch field", 512);
if (batch.Sequence < 1 || batch.Conversations.Count + batch.Messages.Count > RemoteDataProtocol.MaxBatchMessages)
if (batch.Sequence < 1 || batch.Conversations.Count + batch.Messages.Count > RemoteDataProtocol.MaxBatchMessages
|| batch.Contacts.Count > RemoteDataProtocol.MaxBatchItems
|| batch.StreamKey is not ("messages" or "contacts")
|| batch.StreamKey == "contacts" && (string.IsNullOrWhiteSpace(batch.ContactsSnapshotId) || batch.Conversations.Count > 0 || batch.Messages.Count > 0)
|| batch.StreamKey == "messages" && (batch.Contacts.Count > 0 || batch.ContactsSnapshotId is not null))
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "The sync batch sequence or item count is invalid.");
if (batch.ContactsSnapshotId is not null)
RemoteAgentOptions.ValidateIdentifier(batch.ContactsSnapshotId, "contacts snapshot", 512);
foreach (var contact in batch.Contacts)
{
if (string.IsNullOrWhiteSpace(contact.ChatId) || contact.ChatId.Length > 512
|| contact.DisplayName.Length > 2048 || contact.Remark.Length > 2048
|| contact.ChatType is not (ReportingChatType.Group or ReportingChatType.Private)
|| contact.ObservedAt == default)
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "The contact record is invalid.");
}
}
}
@@ -136,7 +136,7 @@ public sealed class RemoteAgentHostedService(
var tasks = await client.PollTasksAsync(accountId, stoppingToken, waitSeconds: 5);
foreach (var task in tasks)
{
await ProcessTaskAsync(client, remote, reporting, ledger, task, stoppingToken);
await ProcessTaskAsync(client, remote, reporting, ledger, task, dataQueue, syncState, stoppingToken);
}
}
@@ -284,40 +284,47 @@ public sealed class RemoteAgentHostedService(
{
foreach (var account in reporting.Accounts.Where(item => item.Enabled))
{
var local = state.GetLatest(account.AccountId, "messages");
RemoteDataSyncStatus remote;
try
var revoked = false;
foreach (var streamKey in new[] { "messages", "contacts" })
{
remote = await client.GetDataSyncStatusAsync(account.AccountId, cancellationToken).ConfigureAwait(false);
}
catch (RemoteClientException exception) when (exception.StatusCode == 404 && exception.Code == "NotFound")
{
// Older control planes do not expose the reconciliation endpoint; retain the legacy ACK path.
continue;
}
catch (RemoteClientException exception) when (exception.StatusCode == 403 && exception.Code == "AccountNotAuthorized")
{
var droppedOnRevoke = queue.DropAllForAccount(account.AccountId);
blockedAccountIds.Add(account.AccountId);
logger.LogWarning(
"Data sync authorization is revoked; accountId={AccountId}; pending content was discarded and collection is paused; droppedPendingBatches={DroppedPendingBatches}.",
account.AccountId, droppedOnRevoke);
continue;
}
catch (HttpRequestException)
{
// A disconnected platform must not prevent local DB collection.
return;
}
var local = state.GetLatest(account.AccountId, streamKey);
RemoteDataSyncStatus remote;
try
{
remote = await client.GetDataSyncStatusAsync(account.AccountId, cancellationToken, streamKey).ConfigureAwait(false);
}
catch (RemoteClientException exception) when (exception.StatusCode == 404 && exception.Code == "NotFound")
{
// Older control planes do not expose the reconciliation endpoint; retain the legacy ACK path.
continue;
}
catch (RemoteClientException exception) when (exception.StatusCode == 403 && exception.Code == "AccountNotAuthorized")
{
var droppedOnRevoke = queue.DropAllForAccount(account.AccountId);
blockedAccountIds.Add(account.AccountId);
logger.LogWarning(
"Data sync authorization is revoked; accountId={AccountId}; pending content was discarded and collection is paused; droppedPendingBatches={DroppedPendingBatches}.",
account.AccountId, droppedOnRevoke);
revoked = true;
break;
}
catch (HttpRequestException)
{
// A disconnected platform must not prevent local DB collection.
return;
}
if (blockedAccountIds.Remove(account.AccountId))
logger.LogInformation("Data sync authorization was restored; accountId={AccountId}; collection is resumed.", account.AccountId);
if (!state.Reconcile(remote, account.AccountId, "messages"))
if (blockedAccountIds.Remove(account.AccountId))
logger.LogInformation("Data sync authorization was restored; accountId={AccountId}; collection is resumed.", account.AccountId);
if (!state.Reconcile(remote, account.AccountId, streamKey))
continue;
var dropped = queue.DropForAccount(account.AccountId, streamKey);
logger.LogWarning(
"Data sync checkpoint reconciled with the platform; accountId={AccountId}; stream={StreamKey}; localSequence={LocalSequence}; platformSequence={PlatformSequence}; droppedPendingBatches={DroppedPendingBatches}.",
account.AccountId, streamKey, local.ConfirmedSequence, remote.ConfirmedSequence, dropped);
}
if (revoked)
continue;
var dropped = queue.DropForAccount(account.AccountId, "messages");
logger.LogWarning(
"Data sync checkpoint reconciled with the platform; accountId={AccountId}; localSequence={LocalSequence}; platformSequence={PlatformSequence}; droppedPendingBatches={DroppedPendingBatches}.",
account.AccountId, local.ConfirmedSequence, remote.ConfirmedSequence, dropped);
}
}
@@ -347,6 +354,8 @@ public sealed class RemoteAgentHostedService(
ReportingConfig reporting,
RemoteTaskLedger ledger,
RemoteTaskEnvelope task,
RemoteDataBatchQueue? dataQueue,
RemoteDataSyncStateStore? syncState,
CancellationToken cancellationToken)
{
if (task.Status is RemoteTaskStatus.Accepted or RemoteTaskStatus.Running)
@@ -415,10 +424,56 @@ public sealed class RemoteAgentHostedService(
var renewed = await client.RenewTaskAsync(started, cancellationToken);
if (task.Kind == "sync-data")
{
var result = new RemoteTaskResult(task.TaskId, task.AccountId, renewed.LeaseGeneration,
var streamKey = task.Payload.ValueKind == JsonValueKind.Object
&& task.Payload.TryGetProperty("stream_key", out var streamElement)
&& streamElement.ValueKind == JsonValueKind.String
? streamElement.GetString() ?? "messages"
: "messages";
if (streamKey == "contacts")
{
try
{
if (dataQueue is null || syncState is null)
throw new ServiceException("SyncUnavailable", 503, "The contacts sync queue is not available.");
var queued = await QueueContactsSnapshotAsync(remote.NodeId!, reporting, task.AccountId, dataQueue, syncState, cancellationToken);
var message = queued ? "通讯录同步已加入节点本地队列。" : "已有通讯录同步待上传,本次请求未重复排队。";
var result = new RemoteTaskResult(task.TaskId, task.AccountId, renewed.LeaseGeneration,
RemoteTaskStatus.Succeeded, null, message, false, null, Guid.NewGuid().ToString("N"));
ledger.Complete(result);
await ReportResultAsync(client, ledger, result, reporting, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (ServiceException exception)
{
var result = new RemoteTaskResult(task.TaskId, task.AccountId, renewed.LeaseGeneration,
RemoteTaskStatus.Failed, exception.Code, "通讯录同步失败。", false, null, Guid.NewGuid().ToString("N"));
ledger.Complete(result);
await ReportResultAsync(client, ledger, result, reporting, cancellationToken);
}
catch (WxAgentException exception)
{
var result = new RemoteTaskResult(task.TaskId, task.AccountId, renewed.LeaseGeneration,
RemoteTaskStatus.Failed, exception.Code.ToString(), "通讯录同步失败。", false, null, Guid.NewGuid().ToString("N"));
ledger.Complete(result);
await ReportResultAsync(client, ledger, result, reporting, cancellationToken);
}
return;
}
if (streamKey != "messages")
{
var result = new RemoteTaskResult(task.TaskId, task.AccountId, renewed.LeaseGeneration,
RemoteTaskStatus.Failed, "InvalidTaskPayload", "The sync stream is unsupported.", false, null, Guid.NewGuid().ToString("N"));
ledger.Complete(result);
await ReportResultAsync(client, ledger, result, reporting, cancellationToken);
return;
}
var messagesResult = new RemoteTaskResult(task.TaskId, task.AccountId, renewed.LeaseGeneration,
RemoteTaskStatus.Succeeded, null, "后台数据同步已受理;采集器将在下一轮同步周期执行。", false, null, Guid.NewGuid().ToString("N"));
ledger.Complete(result);
await ReportResultAsync(client, ledger, result, reporting, cancellationToken);
ledger.Complete(messagesResult);
await ReportResultAsync(client, ledger, messagesResult, reporting, cancellationToken);
return;
}
@@ -600,6 +655,105 @@ public sealed class RemoteAgentHostedService(
}
}
private async Task<bool> QueueContactsSnapshotAsync(
string nodeId,
ReportingConfig reporting,
string accountId,
RemoteDataBatchQueue dataQueue,
RemoteDataSyncStateStore syncState,
CancellationToken cancellationToken)
{
if (dataQueue.Pending().Any(batch => batch.AccountId == accountId && batch.StreamKey == "contacts"))
return false;
var scopes = AuthorizedChatScopes(reporting, accountId);
RequireScopes(reporting, accountId, scopes);
var authorizedContacts = await ReadContactsForScopesAsync(accountId, scopes, cancellationToken);
var authorizedById = authorizedContacts.ToDictionary(contact => contact.Id, StringComparer.Ordinal);
var groupIds = new HashSet<string>(StringComparer.Ordinal);
for (var offset = 0; ;)
{
var page = await backend.ContactsAsync(accountId, null, true, 200, offset, cancellationToken);
foreach (var group in page.Items)
{
if (authorizedById.ContainsKey(group.Id))
groupIds.Add(group.Id);
}
if (!page.HasMore)
break;
var nextOffset = page.NextOffset ?? offset + page.Items.Count;
if (nextOffset <= offset)
throw new ServiceException("InvalidPagination", 502, "The contacts page did not advance.");
offset = nextOffset;
}
var observedAt = DateTimeOffset.UtcNow;
var contacts = authorizedContacts
.Select(contact => new RemoteSyncContact(
contact.Id,
groupIds.Contains(contact.Id) ? ReportingChatType.Group : ReportingChatType.Private,
contact.DisplayName ?? string.Empty,
contact.Remark ?? string.Empty,
observedAt))
.Where(contact => scopes.Any(scope => scope.ChatType == contact.ChatType
&& (scope.ChatId == "*" || scope.ChatId == contact.ChatId)))
.GroupBy(contact => contact.ChatId, StringComparer.Ordinal)
.Select(group => group.First())
.OrderBy(contact => contact.ChatType)
.ThenBy(contact => contact.ChatId, StringComparer.Ordinal)
.ToArray();
const string streamKey = "contacts";
var checkpoint = syncState.GetLatest(accountId, streamKey);
var sourceGeneration = syncState.GetLatest(accountId, "messages").SourceGeneration;
if (string.IsNullOrWhiteSpace(sourceGeneration))
sourceGeneration = checkpoint.SourceGeneration;
if (string.IsNullOrWhiteSpace(sourceGeneration))
throw new ServiceException("SyncUnavailable", 503, "The account data source has not been initialized.");
var sequence = checkpoint.SourceGeneration == sourceGeneration ? checkpoint.ConfirmedSequence + 1 : 1;
var snapshotId = Guid.NewGuid().ToString("N");
var pendingContacts = new List<RemoteSyncContact>();
RemoteSyncBatch CreateBatch(IReadOnlyList<RemoteSyncContact> items, bool complete)
{
var batch = new RemoteSyncBatch(
nodeId, accountId, Guid.NewGuid().ToString("N"), sourceGeneration, streamKey, sequence,
"{}", "{}", string.Empty, complete ? "complete" : "partial", [], [])
{
Contacts = items,
ContactsSnapshotId = snapshotId
};
return batch with { PayloadHash = RemoteDataBatchAuthorization.ComputePayloadHash(batch) };
}
void Enqueue(bool complete)
{
var batch = CreateBatch(pendingContacts.ToArray(), complete);
if (JsonSerializer.SerializeToUtf8Bytes(batch, RemoteJson.Options).Length > RemoteDataProtocol.MaxBatchBytes - 2048)
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "A contacts sync batch exceeds the size limit.");
var result = dataQueue.Enqueue(reporting, batch);
if (!result.Accepted)
throw new ServiceException(result.Reason ?? "SyncQueueFull", 503, "The contacts sync batch was not queued.");
sequence++;
pendingContacts.Clear();
}
foreach (var contact in contacts)
{
pendingContacts.Add(contact);
if (pendingContacts.Count <= RemoteDataProtocol.MaxBatchItems
&& JsonSerializer.SerializeToUtf8Bytes(CreateBatch(pendingContacts, false), RemoteJson.Options).Length <= RemoteDataProtocol.MaxBatchBytes - 2048)
continue;
pendingContacts.RemoveAt(pendingContacts.Count - 1);
if (pendingContacts.Count == 0)
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "A contact record exceeds the sync batch size limit.");
Enqueue(false);
pendingContacts.Add(contact);
}
Enqueue(true);
return true;
}
private async Task<IReadOnlyList<ContactInfo>> ReadContactsForScopesAsync(
string accountId, IReadOnlyList<RemoteReportingScope> scopes, CancellationToken cancellationToken)
{