forked from xiongyuxing/tiku-backend.net
feat: add operations catalog readonly endpoints
This commit is contained in:
@@ -370,6 +370,84 @@ public sealed class CatalogController(
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("banners")]
|
||||
[EndpointSummary("查询首页横幅")]
|
||||
[ProducesResponseType<CatalogList<BannerCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CatalogList<BannerCatalogItem>>> GetBanners(
|
||||
[FromQuery] CatalogQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await catalogQueryService.GetBannersAsync(
|
||||
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("faqs")]
|
||||
[EndpointSummary("查询常见问题")]
|
||||
[ProducesResponseType<CatalogList<FaqCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CatalogList<FaqCatalogItem>>> GetFaqs(
|
||||
[FromQuery] CatalogQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await catalogQueryService.GetFaqsAsync(
|
||||
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("announcements")]
|
||||
[EndpointSummary("查询公告")]
|
||||
[ProducesResponseType<CatalogList<AnnouncementCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CatalogList<AnnouncementCatalogItem>>> GetAnnouncements(
|
||||
[FromQuery] CatalogQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await catalogQueryService.GetAnnouncementsAsync(
|
||||
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("exam-dates")]
|
||||
[EndpointSummary("查询考试日期")]
|
||||
[ProducesResponseType<CatalogList<ExamDateCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CatalogList<ExamDateCatalogItem>>> GetExamDates(
|
||||
[FromQuery] CatalogQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await catalogQueryService.GetExamDatesAsync(
|
||||
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("products")]
|
||||
[EndpointSummary("查询可购买产品")]
|
||||
[ProducesResponseType<CatalogList<ProductCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CatalogList<ProductCatalogItem>>> GetProducts(
|
||||
[FromQuery] CatalogQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await catalogQueryService.GetProductsAsync(
|
||||
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("svip-plans")]
|
||||
[EndpointSummary("查询 SVIP 套餐")]
|
||||
[ProducesResponseType<CatalogList<SvipPlanCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CatalogList<SvipPlanCatalogItem>>> GetSvipPlans(
|
||||
[FromQuery] CatalogQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await catalogQueryService.GetSvipPlansAsync(
|
||||
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<Guid> ResolveTenantIdAsync(
|
||||
CatalogQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Commerce;
|
||||
|
||||
namespace Tiku.Application.Catalog;
|
||||
|
||||
@@ -101,3 +102,85 @@ public sealed record CategoryCatalogItem(
|
||||
CategoryType? CategoryType,
|
||||
int Order,
|
||||
int? SvipQuestionLimit);
|
||||
|
||||
public sealed record BannerCatalogItem(
|
||||
Guid Id,
|
||||
string? LegacyId,
|
||||
Guid? RegionId,
|
||||
string? Title,
|
||||
string? Subtitle,
|
||||
string? Content,
|
||||
string? ButtonText,
|
||||
string? ButtonLink,
|
||||
string? BackgroundColor,
|
||||
string? BorderColor,
|
||||
int Order,
|
||||
bool IsActive);
|
||||
|
||||
public sealed record FaqCatalogItem(
|
||||
Guid Id,
|
||||
string? LegacyId,
|
||||
Guid? RegionId,
|
||||
string? Question,
|
||||
string? Answer,
|
||||
int Order,
|
||||
bool IsActive);
|
||||
|
||||
public sealed record AnnouncementCatalogItem(
|
||||
Guid Id,
|
||||
string? LegacyId,
|
||||
string? Content,
|
||||
string? Link,
|
||||
string? BackgroundColor,
|
||||
int Order,
|
||||
bool IsActive);
|
||||
|
||||
public sealed record ExamDateCatalogItem(
|
||||
Guid Id,
|
||||
string? LegacyId,
|
||||
Guid? RegionId,
|
||||
Guid? SchoolId,
|
||||
string ExamName,
|
||||
DateTimeOffset? ExamAt,
|
||||
string? ExamType,
|
||||
string? Description,
|
||||
JsonElement Metadata,
|
||||
int Order,
|
||||
bool IsActive,
|
||||
int? DaysLeft);
|
||||
|
||||
public sealed record ProductCatalogItem(
|
||||
Guid Id,
|
||||
string? LegacyId,
|
||||
Guid? RegionId,
|
||||
string Title,
|
||||
decimal? Price,
|
||||
string? PriceLabel,
|
||||
string? Link,
|
||||
ProductType Type,
|
||||
JsonElement Tags,
|
||||
string? Cover,
|
||||
string? PreviewIframe,
|
||||
JsonElement DetailImages,
|
||||
int Order,
|
||||
bool IsActive);
|
||||
|
||||
public sealed record SvipPlanCatalogItem(
|
||||
Guid Id,
|
||||
string? LegacyId,
|
||||
Guid? RegionId,
|
||||
string Name,
|
||||
int PriceCents,
|
||||
decimal Price,
|
||||
int? OriginalPriceCents,
|
||||
decimal? OriginalPrice,
|
||||
int Days,
|
||||
string? Description,
|
||||
string? PerDayLabel,
|
||||
string? Badge,
|
||||
bool Recommended,
|
||||
bool CouponOnly,
|
||||
string? VpProductId,
|
||||
bool VpEnabled,
|
||||
int Order,
|
||||
bool IsActive);
|
||||
|
||||
@@ -29,4 +29,28 @@ public interface ICatalogQueryService
|
||||
Task<CatalogList<CategoryCatalogItem>> GetCategoriesAsync(
|
||||
CatalogFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CatalogList<BannerCatalogItem>> GetBannersAsync(
|
||||
CatalogFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CatalogList<FaqCatalogItem>> GetFaqsAsync(
|
||||
CatalogFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CatalogList<AnnouncementCatalogItem>> GetAnnouncementsAsync(
|
||||
CatalogFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CatalogList<ExamDateCatalogItem>> GetExamDatesAsync(
|
||||
CatalogFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CatalogList<ProductCatalogItem>> GetProductsAsync(
|
||||
CatalogFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CatalogList<SvipPlanCatalogItem>> GetSvipPlansAsync(
|
||||
CatalogFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ public sealed class Product : AuditableTenantEntity
|
||||
public string? Cover { get; set; }
|
||||
public string? PreviewIframe { get; set; }
|
||||
public JsonElement DetailImages { get; set; } = JsonDefaults.Array();
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class SvipPlan : AuditableTenantEntity
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
@@ -162,6 +165,316 @@ public sealed class CatalogEndpointTests
|
||||
Assert.Equal("tenant_not_found", body.RootElement.GetProperty("code").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Banners_are_filtered_by_tenant_region_and_active_status()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var otherTenantId = Guid.NewGuid();
|
||||
var regionId = Guid.NewGuid();
|
||||
var otherRegionId = Guid.NewGuid();
|
||||
await using var factory = new ApiTestFactory();
|
||||
await factory.SeedAsync(
|
||||
Tenant(tenantId, "master"),
|
||||
Tenant(otherTenantId, "other"),
|
||||
new Region { Id = regionId, TenantId = tenantId, Name = "浙江" },
|
||||
new Region { Id = otherRegionId, TenantId = tenantId, Name = "江苏" },
|
||||
new Banner
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
RegionId = regionId,
|
||||
Title = "第二张",
|
||||
SortOrder = 2,
|
||||
IsActive = true
|
||||
},
|
||||
new Banner
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
RegionId = regionId,
|
||||
Title = "第一张",
|
||||
SortOrder = 1,
|
||||
IsActive = true
|
||||
},
|
||||
new Banner
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
RegionId = regionId,
|
||||
Title = "停用横幅",
|
||||
IsActive = false
|
||||
},
|
||||
new Banner
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
RegionId = otherRegionId,
|
||||
Title = "其他地区横幅",
|
||||
IsActive = true
|
||||
},
|
||||
new Banner
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = otherTenantId,
|
||||
Title = "其他租户横幅",
|
||||
IsActive = true
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync($"/api/catalog/banners?tenantCode=master®ionId={regionId}");
|
||||
var items = await ReadItemsAsync(response);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(["第一张", "第二张"], items.Select(item => item.GetProperty("title").GetString()!).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Faqs_and_announcements_only_return_active_current_tenant_items()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var otherTenantId = Guid.NewGuid();
|
||||
await using var factory = new ApiTestFactory();
|
||||
await factory.SeedAsync(
|
||||
Tenant(tenantId, "master"),
|
||||
Tenant(otherTenantId, "other"),
|
||||
new Faq
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
Question = "如何购买?",
|
||||
Answer = "在线购买",
|
||||
IsActive = true
|
||||
},
|
||||
new Faq
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
Question = "停用 FAQ",
|
||||
IsActive = false
|
||||
},
|
||||
new Faq
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = otherTenantId,
|
||||
Question = "其他租户 FAQ",
|
||||
IsActive = true
|
||||
},
|
||||
new Announcement
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
Content = "暑期活动",
|
||||
IsActive = true
|
||||
},
|
||||
new Announcement
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
Content = "停用公告",
|
||||
IsActive = false
|
||||
},
|
||||
new Announcement
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = otherTenantId,
|
||||
Content = "其他租户公告",
|
||||
IsActive = true
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var faqResponse = await client.GetAsync("/api/catalog/faqs?tenantCode=master");
|
||||
using var announcementResponse = await client.GetAsync("/api/catalog/announcements?tenantCode=master");
|
||||
var faqs = await ReadItemsAsync(faqResponse);
|
||||
var announcements = await ReadItemsAsync(announcementResponse);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, faqResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, announcementResponse.StatusCode);
|
||||
Assert.Equal("如何购买?", Assert.Single(faqs).GetProperty("question").GetString());
|
||||
Assert.Equal("暑期活动", Assert.Single(announcements).GetProperty("content").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exam_dates_support_region_school_filters_and_days_left()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var regionId = Guid.NewGuid();
|
||||
var schoolId = Guid.NewGuid();
|
||||
var futureExamAt = new DateTimeOffset(DateTimeOffset.UtcNow.Date.AddDays(10), TimeSpan.Zero);
|
||||
await using var factory = new ApiTestFactory();
|
||||
await factory.SeedAsync(
|
||||
Tenant(tenantId, "master"),
|
||||
new Region { Id = regionId, TenantId = tenantId, Name = "浙江" },
|
||||
new School { Id = schoolId, TenantId = tenantId, RegionId = regionId, Name = "测试院校" },
|
||||
new ExamDate
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
RegionId = regionId,
|
||||
SchoolId = schoolId,
|
||||
ExamName = "校考",
|
||||
ExamAt = futureExamAt,
|
||||
IsActive = true
|
||||
},
|
||||
new ExamDate
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
ExamName = "通用考试",
|
||||
ExamAt = futureExamAt.AddDays(1),
|
||||
IsActive = true
|
||||
},
|
||||
new ExamDate
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
RegionId = Guid.NewGuid(),
|
||||
SchoolId = Guid.NewGuid(),
|
||||
ExamName = "其他地区学校考试",
|
||||
ExamAt = futureExamAt.AddDays(2),
|
||||
IsActive = true
|
||||
},
|
||||
new ExamDate
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
RegionId = regionId,
|
||||
SchoolId = schoolId,
|
||||
ExamName = "停用考试",
|
||||
IsActive = false
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync(
|
||||
$"/api/catalog/exam-dates?tenantCode=master®ionId={regionId}&schoolId={schoolId}");
|
||||
var items = await ReadItemsAsync(response);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(["校考", "通用考试"], items.Select(item => item.GetProperty("examName").GetString()!).ToArray());
|
||||
Assert.Equal(10, items[0].GetProperty("daysLeft").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Products_filter_active_region_type_keyword_and_limit()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var regionId = Guid.NewGuid();
|
||||
await using var factory = new ApiTestFactory();
|
||||
await factory.SeedAsync(
|
||||
Tenant(tenantId, "master"),
|
||||
new Region { Id = regionId, TenantId = tenantId, Name = "浙江" },
|
||||
new Product
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
RegionId = regionId,
|
||||
Title = "联考资料 B",
|
||||
Type = ProductType.Material,
|
||||
SortOrder = 2,
|
||||
LegacyPriceText = "¥20",
|
||||
IsActive = true
|
||||
},
|
||||
new Product
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
RegionId = regionId,
|
||||
Title = "联考资料 A",
|
||||
Type = ProductType.Material,
|
||||
SortOrder = 1,
|
||||
LegacyPriceText = "¥10",
|
||||
IsActive = true
|
||||
},
|
||||
new Product
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
RegionId = regionId,
|
||||
Title = "联考课程",
|
||||
Type = ProductType.Course,
|
||||
IsActive = true
|
||||
},
|
||||
new Product
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
RegionId = regionId,
|
||||
Title = "停用资料",
|
||||
Type = ProductType.Material,
|
||||
IsActive = false
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync(
|
||||
$"/api/catalog/products?tenantCode=master®ionId={regionId}&type=material&keyword=联考&limit=1");
|
||||
var items = await ReadItemsAsync(response);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var item = Assert.Single(items);
|
||||
Assert.Equal("联考资料 A", item.GetProperty("title").GetString());
|
||||
Assert.Equal("¥10", item.GetProperty("priceLabel").GetString());
|
||||
Assert.True(item.GetProperty("isActive").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Svip_plans_filter_active_region_and_include_global_plans()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var regionId = Guid.NewGuid();
|
||||
await using var factory = new ApiTestFactory();
|
||||
await factory.SeedAsync(
|
||||
Tenant(tenantId, "master"),
|
||||
new Region { Id = regionId, TenantId = tenantId, Name = "浙江" },
|
||||
new SvipPlan
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
RegionId = regionId,
|
||||
Name = "地区月卡",
|
||||
PriceCents = 3000,
|
||||
Days = 30,
|
||||
SortOrder = 2,
|
||||
IsActive = true
|
||||
},
|
||||
new SvipPlan
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
Name = "通用周卡",
|
||||
PriceCents = 900,
|
||||
Days = 7,
|
||||
SortOrder = 1,
|
||||
IsActive = true
|
||||
},
|
||||
new SvipPlan
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
RegionId = Guid.NewGuid(),
|
||||
Name = "其他地区卡",
|
||||
PriceCents = 1000,
|
||||
Days = 7,
|
||||
IsActive = true
|
||||
},
|
||||
new SvipPlan
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
RegionId = regionId,
|
||||
Name = "停用卡",
|
||||
PriceCents = 1,
|
||||
Days = 1,
|
||||
IsActive = false
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync($"/api/catalog/svip-plans?tenantCode=master®ionId={regionId}");
|
||||
var items = await ReadItemsAsync(response);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(["通用周卡", "地区月卡"], items.Select(item => item.GetProperty("name").GetString()!).ToArray());
|
||||
Assert.Equal(9m, items[0].GetProperty("price").GetDecimal());
|
||||
}
|
||||
|
||||
private static Tenant Tenant(Guid id, string slug)
|
||||
{
|
||||
return new Tenant
|
||||
|
||||
@@ -556,6 +556,23 @@ public sealed class PersistenceModelTests
|
||||
{
|
||||
using var context = new TikuDbContext(Options);
|
||||
|
||||
var productType = context.Model.FindEntityType(typeof(Product))!;
|
||||
var isActive = productType.FindProperty(nameof(Product.IsActive))!;
|
||||
var productsTable = StoreObjectIdentifier.Table("products", null);
|
||||
Assert.Equal("is_active", isActive.GetColumnName(productsTable));
|
||||
Assert.Contains(
|
||||
productType.GetIndexes(),
|
||||
index => !index.IsUnique
|
||||
&& index.Properties
|
||||
.Select(property => property.Name)
|
||||
.SequenceEqual([
|
||||
nameof(Product.TenantId),
|
||||
nameof(Product.RegionId),
|
||||
nameof(Product.Type),
|
||||
nameof(Product.IsActive),
|
||||
nameof(Product.SortOrder)
|
||||
]));
|
||||
|
||||
AssertHasUniqueIndex<Order>(nameof(Order.TenantId), nameof(Order.OrderNo));
|
||||
AssertHasUniqueIndex<ActivationCode>(nameof(ActivationCode.TenantId), nameof(ActivationCode.Code));
|
||||
AssertHasUniqueIndex<TenantPaymentAccount>(
|
||||
|
||||
Reference in New Issue
Block a user