feat(api): .NET 10 multi-tenant REST API
Full backend implementation: - Multi-tenant cafe/restaurant management (menus, orders, tables, staff) - POS order flow with ZarinPal and Snappfood payment integration - OTP authentication via Kavenegar SMS - QR digital menu with public discover/finder endpoints - Customer loyalty, coupons, CRM - PostgreSQL via EF Core, Redis for caching/sessions - Background jobs, webhook handlers - Full migration history Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
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 PlanLimits.MaxMenuAi3dPerMonth(planTier);
|
||||
}
|
||||
|
||||
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=");
|
||||
}
|
||||
Reference in New Issue
Block a user