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

@@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Api.Contracts;
using Tiku.Domain.Catalog;
using Tiku.Domain.Commerce;
using Tiku.Domain.Common;
using Tiku.Domain.Identity;
using Tiku.Domain.Learning;
@@ -17,6 +18,177 @@ namespace Tiku.IntegrationTests.Api;
public sealed class TenantAdminDirectEndpointTests
{
[Fact]
public async Task Tenant_admin_can_view_operational_overview()
{
await using var factory = new ApiTestFactory();
var seed = await SeedAdminAsync(factory);
var studentId = Guid.NewGuid();
var classId = Guid.NewGuid();
await factory.SeedAsync(
new User { Id = studentId, Phone = "13900009901", Name = "概览学生" },
new TenantMembership { TenantId = seed.TenantId, UserId = studentId, Role = TenantRole.Student, Status = MembershipStatus.Active },
new StudentProfile { TenantId = seed.TenantId, UserId = studentId },
new TenantClass { Id = classId, TenantId = seed.TenantId, Name = "概览班级", Status = TenantRecordStatus.Active },
new TenantStudentFollowup
{
TenantId = seed.TenantId,
StudentUserId = studentId,
Title = "待跟进",
Status = StudentFollowupStatus.Open
},
new UserNotification
{
TenantId = seed.TenantId,
UserId = studentId,
NotificationType = "admin",
Title = "通知",
Message = "概览通知",
Status = NotificationStatus.Unread
},
new Order
{
TenantId = seed.TenantId,
UserId = studentId,
OrderNo = "OVERVIEW-ORDER",
Status = OrderStatus.Paid,
AmountCents = 2_000,
PaidAt = DateTimeOffset.UtcNow
});
using var client = factory.CreateClient();
await LoginAsync(client, seed);
var response = await client.GetAsync("/api/tenant-admin/overview");
var body = await ReadJsonAsync(response);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(1, body.RootElement.GetProperty("studentCount").GetInt32());
Assert.Equal(1, body.RootElement.GetProperty("classCount").GetInt32());
Assert.Equal(1, body.RootElement.GetProperty("pendingFollowupCount").GetInt32());
Assert.Equal(1, body.RootElement.GetProperty("unreadNotificationCount").GetInt32());
Assert.Equal(1, body.RootElement.GetProperty("paidOrderCount").GetInt32());
Assert.Equal(2_000, body.RootElement.GetProperty("revenueCents").GetInt32());
}
[Fact]
public async Task Tenant_admin_can_run_student_bulk_operations_supervision_and_reports()
{
await using var factory = new ApiTestFactory();
var seed = await SeedAdminAsync(factory);
var regionId = Guid.NewGuid();
var classId = Guid.NewGuid();
var pointTaskId = Guid.NewGuid();
var pointItemId = Guid.NewGuid();
var existingStudentId = Guid.NewGuid();
await factory.SeedAsync(
new Region { Id = regionId, TenantId = seed.TenantId, Name = "批量区域" },
new TenantClass { Id = classId, TenantId = seed.TenantId, RegionId = regionId, Name = "批量班级", Status = TenantRecordStatus.Active },
new User { Id = existingStudentId, Phone = "13900008888", Name = "已有学生", Score = -10 },
new TenantMembership { TenantId = seed.TenantId, UserId = existingStudentId, Role = TenantRole.Student, Status = MembershipStatus.Active },
new StudentProfile
{
TenantId = seed.TenantId,
UserId = existingStudentId,
RegionId = regionId,
LastCheckInDate = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-10)),
QuestionsAnsweredToday = 0
},
new Report { TenantId = seed.TenantId, UserId = existingStudentId, Status = ReportStatus.Pending, Type = ReportType.Suggestion },
new PointActivityTask { Id = pointTaskId, TenantId = seed.TenantId, TaskKey = "bulk-risk", Title = "风险任务", Points = 1000 },
new PointActivityClaim
{
TenantId = seed.TenantId,
TaskId = pointTaskId,
UserId = existingStudentId,
TaskKey = "bulk-risk",
Points = 1000,
Status = PointActivityClaimStatus.Claimed
},
new PointExchangeItem { Id = pointItemId, TenantId = seed.TenantId, ItemKey = "risk-item", Name = "风险兑换", PointsCost = 10 },
new PointExchangeOrder
{
TenantId = seed.TenantId,
ItemId = pointItemId,
UserId = existingStudentId,
OrderNo = "POINT-RISK-1",
ItemName = "风险兑换",
Status = PointExchangeOrderStatus.Cancelled,
PointsCost = 10
});
using var client = factory.CreateClient();
await LoginAsync(client, seed);
var importRequest = new TenantAdminStudentImportDto
{
Rows =
[
new TenantAdminStudentImportRowDto
{
User = new TenantAdminUserLookupDto { Phone = "13900007777", Name = "批量学生" },
RegionId = regionId,
ClassId = classId
},
new TenantAdminStudentImportRowDto()
]
};
var previewResponse = await client.PostAsJsonAsync("/api/tenant-admin/students/import/preview", importRequest);
var importResponse = await client.PostAsJsonAsync("/api/tenant-admin/students/import", importRequest);
var importJson = await ReadJsonAsync(importResponse);
var importedUserId = await GetUserIdByPhoneAsync(factory, "13900007777");
var assignResponse = await client.PostAsJsonAsync(
"/api/tenant-admin/students/bulk-assign-class",
new TenantAdminBulkAssignClassDto { ClassId = classId, UserIds = [existingStudentId, importedUserId] });
var statusResponse = await client.PostAsJsonAsync(
"/api/tenant-admin/students/bulk-status",
new TenantAdminBulkStatusDto { UserIds = [importedUserId], Status = "disabled", Reason = "batch test" });
var ruleResponse = await client.PutAsJsonAsync(
"/api/tenant-admin/students/supervision/rules",
new UpsertTenantSupervisionRuleDto
{
Code = "inactive",
Title = "长时间未学习",
DaysWithoutCheckIn = 3,
MaxQuestionsAnsweredToday = 0
});
var rulesResponse = await client.GetAsync("/api/tenant-admin/students/supervision/rules");
var previewRiskResponse = await client.GetAsync("/api/tenant-admin/students/supervision/preview");
var generateResponse = await client.PostAsJsonAsync(
"/api/tenant-admin/students/supervision/generate",
new TenantSupervisionGenerateDto { UserIds = [existingStudentId], AssignedToUserId = seed.UserId });
var followupReportResponse = await client.GetAsync("/api/tenant-admin/student-followups/report");
var feedbackReportResponse = await client.GetAsync("/api/tenant-admin/feedbacks/report");
var pointRiskReportResponse = await client.GetAsync("/api/tenant-admin/points/risk-report");
Assert.Equal(HttpStatusCode.OK, previewResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, importResponse.StatusCode);
Assert.Equal(1, importJson.RootElement.GetProperty("createdOrUpdatedCount").GetInt32());
Assert.Equal(1, importJson.RootElement.GetProperty("invalidItems").GetArrayLength());
Assert.Equal(HttpStatusCode.OK, assignResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, statusResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, ruleResponse.StatusCode);
Assert.Contains("inactive", await rulesResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
Assert.Contains(existingStudentId.ToString(), await previewRiskResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
Assert.Equal(HttpStatusCode.OK, generateResponse.StatusCode);
Assert.Contains("openCount", await followupReportResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
Assert.Contains("pendingCount", await feedbackReportResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
Assert.Contains("negativeScoreUserCount", await pointRiskReportResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
using var scope = factory.CreateSystemScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
Assert.True(await dbContext.TenantClassMembers.AnyAsync(item =>
item.TenantId == seed.TenantId &&
item.ClassId == classId &&
item.UserId == importedUserId));
Assert.True(await dbContext.TenantMemberships.AnyAsync(item =>
item.TenantId == seed.TenantId &&
item.UserId == importedUserId &&
item.Status == MembershipStatus.Disabled));
Assert.True(await dbContext.TenantStudentFollowups.AnyAsync(item =>
item.TenantId == seed.TenantId &&
item.StudentUserId == existingStudentId &&
item.FollowupType == StudentFollowupType.Risk));
}
[Fact]
public async Task Tenant_admin_can_manage_classes_members_and_students()
{
@@ -404,6 +576,16 @@ public sealed class TenantAdminDirectEndpointTests
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
}
private static async Task<Guid> GetUserIdByPhoneAsync(ApiTestFactory factory, string phone)
{
using var scope = factory.CreateSystemScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
return await dbContext.Users
.Where(user => user.Phone == phone)
.Select(user => user.Id)
.SingleAsync();
}
private static async Task<JsonDocument> ReadJsonAsync(HttpResponseMessage response)
{
var stream = await response.Content.ReadAsStreamAsync();