forked from gongxuegit/tiku-backend.net
feat: complete phase six backoffice operations
This commit is contained in:
@@ -15,6 +15,11 @@ using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus;
|
||||
using OrderStatus = Tiku.Domain.Commerce.OrderStatus;
|
||||
using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus;
|
||||
using PointExchangeOrderStatus = Tiku.Domain.Commerce.PointExchangeOrderStatus;
|
||||
using ReconciliationIssueStatus = Tiku.Domain.Commerce.ReconciliationIssueStatus;
|
||||
|
||||
namespace Tiku.Infrastructure.TenantAdmin;
|
||||
|
||||
@@ -25,6 +30,90 @@ public sealed class TenantAdminDirectService(
|
||||
ICurrentAccessContext currentAccessContext,
|
||||
IAuthSessionStore sessionStore) : ITenantAdminDirectService
|
||||
{
|
||||
public async Task<TenantAdminOverviewItem> GetOverviewAsync(
|
||||
TenantAdminActor actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
var regionIds = scope.RegionIds.ToArray();
|
||||
var classIds = scope.ClassIds.ToArray();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var today = new DateTimeOffset(now.UtcDateTime.Date, TimeSpan.Zero);
|
||||
var scopedClasses = dbContext.TenantClasses.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId)
|
||||
.ApplyDataScope(
|
||||
scope,
|
||||
item => item.CreatedBy == actor.UserId,
|
||||
item => classIds.Contains(item.Id) || (item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)));
|
||||
var scopedStudents = dbContext.StudentProfiles.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId)
|
||||
.ApplyDataScope(
|
||||
scope,
|
||||
item => item.UserId == actor.UserId,
|
||||
item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
|
||||
var scopedOrders = dbContext.Orders.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId)
|
||||
.ApplyDataScope(
|
||||
scope,
|
||||
item => item.UserId == actor.UserId,
|
||||
item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
|
||||
|
||||
var studentCount = await scopedStudents.CountAsync(cancellationToken);
|
||||
var classCount = await scopedClasses.CountAsync(item => item.Status == TenantRecordStatus.Active, cancellationToken);
|
||||
var staffCount = await dbContext.TenantMemberships.AsNoTracking()
|
||||
.CountAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.Status == MembershipStatus.Active &&
|
||||
item.Role != TenantRole.Student,
|
||||
cancellationToken);
|
||||
var activePracticeCount = await dbContext.PracticeSessions.AsNoTracking()
|
||||
.CountAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.FinishedAt == null &&
|
||||
(!item.ExpiresAt.HasValue || item.ExpiresAt > now),
|
||||
cancellationToken);
|
||||
var todayPracticeCount = await dbContext.PracticeSessions.AsNoTracking()
|
||||
.CountAsync(item => item.TenantId == actor.TenantId && item.StartedAt >= today, cancellationToken);
|
||||
var pendingFollowupCount = await dbContext.TenantStudentFollowups.AsNoTracking()
|
||||
.CountAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
(item.Status == StudentFollowupStatus.Open || item.Status == StudentFollowupStatus.InProgress),
|
||||
cancellationToken);
|
||||
var unreadNotificationCount = await dbContext.UserNotifications.AsNoTracking()
|
||||
.CountAsync(item => item.TenantId == actor.TenantId && item.Status == NotificationStatus.Unread, cancellationToken);
|
||||
var paidOrderCount = await scopedOrders.CountAsync(item => item.Status == OrderStatus.Paid, cancellationToken);
|
||||
var revenueCents = await scopedOrders
|
||||
.Where(item => item.Status == OrderStatus.Paid || item.Status == OrderStatus.PartiallyRefunded || item.Status == OrderStatus.Refunded)
|
||||
.SumAsync(item => item.AmountCents - item.RefundedAmountCents, cancellationToken);
|
||||
var pendingRefundCount = await dbContext.CommerceRefundRequests.AsNoTracking()
|
||||
.CountAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
(item.Status == CommerceRefundStatus.Requested ||
|
||||
item.Status == CommerceRefundStatus.Approved ||
|
||||
item.Status == CommerceRefundStatus.Processing),
|
||||
cancellationToken);
|
||||
var openReconciliationIssueCount = await dbContext.CommerceReconciliationIssues.AsNoTracking()
|
||||
.CountAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.Status != ReconciliationIssueStatus.Resolved &&
|
||||
item.Status != ReconciliationIssueStatus.Ignored,
|
||||
cancellationToken);
|
||||
|
||||
return new TenantAdminOverviewItem(
|
||||
studentCount,
|
||||
classCount,
|
||||
staffCount,
|
||||
activePracticeCount,
|
||||
todayPracticeCount,
|
||||
pendingFollowupCount,
|
||||
unreadNotificationCount,
|
||||
paidOrderCount,
|
||||
revenueCents,
|
||||
pendingRefundCount,
|
||||
openReconciliationIssueCount,
|
||||
now);
|
||||
}
|
||||
|
||||
public async Task<TenantAdminClassList> GetClassesAsync(
|
||||
TenantAdminActor actor,
|
||||
TenantAdminClassFilter filter,
|
||||
@@ -494,6 +583,289 @@ public sealed class TenantAdminDirectService(
|
||||
new TenantAdminStudentStatusItem(membership.Id, membership.UserId, membership.Role, membership.Status, membership.UpdatedAt));
|
||||
}
|
||||
|
||||
public async Task<TenantAdminStudentImportPreview> PreviewStudentImportAsync(
|
||||
TenantAdminActor actor,
|
||||
TenantAdminStudentImportCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
var items = await BuildStudentImportPreviewAsync(actor, scope, command, cancellationToken);
|
||||
return new TenantAdminStudentImportPreview(
|
||||
command.Rows.Count,
|
||||
items.Count(item => item.Valid),
|
||||
items.Count(item => !item.Valid),
|
||||
items);
|
||||
}
|
||||
|
||||
public async Task<TenantAdminStudentImportResult> ImportStudentsAsync(
|
||||
TenantAdminActor actor,
|
||||
TenantAdminStudentImportCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
var previewItems = await BuildStudentImportPreviewAsync(actor, scope, command, cancellationToken);
|
||||
var invalidItems = previewItems.Where(item => !item.Valid).ToArray();
|
||||
var createdOrUpdated = 0;
|
||||
var classAssigned = 0;
|
||||
var rowNo = 0;
|
||||
foreach (var row in command.Rows)
|
||||
{
|
||||
rowNo++;
|
||||
if (invalidItems.Any(item => item.RowNo == rowNo))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var user = await ResolveUserAsync(row.User, "student", cancellationToken);
|
||||
if (row.RawProfile.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
user.RawProfile = row.RawProfile.Clone();
|
||||
}
|
||||
|
||||
await EnsureMembershipAsync(actor.TenantId, user.Id, TenantRole.Student, cancellationToken);
|
||||
await EnsureStudentProfileAsync(
|
||||
actor.TenantId,
|
||||
user.Id,
|
||||
row.RegionId,
|
||||
null,
|
||||
null,
|
||||
Normalize(row.AvatarPreset),
|
||||
JsonDefaults.Object(),
|
||||
JsonDefaults.Object(),
|
||||
JsonDefaults.Object(),
|
||||
cancellationToken);
|
||||
createdOrUpdated++;
|
||||
|
||||
if (row.ClassId.HasValue)
|
||||
{
|
||||
await UpsertClassMemberCoreAsync(
|
||||
actor,
|
||||
row.ClassId.Value,
|
||||
user.Id,
|
||||
TenantClassMemberType.Student,
|
||||
TenantClassMemberStatus.Active,
|
||||
JsonSerializer.SerializeToElement(new { source = "student_import" }),
|
||||
cancellationToken);
|
||||
classAssigned++;
|
||||
}
|
||||
}
|
||||
|
||||
await AddAuditAsync(actor, "tenant.student.imported", "student_profiles", actor.TenantId, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new TenantAdminStudentImportResult(command.Rows.Count, createdOrUpdated, classAssigned, invalidItems);
|
||||
}
|
||||
|
||||
public async Task<TenantAdminBulkOperationResult> BulkAssignClassAsync(
|
||||
TenantAdminActor actor,
|
||||
TenantAdminBulkAssignClassCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
await AssertClassAsync(actor, scope, command.ClassId, cancellationToken);
|
||||
var failed = new List<Guid>();
|
||||
var succeeded = 0;
|
||||
foreach (var userId in command.UserIds.Where(id => id != Guid.Empty).Distinct())
|
||||
{
|
||||
try
|
||||
{
|
||||
await AssertStudentAsync(actor, scope, userId, cancellationToken);
|
||||
await UpsertClassMemberCoreAsync(
|
||||
actor,
|
||||
command.ClassId,
|
||||
userId,
|
||||
TenantClassMemberType.Student,
|
||||
TenantClassMemberStatus.Active,
|
||||
JsonSerializer.SerializeToElement(new { source = "bulk_assign_class" }),
|
||||
cancellationToken);
|
||||
succeeded++;
|
||||
}
|
||||
catch (TenantAdminDirectException)
|
||||
{
|
||||
failed.Add(userId);
|
||||
}
|
||||
}
|
||||
|
||||
await AddAuditAsync(actor, "tenant.student.bulk_class_assigned", "tenant_classes", command.ClassId, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new TenantAdminBulkOperationResult(command.UserIds.Count, succeeded, failed.Count, failed);
|
||||
}
|
||||
|
||||
public async Task<TenantAdminBulkOperationResult> BulkUpdateStudentStatusAsync(
|
||||
TenantAdminActor actor,
|
||||
TenantAdminBulkStatusCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var failed = new List<Guid>();
|
||||
var succeeded = 0;
|
||||
foreach (var userId in command.UserIds.Where(id => id != Guid.Empty).Distinct())
|
||||
{
|
||||
try
|
||||
{
|
||||
await UpdateStudentStatusAsync(
|
||||
actor,
|
||||
new UpdateTenantAdminStudentStatusCommand(userId, command.Status, command.Reason),
|
||||
cancellationToken);
|
||||
succeeded++;
|
||||
}
|
||||
catch (TenantAdminDirectException)
|
||||
{
|
||||
failed.Add(userId);
|
||||
}
|
||||
}
|
||||
|
||||
await AddAuditAsync(actor, "tenant.student.bulk_status_updated", "tenant_memberships", actor.TenantId, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new TenantAdminBulkOperationResult(command.UserIds.Count, succeeded, failed.Count, failed);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<TenantSupervisionRuleItem>> GetSupervisionRulesAsync(
|
||||
TenantAdminActor actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await RequireDataScopeAsync(actor, cancellationToken);
|
||||
return new CatalogList<TenantSupervisionRuleItem>(await GetSupervisionRulesCoreAsync(actor.TenantId, cancellationToken));
|
||||
}
|
||||
|
||||
public async Task<ContentManagementResult<TenantSupervisionRuleItem>> 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<TenantSupervisionRuleItem>(item);
|
||||
}
|
||||
|
||||
public async Task<TenantSupervisionPreview> PreviewSupervisionAsync(
|
||||
TenantAdminActor actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
return new TenantSupervisionPreview(await BuildSupervisionRiskStudentsAsync(actor, scope, cancellationToken));
|
||||
}
|
||||
|
||||
public async Task<TenantSupervisionGenerateResult> 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<TenantFollowupReport> 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<TenantFeedbackReport> 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<TenantPointRiskReport> 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);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<TenantAdminStudentNoteItem>> GetStudentNotesAsync(
|
||||
TenantAdminActor actor,
|
||||
TenantAdminStudentActivityFilter filter,
|
||||
@@ -1669,6 +2041,197 @@ public sealed class TenantAdminDirectService(
|
||||
return profile;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyCollection<TenantAdminStudentImportPreviewItem>> BuildStudentImportPreviewAsync(
|
||||
TenantAdminActor actor,
|
||||
CurrentDataScope scope,
|
||||
TenantAdminStudentImportCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var items = new List<TenantAdminStudentImportPreviewItem>();
|
||||
var rowNo = 0;
|
||||
foreach (var row in command.Rows.Take(1000))
|
||||
{
|
||||
rowNo++;
|
||||
string? reason = null;
|
||||
var phone = Normalize(row.User.Phone);
|
||||
var email = Normalize(row.User.Email);
|
||||
var name = Normalize(row.User.Name);
|
||||
if (row.User.UserId is null && phone is null && email is null && name is null)
|
||||
{
|
||||
reason = "user_required";
|
||||
}
|
||||
else if (!scope.AllowsResource(actor.UserId, actor.UserId, row.RegionId))
|
||||
{
|
||||
reason = "data_scope_denied";
|
||||
}
|
||||
else if (row.RegionId.HasValue && !await dbContext.Regions.AnyAsync(item => item.TenantId == actor.TenantId && item.Id == row.RegionId.Value, cancellationToken))
|
||||
{
|
||||
reason = "region_not_found";
|
||||
}
|
||||
else if (row.ClassId.HasValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
await AssertClassAsync(actor, scope, row.ClassId, cancellationToken);
|
||||
}
|
||||
catch (TenantAdminDirectException exception)
|
||||
{
|
||||
reason = exception.Code;
|
||||
}
|
||||
}
|
||||
|
||||
items.Add(new TenantAdminStudentImportPreviewItem(
|
||||
rowNo,
|
||||
reason is null,
|
||||
reason,
|
||||
phone,
|
||||
email,
|
||||
name,
|
||||
row.RegionId,
|
||||
row.ClassId));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private async Task<TenantClassMember> UpsertClassMemberCoreAsync(
|
||||
TenantAdminActor actor,
|
||||
Guid classId,
|
||||
Guid userId,
|
||||
TenantClassMemberType memberType,
|
||||
TenantClassMemberStatus status,
|
||||
JsonElement metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var item = await dbContext.TenantClassMembers.FirstOrDefaultAsync(member =>
|
||||
member.TenantId == actor.TenantId &&
|
||||
member.ClassId == classId &&
|
||||
member.UserId == userId &&
|
||||
member.MemberType == memberType,
|
||||
cancellationToken);
|
||||
if (item is null)
|
||||
{
|
||||
item = new TenantClassMember
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
ClassId = classId,
|
||||
UserId = userId,
|
||||
MemberType = memberType,
|
||||
JoinedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
dbContext.TenantClassMembers.Add(item);
|
||||
}
|
||||
|
||||
item.Status = status;
|
||||
item.LeftAt = status == TenantClassMemberStatus.Removed ? DateTimeOffset.UtcNow : null;
|
||||
item.Metadata = JsonObjectOrDefault(metadata);
|
||||
return item;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyCollection<TenantSupervisionRuleItem>> GetSupervisionRulesCoreAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var settings = await dbContext.TenantSettings.AsNoTracking().SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken);
|
||||
if (settings is null ||
|
||||
settings.AdminFeatureFlags.ValueKind != JsonValueKind.Object ||
|
||||
!settings.AdminFeatureFlags.TryGetProperty("supervisionRules", out var rulesElement) ||
|
||||
rulesElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<TenantSupervisionRuleItem[]>(rulesElement.GetRawText()) ?? [];
|
||||
}
|
||||
|
||||
private async Task SaveSupervisionRulesCoreAsync(
|
||||
Guid tenantId,
|
||||
IReadOnlyCollection<TenantSupervisionRuleItem> rules,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var settings = await dbContext.TenantSettings.SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken);
|
||||
if (settings is null)
|
||||
{
|
||||
settings = new TenantSettings { TenantId = tenantId };
|
||||
dbContext.TenantSettings.Add(settings);
|
||||
}
|
||||
|
||||
var existing = settings.AdminFeatureFlags.ValueKind == JsonValueKind.Object
|
||||
? JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(settings.AdminFeatureFlags.GetRawText()) ?? []
|
||||
: [];
|
||||
existing["supervisionRules"] = JsonSerializer.SerializeToElement(rules);
|
||||
settings.AdminFeatureFlags = JsonSerializer.SerializeToElement(existing);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyCollection<TenantSupervisionRiskStudentItem>> BuildSupervisionRiskStudentsAsync(
|
||||
TenantAdminActor actor,
|
||||
CurrentDataScope scope,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var rules = (await GetSupervisionRulesCoreAsync(actor.TenantId, cancellationToken))
|
||||
.Where(rule => rule.Enabled)
|
||||
.ToArray();
|
||||
if (rules.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var regionIds = scope.RegionIds.ToArray();
|
||||
var students = await dbContext.StudentProfiles.AsNoTracking()
|
||||
.Where(profile => profile.TenantId == actor.TenantId)
|
||||
.ApplyDataScope(
|
||||
scope,
|
||||
profile => profile.UserId == actor.UserId,
|
||||
profile => profile.RegionId.HasValue && regionIds.Contains(profile.RegionId.Value))
|
||||
.Select(profile => new
|
||||
{
|
||||
Profile = profile,
|
||||
User = dbContext.Users.Where(user => user.Id == profile.UserId).FirstOrDefault()
|
||||
})
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var result = new List<TenantSupervisionRiskStudentItem>();
|
||||
foreach (var row in students)
|
||||
{
|
||||
var hitRules = new List<string>();
|
||||
var reasons = new List<string>();
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
if (rule.DaysWithoutCheckIn.HasValue)
|
||||
{
|
||||
var days = row.Profile.LastCheckInDate.HasValue
|
||||
? today.DayNumber - row.Profile.LastCheckInDate.Value.DayNumber
|
||||
: int.MaxValue;
|
||||
if (days >= rule.DaysWithoutCheckIn.Value)
|
||||
{
|
||||
hitRules.Add(rule.Code);
|
||||
reasons.Add($"{rule.Title}: {days} days without check-in");
|
||||
}
|
||||
}
|
||||
|
||||
if (rule.MaxQuestionsAnsweredToday.HasValue &&
|
||||
row.Profile.QuestionsAnsweredToday <= rule.MaxQuestionsAnsweredToday.Value)
|
||||
{
|
||||
hitRules.Add(rule.Code);
|
||||
reasons.Add($"{rule.Title}: questions answered today <= {rule.MaxQuestionsAnsweredToday.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
if (hitRules.Count > 0)
|
||||
{
|
||||
result.Add(new TenantSupervisionRiskStudentItem(
|
||||
row.Profile.UserId,
|
||||
row.User?.Name,
|
||||
MaskPhone(row.User?.Phone),
|
||||
row.Profile.RegionId,
|
||||
hitRules.Distinct(StringComparer.Ordinal).ToArray(),
|
||||
reasons.Distinct(StringComparer.Ordinal).ToArray()));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task AssertStudentAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken)
|
||||
{
|
||||
var exists = await dbContext.TenantMemberships.AnyAsync(
|
||||
@@ -2261,6 +2824,19 @@ public sealed class TenantAdminDirectService(
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static string NormalizeCode(string value)
|
||||
{
|
||||
return value.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string? MaskPhone(string? phone)
|
||||
{
|
||||
var value = Normalize(phone);
|
||||
return value is { Length: >= 7 }
|
||||
? $"{value[..3]}****{value[^4..]}"
|
||||
: value;
|
||||
}
|
||||
|
||||
private static JsonElement JsonObjectOrDefault(JsonElement value)
|
||||
{
|
||||
return value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDefaults.Object();
|
||||
|
||||
Reference in New Issue
Block a user