Files
meezi/src/Meezi.API/Services/MenuAi3dGenerationService.cs
T
soroush.asadi e46d833371
CI/CD / CI · API (dotnet build + test) (push) Successful in 43s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 31s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m4s
CI/CD / CI · Admin Web (tsc) (push) Has been cancelled
CI/CD / CI · Website (tsc) (push) Has been cancelled
CI/CD / CI · Koja (tsc) (push) Has been cancelled
CI/CD / Deploy · all services (push) Has been cancelled
feat(plans): report-history + AI-3D limits read from the catalog (S3 finish)
The last two limits that still read hardcoded PlanLimits now come from the
admin-editable catalog, so editing them in the admin panel takes effect:

- ReportPlanGate is now limit-driven (takes int maxDays, not a tier); ReportsController
  resolves MaxReportHistoryDays from catalog.GetLimitsAsync. LimitMessage is generic
  (reflects the actual days). EnsureReportDateAllowed is now async.
- MenuAi3dGenerationService.ResolveLimitAsync reads MaxMenuAi3dPerMonth from the catalog.

Every plan limit + feature gate is now DB-driven and admin-editable. 86 tests pass.
2026-06-03 06:57:59 +03:30

293 lines
12 KiB
C#

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<MenuAi3dUsageDto> 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<MenuAi3dGenerationService> _logger;
public MenuAi3dGenerationService(
AppDbContext db,
IPlatformCatalogService catalog,
IMediaStorageService media,
IConnectionMultiplexer redis,
IHttpClientFactory httpClientFactory,
IPlatformRuntimeConfig platform,
IOptions<MenuAi3dOptions> options,
IConfiguration configuration,
IWebHostEnvironment env,
ILogger<MenuAi3dGenerationService> 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<MenuAi3dUsageDto> 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<int> 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<int> 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<int> 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<byte[]> 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<string?> 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<byte[]> 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;
}
/// <summary>Minimal valid GLB (empty scene) for local development without Meshy API key.</summary>
private static byte[] DevStubGlbBytes() =>
Convert.FromBase64String(
"Z2xURgIAAACI3gAQAwEAAFBLQVRGT1JNUwBCeHAEAgAqBUZsAE1BVEhQAgAgAAAAAO4AAABKQwAAAAAAAJAAAAA=");
}