feat: add tenant admin engagement endpoints
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user