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
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.
293 lines
12 KiB
C#
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=");
|
|
}
|