feat: complete phase six backoffice operations

This commit is contained in:
2026-07-28 14:31:43 +08:00
parent 747ff59d76
commit 99e4e43122
31 changed files with 23504 additions and 26 deletions

View File

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