using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; using System.Text.Json; namespace ChangeT50.Agent; public sealed class AgentApiClient { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower }; private readonly HttpClient _http; private readonly AgentOptions _options; public AgentApiClient(HttpClient http, AgentOptions options) { _http = http; _options = options; _http.BaseAddress = options.BaseUrl; } public Task GetTasksAsync(int limit = 20, CancellationToken cancellationToken = default) => SendAsync(HttpMethod.Get, $"/api/v1/one-card-agent/tasks?limit={Math.Clamp(limit, 1, 50)}", null, cancellationToken); public Task AcceptTaskAsync(string clientOrderNo, string requestId, CancellationToken cancellationToken = default) => SendAsync(HttpMethod.Post, "/api/v1/one-card-agent/task-acceptances", new { client_order_no = clientOrderNo, agent_request_id = requestId }, cancellationToken); public Task PostConsumeResultAsync(ConsumeExecution result, string leaseToken, CancellationToken cancellationToken = default) => SendAsync(HttpMethod.Post, "/api/v1/one-card-agent/consume-results", new { client_order_no = result.ClientOrderNo, lease_token = leaseToken, amount_cents = result.AmountCents, result = result.Result, result_code = result.ResultCode, message = result.Message, card_no_hash = result.CardNoHash, card_no_masked = result.CardNoMasked, op_count_after = result.OpCountAfter, psam_trade_no = result.PsamTradeNo, tac = result.Tac, occurred_at = result.OccurredAt }, cancellationToken); public Task PostUploadResultAsync(string clientOrderNo, UploadExecution result, CancellationToken cancellationToken = default) => SendAsync(HttpMethod.Post, "/api/v1/one-card-agent/upload-results", new { client_order_no = clientOrderNo, result = result.Result, result_code = result.ResultCode, message = result.Message, provider_trade_no = result.ProviderTradeNo, retry_count = result.RetryCount, balance_before_cents = result.BalanceBeforeCents, balance_after_cents = result.BalanceAfterCents }, cancellationToken); public Task GetRecoveryTasksAsync(long cursor = 0, int limit = 20, CancellationToken cancellationToken = default) => SendAsync(HttpMethod.Get, $"/api/v1/one-card-agent/recovery-tasks?cursor={Math.Max(0, cursor)}&limit={Math.Clamp(limit, 1, 100)}", null, cancellationToken); public Task PostHeartbeatAsync(HealthSnapshot health, CancellationToken cancellationToken = default) => SendAsync(HttpMethod.Post, "/api/v1/one-card-agent/heartbeats", health, cancellationToken); public Task PostIdentityBatchAsync(string batchId, string cursor, IReadOnlyCollection items, CancellationToken cancellationToken = default) => SendAsync(HttpMethod.Post, "/api/v1/one-card-agent/identity-batches", new { batch_id = batchId, cursor, items }, cancellationToken); public Task GetPersonAsync(string externalNo, CancellationToken cancellationToken = default) => SendAsync(HttpMethod.Get, $"/api/v1/one-card-agent/persons/{Uri.EscapeDataString(externalNo)}", null, cancellationToken); public Task AcknowledgeIdentityBatchAsync(string batchId, string cursor, CancellationToken cancellationToken = default) => SendAsync(HttpMethod.Post, "/api/v1/one-card-agent/identity-batch-acknowledgements", new { batch_id = batchId, cursor }, cancellationToken); public Task PostReconciliationAsync( string reconciliationId, string clientOrderNo, string result, string remoteStatus, long amountCents, long remoteAmountCents, CancellationToken cancellationToken = default) => SendAsync(HttpMethod.Post, "/api/v1/one-card-agent/reconciliation-results", new { reconciliation_id = reconciliationId, client_order_no = clientOrderNo, result, remote_status = remoteStatus, amount_cents = amountCents, remote_amount_cents = remoteAmountCents, occurred_at = DateTimeOffset.Now }, cancellationToken); private async Task SendAsync(HttpMethod method, string relativeUrl, object? payload, CancellationToken cancellationToken) { var requestUri = new Uri(relativeUrl, UriKind.Relative); var body = payload is null ? string.Empty : JsonSerializer.Serialize(payload, JsonOptions); using var request = new HttpRequestMessage(method, requestUri); if (payload is not null) { request.Content = new StringContent(body, Encoding.UTF8, "application/json"); request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" }; } var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); var nonce = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(); var path = requestUri.OriginalString.Split('?', 2)[0]; var signature = Sign(method.Method, path, timestamp, nonce, body, _options.Secret); request.Headers.TryAddWithoutValidation("X-OneCard-Provider", _options.ProviderCode); request.Headers.TryAddWithoutValidation("X-OneCard-Device", _options.DeviceId); request.Headers.TryAddWithoutValidation("X-OneCard-Timestamp", timestamp); request.Headers.TryAddWithoutValidation("X-OneCard-Nonce", nonce); request.Headers.TryAddWithoutValidation("X-OneCard-Signature", signature); using var response = await _http.SendAsync(request, cancellationToken).ConfigureAwait(false); var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { throw new HttpRequestException($"Agent API returned HTTP {(int)response.StatusCode}."); } var envelope = JsonSerializer.Deserialize>(responseBody, JsonOptions) ?? throw new InvalidDataException("Agent API returned an empty or invalid envelope."); if (envelope.Code != 0) { throw new InvalidOperationException($"Agent API rejected the request: {envelope.Message} (trace {envelope.TraceId})."); } return envelope.Data ?? throw new InvalidDataException("Agent API success envelope did not contain data."); } internal static string Sign(string method, string path, string timestamp, string nonce, string body, string secret) { var bodyHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(body))).ToLowerInvariant(); var plain = string.Join('\n', method.ToUpperInvariant(), "/" + path.TrimStart('/'), timestamp, nonce, bodyHash); return Convert.ToHexString(HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(plain))) .ToLowerInvariant(); } }