From 28befc57be76e360a9a6ac5d4e108a17fbb4e6ad Mon Sep 17 00:00:00 2001 From: xiong Date: Sun, 26 Jul 2026 19:15:39 +0800 Subject: [PATCH] feat: add tenant admin engagement endpoints --- Tiku.Api/Contracts/TenantAdminDirectDtos.cs | 165 +++++++ .../TenantAdminDirectController.cs | 80 ++++ .../TenantAdmin/ITenantAdminDirectService.cs | 40 ++ .../TenantAdmin/TenantAdminDirectModels.cs | 145 ++++++ .../TenantAdmin/TenantAdminDirectService.cs | 415 ++++++++++++++++++ .../Api/TenantAdminDirectEndpointTests.cs | 85 ++++ 6 files changed, 930 insertions(+) diff --git a/Tiku.Api/Contracts/TenantAdminDirectDtos.cs b/Tiku.Api/Contracts/TenantAdminDirectDtos.cs index aa94f40..7cb4e6c 100644 --- a/Tiku.Api/Contracts/TenantAdminDirectDtos.cs +++ b/Tiku.Api/Contracts/TenantAdminDirectDtos.cs @@ -110,6 +110,65 @@ public sealed class TenantAdminRoleTemplateQueryDto } } +public sealed class TenantAdminBadgeQueryDto +{ + public string? Category { get; set; } + public bool IncludeInactive { get; set; } + + [Range(1, 500)] + public int? Limit { get; set; } + + public TenantAdminBadgeFilter ToFilter() + { + return new TenantAdminBadgeFilter(Category, IncludeInactive, Limit); + } +} + +public sealed class TenantAdminBadgeGrantQueryDto +{ + public Guid? UserId { get; set; } + public Guid? BadgeId { get; set; } + + [Range(1, 500)] + public int? Limit { get; set; } + + public TenantAdminBadgeGrantFilter ToFilter() + { + return new TenantAdminBadgeGrantFilter(UserId, BadgeId, Limit); + } +} + +public sealed class TenantAdminNotificationQueryDto +{ + public Guid? UserId { get; set; } + public string? Status { get; set; } + public string? NotificationType { get; set; } + + [Range(1, 500)] + public int? Limit { get; set; } + + public TenantAdminNotificationFilter ToFilter() + { + return new TenantAdminNotificationFilter(UserId, Status, NotificationType, Limit); + } +} + +public sealed class TenantAdminFeedbackQueryDto +{ + public Guid? UserId { get; set; } + public string? Status { get; set; } + public string? Type { get; set; } + public string? Keyword { get; set; } + + [Range(1, 500)] + public int? Limit { get; set; } + + public TenantAdminFeedbackFilter ToFilter() + { + return new TenantAdminFeedbackFilter(UserId, Status, Type, Keyword, Limit); + } +} + public sealed class UpsertTenantAdminClassDto { public Guid? Id { get; set; } @@ -406,3 +465,109 @@ public sealed class UpsertTenantAuthProviderDto return new UpsertTenantAuthProviderCommand(Provider, Status, DisplayName, ConfigPublic); } } + +public sealed class UpsertTenantAdminBadgeDto +{ + public Guid? Id { get; set; } + public string? LegacyId { get; set; } + public required string Name { get; set; } + public string? Description { get; set; } + public string? Category { get; set; } + public string? IconUrl { get; set; } + public int? Level { get; set; } + public string? UnlockType { get; set; } + public string? ConditionField { get; set; } + public string? ConditionOperator { get; set; } + public decimal? ConditionValue { get; set; } + public JsonElement ConditionExtra { get; set; } = JsonDefaults.Object(); + public int? Order { get; set; } + public bool? IsActive { get; set; } + + public UpsertTenantAdminBadgeCommand ToCommand() + { + return new UpsertTenantAdminBadgeCommand( + Id, + LegacyId, + Name, + Description, + Category, + IconUrl, + Level, + UnlockType, + ConditionField, + ConditionOperator, + ConditionValue, + ConditionExtra, + Order, + IsActive); + } +} + +public sealed class GrantTenantAdminBadgeDto +{ + [Required] + public Guid UserId { get; set; } + + [Required] + public Guid BadgeId { get; set; } + + public string? LegacyId { get; set; } + public string? Note { get; set; } + public DateTimeOffset? GrantedAt { get; set; } + public JsonElement Metadata { get; set; } = JsonDefaults.Object(); + + public GrantTenantAdminBadgeCommand ToCommand() + { + return new GrantTenantAdminBadgeCommand(UserId, BadgeId, LegacyId, Note, GrantedAt, Metadata); + } +} + +public sealed class UpsertTenantAdminNotificationDto +{ + [Required] + public Guid UserId { get; set; } + + public required string NotificationType { get; set; } + public string? Severity { get; set; } + public required string Title { get; set; } + public required string Message { get; set; } + public string? ActionLabel { get; set; } + public string? ActionPath { get; set; } + public string? SourceType { get; set; } + public Guid? SourceId { get; set; } + public string? DedupeKey { get; set; } + public JsonElement Metadata { get; set; } = JsonDefaults.Object(); + + public UpsertTenantAdminNotificationCommand ToCommand() + { + return new UpsertTenantAdminNotificationCommand( + UserId, + NotificationType, + Severity, + Title, + Message, + ActionLabel, + ActionPath, + SourceType, + SourceId, + DedupeKey, + Metadata); + } +} + +public sealed class UpdateTenantAdminFeedbackDto +{ + [Required] + public Guid FeedbackId { get; set; } + + public string? Status { get; set; } + public string? Priority { get; set; } + public string? Resolution { get; set; } + public string? Note { get; set; } + public JsonElement Metadata { get; set; } = JsonDefaults.Object(); + + public UpdateTenantAdminFeedbackCommand ToCommand() + { + return new UpdateTenantAdminFeedbackCommand(FeedbackId, Status, Priority, Resolution, Note, Metadata); + } +} diff --git a/Tiku.Api/Controllers/TenantAdminDirectController.cs b/Tiku.Api/Controllers/TenantAdminDirectController.cs index 2e59938..df0e6ce 100644 --- a/Tiku.Api/Controllers/TenantAdminDirectController.cs +++ b/Tiku.Api/Controllers/TenantAdminDirectController.cs @@ -318,6 +318,86 @@ public sealed class TenantAdminDirectController( return Ok(await tenantAdminService.UpsertAuthProviderAsync(ResolveActor(), request.ToCommand(), cancellationToken)); } + [HttpGet("badges")] + [EndpointSummary("查询租户勋章")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetBadges( + [FromQuery] TenantAdminBadgeQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.GetBadgesAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + } + + [HttpPut("badges")] + [EndpointSummary("新增或更新租户勋章")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertBadge( + UpsertTenantAdminBadgeDto request, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.UpsertBadgeAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpGet("badge-grants")] + [EndpointSummary("查询勋章发放记录")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetBadgeGrants( + [FromQuery] TenantAdminBadgeGrantQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.GetBadgeGrantsAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + } + + [HttpPost("badge-grants")] + [EndpointSummary("向租户成员发放勋章")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GrantBadge( + GrantTenantAdminBadgeDto request, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.GrantBadgeAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpGet("notifications")] + [EndpointSummary("查询用户站内通知")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetNotifications( + [FromQuery] TenantAdminNotificationQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.GetNotificationsAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + } + + [HttpPut("notifications")] + [EndpointSummary("新增或更新用户站内通知")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertNotification( + UpsertTenantAdminNotificationDto request, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.UpsertNotificationAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpGet("feedbacks")] + [EndpointSummary("查询用户反馈")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetFeedbacks( + [FromQuery] TenantAdminFeedbackQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.GetFeedbacksAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + } + + [HttpPost("feedbacks/status")] + [EndpointSummary("处理用户反馈")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpdateFeedback( + UpdateTenantAdminFeedbackDto request, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.UpdateFeedbackAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + private TenantAdminActor ResolveActor() { if (currentTenant.TenantId is null || currentUser.UserId is null) diff --git a/Tiku.Application/TenantAdmin/ITenantAdminDirectService.cs b/Tiku.Application/TenantAdmin/ITenantAdminDirectService.cs index e8bf7b5..9779204 100644 --- a/Tiku.Application/TenantAdmin/ITenantAdminDirectService.cs +++ b/Tiku.Application/TenantAdmin/ITenantAdminDirectService.cs @@ -154,4 +154,44 @@ public interface ITenantAdminDirectService TenantAdminActor actor, UpsertTenantAuthProviderCommand command, CancellationToken cancellationToken = default); + + Task> GetBadgesAsync( + TenantAdminActor actor, + TenantAdminBadgeFilter filter, + CancellationToken cancellationToken = default); + + Task> UpsertBadgeAsync( + TenantAdminActor actor, + UpsertTenantAdminBadgeCommand command, + CancellationToken cancellationToken = default); + + Task> GetBadgeGrantsAsync( + TenantAdminActor actor, + TenantAdminBadgeGrantFilter filter, + CancellationToken cancellationToken = default); + + Task> GrantBadgeAsync( + TenantAdminActor actor, + GrantTenantAdminBadgeCommand command, + CancellationToken cancellationToken = default); + + Task> GetNotificationsAsync( + TenantAdminActor actor, + TenantAdminNotificationFilter filter, + CancellationToken cancellationToken = default); + + Task> UpsertNotificationAsync( + TenantAdminActor actor, + UpsertTenantAdminNotificationCommand command, + CancellationToken cancellationToken = default); + + Task> GetFeedbacksAsync( + TenantAdminActor actor, + TenantAdminFeedbackFilter filter, + CancellationToken cancellationToken = default); + + Task> UpdateFeedbackAsync( + TenantAdminActor actor, + UpdateTenantAdminFeedbackCommand command, + CancellationToken cancellationToken = default); } diff --git a/Tiku.Application/TenantAdmin/TenantAdminDirectModels.cs b/Tiku.Application/TenantAdmin/TenantAdminDirectModels.cs index 0bad9a8..8606975 100644 --- a/Tiku.Application/TenantAdmin/TenantAdminDirectModels.cs +++ b/Tiku.Application/TenantAdmin/TenantAdminDirectModels.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Tiku.Domain.Common; +using Tiku.Domain.Learning; using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; @@ -47,6 +48,29 @@ public sealed record TenantAdminRoleTemplateFilter( string? Status = null, int? Limit = null); +public sealed record TenantAdminBadgeFilter( + string? Category = null, + bool IncludeInactive = false, + int? Limit = null); + +public sealed record TenantAdminBadgeGrantFilter( + Guid? UserId = null, + Guid? BadgeId = null, + int? Limit = null); + +public sealed record TenantAdminNotificationFilter( + Guid? UserId = null, + string? Status = null, + string? NotificationType = null, + int? Limit = null); + +public sealed record TenantAdminFeedbackFilter( + Guid? UserId = null, + string? Status = null, + string? Type = null, + string? Keyword = null, + int? Limit = null); + public sealed record UpsertTenantAdminClassCommand( Guid? Id, Guid? RegionId, @@ -171,6 +195,51 @@ public sealed record UpsertTenantAuthProviderCommand( string? DisplayName, JsonElement ConfigPublic); +public sealed record UpsertTenantAdminBadgeCommand( + Guid? Id, + string? LegacyId, + string Name, + string? Description, + string? Category, + string? IconUrl, + int? Level, + string? UnlockType, + string? ConditionField, + string? ConditionOperator, + decimal? ConditionValue, + JsonElement ConditionExtra, + int? Order, + bool? IsActive); + +public sealed record GrantTenantAdminBadgeCommand( + Guid UserId, + Guid BadgeId, + string? LegacyId, + string? Note, + DateTimeOffset? GrantedAt, + JsonElement Metadata); + +public sealed record UpsertTenantAdminNotificationCommand( + Guid UserId, + string NotificationType, + string? Severity, + string Title, + string Message, + string? ActionLabel, + string? ActionPath, + string? SourceType, + Guid? SourceId, + string? DedupeKey, + JsonElement Metadata); + +public sealed record UpdateTenantAdminFeedbackCommand( + Guid FeedbackId, + string? Status, + string? Priority, + string? Resolution, + string? Note, + JsonElement Metadata); + public sealed record TenantAdminClassList( IReadOnlyCollection Items, bool Scoped); @@ -403,6 +472,82 @@ public sealed record TenantAuthProviderItem( DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt); +public sealed record TenantAdminBadgeItem( + Guid Id, + string? LegacyId, + string Name, + string? Description, + string? Category, + string? IconUrl, + int? Level, + string? UnlockType, + string? ConditionField, + string? ConditionOperator, + decimal? ConditionValue, + JsonElement ConditionExtra, + int Order, + bool IsActive, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); + +public sealed record TenantAdminBadgeGrantItem( + Guid Id, + string? LegacyId, + Guid? UserId, + string? UserName, + string? UserPhone, + Guid? BadgeId, + string? BadgeName, + string? BadgeCategory, + string? IconUrl, + int? Level, + Guid? GrantedBy, + string? GrantedByName, + string? Note, + DateTimeOffset? GrantedAt, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); + +public sealed record TenantAdminNotificationItem( + Guid Id, + Guid UserId, + string NotificationType, + NotificationStatus Status, + NotificationSeverity Severity, + string Title, + string Message, + string? ActionLabel, + string? ActionPath, + string? SourceType, + Guid? SourceId, + string? DedupeKey, + JsonElement Metadata, + Guid? CreatedBy, + DateTimeOffset? ReadAt, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); + +public sealed record TenantAdminFeedbackItem( + Guid Id, + Guid? UserId, + string? UserName, + string? UserPhone, + Guid? QuestionId, + ReportType? Type, + string? Title, + string? Category, + string? Description, + ReportStatus Status, + ReportPriority Priority, + Guid? HandledBy, + DateTimeOffset? HandledAt, + string? Resolution, + string? Contact, + JsonElement Attachments, + JsonElement Metadata, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); + public sealed class TenantAdminDirectException(string message, string code) : Exception(message) { public string Code { get; } = code; diff --git a/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs b/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs index da4d693..332737b 100644 --- a/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs +++ b/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs @@ -6,6 +6,7 @@ using Tiku.Application.TenantAdmin; 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.Persistence; @@ -1136,6 +1137,332 @@ public sealed class TenantAdminDirectService(TikuDbContext dbContext) : ITenantA return new ContentManagementResult(ToAuthProviderItem(item)); } + public async Task> GetBadgesAsync( + TenantAdminActor actor, + TenantAdminBadgeFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.Badges.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + if (!string.IsNullOrWhiteSpace(filter.Category)) + { + query = query.Where(item => item.Category == filter.Category.Trim()); + } + + if (!filter.IncludeInactive) + { + query = query.Where(item => item.IsActive); + } + + var items = await query + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.Level ?? int.MaxValue) + .ThenByDescending(item => item.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .Select(item => ToBadgeItem(item)) + .ToArrayAsync(cancellationToken); + return new CatalogList(items); + } + + public async Task> UpsertBadgeAsync( + TenantAdminActor actor, + UpsertTenantAdminBadgeCommand command, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); + var item = await ResolveTenantEntityAsync(dbContext.Badges, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var isNew = item is null; + item ??= new Badge { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; + item.LegacyId = Normalize(command.LegacyId); + item.Name = command.Name.Trim(); + item.Description = Normalize(command.Description); + item.Category = Normalize(command.Category) ?? "custom"; + item.IconUrl = Normalize(command.IconUrl); + item.Level = command.Level; + item.UnlockType = Normalize(command.UnlockType) ?? "manual"; + item.ConditionField = Normalize(command.ConditionField); + item.ConditionOperator = Normalize(command.ConditionOperator); + item.ConditionValue = command.ConditionValue; + item.ConditionExtra = JsonObjectOrDefault(command.ConditionExtra); + item.SortOrder = command.Order ?? item.SortOrder; + item.IsActive = command.IsActive ?? item.IsActive; + if (isNew) + { + dbContext.Badges.Add(item); + } + + await AddAuditAsync(actor, "tenant.badge.upserted", "badges", item.Id, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToBadgeItem(item)); + } + + public async Task> GetBadgeGrantsAsync( + TenantAdminActor actor, + TenantAdminBadgeGrantFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.UserBadges.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + if (filter.UserId.HasValue) + { + query = query.Where(item => item.UserId == filter.UserId.Value); + } + + if (filter.BadgeId.HasValue) + { + query = query.Where(item => item.BadgeId == filter.BadgeId.Value); + } + + var grants = await query + .OrderByDescending(item => item.GrantedAt ?? item.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken); + var userIds = grants.Select(item => item.UserId).OfType().Concat(grants.Select(item => item.GrantedBy).OfType()).Distinct().ToArray(); + var badgeIds = grants.Select(item => item.BadgeId).OfType().Distinct().ToArray(); + var users = await dbContext.Users.AsNoTracking() + .Where(user => userIds.Contains(user.Id)) + .ToDictionaryAsync(user => user.Id, cancellationToken); + var badges = await dbContext.Badges.AsNoTracking() + .Where(badge => badge.TenantId == actor.TenantId && badgeIds.Contains(badge.Id)) + .ToDictionaryAsync(badge => badge.Id, cancellationToken); + + return new CatalogList(grants.Select(grant => + ToBadgeGrantItem( + grant, + grant.UserId.HasValue && users.TryGetValue(grant.UserId.Value, out var user) ? user : null, + grant.GrantedBy.HasValue && users.TryGetValue(grant.GrantedBy.Value, out var grantedBy) ? grantedBy : null, + grant.BadgeId.HasValue && badges.TryGetValue(grant.BadgeId.Value, out var badge) ? badge : null)).ToArray()); + } + + public async Task> GrantBadgeAsync( + TenantAdminActor actor, + GrantTenantAdminBadgeCommand command, + CancellationToken cancellationToken = default) + { + var badge = await dbContext.Badges.FirstOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == command.BadgeId, + cancellationToken); + if (badge is null) + { + throw new TenantAdminDirectException("Badge was not found.", "badge_not_found"); + } + + if (!badge.IsActive) + { + throw new TenantAdminDirectException("Cannot grant inactive badge.", "badge_inactive"); + } + + await AssertTenantMemberAsync(actor.TenantId, command.UserId, cancellationToken); + var grant = await dbContext.UserBadges.FirstOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.UserId == command.UserId && item.BadgeId == command.BadgeId, + cancellationToken); + var isNew = grant is null; + grant ??= new UserBadge + { + TenantId = actor.TenantId, + UserId = command.UserId, + BadgeId = command.BadgeId, + GrantedBy = actor.UserId, + GrantedAt = command.GrantedAt ?? DateTimeOffset.UtcNow + }; + grant.LegacyId = Normalize(command.LegacyId) ?? grant.LegacyId ?? $"badge:{command.BadgeId}:user:{command.UserId}"; + grant.Note = Normalize(command.Note) ?? grant.Note; + grant.GrantedBy ??= actor.UserId; + grant.GrantedAt ??= command.GrantedAt ?? DateTimeOffset.UtcNow; + if (isNew) + { + dbContext.UserBadges.Add(grant); + } + + var notification = await dbContext.UserNotifications.FirstOrDefaultAsync( + item => + item.TenantId == actor.TenantId && + item.UserId == command.UserId && + item.NotificationType == "badge_granted" && + item.DedupeKey == grant.LegacyId, + cancellationToken); + notification ??= new UserNotification + { + TenantId = actor.TenantId, + UserId = command.UserId, + NotificationType = "badge_granted", + DedupeKey = grant.LegacyId, + CreatedBy = actor.UserId + }; + notification.Severity = NotificationSeverity.Success; + notification.Title = $"获得勋章:{badge.Name}"; + notification.Message = Normalize(command.Note) ?? "管理员为你发放了一枚新的学习勋章。"; + notification.ActionLabel = "查看勋章"; + notification.ActionPath = "/profile?tab=badges"; + notification.SourceType = "user_badges"; + notification.SourceId = grant.Id; + notification.Metadata = JsonSerializer.SerializeToElement(new { badgeId = badge.Id, badgeName = badge.Name }); + if (dbContext.Entry(notification).State == EntityState.Detached) + { + dbContext.UserNotifications.Add(notification); + } + + await AddAuditAsync(actor, "tenant.badge.granted", "user_badges", grant.Id, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + var user = await dbContext.Users.AsNoTracking().FirstOrDefaultAsync(item => item.Id == command.UserId, cancellationToken); + return new ContentManagementResult(ToBadgeGrantItem(grant, user, null, badge)); + } + + public async Task> GetNotificationsAsync( + TenantAdminActor actor, + TenantAdminNotificationFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.UserNotifications.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + if (filter.UserId.HasValue) + { + query = query.Where(item => item.UserId == filter.UserId.Value); + } + + if (!string.IsNullOrWhiteSpace(filter.Status)) + { + query = query.Where(item => item.Status == ParseEnum(filter.Status, "invalid_notification_status")); + } + + if (!string.IsNullOrWhiteSpace(filter.NotificationType)) + { + query = query.Where(item => item.NotificationType == filter.NotificationType.Trim()); + } + + var items = await query + .OrderByDescending(item => item.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .Select(item => ToNotificationItem(item)) + .ToArrayAsync(cancellationToken); + return new CatalogList(items); + } + + public async Task> UpsertNotificationAsync( + TenantAdminActor actor, + UpsertTenantAdminNotificationCommand command, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(command.NotificationType); + ArgumentException.ThrowIfNullOrWhiteSpace(command.Title); + ArgumentException.ThrowIfNullOrWhiteSpace(command.Message); + await AssertTenantMemberAsync(actor.TenantId, command.UserId, cancellationToken); + + var item = !string.IsNullOrWhiteSpace(command.DedupeKey) + ? await dbContext.UserNotifications.FirstOrDefaultAsync(notification => + notification.TenantId == actor.TenantId && + notification.UserId == command.UserId && + notification.NotificationType == command.NotificationType && + notification.DedupeKey == command.DedupeKey, + cancellationToken) + : null; + var isNew = item is null; + item ??= new UserNotification + { + TenantId = actor.TenantId, + UserId = command.UserId, + CreatedBy = actor.UserId + }; + item.NotificationType = command.NotificationType.Trim(); + item.Severity = ParseEnum(command.Severity, NotificationSeverity.Info, "invalid_notification_severity"); + item.Title = command.Title.Trim(); + item.Message = command.Message.Trim(); + item.ActionLabel = Normalize(command.ActionLabel); + item.ActionPath = Normalize(command.ActionPath); + item.SourceType = Normalize(command.SourceType); + item.SourceId = command.SourceId; + item.DedupeKey = Normalize(command.DedupeKey); + item.Metadata = JsonObjectOrDefault(command.Metadata); + if (isNew) + { + dbContext.UserNotifications.Add(item); + } + + await AddAuditAsync(actor, "tenant.notification.upserted", "user_notifications", item.Id, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToNotificationItem(item)); + } + + public async Task> GetFeedbacksAsync( + TenantAdminActor actor, + TenantAdminFeedbackFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.Reports.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + if (filter.UserId.HasValue) + { + query = query.Where(item => item.UserId == filter.UserId.Value); + } + + if (!string.IsNullOrWhiteSpace(filter.Status)) + { + query = query.Where(item => item.Status == ParseEnum(filter.Status, "invalid_feedback_status")); + } + + if (!string.IsNullOrWhiteSpace(filter.Type)) + { + query = query.Where(item => item.Type == ParseEnum(filter.Type, "invalid_feedback_type")); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(item => + (item.Title != null && item.Title.Contains(keyword)) || + (item.Description != null && item.Description.Contains(keyword)) || + (item.Contact != null && item.Contact.Contains(keyword))); + } + + var reports = await query + .OrderByDescending(item => item.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken); + var userIds = reports.Select(item => item.UserId).OfType().Distinct().ToArray(); + var users = await dbContext.Users.AsNoTracking() + .Where(user => userIds.Contains(user.Id)) + .ToDictionaryAsync(user => user.Id, cancellationToken); + return new CatalogList(reports.Select(report => + ToFeedbackItem(report, report.UserId.HasValue && users.TryGetValue(report.UserId.Value, out var user) ? user : null)).ToArray()); + } + + public async Task> UpdateFeedbackAsync( + TenantAdminActor actor, + UpdateTenantAdminFeedbackCommand command, + CancellationToken cancellationToken = default) + { + var report = await dbContext.Reports.FirstOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == command.FeedbackId, + cancellationToken); + if (report is null) + { + throw new TenantAdminDirectException("Feedback was not found.", "feedback_not_found"); + } + + var fromStatus = report.Status; + report.Status = ParseEnum(command.Status, report.Status, "invalid_feedback_status"); + report.Priority = ParseEnum(command.Priority, report.Priority, "invalid_feedback_priority"); + report.Resolution = Normalize(command.Resolution) ?? report.Resolution; + if (report.Status is ReportStatus.Accepted or ReportStatus.Rejected or ReportStatus.Resolved or ReportStatus.Closed) + { + report.HandledBy = actor.UserId; + report.HandledAt = DateTimeOffset.UtcNow; + } + + dbContext.ReportStatusEvents.Add(new ReportStatusEvent + { + TenantId = actor.TenantId, + ReportId = report.Id, + FromStatus = fromStatus, + ToStatus = report.Status, + Note = Normalize(command.Note), + ActorUserId = actor.UserId, + Metadata = JsonObjectOrDefault(command.Metadata) + }); + await AddAuditAsync(actor, "tenant.feedback.updated", "reports", report.Id, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + var user = report.UserId.HasValue + ? await dbContext.Users.AsNoTracking().FirstOrDefaultAsync(item => item.Id == report.UserId.Value, cancellationToken) + : null; + return new ContentManagementResult(ToFeedbackItem(report, user)); + } + private async Task ResolveUserAsync(UserLookupCommand command, string primaryRole, CancellationToken cancellationToken) { User? user = null; @@ -1621,6 +1948,94 @@ public sealed class TenantAdminDirectService(TikuDbContext dbContext) : ITenantA item.UpdatedAt); } + private static TenantAdminBadgeItem ToBadgeItem(Badge item) + { + return new TenantAdminBadgeItem( + item.Id, + item.LegacyId, + item.Name, + item.Description, + item.Category, + item.IconUrl, + item.Level, + item.UnlockType, + item.ConditionField, + item.ConditionOperator, + item.ConditionValue, + item.ConditionExtra, + item.SortOrder, + item.IsActive, + item.CreatedAt, + item.UpdatedAt); + } + + private static TenantAdminBadgeGrantItem ToBadgeGrantItem(UserBadge grant, User? user, User? grantedBy, Badge? badge) + { + return new TenantAdminBadgeGrantItem( + grant.Id, + grant.LegacyId, + grant.UserId, + user?.Name ?? user?.Username, + user?.Phone, + grant.BadgeId, + badge?.Name, + badge?.Category, + badge?.IconUrl, + badge?.Level, + grant.GrantedBy, + grantedBy?.Name ?? grantedBy?.Username, + grant.Note, + grant.GrantedAt, + grant.CreatedAt, + grant.UpdatedAt); + } + + private static TenantAdminNotificationItem ToNotificationItem(UserNotification item) + { + return new TenantAdminNotificationItem( + item.Id, + item.UserId, + item.NotificationType, + item.Status, + item.Severity, + item.Title, + item.Message, + item.ActionLabel, + item.ActionPath, + item.SourceType, + item.SourceId, + item.DedupeKey, + item.Metadata, + item.CreatedBy, + item.ReadAt, + item.CreatedAt, + item.UpdatedAt); + } + + private static TenantAdminFeedbackItem ToFeedbackItem(Report report, User? user) + { + return new TenantAdminFeedbackItem( + report.Id, + report.UserId, + user?.Name ?? user?.Username, + user?.Phone, + report.QuestionId, + report.Type, + report.Title, + report.Category, + report.Description, + report.Status, + report.Priority, + report.HandledBy, + report.HandledAt, + report.Resolution, + report.Contact, + report.Attachments, + report.Metadata, + report.CreatedAt, + report.UpdatedAt); + } + private static int ResolveLimit(int? limit) { return Math.Clamp(limit ?? 100, 1, 500); diff --git a/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs b/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs index c1e4282..d99f5b0 100644 --- a/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs @@ -7,6 +7,7 @@ using Tiku.Api.Contracts; 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; @@ -299,6 +300,90 @@ public sealed class TenantAdminDirectEndpointTests Assert.NotEmpty(auditJson.RootElement.GetProperty("items").EnumerateArray()); } + [Fact] + public async Task Tenant_admin_can_manage_badges_notifications_and_feedbacks() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedAdminAsync(factory); + var studentId = Guid.NewGuid(); + var feedbackId = Guid.NewGuid(); + await factory.SeedAsync( + new User { Id = studentId, Phone = "13900000005", Name = "反馈学生" }, + new TenantMembership { TenantId = seed.TenantId, UserId = studentId, Role = TenantRole.Student, Status = MembershipStatus.Active }, + new StudentProfile { TenantId = seed.TenantId, UserId = studentId }, + new Report + { + Id = feedbackId, + TenantId = seed.TenantId, + UserId = studentId, + Type = ReportType.Suggestion, + Title = "希望增加解析", + Description = "视频解析再详细一点", + Status = ReportStatus.Pending + }); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + + var badgeResponse = await client.PutAsJsonAsync( + "/api/tenant-admin/badges", + new UpsertTenantAdminBadgeDto + { + Name = "反馈达人", + Category = "feedback", + UnlockType = "manual", + Order = 1 + }); + var badgeJson = await ReadJsonAsync(badgeResponse); + var badgeId = badgeJson.RootElement.GetProperty("item").GetProperty("id").GetGuid(); + + var grantResponse = await client.PostAsJsonAsync( + "/api/tenant-admin/badge-grants", + new GrantTenantAdminBadgeDto + { + UserId = studentId, + BadgeId = badgeId, + Note = "感谢反馈" + }); + var notificationResponse = await client.PutAsJsonAsync( + "/api/tenant-admin/notifications", + new UpsertTenantAdminNotificationDto + { + UserId = studentId, + NotificationType = "admin_message", + Severity = "info", + Title = "学习提醒", + Message = "记得复习错题", + DedupeKey = "daily-review" + }); + var notificationsListResponse = await client.GetAsync($"/api/tenant-admin/notifications?userId={studentId}"); + var feedbackUpdateResponse = await client.PostAsJsonAsync( + "/api/tenant-admin/feedbacks/status", + new UpdateTenantAdminFeedbackDto + { + FeedbackId = feedbackId, + Status = "resolved", + Priority = "high", + Resolution = "已安排补充解析", + Note = "后台处理完成" + }); + var feedbackListResponse = await client.GetAsync("/api/tenant-admin/feedbacks?status=resolved"); + var notificationsJson = await ReadJsonAsync(notificationsListResponse); + var feedbackJson = await ReadJsonAsync(feedbackListResponse); + + Assert.Equal(HttpStatusCode.OK, badgeResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, grantResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, notificationResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, feedbackUpdateResponse.StatusCode); + Assert.True(notificationsJson.RootElement.GetProperty("items").GetArrayLength() >= 2); + Assert.Single(feedbackJson.RootElement.GetProperty("items").EnumerateArray()); + + using var scope = factory.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + Assert.True(await dbContext.UserBadges.AnyAsync(item => item.TenantId == seed.TenantId && item.UserId == studentId && item.BadgeId == badgeId)); + Assert.True(await dbContext.UserNotifications.AnyAsync(item => item.TenantId == seed.TenantId && item.UserId == studentId && item.NotificationType == "badge_granted")); + Assert.True(await dbContext.ReportStatusEvents.AnyAsync(item => item.TenantId == seed.TenantId && item.ReportId == feedbackId && item.ToStatus == ReportStatus.Resolved)); + } + private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedAdminAsync( ApiTestFactory factory, TenantRole role = TenantRole.TenantAdmin)