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,130 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.API.Models.Public;
|
||||
using Meezi.Infrastructure.Data;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface ICoffeeAdvisorService
|
||||
{
|
||||
Task<(CoffeeAdvisorResultDto? Data, string? ErrorCode, string? Message)> RecommendAsync(
|
||||
CoffeeAdvisorRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class CoffeeAdvisorService : ICoffeeAdvisorService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IOpenAiChatService _openAi;
|
||||
private readonly ILogger<CoffeeAdvisorService> _logger;
|
||||
|
||||
public CoffeeAdvisorService(
|
||||
AppDbContext db,
|
||||
IOpenAiChatService openAi,
|
||||
ILogger<CoffeeAdvisorService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_openAi = openAi;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<(CoffeeAdvisorResultDto? Data, string? ErrorCode, string? Message)> RecommendAsync(
|
||||
CoffeeAdvisorRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var purpose = request.Purpose?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(purpose) || purpose.Length < 3)
|
||||
return (null, "INVALID_REQUEST", "Describe what you need (at least 3 characters).");
|
||||
|
||||
if (!await _openAi.IsConfiguredForCoffeeAdvisorAsync(cancellationToken))
|
||||
return (null, "AI_NOT_CONFIGURED", "Coffee advisor is not available right now.");
|
||||
|
||||
var menuLines = await LoadMenuContextAsync(request.CafeSlug, cancellationToken);
|
||||
var systemPrompt =
|
||||
"""
|
||||
You are a specialty coffee advisor for Iranian cafés. Respond ONLY with valid JSON (no markdown).
|
||||
Schema: { "summary": string (1-2 sentences in Persian), "picks": [ { "name": string, "reason": string (Persian), "menuItemId": string|null } ] }
|
||||
Rules: suggest 1-3 drinks; prefer items from the menu list when provided; match the guest's purpose (energy, relax, meeting, dessert pairing, etc.); be concise and friendly in Persian.
|
||||
""";
|
||||
var userPrompt = $"""
|
||||
Guest purpose: {purpose}
|
||||
{(menuLines.Count > 0 ? "Café menu (id | name | description):\n" + string.Join("\n", menuLines) : "No specific café menu — suggest classic café drinks.")}
|
||||
""";
|
||||
|
||||
string? json;
|
||||
try
|
||||
{
|
||||
json = await _openAi.CompleteJsonAsync(systemPrompt, userPrompt, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Coffee advisor OpenAI call failed");
|
||||
return (null, "AI_FAILED", "Could not get a recommendation. Try again later.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return (null, "AI_FAILED", "Could not get a recommendation. Try again later.");
|
||||
|
||||
try
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<AdvisorJson>(json, JsonOpts);
|
||||
if (parsed is null || string.IsNullOrWhiteSpace(parsed.Summary))
|
||||
return (null, "AI_FAILED", "Invalid advisor response.");
|
||||
|
||||
var picks = (parsed.Picks ?? [])
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p.Name))
|
||||
.Take(3)
|
||||
.Select(p => new CoffeeAdvisorPickDto(
|
||||
p.Name!.Trim(),
|
||||
p.Reason?.Trim() ?? "",
|
||||
string.IsNullOrWhiteSpace(p.MenuItemId) ? null : p.MenuItemId.Trim()))
|
||||
.ToList();
|
||||
|
||||
return (new CoffeeAdvisorResultDto(parsed.Summary.Trim(), picks), null, null);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Coffee advisor JSON parse failed");
|
||||
return (null, "AI_FAILED", "Could not parse advisor response.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<string>> LoadMenuContextAsync(string? slug, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(slug))
|
||||
return [];
|
||||
|
||||
var cafeId = await _db.Cafes.AsNoTracking()
|
||||
.Where(c => c.Slug == slug.Trim() && c.DeletedAt == null)
|
||||
.Select(c => c.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (cafeId is null)
|
||||
return [];
|
||||
|
||||
var items = await _db.MenuItems.AsNoTracking()
|
||||
.Where(i => i.CafeId == cafeId && i.IsAvailable && i.DeletedAt == null)
|
||||
.OrderBy(i => i.Name)
|
||||
.Take(40)
|
||||
.Select(i => new { i.Id, i.Name, i.Description })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return items
|
||||
.Select(i => $"{i.Id} | {i.Name} | {(string.IsNullOrWhiteSpace(i.Description) ? "-" : i.Description)}")
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private sealed class AdvisorJson
|
||||
{
|
||||
public string? Summary { get; set; }
|
||||
public List<AdvisorPickJson>? Picks { get; set; }
|
||||
}
|
||||
|
||||
private sealed class AdvisorPickJson
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
public string? MenuItemId { get; set; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user