From 89e1f2a4df5c44af72041fdd51144f21f3635b0c Mon Sep 17 00:00:00 2001 From: xiong Date: Sun, 26 Jul 2026 18:39:55 +0800 Subject: [PATCH] feat: add student engagement endpoints --- Tiku.Api/Contracts/ProfileDtos.cs | 68 +++++ Tiku.Api/Controllers/ProfileController.cs | 65 +++++ Tiku.Application/Profile/ProfileModels.cs | 76 +++++ Tiku.Infrastructure/Profile/ProfileService.cs | 260 ++++++++++++++++++ .../Api/ProfileEndpointTests.cs | 78 ++++++ 5 files changed, 547 insertions(+) diff --git a/Tiku.Api/Contracts/ProfileDtos.cs b/Tiku.Api/Contracts/ProfileDtos.cs index 2bf947e..5f8e642 100644 --- a/Tiku.Api/Contracts/ProfileDtos.cs +++ b/Tiku.Api/Contracts/ProfileDtos.cs @@ -11,6 +11,17 @@ public sealed class ProfileQueryDto [Range(1, 20)] public int? Limit { get; set; } + + [StringLength(32)] + public string? Status { get; set; } + + [StringLength(50)] + public string? Type { get; set; } + + [StringLength(50)] + public string? Category { get; set; } + + public bool IncludeLocked { get; set; } } public sealed class UpdateProfileDto @@ -43,3 +54,60 @@ public sealed class UpdateProfileDto RecentActivities); } } + +public sealed class NotificationStatusDto +{ + [Required] + [MinLength(1)] + [MaxLength(100)] + public IReadOnlyCollection NotificationIds { get; set; } = []; + + [StringLength(32)] + public string? Status { get; set; } + + public UpdateNotificationStatusCommand ToCommand() + { + return new UpdateNotificationStatusCommand(NotificationIds, Status); + } +} + +public sealed class SubmitFeedbackDto +{ + public Guid? QuestionId { get; set; } + + [StringLength(50)] + public string? Type { get; set; } + + [StringLength(100)] + public string? Category { get; set; } + + [StringLength(200)] + public string? Title { get; set; } + + [Required] + [StringLength(5000)] + public string Description { get; set; } = string.Empty; + + [StringLength(32)] + public string? Priority { get; set; } + + [StringLength(200)] + public string? Contact { get; set; } + + public JsonElement Attachments { get; set; } = JsonSerializer.SerializeToElement(Array.Empty()); + public JsonElement Metadata { get; set; } = JsonSerializer.SerializeToElement(new { }); + + public SubmitFeedbackCommand ToCommand() + { + return new SubmitFeedbackCommand( + QuestionId, + Type, + Category, + Title, + Description, + Priority, + Contact, + Attachments, + Metadata); + } +} diff --git a/Tiku.Api/Controllers/ProfileController.cs b/Tiku.Api/Controllers/ProfileController.cs index c5539d4..6dae31e 100644 --- a/Tiku.Api/Controllers/ProfileController.cs +++ b/Tiku.Api/Controllers/ProfileController.cs @@ -55,6 +55,71 @@ public sealed class ProfileController( cancellationToken)); } + [HttpGet("notifications")] + [EndpointSummary("查询用户通知")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Notifications( + [FromQuery] ProfileQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await profileService.GetNotificationsAsync( + ResolveActor(), + new ProfileNotificationQuery(query.Status, query.Limit), + cancellationToken)); + } + + [HttpPost("notifications/status")] + [EndpointSummary("更新通知状态")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> UpdateNotificationStatus( + NotificationStatusDto request, + CancellationToken cancellationToken) + { + return Ok(await profileService.UpdateNotificationStatusAsync( + ResolveActor(), + request.ToCommand(), + cancellationToken)); + } + + [HttpGet("badges")] + [EndpointSummary("查询徽章列表")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Badges( + [FromQuery] ProfileQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await profileService.GetBadgesAsync( + ResolveActor(), + new BadgeQuery(query.IncludeLocked, query.Category, query.Limit), + cancellationToken)); + } + + [HttpGet("feedbacks")] + [EndpointSummary("查询反馈记录")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> Feedbacks( + [FromQuery] ProfileQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await profileService.GetFeedbacksAsync( + ResolveActor(), + new FeedbackQuery(query.Status, query.Type, query.Limit), + cancellationToken)); + } + + [HttpPost("feedbacks")] + [EndpointSummary("提交意见反馈")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SubmitFeedback( + SubmitFeedbackDto request, + CancellationToken cancellationToken) + { + return Ok(await profileService.SubmitFeedbackAsync( + ResolveActor(), + request.ToCommand(), + cancellationToken)); + } + private ProfileActor ResolveActor() { if (currentTenant.TenantId is null || currentUser.UserId is null) diff --git a/Tiku.Application/Profile/ProfileModels.cs b/Tiku.Application/Profile/ProfileModels.cs index cf3179a..a9a3a77 100644 --- a/Tiku.Application/Profile/ProfileModels.cs +++ b/Tiku.Application/Profile/ProfileModels.cs @@ -73,9 +73,85 @@ public sealed record ExamCountdownList( Guid? RegionId, Guid? SchoolId); +public sealed record ProfileNotificationQuery(string? Status = null, int? Limit = null); + +public sealed record ProfileNotificationItem( + Guid Id, + string NotificationType, + string Status, + string Severity, + string Title, + string Message, + string? ActionLabel, + string? ActionPath, + string? SourceType, + Guid? SourceId, + JsonElement Metadata, + DateTimeOffset? ReadAt, + DateTimeOffset CreatedAt); + +public sealed record ProfileNotificationList( + IReadOnlyCollection Items, + IReadOnlyDictionary Summary); + +public sealed record UpdateNotificationStatusCommand( + IReadOnlyCollection NotificationIds, + string? Status); + +public sealed record BadgeQuery(bool IncludeLocked = false, string? Category = null, int? Limit = null); + +public sealed record BadgeItem( + Guid Id, + string Name, + string? Description, + string? Category, + string? IconUrl, + int? Level, + string? UnlockType, + JsonElement ConditionExtra, + bool IsUnlocked, + DateTimeOffset? GrantedAt, + string? Note, + int Order); + +public sealed record BadgeList(IReadOnlyCollection Items, int Total, int Unlocked, bool IncludeLocked); + +public sealed record FeedbackQuery(string? Status = null, string? Type = null, int? Limit = null); + +public sealed record FeedbackItem( + Guid Id, + Guid? QuestionId, + string? Type, + string? Title, + string? Category, + string? Description, + string Status, + string Priority, + string? Contact, + JsonElement Attachments, + JsonElement Metadata, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); + +public sealed record SubmitFeedbackCommand( + Guid? QuestionId, + string? Type, + string? Category, + string? Title, + string Description, + string? Priority, + string? Contact, + JsonElement Attachments, + JsonElement Metadata); + public interface IProfileService { Task GetMeAsync(ProfileActor actor, ProfileQuery query, CancellationToken cancellationToken = default); Task UpdateMeAsync(ProfileActor actor, UpdateProfileCommand command, CancellationToken cancellationToken = default); Task GetExamCountdownsAsync(ProfileActor actor, ProfileQuery query, CancellationToken cancellationToken = default); + Task GetNotificationsAsync(ProfileActor actor, ProfileNotificationQuery query, CancellationToken cancellationToken = default); + Task UpdateNotificationStatusAsync(ProfileActor actor, UpdateNotificationStatusCommand command, CancellationToken cancellationToken = default); + Task GetBadgesAsync(ProfileActor actor, BadgeQuery query, CancellationToken cancellationToken = default); + Task> GetFeedbacksAsync(ProfileActor actor, FeedbackQuery query, CancellationToken cancellationToken = default); + Task SubmitFeedbackAsync(ProfileActor actor, SubmitFeedbackCommand command, CancellationToken cancellationToken = default); } diff --git a/Tiku.Infrastructure/Profile/ProfileService.cs b/Tiku.Infrastructure/Profile/ProfileService.cs index 2d29115..60628f4 100644 --- a/Tiku.Infrastructure/Profile/ProfileService.cs +++ b/Tiku.Infrastructure/Profile/ProfileService.cs @@ -6,6 +6,7 @@ using Tiku.Domain.Commerce; using Tiku.Domain.Common; using Tiku.Domain.Identity; using Tiku.Domain.Learning; +using Tiku.Domain.Operations; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Profile; @@ -122,6 +123,175 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService profile.SelectedSchoolId); } + public async Task GetNotificationsAsync( + ProfileActor actor, + ProfileNotificationQuery query, + CancellationToken cancellationToken = default) + { + await EnsureProfileAsync(actor, cancellationToken); + var notifications = dbContext.UserNotifications.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); + if (!string.IsNullOrWhiteSpace(query.Status)) + { + notifications = notifications.Where(item => item.Status == ParseNotificationStatus(query.Status)); + } + + var limit = Math.Clamp(query.Limit ?? 50, 1, 100); + var items = await notifications + .OrderByDescending(item => item.CreatedAt) + .Take(limit) + .ToArrayAsync(cancellationToken); + return new ProfileNotificationList( + items.Select(ToNotificationItem).ToArray(), + await BuildNotificationSummaryAsync(actor, cancellationToken)); + } + + public async Task UpdateNotificationStatusAsync( + ProfileActor actor, + UpdateNotificationStatusCommand command, + CancellationToken cancellationToken = default) + { + if (command.NotificationIds.Count is 0 or > 100) + { + throw new ProfileException("Notification ids must contain 1 to 100 items.", "invalid_notification_ids"); + } + + var status = ParseNotificationStatus(command.Status ?? "read"); + if (status == NotificationStatus.Unread) + { + throw new ProfileException("Notification status cannot be set to unread.", "invalid_notification_status"); + } + + var notifications = await dbContext.UserNotifications + .Where(item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + command.NotificationIds.Contains(item.Id)) + .ToArrayAsync(cancellationToken); + foreach (var notification in notifications) + { + notification.Status = status; + notification.ReadAt = status == NotificationStatus.Read ? DateTimeOffset.UtcNow : notification.ReadAt; + } + + await dbContext.SaveChangesAsync(cancellationToken); + return new ProfileNotificationList( + notifications.Select(ToNotificationItem).ToArray(), + await BuildNotificationSummaryAsync(actor, cancellationToken)); + } + + public async Task GetBadgesAsync( + ProfileActor actor, + BadgeQuery query, + CancellationToken cancellationToken = default) + { + await EnsureProfileAsync(actor, cancellationToken); + var badgesQuery = dbContext.Badges.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.IsActive); + if (!string.IsNullOrWhiteSpace(query.Category)) + { + var category = query.Category.Trim(); + badgesQuery = badgesQuery.Where(item => item.Category == category); + } + + var badges = await badgesQuery + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.Name) + .Take(Math.Clamp(query.Limit ?? 100, 1, 200)) + .ToArrayAsync(cancellationToken); + var badgeIds = badges.Select(item => item.Id).ToArray(); + var grants = await dbContext.UserBadges.AsNoTracking() + .Where(item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.BadgeId.HasValue && + badgeIds.Contains(item.BadgeId.Value)) + .ToDictionaryAsync(item => item.BadgeId!.Value, cancellationToken); + var items = badges + .Select(badge => + { + grants.TryGetValue(badge.Id, out var grant); + return new BadgeItem( + badge.Id, + badge.Name, + badge.Description, + badge.Category, + badge.IconUrl, + badge.Level, + badge.UnlockType, + badge.ConditionExtra, + grant is not null, + grant?.GrantedAt, + grant?.Note, + badge.SortOrder); + }) + .Where(item => query.IncludeLocked || item.IsUnlocked) + .ToArray(); + + return new BadgeList(items, badges.Length, grants.Count, query.IncludeLocked); + } + + public async Task> GetFeedbacksAsync( + ProfileActor actor, + FeedbackQuery query, + CancellationToken cancellationToken = default) + { + await EnsureProfileAsync(actor, cancellationToken); + var feedbacks = dbContext.Reports.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); + if (!string.IsNullOrWhiteSpace(query.Status)) + { + feedbacks = feedbacks.Where(item => item.Status == ParseReportStatus(query.Status)); + } + + if (!string.IsNullOrWhiteSpace(query.Type)) + { + feedbacks = feedbacks.Where(item => item.Type == ParseReportType(query.Type)); + } + + return await feedbacks + .OrderByDescending(item => item.CreatedAt) + .Take(Math.Clamp(query.Limit ?? 50, 1, 100)) + .Select(item => ToFeedbackItem(item)) + .ToArrayAsync(cancellationToken); + } + + public async Task SubmitFeedbackAsync( + ProfileActor actor, + SubmitFeedbackCommand command, + CancellationToken cancellationToken = default) + { + await EnsureProfileAsync(actor, cancellationToken); + ArgumentException.ThrowIfNullOrWhiteSpace(command.Description); + await AssertReferenceAsync(actor.TenantId, command.QuestionId, "question_not_found", cancellationToken); + + var feedback = new Report + { + TenantId = actor.TenantId, + UserId = actor.UserId, + QuestionId = command.QuestionId, + Type = ParseReportType(command.Type ?? "other"), + Title = string.IsNullOrWhiteSpace(command.Title) ? null : command.Title.Trim(), + Category = string.IsNullOrWhiteSpace(command.Category) ? null : command.Category.Trim(), + Description = command.Description.Trim(), + Priority = ParseReportPriority(command.Priority ?? "normal"), + Contact = string.IsNullOrWhiteSpace(command.Contact) ? null : command.Contact.Trim(), + Attachments = JsonArrayOrDefault(command.Attachments), + Metadata = JsonObjectOrDefault(command.Metadata) + }; + dbContext.Reports.Add(feedback); + dbContext.ReportStatusEvents.Add(new ReportStatusEvent + { + TenantId = actor.TenantId, + ReportId = feedback.Id, + ToStatus = ReportStatus.Pending, + Note = "student submitted feedback", + ActorUserId = actor.UserId + }); + await dbContext.SaveChangesAsync(cancellationToken); + return ToFeedbackItem(feedback); + } + private async Task EnsureProfileAsync( ProfileActor actor, CancellationToken cancellationToken) @@ -155,6 +325,18 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService return profile; } + private async Task> BuildNotificationSummaryAsync( + ProfileActor actor, + CancellationToken cancellationToken) + { + var items = await dbContext.UserNotifications.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId) + .GroupBy(item => item.Status) + .Select(group => new { Status = group.Key, Count = group.Count() }) + .ToArrayAsync(cancellationToken); + return items.ToDictionary(item => ToWire(item.Status), item => item.Count, StringComparer.Ordinal); + } + private async Task BuildProfileItemAsync( ProfileActor actor, StudentProfile profile, @@ -265,6 +447,84 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService { return value.ValueKind == JsonValueKind.Array ? value.Clone() : JsonDefaults.Array(); } + + private static ProfileNotificationItem ToNotificationItem(UserNotification notification) + { + return new ProfileNotificationItem( + notification.Id, + notification.NotificationType, + ToWire(notification.Status), + ToWire(notification.Severity), + notification.Title, + notification.Message, + notification.ActionLabel, + notification.ActionPath, + notification.SourceType, + notification.SourceId, + notification.Metadata, + notification.ReadAt, + notification.CreatedAt); + } + + private static FeedbackItem ToFeedbackItem(Report report) + { + return new FeedbackItem( + report.Id, + report.QuestionId, + report.Type.HasValue ? ToWire(report.Type.Value) : null, + report.Title, + report.Category, + report.Description, + ToWire(report.Status), + ToWire(report.Priority), + report.Contact, + report.Attachments, + report.Metadata, + report.CreatedAt, + report.UpdatedAt); + } + + private static NotificationStatus ParseNotificationStatus(string value) + { + return ParseEnum(value, "invalid_notification_status"); + } + + private static ReportType ParseReportType(string value) + { + return ParseEnum(value, "invalid_feedback_type"); + } + + private static ReportStatus ParseReportStatus(string value) + { + return ParseEnum(value, "invalid_feedback_status"); + } + + private static ReportPriority ParseReportPriority(string value) + { + return ParseEnum(value, "invalid_feedback_priority"); + } + + private static TEnum ParseEnum(string value, string code) + where TEnum : struct, Enum + { + var normalized = value.Replace("_", string.Empty, StringComparison.Ordinal).Replace("-", string.Empty, StringComparison.Ordinal); + if (Enum.TryParse(normalized, true, out var parsed)) + { + return parsed; + } + + throw new ProfileException("Profile enum value is invalid.", code); + } + + private static string ToWire(TEnum value) + where TEnum : struct, Enum + { + var text = value.ToString(); + return string.Concat(text.Select((ch, index) => + index > 0 && char.IsUpper(ch) + ? "_" + char.ToLowerInvariant(ch) + : char.ToLowerInvariant(ch).ToString())); + } } public sealed class ProfileException(string message, string code) : Exception(message) diff --git a/Tiku.IntegrationTests/Api/ProfileEndpointTests.cs b/Tiku.IntegrationTests/Api/ProfileEndpointTests.cs index 8369769..bb2d213 100644 --- a/Tiku.IntegrationTests/Api/ProfileEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/ProfileEndpointTests.cs @@ -7,6 +7,7 @@ using Tiku.Domain.Catalog; using Tiku.Domain.Common; using Tiku.Domain.Identity; using Tiku.Domain.Learning; +using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Persistence; @@ -107,6 +108,83 @@ public sealed class ProfileEndpointTests Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } + [Fact] + public async Task Student_can_manage_notifications_badges_and_feedbacks() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedStudentAsync(factory); + var notificationId = Guid.NewGuid(); + var badgeId = Guid.NewGuid(); + await factory.SeedAsync( + new StudentProfile + { + TenantId = seed.TenantId, + UserId = seed.UserId + }, + new UserNotification + { + Id = notificationId, + TenantId = seed.TenantId, + UserId = seed.UserId, + NotificationType = "system", + Title = "欢迎", + Message = "欢迎回来", + Status = NotificationStatus.Unread + }, + new Badge + { + Id = badgeId, + TenantId = seed.TenantId, + Name = "首练", + Category = "learning", + IsActive = true + }, + new UserBadge + { + TenantId = seed.TenantId, + UserId = seed.UserId, + BadgeId = badgeId, + GrantedAt = DateTimeOffset.UtcNow + }); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + + var notificationsResponse = await client.GetAsync("/api/profile/notifications"); + var notificationsJson = await ReadJsonAsync(notificationsResponse); + var statusResponse = await client.PostAsJsonAsync( + "/api/profile/notifications/status", + new NotificationStatusDto { NotificationIds = [notificationId], Status = "read" }); + var badgesResponse = await client.GetAsync("/api/profile/badges?includeLocked=true"); + var badgesJson = await ReadJsonAsync(badgesResponse); + var feedbackResponse = await client.PostAsJsonAsync( + "/api/profile/feedbacks", + new SubmitFeedbackDto + { + Type = "suggestion", + Title = "建议", + Description = "希望增加练习提醒", + Priority = "normal" + }); + var feedbackJson = await ReadJsonAsync(feedbackResponse); + var feedbacksResponse = await client.GetAsync("/api/profile/feedbacks?type=suggestion"); + var feedbacksJson = await ReadJsonAsync(feedbacksResponse); + + Assert.Equal(HttpStatusCode.OK, notificationsResponse.StatusCode); + Assert.Equal("unread", notificationsJson.RootElement.GetProperty("items").EnumerateArray().Single().GetProperty("status").GetString()); + Assert.Equal(HttpStatusCode.OK, statusResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, badgesResponse.StatusCode); + Assert.True(badgesJson.RootElement.GetProperty("items").EnumerateArray().Single().GetProperty("isUnlocked").GetBoolean()); + Assert.Equal(HttpStatusCode.OK, feedbackResponse.StatusCode); + Assert.Equal("suggestion", feedbackJson.RootElement.GetProperty("type").GetString()); + Assert.Equal(HttpStatusCode.OK, feedbacksResponse.StatusCode); + Assert.Single(feedbacksJson.RootElement.EnumerateArray()); + + using var scope = factory.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + Assert.Equal(NotificationStatus.Read, dbContext.UserNotifications.Single(item => item.Id == notificationId).Status); + Assert.Single(dbContext.ReportStatusEvents); + } + private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedStudentAsync(ApiTestFactory factory) { var tenantId = Guid.NewGuid();