forked from xiongyuxing/tiku-backend.net
feat: add student engagement endpoints
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user