From be0b48cfd992f64867deb4358662e8708d59d154 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Mon, 23 Jun 2025 00:29:37 +0800 Subject: [PATCH] :sparkles: Afdian as payment handler --- DysonNetwork.Sphere/Account/AccountService.cs | 9 + .../Startup/ServiceCollectionExtensions.cs | 2 + .../PaymentHandlers/AfdianPaymentHandler.cs | 420 ++++++++++++++++++ .../PaymentHandlers/ISubscriptionOrder.cs | 18 + .../Wallet/SubscriptionController.cs | 30 +- .../Wallet/SubscriptionRenewalJob.cs | 11 +- .../Wallet/SubscriptionService.cs | 93 +++- DysonNetwork.Sphere/appsettings.json | 12 + 8 files changed, 586 insertions(+), 9 deletions(-) create mode 100644 DysonNetwork.Sphere/Wallet/PaymentHandlers/AfdianPaymentHandler.cs create mode 100644 DysonNetwork.Sphere/Wallet/PaymentHandlers/ISubscriptionOrder.cs diff --git a/DysonNetwork.Sphere/Account/AccountService.cs b/DysonNetwork.Sphere/Account/AccountService.cs index a445ea2..db3b9e8 100644 --- a/DysonNetwork.Sphere/Account/AccountService.cs +++ b/DysonNetwork.Sphere/Account/AccountService.cs @@ -57,6 +57,15 @@ public class AccountService( return contact?.Account; } + public async Task LookupAccountByConnection(string identifier, string provider) + { + var connection = await db.AccountConnections + .Where(c => c.ProvidedIdentifier == identifier && c.Provider == provider) + .Include(c => c.Account) + .FirstOrDefaultAsync(); + return connection?.Account; + } + public async Task GetAccountLevel(Guid accountId) { var profile = await db.AccountProfiles diff --git a/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs b/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs index 5d04ab3..7f34bc4 100644 --- a/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs +++ b/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs @@ -25,6 +25,7 @@ using StackExchange.Redis; using System.Text.Json; using System.Threading.RateLimiting; using DysonNetwork.Sphere.Connection.WebReader; +using DysonNetwork.Sphere.Wallet.PaymentHandlers; using tusdotnet.Stores; namespace DysonNetwork.Sphere.Startup; @@ -222,6 +223,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/DysonNetwork.Sphere/Wallet/PaymentHandlers/AfdianPaymentHandler.cs b/DysonNetwork.Sphere/Wallet/PaymentHandlers/AfdianPaymentHandler.cs new file mode 100644 index 0000000..4c4bd91 --- /dev/null +++ b/DysonNetwork.Sphere/Wallet/PaymentHandlers/AfdianPaymentHandler.cs @@ -0,0 +1,420 @@ +using System.Security.Cryptography; +using System.Text; +using Newtonsoft.Json; +using NodaTime; + +namespace DysonNetwork.Sphere.Wallet.PaymentHandlers; + +public class AfdianPaymentHandler( + IHttpClientFactory httpClientFactory, + ILogger logger, + IConfiguration configuration +) +{ + private readonly IHttpClientFactory _httpClientFactory = + httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + private readonly IConfiguration _configuration = + configuration ?? throw new ArgumentNullException(nameof(configuration)); + + private string CalculateSign(string token, string userId, string paramsJson, long ts) + { + var kvString = $"{token}params{paramsJson}ts{ts}user_id{userId}"; + using (var md5 = MD5.Create()) + { + var hashBytes = md5.ComputeHash(Encoding.UTF8.GetBytes(kvString)); + return BitConverter.ToString(hashBytes).Replace("-", "").ToLower(); + } + } + + public async Task ListOrderAsync(int page = 1) + { + try + { + var userId = "abc"; // Replace with your actual USER_ID + var token = _configuration["Payment:Auth:Afdian"] ?? ""; + var paramsJson = JsonConvert.SerializeObject(new { page }); + var ts = (long)(DateTime.UtcNow - new DateTime(1970, 1, 1)) + .TotalSeconds; // Current timestamp in seconds + + var sign = CalculateSign(token, userId, paramsJson, ts); + + var client = _httpClientFactory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/api/open/query-order") + { + Content = new StringContent(JsonConvert.SerializeObject(new + { + user_id = userId, + @params = paramsJson, + ts, + sign + }), Encoding.UTF8, "application/json") + }; + + var response = await client.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + _logger.LogError( + $"Response Error: {response.StatusCode}, {await response.Content.ReadAsStringAsync()}"); + return null; + } + + var result = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching orders"); + throw; + } + } + + /// + /// Get a specific order by its ID (out_trade_no) + /// + /// The order ID to query + /// The order item if found, otherwise null + public async Task GetOrderAsync(string orderId) + { + if (string.IsNullOrEmpty(orderId)) + { + _logger.LogWarning("Order ID cannot be null or empty"); + return null; + } + + try + { + var userId = "abc"; // Replace with your actual USER_ID + var token = _configuration["Payment:Auth:Afdian"] ?? ""; + var paramsJson = JsonConvert.SerializeObject(new { out_trade_no = orderId }); + var ts = (long)(DateTime.UtcNow - new DateTime(1970, 1, 1)) + .TotalSeconds; // Current timestamp in seconds + + var sign = CalculateSign(token, userId, paramsJson, ts); + + var client = _httpClientFactory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/api/open/query-order") + { + Content = new StringContent(JsonConvert.SerializeObject(new + { + user_id = userId, + @params = paramsJson, + ts, + sign + }), Encoding.UTF8, "application/json") + }; + + var response = await client.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + _logger.LogError( + $"Response Error: {response.StatusCode}, {await response.Content.ReadAsStringAsync()}"); + return null; + } + + var result = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + + // Check if we have a valid response and orders in the list + if (result?.Data?.Orders == null || result.Data.Orders.Count == 0) + { + _logger.LogWarning($"No order found with ID: {orderId}"); + return null; + } + + // Since we're querying by a specific order ID, we should only get one result + return result.Data.Orders.FirstOrDefault(); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error fetching order with ID: {orderId}"); + throw; + } + } + + /// + /// Get multiple orders by their IDs (out_trade_no) + /// + /// A collection of order IDs to query + /// A list of found order items + public async Task> GetOrders(IEnumerable orderIds) + { + if (orderIds == null || !orderIds.Any()) + { + _logger.LogWarning("Order IDs cannot be null or empty"); + return new List(); + } + + try + { + // Join the order IDs with commas as specified in the API documentation + var orderIdsParam = string.Join(",", orderIds); + + var userId = "abc"; // Replace with your actual USER_ID + var token = _configuration["Payment:Auth:Afdian"] ?? ""; + var paramsJson = JsonConvert.SerializeObject(new { out_trade_no = orderIdsParam }); + var ts = (long)(DateTime.UtcNow - new DateTime(1970, 1, 1)) + .TotalSeconds; // Current timestamp in seconds + + var sign = CalculateSign(token, userId, paramsJson, ts); + + var client = _httpClientFactory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/api/open/query-order") + { + Content = new StringContent(JsonConvert.SerializeObject(new + { + user_id = userId, + @params = paramsJson, + ts, + sign + }), Encoding.UTF8, "application/json") + }; + + var response = await client.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + _logger.LogError( + $"Response Error: {response.StatusCode}, {await response.Content.ReadAsStringAsync()}"); + return new List(); + } + + var result = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + + // Check if we have a valid response and orders in the list + if (result?.Data?.Orders == null || result.Data.Orders.Count == 0) + { + _logger.LogWarning($"No orders found with IDs: {orderIdsParam}"); + return new List(); + } + + return result.Data.Orders; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error fetching orders"); + throw; + } + } + + /// + /// Handle an incoming webhook from Afdian's payment platform + /// + /// The HTTP request containing webhook data + /// An action to process the received order + /// A WebhookResponse object to be returned to Afdian + public async Task HandleWebhook(HttpRequest request, Func? processOrderAction) + { + try + { + // Read the request body + string requestBody; + using (var reader = new StreamReader(request.Body, Encoding.UTF8)) + { + requestBody = await reader.ReadToEndAsync(); + } + + if (string.IsNullOrEmpty(requestBody)) + { + _logger.LogError("Webhook request body is empty"); + return new WebhookResponse { ErrorCode = 400, ErrorMessage = "Empty request body" }; + } + + _logger.LogInformation($"Received webhook: {requestBody}"); + + // Parse the webhook data + var webhook = JsonConvert.DeserializeObject(requestBody); + + if (webhook == null) + { + _logger.LogError("Failed to parse webhook data"); + return new WebhookResponse { ErrorCode = 400, ErrorMessage = "Invalid webhook data" }; + } + + // Validate the webhook type + if (webhook.Data.Type != "order") + { + _logger.LogWarning($"Unsupported webhook type: {webhook.Data.Type}"); + return new WebhookResponse { ErrorCode = 200, ErrorMessage = "Unsupported type, but acknowledged" }; + } + + // Process the order + try + { + // Check for duplicate order processing by storing processed order IDs + // (You would implement a more permanent storage mechanism for production) + if (processOrderAction != null) + await processOrderAction(webhook.Data); + else + _logger.LogInformation($"Order received but no processing action provided: {webhook.Data.Order.TradeNumber}"); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error processing order {webhook.Data.Order.TradeNumber}"); + // Still returning success to Afdian to prevent repeated callbacks + // Your system should handle the error internally + } + + // Return success response to Afdian + return new WebhookResponse { ErrorCode = 200, ErrorMessage = "" }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error handling webhook"); + return new WebhookResponse { ErrorCode = 500, ErrorMessage = "Internal server error" }; + } + } + + public string? GetSubscriptionPlanId(string subscriptionKey) + { + var planId = _configuration[$"Payment:Subscriptions:Afdian:{subscriptionKey}"]; + + if (string.IsNullOrEmpty(planId)) + { + _logger.LogWarning($"Unknown subscription key: {subscriptionKey}"); + return null; + } + + return planId; + } +} + +public class OrderResponse +{ + [JsonProperty("ec")] public int ErrorCode { get; set; } + + [JsonProperty("em")] public string ErrorMessage { get; set; } = null!; + + [JsonProperty("data")] public OrderData Data { get; set; } = null!; +} + +public class OrderData +{ + [JsonProperty("list")] public List Orders { get; set; } = null!; + + [JsonProperty("total_count")] public int TotalCount { get; set; } + + [JsonProperty("total_page")] public int TotalPages { get; set; } + + [JsonProperty("request")] public RequestDetails Request { get; set; } = null!; +} + +public class OrderItem : ISubscriptionOrder +{ + [JsonProperty("out_trade_no")] public string TradeNumber { get; set; } = null!; + + [JsonProperty("user_id")] public string UserId { get; set; } = null!; + + [JsonProperty("plan_id")] public string PlanId { get; set; } = null!; + + [JsonProperty("month")] public int Months { get; set; } + + [JsonProperty("total_amount")] public string TotalAmount { get; set; } = null!; + + [JsonProperty("show_amount")] public string ShowAmount { get; set; } = null!; + + [JsonProperty("status")] public int Status { get; set; } + + [JsonProperty("remark")] public string Remark { get; set; } = null!; + + [JsonProperty("redeem_id")] public string RedeemId { get; set; } = null!; + + [JsonProperty("product_type")] public int ProductType { get; set; } + + [JsonProperty("discount")] public string Discount { get; set; } = null!; + + [JsonProperty("sku_detail")] public List SkuDetail { get; set; } = null!; + + [JsonProperty("create_time")] public long CreateTime { get; set; } + + [JsonProperty("user_name")] public string UserName { get; set; } = null!; + + [JsonProperty("plan_title")] public string PlanTitle { get; set; } = null!; + + [JsonProperty("user_private_id")] public string UserPrivateId { get; set; } = null!; + + [JsonProperty("address_person")] public string AddressPerson { get; set; } = null!; + + [JsonProperty("address_phone")] public string AddressPhone { get; set; } = null!; + + [JsonProperty("address_address")] public string AddressAddress { get; set; } = null!; + + public Instant BegunAt => Instant.FromUnixTimeSeconds(CreateTime); + + public Duration Duration => Duration.FromDays(Months * 30); + + public string Provider => "afdian"; + + public string Id => TradeNumber; + + public string SubscriptionId => PlanId; + + public string AccountId => UserId; +} + +public class RequestDetails +{ + [JsonProperty("user_id")] public string UserId { get; set; } = null!; + + [JsonProperty("params")] public string Params { get; set; } = null!; + + [JsonProperty("ts")] public long Timestamp { get; set; } + + [JsonProperty("sign")] public string Sign { get; set; } = null!; +} + +/// +/// Request structure for Afdian webhook +/// +public class WebhookRequest +{ + [JsonProperty("ec")] public int ErrorCode { get; set; } + + [JsonProperty("em")] public string ErrorMessage { get; set; } = null!; + + [JsonProperty("data")] public WebhookOrderData Data { get; set; } = null!; +} + +/// +/// Order data contained in the webhook +/// +public class WebhookOrderData +{ + [JsonProperty("type")] public string Type { get; set; } = null!; + + [JsonProperty("order")] public WebhookOrderDetails Order { get; set; } = null!; +} + +/// +/// Order details in the webhook +/// +public class WebhookOrderDetails : OrderItem +{ + [JsonProperty("custom_order_id")] public string CustomOrderId { get; set; } = null!; +} + +/// +/// Response structure to acknowledge webhook receipt +/// +public class WebhookResponse +{ + [JsonProperty("ec")] public int ErrorCode { get; set; } = 200; + + [JsonProperty("em")] public string ErrorMessage { get; set; } = ""; +} + +/// +/// SKU detail item +/// +public class SkuDetailItem +{ + [JsonProperty("sku_id")] public string SkuId { get; set; } = null!; + + [JsonProperty("count")] public int Count { get; set; } + + [JsonProperty("name")] public string Name { get; set; } = null!; + + [JsonProperty("album_id")] public string AlbumId { get; set; } = null!; + + [JsonProperty("pic")] public string Picture { get; set; } = null!; +} diff --git a/DysonNetwork.Sphere/Wallet/PaymentHandlers/ISubscriptionOrder.cs b/DysonNetwork.Sphere/Wallet/PaymentHandlers/ISubscriptionOrder.cs new file mode 100644 index 0000000..a64aa11 --- /dev/null +++ b/DysonNetwork.Sphere/Wallet/PaymentHandlers/ISubscriptionOrder.cs @@ -0,0 +1,18 @@ +using NodaTime; + +namespace DysonNetwork.Sphere.Wallet.PaymentHandlers; + +public interface ISubscriptionOrder +{ + public string Id { get; } + + public string SubscriptionId { get; } + + public Instant BegunAt { get; } + + public Duration Duration { get; } + + public string Provider { get; } + + public string AccountId { get; } +} diff --git a/DysonNetwork.Sphere/Wallet/SubscriptionController.cs b/DysonNetwork.Sphere/Wallet/SubscriptionController.cs index eb9c209..236067b 100644 --- a/DysonNetwork.Sphere/Wallet/SubscriptionController.cs +++ b/DysonNetwork.Sphere/Wallet/SubscriptionController.cs @@ -3,12 +3,13 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using NodaTime; using System.ComponentModel.DataAnnotations; +using DysonNetwork.Sphere.Wallet.PaymentHandlers; namespace DysonNetwork.Sphere.Wallet; [ApiController] [Route("/subscriptions")] -public class SubscriptionController(SubscriptionService subscriptions, AppDatabase db) : ControllerBase +public class SubscriptionController(SubscriptionService subscriptions, AfdianPaymentHandler afdian, AppDatabase db) : ControllerBase { [HttpGet] [Authorize] @@ -172,4 +173,31 @@ public class SubscriptionController(SubscriptionService subscriptions, AppDataba return BadRequest(ex.Message); } } + + public class RestorePurchaseRequest + { + [Required] public string OrderId { get; set; } = null!; + } + + [HttpPost("order/restore/afdian")] + public async Task RestorePurchaseFromAfdian([FromBody] RestorePurchaseRequest request) + { + var order = await afdian.GetOrderAsync(request.OrderId); + if (order is null) return NotFound($"Order with ID {request.OrderId} was not found."); + + var subscription = await subscriptions.CreateSubscriptionFromOrder(order); + return Ok(subscription); + } + + [HttpPost("order/handle/afdian")] + public async Task AfdianWebhook() + { + var response = await afdian.HandleWebhook(Request, async (webhookData) => + { + var order = webhookData.Order; + await subscriptions.CreateSubscriptionFromOrder(order); + }); + + return Ok(response); + } } \ No newline at end of file diff --git a/DysonNetwork.Sphere/Wallet/SubscriptionRenewalJob.cs b/DysonNetwork.Sphere/Wallet/SubscriptionRenewalJob.cs index 2ef632f..ad74bcb 100644 --- a/DysonNetwork.Sphere/Wallet/SubscriptionRenewalJob.cs +++ b/DysonNetwork.Sphere/Wallet/SubscriptionRenewalJob.cs @@ -152,16 +152,15 @@ public class SubscriptionRenewalJob( var validSubscriptions = await db.WalletSubscriptions .Where(s => membershipIds.Contains(s.Id)) .Where(s => s.IsActive) - .Where(s => !s.EndedAt.HasValue || s.EndedAt.Value > now) - .Select(s => s.Id) .ToListAsync(); + var validSubscriptionsId = validSubscriptions + .Where(s => s.IsAvailable) + .Select(s => s.Id) + .ToList(); // Identify accounts that need updating (membership expired or not in validSubscriptions) var accountIdsToUpdate = accountsWithMemberships - .Where(a => a.StellarMembership != null && ( - (a.StellarMembership.EndedAt.HasValue && a.StellarMembership.EndedAt.Value <= now) || - !validSubscriptions.Contains(a.StellarMembership.Id) - )) + .Where(a => a.StellarMembership != null && !validSubscriptionsId.Contains(a.StellarMembership.Id)) .Select(a => a.Id) .ToList(); diff --git a/DysonNetwork.Sphere/Wallet/SubscriptionService.cs b/DysonNetwork.Sphere/Wallet/SubscriptionService.cs index 9862bb8..f21fe7b 100644 --- a/DysonNetwork.Sphere/Wallet/SubscriptionService.cs +++ b/DysonNetwork.Sphere/Wallet/SubscriptionService.cs @@ -1,11 +1,19 @@ using System.Text.Json; +using DysonNetwork.Sphere.Account; using DysonNetwork.Sphere.Storage; +using DysonNetwork.Sphere.Wallet.PaymentHandlers; using Microsoft.EntityFrameworkCore; using NodaTime; namespace DysonNetwork.Sphere.Wallet; -public class SubscriptionService(AppDatabase db, PaymentService payment, ICacheService cache) +public class SubscriptionService( + AppDatabase db, + PaymentService payment, + AccountService accounts, + IConfiguration configuration, + ICacheService cache +) { public async Task CreateSubscriptionAsync( Account.Account account, @@ -77,6 +85,85 @@ public class SubscriptionService(AppDatabase db, PaymentService payment, ICacheS return subscription; } + public async Task CreateSubscriptionFromOrder(ISubscriptionOrder order) + { + var cfgSection = configuration.GetSection("Payment:Subscriptions"); + var provider = order.Provider; + + var currency = "irl"; + var subscriptionIdentifier = order.SubscriptionId; + switch (provider) + { + case "afdian": + var afdianPlans = cfgSection.GetValue>("Afdian"); + var afdianPlan = afdianPlans?.FirstOrDefault(p => p.Value == subscriptionIdentifier); + if (afdianPlan?.Key is not null) subscriptionIdentifier = afdianPlan.Value.Key; + currency = "cny"; + break; + default: + break; + } + + var subscriptionTemplate = SubscriptionTypeData + .SubscriptionDict.TryGetValue(subscriptionIdentifier, out var template) + ? template + : null; + if (subscriptionTemplate is null) + throw new ArgumentOutOfRangeException(nameof(subscriptionIdentifier), $@"Subscription {subscriptionIdentifier} was not found."); + + Account.Account? account = null; + if (!string.IsNullOrEmpty(provider)) + account = await accounts.LookupAccountByConnection(order.AccountId, provider); + else if (Guid.TryParse(order.AccountId, out var accountId)) + account = await db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId); + + if (account is null) + throw new InvalidOperationException($"Account was not found with identifier {order.AccountId}"); + + var cycleDuration = order.Duration; + + var existingSubscription = await GetSubscriptionAsync(account.Id, subscriptionIdentifier); + if (existingSubscription is not null && existingSubscription.PaymentMethod != provider) + throw new InvalidOperationException($"Active subscription with identifier {subscriptionIdentifier} already exists."); + if (existingSubscription?.PaymentDetails.OrderId == order.Id) + return existingSubscription; + if (existingSubscription is not null) + { + // Same provider, but different order, renew the subscription + existingSubscription.PaymentDetails.OrderId = order.Id; + existingSubscription.EndedAt = order.BegunAt.Plus(cycleDuration); + existingSubscription.RenewalAt = order.BegunAt.Plus(cycleDuration); + existingSubscription.Status = SubscriptionStatus.Paid; + + db.Update(existingSubscription); + await db.SaveChangesAsync(); + + return existingSubscription; + } + + var subscription = new Subscription + { + BegunAt = order.BegunAt, + EndedAt = order.BegunAt.Plus(cycleDuration), + IsActive = true, + Status = SubscriptionStatus.Unpaid, + PaymentMethod = provider, + PaymentDetails = new PaymentDetails + { + Currency = currency, + OrderId = order.Id, + }, + BasePrice = subscriptionTemplate.BasePrice, + RenewalAt = order.BegunAt.Plus(cycleDuration), + AccountId = account.Id, + }; + + db.WalletSubscriptions.Add(subscription); + await db.SaveChangesAsync(); + + return subscription; + } + /// /// Cancel the renewal of the current activated subscription. /// @@ -218,6 +305,8 @@ public class SubscriptionService(AppDatabase db, PaymentService payment, ICacheS private const string SubscriptionCacheKeyPrefix = "subscription:"; + public AccountService Accounts { get; } = accounts; + public async Task GetSubscriptionAsync(Guid accountId, string identifier) { // Create a unique cache key for this subscription @@ -244,4 +333,4 @@ public class SubscriptionService(AppDatabase db, PaymentService payment, ICacheS return subscription; } -} \ No newline at end of file +} diff --git a/DysonNetwork.Sphere/appsettings.json b/DysonNetwork.Sphere/appsettings.json index 88cdb29..347eca2 100644 --- a/DysonNetwork.Sphere/appsettings.json +++ b/DysonNetwork.Sphere/appsettings.json @@ -101,6 +101,18 @@ "DiscoveryEndpoint": "YOUR_MICROSOFT_DISCOVERY_ENDPOINT" } }, + "Payment": { + "Auth": { + "Afdian": "" + }, + "Subscriptions": { + "Afdian": { + "solian.stellar.primary": "7d17aae23c9611f0b5705254001e7c00", + "solian.stellar.nova": "7dfae4743c9611f0b3a55254001e7c00", + "solian.stellar.supernova": "141713ee3d6211f085b352540025c377" + } + } + }, "KnownProxies": [ "127.0.0.1", "::1"