diff --git a/LMS.Common/Extensions/BeijingTimeExtension.cs b/LMS.Common/Extensions/BeijingTimeExtension.cs
index 8a9bf02..864cb2c 100644
--- a/LMS.Common/Extensions/BeijingTimeExtension.cs
+++ b/LMS.Common/Extensions/BeijingTimeExtension.cs
@@ -13,14 +13,40 @@
}
///
- /// 将UTC时间转换为北京时间
+ /// 智能转换时间为北京时间
+ /// 如果是UTC时间则转换,否则直接返回
///
- ///
- ///
- public static DateTime TransferUtcToBeijingTime(DateTime utcTime)
+ /// 输入的时间
+ /// 北京时间
+ public static DateTime TransferUtcToBeijingTime(DateTime dateTime)
{
- return TimeZoneInfo.ConvertTimeFromUtc(utcTime,
- TimeZoneInfo.FindSystemTimeZoneById("China Standard Time"));
+ // 只有UTC时间才需要转换
+ if (dateTime.Kind == DateTimeKind.Utc)
+ {
+ try
+ {
+ // 优先使用系统时区信息
+ return TimeZoneInfo.ConvertTimeFromUtc(dateTime,
+ TimeZoneInfo.FindSystemTimeZoneById("China Standard Time"));
+ }
+ catch (TimeZoneNotFoundException)
+ {
+ try
+ {
+ // Linux系统可能使用这个ID
+ return TimeZoneInfo.ConvertTimeFromUtc(dateTime,
+ TimeZoneInfo.FindSystemTimeZoneById("Asia/Shanghai"));
+ }
+ catch (TimeZoneNotFoundException)
+ {
+ // 找不到时区就手动加8小时
+ return dateTime.AddHours(8);
+ }
+ }
+ }
+
+ // 非UTC时间直接返回
+ return dateTime;
}
}
}
diff --git a/LMS.Common/LMS.Common.csproj b/LMS.Common/LMS.Common.csproj
index 67198ff..94d9115 100644
--- a/LMS.Common/LMS.Common.csproj
+++ b/LMS.Common/LMS.Common.csproj
@@ -8,6 +8,8 @@
+
+
diff --git a/LMS.DAO/ApplicationDbContext.cs b/LMS.DAO/ApplicationDbContext.cs
index 1c9a317..7698f77 100644
--- a/LMS.DAO/ApplicationDbContext.cs
+++ b/LMS.DAO/ApplicationDbContext.cs
@@ -1,6 +1,7 @@
using LMS.Repository.DB;
+using LMS.Repository.MJPackage;
using LMS.Repository.Models.DB;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
@@ -46,6 +47,12 @@ namespace LMS.DAO
public DbSet DataInfo { get; set; }
+ public DbSet MJApiTokens { get; set; }
+
+ public DbSet MJApiTokenUsage { get; set; }
+
+ public DbSet MJApiTasks { get; set; }
+
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
@@ -81,6 +88,30 @@ namespace LMS.DAO
)
.HasColumnType("json"); // 指定MySQL字段类型为JSON
});
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToTable("MJApiTokens");
+ entity.Property(e => e.Token).IsRequired().HasMaxLength(64);
+ entity.Property(e => e.DailyLimit).HasDefaultValue(0);
+ entity.Property(e => e.TotalLimit).HasDefaultValue(0);
+ entity.Property(e => e.ConcurrencyLimit).HasDefaultValue(1);
+ entity.Property(e => e.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ entity.HasIndex(e => e.Token).IsUnique();
+ entity.HasIndex(e => e.ExpiresAt);
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToTable("MJApiTokenUsage");
+ entity.HasKey(e => new { e.TokenId, e.Date });
+
+ entity.HasOne()
+ .WithMany()
+ .HasForeignKey(e => e.TokenId)
+ .OnDelete(DeleteBehavior.Cascade);
+ });
}
}
}
diff --git a/LMS.Repository/DB/MJApiTasks.cs b/LMS.Repository/DB/MJApiTasks.cs
new file mode 100644
index 0000000..1a7bb45
--- /dev/null
+++ b/LMS.Repository/DB/MJApiTasks.cs
@@ -0,0 +1,41 @@
+using LMS.Repository.MJPackage;
+using System.ComponentModel.DataAnnotations;
+
+namespace LMS.Repository.DB
+{
+ public class MJApiTasks
+ {
+ [Key]
+ public string TaskId { get; set; }
+ public string Token { get; set; }
+ public long TokenId { get; set; }
+ public DateTime StartTime { get; set; }
+ public DateTime? EndTime { get; set; }
+ public string Status { get; set; }
+ public string ThirdPartyTaskId { get; set; } // 第三方任务ID
+ public string? Properties { get; set; }
+ }
+
+ public class MJApiTaskCollection : MJApiTasks
+ {
+ public long TokenId { get; set; }
+ public string Token { get; set; }
+ }
+
+ public class MJTaskStatus
+ {
+ public const string NOT_START = "NOT_START";
+
+ public const string SUBMITTED = "SUBMITTED";
+
+ public const string IN_PROGRESS = "IN_PROGRESS";
+
+ public const string FAILURE = "FAILURE";
+
+ public const string SUCCESS = "SUCCESS";
+
+ public const string MODAL = "MODAL";
+
+ public const string CANCEL = "CANCEL";
+ }
+}
diff --git a/LMS.Repository/DB/MJApiTokenUsage.cs b/LMS.Repository/DB/MJApiTokenUsage.cs
new file mode 100644
index 0000000..7870089
--- /dev/null
+++ b/LMS.Repository/DB/MJApiTokenUsage.cs
@@ -0,0 +1,22 @@
+using LMS.Common.Extensions;
+using System.ComponentModel.DataAnnotations;
+
+namespace LMS.Repository.DB
+{
+ public class MJApiTokenUsage
+ {
+ [Key]
+ public long TokenId { get; set; }
+
+ [Key]
+ public DateTime Date { get; set; }
+
+ public int DailyUsage { get; set; } = 0;
+
+ public int TotalUsage { get; set; } = 0;
+
+ public DateTime LastActivityAt { get; set; } = BeijingTimeExtension.GetBeijingTime();
+
+ public string? HistoryUse { get; set; } = null;
+ }
+}
diff --git a/LMS.Repository/DB/MJApiTokens.cs b/LMS.Repository/DB/MJApiTokens.cs
new file mode 100644
index 0000000..947d1ac
--- /dev/null
+++ b/LMS.Repository/DB/MJApiTokens.cs
@@ -0,0 +1,29 @@
+using LMS.Common.Extensions;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace LMS.Repository.DB
+{
+ public class MJApiTokens
+ {
+ [Key]
+ [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ public long Id { get; set; }
+
+ [Required]
+ [StringLength(64)]
+ public required string Token { get; set; }
+ [Required]
+ public required string UseToken { get; set; } // 实际使用的Token
+
+ public int DailyLimit { get; set; } = 0;
+
+ public int TotalLimit { get; set; } = 0;
+
+ public int ConcurrencyLimit { get; set; } = 1;
+
+ public DateTime CreatedAt { get; set; } = BeijingTimeExtension.GetBeijingTime();
+
+ public DateTime? ExpiresAt { get; set; }
+ }
+}
diff --git a/LMS.Repository/MJPackage/AddOrModifyTokenModel.cs b/LMS.Repository/MJPackage/AddOrModifyTokenModel.cs
new file mode 100644
index 0000000..34a57a2
--- /dev/null
+++ b/LMS.Repository/MJPackage/AddOrModifyTokenModel.cs
@@ -0,0 +1,43 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace LMS.Repository.MJPackage
+{
+ public class AddOrModifyTokenModel
+ {
+ ///
+ /// token
+ ///
+ [Required]
+ [StringLength(64)]
+ public required string Token { get; set; }
+
+ ///
+ /// 实际使用的Token
+ ///
+ public required string UseToken { get; set; }
+
+ ///
+ /// 日限制
+ ///
+ [Required]
+ public required int DailyLimit { get; set; }
+
+ ///
+ /// 总限制
+ ///
+ [Required]
+ public required int TotalLimit { get; set; }
+
+ ///
+ /// 并发限制
+ ///
+ [Required]
+ public required int ConcurrencyLimit { get; set; }
+
+ ///
+ /// 使用天数
+ ///
+ [Required]
+ public required int UseDayCount { get; set; }
+ }
+}
diff --git a/LMS.Repository/MJPackage/MJSubmitImageModel.cs b/LMS.Repository/MJPackage/MJSubmitImageModel.cs
new file mode 100644
index 0000000..baaad93
--- /dev/null
+++ b/LMS.Repository/MJPackage/MJSubmitImageModel.cs
@@ -0,0 +1,74 @@
+namespace LMS.Repository.MJPackage
+{
+ public class MJSubmitImageModel
+ {
+ ///
+ /// bot 类型,mj(默认)或niji
+ /// MID_JOURNEY | 枚举值: NIJI_JOURNEY
+ ///
+ public string? BotType { get; set; }
+
+ ///
+ /// 提示词。
+ ///
+ public string Prompt { get; set; }
+
+ ///
+ /// 垫图base64数组。
+ ///
+ public List? Base64Array { get; set; }
+
+ ///
+ /// 账号过滤
+ ///
+ public AccountFilter? AccountFilter { get; set; }
+
+ ///
+ /// 自定义参数。
+ ///
+ public string? State { get; set; }
+
+ ///
+ /// 回调地址, 为空时使用全局notifyHook。
+ ///
+ public string? NotifyHook { get; set; }
+ }
+
+ public class AccountFilter
+ {
+ ///
+ /// 过滤指定实例的账号
+ ///
+ public string? InstanceId { get; set; }
+
+ ///
+ /// 账号模式 RELAX | FAST | TURBO
+ ///
+ public List? Modes { get; set; } = new List();
+
+ ///
+ /// 账号是否 remix(Midjourney Remix)
+ ///
+ public bool? Remix { get; set; }
+
+ ///
+ /// 账号是否 remix(Nijiourney Remix)
+ ///
+ public bool? NijiRemix { get; set; }
+
+ ///
+ /// 账号过滤时,remix 自动提交视为账号的 remix 为 false
+ ///
+ public bool? RemixAutoConsidered { get; set; }
+ }
+
+ ///
+ /// 生成速度模式枚举.
+ ///
+ public enum GenerationSpeedMode
+ {
+ RELAX,
+ FAST,
+ TURBO
+ }
+}
diff --git a/LMS.Repository/MJPackage/MJTaskCallbackModel.cs b/LMS.Repository/MJPackage/MJTaskCallbackModel.cs
new file mode 100644
index 0000000..ccab6e7
--- /dev/null
+++ b/LMS.Repository/MJPackage/MJTaskCallbackModel.cs
@@ -0,0 +1,39 @@
+using LMS.Repository.DB;
+using System.Text.Json.Serialization;
+
+namespace LMS.Repository.MJPackage
+{
+ public class MJTaskCallbackModel
+ {
+ public string Id { get; set; }
+
+ public string? Action { get; set; }
+
+ public MJTaskStatus? Status { get; set; }
+
+ public string? Prompt { get; set; }
+
+ public string? PromptEn { get; set; }
+
+ public string? Description { get; set; }
+
+ public long? SubmitTime { get; set; }
+
+ public long? StartTime { get; set; }
+
+ public long? FinishTime { get; set; }
+
+ public string? Progress { get; set; }
+
+ public string? ImageUrl { get; set; }
+
+ public string? FailReason { get; set; }
+
+ public ResponseProperties? Properties { get; set; }
+ }
+
+ public class ResponseProperties
+ {
+ public string? FinalPrompt { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/LMS.Repository/MJPackage/SyncResult.cs b/LMS.Repository/MJPackage/SyncResult.cs
new file mode 100644
index 0000000..9e73cb1
--- /dev/null
+++ b/LMS.Repository/MJPackage/SyncResult.cs
@@ -0,0 +1,18 @@
+namespace LMS.Repository.MJPackage
+{
+ // Models/SyncResult.cs
+ public class SyncResult
+ {
+ public int TotalTokenCount { get; set; }
+ public int ActiveTokenCount { get; set; }
+ public int RecordsUpdated { get; set; }
+ }
+
+ public class TaskStatistics
+ {
+ public int TotalTasks { get; set; } = 0;
+ public int CompletedTasks { get; set; } = 0;
+ public int FailedTasks { get; set; } = 0;
+ public int InProgressTasks { get; set; } = 0;
+ }
+}
diff --git a/LMS.Repository/MJPackage/TokenCacheItem.cs b/LMS.Repository/MJPackage/TokenCacheItem.cs
new file mode 100644
index 0000000..b8f61d2
--- /dev/null
+++ b/LMS.Repository/MJPackage/TokenCacheItem.cs
@@ -0,0 +1,29 @@
+using LMS.Common.Extensions;
+using LMS.Repository.DB;
+
+namespace LMS.Repository.MJPackage
+{
+ // Models/TokenCacheItem.cs
+ public class TokenCacheItem
+ {
+ public long Id { get; set; }
+ public string Token { get; set; }
+ public string UseToken { get; set; } // 实际请求使用的Token
+ public int DailyLimit { get; set; }
+ public int TotalLimit { get; set; }
+ public int ConcurrencyLimit { get; set; } // 新增:并发限制
+ public DateTime CreatedAt { get; set; }
+ public DateTime? ExpiresAt { get; set; }
+ public int DailyUsage { get; set; }
+ public int TotalUsage { get; set; }
+ public DateTime LastActivityTime { get; set; } = BeijingTimeExtension.GetBeijingTime();
+ public string? HistoryUse { get; set; } // 历史使用记录
+
+ public int CurrentlyExecuting { get; set; } = 0;
+ }
+
+ public class TokenAndTaskCollection : TokenCacheItem
+ {
+ public List TaskCollections { get; set; } = [];
+ }
+}
diff --git a/LMS.Repository/MJPackage/TokenCacheStats.cs b/LMS.Repository/MJPackage/TokenCacheStats.cs
new file mode 100644
index 0000000..e453c96
--- /dev/null
+++ b/LMS.Repository/MJPackage/TokenCacheStats.cs
@@ -0,0 +1,11 @@
+namespace LMS.Repository.MJPackage
+{
+ public class TokenCacheStats
+ {
+ public int TotalTokens { get; set; }
+ public int ActiveTokens { get; set; }
+ public int InactiveTokens { get; set; }
+ public int TotalDailyUsage { get; set; }
+ public int TotalUsage { get; set; }
+ }
+}
diff --git a/LMS.Repository/MJPackage/TokenQueryResult.cs b/LMS.Repository/MJPackage/TokenQueryResult.cs
new file mode 100644
index 0000000..743596e
--- /dev/null
+++ b/LMS.Repository/MJPackage/TokenQueryResult.cs
@@ -0,0 +1,20 @@
+namespace LMS.Repository.MJPackage
+{
+ // 查询结果映射类
+ public class TokenQueryResult
+ {
+ public long Id { get; set; }
+ public string Token { get; set; }
+ public string UseToken { get; set; } // 实际请求使用的Token
+ public int DailyLimit { get; set; }
+ public int TotalLimit { get; set; }
+ public int ConcurrencyLimit { get; set; }
+ public DateTime CreatedAt { get; set; }
+ public DateTime? ExpiresAt { get; set; }
+ public int DailyUsage { get; set; }
+ public int TotalUsage { get; set; }
+ public DateTime LastActivityTime { get; set; }
+
+ public string? HistoryUse { get; set; } // 历史使用记录
+ }
+}
diff --git a/LMS.Repository/MJPackage/TokenUsageData.cs b/LMS.Repository/MJPackage/TokenUsageData.cs
new file mode 100644
index 0000000..fc8e853
--- /dev/null
+++ b/LMS.Repository/MJPackage/TokenUsageData.cs
@@ -0,0 +1,12 @@
+namespace LMS.Repository.MJPackage
+{
+ public class TokenUsageData
+ {
+ public long TokenId { get; set; }
+ public DateTime Date { get; set; }
+ public int DailyUsage { get; set; }
+ public int TotalUsage { get; set; }
+ public DateTime LastActivityTime { get; set; }
+ public string? HistoryUse { get; set; }
+ }
+}
diff --git a/LMS.Tools/MJPackage/ITaskConcurrencyManager.cs b/LMS.Tools/MJPackage/ITaskConcurrencyManager.cs
new file mode 100644
index 0000000..6a67758
--- /dev/null
+++ b/LMS.Tools/MJPackage/ITaskConcurrencyManager.cs
@@ -0,0 +1,19 @@
+using LMS.Repository.DB;
+
+
+namespace LMS.Tools.MJPackage
+{
+ // Services/ITaskConcurrencyManager.cs
+ public interface ITaskConcurrencyManager
+ {
+ Task CreateTaskAsync(string token, string thirdPartyTaskId);
+ Task UpdateTaskInDatabase(MJApiTasks mJApiTasks);
+ Task GetTaskInfoAsync(string taskId);
+
+ Task GetTaskInfoByThirdPartyIdAsync(string taskId);
+
+ Task> GetRunningTasksAsync(string token = null);
+ Task<(int maxConcurrency, int running, int available)> GetConcurrencyStatusAsync(string token);
+ Task CleanupTimeoutTasksAsync(TimeSpan timeout);
+ }
+}
diff --git a/LMS.Tools/MJPackage/ITaskService.cs b/LMS.Tools/MJPackage/ITaskService.cs
new file mode 100644
index 0000000..3543ba7
--- /dev/null
+++ b/LMS.Tools/MJPackage/ITaskService.cs
@@ -0,0 +1,10 @@
+using LMS.Repository.DB;
+using Microsoft.AspNetCore.Mvc;
+
+namespace LMS.Tools.MJPackage
+{
+ public interface ITaskService
+ {
+ Task?> FetchTaskAsync(MJApiTasks mJApiTasks);
+ }
+}
diff --git a/LMS.Tools/MJPackage/ITokenService.cs b/LMS.Tools/MJPackage/ITokenService.cs
new file mode 100644
index 0000000..f7b39c0
--- /dev/null
+++ b/LMS.Tools/MJPackage/ITokenService.cs
@@ -0,0 +1,22 @@
+using LMS.Repository.DB;
+using LMS.Repository.MJPackage;
+
+namespace LMS.Tools.MJPackage
+{
+ public interface ITokenService
+ {
+ Task GetTokenAsync(string token);
+
+ Task GetDatabaseTokenAsync(string token, bool hasHistory = false);
+
+ Task GetMJapiTokenByIdAsync(long tokenId);
+
+ Task ResetDailyUsage();
+
+ void IncrementUsage(string token);
+
+ Task LoadOriginTokenAsync();
+
+ Task GetOriginToken();
+ }
+}
diff --git a/LMS.Tools/MJPackage/TaskConcurrencyManager.cs b/LMS.Tools/MJPackage/TaskConcurrencyManager.cs
new file mode 100644
index 0000000..5e714c0
--- /dev/null
+++ b/LMS.Tools/MJPackage/TaskConcurrencyManager.cs
@@ -0,0 +1,216 @@
+using LMS.Common.Extensions;
+using LMS.DAO;
+using LMS.Repository.DB;
+using LMS.Repository.MJPackage;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using System.Collections.Concurrent;
+using System.Text;
+
+namespace LMS.Tools.MJPackage
+{
+ public class TaskConcurrencyManager : ITaskConcurrencyManager
+ {
+ private readonly ConcurrentDictionary _activeTasks = new();
+ private readonly ConcurrentDictionary _thirdPartyTaskMap = new(); // ThirdPartyTaskId -> TaskId
+ private readonly TokenUsageTracker _usageTracker;
+ private readonly IServiceScopeFactory _scopeFactory;
+ private readonly ILogger _logger;
+ private readonly ApplicationDbContext _dbContext;
+ private readonly ITokenService _tokenService;
+
+ public TaskConcurrencyManager(
+ TokenUsageTracker usageTracker,
+ IServiceScopeFactory scopeFactory,
+ ILogger logger,
+ ApplicationDbContext dbContext,
+ ITokenService tokenService)
+ {
+ _usageTracker = usageTracker;
+ _scopeFactory = scopeFactory;
+ _logger = logger;
+ _dbContext = dbContext;
+ _tokenService = tokenService;
+ }
+
+ ///
+ /// 尝试开始新任务(获取并发许可)
+ ///
+ public async Task CreateTaskAsync(
+ string token,
+ string thirdPartyTaskId)
+ {
+ try
+ {
+ TokenCacheItem? tokenConfig = await _tokenService.GetTokenAsync(token);
+ if (tokenConfig == null || string.IsNullOrWhiteSpace(tokenConfig.UseToken))
+ {
+ _logger.LogWarning($"无效的Token: {token}");
+ return;
+ }
+ // 创建任务信息
+ var taskId = Guid.NewGuid().ToString("N");
+ var mJApiTasks = new MJApiTasks
+ {
+ TaskId = taskId,
+ Token = token,
+ TokenId = tokenConfig.Id,
+ StartTime = BeijingTimeExtension.GetBeijingTime(),
+ Status = MJTaskStatus.NOT_START,
+ ThirdPartyTaskId = thirdPartyTaskId,
+ Properties = null
+ };
+
+ // 5. 持久化任务信息到数据库
+ await SaveTaskToDatabase(mJApiTasks);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, $"开始任务时发生错误: Token={token}");
+ }
+ }
+
+
+ ///
+ /// 获取任务信息
+ ///
+ public async Task GetTaskInfoAsync(string taskId)
+ {
+ if (_activeTasks.TryGetValue(taskId, out var taskInfo))
+ {
+ return taskInfo;
+ }
+
+ // 如果内存中没有,尝试从数据库加载
+ return await LoadTaskFromDatabase(taskId);
+ }
+
+ ///
+ /// 通过第三方ID获取数据
+ ///
+ ///
+ ///
+ public async Task GetTaskInfoByThirdPartyIdAsync(string thirdPartyId)
+ {
+ if (string.IsNullOrWhiteSpace(thirdPartyId))
+ {
+ _logger.LogWarning("第三方任务ID为空");
+ return null;
+ }
+ MJApiTasks? mJApiTasks = await _dbContext.MJApiTasks.FirstOrDefaultAsync(x => x.ThirdPartyTaskId == thirdPartyId);
+ return mJApiTasks;
+ }
+
+ ///
+ /// 获取运行中的任务列表
+ ///
+ public async Task> GetRunningTasksAsync(string token = null)
+ {
+ var runningTasks = _activeTasks.Values
+ .Where(t => t.Status != MJTaskStatus.SUCCESS && t.Status != MJTaskStatus.FAILURE && t.Status != MJTaskStatus.CANCEL)
+ .Where(t => string.IsNullOrEmpty(token) || t.Token == token)
+ .OrderBy(t => t.StartTime)
+ .ToList();
+
+ _logger.LogDebug($"当前运行中的任务数: {runningTasks.Count}" + (string.IsNullOrEmpty(token) ? "" : $", Token={token}"));
+
+ return await Task.FromResult(runningTasks);
+ }
+
+ ///
+ /// 获取Token的并发状态
+ ///
+ public async Task<(int maxConcurrency, int running, int available)> GetConcurrencyStatusAsync(string token)
+ {
+ var status = _usageTracker.GetConcurrencyStatus(token);
+ return await Task.FromResult((status.maxCount, status.currentlyExecuting, status.available));
+ }
+
+ ///
+ /// 清理超时任务
+ ///
+ public async Task CleanupTimeoutTasksAsync(TimeSpan timeout)
+ {
+ _logger.LogInformation($"开始清理超时任务,超时阈值: {timeout.TotalMinutes}分钟");
+
+ var cutoffTime = BeijingTimeExtension.GetBeijingTime() - timeout;
+ var timeoutTasks = _activeTasks.Values
+ .Where(t => t.StartTime < cutoffTime && t.Status != MJTaskStatus.SUCCESS && t.Status != MJTaskStatus.FAILURE && t.Status != MJTaskStatus.CANCEL)
+ .ToList();
+
+ _logger.LogInformation($"发现 {timeoutTasks.Count} 个超时任务");
+
+ foreach (var task in timeoutTasks)
+ {
+ _logger.LogWarning($"清理超时任务: TaskId={task.TaskId}, Token={task.Token}, 开始时间={task.StartTime:yyyy-MM-dd HH:mm:ss}");
+ _usageTracker.ReleaseConcurrencyPermit(task.Token);
+ }
+ }
+
+ ///
+ /// 保存任务到数据库
+ ///
+ private async Task SaveTaskToDatabase(MJApiTasks mJApiTasks)
+ {
+ try
+ {
+ await _dbContext.MJApiTasks.AddAsync(mJApiTasks);
+ await _dbContext.SaveChangesAsync();
+ _logger.LogInformation($"任务已保存到数据库: TaskId={mJApiTasks.TaskId}, Token={mJApiTasks.Token}");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, $"保存任务到数据库失败: TaskId={mJApiTasks.TaskId}");
+ }
+ }
+
+ ///
+ /// 更新数据库中的任务状态
+ ///
+ public async Task UpdateTaskInDatabase(MJApiTasks mJApiTasks)
+ {
+ try
+ {
+ MJApiTasks? apiTasks = await _dbContext.MJApiTasks.FirstOrDefaultAsync(x => x.ThirdPartyTaskId == mJApiTasks.ThirdPartyTaskId);
+ if (apiTasks == null)
+ {
+ _logger.LogWarning($"未找到任务: TaskId={mJApiTasks.TaskId}");
+ return;
+ }
+ apiTasks.Status = mJApiTasks.Status;
+ apiTasks.EndTime = mJApiTasks.EndTime;
+ apiTasks.Properties = mJApiTasks.Properties;
+ _dbContext.MJApiTasks.Update(apiTasks);
+ await _dbContext.SaveChangesAsync();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, $"更新任务状态到数据库失败: TaskId={mJApiTasks.TaskId}");
+ }
+ }
+
+ ///
+ /// 从数据库加载任务
+ ///
+ private async Task LoadTaskFromDatabase(string taskId)
+ {
+ try
+ {
+ MJApiTasks? mJApiTasks = await _dbContext.MJApiTasks.FirstOrDefaultAsync(x => x.TaskId == taskId);
+ if (mJApiTasks == null)
+ {
+ _logger.LogWarning($"未找到任务: TaskId={taskId}");
+ return null;
+ }
+
+ return mJApiTasks;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, $"从数据库加载任务失败: TaskId={taskId}");
+ return null;
+ }
+ }
+ }
+}
diff --git a/LMS.Tools/MJPackage/TaskService.cs b/LMS.Tools/MJPackage/TaskService.cs
new file mode 100644
index 0000000..4fa0e91
--- /dev/null
+++ b/LMS.Tools/MJPackage/TaskService.cs
@@ -0,0 +1,147 @@
+
+using LMS.Common.Extensions;
+using LMS.DAO;
+using LMS.Repository.DB;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+using Newtonsoft.Json;
+using System.Net;
+using System.Net.Sockets;
+using System.Threading.Tasks;
+
+namespace LMS.Tools.MJPackage
+{
+ public class TaskService(ITokenService tokenService, ILogger logger, ApplicationDbContext dbContext, ITaskConcurrencyManager taskConcurrencyManager) : ITaskService
+ {
+ private readonly ITokenService _tokenService = tokenService;
+ private readonly ILogger _logger = logger;
+ private readonly ApplicationDbContext _dbContext = dbContext;
+ private readonly ITaskConcurrencyManager _taskConcurrencyManager = taskConcurrencyManager;
+
+ public async Task?> FetchTaskAsync(MJApiTasks mJApiTasks)
+ {
+ try
+ {
+ // 获取UseToken,先尝试 Token,再尝试 TokenId
+ var tokenConfig = await _tokenService.GetTokenAsync(mJApiTasks.Token);
+ string useToken = string.Empty;
+
+ if (tokenConfig == null)
+ {
+ // Token 没找到 尝试用 TokenId 查找
+ MJApiTokens? mJApiTokens = await _tokenService.GetMJapiTokenByIdAsync(mJApiTasks.TokenId);
+ if (mJApiTokens == null)
+ {
+ return null;
+ }
+ useToken = mJApiTokens.UseToken;
+ }
+ else
+ {
+ useToken = tokenConfig.UseToken;
+ }
+
+ if (string.IsNullOrWhiteSpace(useToken))
+ {
+ _logger.LogInformation($"Token is empty for task ID: {mJApiTasks.TaskId}");
+ return null;
+ }
+
+ // 尝试备用API
+ var backupResult = await TryBackupApiAsync(mJApiTasks.ThirdPartyTaskId, useToken);
+
+ if (string.IsNullOrWhiteSpace(backupResult))
+ {
+ // 没有找到数据
+ _logger.LogInformation($"备用API没有返回数据,TaskId: {mJApiTasks.TaskId}");
+ return null;
+ }
+
+ var properties = new Dictionary();
+ try
+ {
+ // 不为空 开始解析数据
+ properties = JsonConvert.DeserializeObject>(backupResult);
+ }
+ catch (JsonException ex)
+ {
+ _logger.LogError($"解析备用API返回数据失败: {ex.Message}");
+ return null;
+ }
+
+ if (properties == null)
+ {
+ _logger.LogInformation($"备用API返回数据为空,TaskId: {mJApiTasks.TaskId}");
+ return null;
+ }
+ return properties;
+ }
+ catch (Exception ex)
+ {
+ // 记录异常日志
+ _logger.LogError($"Error fetching task: {ex.Message}");
+ return null;
+ }
+ }
+ private async Task TryBackupApiAsync(string id, string useToken)
+ {
+ const string backupUrlTemplate = "https://api.laitool.cc/mj/task/{0}/fetch";
+ const int maxRetries = 3;
+ const int baseDelayMs = 1000;
+
+ using var client = new HttpClient();
+ client.DefaultRequestHeaders.Add("Authorization", "sk-" + useToken);
+ client.Timeout = TimeSpan.FromSeconds(30);
+
+ var backupUrl = string.Format(backupUrlTemplate, id);
+
+ for (int attempt = 1; attempt <= maxRetries; attempt++)
+ {
+ try
+ {
+ var response = await client.GetAsync(backupUrl);
+ var content = await response.Content.ReadAsStringAsync();
+ // 判断请求是不是报错
+ if (!response.IsSuccessStatusCode)
+ {
+ _logger.LogWarning("备用API调用返回错误状态码,TaskId: {TaskId}, Attempt: {Attempt}, StatusCode: {StatusCode}",
+ id, attempt, response.StatusCode);
+ return null;
+ }
+ return content;
+ }
+ catch (Exception ex) when (IsRetriableException(ex))
+ {
+ if (attempt < maxRetries)
+ {
+ var delay = baseDelayMs * (int)Math.Pow(2, attempt - 1);
+ _logger.LogWarning(ex, "备用API调用失败,TaskId: {TaskId}, Attempt: {Attempt}, 将在{Delay}ms后重试",
+ id, attempt, delay);
+ await Task.Delay(delay);
+ }
+ else
+ {
+ _logger.LogError(ex, "备用API调用最终失败,TaskId: {TaskId}, MaxAttempts: {MaxAttempts}",
+ id, maxRetries);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "备用API调用发生不可重试异常,TaskId: {TaskId}, Attempt: {Attempt}",
+ id, attempt);
+ break;
+ }
+ }
+
+ return null;
+ }
+
+ private static bool IsRetriableException(Exception ex)
+ {
+ return ex is HttpRequestException ||
+ ex is TaskCanceledException ||
+ ex is SocketException;
+ }
+
+ }
+}
diff --git a/LMS.Tools/MJPackage/TaskStatusCheckService.cs b/LMS.Tools/MJPackage/TaskStatusCheckService.cs
new file mode 100644
index 0000000..9049353
--- /dev/null
+++ b/LMS.Tools/MJPackage/TaskStatusCheckService.cs
@@ -0,0 +1,114 @@
+using LMS.Common.Extensions;
+using LMS.DAO;
+using LMS.Repository.DB;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Newtonsoft.Json;
+using Quartz;
+
+namespace LMS.Tools.MJPackage
+{
+ [DisallowConcurrentExecution]
+ public class TaskStatusCheckService(ITokenService tokenService, ApplicationDbContext dbContext, ILogger logger, ITaskService taskService, ITaskConcurrencyManager taskConcurrencyManager) : IJob
+ {
+ private readonly ITokenService _tokenService = tokenService;
+ private readonly ApplicationDbContext _dbContext = dbContext;
+ private readonly ILogger _logger = logger;
+ private readonly ITaskService _taskService = taskService;
+ private readonly ITaskConcurrencyManager _taskConcurrencyManager = taskConcurrencyManager;
+
+ public async Task Execute(IJobExecutionContext context)
+ {
+ _logger.LogInformation($"开始检查TASK信息 - 检查间隔: 5 分钟,同步加载 原始请求的Token!");
+
+ var startTime = BeijingTimeExtension.GetBeijingTime();
+ try
+ {
+ // 强制同步数据库数据
+ await _tokenService.LoadOriginTokenAsync();
+
+ // 检查Task状态和返回值
+ // 获取所有超过五分钟没有完成的人物
+ List tasks = await _dbContext.MJApiTasks.Where(t => t.Status != MJTaskStatus.CANCEL && t.Status != MJTaskStatus.SUCCESS && t.Status != MJTaskStatus.FAILURE && t.StartTime < BeijingTimeExtension.GetBeijingTime().AddMinutes(-5)).ToListAsync();
+
+ if (tasks.Count == 0)
+ {
+ _logger.LogInformation("没有需要检查的任务!");
+ return;
+ }
+
+ // 开始每个请求
+ foreach (MJApiTasks task in tasks)
+ {
+ try
+ {
+ Dictionary? properties = await _taskService.FetchTaskAsync(task);
+ // 没有找到数据的
+ if (properties == null)
+ {
+ // 没有找到数据 直接把任务失败
+ task.Status = MJTaskStatus.FAILURE;
+ var newProperties = new
+ {
+ failReason = "任务丢失或未找到"
+ };
+ task.EndTime = BeijingTimeExtension.GetBeijingTime();
+ task.Properties = JsonConvert.SerializeObject(newProperties);
+ }
+ else
+ {
+ // 尝试获取状态字段
+ string status = MJTaskStatus.SUBMITTED;
+ if (properties.TryGetValue("status", out var statusElement))
+ {
+ status = statusElement.ToString() ?? MJTaskStatus.SUBMITTED;
+ }
+ else if (properties.TryGetValue("Status", out var statusElementCap))
+ {
+ status = statusElementCap.ToString() ?? MJTaskStatus.SUBMITTED;
+ }
+ task.Status = status;
+
+ if (status == MJTaskStatus.SUCCESS || status == MJTaskStatus.FAILURE || status == MJTaskStatus.CANCEL)
+ {
+ // 当前任务已经被释放过了
+ // 开始修改数据
+ task.EndTime = BeijingTimeExtension.GetBeijingTime();
+ task.Properties = JsonConvert.SerializeObject(properties);
+ }
+ else
+ {
+ // 任务还在处理中
+ task.EndTime = null; // 处理中没有结束时间
+ task.Properties = JsonConvert.SerializeObject(properties);
+ }
+ }
+ // 开始修改数据
+ await _taskConcurrencyManager.UpdateTaskInDatabase(task);
+ }
+ catch (Exception ex)
+ {
+ // 报错
+ _logger.LogError(ex, "检查任务 {TaskId} 时发生错误", task.TaskId);
+ task.Status = MJTaskStatus.FAILURE;
+ var newProperties = new
+ {
+ failReason = "任务报错"
+ };
+ task.EndTime = BeijingTimeExtension.GetBeijingTime();
+ task.Properties = JsonConvert.SerializeObject(newProperties);
+ // 开始修改数据
+ await _taskConcurrencyManager.UpdateTaskInDatabase(task);
+ }
+ }
+ var duration = BeijingTimeExtension.GetBeijingTime() - startTime;
+ _logger.LogInformation($"Task状态检查完成,影响的Task {tasks.Count},耗时: {duration}ms", duration.TotalMilliseconds);
+ }
+ catch (Exception ex)
+ {
+ var duration = BeijingTimeExtension.GetBeijingTime() - startTime;
+ _logger.LogError(ex, "Token同步失败,耗时: {Duration}ms", duration.TotalMilliseconds);
+ }
+ }
+ }
+}
diff --git a/LMS.Tools/MJPackage/TokenResetService.cs b/LMS.Tools/MJPackage/TokenResetService.cs
new file mode 100644
index 0000000..62eb84e
--- /dev/null
+++ b/LMS.Tools/MJPackage/TokenResetService.cs
@@ -0,0 +1,24 @@
+using LMS.Common.Extensions;
+using Microsoft.Extensions.Logging;
+using Quartz;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace LMS.Tools.MJPackage
+{
+ [DisallowConcurrentExecution]
+ public class TokenResetService(ITokenService tokenService, ILogger logger) : IJob
+ {
+ private readonly ITokenService _tokenService = tokenService;
+ private readonly ILogger _logger = logger;
+ public async Task Execute(IJobExecutionContext context)
+ {
+ _logger.LogInformation("开始每天重置 Token 日使用 统计数据, 执行时间 " + BeijingTimeExtension.GetBeijingTime());
+ await _tokenService.ResetDailyUsage();
+ }
+
+ }
+}
diff --git a/LMS.Tools/MJPackage/TokenService.cs b/LMS.Tools/MJPackage/TokenService.cs
new file mode 100644
index 0000000..2de2ce1
--- /dev/null
+++ b/LMS.Tools/MJPackage/TokenService.cs
@@ -0,0 +1,352 @@
+using LMS.Common.Extensions;
+using LMS.DAO;
+using LMS.Repository.DB;
+using LMS.Repository.MJPackage;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Logging;
+using Newtonsoft.Json;
+using System.Data;
+using System.Runtime.CompilerServices;
+
+namespace LMS.Tools.MJPackage
+{
+ public class TokenService(
+ ApplicationDbContext dbContext,
+ IMemoryCache memoryCache,
+ TokenUsageTracker usageTracker,
+ ILogger logger) : ITokenService
+ {
+ private readonly ApplicationDbContext _dbContext = dbContext;
+ private readonly IMemoryCache _memoryCache = memoryCache;
+ private readonly TokenUsageTracker _usageTracker = usageTracker;
+ private readonly ILogger _logger = logger;
+
+ ///
+ /// 从数据库获取token
+ ///
+ ///
+ ///
+ public async Task GetDatabaseTokenAsync(string token, bool hasHistory = false)
+ {
+ try
+ {
+ var today = BeijingTimeExtension.GetBeijingTime().Date;
+ // 使用EF Core的FromSqlRaw执行原生SQL
+ var dbResult = await _dbContext.Database
+ .SqlQuery($@"
+ SELECT
+ t.Id, t.Token, t.DailyLimit, t.TotalLimit, t.ConcurrencyLimit,
+ t.CreatedAt, t.ExpiresAt, t.UseToken,
+ COALESCE(u.DailyUsage, 0) as DailyUsage,
+ COALESCE(u.TotalUsage, 0) as TotalUsage,
+ COALESCE(u.HistoryUse, '') as HistoryUse,
+ COALESCE(u.LastActivityAt, t.CreatedAt) as LastActivityTime
+ FROM MJApiTokens t
+ LEFT JOIN MJApiTokenUsage u ON t.Id = u.TokenId
+ WHERE t.Token = {token}")
+ .FirstOrDefaultAsync();
+
+ if (dbResult == null)
+ {
+ return null;
+ }
+
+ // 3. 转换为TokenCacheItem
+ var tokenItem = new TokenCacheItem
+ {
+ Id = dbResult.Id,
+ Token = dbResult.Token,
+ UseToken = dbResult.UseToken ?? string.Empty, // 确保UseToken不为null
+ DailyLimit = dbResult.DailyLimit,
+ TotalLimit = dbResult.TotalLimit,
+ ConcurrencyLimit = dbResult.ConcurrencyLimit,
+ CreatedAt = dbResult.CreatedAt,
+ ExpiresAt = dbResult.ExpiresAt,
+ DailyUsage = dbResult.DailyUsage,
+ TotalUsage = dbResult.TotalUsage,
+ LastActivityTime = dbResult.LastActivityTime,
+ HistoryUse = hasHistory ? dbResult.HistoryUse : string.Empty
+ };
+ return tokenItem;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, $"从数据库获取Token时发生错误: {token}");
+ throw;
+ }
+ }
+
+ public async Task GetMJapiTokenByIdAsync(long tokenId)
+ {
+ try
+ {
+ MJApiTokens? mJApiTokens = await _dbContext.MJApiTokens
+ .AsNoTracking()
+ .FirstOrDefaultAsync(t => t.Id == tokenId);
+ return mJApiTokens;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, $"获取Token ID {tokenId} 时发生错误");
+ throw;
+ }
+ }
+
+ ///
+ /// 异步获取Token信息,先从缓存获取,缓存未命中则从数据库加载
+ ///
+ /// Token字符串
+ /// Token缓存项,未找到返回null
+ public async Task GetTokenAsync(string token)
+ {
+ _logger.LogDebug($"开始获取Token: {token}");
+
+ // 1. 检查内存缓存
+ if (_usageTracker.TryGetToken(token, out var cacheItem))
+ {
+ _logger.LogDebug($"Token从内存缓存中获取成功: {token}");
+ return cacheItem;
+ }
+
+ // 2. 从数据库加载 - 使用EF Core原生SQL查询
+ _logger.LogDebug($"Token不在缓存中,从数据库加载: {token}");
+
+ try
+ {
+ TokenCacheItem? tokenItem = await GetDatabaseTokenAsync(token);
+ if (tokenItem == null)
+ {
+ _logger.LogWarning($"Token未找到: {token}");
+ return null;
+ }
+
+ // 更新最后活动时间,从数据库中获取得话 设置最后活跃时间为当前时间
+ tokenItem.LastActivityTime = BeijingTimeExtension.GetBeijingTime();
+ // 4. 加入内存缓存
+ _usageTracker.AddOrUpdateToken(tokenItem);
+
+ // 5. 设置内存缓存 (30分钟)
+ _memoryCache.Set($"Token_{token}", tokenItem, TimeSpan.FromMinutes(30));
+
+ _logger.LogInformation($"Token从数据库加载成功: {token}, ID: {tokenItem.Id}, 日限制: {tokenItem.DailyLimit}, 并发限制: {tokenItem.ConcurrencyLimit}");
+ return tokenItem;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, $"从数据库获取Token时发生错误: {token}");
+ throw;
+ }
+ }
+
+ ///
+ /// 增加Token使用量
+ ///
+ /// Token字符串
+ public void IncrementUsage(string token)
+ {
+ _logger.LogDebug($"递增Token使用量: {token}");
+ _usageTracker.IncrementUsage(token);
+ }
+
+ public async Task LoadOriginTokenAsync()
+ {
+ // 没找到 从数据库中获取
+ Options? oprions = await _dbContext.Options.Where(x => x.Key == "MJPackageOriginToken").FirstOrDefaultAsync();
+ if (oprions == null)
+ {
+ _logger.LogWarning("未找到原始Token配置");
+ return string.Empty;
+ }
+
+ // 处理数据
+ string originToken = oprions.GetValueObject() ?? string.Empty;
+ if (string.IsNullOrWhiteSpace(originToken))
+ {
+ _logger.LogWarning("未找到原始Token配置");
+ return string.Empty;
+ }
+ _usageTracker.OriginToken = originToken;
+ return originToken;
+ }
+
+ public async Task GetOriginToken()
+ {
+ // 缓存中就有 直接返回
+ if (!string.IsNullOrWhiteSpace(_usageTracker.OriginToken))
+ {
+ return _usageTracker.OriginToken;
+ }
+ // 缓存中没有 从数据库中获取
+ return await LoadOriginTokenAsync();
+ }
+
+
+ ///
+ /// 重置Token的使用数据
+ ///
+ ///
+ public async Task ResetDailyUsage()
+ {
+ var startTime = BeijingTimeExtension.GetBeijingTime();
+ try
+ {
+ // 批量重置数据库数据
+ int totalTokenCount = await BatchResetTokenDailyUsage();
+
+ // 删除不活跃的token
+ var (act, nact) = _usageTracker.RemoveNotActiveTokens(TimeSpan.FromMinutes(5));
+
+ // 重置缓存中的数据
+ _usageTracker.ResetDailyUsage();
+
+ var duration = BeijingTimeExtension.GetBeijingTime() - startTime;
+ _logger.LogInformation($"Token日使用量重置完成: {totalTokenCount} 个Token, 活跃Token: {act}, 耗时: {duration.TotalMilliseconds}ms");
+ }
+ catch (Exception ex)
+ {
+ var duration = BeijingTimeExtension.GetBeijingTime() - startTime;
+ _logger.LogError(ex, "Token同步失败,耗时: {Duration}ms", duration.TotalMilliseconds);
+ }
+ }
+
+
+ ///
+ /// 批量重置当日使用限制
+ ///
+ ///
+ private async Task BatchResetTokenDailyUsage()
+ {
+ var beijingTime = BeijingTimeExtension.GetBeijingTime();
+
+ _logger.LogInformation($"重置token日限制:开始批量重置 - 北京时间: {beijingTime:yyyy-MM-dd HH:mm:ss}");
+
+ // 修复SQL查询 - 只查询有使用记录且需要重置的Token
+ string sql = @"
+ SELECT
+ t.Id, t.Token, t.DailyLimit, t.TotalLimit, t.ConcurrencyLimit,
+ t.CreatedAt, t.ExpiresAt, t.UseToken,
+ COALESCE(u.DailyUsage, 0) as DailyUsage,
+ COALESCE(u.HistoryUse, '') as HistoryUse,
+ COALESCE(u.TotalUsage, 0) as TotalUsage,
+ COALESCE(u.LastActivityAt, t.CreatedAt) as LastActivityTime
+ FROM MJApiTokens t
+ LEFT JOIN MJApiTokenUsage u ON t.Id = u.TokenId
+ WHERE u.DailyUsage > 0
+ AND (t.ExpiresAt IS NULL OR t.ExpiresAt > UTC_TIMESTAMP())";
+
+ var dbResult = await _dbContext.Database
+ .SqlQuery(FormattableStringFactory.Create(sql))
+ .ToListAsync();
+
+ if (dbResult.Count == 0)
+ {
+ _logger.LogInformation("重置token日限制:没有需要重置的token");
+ return 0;
+ }
+
+ _logger.LogInformation($"找到 {dbResult.Count} 个需要重置的Token");
+
+ // 统计重置前的总使用量
+ var totalDailyUsageBeforeReset = dbResult.Sum(x => x.DailyUsage);
+ _logger.LogInformation($"重置前总日使用量: {totalDailyUsageBeforeReset}");
+
+ var updatedCount = 0;
+ const int batchSize = 100; // 分批处理,避免内存过大
+
+ // 分批处理Token重置
+ for (int batchStart = 0; batchStart < dbResult.Count; batchStart += batchSize)
+ {
+ var batch = dbResult.Skip(batchStart).Take(batchSize).ToList();
+ var batchTokenIds = batch.Select(x => x.Id).ToList();
+
+ // 批量查询当前批次的使用记录
+ var tokenUsageList = await _dbContext.MJApiTokenUsage
+ .Where(x => batchTokenIds.Contains(x.TokenId))
+ .ToListAsync();
+
+ if (!tokenUsageList.Any())
+ {
+ _logger.LogWarning($"批次 {batchStart / batchSize + 1}: 没有找到使用记录");
+ continue;
+ }
+
+ // 使用事务确保数据一致性
+ using var transaction = await _dbContext.Database.BeginTransactionAsync();
+ try
+ {
+ foreach (var tokenUsage in tokenUsageList)
+ {
+ var tokenInfo = batch.FirstOrDefault(x => x.Id == tokenUsage.TokenId);
+ if (tokenInfo == null || tokenUsage.DailyUsage == 0)
+ continue;
+
+ // 处理历史记录
+ ProcessHistoryAndResetUsage(tokenUsage, tokenInfo);
+ }
+ // 批量保存
+ var batchUpdated = await _dbContext.SaveChangesAsync();
+ await transaction.CommitAsync();
+
+ updatedCount += batchUpdated;
+ _logger.LogInformation($"批次 {batchStart / batchSize + 1} 完成: 处理 {batch.Count} 个Token,更新 {batchUpdated} 条记录");
+ }
+ catch (Exception ex)
+ {
+ await transaction.RollbackAsync();
+ _logger.LogError(ex, $"批次 {batchStart / batchSize + 1} 重置失败");
+ throw;
+ }
+ }
+
+ _logger.LogInformation($"✅ 批量重置完成 - 总共更新 {updatedCount} 条记录");
+ _logger.LogInformation($"📊 重置统计 - 重置前日使用量: {totalDailyUsageBeforeReset} → 重置后: 0");
+
+ return updatedCount;
+ }
+
+ ///
+ /// 处理历史记录并重置使用量
+ ///
+ private void ProcessHistoryAndResetUsage(MJApiTokenUsage tokenUsage, TokenQueryResult tokenInfo)
+ {
+ try
+ {
+ // 解析现有历史记录
+ List historyList;
+ try
+ {
+ historyList = string.IsNullOrEmpty(tokenUsage.HistoryUse)
+ ? []
+ : JsonConvert.DeserializeObject>(tokenUsage.HistoryUse) ?? new List();
+ }
+ catch (JsonException ex)
+ {
+ _logger.LogWarning(ex, $"Token {tokenInfo.Token} 历史记录JSON解析失败,将创建新的历史记录");
+ historyList = [];
+ }
+
+ // 添加当前记录到历史
+ historyList.Add(new MJApiTokenUsage
+ {
+ TokenId = tokenUsage.TokenId,
+ Date = BeijingTimeExtension.GetBeijingTime().Date.AddDays(-1),
+ DailyUsage = tokenUsage.DailyUsage,
+ TotalUsage = tokenUsage.TotalUsage,
+ LastActivityAt = tokenUsage.LastActivityAt,
+ HistoryUse = ""
+ });
+
+ // 重置使用量
+ tokenUsage.DailyUsage = 0;
+ tokenUsage.HistoryUse = JsonConvert.SerializeObject(historyList);
+
+ _logger.LogDebug($"Token {tokenInfo.Token} 重置: 日使用量 {tokenUsage.DailyUsage} → 0, 历史记录数: {historyList.Count}");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, $"处理Token {tokenInfo.Token} 的历史记录时发生错误");
+ }
+ }
+ }
+}
diff --git a/LMS.Tools/MJPackage/TokenSyncService.cs b/LMS.Tools/MJPackage/TokenSyncService.cs
new file mode 100644
index 0000000..d62bb3b
--- /dev/null
+++ b/LMS.Tools/MJPackage/TokenSyncService.cs
@@ -0,0 +1,198 @@
+using LMS.Common.Extensions;
+using LMS.DAO;
+using LMS.Repository.MJPackage;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Quartz;
+using System.Data;
+using System.Text;
+
+namespace LMS.Tools.MJPackage
+{
+ [DisallowConcurrentExecution]
+ public class TokenSyncService : IJob
+ {
+ private readonly TokenUsageTracker _usageTracker;
+ private readonly IServiceProvider _serviceProvider;
+ private readonly ILogger _logger;
+
+ // 活动阈值:5分钟内有活动的Token才同步
+ private readonly TimeSpan _activityThreshold = TimeSpan.FromMinutes(5);
+
+ public TokenSyncService(
+ TokenUsageTracker usageTracker,
+ IServiceProvider serviceProvider,
+ ILogger logger)
+ {
+ _usageTracker = usageTracker;
+ _serviceProvider = serviceProvider;
+ _logger = logger;
+ }
+
+ public async Task Execute(IJobExecutionContext context)
+ {
+ _logger.LogInformation($"开始同步Token信息 - 同步间隔: 30 秒, 活动阈值: {_activityThreshold.TotalMinutes}分钟 (使用EF Core)");
+
+ var startTime = BeijingTimeExtension.GetBeijingTime();
+ try
+ {
+ var syncResult = await SyncActiveTokensToDatabase();
+ var duration = BeijingTimeExtension.GetBeijingTime() - startTime;
+
+ if (syncResult.ActiveTokenCount > 0)
+ {
+ _logger.LogInformation(
+ "Token同步完成: {ActiveTokens}/{TotalTokens} 个活跃Token已同步, 耗时: {Duration}ms, 更新记录: {RecordsUpdated}",
+ syncResult.ActiveTokenCount,
+ syncResult.TotalTokenCount,
+ duration.TotalMilliseconds,
+ syncResult.RecordsUpdated);
+ }
+ else
+ {
+ _logger.LogDebug(
+ "Token同步跳过: 无活跃Token (总计: {TotalTokens}, 耗时: {Duration}ms)",
+ syncResult.TotalTokenCount,
+ duration.TotalMilliseconds);
+ }
+ }
+ catch (Exception ex)
+ {
+ var duration = BeijingTimeExtension.GetBeijingTime() - startTime;
+ _logger.LogError(ex, "Token同步失败,耗时: {Duration}ms", duration.TotalMilliseconds);
+ }
+ }
+
+ ///
+ /// 同步活跃Token数据到数据库
+ ///
+ /// 同步结果
+ private async Task SyncActiveTokensToDatabase()
+ {
+ // 先 删除10分钟内不活跃得Token
+ var (act, nact) = _usageTracker.RemoveNotActiveTokens(TimeSpan.FromMinutes(10));
+ _logger.LogInformation($"删除不活跃的 Token 数 {nact},删除后活跃 Token 数:{act},判断不活跃时间:{10} 分钟");
+
+ // 1. 获取活跃Token(最近5分钟内有活动的Token)
+ var activeTokens = _usageTracker.GetActiveTokens(_activityThreshold).ToList();
+ var totalTokens = _usageTracker.GetAllTokens().Count();
+
+ if (!activeTokens.Any())
+ {
+ _logger.LogInformation("0 条活跃Token,跳过同步!");
+ return new SyncResult
+ {
+ TotalTokenCount = totalTokens,
+ ActiveTokenCount = 0,
+ RecordsUpdated = 0
+ };
+ }
+
+ // 2. 创建数据库上下文
+ using var scope = _serviceProvider.CreateScope();
+ var dbContext = scope.ServiceProvider.GetRequiredService();
+
+ var today = BeijingTimeExtension.GetBeijingTime().Date;
+ var recordsUpdated = 0;
+
+ // 3. 构造批量数据
+ var batchData = activeTokens.Select(token => new TokenUsageData
+ {
+ TokenId = token.Id,
+ Date = today,
+ DailyUsage = token.DailyUsage,
+ TotalUsage = token.TotalUsage,
+ LastActivityTime = token.LastActivityTime
+ }).ToList();
+
+ // 4. 使用EF Core事务批量更新数据库
+ using var transaction = await dbContext.Database.BeginTransactionAsync();
+ try
+ {
+ recordsUpdated = await BatchUpdateTokenUsageWithEfCore(dbContext, batchData);
+ await transaction.CommitAsync();
+
+ _logger.LogDebug(
+ "批量更新完成: {RecordsUpdated} 条活跃Token记录已更新,跳过 {SkippedCount} 条非活跃Token",
+ recordsUpdated,
+ totalTokens - activeTokens.Count);
+ }
+ catch (Exception ex)
+ {
+ await transaction.RollbackAsync();
+ _logger.LogError(ex, "数据库事务失败,已回滚");
+ throw;
+ }
+
+ return new SyncResult
+ {
+ TotalTokenCount = totalTokens,
+ ActiveTokenCount = activeTokens.Count,
+ RecordsUpdated = recordsUpdated
+ };
+ }
+
+ ///
+ /// 使用EF Core批量更新Token使用数据
+ ///
+ /// 数据库上下文
+ /// 批量数据
+ /// 更新的记录数
+ private async Task BatchUpdateTokenUsageWithEfCore(ApplicationDbContext dbContext, List batchData)
+ {
+ int batchSize = 500;
+ if (!batchData.Any()) return 0;
+
+ var recordsUpdated = 0;
+
+ // 分批处理
+ for (int i = 0; i < batchData.Count; i += batchSize)
+ {
+ var batch = batchData.Skip(i).Take(batchSize).ToList();
+
+ // 构建真正的批量 SQL
+ var sqlBuilder = new StringBuilder();
+ var parameters = new List