forked from gongxuegit/tiku-backend.net
feat: add student engagement endpoints
This commit is contained in:
@@ -11,6 +11,17 @@ public sealed class ProfileQueryDto
|
|||||||
|
|
||||||
[Range(1, 20)]
|
[Range(1, 20)]
|
||||||
public int? Limit { get; set; }
|
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
|
public sealed class UpdateProfileDto
|
||||||
@@ -43,3 +54,60 @@ public sealed class UpdateProfileDto
|
|||||||
RecentActivities);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,6 +55,71 @@ public sealed class ProfileController(
|
|||||||
cancellationToken));
|
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()
|
private ProfileActor ResolveActor()
|
||||||
{
|
{
|
||||||
if (currentTenant.TenantId is null || currentUser.UserId is null)
|
if (currentTenant.TenantId is null || currentUser.UserId is null)
|
||||||
|
|||||||
@@ -73,9 +73,85 @@ public sealed record ExamCountdownList(
|
|||||||
Guid? RegionId,
|
Guid? RegionId,
|
||||||
Guid? SchoolId);
|
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
|
public interface IProfileService
|
||||||
{
|
{
|
||||||
Task<StudentProfileItem> GetMeAsync(ProfileActor actor, ProfileQuery query, CancellationToken cancellationToken = default);
|
Task<StudentProfileItem> GetMeAsync(ProfileActor actor, ProfileQuery query, CancellationToken cancellationToken = default);
|
||||||
Task<StudentProfileItem> UpdateMeAsync(ProfileActor actor, UpdateProfileCommand command, CancellationToken cancellationToken = default);
|
Task<StudentProfileItem> UpdateMeAsync(ProfileActor actor, UpdateProfileCommand command, CancellationToken cancellationToken = default);
|
||||||
Task<ExamCountdownList> GetExamCountdownsAsync(ProfileActor actor, ProfileQuery query, 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using Tiku.Domain.Commerce;
|
|||||||
using Tiku.Domain.Common;
|
using Tiku.Domain.Common;
|
||||||
using Tiku.Domain.Identity;
|
using Tiku.Domain.Identity;
|
||||||
using Tiku.Domain.Learning;
|
using Tiku.Domain.Learning;
|
||||||
|
using Tiku.Domain.Operations;
|
||||||
using Tiku.Infrastructure.Persistence;
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
|
||||||
namespace Tiku.Infrastructure.Profile;
|
namespace Tiku.Infrastructure.Profile;
|
||||||
@@ -122,6 +123,175 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService
|
|||||||
profile.SelectedSchoolId);
|
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(
|
private async Task<StudentProfile> EnsureProfileAsync(
|
||||||
ProfileActor actor,
|
ProfileActor actor,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -155,6 +325,18 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService
|
|||||||
return profile;
|
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(
|
private async Task<StudentProfileItem> BuildProfileItemAsync(
|
||||||
ProfileActor actor,
|
ProfileActor actor,
|
||||||
StudentProfile profile,
|
StudentProfile profile,
|
||||||
@@ -265,6 +447,84 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService
|
|||||||
{
|
{
|
||||||
return value.ValueKind == JsonValueKind.Array ? value.Clone() : JsonDefaults.Array();
|
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)
|
public sealed class ProfileException(string message, string code) : Exception(message)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using Tiku.Domain.Catalog;
|
|||||||
using Tiku.Domain.Common;
|
using Tiku.Domain.Common;
|
||||||
using Tiku.Domain.Identity;
|
using Tiku.Domain.Identity;
|
||||||
using Tiku.Domain.Learning;
|
using Tiku.Domain.Learning;
|
||||||
|
using Tiku.Domain.Operations;
|
||||||
using Tiku.Domain.Tenancy;
|
using Tiku.Domain.Tenancy;
|
||||||
using Tiku.Infrastructure.Auth;
|
using Tiku.Infrastructure.Auth;
|
||||||
using Tiku.Infrastructure.Persistence;
|
using Tiku.Infrastructure.Persistence;
|
||||||
@@ -107,6 +108,83 @@ public sealed class ProfileEndpointTests
|
|||||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
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)
|
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedStudentAsync(ApiTestFactory factory)
|
||||||
{
|
{
|
||||||
var tenantId = Guid.NewGuid();
|
var tenantId = Guid.NewGuid();
|
||||||
|
|||||||
Reference in New Issue
Block a user