多角色账号按 All > College > Class > Self 自动取最高权限。

学院管理员限制在所属学院。
辅导员通过稳定账号 ID 绑定行政班,避免重名串班。
教师只能访问本人档案、授课课程和所授课学生。
学生只能访问本人档案及所在班级课程。
教师/学生角色会自动校验并绑定工号或学号档案。
超级管理员可在用户页面调整角色、学院、工号/学号,并预览生效后的数据范围。
This commit is contained in:
2026-07-24 14:54:30 +08:00 Unverified
parent 0b463fa4f2
commit bcc4d33bd5
26 changed files with 2333 additions and 88 deletions
@@ -0,0 +1,89 @@
using System.Security.Claims;
using Jiaowu.Api.Domain.Identity;
namespace Jiaowu.Api.Infrastructure.Auth;
public interface ICurrentUserDataScope
{
CurrentUserScope Current { get; }
}
public sealed record CurrentUserScope(
Guid UserId,
string? DisplayName,
Guid? CollegeId,
DataScope Scope,
IReadOnlySet<string> Roles)
{
public bool IsInRole(string role) => Roles.Contains(role);
public bool CanAccessCollege(Guid collegeId) =>
Scope == DataScope.All ||
Scope == DataScope.College && CollegeId == collegeId;
public Guid? RestrictedCollegeId => Scope switch
{
DataScope.All => null,
DataScope.College => CollegeId ?? Guid.Empty,
_ => Guid.Empty
};
}
public sealed class CurrentUserDataScope(IHttpContextAccessor httpContextAccessor)
: ICurrentUserDataScope
{
public CurrentUserScope Current
{
get
{
var principal = httpContextAccessor.HttpContext?.User;
var roles = new HashSet<string>(
principal?.FindAll(ClaimTypes.Role).Select(x => x.Value) ?? [],
StringComparer.OrdinalIgnoreCase);
return new CurrentUserScope(
ParseGuid(principal?.FindFirstValue(ClaimTypes.NameIdentifier)),
principal?.FindFirstValue("display_name") ??
principal?.FindFirstValue(ClaimTypes.Name),
ParseNullableGuid(principal?.FindFirstValue("college_id")),
EffectiveDataScopeResolver.Resolve(roles),
roles);
}
}
private static Guid ParseGuid(string? value) =>
Guid.TryParse(value, out var id) ? id : Guid.Empty;
private static Guid? ParseNullableGuid(string? value) =>
Guid.TryParse(value, out var id) ? id : null;
}
public static class EffectiveDataScopeResolver
{
private static readonly IReadOnlyDictionary<string, DataScope> RoleScopes =
new Dictionary<string, DataScope>(StringComparer.OrdinalIgnoreCase)
{
[SystemRoles.SuperAdmin] = DataScope.All,
[SystemRoles.AcademicAdmin] = DataScope.All,
[SystemRoles.Leader] = DataScope.All,
[SystemRoles.CollegeAdmin] = DataScope.College,
[SystemRoles.Counselor] = DataScope.Class,
[SystemRoles.Teacher] = DataScope.Self,
[SystemRoles.Student] = DataScope.Self
};
public static DataScope Resolve(IEnumerable<string> roles)
{
var effectiveScope = DataScope.Self;
foreach (var role in roles)
{
if (RoleScopes.TryGetValue(role, out var roleScope) &&
roleScope > effectiveScope)
{
effectiveScope = roleScope;
}
}
return effectiveScope;
}
}
@@ -23,7 +23,8 @@ public sealed class TokenService(IOptions<JwtOptions> options) : ITokenService
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new(JwtRegisteredClaimNames.UniqueName, user.UserName ?? string.Empty),
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new(ClaimTypes.Name, user.DisplayName)
new(ClaimTypes.Name, user.DisplayName),
new("display_name", user.DisplayName)
};
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
@@ -72,6 +72,14 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.WithMany()
.HasForeignKey(x => x.MajorId)
.OnDelete(DeleteBehavior.Restrict);
builder.Entity<AdministrativeClass>(entity =>
{
entity.HasIndex(x => x.CounselorUserId);
entity.HasOne(x => x.CounselorUser)
.WithMany()
.HasForeignKey(x => x.CounselorUserId)
.OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<Building>()
.HasOne(x => x.Campus)
@@ -419,6 +419,85 @@ public sealed class DatabaseInitializer(
});
await db.SaveChangesAsync();
}
await SeedDevelopmentUsersAsync(computerCollege.Id);
}
private async Task SeedDevelopmentUsersAsync(Guid collegeId)
{
var definitions = new[]
{
new DevelopmentUser(
"academic", "校级教务员", "Academic@123456",
null, null, SystemRoles.AcademicAdmin),
new DevelopmentUser(
"college", "计算机学院教务员", "College@123456",
"A2026001", collegeId, SystemRoles.CollegeAdmin),
new DevelopmentUser(
"counselor", "陈老师", "Counselor@123456",
"C2026001", collegeId, SystemRoles.Counselor),
new DevelopmentUser(
"teacher", "陈明远", "Teacher@123456",
"T2026001", collegeId, SystemRoles.Teacher),
new DevelopmentUser(
"student", "周启航", "Student@123456",
"202601001", collegeId, SystemRoles.Student),
new DevelopmentUser(
"leader", "教学分管领导", "Leader@123456",
null, null, SystemRoles.Leader)
};
foreach (var definition in definitions)
{
var user = await userManager.FindByNameAsync(definition.UserName);
if (user is null)
{
user = new ApplicationUser
{
UserName = definition.UserName,
DisplayName = definition.DisplayName,
StaffNumber = definition.StaffNumber,
CollegeId = definition.CollegeId,
LockoutEnabled = true,
IsEnabled = true
};
EnsureSucceeded(
await userManager.CreateAsync(user, definition.Password),
$"创建开发账号 {definition.UserName}");
}
if (!await userManager.IsInRoleAsync(user, definition.Role))
{
EnsureSucceeded(
await userManager.AddToRoleAsync(user, definition.Role),
$"授予开发账号 {definition.UserName} 角色");
}
if (definition.Role == SystemRoles.Teacher)
{
var teacher = await db.Teachers.SingleAsync(
x => x.TeacherNumber == definition.StaffNumber);
if (!teacher.UserId.HasValue) teacher.UserId = user.Id;
}
if (definition.Role == SystemRoles.Student)
{
var student = await db.Students.SingleAsync(
x => x.StudentNumber == definition.StaffNumber);
if (!student.UserId.HasValue) student.UserId = user.Id;
}
if (definition.Role == SystemRoles.Counselor)
{
var classes = await db.AdministrativeClasses
.Where(x =>
x.CounselorUserId == null &&
x.CounselorName == definition.DisplayName)
.ToListAsync();
foreach (var administrativeClass in classes)
administrativeClass.CounselorUserId = user.Id;
}
}
await db.SaveChangesAsync();
}
private static void EnsureSucceeded(IdentityResult result, string action)
@@ -431,4 +510,12 @@ public sealed class DatabaseInitializer(
throw new InvalidOperationException(
$"{action}失败:{string.Join("", result.Errors.Select(x => x.Description))}");
}
private sealed record DevelopmentUser(
string UserName,
string DisplayName,
string Password,
string? StaffNumber,
Guid? CollegeId,
string Role);
}
@@ -10,6 +10,7 @@ public sealed class DevelopmentSqliteMigrator(
private const string CurriculumPlansMigration = "20260724_02_curriculum_plans";
private const string TeachingTasksMigration = "20260724_03_teaching_tasks";
private const string SchedulesMigration = "20260724_04_schedules";
private const string ClassCounselorMigration = "20260724_05_class_counselor";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -43,6 +44,20 @@ public sealed class DevelopmentSqliteMigrator(
SchedulesMigration,
SchedulesStatements,
cancellationToken);
var counselorColumnExists = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM pragma_table_info('AdministrativeClasses')
WHERE name = 'CounselorUserId'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
ClassCounselorMigration,
counselorColumnExists
? ClassCounselorStatements.Skip(1)
: ClassCounselorStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -399,4 +414,16 @@ public sealed class DevelopmentSqliteMigrator(
ON "ScheduleEntries" ("ClassroomId");
"""
];
private static readonly string[] ClassCounselorStatements =
[
"""
ALTER TABLE "AdministrativeClasses"
ADD COLUMN "CounselorUserId" TEXT NULL;
""",
"""
CREATE INDEX IF NOT EXISTS "IX_AdministrativeClasses_CounselorUserId"
ON "AdministrativeClasses" ("CounselorUserId");
"""
];
}
@@ -0,0 +1,50 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class ClassCounselorAssignment : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "CounselorUserId",
table: "AdministrativeClasses",
type: "char(36)",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_AdministrativeClasses_CounselorUserId",
table: "AdministrativeClasses",
column: "CounselorUserId");
migrationBuilder.AddForeignKey(
name: "FK_AdministrativeClasses_AspNetUsers_CounselorUserId",
table: "AdministrativeClasses",
column: "CounselorUserId",
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_AdministrativeClasses_AspNetUsers_CounselorUserId",
table: "AdministrativeClasses");
migrationBuilder.DropIndex(
name: "IX_AdministrativeClasses_CounselorUserId",
table: "AdministrativeClasses");
migrationBuilder.DropColumn(
name: "CounselorUserId",
table: "AdministrativeClasses");
}
}
}
@@ -89,6 +89,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<string>("CounselorName")
.HasColumnType("longtext");
b.Property<Guid?>("CounselorUserId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
@@ -117,6 +120,8 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasIndex("Code")
.IsUnique();
b.HasIndex("CounselorUserId");
b.HasIndex("MajorId");
b.HasIndex("IsEnabled", "SortOrder");
@@ -1154,12 +1159,19 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b =>
{
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "CounselorUser")
.WithMany()
.HasForeignKey("CounselorUserId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Jiaowu.Api.Domain.Academic.Major", "Major")
.WithMany()
.HasForeignKey("MajorId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("CounselorUser");
b.Navigation("Major");
});