Initial import into Gitea
This commit is contained in:
299
Gateway/Services/ChannelConfigService.cs
Normal file
299
Gateway/Services/ChannelConfigService.cs
Normal file
@@ -0,0 +1,299 @@
|
||||
using Gateway.Data;
|
||||
using Gateway.Models;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Loads channel provider configuration from the database (tbChannelConfig)
|
||||
/// instead of appsettings.json. Caches in memory and provides a populated
|
||||
/// MultiChannelConfig instance for DI consumers.
|
||||
///
|
||||
/// Why: Complex nested JSON in appsettings.json was causing startup crashes
|
||||
/// with the .NET configuration parser. Database-driven config is also easier
|
||||
/// to update without redeployment.
|
||||
///
|
||||
/// Usage:
|
||||
/// - Called once at startup to populate the singleton MultiChannelConfig
|
||||
/// - Admin endpoints can call RefreshAsync() to reload after DB changes
|
||||
/// </summary>
|
||||
public sealed class ChannelConfigService
|
||||
{
|
||||
private readonly IServiceProvider _sp;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly ILogger<ChannelConfigService> _log;
|
||||
private MultiChannelConfig _cached;
|
||||
private readonly object _lock = new();
|
||||
|
||||
public ChannelConfigService(
|
||||
IServiceProvider sp,
|
||||
IConfiguration cfg,
|
||||
ILogger<ChannelConfigService> log)
|
||||
{
|
||||
_sp = sp;
|
||||
_cfg = cfg;
|
||||
_log = log;
|
||||
_cached = BuildDefaults();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The current in-memory config. Always non-null (falls back to defaults).
|
||||
/// </summary>
|
||||
public MultiChannelConfig Current
|
||||
{
|
||||
get { lock (_lock) { return _cached; } }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load channel config from the database.
|
||||
/// Call at startup and whenever admin updates channel config.
|
||||
/// </summary>
|
||||
public async Task LoadAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _sp.CreateScope();
|
||||
var sql = scope.ServiceProvider.GetRequiredService<SqlService>();
|
||||
|
||||
var resp = await sql.ExecProcAsync(
|
||||
SqlNames.Procs.ChannelConfig, "list", "{}", ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
{
|
||||
_log.LogWarning("[ChannelConfig] DB returned empty — using defaults");
|
||||
return;
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("ok", out var okProp) || !okProp.GetBoolean())
|
||||
{
|
||||
_log.LogWarning("[ChannelConfig] DB returned ok=false — using defaults");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!root.TryGetProperty("channels", out var channelsEl) ||
|
||||
channelsEl.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
_log.LogWarning("[ChannelConfig] No channels array in response — using defaults");
|
||||
return;
|
||||
}
|
||||
|
||||
var channels = new Dictionary<string, ProviderConfig>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var ch in channelsEl.EnumerateArray())
|
||||
{
|
||||
var config = ParseChannel(ch);
|
||||
if (config != null)
|
||||
channels[config.ChannelType] = config;
|
||||
}
|
||||
|
||||
// Build new MultiChannelConfig with DB channels + appsettings allocation
|
||||
var newConfig = new MultiChannelConfig
|
||||
{
|
||||
Channels = channels,
|
||||
Allocation = LoadAllocationFromConfig()
|
||||
};
|
||||
|
||||
lock (_lock) { _cached = newConfig; }
|
||||
|
||||
_log.LogInformation(
|
||||
"[ChannelConfig] Loaded {Count} channels from DB: {Types}",
|
||||
channels.Count,
|
||||
string.Join(", ", channels.Keys));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[ChannelConfig] Failed to load from DB — using defaults");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reload config from DB (for admin refresh endpoints).</summary>
|
||||
public Task RefreshAsync(CancellationToken ct = default) => LoadAsync(ct);
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Parsing
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
private static ProviderConfig? ParseChannel(JsonElement ch)
|
||||
{
|
||||
var channelType = Str(ch, "channelType");
|
||||
var displayName = Str(ch, "displayName");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(channelType) || string.IsNullOrWhiteSpace(displayName))
|
||||
return null;
|
||||
|
||||
return new ProviderConfig
|
||||
{
|
||||
ChannelType = channelType,
|
||||
DisplayName = displayName,
|
||||
Description = Str(ch, "description"),
|
||||
Icon = Str(ch, "icon"),
|
||||
Color = Str(ch, "color"),
|
||||
Enabled = Bool(ch, "enabled", true),
|
||||
IsStub = Bool(ch, "isStub", true),
|
||||
Endpoint = Str(ch, "endpoint"),
|
||||
InternalKey = Str(ch, "internalKey"),
|
||||
MinDailyBudget = Dec(ch, "minDailyBudget", 5m),
|
||||
MinMonthlyBudget = Dec(ch, "minMonthlyBudget", 150m),
|
||||
SupportedObjectives = StringList(ch, "supportedObjectives"),
|
||||
SupportedCreativeFormats = StringList(ch, "supportedCreativeFormats"),
|
||||
ApprovalEstimateHours = Int(ch, "approvalEstimateHours", 24),
|
||||
MetricsRefreshIntervalMinutes = Int(ch, "metricsRefreshIntervalMinutes", 60),
|
||||
AuthMethod = Str(ch, "authMethod"),
|
||||
KeyVaultSecretName = Str(ch, "keyVaultSecretName"),
|
||||
StatusMappings = StringDict(ch, "statusMappings")
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Allocation (stays in appsettings — simple scalars)
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
private AllocationSettings LoadAllocationFromConfig()
|
||||
{
|
||||
var section = _cfg.GetSection("MultiChannel:Allocation");
|
||||
if (!section.Exists())
|
||||
return new AllocationSettings();
|
||||
|
||||
return new AllocationSettings
|
||||
{
|
||||
MinMultiChannelMonthlyBudget = section.GetValue("MinMultiChannelMonthlyBudget", 500.00m),
|
||||
MaxChannelsPerInitiative = section.GetValue("MaxChannelsPerInitiative", 5),
|
||||
DefaultAllocationStrategy = section.GetValue("DefaultAllocationStrategy", "template") ?? "template",
|
||||
PerformanceEvalIntervalDays = section.GetValue("PerformanceEvalIntervalDays", 7),
|
||||
PerformanceLookbackDays = section.GetValue("PerformanceLookbackDays", 14),
|
||||
PerformanceLearningPeriodDays = section.GetValue("PerformanceLearningPeriodDays", 14),
|
||||
MaxAllocationShiftPct = section.GetValue("MaxAllocationShiftPct", 15.00m),
|
||||
MinChannelAllocationPct = section.GetValue("MinChannelAllocationPct", 10.00m),
|
||||
MaxChannelAllocationPct = section.GetValue("MaxChannelAllocationPct", 80.00m)
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Defaults (used until DB load completes or if DB is unavailable)
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
private MultiChannelConfig BuildDefaults()
|
||||
{
|
||||
return new MultiChannelConfig
|
||||
{
|
||||
Channels = new Dictionary<string, ProviderConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["google_ads"] = new()
|
||||
{
|
||||
ChannelType = "google_ads",
|
||||
DisplayName = "Google Ads",
|
||||
Description = "Search, Display, Shopping & Performance Max across Google properties",
|
||||
Icon = "google",
|
||||
Color = "#4285F4",
|
||||
Enabled = true,
|
||||
IsStub = false,
|
||||
MinDailyBudget = 10m,
|
||||
MinMonthlyBudget = 300m,
|
||||
SupportedObjectives = new() { "awareness", "traffic", "conversions", "leads", "sales" },
|
||||
SupportedCreativeFormats = new() { "text", "image", "responsive", "video" },
|
||||
ApprovalEstimateHours = 24,
|
||||
AuthMethod = "mcc"
|
||||
},
|
||||
["meta"] = new()
|
||||
{
|
||||
ChannelType = "meta",
|
||||
DisplayName = "Meta Ads",
|
||||
Description = "Facebook, Instagram, Messenger & Threads advertising",
|
||||
Icon = "meta",
|
||||
Color = "#1877F2",
|
||||
Enabled = true,
|
||||
IsStub = true,
|
||||
MinDailyBudget = 5m,
|
||||
MinMonthlyBudget = 250m,
|
||||
SupportedObjectives = new() { "awareness", "traffic", "conversions", "leads", "sales" },
|
||||
SupportedCreativeFormats = new() { "image", "video", "carousel", "stories" },
|
||||
ApprovalEstimateHours = 48,
|
||||
AuthMethod = "oauth2"
|
||||
},
|
||||
["tiktok"] = new()
|
||||
{
|
||||
ChannelType = "tiktok",
|
||||
DisplayName = "TikTok Ads",
|
||||
Description = "In-feed video ads across TikTok and partner apps",
|
||||
Icon = "tiktok",
|
||||
Color = "#000000",
|
||||
Enabled = true,
|
||||
IsStub = true,
|
||||
MinDailyBudget = 20m,
|
||||
MinMonthlyBudget = 200m,
|
||||
SupportedObjectives = new() { "awareness", "traffic", "conversions", "leads", "sales" },
|
||||
SupportedCreativeFormats = new() { "video", "image", "spark_ads" },
|
||||
ApprovalEstimateHours = 24,
|
||||
AuthMethod = "oauth2"
|
||||
}
|
||||
},
|
||||
Allocation = new AllocationSettings()
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// JSON helpers
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
private static string? Str(JsonElement el, string prop)
|
||||
{
|
||||
if (el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String)
|
||||
return v.GetString();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool Bool(JsonElement el, string prop, bool def = false)
|
||||
{
|
||||
if (el.TryGetProperty(prop, out var v))
|
||||
{
|
||||
if (v.ValueKind == JsonValueKind.True) return true;
|
||||
if (v.ValueKind == JsonValueKind.False) return false;
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
private static int Int(JsonElement el, string prop, int def = 0)
|
||||
{
|
||||
if (el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.Number)
|
||||
return v.GetInt32();
|
||||
return def;
|
||||
}
|
||||
|
||||
private static decimal Dec(JsonElement el, string prop, decimal def = 0m)
|
||||
{
|
||||
if (el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.Number)
|
||||
return v.GetDecimal();
|
||||
return def;
|
||||
}
|
||||
|
||||
private static List<string> StringList(JsonElement el, string prop)
|
||||
{
|
||||
var list = new List<string>();
|
||||
if (el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in v.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.String)
|
||||
list.Add(item.GetString()!);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> StringDict(JsonElement el, string prop)
|
||||
{
|
||||
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var p in v.EnumerateObject())
|
||||
{
|
||||
if (p.Value.ValueKind == JsonValueKind.String)
|
||||
dict[p.Name] = p.Value.GetString()!;
|
||||
}
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user