using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Meezi.API.Configuration; using Meezi.Core.Constants; using Meezi.Core.Enums; using Meezi.Core.Interfaces; using Meezi.Core.Platform; using Meezi.Infrastructure.Data; using Meezi.Infrastructure.Services.Platform; using StackExchange.Redis; namespace Meezi.API.Services; public record MenuAi3dUsageDto(int Used, int Limit, string Period); public record MenuAi3dGenerateResultDto(string Model3dUrl, int Used, int Limit); public interface IMenuAi3dGenerationService { Task GetUsageAsync(string cafeId, PlanTier planTier, CancellationToken cancellationToken = default); Task<(MenuAi3dGenerateResultDto? Data, string? ErrorCode, string? Message)> GenerateFromItemImageAsync( string cafeId, string itemId, PlanTier planTier, CancellationToken cancellationToken = default); } public class MenuAi3dGenerationService : IMenuAi3dGenerationService { private const string FeatureMenu3d = "menu_3d"; private const string FeatureMenu3dAi = "menu_3d_ai"; private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; private readonly AppDbContext _db; private readonly IPlatformCatalogService _catalog; private readonly IMediaStorageService _media; private readonly IConnectionMultiplexer _redis; private readonly IHttpClientFactory _httpClientFactory; private readonly IPlatformRuntimeConfig _platform; private readonly MenuAi3dOptions _options; private readonly IConfiguration _configuration; private readonly IWebHostEnvironment _env; private readonly ILogger _logger; public MenuAi3dGenerationService( AppDbContext db, IPlatformCatalogService catalog, IMediaStorageService media, IConnectionMultiplexer redis, IHttpClientFactory httpClientFactory, IPlatformRuntimeConfig platform, IOptions options, IConfiguration configuration, IWebHostEnvironment env, ILogger logger) { _db = db; _catalog = catalog; _media = media; _redis = redis; _httpClientFactory = httpClientFactory; _platform = platform; _options = options.Value; _configuration = configuration; _env = env; _logger = logger; } public async Task GetUsageAsync( string cafeId, PlanTier planTier, CancellationToken cancellationToken = default) { var limit = await ResolveLimitAsync(cafeId, planTier, cancellationToken); var used = await GetUsedCountAsync(cafeId); return new MenuAi3dUsageDto(used, limit, DateTime.UtcNow.ToString("yyyy-MM")); } public async Task<(MenuAi3dGenerateResultDto? Data, string? ErrorCode, string? Message)> GenerateFromItemImageAsync( string cafeId, string itemId, PlanTier planTier, CancellationToken cancellationToken = default) { if (!await _catalog.IsFeatureEnabledForCafeAsync(cafeId, planTier, FeatureMenu3d, cancellationToken)) return (null, "PLAN_FEATURE_DISABLED", "3D menu is not included in your plan."); if (!await _catalog.IsFeatureEnabledForCafeAsync(cafeId, planTier, FeatureMenu3dAi, cancellationToken)) return (null, "PLAN_FEATURE_DISABLED", "AI 3D generation requires Business plan or higher."); var limit = await ResolveLimitAsync(cafeId, planTier, cancellationToken); if (limit <= 0) return (null, "PLAN_FEATURE_DISABLED", "AI 3D generation is not available on your plan."); var used = await GetUsedCountAsync(cafeId); if (used >= limit) return (null, "PLAN_LIMIT_REACHED", "Monthly AI 3D generation limit reached (100)."); var item = await _db.MenuItems.FirstOrDefaultAsync( i => i.CafeId == cafeId && i.Id == itemId, cancellationToken); if (item is null) return (null, "NOT_FOUND", "Menu item not found."); if (string.IsNullOrWhiteSpace(item.ImageUrl)) return (null, "NO_IMAGE", "Upload a product photo before generating a 3D model."); var imageUrl = ResolvePublicUrl(item.ImageUrl.Trim()); byte[] glbBytes; try { glbBytes = await GenerateGlbBytesAsync(imageUrl, cancellationToken); } catch (Exception ex) { _logger.LogWarning(ex, "AI 3D generation failed for cafe {CafeId} item {ItemId}", cafeId, itemId); return (null, "AI_GENERATION_FAILED", "Could not generate 3D model. Try again later."); } var modelUrl = await _media.SaveMenuModel3dFromBytesAsync(cafeId, glbBytes, cancellationToken); if (modelUrl is null) return (null, "INVALID_FILE", "Generated model could not be saved."); item.Model3dUrl = modelUrl; await _db.SaveChangesAsync(cancellationToken); var newUsed = await IncrementUsageAsync(cafeId); return (new MenuAi3dGenerateResultDto(modelUrl, newUsed, limit), null, null); } private async Task ResolveLimitAsync(string cafeId, PlanTier planTier, CancellationToken cancellationToken) { if (!await _catalog.IsFeatureEnabledForCafeAsync(cafeId, planTier, FeatureMenu3dAi, cancellationToken)) return 0; return (await _catalog.GetLimitsAsync(planTier, cancellationToken)).MaxMenuAi3dPerMonth; } private static string UsageKey(string cafeId) => $"ai3d:usage:{cafeId}:{DateTime.UtcNow:yyyy-MM}"; private async Task GetUsedCountAsync(string cafeId) { var redis = _redis.GetDatabase(); var val = await redis.StringGetAsync(UsageKey(cafeId)); return val.HasValue && int.TryParse(val.ToString(), out var n) ? n : 0; } private async Task IncrementUsageAsync(string cafeId) { var redis = _redis.GetDatabase(); var key = UsageKey(cafeId); var next = (int)await redis.StringIncrementAsync(key); if (next == 1) { var endOfMonth = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc) .AddMonths(1); await redis.KeyExpireAsync(key, endOfMonth - DateTime.UtcNow); } return next; } private string ResolvePublicUrl(string url) { if (url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) return url; var baseUrl = _configuration["App:PublicBaseUrl"]?.TrimEnd('/') ?? "http://localhost:5080"; return url.StartsWith('/') ? $"{baseUrl}{url}" : $"{baseUrl}/{url}"; } private async Task GenerateGlbBytesAsync(string imageUrl, CancellationToken cancellationToken) { var apiKey = await ResolveMeshyApiKeyAsync(cancellationToken); if (!string.IsNullOrWhiteSpace(apiKey)) return await GenerateViaMeshyAsync(imageUrl, apiKey, cancellationToken); if (_options.AllowDevStub && _env.IsDevelopment()) return DevStubGlbBytes(); throw new InvalidOperationException("AI 3D provider is not configured."); } private async Task ResolveMeshyApiKeyAsync(CancellationToken cancellationToken) { var menu3dOn = await _platform.GetAsync(PlatformIntegrationKeys.MeshyMenu3dEnabled, cancellationToken); if (menu3dOn is "false") return null; var enabled = await _platform.GetAsync(PlatformIntegrationKeys.MeshyEnabled, cancellationToken); if (enabled is "false") return null; var fromDb = await _platform.GetAsync(PlatformIntegrationKeys.MeshyApiKey, cancellationToken); if (!string.IsNullOrWhiteSpace(fromDb)) return fromDb.Trim(); return string.IsNullOrWhiteSpace(_options.ApiKey) ? null : _options.ApiKey.Trim(); } private async Task GenerateViaMeshyAsync( string imageUrl, string apiKey, CancellationToken cancellationToken) { var client = _httpClientFactory.CreateClient("MenuAi3d"); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); var baseUrl = _options.BaseUrl.TrimEnd('/'); var createPath = _options.ImageTo3dPath.TrimStart('/'); var createBody = JsonSerializer.Serialize(new { image_url = imageUrl }, JsonOpts); using var createReq = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl}/{createPath}") { Content = new StringContent(createBody, Encoding.UTF8, "application/json") }; using var createRes = await client.SendAsync(createReq, cancellationToken); createRes.EnsureSuccessStatusCode(); await using var createStream = await createRes.Content.ReadAsStreamAsync(cancellationToken); var createDoc = await JsonDocument.ParseAsync(createStream, cancellationToken: cancellationToken); var taskId = createDoc.RootElement.TryGetProperty("result", out var resultEl) ? resultEl.GetString() : createDoc.RootElement.TryGetProperty("id", out var idEl) ? idEl.GetString() : null; if (string.IsNullOrWhiteSpace(taskId)) throw new InvalidOperationException("AI provider did not return a task id."); var pollUrl = $"{baseUrl}/{createPath}/{taskId}"; var deadline = DateTime.UtcNow.AddSeconds(Math.Max(30, _options.PollTimeoutSeconds)); var interval = TimeSpan.FromSeconds(Math.Clamp(_options.PollIntervalSeconds, 2, 30)); while (DateTime.UtcNow < deadline) { await Task.Delay(interval, cancellationToken); using var pollRes = await client.GetAsync(pollUrl, cancellationToken); pollRes.EnsureSuccessStatusCode(); await using var pollStream = await pollRes.Content.ReadAsStreamAsync(cancellationToken); var pollDoc = await JsonDocument.ParseAsync(pollStream, cancellationToken: cancellationToken); var status = pollDoc.RootElement.TryGetProperty("status", out var statusEl) ? statusEl.GetString() : null; if (string.Equals(status, "SUCCEEDED", StringComparison.OrdinalIgnoreCase) || string.Equals(status, "COMPLETED", StringComparison.OrdinalIgnoreCase)) { var glbUrl = ExtractGlbUrl(pollDoc.RootElement); if (string.IsNullOrWhiteSpace(glbUrl)) throw new InvalidOperationException("AI provider succeeded but returned no GLB URL."); using var downloadRes = await client.GetAsync(glbUrl, cancellationToken); downloadRes.EnsureSuccessStatusCode(); return await downloadRes.Content.ReadAsByteArrayAsync(cancellationToken); } if (string.Equals(status, "FAILED", StringComparison.OrdinalIgnoreCase) || string.Equals(status, "CANCELED", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("AI provider task failed."); } throw new TimeoutException("AI 3D generation timed out."); } private static string? ExtractGlbUrl(JsonElement root) { if (root.TryGetProperty("model_urls", out var urls) && urls.TryGetProperty("glb", out var glb) && glb.ValueKind == JsonValueKind.String) return glb.GetString(); if (root.TryGetProperty("model_url", out var single) && single.ValueKind == JsonValueKind.String) return single.GetString(); return null; } /// Minimal valid GLB (empty scene) for local development without Meshy API key. private static byte[] DevStubGlbBytes() => Convert.FromBase64String( "Z2xURgIAAACI3gAQAwEAAFBLQVRGT1JNUwBCeHAEAgAqBUZsAE1BVEhQAgAgAAAAAO4AAABKQwAAAAAAAJAAAAA="); }