feat: add student engagement endpoints

This commit is contained in:
xiong
2026-07-26 18:39:55 +08:00
parent 387b85342a
commit 89e1f2a4df
5 changed files with 547 additions and 0 deletions

View File

@@ -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<Guid> 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<object>());
public JsonElement Metadata { get; set; } = JsonSerializer.SerializeToElement(new { });
public SubmitFeedbackCommand ToCommand()
{
return new SubmitFeedbackCommand(
QuestionId,
Type,
Category,
Title,
Description,
Priority,
Contact,
Attachments,
Metadata);
}
}

View File

@@ -55,6 +55,71 @@ public sealed class ProfileController(
cancellationToken));
}
[HttpGet("notifications")]
[EndpointSummary("查询用户通知")]
[ProducesResponseType<ProfileNotificationList>(StatusCodes.Status200OK)]
public async Task<ActionResult<ProfileNotificationList>> Notifications(
[FromQuery] ProfileQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await profileService.GetNotificationsAsync(
ResolveActor(),
new ProfileNotificationQuery(query.Status, query.Limit),
cancellationToken));
}
[HttpPost("notifications/status")]
[EndpointSummary("更新通知状态")]
[ProducesResponseType<ProfileNotificationList>(StatusCodes.Status200OK)]
public async Task<ActionResult<ProfileNotificationList>> UpdateNotificationStatus(
NotificationStatusDto request,
CancellationToken cancellationToken)
{
return Ok(await profileService.UpdateNotificationStatusAsync(
ResolveActor(),
request.ToCommand(),
cancellationToken));
}
[HttpGet("badges")]
[EndpointSummary("查询徽章列表")]
[ProducesResponseType<BadgeList>(StatusCodes.Status200OK)]
public async Task<ActionResult<BadgeList>> 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<IReadOnlyCollection<FeedbackItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyCollection<FeedbackItem>>> 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<FeedbackItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<FeedbackItem>> 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)

View File

@@ -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<ProfileNotificationItem> Items,
IReadOnlyDictionary<string, int> Summary);
public sealed record UpdateNotificationStatusCommand(
IReadOnlyCollection<Guid> 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<BadgeItem> 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<StudentProfileItem> GetMeAsync(ProfileActor actor, ProfileQuery query, CancellationToken cancellationToken = default);
Task<StudentProfileItem> UpdateMeAsync(ProfileActor actor, UpdateProfileCommand command, CancellationToken cancellationToken = default);
Task<ExamCountdownList> GetExamCountdownsAsync(ProfileActor actor, ProfileQuery query, CancellationToken cancellationToken = default);
Task<ProfileNotificationList> GetNotificationsAsync(ProfileActor actor, ProfileNotificationQuery query, CancellationToken cancellationToken = default);
Task<ProfileNotificationList> UpdateNotificationStatusAsync(ProfileActor actor, UpdateNotificationStatusCommand command, CancellationToken cancellationToken = default);
Task<BadgeList> GetBadgesAsync(ProfileActor actor, BadgeQuery query, CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<FeedbackItem>> GetFeedbacksAsync(ProfileActor actor, FeedbackQuery query, CancellationToken cancellationToken = default);
Task<FeedbackItem> SubmitFeedbackAsync(ProfileActor actor, SubmitFeedbackCommand command, CancellationToken cancellationToken = default);
}

View File

@@ -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<ProfileNotificationList> 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<ProfileNotificationList> 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<BadgeList> 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<IReadOnlyCollection<FeedbackItem>> 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<FeedbackItem> SubmitFeedbackAsync(
ProfileActor actor,
SubmitFeedbackCommand command,
CancellationToken cancellationToken = default)
{
await EnsureProfileAsync(actor, cancellationToken);
ArgumentException.ThrowIfNullOrWhiteSpace(command.Description);
await AssertReferenceAsync<Tiku.Domain.QuestionBanks.Question>(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<StudentProfile> EnsureProfileAsync(
ProfileActor actor,
CancellationToken cancellationToken)
@@ -155,6 +325,18 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService
return profile;
}
private async Task<IReadOnlyDictionary<string, int>> 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<StudentProfileItem> 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<NotificationStatus>(value, "invalid_notification_status");
}
private static ReportType ParseReportType(string value)
{
return ParseEnum<ReportType>(value, "invalid_feedback_type");
}
private static ReportStatus ParseReportStatus(string value)
{
return ParseEnum<ReportStatus>(value, "invalid_feedback_status");
}
private static ReportPriority ParseReportPriority(string value)
{
return ParseEnum<ReportPriority>(value, "invalid_feedback_priority");
}
private static TEnum ParseEnum<TEnum>(string value, string code)
where TEnum : struct, Enum
{
var normalized = value.Replace("_", string.Empty, StringComparison.Ordinal).Replace("-", string.Empty, StringComparison.Ordinal);
if (Enum.TryParse<TEnum>(normalized, true, out var parsed))
{
return parsed;
}
throw new ProfileException("Profile enum value is invalid.", code);
}
private static string ToWire<TEnum>(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)

View File

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