feat: add operations catalog readonly endpoints

This commit is contained in:
xiong
2026-07-26 15:11:32 +08:00
parent fc80176a90
commit b5be831749
11 changed files with 16190 additions and 3 deletions

View File

@@ -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)

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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 })

View File

@@ -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" });
}
}
}

View File

@@ -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);
});

View File

@@ -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&regionId={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&regionId={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&regionId={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&regionId={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

View File

@@ -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>(