369 lines
19 KiB
C#
369 lines
19 KiB
C#
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Tiku.Application.Catalog;
|
|
using Tiku.Application.Content;
|
|
using Tiku.Application.Notifications;
|
|
using Tiku.Application.Tenancy;
|
|
using Tiku.Application.TenantAdmin;
|
|
using Tiku.Domain.Learning;
|
|
using Tiku.Domain.Operations;
|
|
using Tiku.Domain.Tenancy;
|
|
using Tiku.Infrastructure.Security;
|
|
using Tiku.Infrastructure.Tenancy;
|
|
|
|
namespace Tiku.Infrastructure.TenantAdmin;
|
|
|
|
internal sealed class TenantEngagementService(TenantAdminServiceDependencies dependencies)
|
|
: TenantAdminServiceBase(dependencies), ITenantEngagementService
|
|
{
|
|
public async Task<CatalogList<TenantAdminBadgeItem>> GetBadgesAsync(
|
|
TenantAdminActor actor,
|
|
TenantAdminBadgeFilter filter,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await RequireAllDataScopeAsync(actor, cancellationToken);
|
|
var query = tenantAdministrationPersistence.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)
|
|
{
|
|
await RequireAllDataScopeAsync(actor, cancellationToken);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
|
|
var item = await ResolveTenantEntityAsync(tenantAdministrationPersistence.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) tenantAdministrationPersistence.Badges.Add(item);
|
|
|
|
await AddAuditAsync(actor, "tenant.badge.upserted", "badges", item.Id, cancellationToken);
|
|
await unitOfWork.SaveChangesAsync(cancellationToken);
|
|
return new ContentManagementResult<TenantAdminBadgeItem>(ToBadgeItem(item));
|
|
}
|
|
|
|
public async Task<CatalogList<TenantAdminBadgeGrantItem>> GetBadgeGrantsAsync(
|
|
TenantAdminActor actor,
|
|
TenantAdminBadgeGrantFilter filter,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
|
var regionIds = scope.RegionIds.ToArray();
|
|
var classIds = scope.ClassIds.ToArray();
|
|
var query = tenantAdministrationPersistence.UserBadges.AsNoTracking()
|
|
.Where(item => item.TenantId == actor.TenantId)
|
|
.ApplyDataScope(
|
|
scope,
|
|
item => item.UserId == actor.UserId || item.GrantedBy == actor.UserId,
|
|
item => item.UserId.HasValue &&
|
|
(tenantAdministrationPersistence.StudentProfiles.Any(profile =>
|
|
profile.TenantId == actor.TenantId &&
|
|
profile.UserId == item.UserId.Value &&
|
|
profile.RegionId.HasValue &&
|
|
regionIds.Contains(profile.RegionId.Value)) ||
|
|
tenantAdministrationPersistence.TenantClassMembers.Any(member =>
|
|
member.TenantId == actor.TenantId &&
|
|
member.UserId == item.UserId.Value &&
|
|
member.Status == TenantClassMemberStatus.Active &&
|
|
classIds.Contains(member.ClassId))));
|
|
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 identityPersistence.Users.AsNoTracking()
|
|
.Where(user => userIds.Contains(user.Id))
|
|
.ToDictionaryAsync(user => user.Id, cancellationToken);
|
|
var badges = await tenantAdministrationPersistence.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 scope = await RequireDataScopeAsync(actor, cancellationToken);
|
|
var badge = await tenantAdministrationPersistence.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 AssertStudentAsync(actor, scope, command.UserId, cancellationToken);
|
|
var grant = await tenantAdministrationPersistence.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;
|
|
grant.Note = Normalize(command.Note) ?? grant.Note;
|
|
grant.GrantedBy ??= actor.UserId;
|
|
grant.GrantedAt ??= command.GrantedAt ?? DateTimeOffset.UtcNow;
|
|
if (isNew) tenantAdministrationPersistence.UserBadges.Add(grant);
|
|
|
|
var dedupeKey = $"badge:{grant.Id:N}";
|
|
await notificationProvider.UpsertInAppAsync(
|
|
new InAppNotificationRequest(
|
|
actor.TenantId,
|
|
command.UserId,
|
|
"badge_granted",
|
|
NotificationSeverity.Success,
|
|
$"获得勋章:{badge.Name}",
|
|
Normalize(command.Note) ?? "管理员为你发放了一枚新的学习勋章。",
|
|
actor.UserId,
|
|
"查看勋章",
|
|
"/profile?tab=badges",
|
|
"user_badges",
|
|
grant.Id,
|
|
dedupeKey,
|
|
JsonSerializer.SerializeToElement(new { badgeId = badge.Id, badgeName = badge.Name })),
|
|
cancellationToken);
|
|
|
|
await AddAuditAsync(actor, "tenant.badge.granted", "user_badges", grant.Id, cancellationToken);
|
|
await unitOfWork.SaveChangesAsync(cancellationToken);
|
|
var user = await identityPersistence.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 scope = await RequireDataScopeAsync(actor, cancellationToken);
|
|
var regionIds = scope.RegionIds.ToArray();
|
|
var classIds = scope.ClassIds.ToArray();
|
|
var query = jobsOperationsPersistence.UserNotifications.AsNoTracking()
|
|
.Where(item => item.TenantId == actor.TenantId)
|
|
.ApplyDataScope(
|
|
scope,
|
|
item => item.UserId == actor.UserId || item.CreatedBy == actor.UserId,
|
|
item => tenantAdministrationPersistence.StudentProfiles.Any(profile =>
|
|
profile.TenantId == actor.TenantId &&
|
|
profile.UserId == item.UserId &&
|
|
profile.RegionId.HasValue &&
|
|
regionIds.Contains(profile.RegionId.Value)) ||
|
|
tenantAdministrationPersistence.TenantClassMembers.Any(member =>
|
|
member.TenantId == actor.TenantId &&
|
|
member.UserId == item.UserId &&
|
|
member.Status == TenantClassMemberStatus.Active &&
|
|
classIds.Contains(member.ClassId)));
|
|
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)
|
|
{
|
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(command.NotificationType);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(command.Title);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(command.Message);
|
|
await AssertStudentAsync(actor, scope, command.UserId, cancellationToken);
|
|
|
|
var item = await notificationProvider.UpsertInAppAsync(
|
|
new InAppNotificationRequest(
|
|
actor.TenantId,
|
|
command.UserId,
|
|
command.NotificationType,
|
|
ParseEnum(command.Severity, NotificationSeverity.Info, "invalid_notification_severity"),
|
|
command.Title,
|
|
command.Message,
|
|
actor.UserId,
|
|
command.ActionLabel,
|
|
command.ActionPath,
|
|
command.SourceType,
|
|
command.SourceId,
|
|
command.DedupeKey,
|
|
JsonObjectOrDefault(command.Metadata)),
|
|
cancellationToken);
|
|
|
|
await AddAuditAsync(actor, "tenant.notification.upserted", "user_notifications", item.Id, cancellationToken);
|
|
await unitOfWork.SaveChangesAsync(cancellationToken);
|
|
return new ContentManagementResult<TenantAdminNotificationItem>(ToNotificationItem(item));
|
|
}
|
|
|
|
public async Task<CatalogList<TenantAdminFeedbackItem>> GetFeedbacksAsync(
|
|
TenantAdminActor actor,
|
|
TenantAdminFeedbackFilter filter,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
|
var regionIds = scope.RegionIds.ToArray();
|
|
var classIds = scope.ClassIds.ToArray();
|
|
var query = learningPersistence.Reports.AsNoTracking()
|
|
.Where(item => item.TenantId == actor.TenantId)
|
|
.ApplyDataScope(
|
|
scope,
|
|
item => item.UserId == actor.UserId || item.HandledBy == actor.UserId,
|
|
item => item.UserId.HasValue &&
|
|
(tenantAdministrationPersistence.StudentProfiles.Any(profile =>
|
|
profile.TenantId == actor.TenantId &&
|
|
profile.UserId == item.UserId.Value &&
|
|
profile.RegionId.HasValue &&
|
|
regionIds.Contains(profile.RegionId.Value)) ||
|
|
tenantAdministrationPersistence.TenantClassMembers.Any(member =>
|
|
member.TenantId == actor.TenantId &&
|
|
member.UserId == item.UserId.Value &&
|
|
member.Status == TenantClassMemberStatus.Active &&
|
|
classIds.Contains(member.ClassId))));
|
|
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 identityPersistence.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 scope = await RequireDataScopeAsync(actor, cancellationToken);
|
|
var regionIds = scope.RegionIds.ToArray();
|
|
var classIds = scope.ClassIds.ToArray();
|
|
var report = await learningPersistence.Reports
|
|
.Where(item => item.TenantId == actor.TenantId && item.Id == command.FeedbackId)
|
|
.ApplyDataScope(
|
|
scope,
|
|
item => item.UserId == actor.UserId || item.HandledBy == actor.UserId,
|
|
item => item.UserId.HasValue &&
|
|
(tenantAdministrationPersistence.StudentProfiles.Any(profile =>
|
|
profile.TenantId == actor.TenantId &&
|
|
profile.UserId == item.UserId.Value &&
|
|
profile.RegionId.HasValue &&
|
|
regionIds.Contains(profile.RegionId.Value)) ||
|
|
tenantAdministrationPersistence.TenantClassMembers.Any(member =>
|
|
member.TenantId == actor.TenantId &&
|
|
member.UserId == item.UserId.Value &&
|
|
member.Status == TenantClassMemberStatus.Active &&
|
|
classIds.Contains(member.ClassId))))
|
|
.FirstOrDefaultAsync(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;
|
|
}
|
|
|
|
learningPersistence.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 unitOfWork.SaveChangesAsync(cancellationToken);
|
|
var user = report.UserId.HasValue
|
|
? await identityPersistence.Users.AsNoTracking()
|
|
.FirstOrDefaultAsync(item => item.Id == report.UserId.Value, cancellationToken)
|
|
: null;
|
|
return new ContentManagementResult<TenantAdminFeedbackItem>(ToFeedbackItem(report, user));
|
|
}
|
|
}
|
|
|