using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Catalog; using Tiku.Application.Content; using Tiku.Application.TenantAdmin; using Tiku.Domain.Learning; using Tiku.Domain.Tenancy; using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus; using PointExchangeOrderStatus = Tiku.Domain.Commerce.PointExchangeOrderStatus; namespace Tiku.Infrastructure.TenantAdmin; public sealed partial class TenantAdminDirectService { public async Task> GetSupervisionRulesAsync( TenantAdminActor actor, CancellationToken cancellationToken = default) { await RequireDataScopeAsync(actor, cancellationToken); return new CatalogList( await GetSupervisionRulesCoreAsync(actor.TenantId, cancellationToken)); } public async Task> UpsertSupervisionRuleAsync( TenantAdminActor actor, UpsertTenantSupervisionRuleCommand command, CancellationToken cancellationToken = default) { await RequireDataScopeAsync(actor, cancellationToken); var code = NormalizeCode(command.Code); ArgumentException.ThrowIfNullOrWhiteSpace(command.Title); var rules = (await GetSupervisionRulesCoreAsync(actor.TenantId, cancellationToken)).ToList(); var item = new TenantSupervisionRuleItem( code, command.Title.Trim(), command.Enabled, command.DaysWithoutCheckIn, command.MaxQuestionsAnsweredToday, Normalize(command.FollowupType) ?? StudentFollowupType.Risk.ToString(), Normalize(command.Priority) ?? StudentFollowupPriority.High.ToString(), JsonObjectOrDefault(command.Metadata)); rules.RemoveAll(rule => string.Equals(rule.Code, code, StringComparison.Ordinal)); rules.Add(item); await SaveSupervisionRulesCoreAsync(actor.TenantId, rules.OrderBy(rule => rule.Code).ToArray(), cancellationToken); await AddAuditAsync(actor, "tenant.supervision_rule.upserted", "tenant_settings", actor.TenantId, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); } public async Task PreviewSupervisionAsync( TenantAdminActor actor, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); return new TenantSupervisionPreview(await BuildSupervisionRiskStudentsAsync(actor, scope, cancellationToken)); } public async Task GenerateSupervisionFollowupsAsync( TenantAdminActor actor, TenantSupervisionGenerateCommand command, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); await AssertTenantMemberAsync(actor.TenantId, command.AssignedToUserId, cancellationToken); var riskStudents = await BuildSupervisionRiskStudentsAsync(actor, scope, cancellationToken); if (command.UserIds is { Count: > 0 }) { var selected = command.UserIds.Where(id => id != Guid.Empty).Distinct().ToHashSet(); riskStudents = riskStudents.Where(item => selected.Contains(item.UserId)).ToArray(); } var created = 0; foreach (var student in riskStudents) { if (await dbContext.TenantStudentFollowups.AnyAsync(item => item.TenantId == actor.TenantId && item.StudentUserId == student.UserId && item.Status != StudentFollowupStatus.Done && item.FollowupType == StudentFollowupType.Risk, cancellationToken)) continue; dbContext.TenantStudentFollowups.Add(new TenantStudentFollowup { TenantId = actor.TenantId, StudentUserId = student.UserId, AssignedToUserId = command.AssignedToUserId, Title = "学习风险督导", Description = string.Join(";", student.Reasons), FollowupType = StudentFollowupType.Risk, Priority = StudentFollowupPriority.High, Status = StudentFollowupStatus.Open, DueAt = command.DueAt, CreatedBy = actor.UserId, UpdatedBy = actor.UserId, Metadata = JsonSerializer.SerializeToElement(new { ruleCodes = student.RuleCodes }) }); created++; } await AddAuditAsync(actor, "tenant.supervision_followups.generated", "tenant_student_followups", actor.TenantId, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new TenantSupervisionGenerateResult(created); } public async Task GetFollowupReportAsync( TenantAdminActor actor, CancellationToken cancellationToken = default) { await RequireDataScopeAsync(actor, cancellationToken); var now = DateTimeOffset.UtcNow; return new TenantFollowupReport( await dbContext.TenantStudentFollowups.CountAsync( item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.Open, cancellationToken), await dbContext.TenantStudentFollowups.CountAsync( item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.InProgress, cancellationToken), await dbContext.TenantStudentFollowups.CountAsync( item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.Done, cancellationToken), await dbContext.TenantStudentFollowups.CountAsync(item => item.TenantId == actor.TenantId && item.DueAt.HasValue && item.DueAt < now && item.Status != StudentFollowupStatus.Done && item.Status != StudentFollowupStatus.Cancelled, cancellationToken)); } public async Task GetFeedbackReportAsync( TenantAdminActor actor, CancellationToken cancellationToken = default) { await RequireDataScopeAsync(actor, cancellationToken); return new TenantFeedbackReport( await dbContext.Reports.CountAsync( item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Pending, cancellationToken), await dbContext.Reports.CountAsync( item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Accepted, cancellationToken), await dbContext.Reports.CountAsync( item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Rejected, cancellationToken), await dbContext.Reports.CountAsync( item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Resolved, cancellationToken), await dbContext.Reports.CountAsync( item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Closed, cancellationToken)); } public async Task GetPointRiskReportAsync( TenantAdminActor actor, CancellationToken cancellationToken = default) { await RequireDataScopeAsync(actor, cancellationToken); var negativeScoreUsers = await dbContext.Users .Where(user => user.Score < 0 && dbContext.TenantMemberships.Any(member => member.TenantId == actor.TenantId && member.UserId == user.Id && member.Status == MembershipStatus.Active)) .CountAsync(cancellationToken); var since = DateTimeOffset.UtcNow.AddDays(-7); var highClaimUsers = await dbContext.PointActivityClaims .Where(item => item.TenantId == actor.TenantId && item.Status == PointActivityClaimStatus.Claimed && item.ClaimedAt >= since) .GroupBy(item => item.UserId) .Where(group => group.Sum(item => item.Points) >= 1000) .CountAsync(cancellationToken); var cancelledExchangeOrders = await dbContext.PointExchangeOrders.CountAsync( item => item.TenantId == actor.TenantId && item.Status == PointExchangeOrderStatus.Cancelled, cancellationToken); return new TenantPointRiskReport(negativeScoreUsers, highClaimUsers, cancelledExchangeOrders); } }