feat(marketing): implement marketing calendar backend
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 1m59s

This commit is contained in:
2026-03-03 10:10:41 +08:00
parent decfa4fa12
commit 1b28fa6db4
8 changed files with 1891 additions and 1 deletions

View File

@@ -0,0 +1,190 @@
namespace TakeoutSaaS.TenantApi.Contracts.Marketing;
/// <summary>
/// 营销日历总览查询请求。
/// </summary>
public sealed class MarketingCalendarOverviewRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 年份。
/// </summary>
public int Year { get; set; }
/// <summary>
/// 月份1-12
/// </summary>
public int Month { get; set; }
}
/// <summary>
/// 营销日历总览响应。
/// </summary>
public sealed class MarketingCalendarOverviewResponse
{
public string Month { get; set; } = string.Empty;
public int Year { get; set; }
public int MonthValue { get; set; }
public string MonthStartDate { get; set; } = string.Empty;
public string MonthEndDate { get; set; } = string.Empty;
public int TodayDay { get; set; }
public List<MarketingCalendarDayResponse> Days { get; set; } = [];
public List<MarketingCalendarLegendResponse> Legends { get; set; } = [];
public MarketingCalendarStatsResponse Stats { get; set; } = new();
public MarketingCalendarConflictBannerResponse? ConflictBanner { get; set; }
public List<MarketingCalendarConflictResponse> Conflicts { get; set; } = [];
public List<MarketingCalendarActivityResponse> Activities { get; set; } = [];
}
public sealed class MarketingCalendarDayResponse
{
public int Day { get; set; }
public bool IsWeekend { get; set; }
public bool IsToday { get; set; }
}
public sealed class MarketingCalendarLegendResponse
{
public string Type { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public string Color { get; set; } = string.Empty;
}
public sealed class MarketingCalendarStatsResponse
{
public int TotalActivityCount { get; set; }
public int OngoingCount { get; set; }
public int MaxConcurrentCount { get; set; }
public decimal EstimatedDiscountAmount { get; set; }
}
public sealed class MarketingCalendarActivityResponse
{
public string ActivityId { get; set; } = string.Empty;
public string SourceType { get; set; } = string.Empty;
public string SourceId { get; set; } = string.Empty;
public string CalendarType { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Color { get; set; } = string.Empty;
public string Summary { get; set; } = string.Empty;
public string DisplayStatus { get; set; } = string.Empty;
public bool IsDimmed { get; set; }
public string StartDate { get; set; } = string.Empty;
public string EndDate { get; set; } = string.Empty;
public decimal EstimatedDiscountAmount { get; set; }
public List<MarketingCalendarActivityBarResponse> Bars { get; set; } = [];
public MarketingCalendarActivityDetailResponse Detail { get; set; } = new();
}
public sealed class MarketingCalendarActivityBarResponse
{
public string BarId { get; set; } = string.Empty;
public int StartDay { get; set; }
public int EndDay { get; set; }
public string Label { get; set; } = string.Empty;
public bool IsMilestone { get; set; }
public bool IsDimmed { get; set; }
}
public sealed class MarketingCalendarActivityDetailResponse
{
public string ModuleName { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public List<MarketingCalendarDetailFieldResponse> Fields { get; set; } = [];
}
public sealed class MarketingCalendarDetailFieldResponse
{
public string Label { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
}
public sealed class MarketingCalendarConflictBannerResponse
{
public string ConflictId { get; set; } = string.Empty;
public int StartDay { get; set; }
public int EndDay { get; set; }
public int ActivityCount { get; set; }
public int MaxConcurrentCount { get; set; }
public int ConflictCount { get; set; }
}
public sealed class MarketingCalendarConflictResponse
{
public string ConflictId { get; set; } = string.Empty;
public int StartDay { get; set; }
public int EndDay { get; set; }
public int ActivityCount { get; set; }
public int MaxConcurrentCount { get; set; }
public List<string> ActivityIds { get; set; } = [];
public List<MarketingCalendarConflictActivityResponse> Activities { get; set; } = [];
}
public sealed class MarketingCalendarConflictActivityResponse
{
public string ActivityId { get; set; } = string.Empty;
public string CalendarType { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Summary { get; set; } = string.Empty;
public string Color { get; set; } = string.Empty;
public string DisplayStatus { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,171 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Application.App.Coupons.Calendar.Dto;
using TakeoutSaaS.Application.App.Coupons.Calendar.Queries;
using TakeoutSaaS.Application.App.Stores.Services;
using TakeoutSaaS.Infrastructure.App.Persistence;
using TakeoutSaaS.Module.Authorization.Attributes;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
using TakeoutSaaS.TenantApi.Contracts.Marketing;
namespace TakeoutSaaS.TenantApi.Controllers;
/// <summary>
/// 营销中心营销日历。
/// </summary>
[ApiVersion("1.0")]
[Authorize]
[Route("api/tenant/v{version:apiVersion}/marketing/calendar")]
public sealed class MarketingCalendarController(
IMediator mediator,
TakeoutAppDbContext dbContext,
StoreContextService storeContextService)
: BaseApiController
{
private const string ViewPermission = "tenant:marketing:calendar:view";
private const string ManagePermission = "tenant:marketing:calendar:manage";
/// <summary>
/// 获取营销日历总览。
/// </summary>
[HttpGet("overview")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<MarketingCalendarOverviewResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MarketingCalendarOverviewResponse>> Overview(
[FromQuery] MarketingCalendarOverviewRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new GetMarketingCalendarOverviewQuery
{
StoreId = storeId,
Year = request.Year,
Month = request.Month
}, cancellationToken);
return ApiResponse<MarketingCalendarOverviewResponse>.Ok(MapOverview(result));
}
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
{
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
}
private static MarketingCalendarOverviewResponse MapOverview(MarketingCalendarOverviewDto source)
{
return new MarketingCalendarOverviewResponse
{
Month = source.Month,
Year = source.Year,
MonthValue = source.MonthValue,
MonthStartDate = StoreApiHelpers.ToDateOnly(source.MonthStartDate),
MonthEndDate = StoreApiHelpers.ToDateOnly(source.MonthEndDate),
TodayDay = source.TodayDay,
Days = source.Days
.Select(item => new MarketingCalendarDayResponse
{
Day = item.Day,
IsWeekend = item.IsWeekend,
IsToday = item.IsToday
})
.ToList(),
Legends = source.Legends
.Select(item => new MarketingCalendarLegendResponse
{
Type = item.Type,
Label = item.Label,
Color = item.Color
})
.ToList(),
Stats = new MarketingCalendarStatsResponse
{
TotalActivityCount = source.Stats.TotalActivityCount,
OngoingCount = source.Stats.OngoingCount,
MaxConcurrentCount = source.Stats.MaxConcurrentCount,
EstimatedDiscountAmount = source.Stats.EstimatedDiscountAmount
},
ConflictBanner = source.ConflictBanner is null
? null
: new MarketingCalendarConflictBannerResponse
{
ConflictId = source.ConflictBanner.ConflictId,
StartDay = source.ConflictBanner.StartDay,
EndDay = source.ConflictBanner.EndDay,
ActivityCount = source.ConflictBanner.ActivityCount,
MaxConcurrentCount = source.ConflictBanner.MaxConcurrentCount,
ConflictCount = source.ConflictBanner.ConflictCount
},
Conflicts = source.Conflicts
.Select(MapConflict)
.ToList(),
Activities = source.Activities
.Select(MapActivity)
.ToList()
};
}
private static MarketingCalendarActivityResponse MapActivity(MarketingCalendarActivityDto source)
{
return new MarketingCalendarActivityResponse
{
ActivityId = source.ActivityId,
SourceType = source.SourceType,
SourceId = source.SourceId,
CalendarType = source.CalendarType,
Name = source.Name,
Color = source.Color,
Summary = source.Summary,
DisplayStatus = source.DisplayStatus,
IsDimmed = source.IsDimmed,
StartDate = StoreApiHelpers.ToDateOnly(source.StartDate),
EndDate = StoreApiHelpers.ToDateOnly(source.EndDate),
EstimatedDiscountAmount = source.EstimatedDiscountAmount,
Bars = source.Bars.Select(item => new MarketingCalendarActivityBarResponse
{
BarId = item.BarId,
StartDay = item.StartDay,
EndDay = item.EndDay,
Label = item.Label,
IsMilestone = item.IsMilestone,
IsDimmed = item.IsDimmed
}).ToList(),
Detail = new MarketingCalendarActivityDetailResponse
{
ModuleName = source.Detail.ModuleName,
Description = source.Detail.Description,
Fields = source.Detail.Fields.Select(item => new MarketingCalendarDetailFieldResponse
{
Label = item.Label,
Value = item.Value
}).ToList()
}
};
}
private static MarketingCalendarConflictResponse MapConflict(MarketingCalendarConflictDto source)
{
return new MarketingCalendarConflictResponse
{
ConflictId = source.ConflictId,
StartDay = source.StartDay,
EndDay = source.EndDay,
ActivityCount = source.ActivityCount,
MaxConcurrentCount = source.MaxConcurrentCount,
ActivityIds = source.ActivityIds.ToList(),
Activities = source.Activities.Select(item => new MarketingCalendarConflictActivityResponse
{
ActivityId = item.ActivityId,
CalendarType = item.CalendarType,
Name = item.Name,
Summary = item.Summary,
Color = item.Color,
DisplayStatus = item.DisplayStatus
}).ToList()
};
}
}

View File

@@ -0,0 +1,397 @@
namespace TakeoutSaaS.Application.App.Coupons.Calendar.Dto;
/// <summary>
/// 营销日历总览。
/// </summary>
public sealed class MarketingCalendarOverviewDto
{
/// <summary>
/// 月份标识yyyy-MM
/// </summary>
public string Month { get; init; } = string.Empty;
/// <summary>
/// 年份。
/// </summary>
public int Year { get; init; }
/// <summary>
/// 月份1-12
/// </summary>
public int MonthValue { get; init; }
/// <summary>
/// 月初日期UTC
/// </summary>
public DateTime MonthStartDate { get; init; }
/// <summary>
/// 月末日期UTC
/// </summary>
public DateTime MonthEndDate { get; init; }
/// <summary>
/// 今天所在日(不在当月则为 0
/// </summary>
public int TodayDay { get; init; }
/// <summary>
/// 日期头列表。
/// </summary>
public IReadOnlyList<MarketingCalendarDayDto> Days { get; init; } = [];
/// <summary>
/// 图例。
/// </summary>
public IReadOnlyList<MarketingCalendarLegendDto> Legends { get; init; } = [];
/// <summary>
/// 顶部统计。
/// </summary>
public MarketingCalendarStatsDto Stats { get; init; } = new();
/// <summary>
/// 冲突横幅。
/// </summary>
public MarketingCalendarConflictBannerDto? ConflictBanner { get; init; }
/// <summary>
/// 冲突区间。
/// </summary>
public IReadOnlyList<MarketingCalendarConflictDto> Conflicts { get; init; } = [];
/// <summary>
/// 活动列表。
/// </summary>
public IReadOnlyList<MarketingCalendarActivityDto> Activities { get; init; } = [];
}
/// <summary>
/// 日期头。
/// </summary>
public sealed class MarketingCalendarDayDto
{
/// <summary>
/// 日1-31
/// </summary>
public int Day { get; init; }
/// <summary>
/// 是否周末。
/// </summary>
public bool IsWeekend { get; init; }
/// <summary>
/// 是否今日。
/// </summary>
public bool IsToday { get; init; }
}
/// <summary>
/// 图例。
/// </summary>
public sealed class MarketingCalendarLegendDto
{
/// <summary>
/// 图例类型。
/// </summary>
public string Type { get; init; } = string.Empty;
/// <summary>
/// 图例名称。
/// </summary>
public string Label { get; init; } = string.Empty;
/// <summary>
/// 图例颜色。
/// </summary>
public string Color { get; init; } = string.Empty;
}
/// <summary>
/// 顶部统计。
/// </summary>
public sealed class MarketingCalendarStatsDto
{
/// <summary>
/// 本月活动数。
/// </summary>
public int TotalActivityCount { get; init; }
/// <summary>
/// 进行中活动数。
/// </summary>
public int OngoingCount { get; init; }
/// <summary>
/// 最大并行活动数。
/// </summary>
public int MaxConcurrentCount { get; init; }
/// <summary>
/// 本月预计优惠金额。
/// </summary>
public decimal EstimatedDiscountAmount { get; init; }
}
/// <summary>
/// 活动。
/// </summary>
public sealed class MarketingCalendarActivityDto
{
/// <summary>
/// 活动唯一键(跨模块)。
/// </summary>
public string ActivityId { get; init; } = string.Empty;
/// <summary>
/// 来源模块full_reduction/flash_sale/seckill/coupon/punch_card
/// </summary>
public string SourceType { get; init; } = string.Empty;
/// <summary>
/// 来源标识。
/// </summary>
public string SourceId { get; init; } = string.Empty;
/// <summary>
/// 日历类型reduce/gift/second_half/flash_sale/seckill/coupon/punch_card
/// </summary>
public string CalendarType { get; init; } = string.Empty;
/// <summary>
/// 活动名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 活动颜色。
/// </summary>
public string Color { get; init; } = string.Empty;
/// <summary>
/// 活动摘要。
/// </summary>
public string Summary { get; init; } = string.Empty;
/// <summary>
/// 展示状态ongoing/upcoming/ended/disabled
/// </summary>
public string DisplayStatus { get; init; } = string.Empty;
/// <summary>
/// 是否弱化。
/// </summary>
public bool IsDimmed { get; init; }
/// <summary>
/// 活动开始日期UTC
/// </summary>
public DateTime StartDate { get; init; }
/// <summary>
/// 活动结束日期UTC
/// </summary>
public DateTime EndDate { get; init; }
/// <summary>
/// 预计优惠金额。
/// </summary>
public decimal EstimatedDiscountAmount { get; init; }
/// <summary>
/// 活动条。
/// </summary>
public IReadOnlyList<MarketingCalendarActivityBarDto> Bars { get; init; } = [];
/// <summary>
/// 二级抽屉详情。
/// </summary>
public MarketingCalendarActivityDetailDto Detail { get; init; } = new();
}
/// <summary>
/// 活动条。
/// </summary>
public sealed class MarketingCalendarActivityBarDto
{
/// <summary>
/// 条标识。
/// </summary>
public string BarId { get; init; } = string.Empty;
/// <summary>
/// 开始日1-31
/// </summary>
public int StartDay { get; init; }
/// <summary>
/// 结束日1-31
/// </summary>
public int EndDay { get; init; }
/// <summary>
/// 条文案。
/// </summary>
public string Label { get; init; } = string.Empty;
/// <summary>
/// 是否里程碑。
/// </summary>
public bool IsMilestone { get; init; }
/// <summary>
/// 是否弱化。
/// </summary>
public bool IsDimmed { get; init; }
}
/// <summary>
/// 活动详情。
/// </summary>
public sealed class MarketingCalendarActivityDetailDto
{
/// <summary>
/// 模块名称。
/// </summary>
public string ModuleName { get; init; } = string.Empty;
/// <summary>
/// 详情描述。
/// </summary>
public string Description { get; init; } = string.Empty;
/// <summary>
/// 明细字段。
/// </summary>
public IReadOnlyList<MarketingCalendarDetailFieldDto> Fields { get; init; } = [];
}
/// <summary>
/// 详情字段。
/// </summary>
public sealed class MarketingCalendarDetailFieldDto
{
/// <summary>
/// 标签。
/// </summary>
public string Label { get; init; } = string.Empty;
/// <summary>
/// 值。
/// </summary>
public string Value { get; init; } = string.Empty;
}
/// <summary>
/// 冲突横幅。
/// </summary>
public sealed class MarketingCalendarConflictBannerDto
{
/// <summary>
/// 冲突标识。
/// </summary>
public string ConflictId { get; init; } = string.Empty;
/// <summary>
/// 开始日。
/// </summary>
public int StartDay { get; init; }
/// <summary>
/// 结束日。
/// </summary>
public int EndDay { get; init; }
/// <summary>
/// 同时进行活动数。
/// </summary>
public int ActivityCount { get; init; }
/// <summary>
/// 最大并行活动数。
/// </summary>
public int MaxConcurrentCount { get; init; }
/// <summary>
/// 冲突区间数。
/// </summary>
public int ConflictCount { get; init; }
}
/// <summary>
/// 冲突区间。
/// </summary>
public sealed class MarketingCalendarConflictDto
{
/// <summary>
/// 冲突标识。
/// </summary>
public string ConflictId { get; init; } = string.Empty;
/// <summary>
/// 开始日。
/// </summary>
public int StartDay { get; init; }
/// <summary>
/// 结束日。
/// </summary>
public int EndDay { get; init; }
/// <summary>
/// 同时进行活动数。
/// </summary>
public int ActivityCount { get; init; }
/// <summary>
/// 最大并行活动数。
/// </summary>
public int MaxConcurrentCount { get; init; }
/// <summary>
/// 活动标识集合。
/// </summary>
public IReadOnlyList<string> ActivityIds { get; init; } = [];
/// <summary>
/// 冲突活动摘要。
/// </summary>
public IReadOnlyList<MarketingCalendarConflictActivityDto> Activities { get; init; } = [];
}
/// <summary>
/// 冲突活动摘要。
/// </summary>
public sealed class MarketingCalendarConflictActivityDto
{
/// <summary>
/// 活动唯一键。
/// </summary>
public string ActivityId { get; init; } = string.Empty;
/// <summary>
/// 日历类型。
/// </summary>
public string CalendarType { get; init; } = string.Empty;
/// <summary>
/// 活动名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 活动摘要。
/// </summary>
public string Summary { get; init; } = string.Empty;
/// <summary>
/// 活动颜色。
/// </summary>
public string Color { get; init; } = string.Empty;
/// <summary>
/// 展示状态。
/// </summary>
public string DisplayStatus { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.Calendar.Dto;
namespace TakeoutSaaS.Application.App.Coupons.Calendar.Queries;
/// <summary>
/// 查询营销日历总览。
/// </summary>
public sealed class GetMarketingCalendarOverviewQuery : IRequest<MarketingCalendarOverviewDto>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 年份。
/// </summary>
public int Year { get; init; }
/// <summary>
/// 月份1-12
/// </summary>
public int Month { get; init; }
}

View File

@@ -38,6 +38,14 @@ public interface IPunchCardRepository
IReadOnlyCollection<long> templateIds,
CancellationToken cancellationToken = default);
/// <summary>
/// 查询单门店全部次卡模板。
/// </summary>
Task<IReadOnlyList<PunchCardTemplate>> GetTemplatesByStoreAsync(
long tenantId,
long storeId,
CancellationToken cancellationToken = default);
/// <summary>
/// 按模板批量统计售卖与在用信息。
/// </summary>

View File

@@ -92,6 +92,20 @@ public sealed class EfPunchCardRepository(TakeoutAppDbContext context) : IPunchC
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<PunchCardTemplate>> GetTemplatesByStoreAsync(
long tenantId,
long storeId,
CancellationToken cancellationToken = default)
{
return await context.PunchCardTemplates
.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.StoreId == storeId)
.OrderByDescending(item => item.UpdatedAt ?? item.CreatedAt)
.ThenByDescending(item => item.Id)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<Dictionary<long, PunchCardTemplateAggregateSnapshot>> GetTemplateAggregateByTemplateIdsAsync(
long tenantId,