feat: integrate PYMS payment gateway, add blog/sitepage/image services, local file manager
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m44s

Payment Gateway:
- Add PYMSPaymentService: IPaymentGatewayService via gRPC to PYMS microservice
- Add ZarinPalPaymentService: direct ZarinPal integration (backup)
- Register 'pyms' payment provider in DI ConfigureServices
- Add PYMS proto files (pyms_transaction.proto, pyms_public_messages.proto)
- Fix VerifyDiscountWalletCharge: pass 'OK' as status instead of Authority
- Update appsettings: PaymentProvider=pyms, sandbox mode, merchant ID

Blog System:
- Add BlogCategory, BlogPost, BlogPostImage entities and CQRS
- Add proto files and gRPC services for blog management
- Add Mapster profiles for blog responses

Content Management:
- Add SitePage entity and CQRS for static pages
- Add proto and gRPC service for site pages

Image/File Management:
- Add LocalFileManager with disk storage + base64 serving + FMS fallback
- Add ImagePathResolverInterceptor for gRPC responses
- Add ImageResolverService for explicit image resolution
- Add UploadsController for public file serving with FMS fallback
- Add PaymentCallbackController for discount order payment callbacks

Database:
- Add blog and content entity migrations
- Remove ImagePath MaxLength constraints
- Remove old FileManagementService (replaced by LocalFileManager)
This commit is contained in:
masoodafar-web
2026-02-15 23:01:16 +03:30
parent 5a4e4a960d
commit 2502cbbda2
177 changed files with 16632 additions and 487 deletions
@@ -37,7 +37,8 @@ public static class ConfigureServices
services.AddScoped<IAlertService, AlertService>();
services.AddScoped<IUserNotificationService, UserNotificationService>();
services.AddScoped<IKavenegarService, KavenegarService>();
services.AddScoped<IFileManagementService, FileManagementService>();
// Local file manager — files are saved to wwwroot/uploads/ on CMS disk
services.AddSingleton<CMSMicroservice.Application.Common.FileManager.IFileManager, LocalFileManager>();
services.AddScoped<IPermissionService, PermissionService>();
// Daya Loan API Service - قابل تغییر بین Mock و Real
@@ -73,19 +74,31 @@ public static class ConfigureServices
});
}
// Payment Gateway Service - فقط Daya (درگاه اینترنتی از Gateway میاد نه CMS)
var useRealPaymentGateway = configuration.GetValue<bool>("UseRealPaymentGateway", false);
// Payment Gateway Service - Multi-Provider Architecture
// پشتیبانی از درگاه‌های مختلف: ZarinPal, Daya, PYMS, Mock
var paymentProvider = configuration.GetValue<string>("PaymentProvider", "Mock")?.ToLowerInvariant();
if (useRealPaymentGateway)
switch (paymentProvider)
{
// فقط Daya برای پرداخت به کاربران (Payout)
services.AddHttpClient<IPaymentGatewayService, DayaPaymentService>()
.SetHandlerLifetime(TimeSpan.FromMinutes(5));
}
else
{
// Mock برای Development و Testing
services.AddScoped<IPaymentGatewayService, MockPaymentGatewayService>();
case "zarinpal":
services.AddHttpClient<IPaymentGatewayService, ZarinPalPaymentService>()
.SetHandlerLifetime(TimeSpan.FromMinutes(5));
break;
case "daya":
services.AddHttpClient<IPaymentGatewayService, DayaPaymentService>()
.SetHandlerLifetime(TimeSpan.FromMinutes(5));
break;
case "pyms":
// PYMS (Payment Microservice) — ارتباط gRPC با سرویس پرداخت مستقل
services.AddSingleton<IPaymentGatewayService, PYMSPaymentService>();
break;
case "mock":
default:
services.AddScoped<IPaymentGatewayService, MockPaymentGatewayService>();
break;
}
services.AddScoped<IApplicationDbContext>(p => p.GetRequiredService<ApplicationDbContext>());
@@ -2,6 +2,8 @@ using System.Reflection;
using CMSMicroservice.Application.Common.Interfaces;
using Microsoft.EntityFrameworkCore.Diagnostics;
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Entities.Blog;
using CMSMicroservice.Domain.Entities.Content;
using CMSMicroservice.Domain.Entities.Payment;
using CMSMicroservice.Domain.Entities.Geography;
using CMSMicroservice.Domain.Entities.Order;
@@ -138,4 +140,15 @@ public class ApplicationDbContext : DbContext, IApplicationDbContext
public DbSet<Warehouse> Warehouses => Set<Warehouse>();
public DbSet<InventoryItem> InventoryItems => Set<InventoryItem>();
public DbSet<StockMovement> StockMovements => Set<StockMovement>();
// ============= Blog DbSets =============
public DbSet<BlogPost> BlogPosts => Set<BlogPost>();
public DbSet<BlogCategory> BlogCategories => Set<BlogCategory>();
public DbSet<BlogPostCategory> BlogPostCategories => Set<BlogPostCategory>();
public DbSet<BlogPostTag> BlogPostTags => Set<BlogPostTag>();
public DbSet<BlogPostImage> BlogPostImages => Set<BlogPostImage>();
// ============= Content Management DbSets =============
public DbSet<SitePage> SitePages => Set<SitePage>();
public DbSet<SitePageSection> SitePageSections => Set<SitePageSection>();
}
@@ -0,0 +1,25 @@
using CMSMicroservice.Domain.Entities.Blog;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace CMSMicroservice.Infrastructure.Persistence.Configurations.Blog;
public class BlogCategoryConfiguration : IEntityTypeConfiguration<BlogCategory>
{
public void Configure(EntityTypeBuilder<BlogCategory> builder)
{
builder.ToTable("BlogCategories");
builder.HasKey(x => x.Id);
builder.Property(x => x.Title).IsRequired().HasMaxLength(100);
builder.Property(x => x.Slug).IsRequired().HasMaxLength(100);
builder.Property(x => x.Description).HasMaxLength(500);
builder.Property(x => x.IconName).HasMaxLength(100);
builder.Property(x => x.SortOrder).IsRequired().HasDefaultValue(0);
builder.Property(x => x.IsActive).IsRequired().HasDefaultValue(true);
// Indexes
builder.HasIndex(x => x.Slug).IsUnique().HasDatabaseName("IX_BlogCategories_Slug");
builder.HasIndex(x => x.IsActive).HasDatabaseName("IX_BlogCategories_IsActive");
}
}
@@ -0,0 +1,22 @@
using CMSMicroservice.Domain.Entities.Blog;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace CMSMicroservice.Infrastructure.Persistence.Configurations.Blog;
public class BlogPostCategoryConfiguration : IEntityTypeConfiguration<BlogPostCategory>
{
public void Configure(EntityTypeBuilder<BlogPostCategory> builder)
{
builder.ToTable("BlogPostCategories");
builder.HasKey(e => e.Id);
builder.HasOne(d => d.BlogPost)
.WithMany(p => p.BlogPostCategories)
.HasForeignKey(d => d.BlogPostId);
builder.HasOne(d => d.BlogCategory)
.WithMany(p => p.BlogPostCategories)
.HasForeignKey(d => d.BlogCategoryId);
}
}
@@ -0,0 +1,35 @@
using CMSMicroservice.Domain.Entities.Blog;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace CMSMicroservice.Infrastructure.Persistence.Configurations.Blog;
public class BlogPostConfiguration : IEntityTypeConfiguration<BlogPost>
{
public void Configure(EntityTypeBuilder<BlogPost> builder)
{
builder.ToTable("BlogPosts");
builder.HasKey(x => x.Id);
builder.Property(x => x.Title).IsRequired().HasMaxLength(200);
builder.Property(x => x.Slug).IsRequired().HasMaxLength(200);
builder.Property(x => x.Summary).HasMaxLength(500);
builder.Property(x => x.HtmlContent).IsRequired();
builder.Property(x => x.FeaturedImagePath);
builder.Property(x => x.FeaturedImageThumbnailPath);
builder.Property(x => x.Status).IsRequired();
builder.Property(x => x.ViewCount).IsRequired().HasDefaultValue(0);
builder.Property(x => x.AuthorUserId).IsRequired();
builder.Property(x => x.IsFeatured).IsRequired().HasDefaultValue(false);
builder.Property(x => x.SortOrder).IsRequired().HasDefaultValue(0);
// Indexes
builder.HasIndex(x => x.Slug).IsUnique().HasDatabaseName("IX_BlogPosts_Slug");
builder.HasIndex(x => x.Status).HasDatabaseName("IX_BlogPosts_Status");
builder.HasIndex(x => x.PublishedAt).HasDatabaseName("IX_BlogPosts_PublishedAt");
builder.HasIndex(x => x.IsFeatured).HasDatabaseName("IX_BlogPosts_IsFeatured");
builder.HasIndex(x => x.AuthorUserId).HasDatabaseName("IX_BlogPosts_AuthorUserId");
builder.HasIndex(x => new { x.Status, x.PublishedAt })
.HasDatabaseName("IX_BlogPosts_Status_PublishedAt");
}
}
@@ -0,0 +1,25 @@
using CMSMicroservice.Domain.Entities.Blog;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace CMSMicroservice.Infrastructure.Persistence.Configurations.Blog;
public class BlogPostImageConfiguration : IEntityTypeConfiguration<BlogPostImage>
{
public void Configure(EntityTypeBuilder<BlogPostImage> builder)
{
builder.ToTable("BlogPostImages");
builder.HasKey(x => x.Id);
builder.Property(x => x.BlogPostId).IsRequired();
builder.Property(x => x.ImagePath).IsRequired();
builder.Property(x => x.ThumbnailPath).IsRequired();
builder.Property(x => x.AltText).HasMaxLength(200);
builder.Property(x => x.Caption).HasMaxLength(300);
builder.Property(x => x.SortOrder).IsRequired().HasDefaultValue(0);
builder.HasOne(d => d.BlogPost)
.WithMany(p => p.BlogPostImages)
.HasForeignKey(d => d.BlogPostId);
}
}
@@ -0,0 +1,22 @@
using CMSMicroservice.Domain.Entities.Blog;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace CMSMicroservice.Infrastructure.Persistence.Configurations.Blog;
public class BlogPostTagConfiguration : IEntityTypeConfiguration<BlogPostTag>
{
public void Configure(EntityTypeBuilder<BlogPostTag> builder)
{
builder.ToTable("BlogPostTags");
builder.HasKey(e => e.Id);
builder.HasOne(d => d.BlogPost)
.WithMany(p => p.BlogPostTags)
.HasForeignKey(d => d.BlogPostId);
builder.HasOne(d => d.Tag)
.WithMany()
.HasForeignKey(d => d.TagId);
}
}
@@ -0,0 +1,25 @@
using CMSMicroservice.Domain.Entities.Content;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace CMSMicroservice.Infrastructure.Persistence.Configurations.Content;
public class SitePageConfiguration : IEntityTypeConfiguration<SitePage>
{
public void Configure(EntityTypeBuilder<SitePage> builder)
{
builder.ToTable("SitePages");
builder.HasKey(x => x.Id);
builder.Property(x => x.PageKey).IsRequired().HasMaxLength(50);
builder.Property(x => x.Title).IsRequired().HasMaxLength(200);
builder.Property(x => x.MetaDescription).HasMaxLength(300);
builder.Property(x => x.HeroTitle).HasMaxLength(200);
builder.Property(x => x.HeroSubtitle).HasMaxLength(500);
builder.Property(x => x.HeroImagePath);
builder.Property(x => x.IsActive).IsRequired().HasDefaultValue(true);
// Indexes
builder.HasIndex(x => x.PageKey).IsUnique().HasDatabaseName("IX_SitePages_PageKey");
}
}
@@ -0,0 +1,32 @@
using CMSMicroservice.Domain.Entities.Content;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace CMSMicroservice.Infrastructure.Persistence.Configurations.Content;
public class SitePageSectionConfiguration : IEntityTypeConfiguration<SitePageSection>
{
public void Configure(EntityTypeBuilder<SitePageSection> builder)
{
builder.ToTable("SitePageSections");
builder.HasKey(x => x.Id);
builder.Property(x => x.SitePageId).IsRequired();
builder.Property(x => x.SectionKey).IsRequired().HasMaxLength(100);
builder.Property(x => x.Title).IsRequired().HasMaxLength(200);
builder.Property(x => x.Subtitle).HasMaxLength(300);
builder.Property(x => x.IconName).HasMaxLength(100);
builder.Property(x => x.ImagePath);
builder.Property(x => x.ImageThumbnailPath);
builder.Property(x => x.SortOrder).IsRequired().HasDefaultValue(0);
builder.Property(x => x.IsActive).IsRequired().HasDefaultValue(true);
builder.HasOne(d => d.SitePage)
.WithMany(p => p.Sections)
.HasForeignKey(d => d.SitePageId);
// Indexes
builder.HasIndex(x => new { x.SitePageId, x.SectionKey })
.HasDatabaseName("IX_SitePageSections_PageId_SectionKey");
}
}
@@ -28,8 +28,7 @@ public class DiscountCategoryConfiguration : IEntityTypeConfiguration<DiscountCa
builder.Property(entity => entity.Description)
.HasMaxLength(1000);
builder.Property(entity => entity.ImagePath)
.HasMaxLength(500);
builder.Property(entity => entity.ImagePath);
builder.Property(entity => entity.IsActive)
.IsRequired()
@@ -36,12 +36,10 @@ public class DiscountProductConfiguration : IEntityTypeConfiguration<DiscountPro
.IsRequired();
builder.Property(entity => entity.ImagePath)
.IsRequired()
.HasMaxLength(500);
.IsRequired();
builder.Property(entity => entity.ThumbnailPath)
.IsRequired()
.HasMaxLength(500);
.IsRequired();
builder.Property(entity => entity.IsActive)
.IsRequired()
@@ -19,11 +19,9 @@ public class DiscountProductImageConfiguration : IEntityTypeConfiguration<Discou
.HasMaxLength(500);
builder.Property(x => x.ImagePath)
.IsRequired()
.HasMaxLength(500);
.IsRequired();
builder.Property(x => x.ThumbnailPath)
.HasMaxLength(500);
builder.Property(x => x.ThumbnailPath);
builder.HasOne(x => x.DiscountProduct)
.WithMany(p => p.Images)
@@ -8,6 +8,7 @@ public class ManualPaymentConfiguration : IEntityTypeConfiguration<ManualPayment
{
public void Configure(EntityTypeBuilder<ManualPayment> builder)
{
builder.HasQueryFilter(p => !p.IsDeleted);
builder.ToTable("ManualPayments");
builder.HasKey(x => x.Id);
@@ -8,6 +8,7 @@ public class OrderVATConfiguration : IEntityTypeConfiguration<OrderVAT>
{
public void Configure(EntityTypeBuilder<OrderVAT> builder)
{
builder.HasQueryFilter(p => !p.IsDeleted);
builder.ToTable("OrderVATs");
builder.HasKey(x => x.Id);
@@ -37,7 +38,7 @@ public class OrderVATConfiguration : IEntityTypeConfiguration<OrderVAT>
// Foreign Key
builder.HasOne(x => x.Order)
.WithOne()
.WithOne(x => x.OrderVAT)
.HasForeignKey<OrderVAT>(x => x.OrderId)
.OnDelete(DeleteBehavior.Restrict);
@@ -7,6 +7,7 @@ public class ProductCategoryConfiguration : IEntityTypeConfiguration<ProductCate
{
public void Configure(EntityTypeBuilder<ProductCategory> builder)
{
builder.HasQueryFilter(p => !p.IsDeleted);
builder.ToTable("ProductCategories", "CMS");
builder.HasKey(e => e.Id);
@@ -7,6 +7,7 @@ public class ProductTagConfiguration : IEntityTypeConfiguration<ProductTag>
{
public void Configure(EntityTypeBuilder<ProductTag> builder)
{
builder.HasQueryFilter(p => !p.IsDeleted);
builder.ToTable("ProductTags", "CMS");
builder.HasKey(e => e.Id);
@@ -8,6 +8,7 @@ public class PublicMessageConfiguration : IEntityTypeConfiguration<PublicMessage
{
public void Configure(EntityTypeBuilder<PublicMessage> builder)
{
builder.HasQueryFilter(p => !p.IsDeleted);
builder.ToTable("PublicMessages");
builder.HasKey(x => x.Id);
@@ -0,0 +1,345 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CMSMicroservice.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddBlogAndContentEntities : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "BlogCategories",
schema: "CMS",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Title = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Slug = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Description = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
IconName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
SortOrder = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
Created = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
LastModified = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifiedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_BlogCategories", x => x.Id);
});
migrationBuilder.CreateTable(
name: "BlogPosts",
schema: "CMS",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Title = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Slug = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Summary = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
HtmlContent = table.Column<string>(type: "nvarchar(max)", nullable: false),
FeaturedImagePath = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
FeaturedImageThumbnailPath = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
Status = table.Column<int>(type: "int", nullable: false),
PublishedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
ScheduledPublishAt = table.Column<DateTime>(type: "datetime2", nullable: true),
ViewCount = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
AuthorUserId = table.Column<long>(type: "bigint", nullable: false),
IsFeatured = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
SortOrder = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
Created = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
LastModified = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifiedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_BlogPosts", x => x.Id);
});
migrationBuilder.CreateTable(
name: "SitePages",
schema: "CMS",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
PageKey = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
Title = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
MetaDescription = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: true),
HeroTitle = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
HeroSubtitle = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
HeroImagePath = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
Created = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
LastModified = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifiedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SitePages", x => x.Id);
});
migrationBuilder.CreateTable(
name: "BlogPostCategories",
schema: "CMS",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
BlogPostId = table.Column<long>(type: "bigint", nullable: false),
BlogCategoryId = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_BlogPostCategories", x => x.Id);
table.ForeignKey(
name: "FK_BlogPostCategories_BlogCategories_BlogCategoryId",
column: x => x.BlogCategoryId,
principalSchema: "CMS",
principalTable: "BlogCategories",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_BlogPostCategories_BlogPosts_BlogPostId",
column: x => x.BlogPostId,
principalSchema: "CMS",
principalTable: "BlogPosts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "BlogPostImages",
schema: "CMS",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
BlogPostId = table.Column<long>(type: "bigint", nullable: false),
ImagePath = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
ThumbnailPath = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
AltText = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
Caption = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: true),
SortOrder = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
Created = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
LastModified = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifiedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_BlogPostImages", x => x.Id);
table.ForeignKey(
name: "FK_BlogPostImages_BlogPosts_BlogPostId",
column: x => x.BlogPostId,
principalSchema: "CMS",
principalTable: "BlogPosts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "BlogPostTags",
schema: "CMS",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
BlogPostId = table.Column<long>(type: "bigint", nullable: false),
TagId = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_BlogPostTags", x => x.Id);
table.ForeignKey(
name: "FK_BlogPostTags_BlogPosts_BlogPostId",
column: x => x.BlogPostId,
principalSchema: "CMS",
principalTable: "BlogPosts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_BlogPostTags_Tags_TagId",
column: x => x.TagId,
principalSchema: "CMS",
principalTable: "Tags",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "SitePageSections",
schema: "CMS",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
SitePageId = table.Column<long>(type: "bigint", nullable: false),
SectionKey = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Title = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Subtitle = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: true),
HtmlContent = table.Column<string>(type: "nvarchar(max)", nullable: true),
IconName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
ImagePath = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
ImageThumbnailPath = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
SortOrder = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
ExtraData = table.Column<string>(type: "nvarchar(max)", nullable: true),
Created = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
LastModified = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifiedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SitePageSections", x => x.Id);
table.ForeignKey(
name: "FK_SitePageSections_SitePages_SitePageId",
column: x => x.SitePageId,
principalSchema: "CMS",
principalTable: "SitePages",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_BlogCategories_IsActive",
schema: "CMS",
table: "BlogCategories",
column: "IsActive");
migrationBuilder.CreateIndex(
name: "IX_BlogCategories_Slug",
schema: "CMS",
table: "BlogCategories",
column: "Slug",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_BlogPostCategories_BlogCategoryId",
schema: "CMS",
table: "BlogPostCategories",
column: "BlogCategoryId");
migrationBuilder.CreateIndex(
name: "IX_BlogPostCategories_BlogPostId",
schema: "CMS",
table: "BlogPostCategories",
column: "BlogPostId");
migrationBuilder.CreateIndex(
name: "IX_BlogPostImages_BlogPostId",
schema: "CMS",
table: "BlogPostImages",
column: "BlogPostId");
migrationBuilder.CreateIndex(
name: "IX_BlogPosts_AuthorUserId",
schema: "CMS",
table: "BlogPosts",
column: "AuthorUserId");
migrationBuilder.CreateIndex(
name: "IX_BlogPosts_IsFeatured",
schema: "CMS",
table: "BlogPosts",
column: "IsFeatured");
migrationBuilder.CreateIndex(
name: "IX_BlogPosts_PublishedAt",
schema: "CMS",
table: "BlogPosts",
column: "PublishedAt");
migrationBuilder.CreateIndex(
name: "IX_BlogPosts_Slug",
schema: "CMS",
table: "BlogPosts",
column: "Slug",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_BlogPosts_Status",
schema: "CMS",
table: "BlogPosts",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_BlogPosts_Status_PublishedAt",
schema: "CMS",
table: "BlogPosts",
columns: new[] { "Status", "PublishedAt" });
migrationBuilder.CreateIndex(
name: "IX_BlogPostTags_BlogPostId",
schema: "CMS",
table: "BlogPostTags",
column: "BlogPostId");
migrationBuilder.CreateIndex(
name: "IX_BlogPostTags_TagId",
schema: "CMS",
table: "BlogPostTags",
column: "TagId");
migrationBuilder.CreateIndex(
name: "IX_SitePages_PageKey",
schema: "CMS",
table: "SitePages",
column: "PageKey",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_SitePageSections_PageId_SectionKey",
schema: "CMS",
table: "SitePageSections",
columns: new[] { "SitePageId", "SectionKey" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BlogPostCategories",
schema: "CMS");
migrationBuilder.DropTable(
name: "BlogPostImages",
schema: "CMS");
migrationBuilder.DropTable(
name: "BlogPostTags",
schema: "CMS");
migrationBuilder.DropTable(
name: "SitePageSections",
schema: "CMS");
migrationBuilder.DropTable(
name: "BlogCategories",
schema: "CMS");
migrationBuilder.DropTable(
name: "BlogPosts",
schema: "CMS");
migrationBuilder.DropTable(
name: "SitePages",
schema: "CMS");
}
}
}
@@ -0,0 +1,309 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CMSMicroservice.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class RemoveImagePathMaxLength : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_UserOrders_OrderVATs_OrderVATId",
schema: "CMS",
table: "UserOrders");
migrationBuilder.DropIndex(
name: "IX_UserOrders_OrderVATId",
schema: "CMS",
table: "UserOrders");
migrationBuilder.DropColumn(
name: "OrderVATId",
schema: "CMS",
table: "UserOrders");
migrationBuilder.AlterColumn<string>(
name: "ImageThumbnailPath",
schema: "CMS",
table: "SitePageSections",
type: "nvarchar(max)",
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(500)",
oldMaxLength: 500,
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "ImagePath",
schema: "CMS",
table: "SitePageSections",
type: "nvarchar(max)",
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(500)",
oldMaxLength: 500,
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "HeroImagePath",
schema: "CMS",
table: "SitePages",
type: "nvarchar(max)",
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(500)",
oldMaxLength: 500,
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "ThumbnailPath",
schema: "CMS",
table: "DiscountProducts",
type: "nvarchar(max)",
nullable: false,
oldClrType: typeof(string),
oldType: "nvarchar(500)",
oldMaxLength: 500);
migrationBuilder.AlterColumn<string>(
name: "ImagePath",
schema: "CMS",
table: "DiscountProducts",
type: "nvarchar(max)",
nullable: false,
oldClrType: typeof(string),
oldType: "nvarchar(500)",
oldMaxLength: 500);
migrationBuilder.AlterColumn<string>(
name: "ThumbnailPath",
schema: "CMS",
table: "DiscountProductImages",
type: "nvarchar(max)",
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(500)",
oldMaxLength: 500,
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "ImagePath",
schema: "CMS",
table: "DiscountProductImages",
type: "nvarchar(max)",
nullable: false,
oldClrType: typeof(string),
oldType: "nvarchar(500)",
oldMaxLength: 500);
migrationBuilder.AlterColumn<string>(
name: "ImagePath",
schema: "CMS",
table: "DiscountCategories",
type: "nvarchar(max)",
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(500)",
oldMaxLength: 500,
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "FeaturedImageThumbnailPath",
schema: "CMS",
table: "BlogPosts",
type: "nvarchar(max)",
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(500)",
oldMaxLength: 500,
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "FeaturedImagePath",
schema: "CMS",
table: "BlogPosts",
type: "nvarchar(max)",
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(500)",
oldMaxLength: 500,
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "ThumbnailPath",
schema: "CMS",
table: "BlogPostImages",
type: "nvarchar(max)",
nullable: false,
oldClrType: typeof(string),
oldType: "nvarchar(500)",
oldMaxLength: 500);
migrationBuilder.AlterColumn<string>(
name: "ImagePath",
schema: "CMS",
table: "BlogPostImages",
type: "nvarchar(max)",
nullable: false,
oldClrType: typeof(string),
oldType: "nvarchar(500)",
oldMaxLength: 500);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<long>(
name: "OrderVATId",
schema: "CMS",
table: "UserOrders",
type: "bigint",
nullable: true);
migrationBuilder.AlterColumn<string>(
name: "ImageThumbnailPath",
schema: "CMS",
table: "SitePageSections",
type: "nvarchar(500)",
maxLength: 500,
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(max)",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "ImagePath",
schema: "CMS",
table: "SitePageSections",
type: "nvarchar(500)",
maxLength: 500,
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(max)",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "HeroImagePath",
schema: "CMS",
table: "SitePages",
type: "nvarchar(500)",
maxLength: 500,
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(max)",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "ThumbnailPath",
schema: "CMS",
table: "DiscountProducts",
type: "nvarchar(500)",
maxLength: 500,
nullable: false,
oldClrType: typeof(string),
oldType: "nvarchar(max)");
migrationBuilder.AlterColumn<string>(
name: "ImagePath",
schema: "CMS",
table: "DiscountProducts",
type: "nvarchar(500)",
maxLength: 500,
nullable: false,
oldClrType: typeof(string),
oldType: "nvarchar(max)");
migrationBuilder.AlterColumn<string>(
name: "ThumbnailPath",
schema: "CMS",
table: "DiscountProductImages",
type: "nvarchar(500)",
maxLength: 500,
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(max)",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "ImagePath",
schema: "CMS",
table: "DiscountProductImages",
type: "nvarchar(500)",
maxLength: 500,
nullable: false,
oldClrType: typeof(string),
oldType: "nvarchar(max)");
migrationBuilder.AlterColumn<string>(
name: "ImagePath",
schema: "CMS",
table: "DiscountCategories",
type: "nvarchar(500)",
maxLength: 500,
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(max)",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "FeaturedImageThumbnailPath",
schema: "CMS",
table: "BlogPosts",
type: "nvarchar(500)",
maxLength: 500,
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(max)",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "FeaturedImagePath",
schema: "CMS",
table: "BlogPosts",
type: "nvarchar(500)",
maxLength: 500,
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(max)",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "ThumbnailPath",
schema: "CMS",
table: "BlogPostImages",
type: "nvarchar(500)",
maxLength: 500,
nullable: false,
oldClrType: typeof(string),
oldType: "nvarchar(max)");
migrationBuilder.AlterColumn<string>(
name: "ImagePath",
schema: "CMS",
table: "BlogPostImages",
type: "nvarchar(500)",
maxLength: 500,
nullable: false,
oldClrType: typeof(string),
oldType: "nvarchar(max)");
migrationBuilder.CreateIndex(
name: "IX_UserOrders_OrderVATId",
schema: "CMS",
table: "UserOrders",
column: "OrderVATId");
migrationBuilder.AddForeignKey(
name: "FK_UserOrders_OrderVATs_OrderVATId",
schema: "CMS",
table: "UserOrders",
column: "OrderVATId",
principalSchema: "CMS",
principalTable: "OrderVATs",
principalColumn: "Id");
}
}
}
@@ -23,6 +23,267 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Blog.BlogCategory", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime>("Created")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("IconName")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(true);
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModified")
.HasColumnType("datetime2");
b.Property<string>("LastModifiedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.HasKey("Id");
b.HasIndex("IsActive")
.HasDatabaseName("IX_BlogCategories_IsActive");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("IX_BlogCategories_Slug");
b.ToTable("BlogCategories", "CMS");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Blog.BlogPost", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<long>("AuthorUserId")
.HasColumnType("bigint");
b.Property<DateTime>("Created")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("FeaturedImagePath")
.HasColumnType("nvarchar(max)");
b.Property<string>("FeaturedImageThumbnailPath")
.HasColumnType("nvarchar(max)");
b.Property<string>("HtmlContent")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<bool>("IsFeatured")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<DateTime?>("LastModified")
.HasColumnType("datetime2");
b.Property<string>("LastModifiedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("PublishedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("ScheduledPublishAt")
.HasColumnType("datetime2");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.Property<int>("Status")
.HasColumnType("int");
b.Property<string>("Summary")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("ViewCount")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.HasKey("Id");
b.HasIndex("AuthorUserId")
.HasDatabaseName("IX_BlogPosts_AuthorUserId");
b.HasIndex("IsFeatured")
.HasDatabaseName("IX_BlogPosts_IsFeatured");
b.HasIndex("PublishedAt")
.HasDatabaseName("IX_BlogPosts_PublishedAt");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("IX_BlogPosts_Slug");
b.HasIndex("Status")
.HasDatabaseName("IX_BlogPosts_Status");
b.HasIndex("Status", "PublishedAt")
.HasDatabaseName("IX_BlogPosts_Status_PublishedAt");
b.ToTable("BlogPosts", "CMS");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Blog.BlogPostCategory", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<long>("BlogCategoryId")
.HasColumnType("bigint");
b.Property<long>("BlogPostId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("BlogCategoryId");
b.HasIndex("BlogPostId");
b.ToTable("BlogPostCategories", "CMS");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Blog.BlogPostImage", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("AltText")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<long>("BlogPostId")
.HasColumnType("bigint");
b.Property<string>("Caption")
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<DateTime>("Created")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("ImagePath")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModified")
.HasColumnType("datetime2");
b.Property<string>("LastModifiedBy")
.HasColumnType("nvarchar(max)");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.Property<string>("ThumbnailPath")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("BlogPostId");
b.ToTable("BlogPostImages", "CMS");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Blog.BlogPostTag", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<long>("BlogPostId")
.HasColumnType("bigint");
b.Property<long>("TagId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("BlogPostId");
b.HasIndex("TagId");
b.ToTable("BlogPostTags", "CMS");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Category", b =>
{
b.Property<long>("Id")
@@ -503,6 +764,142 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
b.ToTable("AppVersions", "CMS");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Content.SitePage", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime>("Created")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("HeroImagePath")
.HasColumnType("nvarchar(max)");
b.Property<string>("HeroSubtitle")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("HeroTitle")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(true);
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModified")
.HasColumnType("datetime2");
b.Property<string>("LastModifiedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("MetaDescription")
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<string>("PageKey")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.HasKey("Id");
b.HasIndex("PageKey")
.IsUnique()
.HasDatabaseName("IX_SitePages_PageKey");
b.ToTable("SitePages", "CMS");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Content.SitePageSection", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime>("Created")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("ExtraData")
.HasColumnType("nvarchar(max)");
b.Property<string>("HtmlContent")
.HasColumnType("nvarchar(max)");
b.Property<string>("IconName")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("ImagePath")
.HasColumnType("nvarchar(max)");
b.Property<string>("ImageThumbnailPath")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(true);
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModified")
.HasColumnType("datetime2");
b.Property<string>("LastModifiedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("SectionKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<long>("SitePageId")
.HasColumnType("bigint");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.Property<string>("Subtitle")
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.HasKey("Id");
b.HasIndex("SitePageId", "SectionKey")
.HasDatabaseName("IX_SitePageSections_PageId_SectionKey");
b.ToTable("SitePageSections", "CMS");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Contract", b =>
{
b.Property<long>("Id")
@@ -622,8 +1019,7 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
.HasColumnType("nvarchar(1000)");
b.Property<string>("ImagePath")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
@@ -808,8 +1204,7 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
b.Property<string>("ImagePath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
@@ -847,8 +1242,7 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
b.Property<string>("ThumbnailPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
.HasColumnType("nvarchar(max)");
b.Property<string>("Title")
.IsRequired()
@@ -925,8 +1319,7 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
b.Property<string>("ImagePath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
@@ -944,8 +1337,7 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
.HasColumnType("int");
b.Property<string>("ThumbnailPath")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
.HasColumnType("nvarchar(max)");
b.Property<string>("Title")
.HasMaxLength(200)
@@ -2767,9 +3159,6 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
b.Property<string>("LastModifiedBy")
.HasColumnType("nvarchar(max)");
b.Property<long?>("OrderVATId")
.HasColumnType("bigint");
b.Property<long?>("PackageId")
.HasColumnType("bigint");
@@ -2796,8 +3185,6 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
b.HasKey("Id");
b.HasIndex("OrderVATId");
b.HasIndex("PackageId");
b.HasIndex("TransactionId");
@@ -3167,6 +3554,55 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
b.ToTable("WeekDefinitions", "CMS");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Blog.BlogPostCategory", b =>
{
b.HasOne("CMSMicroservice.Domain.Entities.Blog.BlogCategory", "BlogCategory")
.WithMany("BlogPostCategories")
.HasForeignKey("BlogCategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("CMSMicroservice.Domain.Entities.Blog.BlogPost", "BlogPost")
.WithMany("BlogPostCategories")
.HasForeignKey("BlogPostId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("BlogCategory");
b.Navigation("BlogPost");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Blog.BlogPostImage", b =>
{
b.HasOne("CMSMicroservice.Domain.Entities.Blog.BlogPost", "BlogPost")
.WithMany("BlogPostImages")
.HasForeignKey("BlogPostId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("BlogPost");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Blog.BlogPostTag", b =>
{
b.HasOne("CMSMicroservice.Domain.Entities.Blog.BlogPost", "BlogPost")
.WithMany("BlogPostTags")
.HasForeignKey("BlogPostId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("CMSMicroservice.Domain.Entities.Tag", "Tag")
.WithMany()
.HasForeignKey("TagId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("BlogPost");
b.Navigation("Tag");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Category", b =>
{
b.HasOne("CMSMicroservice.Domain.Entities.Category", "Parent")
@@ -3263,6 +3699,17 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
b.Navigation("WeekDefinition");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Content.SitePageSection", b =>
{
b.HasOne("CMSMicroservice.Domain.Entities.Content.SitePage", "SitePage")
.WithMany("Sections")
.HasForeignKey("SitePageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("SitePage");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.DayaLoanContract", b =>
{
b.HasOne("CMSMicroservice.Domain.Entities.Transaction", "Transaction")
@@ -3502,7 +3949,7 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Order.OrderVAT", b =>
{
b.HasOne("CMSMicroservice.Domain.Entities.UserOrder", "Order")
.WithOne()
.WithOne("OrderVAT")
.HasForeignKey("CMSMicroservice.Domain.Entities.Order.OrderVAT", "OrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
@@ -3657,10 +4104,6 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
modelBuilder.Entity("CMSMicroservice.Domain.Entities.UserOrder", b =>
{
b.HasOne("CMSMicroservice.Domain.Entities.Order.OrderVAT", "OrderVAT")
.WithMany()
.HasForeignKey("OrderVATId");
b.HasOne("CMSMicroservice.Domain.Entities.Package", "Package")
.WithMany("UserOrders")
.HasForeignKey("PackageId");
@@ -3681,8 +4124,6 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("OrderVAT");
b.Navigation("Package");
b.Navigation("Transaction");
@@ -3766,6 +4207,20 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
b.Navigation("Wallet");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Blog.BlogCategory", b =>
{
b.Navigation("BlogPostCategories");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Blog.BlogPost", b =>
{
b.Navigation("BlogPostCategories");
b.Navigation("BlogPostImages");
b.Navigation("BlogPostTags");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Category", b =>
{
b.Navigation("Categories");
@@ -3795,6 +4250,11 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
b.Navigation("UserCommissionPayouts");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Content.SitePage", b =>
{
b.Navigation("Sections");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Contract", b =>
{
b.Navigation("UserContracts");
@@ -3915,6 +4375,8 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
modelBuilder.Entity("CMSMicroservice.Domain.Entities.UserOrder", b =>
{
b.Navigation("FactorDetails");
b.Navigation("OrderVAT");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.UserWallet", b =>
@@ -1,139 +0,0 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Protobuf.Protos.FMS;
using Google.Protobuf;
using Grpc.Net.Client;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Processing;
using System.IO;
namespace CMSMicroservice.Infrastructure.Services;
public class FileManagementService : IFileManagementService, IDisposable
{
private readonly ILogger<FileManagementService> _logger;
private readonly FileInfoContract.FileInfoContractClient _client;
private readonly GrpcChannel _channel;
private const int MainImageMaxWidth = 1200;
private const int MainImageMaxHeight = 1200;
private const int ThumbnailMaxWidth = 300;
private const int ThumbnailMaxHeight = 300;
private const int JpegQuality = 75;
public FileManagementService(IConfiguration configuration, ILogger<FileManagementService> logger)
{
_logger = logger;
var fmsAddress = configuration["FMS:Address"] ?? "https://dl.afrino.co";
_channel = GrpcChannel.ForAddress(fmsAddress, new GrpcChannelOptions
{
MaxReceiveMessageSize = 100 * 1024 * 1024, // 100 MB
MaxSendMessageSize = 100 * 1024 * 1024
});
_client = new FileInfoContract.FileInfoContractClient(_channel);
}
public async Task<string?> UploadFileAsync(string directory, byte[] fileBytes, string mime, string? fileName,
CancellationToken cancellationToken = default)
{
try
{
var request = new CreateNewFileInfoRequest
{
Directory = directory,
File = ByteString.CopyFrom(fileBytes),
Mime = mime,
IsBase64 = false
};
if (!string.IsNullOrWhiteSpace(fileName))
request.FileName = fileName;
var response = await _client.CreateNewFileInfoAsync(request, cancellationToken: cancellationToken);
if (response != null && !string.IsNullOrWhiteSpace(response.File))
{
_logger.LogInformation("File uploaded to FMS successfully. Id: {Id}, Path: {Path}", response.Id, response.File);
return response.File;
}
_logger.LogWarning("FMS upload returned null or empty path for file: {FileName}", fileName);
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error uploading file to FMS. Directory: {Directory}, FileName: {FileName}", directory, fileName);
return null;
}
}
public async Task<(string? MainImagePath, string? ThumbnailPath)> UploadImageWithThumbnailAsync(
string directory, byte[] fileBytes, string mime, string? fileName,
CancellationToken cancellationToken = default)
{
string? mainImagePath = null;
string? thumbnailPath = null;
try
{
// Optimize main image
var mainImageBytes = await OptimizeImageAsync(fileBytes, MainImageMaxWidth, MainImageMaxHeight);
mainImagePath = await UploadFileAsync(directory, mainImageBytes, "image/jpeg", fileName, cancellationToken);
// Create and upload thumbnail
var thumbnailBytes = await OptimizeImageAsync(fileBytes, ThumbnailMaxWidth, ThumbnailMaxHeight);
var thumbFileName = fileName != null ? $"thumb_{fileName}" : null;
thumbnailPath = await UploadFileAsync($"{directory}/Thumbnails", thumbnailBytes, "image/jpeg", thumbFileName, cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing and uploading image with thumbnail. Directory: {Directory}", directory);
}
return (mainImagePath, thumbnailPath);
}
public async Task<bool> DeleteFileAsync(long fileId, CancellationToken cancellationToken = default)
{
try
{
var request = new DeleteFileInfoRequest { Id = fileId };
var response = await _client.DeleteFileInfoAsync(request, cancellationToken: cancellationToken);
return response?.Success ?? false;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting file from FMS. FileId: {FileId}", fileId);
return false;
}
}
private static async Task<byte[]> OptimizeImageAsync(byte[] imageBytes, int maxWidth, int maxHeight)
{
using var image = Image.Load(imageBytes);
// Only resize if larger than max dimensions
if (image.Width > maxWidth || image.Height > maxHeight)
{
image.Mutate(x => x.Resize(new ResizeOptions
{
Size = new Size(maxWidth, maxHeight),
Mode = ResizeMode.Max
}));
}
using var ms = new MemoryStream();
await image.SaveAsJpegAsync(ms, new JpegEncoder { Quality = JpegQuality });
return ms.ToArray();
}
public void Dispose()
{
_channel?.Dispose();
}
}
@@ -0,0 +1,260 @@
using System.IO;
using System.Net.Http;
using CMSMicroservice.Application.Common.FileManager;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Processing;
namespace CMSMicroservice.Infrastructure.Services;
/// <summary>
/// فایل‌منیجر محلی — فایل‌ها روی دیسک ذخیره می‌شوند
/// مسیر نسبی در دیتابیس ذخیره می‌شود
/// موقع واکشی: فایل از دیسک خوانده و به base64 data-URI تبدیل می‌شود
/// </summary>
public sealed class LocalFileManager : IFileManager
{
private readonly ILogger<LocalFileManager> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly string _uploadRoot;
private readonly string _fmsBaseUrl;
// ── تنظیمات بهینه‌سازی تصویر ──
private const int MainMaxWidth = 1200;
private const int MainMaxHeight = 1200;
private const int ThumbMaxWidth = 300;
private const int ThumbMaxHeight = 300;
private const int JpegQuality = 75;
public LocalFileManager(IConfiguration configuration, IHttpClientFactory httpClientFactory, ILogger<LocalFileManager> logger)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
// مسیر ذخیره فایل‌ها — پیش‌فرض: پوشه Uploads در کنار WebApi
_uploadRoot = configuration["FileStorage:UploadPath"]
?? Path.Combine(AppContext.BaseDirectory, "Uploads");
_fmsBaseUrl = configuration["FMS:Address"]?.TrimEnd('/') ?? "https://dl.afrino.co";
Directory.CreateDirectory(_uploadRoot);
_logger.LogInformation("LocalFileManager initialized — UploadRoot: {Root}", _uploadRoot);
}
// ────────────────────────────────────────────────────
// آپلود فایل خام → ذخیره روی دیسک → برگرداندن مسیر نسبی
// ────────────────────────────────────────────────────
public async Task<UploadedFile> UploadAsync(
string directory, byte[] fileBytes, string mime,
string? fileName = null, CancellationToken ct = default)
{
if (fileBytes is not { Length: > 0 })
throw new FileUploadException("فایلی برای آپلود ارسال نشده است");
try
{
var ext = GetExtension(mime, fileName);
var uniqueName = $"{Guid.NewGuid():N}{ext}";
var relativePath = Path.Combine(directory, uniqueName).Replace('\\', '/');
var fullPath = Path.Combine(_uploadRoot, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
await File.WriteAllBytesAsync(fullPath, fileBytes, ct);
_logger.LogInformation(
"File saved — Path: {Path}, Size: {Size}KB",
relativePath, fileBytes.Length / 1024);
return new UploadedFile(0, relativePath);
}
catch (FileUploadException) { throw; }
catch (Exception ex)
{
_logger.LogError(ex, "خطا در ذخیره فایل — Directory: {Dir}", directory);
throw new FileUploadException($"خطا در ذخیره فایل: {ex.Message}", ex);
}
}
// ────────────────────────────────────────────────────
// آپلود تصویر + بندانگشتی → ذخیره روی دیسک
// ────────────────────────────────────────────────────
public async Task<UploadedImage> UploadImageAsync(
string directory, byte[] fileBytes, string mime,
string? fileName = null, CancellationToken ct = default)
{
if (fileBytes is not { Length: > 0 })
throw new FileUploadException("تصویری برای آپلود ارسال نشده است");
var baseName = Guid.NewGuid().ToString("N");
// ① بهینه‌سازی و ذخیره تصویر اصلی
var mainBytes = await OptimizeAsync(fileBytes, MainMaxWidth, MainMaxHeight);
var mainRelative = Path.Combine(directory, $"{baseName}.jpg").Replace('\\', '/');
var mainFull = Path.Combine(_uploadRoot, mainRelative);
Directory.CreateDirectory(Path.GetDirectoryName(mainFull)!);
await File.WriteAllBytesAsync(mainFull, mainBytes, ct);
var main = new UploadedFile(0, mainRelative);
// ② ساخت و ذخیره بندانگشتی
var thumbBytes = await OptimizeAsync(fileBytes, ThumbMaxWidth, ThumbMaxHeight);
var thumbRelative = Path.Combine(directory, $"{baseName}_thumb.jpg").Replace('\\', '/');
var thumbFull = Path.Combine(_uploadRoot, thumbRelative);
await File.WriteAllBytesAsync(thumbFull, thumbBytes, ct);
var thumb = new UploadedFile(0, thumbRelative);
_logger.LogInformation(
"Image saved — Main: {MainPath} ({MainKB}KB), Thumb: {ThumbPath} ({ThumbKB}KB)",
mainRelative, mainBytes.Length / 1024,
thumbRelative, thumbBytes.Length / 1024);
return new UploadedImage(main, thumb);
}
// ────────────────────────────────────────────────────
// حذف فایل از دیسک
// ────────────────────────────────────────────────────
public Task DeleteAsync(long fileId, CancellationToken ct = default)
{
_logger.LogWarning("DeleteAsync called with fileId={Id} — file deletion by ID not supported in disk mode", fileId);
return Task.CompletedTask;
}
// ────────────────────────────────────────────────────
// خواندن فایل از دیسک → تبدیل به base64 data-URI
// ────────────────────────────────────────────────────
public string ResolveImageUrl(string? path)
{
if (string.IsNullOrWhiteSpace(path))
return string.Empty;
// اگر از قبل data-URI یا URL مطلق هست، همان را برگردان
if (path.StartsWith("data:", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
return path;
try
{
var fullPath = Path.Combine(_uploadRoot, path.TrimStart('/'));
if (!File.Exists(fullPath))
{
_logger.LogWarning("Image file not found on disk, trying FMS fallback: {Path}", fullPath);
// ── FMS Fallback: دانلود از dl.afrino.co و کش محلی (برای مهاجرت) ──
if (!TryDownloadFromFms(path.TrimStart('/'), fullPath))
return string.Empty;
_logger.LogInformation("Downloaded and cached from FMS: {Path}", path);
}
var bytes = File.ReadAllBytes(fullPath);
var mime = GetMimeFromExtension(Path.GetExtension(fullPath));
return $"data:{mime};base64,{Convert.ToBase64String(bytes)}";
}
catch (Exception ex)
{
_logger.LogError(ex, "Error reading image from disk: {Path}", path);
return string.Empty;
}
}
// ────────────────────────────────────────────────────
// FMS Fallback — دانلود از سرور قدیمی و کش محلی (مهاجرت)
// ────────────────────────────────────────────────────
private bool TryDownloadFromFms(string relativePath, string localPath)
{
try
{
var fmsUrl = $"{_fmsBaseUrl}/{relativePath}";
_logger.LogInformation("Attempting FMS download: {Url}", fmsUrl);
using var client = _httpClientFactory.CreateClient("FMS");
using var response = client.Send(new HttpRequestMessage(HttpMethod.Get, fmsUrl),
HttpCompletionOption.ResponseHeadersRead);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("FMS returned {Status} for {Url}", response.StatusCode, fmsUrl);
return false;
}
// ذخیره روی دیسک
var directory = Path.GetDirectoryName(localPath)!;
Directory.CreateDirectory(directory);
using var responseStream = response.Content.ReadAsStream();
using var fileStream = new FileStream(localPath, FileMode.Create, FileAccess.Write, FileShare.None);
responseStream.CopyTo(fileStream);
_logger.LogInformation("Cached FMS file locally: {Path} ({Size} bytes)", relativePath, fileStream.Length);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to download from FMS: {Path}", relativePath);
return false;
}
}
// ────────────────────────────────────────────────────
// بهینه‌سازی تصویر (ریسایز + فشرده‌سازی JPEG)
// ────────────────────────────────────────────────────
private static async Task<byte[]> OptimizeAsync(byte[] imageBytes, int maxWidth, int maxHeight)
{
using var image = SixLabors.ImageSharp.Image.Load(imageBytes);
if (image.Width > maxWidth || image.Height > maxHeight)
{
image.Mutate(x => x.Resize(new ResizeOptions
{
Size = new Size(maxWidth, maxHeight),
Mode = ResizeMode.Max
}));
}
using var ms = new MemoryStream();
await image.SaveAsJpegAsync(ms, new JpegEncoder { Quality = JpegQuality });
return ms.ToArray();
}
// ────────────────────────────────────────────────────
// پسوند فایل از mime type
// ────────────────────────────────────────────────────
private static string GetExtension(string mime, string? fileName)
{
if (!string.IsNullOrEmpty(fileName))
{
var ext = Path.GetExtension(fileName);
if (!string.IsNullOrEmpty(ext))
return ext.ToLowerInvariant();
}
return mime.ToLowerInvariant() switch
{
"image/jpeg" or "image/jpg" => ".jpg",
"image/png" => ".png",
"image/gif" => ".gif",
"image/webp" => ".webp",
"image/svg+xml" => ".svg",
"application/pdf" => ".pdf",
_ => ".bin"
};
}
private static string GetMimeFromExtension(string extension)
{
return extension.ToLowerInvariant() switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
".svg" => "image/svg+xml",
".pdf" => "application/pdf",
_ => "application/octet-stream"
};
}
}
@@ -0,0 +1,284 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Protobuf.Protos.PYMS;
using CMSMicroservice.Protobuf.Protos.PYMS.Transaction;
using Grpc.Net.Client;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System.Net.Http;
namespace CMSMicroservice.Infrastructure.Services.Payment;
/// <summary>
/// پیاده‌سازی درگاه پرداخت از طریق PYMS (Payment Microservice)
/// CMS به جای اتصال مستقیم به ZarinPal، از PYMS استفاده می‌کند.
/// PYMS تراکنش‌ها را ذخیره و با ZarinPal ارتباط برقرار می‌کند.
/// </summary>
public class PYMSPaymentService : IPaymentGatewayService, IDisposable
{
private readonly ILogger<PYMSPaymentService> _logger;
private readonly GrpcChannel _channel;
private readonly TransactionContract.TransactionContractClient _client;
private readonly string _merchantId;
private readonly bool _useSandbox;
public PYMSPaymentService(
IConfiguration configuration,
ILogger<PYMSPaymentService> logger)
{
_logger = logger;
var pymsAddress = configuration["PYMS:Address"]
?? throw new InvalidOperationException("PYMS:Address is not configured.");
_merchantId = configuration["ZarinPal:MerchantId"]
?? throw new InvalidOperationException("ZarinPal:MerchantId is not configured.");
_useSandbox = configuration.GetValue<bool>("ZarinPal:UseSandbox", true);
// ایجاد کانال gRPC به PYMS
_channel = GrpcChannel.ForAddress(pymsAddress, new GrpcChannelOptions
{
HttpHandler = new SocketsHttpHandler
{
EnableMultipleHttp2Connections = true,
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(5),
KeepAlivePingDelay = TimeSpan.FromSeconds(60),
KeepAlivePingTimeout = TimeSpan.FromSeconds(30),
}
});
_client = new TransactionContract.TransactionContractClient(_channel);
_logger.LogInformation(
"PYMS Payment Service initialized. Address={Address}, Mode={Mode}",
pymsAddress, _useSandbox ? "🧪 Sandbox" : "🏦 Production");
}
/// <summary>
/// مرحله ۱: ارسال درخواست پرداخت به PYMS
/// PYMS تراکنش را ایجاد و URL درگاه را برمی‌گرداند
/// </summary>
public async Task<PaymentInitiateResult> InitiatePaymentAsync(
PaymentRequest request,
CancellationToken cancellationToken = default)
{
try
{
// CMS مبالغ را به تومان نگه‌داری می‌کند
// PYMS مبلغ را به ریال می‌خواهد — تبدیل تومان به ریال
var amountInRials = (long)(request.Amount * 10);
var grpcRequest = new PaymentRequestRequest
{
MerchantId = _merchantId,
Amount = amountInRials,
CallbackUrl = request.CallbackUrl ?? string.Empty,
Description = request.Description ?? string.Empty,
OrderId = request.UserId.ToString(),
// نوع تراکنش: Sandbox برای تست، Real برای Production
Type = _useSandbox ? TransactionTypeEnum.Sandbox : TransactionTypeEnum.Real,
Currency = CurrencyEnum.Irt, // تومان
};
if (!string.IsNullOrWhiteSpace(request.Mobile))
grpcRequest.Mobile = request.Mobile;
_logger.LogInformation(
"PYMS payment request: Amount={AmountToman} Toman ({AmountRial} Rial), User={UserId}, Sandbox={Sandbox}",
request.Amount, amountInRials, request.UserId, _useSandbox);
var response = await _client.PaymentRequestAsync(grpcRequest, cancellationToken: cancellationToken);
if (!string.IsNullOrEmpty(response.PaymentGWUrl))
{
_logger.LogInformation(
"PYMS payment initiated successfully: GatewayUrl={Url}",
response.PaymentGWUrl);
// از URL درگاه، Authority را استخراج می‌کنیم (آخرین بخش URL)
var authority = ExtractAuthorityFromUrl(response.PaymentGWUrl);
return new PaymentInitiateResult
{
IsSuccess = true,
RefId = authority,
GatewayUrl = response.PaymentGWUrl
};
}
_logger.LogError("PYMS payment request failed: Empty gateway URL returned");
return new PaymentInitiateResult
{
IsSuccess = false,
ErrorMessage = "خطا در دریافت آدرس درگاه از PYMS"
};
}
catch (Grpc.Core.RpcException ex)
{
_logger.LogError(ex, "PYMS gRPC error in InitiatePayment: Status={Status}, Detail={Detail}",
ex.StatusCode, ex.Status.Detail);
return new PaymentInitiateResult
{
IsSuccess = false,
ErrorMessage = $"خطا در ارتباط با سرویس پرداخت: {ex.Status.Detail}"
};
}
catch (Exception ex)
{
_logger.LogError(ex, "PYMS InitiatePayment exception");
return new PaymentInitiateResult
{
IsSuccess = false,
ErrorMessage = $"خطا در ارتباط با سرویس پرداخت: {ex.Message}"
};
}
}
/// <summary>
/// تأیید پرداخت بدون مبلغ — PYMS خودش مبلغ را از تراکنش ذخیره‌شده می‌خواند
/// </summary>
public async Task<PaymentVerificationResult> VerifyPaymentAsync(
string refId,
string verificationToken,
CancellationToken cancellationToken = default)
{
return await VerifyPaymentInternalAsync(refId, verificationToken, cancellationToken);
}
/// <summary>
/// تأیید پرداخت با مبلغ — PYMS خودش verify را انجام می‌دهد
/// refId = Authority, verificationToken = Status (OK/NOK)
/// </summary>
public async Task<PaymentVerificationResult> VerifyPaymentAsync(
string refId,
string verificationToken,
decimal amountInToman,
CancellationToken cancellationToken = default)
{
return await VerifyPaymentInternalAsync(refId, verificationToken, cancellationToken);
}
private async Task<PaymentVerificationResult> VerifyPaymentInternalAsync(
string refId,
string verificationToken,
CancellationToken cancellationToken)
{
try
{
// اگر کاربر لغو کرده
if (!string.Equals(verificationToken, "OK", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("Payment cancelled by user: Authority={Authority}", refId);
return new PaymentVerificationResult
{
IsSuccess = false,
RefId = refId,
Message = "پرداخت توسط کاربر لغو شد"
};
}
var grpcRequest = new PaymentVerificationRequest
{
Authority = refId,
Status = verificationToken
};
_logger.LogInformation("PYMS verify request: Authority={Authority}, Status={Status}",
refId, verificationToken);
var response = await _client.PaymentVerificationAsync(grpcRequest, cancellationToken: cancellationToken);
if (response.PaymentStatus)
{
_logger.LogInformation(
"PYMS payment verified: Id={Id}, RefId={RefId}, OrderId={OrderId}, StatusCode={StatusCode}",
response.Id, response.RefId, response.OrderId, response.VerificationStatusCode);
return new PaymentVerificationResult
{
IsSuccess = true,
RefId = refId,
TrackingCode = response.RefId,
Amount = 0, // مبلغ از DB خوانده می‌شود
Message = response.Message ?? "تراکنش موفق"
};
}
_logger.LogError(
"PYMS verify failed: Authority={Authority}, StatusCode={StatusCode}, Message={Message}",
refId, response.VerificationStatusCode, response.Message);
return new PaymentVerificationResult
{
IsSuccess = false,
RefId = refId,
Message = response.Message ?? "تأیید پرداخت ناموفق"
};
}
catch (Grpc.Core.RpcException ex)
{
_logger.LogError(ex, "PYMS gRPC error in VerifyPayment: Status={Status}, Detail={Detail}",
ex.StatusCode, ex.Status.Detail);
return new PaymentVerificationResult
{
IsSuccess = false,
RefId = refId,
Message = $"خطا در تأیید تراکنش: {ex.Status.Detail}"
};
}
catch (Exception ex)
{
_logger.LogError(ex, "PYMS VerifyPayment exception: Authority={Authority}", refId);
return new PaymentVerificationResult
{
IsSuccess = false,
RefId = refId,
Message = $"خطا در تأیید تراکنش: {ex.Message}"
};
}
}
/// <summary>
/// PYMS فعلاً قابلیت Payout ندارد
/// </summary>
public Task<PayoutResult> ProcessPayoutAsync(
PayoutRequest request,
CancellationToken cancellationToken = default)
{
_logger.LogWarning("PYMS does not support direct payout yet.");
return Task.FromResult(new PayoutResult
{
IsSuccess = false,
Message = "سرویس پرداخت (PYMS) فعلاً از قابلیت واریز مستقیم پشتیبانی نمی‌کند",
ProcessedAt = DateTime.UtcNow
});
}
/// <summary>
/// استخراج Authority از URL درگاه
/// مثال: https://sandbox.zarinpal.com/pg/StartPay/A00000000000000000000000000123456789 → A00000000000000000000000000123456789
/// </summary>
private static string ExtractAuthorityFromUrl(string gatewayUrl)
{
if (string.IsNullOrEmpty(gatewayUrl))
return string.Empty;
// Authority معمولاً آخرین بخش URL است
var uri = new Uri(gatewayUrl);
var segments = uri.Segments;
if (segments.Length > 0)
{
return segments[^1].TrimEnd('/');
}
return gatewayUrl;
}
public void Dispose()
{
_channel?.Dispose();
}
}
@@ -0,0 +1,358 @@
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using CMSMicroservice.Application.Common.Interfaces;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Infrastructure.Services.Payment;
/// <summary>
/// پیاده‌سازی درگاه پرداخت زرین‌پال
/// ساپورت Sandbox (تست) و Production
/// </summary>
public class ZarinPalPaymentService : IPaymentGatewayService
{
private readonly HttpClient _httpClient;
private readonly ILogger<ZarinPalPaymentService> _logger;
private readonly string _merchantId;
private readonly bool _useSandbox;
// آدرس‌های Production
private const string ProductionApiBase = "https://api.zarinpal.com";
private const string ProductionStartPayBase = "https://www.zarinpal.com";
// آدرس‌های Sandbox
private const string SandboxApiBase = "https://sandbox.zarinpal.com";
private const string SandboxStartPayBase = "https://sandbox.zarinpal.com";
// مسیرهای API (مشترک)
private const string RequestEndpoint = "/pg/v4/payment/request.json";
private const string VerifyEndpoint = "/pg/v4/payment/verify.json";
private const string StartPayPath = "/pg/StartPay/";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public ZarinPalPaymentService(
HttpClient httpClient,
IConfiguration configuration,
ILogger<ZarinPalPaymentService> logger)
{
_httpClient = httpClient;
_logger = logger;
_merchantId = configuration["ZarinPal:MerchantId"]
?? throw new InvalidOperationException("ZarinPal:MerchantId is not configured.");
_useSandbox = configuration.GetValue<bool>("ZarinPal:UseSandbox", true);
var apiBase = _useSandbox ? SandboxApiBase : ProductionApiBase;
_httpClient.BaseAddress = new Uri(apiBase);
_logger.LogInformation("ZarinPal payment service initialized. Mode: {Mode}",
_useSandbox ? "🧪 Sandbox" : "🏦 Production");
}
/// <summary>
/// مرحله ۱: ارسال درخواست پرداخت به زرین‌پال و دریافت Authority
/// </summary>
public async Task<PaymentInitiateResult> InitiatePaymentAsync(
PaymentRequest request,
CancellationToken cancellationToken = default)
{
try
{
// زرین‌پال مبلغ را به ریال می‌خواهد — تبدیل تومان به ریال
var amountInRials = (long)(request.Amount * 10);
var zarinPalRequest = new ZarinPalPaymentRequest
{
MerchantId = _merchantId,
Amount = amountInRials,
Description = request.Description,
CallbackUrl = request.CallbackUrl,
Metadata = new ZarinPalMetadata
{
Mobile = string.IsNullOrWhiteSpace(request.Mobile) ? null : request.Mobile
}
};
var jsonContent = JsonSerializer.Serialize(zarinPalRequest, JsonOptions);
var content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
_logger.LogInformation(
"ZarinPal payment request: Amount={AmountToman} Toman ({AmountRial} Rial), User={UserId}, Sandbox={Sandbox}",
request.Amount, amountInRials, request.UserId, _useSandbox);
var response = await _httpClient.PostAsync(RequestEndpoint, content, cancellationToken);
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogDebug("ZarinPal request response: {StatusCode} - {Body}",
response.StatusCode, responseBody);
var result = JsonSerializer.Deserialize<ZarinPalResponse>(responseBody, JsonOptions);
if (result?.Data?.Code == 100 && !string.IsNullOrEmpty(result.Data.Authority))
{
var startPayBase = _useSandbox ? SandboxStartPayBase : ProductionStartPayBase;
var gatewayUrl = $"{startPayBase}{StartPayPath}{result.Data.Authority}";
_logger.LogInformation(
"ZarinPal payment initiated successfully: Authority={Authority}, GatewayUrl={Url}",
result.Data.Authority, gatewayUrl);
return new PaymentInitiateResult
{
IsSuccess = true,
RefId = result.Data.Authority,
GatewayUrl = gatewayUrl
};
}
// خطا
var errorCode = result?.Errors?.Code ?? result?.Data?.Code ?? -1;
var errorMessage = result?.Errors?.Message ?? "خطای ناشناخته از زرین‌پال";
_logger.LogError(
"ZarinPal payment request failed: Code={Code}, Message={Message}",
errorCode, errorMessage);
return new PaymentInitiateResult
{
IsSuccess = false,
ErrorMessage = $"خطای درگاه زرین‌پال (کد {errorCode}): {errorMessage}"
};
}
catch (Exception ex)
{
_logger.LogError(ex, "ZarinPal InitiatePayment exception");
return new PaymentInitiateResult
{
IsSuccess = false,
ErrorMessage = $"خطا در ارتباط با درگاه زرین‌پال: {ex.Message}"
};
}
}
/// <summary>
/// تأیید پرداخت بدون مبلغ — برای سازگاری با اینترفیس.
/// ⚠ زرین‌پال مبلغ را در Verify نیاز دارد. از overload با amount استفاده کنید.
/// </summary>
public Task<PaymentVerificationResult> VerifyPaymentAsync(
string refId,
string verificationToken,
CancellationToken cancellationToken = default)
{
_logger.LogWarning("ZarinPal VerifyPaymentAsync called without amount — verification may fail!");
return VerifyPaymentWithAmountAsync(refId, verificationToken, 0, cancellationToken);
}
/// <summary>
/// تأیید پرداخت با مبلغ — نسخه اصلی برای زرین‌پال
/// refId = Authority، verificationToken = Status (OK/NOK)، amountInToman = مبلغ به تومان
/// </summary>
public Task<PaymentVerificationResult> VerifyPaymentAsync(
string refId,
string verificationToken,
decimal amountInToman,
CancellationToken cancellationToken = default)
{
return VerifyPaymentWithAmountAsync(refId, verificationToken, amountInToman, cancellationToken);
}
private async Task<PaymentVerificationResult> VerifyPaymentWithAmountAsync(
string refId,
string verificationToken,
decimal amountInToman,
CancellationToken cancellationToken)
{
try
{
// verificationToken باید "OK" باشد — در غیر اینصورت کاربر لغو کرده
if (!string.Equals(verificationToken, "OK", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("ZarinPal payment cancelled by user: Authority={Authority}", refId);
return new PaymentVerificationResult
{
IsSuccess = false,
RefId = refId,
Message = "پرداخت توسط کاربر لغو شد"
};
}
// تبدیل تومان → ریال (×۱۰)
var amountInRials = (long)(amountInToman * 10);
var verifyRequest = new ZarinPalVerifyRequest
{
MerchantId = _merchantId,
Authority = refId,
Amount = amountInRials
};
var jsonContent = JsonSerializer.Serialize(verifyRequest, JsonOptions);
var content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
_logger.LogInformation("ZarinPal verify request: Authority={Authority}, Amount={Amount} Rial",
refId, amountInRials);
var response = await _httpClient.PostAsync(VerifyEndpoint, content, cancellationToken);
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogDebug("ZarinPal verify response: {StatusCode} - {Body}",
response.StatusCode, responseBody);
var result = JsonSerializer.Deserialize<ZarinPalResponse>(responseBody, JsonOptions);
// code 100 = موفق | code 101 = قبلاً تأیید شده
if (result?.Data?.Code is 100 or 101)
{
_logger.LogInformation(
"ZarinPal payment verified: Authority={Authority}, RefId={RefId}, CardPan={CardPan}",
refId, result.Data.RefId, result.Data.CardPan);
return new PaymentVerificationResult
{
IsSuccess = true,
RefId = refId,
TrackingCode = result.Data.RefId?.ToString(),
Amount = (result.Data.Amount ?? 0) / 10m, // ریال → تومان
Message = result.Data.Code == 101
? "تراکنش قبلاً تأیید شده"
: "تراکنش موفق"
};
}
var errorCode = result?.Errors?.Code ?? result?.Data?.Code ?? -1;
var errorMessage = result?.Errors?.Message ?? "تأیید تراکنش ناموفق";
_logger.LogError(
"ZarinPal verify failed: Authority={Authority}, Code={Code}, Message={Message}",
refId, errorCode, errorMessage);
return new PaymentVerificationResult
{
IsSuccess = false,
RefId = refId,
Message = $"تأیید پرداخت ناموفق (کد {errorCode}): {errorMessage}"
};
}
catch (Exception ex)
{
_logger.LogError(ex, "ZarinPal VerifyPayment exception: Authority={Authority}", refId);
return new PaymentVerificationResult
{
IsSuccess = false,
RefId = refId,
Message = $"خطا در تأیید تراکنش: {ex.Message}"
};
}
}
/// <summary>
/// زرین‌پال Payout مستقیم ندارد — این متد NotSupported برمی‌گرداند
/// برای Payout باید از سرویس دیگری (مثل دایا) استفاده شود
/// </summary>
public Task<PayoutResult> ProcessPayoutAsync(
PayoutRequest request,
CancellationToken cancellationToken = default)
{
_logger.LogWarning("ZarinPal does not support direct payout. Use a different provider for payouts.");
return Task.FromResult(new PayoutResult
{
IsSuccess = false,
Message = "درگاه زرین‌پال از قابلیت واریز مستقیم پشتیبانی نمی‌کند",
ProcessedAt = DateTime.UtcNow
});
}
// ── ZarinPal Request/Response DTOs ──
private class ZarinPalPaymentRequest
{
public string MerchantId { get; set; } = string.Empty;
public long Amount { get; set; }
public string Description { get; set; } = string.Empty;
public string CallbackUrl { get; set; } = string.Empty;
public ZarinPalMetadata? Metadata { get; set; }
}
private class ZarinPalMetadata
{
public string? Mobile { get; set; }
public string? Email { get; set; }
}
private class ZarinPalVerifyRequest
{
public string MerchantId { get; set; } = string.Empty;
public long Amount { get; set; }
public string Authority { get; set; } = string.Empty;
}
private class ZarinPalResponse
{
public ZarinPalResponseData? Data { get; set; }
[JsonConverter(typeof(ZarinPalErrorsConverter))]
public ZarinPalResponseErrors? Errors { get; set; }
}
private class ZarinPalResponseData
{
public int? Code { get; set; }
public string? Message { get; set; }
public string? Authority { get; set; }
public long? RefId { get; set; }
public long? Amount { get; set; }
public string? CardPan { get; set; }
public string? CardHash { get; set; }
public string? FeeType { get; set; }
public long? Fee { get; set; }
}
private class ZarinPalResponseErrors
{
public int? Code { get; set; }
public string? Message { get; set; }
}
/// <summary>
/// ZarinPal returns errors as [] (empty array) when no error, or as {...} object when there's an error.
/// This converter handles both cases.
/// </summary>
private class ZarinPalErrorsConverter : JsonConverter<ZarinPalResponseErrors?>
{
public override ZarinPalResponseErrors? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.StartArray)
{
// Skip the empty array []
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) { }
return null;
}
if (reader.TokenType == JsonTokenType.StartObject)
{
return JsonSerializer.Deserialize<ZarinPalResponseErrors>(ref reader);
}
if (reader.TokenType == JsonTokenType.Null)
{
return null;
}
reader.Skip();
return null;
}
public override void Write(Utf8JsonWriter writer, ZarinPalResponseErrors? value, JsonSerializerOptions options)
{
JsonSerializer.Serialize(writer, value, options);
}
}
}