feat:Initial commit

This commit is contained in:
elar1s
2025-09-25 22:21:41 +03:00
commit 02934b1fd9
35 changed files with 1267 additions and 0 deletions

16
.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
/StoreService/obj/project.assets.json
# Default ignored files
/shelf/
/workspace.xml
# Rider ignored files
/contentModel.xml
/projectSettingsUpdater.xml
/modules.xml
/.idea.LCT2025.iml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
*.xml
*.cache

16
LCT2025.sln Normal file
View File

@@ -0,0 +1,16 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StoreService", "StoreService\StoreService.csproj", "{5DC91A89-84D9-4C5E-9539-43757A2474D5}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{5DC91A89-84D9-4C5E-9539-43757A2474D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5DC91A89-84D9-4C5E-9539-43757A2474D5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5DC91A89-84D9-4C5E-9539-43757A2474D5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5DC91A89-84D9-4C5E-9539-43757A2474D5}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,76 @@
using Microsoft.AspNetCore.Mvc;
using StoreService.Models;
using StoreService.Services;
namespace StoreService.Controllers;
/// <summary>
/// Endpoints for order lifecycle: create, query, pay and redeem.
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
#region Fields
private readonly IOrderService _orderService;
#endregion
#region Ctor
public OrdersController(IOrderService orderService)
{
_orderService = orderService;
}
#endregion
#region Endpoints
/// <summary>
/// Creates a new order for specified user and store items. Prices are calculated with active discounts.
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(OrderDto), StatusCodes.Status201Created)]
public async Task<IActionResult> Create([FromBody] CreateOrderRequest request, CancellationToken ct)
{
var order = await _orderService.CreateOrderAsync(request, ct);
return CreatedAtAction(nameof(Get), new { id = order.Id }, order);
}
/// <summary>
/// Retrieves an order by id.
/// </summary>
[HttpGet("{id:long}")]
[ProducesResponseType(typeof(OrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Get([FromRoute] long id, CancellationToken ct)
{
var order = await _orderService.GetOrderAsync(id, ct);
return order == null ? NotFound() : Ok(order);
}
/// <summary>
/// Pays (confirms) an order. Sets the paid date. Idempotent except it fails if already paid.
/// </summary>
[HttpPost("{id:long}/pay")]
[ProducesResponseType(typeof(OrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Pay([FromRoute] long id, CancellationToken ct)
{
var order = await _orderService.PayOrderAsync(id, ct);
return Ok(order);
}
/// <summary>
/// Marks items as redeemed (granted to user inventory) after payment.
/// </summary>
[HttpPost("{id:long}/redeem")]
[ProducesResponseType(typeof(OrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Redeem([FromRoute] long id, CancellationToken ct)
{
var order = await _orderService.RedeemOrderItemsAsync(id, ct);
return Ok(order);
}
#endregion
}

View File

@@ -0,0 +1,127 @@
using Microsoft.EntityFrameworkCore;
using StoreService.Database.Entities;
namespace StoreService.Database;
/// <summary>
/// Entity Framework Core database context for the Store microservice.
/// Defines DbSets and configures entity relationships & constraints.
/// </summary>
public class ApplicationContext : DbContext
{
#region Ctor
public ApplicationContext(DbContextOptions<ApplicationContext> options) : base(options)
{
}
#endregion
#region DbSets
public DbSet<StoreCategory> StoreCategories => Set<StoreCategory>();
public DbSet<StoreItem> StoreItems => Set<StoreItem>();
public DbSet<StoreDiscount> StoreDiscounts => Set<StoreDiscount>();
public DbSet<StoreDiscountItem> StoreDiscountItems => Set<StoreDiscountItem>();
public DbSet<StoreOrder> StoreOrders => Set<StoreOrder>();
public DbSet<StoreOrderItem> StoreOrderItems => Set<StoreOrderItem>();
public DbSet<StoreOrderItemDiscount> StoreOrderItemDiscounts => Set<StoreOrderItemDiscount>();
#endregion
#region ModelCreating
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// store_category
modelBuilder.Entity<StoreCategory>(b =>
{
b.ToTable("store_category");
b.HasKey(x => x.Id);
b.Property(x => x.Title).HasMaxLength(256).IsRequired();
});
// store_item
modelBuilder.Entity<StoreItem>(b =>
{
b.ToTable("store_item");
b.HasKey(x => x.Id);
b.Property(x => x.ManaBuyPrice).IsRequired();
b.Property(x => x.ManaSellPrice).IsRequired();
b.Property(x => x.InventoryLimit).IsRequired();
b.Property(x => x.UnlimitedPurchase).HasDefaultValue(false);
b.HasOne(x => x.Category)
.WithMany(c => c.Items)
.HasForeignKey(x => x.StoreCategoryId)
.OnDelete(DeleteBehavior.Restrict);
});
// store_discount
modelBuilder.Entity<StoreDiscount>(b =>
{
b.ToTable("store_discount");
b.HasKey(x => x.Id);
b.Property(x => x.Percentage).HasPrecision(5, 2); // up to 100.00
b.Property(x => x.FromDate).IsRequired();
b.Property(x => x.UntilDate).IsRequired();
b.Property(x => x.IsCanceled).HasDefaultValue(false);
});
// store_discount_item (many-to-many manual mapping)
modelBuilder.Entity<StoreDiscountItem>(b =>
{
b.ToTable("store_discount_item");
b.HasKey(x => x.Id);
b.HasOne(x => x.Discount)
.WithMany(d => d.DiscountItems)
.HasForeignKey(x => x.StoreDiscountId)
.OnDelete(DeleteBehavior.Cascade);
b.HasOne(x => x.StoreItem)
.WithMany(i => i.DiscountItems)
.HasForeignKey(x => x.StoreItemId)
.OnDelete(DeleteBehavior.Cascade);
b.HasIndex(x => new { x.StoreDiscountId, x.StoreItemId }).IsUnique();
});
// store_order
modelBuilder.Entity<StoreOrder>(b =>
{
b.ToTable("store_order");
b.HasKey(x => x.Id);
b.Property(x => x.UserId).IsRequired();
b.Property(x => x.CostUpdateDate).IsRequired();
b.Property(x => x.ItemsRedeemed).HasDefaultValue(false);
});
// store_order_item
modelBuilder.Entity<StoreOrderItem>(b =>
{
b.ToTable("store_order_item");
b.HasKey(x => x.Id);
b.Property(x => x.CalculatedPrice).IsRequired();
b.HasOne(x => x.Order)
.WithMany(o => o.OrderItems)
.HasForeignKey(x => x.StoreOrderId)
.OnDelete(DeleteBehavior.Cascade);
b.HasOne(x => x.StoreItem)
.WithMany(i => i.OrderItems)
.HasForeignKey(x => x.StoreItemId)
.OnDelete(DeleteBehavior.Restrict);
});
// store_order_item_discount
modelBuilder.Entity<StoreOrderItemDiscount>(b =>
{
b.ToTable("store_order_item_discount");
b.HasKey(x => x.Id);
b.HasOne(x => x.OrderItem)
.WithMany(oi => oi.AppliedDiscounts)
.HasForeignKey(x => x.StoreOrderItemId)
.OnDelete(DeleteBehavior.Cascade);
b.HasOne(x => x.Discount)
.WithMany(d => d.OrderItemDiscounts)
.HasForeignKey(x => x.StoreDiscountId)
.OnDelete(DeleteBehavior.Restrict);
b.HasIndex(x => new { x.StoreOrderItemId, x.StoreDiscountId }).IsUnique();
});
}
#endregion
}

View File

@@ -0,0 +1,17 @@
namespace StoreService.Database.Entities;
/// <summary>
/// Category grouping store items.
/// </summary>
public class StoreCategory
{
#region Properties
public long Id { get; set; }
public string Title { get; set; } = string.Empty;
#endregion
#region Navigation
public ICollection<StoreItem> Items { get; set; } = new List<StoreItem>();
#endregion
}

View File

@@ -0,0 +1,36 @@
using System.Diagnostics.CodeAnalysis;
namespace StoreService.Database.Entities;
/// <summary>
/// Percentage discount that can apply to one or more store items within a time window.
/// </summary>
public class StoreDiscount
{
#region Properties
public long Id { get; set; }
public decimal Percentage { get; set; } // 0 - 100 (%)
public string? Description { get; set; }
public DateTime FromDate { get; set; }
public DateTime UntilDate { get; set; }
public bool IsCanceled { get; set; }
#endregion
#region Navigation
public ICollection<StoreDiscountItem> DiscountItems { get; set; } = new List<StoreDiscountItem>();
public ICollection<StoreOrderItemDiscount> OrderItemDiscounts { get; set; } = new List<StoreOrderItemDiscount>();
#endregion
#region Helpers
/// <summary>
/// Checks whether discount is active at provided moment (default: now) ignoring cancellation flag (if canceled returns false).
/// </summary>
public bool IsActive(DateTime? at = null)
{
if (IsCanceled) return false;
var moment = at ?? DateTime.UtcNow;
return moment >= FromDate && moment <= UntilDate;
}
#endregion
}

View File

@@ -0,0 +1,19 @@
namespace StoreService.Database.Entities;
/// <summary>
/// Join entity linking discounts to store items.
/// </summary>
public class StoreDiscountItem
{
#region Properties
public long Id { get; set; }
public long StoreDiscountId { get; set; }
public long StoreItemId { get; set; }
#endregion
#region Navigation
public StoreDiscount? Discount { get; set; }
public StoreItem? StoreItem { get; set; }
#endregion
}

View File

@@ -0,0 +1,25 @@
namespace StoreService.Database.Entities;
/// <summary>
/// Store item with pricing and purchase rules.
/// </summary>
public class StoreItem
{
#region Properties
public long Id { get; set; }
public long ItemId { get; set; } // FK to external Item service (not modeled here)
public long StoreCategoryId { get; set; } // FK to StoreCategory
public long? RankId { get; set; } // Minimum rank required to buy (external system)
public int ManaBuyPrice { get; set; }
public int ManaSellPrice { get; set; }
public bool UnlimitedPurchase { get; set; }
public int InventoryLimit { get; set; }
#endregion
#region Navigation
public StoreCategory? Category { get; set; }
public ICollection<StoreDiscountItem> DiscountItems { get; set; } = new List<StoreDiscountItem>();
public ICollection<StoreOrderItem> OrderItems { get; set; } = new List<StoreOrderItem>();
#endregion
}

View File

@@ -0,0 +1,20 @@
namespace StoreService.Database.Entities;
/// <summary>
/// Represents a purchase order created by a user.
/// </summary>
public class StoreOrder
{
#region Properties
public long Id { get; set; }
public long UserId { get; set; }
public DateTime CostUpdateDate { get; set; } = DateTime.UtcNow; // updated when prices recalculated
public DateTime? PaidDate { get; set; } // when payment succeeded
public bool ItemsRedeemed { get; set; } // becomes true once items granted to inventory
#endregion
#region Navigation
public ICollection<StoreOrderItem> OrderItems { get; set; } = new List<StoreOrderItem>();
#endregion
}

View File

@@ -0,0 +1,21 @@
namespace StoreService.Database.Entities;
/// <summary>
/// Item line inside an order with captured calculated price for history.
/// </summary>
public class StoreOrderItem
{
#region Properties
public long Id { get; set; }
public long StoreOrderId { get; set; }
public long StoreItemId { get; set; }
public int CalculatedPrice { get; set; }
#endregion
#region Navigation
public StoreOrder? Order { get; set; }
public StoreItem? StoreItem { get; set; }
public ICollection<StoreOrderItemDiscount> AppliedDiscounts { get; set; } = new List<StoreOrderItemDiscount>();
#endregion
}

View File

@@ -0,0 +1,19 @@
namespace StoreService.Database.Entities;
/// <summary>
/// Captures which discounts were applied to order items at purchase time.
/// </summary>
public class StoreOrderItemDiscount
{
#region Properties
public long Id { get; set; }
public long StoreOrderItemId { get; set; }
public long? StoreDiscountId { get; set; } // can be null if discount later removed but kept for history
#endregion
#region Navigation
public StoreOrderItem? OrderItem { get; set; }
public StoreDiscount? Discount { get; set; }
#endregion
}

View File

@@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
using StoreService.Database;
namespace StoreService.Extensions;
/// <summary>
/// Database related DI registrations.
/// </summary>
public static class DatabaseExtensions
{
/// <summary>
/// Registers the EF Core DbContext (PostgreSQL).
/// </summary>
public static IServiceCollection AddDatabase(this IServiceCollection services, IConfiguration configuration)
{
services.AddDbContext<ApplicationContext>(options =>
options.UseNpgsql(configuration.GetConnectionString("StoreDb")));
return services;
}
}

View File

@@ -0,0 +1,16 @@
using StoreService.Services;
namespace StoreService.Extensions;
/// <summary>
/// Domain service layer DI registrations.
/// </summary>
public static class DomainServicesExtensions
{
public static IServiceCollection AddDomainServices(this IServiceCollection services)
{
services.AddScoped<IOrderService, OrderService>();
return services;
}
}

View File

@@ -0,0 +1,24 @@
using Serilog;
namespace StoreService.Extensions;
/// <summary>
/// Logging related extensions (Serilog configuration).
/// </summary>
public static class LoggingExtensions
{
/// <summary>
/// Adds Serilog configuration for the host using appsettings.json (Serilog section).
/// </summary>
public static IHostBuilder AddSerilogLogging(this IHostBuilder hostBuilder)
{
hostBuilder.UseSerilog((ctx, services, cfg) =>
{
cfg.ReadFrom.Configuration(ctx.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext();
});
return hostBuilder;
}
}

View File

@@ -0,0 +1,18 @@
using StoreService.Repositories;
namespace StoreService.Extensions;
/// <summary>
/// Repository & UnitOfWork registrations.
/// </summary>
public static class RepositoryExtensions
{
public static IServiceCollection AddRepositories(this IServiceCollection services)
{
// Open generic registration for repositories
services.AddScoped(typeof(IGenericRepository<>), typeof(GenericRepository<>));
// Unit of work (will receive repositories via constructor injection)
services.AddScoped<IUnitOfWork, UnitOfWork>();
return services;
}
}

View File

@@ -0,0 +1,79 @@
using System.Net;
using System.Text.Json;
using System.ComponentModel.DataAnnotations;
namespace StoreService.Middleware;
/// <summary>
/// Global error handling middleware capturing unhandled exceptions and returning structured JSON errors.
/// </summary>
public class ErrorHandlingMiddleware
{
#region Fields
private readonly RequestDelegate _next;
private readonly ILogger _logger;
#endregion
#region Ctor
public ErrorHandlingMiddleware(RequestDelegate next, ILogger<ErrorHandlingMiddleware > logger)
{
_next = next;
_logger = logger;
}
#endregion
#region Invoke
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
await HandleExceptionAsync(context, ex);
}
}
#endregion
#region Helpers
private async Task HandleExceptionAsync(HttpContext context, Exception ex)
{
var status = ex switch
{
ValidationException => (int)HttpStatusCode.BadRequest,
ArgumentException => (int)HttpStatusCode.BadRequest,
KeyNotFoundException => (int)HttpStatusCode.NotFound,
InvalidOperationException => StatusCodes.Status409Conflict,
_ => (int)HttpStatusCode.InternalServerError
};
var errorId = Guid.NewGuid().ToString();
if (status >= 500)
{
_logger.LogError(ex, "Unhandled server exception {ErrorId}", errorId);
}
else
{
_logger.LogWarning(ex, "Handled domain exception {ErrorId} -> {Status}", errorId, status);
}
var env = context.RequestServices.GetRequiredService<IHostEnvironment>();
var problem = new
{
traceId = context.TraceIdentifier,
errorId,
status,
message = ex.Message,
details = env.IsDevelopment() ? ex.StackTrace : null
};
context.Response.ContentType = "application/json";
context.Response.StatusCode = status;
await context.Response.WriteAsync(JsonSerializer.Serialize(problem));
}
#endregion
}

View File

@@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations;
namespace StoreService.Models;
/// <summary>
/// Request body to create a new order consisting of store item identifiers.
/// </summary>
public class CreateOrderRequest
{
/// <summary>Identifier of the user creating the order.</summary>
[Required]
public long UserId { get; set; }
/// <summary>Collection of store item ids to include (unique). Duplicates are ignored.</summary>
[Required]
[MinLength(1)]
public List<long> StoreItemIds { get; set; } = new();
}

View File

@@ -0,0 +1,15 @@
namespace StoreService.Models;
/// <summary>
/// Result DTO representing an order summary.
/// </summary>
public class OrderDto
{
public long Id { get; set; }
public long UserId { get; set; }
public DateTime CostUpdateDate { get; set; }
public DateTime? PaidDate { get; set; }
public bool ItemsRedeemed { get; set; }
public List<OrderItemDto> Items { get; set; } = new();
}

View File

@@ -0,0 +1,13 @@
namespace StoreService.Models;
/// <summary>
/// Order line DTO.
/// </summary>
public class OrderItemDto
{
public long Id { get; set; }
public long StoreItemId { get; set; }
public int CalculatedPrice { get; set; }
public List<long> AppliedDiscountIds { get; set; } = new();
}

View File

@@ -0,0 +1,3 @@
// Legacy aggregated models file kept intentionally empty after splitting into individual files.
// CreateOrderRequest, OrderDto, and OrderItemDto now reside in separate files.

63
StoreService/Program.cs Normal file
View File

@@ -0,0 +1,63 @@
using System.Reflection;
using Microsoft.EntityFrameworkCore;
using Serilog;
using StoreService.Database;
using StoreService.Middleware;
using StoreService.Repositories;
using StoreService.Services;
using StoreService.Extensions; // added for DI extension
using Newtonsoft.Json; // added
var builder = WebApplication.CreateBuilder(args);
#region Serilog
// Use new logging extension
builder.Host.AddSerilogLogging();
#endregion
#region Services
builder.Services
.AddControllers()
.AddNewtonsoftJson(options =>
{
options.SerializerSettings.NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore;
options.SerializerSettings.Formatting = Formatting.None;
options.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
var xmlFilename = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFilename);
if (File.Exists(xmlPath))
{
options.IncludeXmlComments(xmlPath, includeControllerXmlComments: true);
}
});
// Granular DI registration chain
builder.Services
.AddDatabase(builder.Configuration)
.AddRepositories()
.AddDomainServices();
#endregion
var app = builder.Build();
#region Middleware
app.UseSerilogRequestLogging();
app.UseGlobalErrorHandling();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
#endregion
app.Run();

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5141",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7238;http://localhost:5141",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,79 @@
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore;
using StoreService.Database;
namespace StoreService.Repositories;
/// <summary>
/// Generic repository implementation wrapping EF Core DbSet.
/// </summary>
/// <typeparam name="TEntity">Entity type.</typeparam>
public class GenericRepository<TEntity> : IGenericRepository<TEntity> where TEntity : class
{
#region Fields
private readonly ApplicationContext _context;
private readonly DbSet<TEntity> _dbSet;
#endregion
#region Ctor
public GenericRepository(ApplicationContext context)
{
_context = context;
_dbSet = context.Set<TEntity>();
}
#endregion
#region Query
public virtual IQueryable<TEntity> Get(
Expression<Func<TEntity, bool>>? filter = null,
Func<IQueryable<TEntity>, IOrderedQueryable<TEntity>>? orderBy = null,
params Expression<Func<TEntity, object>>[] includes)
{
IQueryable<TEntity> query = _dbSet;
if (filter != null)
query = query.Where(filter);
foreach (var include in includes)
query = query.Include(include);
return orderBy != null ? orderBy(query) : query;
}
public virtual async Task<TEntity?> FirstOrDefaultAsync(Expression<Func<TEntity, bool>> predicate, params Expression<Func<TEntity, object>>[] includes)
{
IQueryable<TEntity> query = _dbSet;
foreach (var include in includes)
query = query.Include(include);
return await query.FirstOrDefaultAsync(predicate);
}
public virtual TEntity? GetById(object id) => _dbSet.Find(id);
public virtual async Task<TEntity?> GetByIdAsync(object id) => await _dbSet.FindAsync(id);
#endregion
#region Mutations
public virtual void Add(TEntity entity) => _dbSet.Add(entity);
public virtual async Task AddAsync(TEntity entity) => await _dbSet.AddAsync(entity);
public virtual void AddRange(IEnumerable<TEntity> entities) => _dbSet.AddRange(entities);
public virtual void Update(TEntity entity) => _dbSet.Update(entity);
public virtual void Delete(object id)
{
var entity = _dbSet.Find(id) ?? throw new KeyNotFoundException($"Entity {typeof(TEntity).Name} with id '{id}' not found");
Delete(entity);
}
public virtual void Delete(TEntity entity)
{
if (_context.Entry(entity).State == EntityState.Detached)
_dbSet.Attach(entity);
_dbSet.Remove(entity);
}
public virtual void DeleteRange(IEnumerable<TEntity> entities) => _dbSet.RemoveRange(entities);
#endregion
}

View File

@@ -0,0 +1,36 @@
using System.Linq.Expressions;
namespace StoreService.Repositories;
/// <summary>
/// Generic repository abstraction for simple CRUD & query operations.
/// </summary>
/// <typeparam name="TEntity">Entity type.</typeparam>
public interface IGenericRepository<TEntity> where TEntity : class
{
#region Query
IQueryable<TEntity> Get(
Expression<Func<TEntity, bool>>? filter = null,
Func<IQueryable<TEntity>, IOrderedQueryable<TEntity>>? orderBy = null,
params Expression<Func<TEntity, object>>[] includes);
Task<TEntity?> FirstOrDefaultAsync(Expression<Func<TEntity, bool>> predicate,
params Expression<Func<TEntity, object>>[] includes);
TEntity? GetById(object id);
Task<TEntity?> GetByIdAsync(object id);
#endregion
#region Mutations
void Add(TEntity entity);
Task AddAsync(TEntity entity);
void AddRange(IEnumerable<TEntity> entities);
void Update(TEntity entity);
void Delete(object id);
void Delete(TEntity entity);
void DeleteRange(IEnumerable<TEntity> entities);
#endregion
}

View File

@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore.Storage;
using StoreService.Database.Entities;
namespace StoreService.Repositories;
/// <summary>
/// Unit of work pattern abstraction encapsulating repositories and transactions.
/// </summary>
public interface IUnitOfWork : IAsyncDisposable, IDisposable
{
#region Repositories
IGenericRepository<StoreCategory> StoreCategories { get; }
IGenericRepository<StoreItem> StoreItems { get; }
IGenericRepository<StoreDiscount> StoreDiscounts { get; }
IGenericRepository<StoreDiscountItem> StoreDiscountItems { get; }
IGenericRepository<StoreOrder> StoreOrders { get; }
IGenericRepository<StoreOrderItem> StoreOrderItems { get; }
IGenericRepository<StoreOrderItemDiscount> StoreOrderItemDiscounts { get; }
#endregion
#region Save
bool SaveChanges();
Task<bool> SaveChangesAsync(CancellationToken ct = default);
#endregion
#region Transactions
Task BeginTransactionAsync(CancellationToken ct = default);
Task CommitTransactionAsync(CancellationToken ct = default);
Task RollbackTransactionAsync(CancellationToken ct = default);
#endregion
}

View File

@@ -0,0 +1,99 @@
using Microsoft.EntityFrameworkCore.Storage;
using StoreService.Database;
using StoreService.Database.Entities;
namespace StoreService.Repositories;
/// <summary>
/// Coordinates repository access and database transactions.
/// </summary>
public class UnitOfWork : IUnitOfWork
{
#region Fields
private readonly ApplicationContext _context;
private IDbContextTransaction? _transaction;
#endregion
#region Ctor
public UnitOfWork(
ApplicationContext context,
IGenericRepository<StoreCategory> storeCategories,
IGenericRepository<StoreItem> storeItems,
IGenericRepository<StoreDiscount> storeDiscounts,
IGenericRepository<StoreDiscountItem> storeDiscountItems,
IGenericRepository<StoreOrder> storeOrders,
IGenericRepository<StoreOrderItem> storeOrderItems,
IGenericRepository<StoreOrderItemDiscount> storeOrderItemDiscounts)
{
_context = context;
StoreCategories = storeCategories;
StoreItems = storeItems;
StoreDiscounts = storeDiscounts;
StoreDiscountItems = storeDiscountItems;
StoreOrders = storeOrders;
StoreOrderItems = storeOrderItems;
StoreOrderItemDiscounts = storeOrderItemDiscounts;
}
#endregion
#region Repositories
public IGenericRepository<StoreCategory> StoreCategories { get; }
public IGenericRepository<StoreItem> StoreItems { get; }
public IGenericRepository<StoreDiscount> StoreDiscounts { get; }
public IGenericRepository<StoreDiscountItem> StoreDiscountItems { get; }
public IGenericRepository<StoreOrder> StoreOrders { get; }
public IGenericRepository<StoreOrderItem> StoreOrderItems { get; }
public IGenericRepository<StoreOrderItemDiscount> StoreOrderItemDiscounts { get; }
#endregion
#region Save
public bool SaveChanges() => _context.SaveChanges() > 0;
public async Task<bool> SaveChangesAsync(CancellationToken ct = default) => (await _context.SaveChangesAsync(ct)) > 0;
#endregion
#region Transactions
public async Task BeginTransactionAsync(CancellationToken ct = default)
{
if (_transaction != null) throw new InvalidOperationException("Transaction already started");
_transaction = await _context.Database.BeginTransactionAsync(ct);
}
public async Task CommitTransactionAsync(CancellationToken ct = default)
{
if (_transaction == null) throw new InvalidOperationException("No transaction started");
try
{
await _transaction.CommitAsync(ct);
}
catch
{
await RollbackTransactionAsync(ct);
throw;
}
finally
{
await _transaction.DisposeAsync();
_transaction = null;
}
}
public async Task RollbackTransactionAsync(CancellationToken ct = default)
{
if (_transaction == null) return;
await _transaction.RollbackAsync(ct);
await _transaction.DisposeAsync();
_transaction = null;
}
#endregion
#region Dispose
public void Dispose() => _transaction?.Dispose();
public async ValueTask DisposeAsync()
{
if (_transaction != null)
await _transaction.DisposeAsync();
}
#endregion
}

View File

@@ -0,0 +1,17 @@
using StoreService.Models;
namespace StoreService.Services;
/// <summary>
/// Service responsible for order lifecycle (create, pay, redeem) including price calculation.
/// </summary>
public interface IOrderService
{
#region Methods
Task<OrderDto> CreateOrderAsync(CreateOrderRequest request, CancellationToken ct = default);
Task<OrderDto?> GetOrderAsync(long id, CancellationToken ct = default);
Task<OrderDto> PayOrderAsync(long id, CancellationToken ct = default);
Task<OrderDto> RedeemOrderItemsAsync(long id, CancellationToken ct = default);
#endregion
}

View File

@@ -0,0 +1,172 @@
using Microsoft.EntityFrameworkCore;
using StoreService.Database.Entities;
using StoreService.Models;
using StoreService.Repositories;
namespace StoreService.Services;
/// <summary>
/// Implements order creation, payment, redemption and price calculation logic.
/// </summary>
public class OrderService : IOrderService
{
#region Fields
private readonly IUnitOfWork _uow;
private readonly ILogger<OrderService> _logger;
#endregion
#region Ctor
public OrderService(IUnitOfWork uow, ILogger<OrderService> logger)
{
_uow = uow;
_logger = logger;
}
#endregion
#region Public Methods
/// <inheritdoc/>
public async Task<OrderDto> CreateOrderAsync(CreateOrderRequest request, CancellationToken ct = default)
{
if (request.StoreItemIds.Count == 0)
throw new ArgumentException("No store items specified", nameof(request.StoreItemIds));
// Ensure uniqueness
var uniqueIds = request.StoreItemIds.Distinct().ToList();
// Load items with potential discount items and discounts for calculation.
var items = await _uow.StoreItems
.Get(i => uniqueIds.Contains(i.Id), includes: i => i.DiscountItems)
.Include(i => i.DiscountItems) // ensure collection loaded
.ToListAsync(ct);
if (items.Count != uniqueIds.Count)
{
var missing = uniqueIds.Except(items.Select(i => i.Id)).ToArray();
throw new KeyNotFoundException($"Store items not found: {string.Join(", ", missing)}");
}
// Preload discounts referenced by items for efficient calculation.
var discountIds = items.SelectMany(i => i.DiscountItems.Select(di => di.StoreDiscountId)).Distinct().ToList();
var discounts = await _uow.StoreDiscounts
.Get(d => discountIds.Contains(d.Id))
.ToListAsync(ct);
var now = DateTime.UtcNow;
var order = new StoreOrder
{
UserId = request.UserId,
CostUpdateDate = now,
OrderItems = new List<StoreOrderItem>()
};
foreach (var item in items)
{
var applicableDiscounts = discounts
.Where(d => item.DiscountItems.Any(di => di.StoreDiscountId == d.Id) && d.IsActive(now))
.ToList();
var calculatedPrice = CalculatePrice(item.ManaBuyPrice, applicableDiscounts);
var orderItem = new StoreOrderItem
{
StoreItemId = item.Id,
CalculatedPrice = calculatedPrice,
AppliedDiscounts = applicableDiscounts.Select(d => new StoreOrderItemDiscount
{
StoreDiscountId = d.Id
}).ToList()
};
order.OrderItems.Add(orderItem);
}
await _uow.StoreOrders.AddAsync(order);
await _uow.SaveChangesAsync(ct);
_logger.LogInformation("Created order {OrderId} for user {UserId}", order.Id, order.UserId);
return MapOrder(order);
}
/// <inheritdoc/>
public async Task<OrderDto?> GetOrderAsync(long id, CancellationToken ct = default)
{
var order = await _uow.StoreOrders
.Get(o => o.Id == id, includes: o => o.OrderItems)
.Include(o => o.OrderItems)
.ThenInclude(oi => oi.AppliedDiscounts)
.FirstOrDefaultAsync(ct);
return order == null ? null : MapOrder(order);
}
/// <inheritdoc/>
public async Task<OrderDto> PayOrderAsync(long id, CancellationToken ct = default)
{
var order = await _uow.StoreOrders
.Get(o => o.Id == id, includes: o => o.OrderItems)
.FirstOrDefaultAsync(ct) ?? throw new KeyNotFoundException($"Order {id} not found");
if (order.PaidDate != null)
throw new InvalidOperationException("Order already paid");
order.PaidDate = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
_logger.Information("Order {OrderId} paid", id);
return await GetOrderRequiredAsync(id, ct);
}
/// <inheritdoc/>
public async Task<OrderDto> RedeemOrderItemsAsync(long id, CancellationToken ct = default)
{
var order = await _uow.StoreOrders.Get(o => o.Id == id).FirstOrDefaultAsync(ct) ??
throw new KeyNotFoundException($"Order {id} not found");
if (order.PaidDate == null)
throw new InvalidOperationException("Order not paid yet");
if (order.ItemsRedeemed)
throw new InvalidOperationException("Order items already redeemed");
// TODO: integrate with external inventory service. For now we just toggle flag.
order.ItemsRedeemed = true;
await _uow.SaveChangesAsync(ct);
_logger.Information("Order {OrderId} items redeemed", id);
return await GetOrderRequiredAsync(id, ct);
}
#endregion
#region Helpers
private int CalculatePrice(int basePrice, IEnumerable<StoreDiscount> discounts)
{
// Assumption: sum discount percentages, cap at 90% to avoid free unless explicitly 100%. Documented decision.
var totalPercent = discounts.Sum(d => d.Percentage);
if (totalPercent > 100m) totalPercent = 100m; // absolute cap
var discounted = basePrice - (int)Math.Floor(basePrice * (decimal)totalPercent / 100m);
return Math.Max(0, discounted);
}
private async Task<OrderDto> GetOrderRequiredAsync(long id, CancellationToken ct)
{
var dto = await GetOrderAsync(id, ct);
if (dto == null) throw new KeyNotFoundException($"Order {id} not found after update");
return dto;
}
private static OrderDto MapOrder(StoreOrder order) => new()
{
Id = order.Id,
UserId = order.UserId,
CostUpdateDate = order.CostUpdateDate,
PaidDate = order.PaidDate,
ItemsRedeemed = order.ItemsRedeemed,
Items = order.OrderItems.Select(oi => new OrderItemDto
{
Id = oi.Id,
StoreItemId = oi.StoreItemId,
CalculatedPrice = oi.CalculatedPrice,
AppliedDiscountIds = oi.AppliedDiscounts.Select(ad => ad.StoreDiscountId ?? 0).ToList()
}).ToList()
};
#endregion
}

View File

@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.9" />
<!-- EF Core -->
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0" PrivateAssets="All" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.0" />
<!-- Serilog -->
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="2.0.0" />
<PackageReference Include="Serilog.Sinks.OpenSearch" Version="1.3.0" />
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
<PackageReference Include="Serilog.Enrichers.Process" Version="3.0.0" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="3.1.0" />
<!-- Swagger -->
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<!-- Newtonsoft Json -->
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<Folder Include="Database/Entities/" />
<Folder Include="Repositories/" />
<Folder Include="Middleware/" />
<Folder Include="Services/" />
<Folder Include="Models/" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,6 @@
@StoreService_HostAddress = http://localhost:5141
GET {{StoreService_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,47 @@
{
"ConnectionStrings": {
"StoreDb": "Host=localhost;Port=5432;Database=store_db;Username=store_user;Password=store_password"
},
"Serilog": {
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File", "Serilog.Sinks.Debug", "Serilog.Sinks.OpenSearch" ],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"System": "Warning"
}
},
"Enrich": [ "FromLogContext", "WithThreadId", "WithEnvironmentName", "WithProcessId" ],
"WriteTo": [
{ "Name": "Console" },
{ "Name": "Debug" },
{
"Name": "File",
"Args": {
"path": "Logs/log-.txt",
"rollingInterval": "Day",
"retainedFileCountLimit": 14,
"shared": true
}
},
{
"Name": "OpenSearch",
"Args": {
"nodeUris": "http://localhost:9200",
"indexFormat": "store-service-logs-{0:yyyy.MM.dd}",
"autoRegisterTemplate": true,
"numberOfShards": 1,
"numberOfReplicas": 0
}
}
]
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@@ -0,0 +1,4 @@
// <autogenerated />
using System;
using System.Reflection;
[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETCoreApp,Version=v9.0", FrameworkDisplayName = ".NET 9.0")]

View File

@@ -0,0 +1,22 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using System;
using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("StoreService")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0")]
[assembly: System.Reflection.AssemblyProductAttribute("StoreService")]
[assembly: System.Reflection.AssemblyTitleAttribute("StoreService")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
// Generated by the MSBuild WriteCodeFragment class.

View File

@@ -0,0 +1,17 @@
// <auto-generated/>
global using global::Microsoft.AspNetCore.Builder;
global using global::Microsoft.AspNetCore.Hosting;
global using global::Microsoft.AspNetCore.Http;
global using global::Microsoft.AspNetCore.Routing;
global using global::Microsoft.Extensions.Configuration;
global using global::Microsoft.Extensions.DependencyInjection;
global using global::Microsoft.Extensions.Hosting;
global using global::Microsoft.Extensions.Logging;
global using global::System;
global using global::System.Collections.Generic;
global using global::System.IO;
global using global::System.Linq;
global using global::System.Net.Http;
global using global::System.Net.Http.Json;
global using global::System.Threading;
global using global::System.Threading.Tasks;