forked from xiongyuxing/tiku-backend.net
feat: add operations catalog readonly endpoints
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Catalog;
|
||||
@@ -326,6 +327,309 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery
|
||||
return new CatalogList<CategoryCatalogItem>(items);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<BannerCatalogItem>> GetBannersAsync(
|
||||
CatalogFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.Banners
|
||||
.AsNoTracking()
|
||||
.Where(banner =>
|
||||
banner.TenantId == filter.TenantId &&
|
||||
banner.IsActive);
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(banner => banner.RegionId == filter.RegionId.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
var keyword = filter.Keyword.Trim();
|
||||
query = query.Where(banner =>
|
||||
(banner.Title != null && banner.Title.Contains(keyword)) ||
|
||||
(banner.Subtitle != null && banner.Subtitle.Contains(keyword)) ||
|
||||
(banner.Content != null && banner.Content.Contains(keyword)));
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderBy(banner => banner.SortOrder)
|
||||
.ThenBy(banner => banner.CreatedAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(banner => new BannerCatalogItem(
|
||||
banner.Id,
|
||||
banner.LegacyId,
|
||||
banner.RegionId,
|
||||
banner.Title,
|
||||
banner.Subtitle,
|
||||
banner.Content,
|
||||
banner.ButtonText,
|
||||
banner.ButtonLink,
|
||||
banner.BackgroundColor,
|
||||
banner.BorderColor,
|
||||
banner.SortOrder,
|
||||
banner.IsActive))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new CatalogList<BannerCatalogItem>(items);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<FaqCatalogItem>> GetFaqsAsync(
|
||||
CatalogFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.Faqs
|
||||
.AsNoTracking()
|
||||
.Where(faq =>
|
||||
faq.TenantId == filter.TenantId &&
|
||||
faq.IsActive);
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(faq => faq.RegionId == filter.RegionId.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
var keyword = filter.Keyword.Trim();
|
||||
query = query.Where(faq =>
|
||||
(faq.Question != null && faq.Question.Contains(keyword)) ||
|
||||
(faq.Answer != null && faq.Answer.Contains(keyword)));
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderBy(faq => faq.SortOrder)
|
||||
.ThenBy(faq => faq.CreatedAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(faq => new FaqCatalogItem(
|
||||
faq.Id,
|
||||
faq.LegacyId,
|
||||
faq.RegionId,
|
||||
faq.Question,
|
||||
faq.Answer,
|
||||
faq.SortOrder,
|
||||
faq.IsActive))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new CatalogList<FaqCatalogItem>(items);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<AnnouncementCatalogItem>> GetAnnouncementsAsync(
|
||||
CatalogFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.Announcements
|
||||
.AsNoTracking()
|
||||
.Where(announcement =>
|
||||
announcement.TenantId == filter.TenantId &&
|
||||
announcement.IsActive);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
var keyword = filter.Keyword.Trim();
|
||||
query = query.Where(announcement =>
|
||||
announcement.Content != null && announcement.Content.Contains(keyword));
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderBy(announcement => announcement.SortOrder)
|
||||
.ThenBy(announcement => announcement.CreatedAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(announcement => new AnnouncementCatalogItem(
|
||||
announcement.Id,
|
||||
announcement.LegacyId,
|
||||
announcement.Content,
|
||||
announcement.Link,
|
||||
announcement.BackgroundColor,
|
||||
announcement.SortOrder,
|
||||
announcement.IsActive))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new CatalogList<AnnouncementCatalogItem>(items);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<ExamDateCatalogItem>> GetExamDatesAsync(
|
||||
CatalogFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.ExamDates
|
||||
.AsNoTracking()
|
||||
.Where(examDate =>
|
||||
examDate.TenantId == filter.TenantId &&
|
||||
examDate.IsActive);
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(examDate =>
|
||||
examDate.RegionId == filter.RegionId.Value ||
|
||||
examDate.RegionId == null);
|
||||
}
|
||||
|
||||
if (filter.SchoolId.HasValue)
|
||||
{
|
||||
query = query.Where(examDate =>
|
||||
examDate.SchoolId == filter.SchoolId.Value ||
|
||||
examDate.SchoolId == null);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Type))
|
||||
{
|
||||
query = query.Where(examDate => examDate.ExamType == filter.Type.Trim());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
var keyword = filter.Keyword.Trim();
|
||||
query = query.Where(examDate =>
|
||||
examDate.ExamName.Contains(keyword) ||
|
||||
(examDate.Description != null && examDate.Description.Contains(keyword)));
|
||||
}
|
||||
|
||||
var today = DateTimeOffset.UtcNow.Date;
|
||||
var rows = await query
|
||||
.OrderBy(examDate => examDate.ExamAt == null)
|
||||
.ThenBy(examDate => examDate.ExamAt)
|
||||
.ThenBy(examDate => examDate.SortOrder)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(examDate => new
|
||||
{
|
||||
examDate.Id,
|
||||
examDate.LegacyId,
|
||||
examDate.RegionId,
|
||||
examDate.SchoolId,
|
||||
examDate.ExamName,
|
||||
examDate.ExamAt,
|
||||
examDate.ExamType,
|
||||
examDate.Description,
|
||||
examDate.Metadata,
|
||||
examDate.SortOrder,
|
||||
examDate.IsActive
|
||||
})
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
var items = rows
|
||||
.Select(examDate => new ExamDateCatalogItem(
|
||||
examDate.Id,
|
||||
examDate.LegacyId,
|
||||
examDate.RegionId,
|
||||
examDate.SchoolId,
|
||||
examDate.ExamName,
|
||||
examDate.ExamAt,
|
||||
examDate.ExamType,
|
||||
examDate.Description,
|
||||
examDate.Metadata,
|
||||
examDate.SortOrder,
|
||||
examDate.IsActive,
|
||||
examDate.ExamAt.HasValue
|
||||
? (int?)(examDate.ExamAt.Value.Date - today).Days
|
||||
: null))
|
||||
.ToArray();
|
||||
|
||||
return new CatalogList<ExamDateCatalogItem>(items);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<ProductCatalogItem>> GetProductsAsync(
|
||||
CatalogFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.Products
|
||||
.AsNoTracking()
|
||||
.Where(product =>
|
||||
product.TenantId == filter.TenantId &&
|
||||
product.IsActive);
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(product => product.RegionId == filter.RegionId.Value);
|
||||
}
|
||||
|
||||
if (TryParseProductType(filter.Type, out var productType))
|
||||
{
|
||||
query = query.Where(product => product.Type == productType);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
var keyword = filter.Keyword.Trim();
|
||||
query = query.Where(product => product.Title.Contains(keyword));
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderBy(product => product.SortOrder)
|
||||
.ThenByDescending(product => product.CreatedAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(product => new ProductCatalogItem(
|
||||
product.Id,
|
||||
product.LegacyId,
|
||||
product.RegionId,
|
||||
product.Title,
|
||||
null,
|
||||
product.LegacyPriceText,
|
||||
product.Link,
|
||||
product.Type,
|
||||
product.Tags,
|
||||
product.Cover,
|
||||
product.PreviewIframe,
|
||||
product.DetailImages,
|
||||
product.SortOrder,
|
||||
product.IsActive))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new CatalogList<ProductCatalogItem>(items);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<SvipPlanCatalogItem>> GetSvipPlansAsync(
|
||||
CatalogFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.SvipPlans
|
||||
.AsNoTracking()
|
||||
.Where(plan =>
|
||||
plan.TenantId == filter.TenantId &&
|
||||
plan.IsActive);
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(plan =>
|
||||
plan.RegionId == filter.RegionId.Value ||
|
||||
plan.RegionId == null);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
var keyword = filter.Keyword.Trim();
|
||||
query = query.Where(plan =>
|
||||
plan.Name.Contains(keyword) ||
|
||||
(plan.Description != null && plan.Description.Contains(keyword)));
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderBy(plan => plan.SortOrder)
|
||||
.ThenBy(plan => plan.PriceCents)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(plan => new SvipPlanCatalogItem(
|
||||
plan.Id,
|
||||
plan.LegacyId,
|
||||
plan.RegionId,
|
||||
plan.Name,
|
||||
plan.PriceCents,
|
||||
plan.PriceCents / 100m,
|
||||
plan.OriginalPriceCents,
|
||||
plan.OriginalPriceCents.HasValue ? plan.OriginalPriceCents.Value / 100m : null,
|
||||
plan.Days,
|
||||
plan.Description,
|
||||
plan.PerDayLabel,
|
||||
plan.Badge,
|
||||
plan.Recommended,
|
||||
plan.CouponOnly,
|
||||
plan.VpProductId,
|
||||
plan.VpEnabled,
|
||||
plan.SortOrder,
|
||||
plan.IsActive))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new CatalogList<SvipPlanCatalogItem>(items);
|
||||
}
|
||||
|
||||
private static IQueryable<T> ApplyKeyword<T>(IQueryable<T> query, string? keyword)
|
||||
where T : class
|
||||
{
|
||||
@@ -358,6 +662,11 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery
|
||||
return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out type);
|
||||
}
|
||||
|
||||
private static bool TryParseProductType(string? value, out ProductType type)
|
||||
{
|
||||
return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out type);
|
||||
}
|
||||
|
||||
private static string? NormalizeEnumValue(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
|
||||
@@ -22,8 +22,9 @@ internal sealed class ProductConfiguration : IEntityTypeConfiguration<Product>
|
||||
builder.Property(entity => entity.Cover).HasMaxLength(2048);
|
||||
builder.Property(entity => entity.PreviewIframe).HasMaxLength(2048);
|
||||
builder.Property(entity => entity.DetailImages).IsJson("[]");
|
||||
builder.Property(entity => entity.IsActive).HasDefaultValue(true);
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.RegionId, entity.Type, entity.SortOrder });
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.RegionId, entity.Type, entity.IsActive, entity.SortOrder });
|
||||
builder.HasOne<Region>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
|
||||
15308
Tiku.Infrastructure/Persistence/Migrations/20260726071027_AddProductActiveAndOperationsCatalog.Designer.cs
generated
Normal file
15308
Tiku.Infrastructure/Persistence/Migrations/20260726071027_AddProductActiveAndOperationsCatalog.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddProductActiveAndOperationsCatalog : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_products_tenant_id_region_id_type_sort_order",
|
||||
table: "products");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "is_active",
|
||||
table: "products",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_products_tenant_id_region_id_type_is_active_sort_order",
|
||||
table: "products",
|
||||
columns: new[] { "tenant_id", "region_id", "type", "is_active", "sort_order" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_products_tenant_id_region_id_type_is_active_sort_order",
|
||||
table: "products");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "is_active",
|
||||
table: "products");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_products_tenant_id_region_id_type_sort_order",
|
||||
table: "products",
|
||||
columns: new[] { "tenant_id", "region_id", "type", "sort_order" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2441,6 +2441,12 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasColumnName("detail_images")
|
||||
.HasDefaultValueSql("'[]'::jsonb");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("is_active");
|
||||
|
||||
b.Property<string>("LegacyId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
@@ -2507,8 +2513,8 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_products_tenant_id_legacy_id");
|
||||
|
||||
b.HasIndex("TenantId", "RegionId", "Type", "SortOrder")
|
||||
.HasDatabaseName("ix_products_tenant_id_region_id_type_sort_order");
|
||||
b.HasIndex("TenantId", "RegionId", "Type", "IsActive", "SortOrder")
|
||||
.HasDatabaseName("ix_products_tenant_id_region_id_type_is_active_sort_order");
|
||||
|
||||
b.ToTable("products", (string)null);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user