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

@@ -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);
}
}

View File

@@ -318,6 +318,86 @@ public sealed class TenantAdminDirectController(
return Ok(await tenantAdminService.UpsertAuthProviderAsync(ResolveActor(), request.ToCommand(), cancellationToken));
}
[HttpGet("badges")]
[EndpointSummary("查询租户勋章")]
[ProducesResponseType<CatalogList<TenantAdminBadgeItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantAdminBadgeItem>>> GetBadges(
[FromQuery] TenantAdminBadgeQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.GetBadgesAsync(ResolveActor(), query.ToFilter(), cancellationToken));
}
[HttpPut("badges")]
[EndpointSummary("新增或更新租户勋章")]
[ProducesResponseType<ContentManagementResult<TenantAdminBadgeItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminBadgeItem>>> UpsertBadge(
UpsertTenantAdminBadgeDto request,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.UpsertBadgeAsync(ResolveActor(), request.ToCommand(), cancellationToken));
}
[HttpGet("badge-grants")]
[EndpointSummary("查询勋章发放记录")]
[ProducesResponseType<CatalogList<TenantAdminBadgeGrantItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantAdminBadgeGrantItem>>> GetBadgeGrants(
[FromQuery] TenantAdminBadgeGrantQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.GetBadgeGrantsAsync(ResolveActor(), query.ToFilter(), cancellationToken));
}
[HttpPost("badge-grants")]
[EndpointSummary("向租户成员发放勋章")]
[ProducesResponseType<ContentManagementResult<TenantAdminBadgeGrantItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminBadgeGrantItem>>> GrantBadge(
GrantTenantAdminBadgeDto request,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.GrantBadgeAsync(ResolveActor(), request.ToCommand(), cancellationToken));
}
[HttpGet("notifications")]
[EndpointSummary("查询用户站内通知")]
[ProducesResponseType<CatalogList<TenantAdminNotificationItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantAdminNotificationItem>>> GetNotifications(
[FromQuery] TenantAdminNotificationQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.GetNotificationsAsync(ResolveActor(), query.ToFilter(), cancellationToken));
}
[HttpPut("notifications")]
[EndpointSummary("新增或更新用户站内通知")]
[ProducesResponseType<ContentManagementResult<TenantAdminNotificationItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminNotificationItem>>> UpsertNotification(
UpsertTenantAdminNotificationDto request,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.UpsertNotificationAsync(ResolveActor(), request.ToCommand(), cancellationToken));
}
[HttpGet("feedbacks")]
[EndpointSummary("查询用户反馈")]
[ProducesResponseType<CatalogList<TenantAdminFeedbackItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantAdminFeedbackItem>>> GetFeedbacks(
[FromQuery] TenantAdminFeedbackQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.GetFeedbacksAsync(ResolveActor(), query.ToFilter(), cancellationToken));
}
[HttpPost("feedbacks/status")]
[EndpointSummary("处理用户反馈")]
[ProducesResponseType<ContentManagementResult<TenantAdminFeedbackItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminFeedbackItem>>> 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)

View File

@@ -154,4 +154,44 @@ public interface ITenantAdminDirectService
TenantAdminActor actor,
UpsertTenantAuthProviderCommand command,
CancellationToken cancellationToken = default);
Task<CatalogList<TenantAdminBadgeItem>> GetBadgesAsync(
TenantAdminActor actor,
TenantAdminBadgeFilter filter,
CancellationToken cancellationToken = default);
Task<ContentManagementResult<TenantAdminBadgeItem>> UpsertBadgeAsync(
TenantAdminActor actor,
UpsertTenantAdminBadgeCommand command,
CancellationToken cancellationToken = default);
Task<CatalogList<TenantAdminBadgeGrantItem>> GetBadgeGrantsAsync(
TenantAdminActor actor,
TenantAdminBadgeGrantFilter filter,
CancellationToken cancellationToken = default);
Task<ContentManagementResult<TenantAdminBadgeGrantItem>> GrantBadgeAsync(
TenantAdminActor actor,
GrantTenantAdminBadgeCommand command,
CancellationToken cancellationToken = default);
Task<CatalogList<TenantAdminNotificationItem>> GetNotificationsAsync(
TenantAdminActor actor,
TenantAdminNotificationFilter filter,
CancellationToken cancellationToken = default);
Task<ContentManagementResult<TenantAdminNotificationItem>> UpsertNotificationAsync(
TenantAdminActor actor,
UpsertTenantAdminNotificationCommand command,
CancellationToken cancellationToken = default);
Task<CatalogList<TenantAdminFeedbackItem>> GetFeedbacksAsync(
TenantAdminActor actor,
TenantAdminFeedbackFilter filter,
CancellationToken cancellationToken = default);
Task<ContentManagementResult<TenantAdminFeedbackItem>> UpdateFeedbackAsync(
TenantAdminActor actor,
UpdateTenantAdminFeedbackCommand command,
CancellationToken cancellationToken = default);
}

View File

@@ -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<TenantAdminClassItem> 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;

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);

View File

@@ -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<TikuDbContext>();
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)