diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7e41b7fb7..2a81627fe 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,5 @@
+## 3.2.2
+- Feature: 新增登录功能
## 3.2.1
- Feature: 镜像新增linux/386和linux/arm/v7架构
## 3.2.0
diff --git a/common.props b/common.props
index 004af5dd2..10a06d561 100644
--- a/common.props
+++ b/common.props
@@ -1,7 +1,7 @@
Ray
- 3.2.1
+ 3.2.2
$(NoWarn);CS1591;CS0436
diff --git a/src/Ray.BiliBiliTool.Domain/User.cs b/src/Ray.BiliBiliTool.Domain/User.cs
new file mode 100644
index 000000000..6b1545d28
--- /dev/null
+++ b/src/Ray.BiliBiliTool.Domain/User.cs
@@ -0,0 +1,15 @@
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Ray.BiliBiliTool.Domain;
+
+[Table("bili_user")]
+public class User
+{
+ [Key]
+ public long Id { get; set; }
+ public required string Username { get; set; }
+ public required string PasswordHash { get; set; }
+ public required string Salt { get; set; }
+ public List Roles { get; set; } = [];
+}
diff --git a/src/Ray.BiliBiliTool.Infrastructure.EF/BiliDbContext.cs b/src/Ray.BiliBiliTool.Infrastructure.EF/BiliDbContext.cs
index 6cb0f8299..1e63bd848 100644
--- a/src/Ray.BiliBiliTool.Infrastructure.EF/BiliDbContext.cs
+++ b/src/Ray.BiliBiliTool.Infrastructure.EF/BiliDbContext.cs
@@ -11,6 +11,7 @@ public class BiliDbContext(IConfiguration config) : DbContext
{
public DbSet ExecutionLogs { get; set; }
public DbSet BiliLogs { get; set; }
+ public DbSet Users { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
@@ -69,6 +70,22 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
.HasIndex(x => x.FireInstanceIdComputed) // 在计算列上创建索引
.HasDatabaseName("IX_Logs_FireInstanceIdComputed");
});
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.Property(e => e.Username).IsRequired().HasMaxLength(50);
+ entity.Property(e => e.PasswordHash).IsRequired();
+ entity.Property(e => e.Salt).IsRequired();
+ entity
+ .Property(e => e.Roles)
+ .HasConversion(
+ v => string.Join(',', v),
+ v => v.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList()
+ );
+
+ entity.HasIndex(e => e.Username).IsUnique();
+ });
}
private void AddSqliteDateTimeOffsetSupport(ModelBuilder builder)
diff --git a/src/Ray.BiliBiliTool.Infrastructure.EF/DbInitializer.cs b/src/Ray.BiliBiliTool.Infrastructure.EF/DbInitializer.cs
new file mode 100644
index 000000000..61fbea14c
--- /dev/null
+++ b/src/Ray.BiliBiliTool.Infrastructure.EF/DbInitializer.cs
@@ -0,0 +1,38 @@
+using Microsoft.EntityFrameworkCore;
+using Ray.BiliBiliTool.Domain;
+using Ray.BiliBiliTool.Infrastructure.Helpers;
+
+namespace Ray.BiliBiliTool.Infrastructure.EF;
+
+public class DbInitializer(BiliDbContext context)
+{
+ private const string DefaultUserName = "admin";
+ private const string DefaultPassword = "BiliTool@2233";
+
+ public async Task InitializeAsync()
+ {
+ await context.Database.MigrateAsync();
+
+ await InitUserAsync();
+ }
+
+ private async Task InitUserAsync()
+ {
+ if (await context.Users.AnyAsync())
+ {
+ return;
+ }
+
+ var (hash, salt) = PasswordHelper.HashPassword(DefaultPassword);
+ var adminUser = new User
+ {
+ Username = DefaultUserName,
+ PasswordHash = hash,
+ Salt = salt,
+ Roles = ["Administrator"],
+ };
+
+ context.Users.Add(adminUser);
+ await context.SaveChangesAsync();
+ }
+}
diff --git a/src/Ray.BiliBiliTool.Infrastructure.EF/Extensions/ServiceCollectionExtension.cs b/src/Ray.BiliBiliTool.Infrastructure.EF/Extensions/ServiceCollectionExtension.cs
index 896444ae6..a60c390b0 100644
--- a/src/Ray.BiliBiliTool.Infrastructure.EF/Extensions/ServiceCollectionExtension.cs
+++ b/src/Ray.BiliBiliTool.Infrastructure.EF/Extensions/ServiceCollectionExtension.cs
@@ -7,6 +7,7 @@ public static class ServiceCollectionExtension
public static IServiceCollection AddEF(this IServiceCollection services)
{
services.AddDbContextFactory();
+ services.AddScoped();
return services;
}
}
diff --git a/src/Ray.BiliBiliTool.Infrastructure.EF/Migrations/20250615100041_AddUser.Designer.cs b/src/Ray.BiliBiliTool.Infrastructure.EF/Migrations/20250615100041_AddUser.Designer.cs
new file mode 100644
index 000000000..c818dee3d
--- /dev/null
+++ b/src/Ray.BiliBiliTool.Infrastructure.EF/Migrations/20250615100041_AddUser.Designer.cs
@@ -0,0 +1,722 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Ray.BiliBiliTool.Infrastructure.EF;
+
+#nullable disable
+
+namespace Ray.BiliBiliTool.Web.Migrations
+{
+ [DbContext(typeof(BiliDbContext))]
+ [Migration("20250615100041_AddUser")]
+ partial class AddUser
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "8.0.3");
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b =>
+ {
+ b.Property("SchedulerName")
+ .HasColumnType("text")
+ .HasColumnName("SCHED_NAME");
+
+ b.Property("TriggerName")
+ .HasColumnType("text")
+ .HasColumnName("TRIGGER_NAME");
+
+ b.Property("TriggerGroup")
+ .HasColumnType("text")
+ .HasColumnName("TRIGGER_GROUP");
+
+ b.Property("BlobData")
+ .HasColumnType("bytea")
+ .HasColumnName("BLOB_DATA");
+
+ b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
+
+ b.ToTable("QRTZ_BLOB_TRIGGERS", (string)null);
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCalendar", b =>
+ {
+ b.Property("SchedulerName")
+ .HasColumnType("text")
+ .HasColumnName("SCHED_NAME");
+
+ b.Property("CalendarName")
+ .HasColumnType("text")
+ .HasColumnName("CALENDAR_NAME");
+
+ b.Property("Calendar")
+ .IsRequired()
+ .HasColumnType("bytea")
+ .HasColumnName("CALENDAR");
+
+ b.HasKey("SchedulerName", "CalendarName");
+
+ b.ToTable("QRTZ_CALENDARS", (string)null);
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b =>
+ {
+ b.Property("SchedulerName")
+ .HasColumnType("text")
+ .HasColumnName("SCHED_NAME");
+
+ b.Property("TriggerName")
+ .HasColumnType("text")
+ .HasColumnName("TRIGGER_NAME");
+
+ b.Property("TriggerGroup")
+ .HasColumnType("text")
+ .HasColumnName("TRIGGER_GROUP");
+
+ b.Property("CronExpression")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("CRON_EXPRESSION");
+
+ b.Property("TimeZoneId")
+ .HasColumnType("text")
+ .HasColumnName("TIME_ZONE_ID");
+
+ b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
+
+ b.ToTable("QRTZ_CRON_TRIGGERS", (string)null);
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzFiredTrigger", b =>
+ {
+ b.Property("SchedulerName")
+ .HasColumnType("text")
+ .HasColumnName("SCHED_NAME");
+
+ b.Property("EntryId")
+ .HasColumnType("text")
+ .HasColumnName("ENTRY_ID");
+
+ b.Property("FiredTime")
+ .HasColumnType("bigint")
+ .HasColumnName("FIRED_TIME");
+
+ b.Property("InstanceName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("INSTANCE_NAME");
+
+ b.Property("IsNonConcurrent")
+ .HasColumnType("bool")
+ .HasColumnName("IS_NONCONCURRENT");
+
+ b.Property("JobGroup")
+ .HasColumnType("text")
+ .HasColumnName("JOB_GROUP");
+
+ b.Property("JobName")
+ .HasColumnType("text")
+ .HasColumnName("JOB_NAME");
+
+ b.Property("Priority")
+ .HasColumnType("integer")
+ .HasColumnName("PRIORITY");
+
+ b.Property("RequestsRecovery")
+ .HasColumnType("bool")
+ .HasColumnName("REQUESTS_RECOVERY");
+
+ b.Property("ScheduledTime")
+ .HasColumnType("bigint")
+ .HasColumnName("SCHED_TIME");
+
+ b.Property("State")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("STATE");
+
+ b.Property("TriggerGroup")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("TRIGGER_GROUP");
+
+ b.Property("TriggerName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("TRIGGER_NAME");
+
+ b.HasKey("SchedulerName", "EntryId");
+
+ b.HasIndex("InstanceName")
+ .HasDatabaseName("IDX_QRTZ_FT_TRIG_INST_NAME");
+
+ b.HasIndex("JobGroup")
+ .HasDatabaseName("IDX_QRTZ_FT_JOB_GROUP");
+
+ b.HasIndex("JobName")
+ .HasDatabaseName("IDX_QRTZ_FT_JOB_NAME");
+
+ b.HasIndex("RequestsRecovery")
+ .HasDatabaseName("IDX_QRTZ_FT_JOB_REQ_RECOVERY");
+
+ b.HasIndex("TriggerGroup")
+ .HasDatabaseName("IDX_QRTZ_FT_TRIG_GROUP");
+
+ b.HasIndex("TriggerName")
+ .HasDatabaseName("IDX_QRTZ_FT_TRIG_NAME");
+
+ b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup")
+ .HasDatabaseName("IDX_QRTZ_FT_TRIG_NM_GP");
+
+ b.ToTable("QRTZ_FIRED_TRIGGERS", (string)null);
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b =>
+ {
+ b.Property("SchedulerName")
+ .HasColumnType("text")
+ .HasColumnName("SCHED_NAME");
+
+ b.Property("JobName")
+ .HasColumnType("text")
+ .HasColumnName("JOB_NAME");
+
+ b.Property("JobGroup")
+ .HasColumnType("text")
+ .HasColumnName("JOB_GROUP");
+
+ b.Property("Description")
+ .HasColumnType("text")
+ .HasColumnName("DESCRIPTION");
+
+ b.Property("IsDurable")
+ .HasColumnType("bool")
+ .HasColumnName("IS_DURABLE");
+
+ b.Property("IsNonConcurrent")
+ .HasColumnType("bool")
+ .HasColumnName("IS_NONCONCURRENT");
+
+ b.Property("IsUpdateData")
+ .HasColumnType("bool")
+ .HasColumnName("IS_UPDATE_DATA");
+
+ b.Property("JobClassName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("JOB_CLASS_NAME");
+
+ b.Property("JobData")
+ .HasColumnType("bytea")
+ .HasColumnName("JOB_DATA");
+
+ b.Property("RequestsRecovery")
+ .HasColumnType("bool")
+ .HasColumnName("REQUESTS_RECOVERY");
+
+ b.HasKey("SchedulerName", "JobName", "JobGroup");
+
+ b.HasIndex("RequestsRecovery")
+ .HasDatabaseName("IDX_QRTZ_J_REQ_RECOVERY");
+
+ b.ToTable("QRTZ_JOB_DETAILS", (string)null);
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzLock", b =>
+ {
+ b.Property("SchedulerName")
+ .HasColumnType("text")
+ .HasColumnName("SCHED_NAME");
+
+ b.Property("LockName")
+ .HasColumnType("text")
+ .HasColumnName("LOCK_NAME");
+
+ b.HasKey("SchedulerName", "LockName");
+
+ b.ToTable("QRTZ_LOCKS", (string)null);
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzPausedTriggerGroup", b =>
+ {
+ b.Property("SchedulerName")
+ .HasColumnType("text")
+ .HasColumnName("SCHED_NAME");
+
+ b.Property("TriggerGroup")
+ .HasColumnType("text")
+ .HasColumnName("TRIGGER_GROUP");
+
+ b.HasKey("SchedulerName", "TriggerGroup");
+
+ b.ToTable("QRTZ_PAUSED_TRIGGER_GRPS", (string)null);
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSchedulerState", b =>
+ {
+ b.Property("SchedulerName")
+ .HasColumnType("text")
+ .HasColumnName("SCHED_NAME");
+
+ b.Property("InstanceName")
+ .HasColumnType("text")
+ .HasColumnName("INSTANCE_NAME");
+
+ b.Property("CheckInInterval")
+ .HasColumnType("bigint")
+ .HasColumnName("CHECKIN_INTERVAL");
+
+ b.Property("LastCheckInTime")
+ .HasColumnType("bigint")
+ .HasColumnName("LAST_CHECKIN_TIME");
+
+ b.HasKey("SchedulerName", "InstanceName");
+
+ b.ToTable("QRTZ_SCHEDULER_STATE", (string)null);
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b =>
+ {
+ b.Property("SchedulerName")
+ .HasColumnType("text")
+ .HasColumnName("SCHED_NAME");
+
+ b.Property("TriggerName")
+ .HasColumnType("text")
+ .HasColumnName("TRIGGER_NAME");
+
+ b.Property("TriggerGroup")
+ .HasColumnType("text")
+ .HasColumnName("TRIGGER_GROUP");
+
+ b.Property("BooleanProperty1")
+ .HasColumnType("bool")
+ .HasColumnName("BOOL_PROP_1");
+
+ b.Property("BooleanProperty2")
+ .HasColumnType("bool")
+ .HasColumnName("BOOL_PROP_2");
+
+ b.Property("DecimalProperty1")
+ .HasColumnType("numeric")
+ .HasColumnName("DEC_PROP_1");
+
+ b.Property("DecimalProperty2")
+ .HasColumnType("numeric")
+ .HasColumnName("DEC_PROP_2");
+
+ b.Property("IntegerProperty1")
+ .HasColumnType("integer")
+ .HasColumnName("INT_PROP_1");
+
+ b.Property("IntegerProperty2")
+ .HasColumnType("integer")
+ .HasColumnName("INT_PROP_2");
+
+ b.Property("LongProperty1")
+ .HasColumnType("bigint")
+ .HasColumnName("LONG_PROP_1");
+
+ b.Property("LongProperty2")
+ .HasColumnType("bigint")
+ .HasColumnName("LONG_PROP_2");
+
+ b.Property("StringProperty1")
+ .HasColumnType("text")
+ .HasColumnName("STR_PROP_1");
+
+ b.Property("StringProperty2")
+ .HasColumnType("text")
+ .HasColumnName("STR_PROP_2");
+
+ b.Property("StringProperty3")
+ .HasColumnType("text")
+ .HasColumnName("STR_PROP_3");
+
+ b.Property("TimeZoneId")
+ .HasColumnType("text")
+ .HasColumnName("TIME_ZONE_ID");
+
+ b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
+
+ b.ToTable("QRTZ_SIMPROP_TRIGGERS", (string)null);
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b =>
+ {
+ b.Property("SchedulerName")
+ .HasColumnType("text")
+ .HasColumnName("SCHED_NAME");
+
+ b.Property("TriggerName")
+ .HasColumnType("text")
+ .HasColumnName("TRIGGER_NAME");
+
+ b.Property("TriggerGroup")
+ .HasColumnType("text")
+ .HasColumnName("TRIGGER_GROUP");
+
+ b.Property("RepeatCount")
+ .HasColumnType("bigint")
+ .HasColumnName("REPEAT_COUNT");
+
+ b.Property("RepeatInterval")
+ .HasColumnType("bigint")
+ .HasColumnName("REPEAT_INTERVAL");
+
+ b.Property("TimesTriggered")
+ .HasColumnType("bigint")
+ .HasColumnName("TIMES_TRIGGERED");
+
+ b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
+
+ b.ToTable("QRTZ_SIMPLE_TRIGGERS", (string)null);
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b =>
+ {
+ b.Property("SchedulerName")
+ .HasColumnType("text")
+ .HasColumnName("SCHED_NAME");
+
+ b.Property("TriggerName")
+ .HasColumnType("text")
+ .HasColumnName("TRIGGER_NAME");
+
+ b.Property("TriggerGroup")
+ .HasColumnType("text")
+ .HasColumnName("TRIGGER_GROUP");
+
+ b.Property("CalendarName")
+ .HasColumnType("text")
+ .HasColumnName("CALENDAR_NAME");
+
+ b.Property("Description")
+ .HasColumnType("text")
+ .HasColumnName("DESCRIPTION");
+
+ b.Property("EndTime")
+ .HasColumnType("bigint")
+ .HasColumnName("END_TIME");
+
+ b.Property("JobData")
+ .HasColumnType("bytea")
+ .HasColumnName("JOB_DATA");
+
+ b.Property("JobGroup")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("JOB_GROUP");
+
+ b.Property("JobName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("JOB_NAME");
+
+ b.Property("MisfireInstruction")
+ .HasColumnType("smallint")
+ .HasColumnName("MISFIRE_INSTR");
+
+ b.Property("NextFireTime")
+ .HasColumnType("bigint")
+ .HasColumnName("NEXT_FIRE_TIME");
+
+ b.Property("PreviousFireTime")
+ .HasColumnType("bigint")
+ .HasColumnName("PREV_FIRE_TIME");
+
+ b.Property("Priority")
+ .HasColumnType("integer")
+ .HasColumnName("PRIORITY");
+
+ b.Property("StartTime")
+ .HasColumnType("bigint")
+ .HasColumnName("START_TIME");
+
+ b.Property("TriggerState")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("TRIGGER_STATE");
+
+ b.Property("TriggerType")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("TRIGGER_TYPE");
+
+ b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
+
+ b.HasIndex("NextFireTime")
+ .HasDatabaseName("IDX_QRTZ_T_NEXT_FIRE_TIME");
+
+ b.HasIndex("TriggerState")
+ .HasDatabaseName("IDX_QRTZ_T_STATE");
+
+ b.HasIndex("NextFireTime", "TriggerState")
+ .HasDatabaseName("IDX_QRTZ_T_NFT_ST");
+
+ b.HasIndex("SchedulerName", "JobName", "JobGroup");
+
+ b.ToTable("QRTZ_TRIGGERS", (string)null);
+ });
+
+ modelBuilder.Entity("Ray.BiliBiliTool.Domain.BiliLogs", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasColumnName("id");
+
+ b.Property("Exception")
+ .HasColumnType("TEXT")
+ .HasColumnName("exception");
+
+ b.Property("FireInstanceIdComputed")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("TEXT")
+ .HasColumnName("fireInstanceIdComputed")
+ .HasComputedColumnSql("json_extract(Properties, '$.FireInstanceId')", false);
+
+ b.Property("Level")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("level");
+
+ b.Property("Properties")
+ .HasColumnType("TEXT")
+ .HasColumnName("properties");
+
+ b.Property("RenderedMessage")
+ .HasColumnType("TEXT")
+ .HasColumnName("renderedMessage");
+
+ b.Property("Timestamp")
+ .HasColumnType("TEXT")
+ .HasColumnName("timeStamp");
+
+ b.HasKey("Id");
+
+ b.HasIndex("FireInstanceIdComputed")
+ .HasDatabaseName("IX_Logs_FireInstanceIdComputed");
+
+ b.ToTable("bili_logs");
+ });
+
+ modelBuilder.Entity("Ray.BiliBiliTool.Domain.ExecutionLog", b =>
+ {
+ b.Property("LogId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("DateAddedUtc")
+ .HasColumnType("INTEGER");
+
+ b.Property("ErrorMessage")
+ .HasMaxLength(8000)
+ .HasColumnType("TEXT");
+
+ b.Property("FireTimeUtc")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsException")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsSuccess")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsVetoed")
+ .HasColumnType("INTEGER");
+
+ b.Property("JobGroup")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("JobName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("JobRunTime")
+ .HasColumnType("TEXT");
+
+ b.Property("LogType")
+ .IsRequired()
+ .HasColumnType("varchar(20)");
+
+ b.Property("Result")
+ .HasMaxLength(8000)
+ .HasColumnType("TEXT");
+
+ b.Property("RetryCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("ReturnCode")
+ .HasMaxLength(28)
+ .HasColumnType("TEXT");
+
+ b.Property("RunInstanceId")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("ScheduleFireTimeUtc")
+ .HasColumnType("INTEGER");
+
+ b.Property("TriggerGroup")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("TriggerName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.HasKey("LogId");
+
+ b.HasIndex("RunInstanceId")
+ .IsUnique();
+
+ b.HasIndex("DateAddedUtc", "LogType");
+
+ b.HasIndex("TriggerName", "TriggerGroup", "JobName", "JobGroup", "DateAddedUtc");
+
+ b.ToTable("bili_execution_logs");
+ });
+
+ modelBuilder.Entity("Ray.BiliBiliTool.Domain.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasColumnName("id");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Roles")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Salt")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Username")
+ .IsUnique();
+
+ b.ToTable("bili_User");
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b =>
+ {
+ b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger")
+ .WithMany("BlobTriggers")
+ .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Trigger");
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b =>
+ {
+ b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger")
+ .WithMany("CronTriggers")
+ .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Trigger");
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b =>
+ {
+ b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger")
+ .WithMany("SimplePropertyTriggers")
+ .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Trigger");
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b =>
+ {
+ b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger")
+ .WithMany("SimpleTriggers")
+ .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Trigger");
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b =>
+ {
+ b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", "JobDetail")
+ .WithMany("Triggers")
+ .HasForeignKey("SchedulerName", "JobName", "JobGroup")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("JobDetail");
+ });
+
+ modelBuilder.Entity("Ray.BiliBiliTool.Domain.ExecutionLog", b =>
+ {
+ b.OwnsOne("Ray.BiliBiliTool.Domain.ExecutionLogDetail", "ExecutionLogDetail", b1 =>
+ {
+ b1.Property("LogId")
+ .HasColumnType("INTEGER");
+
+ b1.Property("ErrorCode")
+ .HasColumnType("INTEGER");
+
+ b1.Property("ErrorHelpLink")
+ .HasMaxLength(1000)
+ .HasColumnType("TEXT");
+
+ b1.Property("ErrorStackTrace")
+ .HasColumnType("TEXT");
+
+ b1.Property("ExecutionDetails")
+ .HasColumnType("TEXT");
+
+ b1.HasKey("LogId");
+
+ b1.ToTable("bili_execution_log_details", (string)null);
+
+ b1.WithOwner()
+ .HasForeignKey("LogId");
+ });
+
+ b.Navigation("ExecutionLogDetail");
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b =>
+ {
+ b.Navigation("Triggers");
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b =>
+ {
+ b.Navigation("BlobTriggers");
+
+ b.Navigation("CronTriggers");
+
+ b.Navigation("SimplePropertyTriggers");
+
+ b.Navigation("SimpleTriggers");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Ray.BiliBiliTool.Infrastructure.EF/Migrations/20250615100041_AddUser.cs b/src/Ray.BiliBiliTool.Infrastructure.EF/Migrations/20250615100041_AddUser.cs
new file mode 100644
index 000000000..7d26e5e68
--- /dev/null
+++ b/src/Ray.BiliBiliTool.Infrastructure.EF/Migrations/20250615100041_AddUser.cs
@@ -0,0 +1,45 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Ray.BiliBiliTool.Web.Migrations
+{
+ ///
+ public partial class AddUser : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "bili_user",
+ columns: table => new
+ {
+ Id = table
+ .Column(type: "INTEGER", nullable: false)
+ .Annotation("Sqlite:Autoincrement", true),
+ Username = table.Column(type: "TEXT", maxLength: 50, nullable: false),
+ PasswordHash = table.Column(type: "TEXT", nullable: false),
+ Salt = table.Column(type: "TEXT", nullable: false),
+ Roles = table.Column(type: "TEXT", nullable: false),
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_bili_user", x => x.Id);
+ }
+ );
+
+ migrationBuilder.CreateIndex(
+ name: "IX_bili_user_Username",
+ table: "bili_user",
+ column: "Username",
+ unique: true
+ );
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(name: "bili_user");
+ }
+ }
+}
diff --git a/src/Ray.BiliBiliTool.Infrastructure.EF/Migrations/BiliDbContextModelSnapshot.cs b/src/Ray.BiliBiliTool.Infrastructure.EF/Migrations/BiliDbContextModelSnapshot.cs
index 7a5a44475..ccb478f34 100644
--- a/src/Ray.BiliBiliTool.Infrastructure.EF/Migrations/BiliDbContextModelSnapshot.cs
+++ b/src/Ray.BiliBiliTool.Infrastructure.EF/Migrations/BiliDbContextModelSnapshot.cs
@@ -580,6 +580,38 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("bili_execution_logs");
});
+ modelBuilder.Entity("Ray.BiliBiliTool.Domain.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasColumnName("id");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Roles")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Salt")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Username")
+ .IsUnique();
+
+ b.ToTable("bili_user");
+ });
+
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b =>
{
b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger")
diff --git a/src/Ray.BiliBiliTool.Infrastructure.EF/README.md b/src/Ray.BiliBiliTool.Infrastructure.EF/README.md
new file mode 100644
index 000000000..505a5a5ea
--- /dev/null
+++ b/src/Ray.BiliBiliTool.Infrastructure.EF/README.md
@@ -0,0 +1,12 @@
+## Add new migration
+
+```bash
+cd ./src/Ray.BiliBiliTool.Web
+dotnet ef migrations add AddUser --project ../Ray.BiliBiliTool.Infrastructure.EF
+```
+
+## Remove migration
+
+```bash
+dotnet ef migrations remove --project ../Ray.BiliBiliTool.Infrastructure.EF
+```
diff --git a/src/Ray.BiliBiliTool.Infrastructure.EF/Ray.BiliBiliTool.Infrastructure.EF.csproj b/src/Ray.BiliBiliTool.Infrastructure.EF/Ray.BiliBiliTool.Infrastructure.EF.csproj
index d551ed75b..335bdf4eb 100644
--- a/src/Ray.BiliBiliTool.Infrastructure.EF/Ray.BiliBiliTool.Infrastructure.EF.csproj
+++ b/src/Ray.BiliBiliTool.Infrastructure.EF/Ray.BiliBiliTool.Infrastructure.EF.csproj
@@ -21,5 +21,6 @@
+
diff --git a/src/Ray.BiliBiliTool.Infrastructure/Helpers/PasswordHelper.cs b/src/Ray.BiliBiliTool.Infrastructure/Helpers/PasswordHelper.cs
new file mode 100644
index 000000000..c052e3753
--- /dev/null
+++ b/src/Ray.BiliBiliTool.Infrastructure/Helpers/PasswordHelper.cs
@@ -0,0 +1,34 @@
+using System.Security.Cryptography;
+
+namespace Ray.BiliBiliTool.Infrastructure.Helpers;
+
+public class PasswordHelper
+{
+ public static (string hash, string salt) HashPassword(string password)
+ {
+ byte[] saltBytes = RandomNumberGenerator.GetBytes(16);
+ string salt = Convert.ToBase64String(saltBytes);
+ string hash = ComputeHash(password, salt);
+ return (hash, salt);
+ }
+
+ public static bool VerifyPassword(string password, string salt, string hash)
+ {
+ string computedHash = ComputeHash(password, salt);
+ return computedHash == hash;
+ }
+
+ private static string ComputeHash(string password, string salt)
+ {
+ byte[] saltBytes = Convert.FromBase64String(salt);
+ using var pbkdf2 = new Rfc2898DeriveBytes(
+ password,
+ saltBytes,
+ 100_000,
+ HashAlgorithmName.SHA256
+ );
+ byte[] hashBytes = pbkdf2.GetBytes(32);
+
+ return Convert.ToBase64String(hashBytes);
+ }
+}
diff --git a/src/Ray.BiliBiliTool.Web/Auth/CustomAuthStateProvider.cs b/src/Ray.BiliBiliTool.Web/Auth/CustomAuthStateProvider.cs
new file mode 100644
index 000000000..e3ec068ed
--- /dev/null
+++ b/src/Ray.BiliBiliTool.Web/Auth/CustomAuthStateProvider.cs
@@ -0,0 +1,26 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Components.Authorization;
+
+namespace Ray.BiliBiliTool.Web.Auth;
+
+public class CustomAuthStateProvider(IHttpContextAccessor httpContextAccessor)
+ : AuthenticationStateProvider
+{
+ public override Task GetAuthenticationStateAsync()
+ {
+ var identity = new ClaimsIdentity();
+ var user = httpContextAccessor.HttpContext?.User;
+
+ if (user?.Identity?.IsAuthenticated == true)
+ {
+ identity = new ClaimsIdentity(user.Claims, "Cookies");
+ }
+
+ return Task.FromResult(new AuthenticationState(new ClaimsPrincipal(identity)));
+ }
+
+ public void NotifyAuthenticationStateChanged()
+ {
+ NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
+ }
+}
diff --git a/src/Ray.BiliBiliTool.Web/Components/Layout/MainLayout.razor b/src/Ray.BiliBiliTool.Web/Components/Layout/MainLayout.razor
index def638b5a..849bba5e6 100644
--- a/src/Ray.BiliBiliTool.Web/Components/Layout/MainLayout.razor
+++ b/src/Ray.BiliBiliTool.Web/Components/Layout/MainLayout.razor
@@ -1,4 +1,5 @@
-@inherits LayoutComponentBase
+@using Microsoft.AspNetCore.Components.Authorization
+@inherits LayoutComponentBase