feat: add tenant admin engagement endpoints

This commit is contained in:
xiong
2026-07-26 19:15:39 +08:00
parent dca6e8c66d
commit 28befc57be
6 changed files with 930 additions and 0 deletions

View File

@@ -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<TenantAuthProviderItem>(ToAuthProviderItem(item));
}
public async Task<CatalogList<TenantAdminBadgeItem>> 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<TenantAdminBadgeItem>(items);
}
public async Task<ContentManagementResult<TenantAdminBadgeItem>> 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<TenantAdminBadgeItem>(ToBadgeItem(item));
}
public async Task<CatalogList<TenantAdminBadgeGrantItem>> 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<Guid>().Concat(grants.Select(item => item.GrantedBy).OfType<Guid>()).Distinct().ToArray();
var badgeIds = grants.Select(item => item.BadgeId).OfType<Guid>().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<TenantAdminBadgeGrantItem>(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<ContentManagementResult<TenantAdminBadgeGrantItem>> 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<TenantAdminBadgeGrantItem>(ToBadgeGrantItem(grant, user, null, badge));
}
public async Task<CatalogList<TenantAdminNotificationItem>> 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<NotificationStatus>(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<TenantAdminNotificationItem>(items);
}
public async Task<ContentManagementResult<TenantAdminNotificationItem>> 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<TenantAdminNotificationItem>(ToNotificationItem(item));
}
public async Task<CatalogList<TenantAdminFeedbackItem>> 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<ReportStatus>(filter.Status, "invalid_feedback_status"));
}
if (!string.IsNullOrWhiteSpace(filter.Type))
{
query = query.Where(item => item.Type == ParseEnum<ReportType>(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<Guid>().Distinct().ToArray();
var users = await dbContext.Users.AsNoTracking()
.Where(user => userIds.Contains(user.Id))
.ToDictionaryAsync(user => user.Id, cancellationToken);
return new CatalogList<TenantAdminFeedbackItem>(reports.Select(report =>
ToFeedbackItem(report, report.UserId.HasValue && users.TryGetValue(report.UserId.Value, out var user) ? user : null)).ToArray());
}
public async Task<ContentManagementResult<TenantAdminFeedbackItem>> 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<TenantAdminFeedbackItem>(ToFeedbackItem(report, user));
}
private async Task<User> 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);