diff --git a/Directory.Build.props b/Directory.Build.props index c27c5d0..67acfc4 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,10 +5,10 @@ - 1.31.6 - 1.31.6.0 - 1.31.6.0 - 1.31.6 + 1.31.7 + 1.31.7.0 + 1.31.7.0 + 1.31.7 Telegram Panel Contributors diff --git a/TelegramPanel.sln b/TelegramPanel.sln index 301f599..228ca1e 100644 --- a/TelegramPanel.sln +++ b/TelegramPanel.sln @@ -1,4 +1,4 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 +Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 diff --git a/docs/developer/modules.md b/docs/developer/modules.md index 345be30..be65553 100644 --- a/docs/developer/modules.md +++ b/docs/developer/modules.md @@ -534,7 +534,56 @@ yield return new ModuleTaskDefinition > 注意:这组字段已经进入抽象层,并且内置持续任务已按此方式声明;外部模块也建议遵循相同结构,便于后续宿主统一扩展任务中心行为。 -### 6) 为“重新运行”提供专用构建器(适合复杂任务) +### 6) 宿主内置数据字典与模板变量(推荐优先复用) + +如果你的模块任务需要“随机文案 / 队列文案 / 图片变量 / 标题模板 / 用户名模板”等能力,建议优先复用宿主已经内置的数据字典体系,而不是在模块里重复造一套词库配置。 + +当前宿主已经提供: + +- 数据字典管理页面:`/data-dictionaries` +- 文本字典:返回 `string` +- 图片字典:返回图片资产引用(适合头像、图片消息等) +- 读取模式:`random` / `queue` +- 队列游标持久化:`queue` 模式的 `NextIndex` 会写入数据库,重启后继续 +- 模板变量语法:固定为 `{name}` +- 内置变量:`{time}`(格式 `yyyyMMddHHmmss`) + +相关宿主服务: + +- `TelegramPanel.Web.Services.DataDictionaryService` +- `TelegramPanel.Web.Services.TemplateRenderingService` +- `TelegramPanel.Web.Services.ImageAssetStorageService` + +推荐用法: + +```csharp +var templateRendering = host.Services.GetRequiredService(); + +var title = await templateRendering.RenderTextTemplateAsync("临时频道{time}_{city}", cancellationToken); +var avatar = await templateRendering.ResolveImageTemplateAsync("{avatar_dict}", cancellationToken); +``` + +约束说明: + +- 标题、描述、公开用户名这类文本字段,只能解析到**文本值** +- 头像、图片消息这类图片字段,只能使用**固定图片**或**图片字典变量** +- 文本字典和图片字典**严格分型**,不要混用 +- 未知变量、空字典、已停用字典、类型不匹配,宿主会直接抛出校验失败 +- 图片变量必须是**单个 token**,例如 `{avatar}`,不能写成 `头像_{avatar}` + +如果你的模块也提供任务编辑器,建议: + +- 在 UI 中直接提示“支持 `{time}` 与 `{字典名}`” +- 文本输入框只展示文本字典变量 +- 图片输入框只展示图片字典变量 +- 让最终配置 JSON 只保存模板字符串 / 字典 token,不要把解析后的随机结果提前固化进配置 + +这样做的好处是: + +- 宿主统一管理字典内容,模块间可以复用同一份变量源 +- 后续扩展新变量 provider 时,模块通常不需要改协议 +- 计划任务、一次性任务、模块页面都能复用同一套解析规则 +### 7) 为“重新运行”提供专用构建器(适合复杂任务) 如果你的任务配置在运行过程中会写回运行态字段,或者重跑前需要清洗旧配置,建议额外实现 `IModuleTaskRerunBuilder`: @@ -666,3 +715,5 @@ public IEnumerable GetApis(ModuleHostContext context) + + diff --git a/src/TelegramPanel.Core/BatchTasks/BatchTaskTypes.cs b/src/TelegramPanel.Core/BatchTasks/BatchTaskTypes.cs index ec2d94d..5c4b394 100644 --- a/src/TelegramPanel.Core/BatchTasks/BatchTaskTypes.cs +++ b/src/TelegramPanel.Core/BatchTasks/BatchTaskTypes.cs @@ -14,6 +14,8 @@ public static class BatchTaskTypes // User 任务(新增) public const string UserJoinSubscribe = "user_join_subscribe"; public const string UserChatActive = "user_chat_active"; + public const string ChannelGroupPrivateCreate = "channel_group_private_create"; + public const string ChannelGroupPublicize = "channel_group_publicize"; // System 任务(记录到任务中心) public const string AccountAutoSync = "account_auto_sync"; diff --git a/src/TelegramPanel.Core/Interfaces/IChannelService.cs b/src/TelegramPanel.Core/Interfaces/IChannelService.cs index 62a54d7..cceff80 100644 --- a/src/TelegramPanel.Core/Interfaces/IChannelService.cs +++ b/src/TelegramPanel.Core/Interfaces/IChannelService.cs @@ -82,6 +82,11 @@ public interface IChannelService /// Task UpdateChannelInfoAsync(int accountId, long channelId, string title, string? about); + /// + /// 设置频道头像。 + /// + Task SetChannelPhotoAsync(int accountId, long channelId, Stream fileStream, string fileName, CancellationToken cancellationToken = default); + /// /// 从频道踢出用户(通过 username),可选是否永久封禁 /// diff --git a/src/TelegramPanel.Core/Interfaces/IGroupService.cs b/src/TelegramPanel.Core/Interfaces/IGroupService.cs index c0d939d..9e46171 100644 --- a/src/TelegramPanel.Core/Interfaces/IGroupService.cs +++ b/src/TelegramPanel.Core/Interfaces/IGroupService.cs @@ -46,4 +46,19 @@ public interface IGroupService /// 获取群组管理员列表(需要权限) /// Task> GetAdminsAsync(int accountId, long groupId); + + /// + /// 更新群组标题与简介。 + /// + Task UpdateGroupInfoAsync(int accountId, long groupId, string title, string? about); + + /// + /// 设置群组公开用户名或切回私密。 + /// + Task SetGroupVisibilityAsync(int accountId, long groupId, bool isPublic, string? username = null); + + /// + /// 设置群组头像。 + /// + Task SetGroupPhotoAsync(int accountId, long groupId, Stream fileStream, string fileName, CancellationToken cancellationToken = default); } diff --git a/src/TelegramPanel.Core/Models/TelegramAccountMessageUpdate.cs b/src/TelegramPanel.Core/Models/TelegramAccountMessageUpdate.cs new file mode 100644 index 0000000..bb93708 --- /dev/null +++ b/src/TelegramPanel.Core/Models/TelegramAccountMessageUpdate.cs @@ -0,0 +1,27 @@ +using TL; + +namespace TelegramPanel.Core.Models; + +public sealed record TelegramInlineButton( + int Index, + int RowIndex, + int ColumnIndex, + string Text, + byte[] CallbackData); + +public sealed record TelegramAccountMessageUpdate( + int AccountId, + DateTimeOffset ReceivedAtUtc, + bool IsEdited, + Message Message, + long? SenderUserId, + string? SenderUsername, + bool SenderIsBot, + int? ReplyToMessageId, + int? ThreadId, + IReadOnlyList Buttons) +{ + public string Text => (Message.message ?? string.Empty).Trim(); + + public bool HasVisualMedia => Message.media is MessageMediaPhoto or MessageMediaDocument; +} diff --git a/src/TelegramPanel.Core/Models/TelegramVerificationModels.cs b/src/TelegramPanel.Core/Models/TelegramVerificationModels.cs new file mode 100644 index 0000000..8bcb839 --- /dev/null +++ b/src/TelegramPanel.Core/Models/TelegramVerificationModels.cs @@ -0,0 +1,12 @@ +namespace TelegramPanel.Core.Models; + +public sealed record TelegramInlineButtonOption(int Index, string Text, byte[] CallbackData); + +public sealed record TelegramVerificationMessageCandidate( + int MessageId, + string? Text, + byte[]? ImageJpegBytes, + IReadOnlyList Buttons, + bool MentionsAccount, + bool IsReplyToSentMessage, + DateTime DateUtc); diff --git a/src/TelegramPanel.Core/ServiceCollectionExtensions.cs b/src/TelegramPanel.Core/ServiceCollectionExtensions.cs index cf9d40b..32650f2 100644 --- a/src/TelegramPanel.Core/ServiceCollectionExtensions.cs +++ b/src/TelegramPanel.Core/ServiceCollectionExtensions.cs @@ -12,6 +12,8 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddTelegramPanelCore(this IServiceCollection services) { + services.AddSingleton(); + // 注册 Telegram 客户端池(单例) services.AddSingleton(); diff --git a/src/TelegramPanel.Core/Services/BatchTaskManagementService.cs b/src/TelegramPanel.Core/Services/BatchTaskManagementService.cs index c1542a2..1c10619 100644 --- a/src/TelegramPanel.Core/Services/BatchTaskManagementService.cs +++ b/src/TelegramPanel.Core/Services/BatchTaskManagementService.cs @@ -26,7 +26,7 @@ public class BatchTaskManagementService public async Task GetTaskAsync(int id) { - return await _batchTaskRepository.GetByIdAsync(id); + return await _batchTaskRepository.GetFreshByIdAsync(id); } public async Task> GetAllTasksAsync() @@ -63,51 +63,51 @@ public class BatchTaskManagementService public async Task UpdateTaskProgressAsync(int taskId, int completed, int failed) { - var task = await _batchTaskRepository.GetByIdAsync(taskId); + var task = await _batchTaskRepository.GetFreshByIdAsync(taskId); if (task != null) { task.Completed = completed; task.Failed = failed; - await _batchTaskRepository.UpdateAsync(task); + await _batchTaskRepository.UpdateFreshAsync(task); } } public async Task UpdateTaskConfigAsync(int taskId, string? config) { - var task = await _batchTaskRepository.GetByIdAsync(taskId); + var task = await _batchTaskRepository.GetFreshByIdAsync(taskId); if (task != null) { task.Config = config; - await _batchTaskRepository.UpdateAsync(task); + await _batchTaskRepository.UpdateFreshAsync(task); } } public async Task UpdateTaskDraftAsync(int taskId, int total, string? config) { - var task = await _batchTaskRepository.GetByIdAsync(taskId); + var task = await _batchTaskRepository.GetFreshByIdAsync(taskId); if (task != null) { if (total < 0) total = 0; task.Total = total; task.Config = config; - await _batchTaskRepository.UpdateAsync(task); + await _batchTaskRepository.UpdateFreshAsync(task); } } public async Task StartTaskAsync(int taskId) { - var task = await _batchTaskRepository.GetByIdAsync(taskId); + var task = await _batchTaskRepository.GetFreshByIdAsync(taskId); if (task != null) { task.Status = "running"; task.StartedAt = DateTime.UtcNow; - await _batchTaskRepository.UpdateAsync(task); + await _batchTaskRepository.UpdateFreshAsync(task); } } public async Task PauseTaskAsync(int taskId) { - var task = await _batchTaskRepository.GetByIdAsync(taskId); + var task = await _batchTaskRepository.GetFreshByIdAsync(taskId); if (task == null) return; @@ -115,13 +115,13 @@ public class BatchTaskManagementService { task.Status = "paused"; task.CompletedAt = null; - await _batchTaskRepository.UpdateAsync(task); + await _batchTaskRepository.UpdateFreshAsync(task); } } public async Task ResumeTaskAsync(int taskId) { - var task = await _batchTaskRepository.GetByIdAsync(taskId); + var task = await _batchTaskRepository.GetFreshByIdAsync(taskId); if (task == null) return; @@ -130,25 +130,25 @@ public class BatchTaskManagementService task.Status = "pending"; task.StartedAt = null; task.CompletedAt = null; - await _batchTaskRepository.UpdateAsync(task); + await _batchTaskRepository.UpdateFreshAsync(task); } } public async Task CompleteTaskAsync(int taskId, bool success = true) { - var task = await _batchTaskRepository.GetByIdAsync(taskId); + var task = await _batchTaskRepository.GetFreshByIdAsync(taskId); if (task == null) return; task.Status = success ? "completed" : "failed"; task.CompletedAt = DateTime.UtcNow; - await _batchTaskRepository.UpdateAsync(task); + await _batchTaskRepository.UpdateFreshAsync(task); await TrimHistoryTasksIfNeededAsync(); } public async Task DeleteTaskAsync(int id) { - var task = await _batchTaskRepository.GetByIdAsync(id); + var task = await _batchTaskRepository.GetFreshByIdAsync(id); if (task != null) { await _batchTaskRepository.DeleteAsync(task); diff --git a/src/TelegramPanel.Core/Services/ChannelManagementService.cs b/src/TelegramPanel.Core/Services/ChannelManagementService.cs index 0d9b149..608f7cb 100644 --- a/src/TelegramPanel.Core/Services/ChannelManagementService.cs +++ b/src/TelegramPanel.Core/Services/ChannelManagementService.cs @@ -1,4 +1,4 @@ -using TelegramPanel.Data.Entities; +using TelegramPanel.Data.Entities; using TelegramPanel.Data.Repositories; namespace TelegramPanel.Core.Services; @@ -84,6 +84,8 @@ public class ChannelManagementService existing.CreatorAccountId = channel.CreatorAccountId; if (channel.CreatedAt.HasValue) existing.CreatedAt = channel.CreatedAt; + if (existing.SystemCreatedAtUtc == null && channel.SystemCreatedAtUtc != null) + existing.SystemCreatedAtUtc = channel.SystemCreatedAtUtc; existing.SyncedAt = DateTime.UtcNow; await _channelRepository.UpdateAsync(existing); @@ -123,6 +125,8 @@ public class ChannelManagementService legacy.GroupId = channel.GroupId; if (channel.CreatedAt.HasValue) legacy.CreatedAt = channel.CreatedAt; + if (legacy.SystemCreatedAtUtc == null && channel.SystemCreatedAtUtc != null) + legacy.SystemCreatedAtUtc = channel.SystemCreatedAtUtc; legacy.SyncedAt = DateTime.UtcNow; await _channelRepository.UpdateAsync(legacy); @@ -280,3 +284,5 @@ public class ChannelManagementService return await _accountChannelRepository.GetPreferredAdminAccountIdAsync(channel.Id); } } + + diff --git a/src/TelegramPanel.Core/Services/GroupManagementService.cs b/src/TelegramPanel.Core/Services/GroupManagementService.cs index 423fbda..a727f09 100644 --- a/src/TelegramPanel.Core/Services/GroupManagementService.cs +++ b/src/TelegramPanel.Core/Services/GroupManagementService.cs @@ -1,4 +1,4 @@ -using TelegramPanel.Data.Entities; +using TelegramPanel.Data.Entities; using TelegramPanel.Data.Repositories; namespace TelegramPanel.Core.Services; @@ -69,6 +69,8 @@ public class GroupManagementService existing.CreatorAccountId = group.CreatorAccountId; if (group.CreatedAt.HasValue) existing.CreatedAt = group.CreatedAt; + if (existing.SystemCreatedAtUtc == null && group.SystemCreatedAtUtc != null) + existing.SystemCreatedAtUtc = group.SystemCreatedAtUtc; existing.SyncedAt = DateTime.UtcNow; await _groupRepository.UpdateAsync(existing); @@ -223,3 +225,5 @@ public class GroupManagementService return await _accountGroupRepository.GetPreferredAdminAccountIdAsync(group.Id); } } + + diff --git a/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs b/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs index 8d2e55e..c268be2 100644 --- a/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs +++ b/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs @@ -24,17 +24,20 @@ public class AccountTelegramToolsService private readonly ITelegramClientPool _clientPool; private readonly IConfiguration _configuration; private readonly ILogger _logger; + private readonly TelegramAccountUpdateHub _updateHub; public AccountTelegramToolsService( AccountManagementService accountManagement, ITelegramClientPool clientPool, IConfiguration configuration, - ILogger logger) + ILogger logger, + TelegramAccountUpdateHub updateHub) { _accountManagement = accountManagement; _clientPool = clientPool; _configuration = configuration; _logger = logger; + _updateHub = updateHub; } /// @@ -1112,22 +1115,109 @@ public class AccountTelegramToolsService /// /// 向已解析的群组/频道目标发送文本消息。 /// - public async Task<(bool Success, string? Error)> SendMessageToResolvedChatAsync( + public async Task<(bool Success, string? Error, int? MessageId)> SendMessageToResolvedChatAsync( int accountId, ResolvedChatTarget target, string message, + int? replyToMessageId = null, CancellationToken cancellationToken = default) { try { var text = (message ?? string.Empty).Trim(); if (text.Length == 0) - return (false, "消息内容为空"); + return (false, "消息内容为空", null); var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken); cancellationToken.ThrowIfCancellationRequested(); - _ = await client.SendMessageAsync(target.Peer, text); + var sent = await client.SendMessageAsync(target.Peer, text, null, replyToMessageId ?? 0); + return (true, null, sent.id); + } + catch (Exception ex) + { + var (summary, details) = MapTelegramException(ex); + var msg = string.IsNullOrWhiteSpace(details) ? summary : $"{summary}:{details}"; + return (false, msg, null); + } + } + + public async Task<(bool Success, string? Error, TelegramVerificationMessageCandidate? Candidate)> WaitForBotVerificationMessageAsync( + int accountId, + ResolvedChatTarget target, + int sentMessageId, + string? currentUsername, + int timeoutSeconds, + CancellationToken cancellationToken = default) + { + if (timeoutSeconds < 3) + timeoutSeconds = 3; + if (timeoutSeconds > 300) + timeoutSeconds = 300; + + try + { + var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken); + var waitStartedAt = DateTimeOffset.UtcNow.AddSeconds(-2); + var update = await _updateHub.WaitForAsync( + accountId, + x => IsCandidateVerificationMessage(x, target, currentUsername, sentMessageId), + waitStartedAt, + TimeSpan.FromSeconds(timeoutSeconds), + cancellationToken); + + if (update == null) + return (false, $"等待验证消息超时({timeoutSeconds} 秒)", null); + + var candidate = await BuildVerificationCandidateAsync( + client, + update.Message, + currentUsername, + sentMessageId, + cancellationToken); + + return candidate == null + ? (false, "匹配到的验证消息为空,无法执行 AI 识别", null) + : (true, null, candidate); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + var (summary, details) = MapTelegramException(ex); + var msg = string.IsNullOrWhiteSpace(details) ? summary : $"{summary}:{details}"; + return (false, msg, null); + } + } + + public async Task<(bool Success, string? Error)> ClickInlineButtonAsync( + int accountId, + ResolvedChatTarget target, + int messageId, + byte[] callbackData, + CancellationToken cancellationToken = default) + { + try + { + if (callbackData == null || callbackData.Length == 0) + return (false, "按钮缺少 callback_data"); + + var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + + _ = await client.Messages_GetBotCallbackAnswer(target.Peer, messageId, callbackData, null, false); + return (true, null); + } + catch (Exception ex) when (IsBotCallbackTimeout(ex)) + { + _logger.LogInformation( + ex, + "Telegram bot callback timed out after click, treat as delivered: accountId={AccountId}, chat={ChatId}, messageId={MessageId}", + accountId, + target.CanonicalId, + messageId); return (true, null); } catch (Exception ex) @@ -1180,6 +1270,223 @@ public class AccountTelegramToolsService return null; } + private bool IsCandidateVerificationMessage( + TelegramAccountMessageUpdate update, + ResolvedChatTarget target, + string? currentUsername, + int sentMessageId) + { + if (!update.SenderIsBot) + return false; + + if (!IsSamePeer(target.Peer, update.Message.peer_id)) + return false; + + var mentionsAccount = ContainsUsernameMention(update.Message.message, currentUsername); + var replyToSent = update.ReplyToMessageId == sentMessageId; + if (!mentionsAccount && !replyToSent) + return false; + + return LooksLikeVerificationChallenge(update); + } + + private async Task BuildVerificationCandidateAsync( + Client client, + Message message, + string? currentUsername, + int sentMessageId, + CancellationToken cancellationToken) + { + var buttons = ExtractInlineButtons(message); + var imageJpegBytes = await TryDownloadVerificationImageAsync(client, message, cancellationToken); + var text = (message.message ?? string.Empty).Trim(); + + if (buttons.Count == 0 && text.Length == 0 && (imageJpegBytes == null || imageJpegBytes.Length == 0)) + return null; + + return new TelegramVerificationMessageCandidate( + MessageId: message.id, + Text: text.Length == 0 ? null : text, + ImageJpegBytes: imageJpegBytes, + Buttons: buttons, + MentionsAccount: ContainsUsernameMention(message.message, currentUsername), + IsReplyToSentMessage: message.ReplyHeader?.reply_to_msg_id == sentMessageId, + DateUtc: message.Date.ToUniversalTime()); + } + + private static bool IsSamePeer(InputPeer targetPeer, Peer actualPeer) + { + return (targetPeer, actualPeer) switch + { + (InputPeerChannel targetChannel, PeerChannel actualChannel) => targetChannel.channel_id == actualChannel.channel_id, + (InputPeerChat targetChat, PeerChat actualChat) => targetChat.chat_id == actualChat.chat_id, + (InputPeerUser targetUser, PeerUser actualUser) => targetUser.user_id == actualUser.user_id, + _ => false + }; + } + + private static bool ContainsUsernameMention(string? text, string? currentUsername) + { + var username = (currentUsername ?? string.Empty).Trim().TrimStart('@'); + if (username.Length == 0) + return false; + + var messageText = (text ?? string.Empty).Trim(); + if (messageText.Length == 0) + return false; + + return messageText.Contains($"@{username}", StringComparison.OrdinalIgnoreCase); + } + + private static bool LooksLikeVerificationChallenge(TelegramAccountMessageUpdate update) + { + if (update.Buttons.Count > 0 || update.HasVisualMedia) + return true; + + var text = update.Text; + if (text.Length == 0) + return false; + + if (ContainsAny(text, "垃圾广告", "广告", "不予处理", "已删除", "违规", "封禁") + && !ContainsAny(text, "验证", "验证码", "校验", "captcha")) + { + return false; + } + + if (ContainsAny(text, + "验证", + "验证码", + "校验", + "请选择", + "点击", + "按钮", + "完成验证", + "请回复", + "答案", + "算式", + "等于多少", + "reply", + "captcha")) + { + return true; + } + + return LooksLikeMathChallenge(text); + } + + private static bool LooksLikeMathChallenge(string text) + { + var digitCount = 0; + foreach (var ch in text) + { + if (char.IsDigit(ch)) + digitCount++; + } + + if (digitCount < 2) + return false; + + return text.IndexOf('+') >= 0 + || text.IndexOf('-') >= 0 + || text.IndexOf('*') >= 0 + || text.IndexOf('/') >= 0 + || text.Contains("×", StringComparison.Ordinal) + || text.Contains("÷", StringComparison.Ordinal) + || text.Contains("=", StringComparison.Ordinal) + || text.IndexOf('=') >= 0; + } + + private static bool ContainsAny(string text, params string[] keywords) + { + foreach (var keyword in keywords) + { + if (!string.IsNullOrWhiteSpace(keyword) + && text.Contains(keyword, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static List ExtractInlineButtons(Message message) + { + if (message.reply_markup is not ReplyInlineMarkup markup) + return new List(); + + var result = new List(); + var index = 0; + foreach (var row in markup.rows ?? Array.Empty()) + { + var buttons = row?.buttons; + if (buttons == null || buttons.Length == 0) + continue; + + foreach (var button in buttons) + { + if (button is KeyboardButtonCallback callback && callback.data is { Length: > 0 }) + { + result.Add(new TelegramInlineButtonOption(index, callback.text ?? string.Empty, callback.data)); + index++; + } + } + } + + return result; + } + + private async Task TryDownloadVerificationImageAsync(Client client, Message message, CancellationToken cancellationToken) + { + try + { + return message.media switch + { + MessageMediaPhoto { photo: Photo photo } => await DownloadPhotoAsJpegAsync(client, photo, cancellationToken), + MessageMediaDocument { document: Document document } => await DownloadDocumentPreviewAsJpegAsync(client, document, cancellationToken), + _ => null + }; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to download verification image from Telegram message {MessageId}", message.id); + return null; + } + } + + private async Task DownloadPhotoAsJpegAsync(Client client, Photo photo, CancellationToken cancellationToken) + { + await using var raw = new MemoryStream(); + await client.DownloadFileAsync(photo, raw, (PhotoSizeBase?)null); + raw.Position = 0; + + await using var jpeg = await TelegramImageProcessor.PrepareStoredImageJpegAsync(raw, cancellationToken: cancellationToken); + return jpeg.ToArray(); + } + + private async Task DownloadDocumentPreviewAsJpegAsync(Client client, Document document, CancellationToken cancellationToken) + { + await using var raw = new MemoryStream(); + + var thumb = document.thumbs?.OfType().LastOrDefault(); + if (thumb != null) + { + await client.DownloadFileAsync(document, raw, thumb); + } + else if ((document.mime_type ?? string.Empty).StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + { + await client.DownloadFileAsync(document, raw, (PhotoSizeBase?)null); + } + else + { + return null; + } + + raw.Position = 0; + await using var jpeg = await TelegramImageProcessor.PrepareStoredImageJpegAsync(raw, cancellationToken: cancellationToken); + return jpeg.ToArray(); + } + private static bool TryParseChatIdCandidate(string raw, out long normalizedId) { normalizedId = 0; @@ -1403,29 +1710,7 @@ public class AccountTelegramToolsService var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken); cancellationToken.ThrowIfCancellationRequested(); - // 先把原图读入内存,然后做“居中裁剪为正方形 + 缩放到 512x512 + JPEG 压缩”,再上传给 Telegram。 - // 这样可以避免:原图过大、长宽比异常、某些上传流读取不稳定等问题。 - await using var raw = new MemoryStream(); - if (fileStream.CanSeek) - fileStream.Position = 0; - - await fileStream.CopyToAsync(raw, cancellationToken); - raw.Position = 0; - - using var image = await Image.LoadAsync(raw, cancellationToken); - image.Mutate(x => x.AutoOrient()); - - const int targetSize = 512; - image.Mutate(x => x.Resize(new ResizeOptions - { - Mode = ResizeMode.Crop, - Size = new Size(targetSize, targetSize) - })); - - await using var encoded = new MemoryStream(); - await image.SaveAsJpegAsync(encoded, new JpegEncoder { Quality = 85 }, cancellationToken); - encoded.Position = 0; - + await using var encoded = await TelegramImageProcessor.PrepareAvatarJpegAsync(fileStream, cancellationToken); var inputFile = await client.UploadFileAsync(encoded, "avatar.jpg"); cancellationToken.ThrowIfCancellationRequested(); @@ -1744,6 +2029,12 @@ public class AccountTelegramToolsService /// /// 将 Telegram 异常映射为可读的摘要和详情。 /// + private static bool IsBotCallbackTimeout(Exception ex) + { + var msg = ex.Message ?? string.Empty; + return msg.Contains("BOT_RESPONSE_TIMEOUT", StringComparison.OrdinalIgnoreCase); + } + public static (string summary, string details) MapTelegramException(Exception ex) { var msg = ex.Message ?? string.Empty; diff --git a/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs b/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs index cd987cb..a97f813 100644 --- a/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs +++ b/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs @@ -499,34 +499,7 @@ public class BotTelegramService if (string.IsNullOrWhiteSpace(fileName)) fileName = "photo.jpg"; - // Bot API setChatPhoto 需要 multipart 上传 InputFile。 - // 为了提高成功率:做“居中裁剪为正方形 + 缩放到 512x512 + JPEG 压缩”,避免原图过大/比例异常导致失败。 - await using var raw = new MemoryStream(); - if (fileStream.CanSeek) - fileStream.Position = 0; - await fileStream.CopyToAsync(raw, cancellationToken); - raw.Position = 0; - - await using var encoded = new MemoryStream(); - try - { - using var image = await Image.LoadAsync(raw, cancellationToken); - image.Mutate(x => x.AutoOrient()); - - const int targetSize = 512; - image.Mutate(x => x.Resize(new ResizeOptions - { - Mode = ResizeMode.Crop, - Size = new Size(targetSize, targetSize) - })); - - await image.SaveAsJpegAsync(encoded, new JpegEncoder { Quality = 85 }, cancellationToken); - encoded.Position = 0; - } - catch (UnknownImageFormatException) - { - throw new InvalidOperationException("不支持的图片格式(建议使用 JPG/PNG)"); - } + await using var encoded = await TelegramImageProcessor.PrepareAvatarJpegAsync(fileStream, cancellationToken); _ = await _api.CallWithFileAsync( token: bot.Token, diff --git a/src/TelegramPanel.Core/Services/Telegram/ChannelService.cs b/src/TelegramPanel.Core/Services/Telegram/ChannelService.cs index 905b93d..7a809af 100644 --- a/src/TelegramPanel.Core/Services/Telegram/ChannelService.cs +++ b/src/TelegramPanel.Core/Services/Telegram/ChannelService.cs @@ -868,6 +868,45 @@ public class ChannelService : IChannelService return true; } + public async Task SetChannelPhotoAsync( + int accountId, + long channelId, + Stream fileStream, + string fileName, + CancellationToken cancellationToken = default) + { + var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken); + var channel = await GetChannelByIdAsync(client, channelId) + ?? throw new InvalidOperationException($"Channel {channelId} not found"); + + if (fileStream == null) + throw new ArgumentException("头像文件为空", nameof(fileStream)); + + fileName = (fileName ?? "channel_photo.jpg").Trim(); + if (string.IsNullOrWhiteSpace(fileName)) + fileName = "channel_photo.jpg"; + + try + { + await using var encoded = await TelegramImageProcessor.PrepareAvatarJpegAsync(fileStream, cancellationToken); + var uploaded = await client.UploadFileAsync(encoded, fileName); + if (uploaded == null) + throw new InvalidOperationException("频道头像上传失败:上传结果为空"); + + await client.Channels_EditPhoto(channel, new InputChatUploadedPhoto + { + flags = InputChatUploadedPhoto.Flags.has_file, + file = uploaded + }); + + return true; + } + catch (SixLabors.ImageSharp.UnknownImageFormatException) + { + throw new InvalidOperationException("不支持的图片格式(建议使用 JPG/PNG)"); + } + } + public async Task KickUserAsync(int accountId, long channelId, string username, bool permanentBan = false) { var client = await GetOrCreateConnectedClientAsync(accountId); diff --git a/src/TelegramPanel.Core/Services/Telegram/GroupService.cs b/src/TelegramPanel.Core/Services/Telegram/GroupService.cs index dd2743a..9372efb 100644 --- a/src/TelegramPanel.Core/Services/Telegram/GroupService.cs +++ b/src/TelegramPanel.Core/Services/Telegram/GroupService.cs @@ -5,6 +5,7 @@ using TelegramPanel.Core.Interfaces; using TelegramPanel.Core.Models; using TelegramPanel.Core.Services; using TL; +using WTelegram; namespace TelegramPanel.Core.Services.Telegram; @@ -416,6 +417,121 @@ public class GroupService : IGroupService return groups.FirstOrDefault(g => g.TelegramId == groupId); } + public async Task UpdateGroupInfoAsync(int accountId, long groupId, string title, string? about) + { + var client = await GetOrCreateConnectedClientAsync(accountId); + var group = await GetEditableMegaGroupAsync(accountId, client, groupId, CancellationToken.None); + + title = (title ?? string.Empty).Trim(); + about = string.IsNullOrWhiteSpace(about) ? null : about.Trim(); + if (string.IsNullOrWhiteSpace(title)) + throw new ArgumentException("群组标题不能为空", nameof(title)); + + await client.Channels_EditTitle(group, title); + if (about != null) + await client.Messages_EditChatAbout(group, about); + + return true; + } + + public async Task SetGroupVisibilityAsync(int accountId, long groupId, bool isPublic, string? username = null) + { + var client = await GetOrCreateConnectedClientAsync(accountId); + var group = await GetEditableMegaGroupAsync(accountId, client, groupId, CancellationToken.None); + + if (isPublic) + { + username = (username ?? string.Empty).Trim().TrimStart('@'); + if (string.IsNullOrWhiteSpace(username)) + throw new InvalidOperationException("公开群组需要设置用户名"); + + var current = (group.MainUsername ?? string.Empty).Trim(); + if (string.Equals(current, username, StringComparison.OrdinalIgnoreCase)) + return true; + + var available = await client.Channels_CheckUsername(group, username); + if (!available) + throw new InvalidOperationException($"用户名 '{username}' 不可用"); + + try + { + await client.Channels_UpdateUsername(group, username); + } + catch (RpcException ex) when (ex.Message.Contains("USERNAME_NOT_MODIFIED", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + else + { + if (string.IsNullOrWhiteSpace(group.MainUsername)) + return true; + + await client.Channels_UpdateUsername(group, string.Empty); + } + + return true; + } + + public async Task SetGroupPhotoAsync( + int accountId, + long groupId, + Stream fileStream, + string fileName, + CancellationToken cancellationToken = default) + { + var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken); + var group = await GetEditableMegaGroupAsync(accountId, client, groupId, cancellationToken); + + if (fileStream == null) + throw new ArgumentException("头像文件为空", nameof(fileStream)); + + fileName = (fileName ?? "group_photo.jpg").Trim(); + if (string.IsNullOrWhiteSpace(fileName)) + fileName = "group_photo.jpg"; + + try + { + await using var encoded = await TelegramImageProcessor.PrepareAvatarJpegAsync(fileStream, cancellationToken); + var uploaded = await client.UploadFileAsync(encoded, fileName); + if (uploaded == null) + throw new InvalidOperationException("群组头像上传失败:上传结果为空"); + + await client.Channels_EditPhoto(group, new InputChatUploadedPhoto + { + flags = InputChatUploadedPhoto.Flags.has_file, + file = uploaded + }); + + return true; + } + catch (SixLabors.ImageSharp.UnknownImageFormatException) + { + throw new InvalidOperationException("不支持的图片格式(建议使用 JPG/PNG)"); + } + } + + private async Task GetEditableMegaGroupAsync(int accountId, Client client, long groupId, CancellationToken cancellationToken) + { + var dialogs = await ExecuteTelegramRequestAsync( + accountId, + "拉取频道/群组对话列表", + () => client.Messages_GetAllDialogs(), + cancellationToken, + resetClientOnTimeout: true); + + var normalizedId = groupId > 0 ? groupId : Math.Abs(groupId); + var mega = dialogs.chats.Values.OfType() + .FirstOrDefault(x => x.IsActive && !x.IsChannel && x.id == normalizedId); + if (mega != null) + return mega; + + if (dialogs.chats.Values.OfType().Any(x => x.IsActive && x.id == normalizedId)) + throw new InvalidOperationException("当前仅支持超级群组自动修改资料/公开状态"); + + throw new InvalidOperationException($"群组 {groupId} not found"); + } + public async Task LeaveGroupAsync(int accountId, long groupId) { try diff --git a/src/TelegramPanel.Core/Services/Telegram/TelegramAccountUpdateHub.cs b/src/TelegramPanel.Core/Services/Telegram/TelegramAccountUpdateHub.cs new file mode 100644 index 0000000..17bb18f --- /dev/null +++ b/src/TelegramPanel.Core/Services/Telegram/TelegramAccountUpdateHub.cs @@ -0,0 +1,92 @@ +using System.Collections.Concurrent; +using TelegramPanel.Core.Models; + +namespace TelegramPanel.Core.Services.Telegram; + +/// +/// 聚合账号侧收到的 Telegram 消息更新,供后台任务按账号/聊天做短期等待。 +/// +public sealed class TelegramAccountUpdateHub +{ + private static readonly TimeSpan MessageRetention = TimeSpan.FromMinutes(10); + private const int MaxMessagesPerAccount = 200; + + private readonly ConcurrentDictionary> _messagesByAccount = new(); + + public Task PublishAsync(TelegramAccountMessageUpdate update) + { + var queue = _messagesByAccount.GetOrAdd(update.AccountId, _ => new ConcurrentQueue()); + queue.Enqueue(update); + TrimQueue(queue); + return Task.CompletedTask; + } + + public IReadOnlyList GetRecentMessages(int accountId, DateTimeOffset sinceUtc) + { + if (!_messagesByAccount.TryGetValue(accountId, out var queue)) + return Array.Empty(); + + return queue + .ToArray() + .Where(x => x.ReceivedAtUtc >= sinceUtc) + .OrderBy(x => x.ReceivedAtUtc) + .ToList(); + } + + public async Task WaitForAsync( + int accountId, + Func predicate, + DateTimeOffset sinceUtc, + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(predicate); + + if (timeout <= TimeSpan.Zero) + timeout = TimeSpan.FromSeconds(1); + + var deadline = DateTimeOffset.UtcNow + timeout; + var seen = new HashSet(StringComparer.Ordinal); + + while (DateTimeOffset.UtcNow < deadline) + { + cancellationToken.ThrowIfCancellationRequested(); + + var items = GetRecentMessages(accountId, sinceUtc); + for (var index = items.Count - 1; index >= 0; index--) + { + var item = items[index]; + var key = $"{item.Message.id}:{item.ReceivedAtUtc.UtcTicks}:{(item.IsEdited ? 1 : 0)}"; + if (!seen.Add(key)) + continue; + + if (predicate(item)) + return item; + } + + var remaining = deadline - DateTimeOffset.UtcNow; + if (remaining <= TimeSpan.Zero) + break; + + var delay = remaining < TimeSpan.FromMilliseconds(300) + ? remaining + : TimeSpan.FromMilliseconds(300); + await Task.Delay(delay, cancellationToken); + } + + return null; + } + + private static void TrimQueue(ConcurrentQueue queue) + { + var cutoff = DateTimeOffset.UtcNow - MessageRetention; + + while (queue.TryPeek(out var item)) + { + if (queue.Count <= MaxMessagesPerAccount && item.ReceivedAtUtc >= cutoff) + break; + + queue.TryDequeue(out _); + } + } +} diff --git a/src/TelegramPanel.Core/Services/Telegram/TelegramClientPool.cs b/src/TelegramPanel.Core/Services/Telegram/TelegramClientPool.cs index d48e398..e2ee618 100644 --- a/src/TelegramPanel.Core/Services/Telegram/TelegramClientPool.cs +++ b/src/TelegramPanel.Core/Services/Telegram/TelegramClientPool.cs @@ -5,6 +5,8 @@ using System.Threading; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using TelegramPanel.Core.Interfaces; +using TelegramPanel.Core.Models; +using TL; using WTelegram; namespace TelegramPanel.Core.Services.Telegram; @@ -19,13 +21,19 @@ public class TelegramClientPool : ITelegramClientPool, IDisposable private readonly ConcurrentDictionary _locks = new(); private readonly IConfiguration _configuration; private readonly ILogger _logger; + private readonly TelegramAccountUpdateHub _updateHub; private bool _disposed; private static int _wtelegramLogConfigured; + private readonly ConcurrentDictionary _updateManagers = new(); - public TelegramClientPool(IConfiguration configuration, ILogger logger) + public TelegramClientPool( + IConfiguration configuration, + ILogger logger, + TelegramAccountUpdateHub updateHub) { _configuration = configuration; _logger = logger; + _updateHub = updateHub; ConfigureWTelegramLoggingOnce(); } @@ -99,6 +107,10 @@ public class TelegramClientPool : ITelegramClientPool, IDisposable var client = new Client(Config); TryRebindApiIdForLoadedSession(client, apiId); + UpdateManager? updateManager = null; + updateManager = client.WithUpdateManager(update => HandleTelegramUpdateAsync(accountId, updateManager!, update)); + _updateManagers[accountId] = updateManager; + // 设置日志回调 client.OnOther += (update) => { @@ -149,6 +161,7 @@ public class TelegramClientPool : ITelegramClientPool, IDisposable if (_clients.TryRemove(accountId, out var client)) { _logger.LogInformation("Removing Telegram client for account {AccountId}", accountId); + _updateManagers.TryRemove(accountId, out _); try { @@ -184,6 +197,7 @@ public class TelegramClientPool : ITelegramClientPool, IDisposable } _clients.Clear(); + _updateManagers.Clear(); foreach (var lockObj in _locks.Values) { @@ -285,4 +299,89 @@ public class TelegramClientPool : ITelegramClientPool, IDisposable _logger.LogDebug("WTelegram({Level}): {Message}", level, message); }; } + + private async Task HandleTelegramUpdateAsync(int accountId, UpdateManager updateManager, Update update) + { + switch (update) + { + case UpdateNewChannelMessage { message: Message message }: + await PublishMessageUpdateAsync(accountId, updateManager, message, isEdited: false); + return; + case UpdateNewMessage { message: Message message }: + await PublishMessageUpdateAsync(accountId, updateManager, message, isEdited: false); + return; + case UpdateEditChannelMessage { message: Message message }: + await PublishMessageUpdateAsync(accountId, updateManager, message, isEdited: true); + return; + case UpdateEditMessage { message: Message message }: + await PublishMessageUpdateAsync(accountId, updateManager, message, isEdited: true); + return; + } + } + + private async Task PublishMessageUpdateAsync(int accountId, UpdateManager updateManager, Message message, bool isEdited) + { + try + { + var senderUserId = (message.from_id as PeerUser)?.user_id; + User? sender = null; + if (senderUserId.HasValue + && updateManager.Users.TryGetValue(senderUserId.Value, out var userBase) + && userBase is User user) + { + sender = user; + } + + var buttons = ExtractInlineButtons(message.reply_markup); + var reply = message.reply_to as MessageReplyHeader; + var update = new TelegramAccountMessageUpdate( + AccountId: accountId, + ReceivedAtUtc: DateTimeOffset.UtcNow, + IsEdited: isEdited, + Message: message, + SenderUserId: senderUserId, + SenderUsername: sender?.username, + SenderIsBot: sender?.IsBot == true, + ReplyToMessageId: reply?.reply_to_msg_id, + ThreadId: reply?.reply_to_top_id, + Buttons: buttons); + + await _updateHub.PublishAsync(update); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to publish Telegram message update (accountId={AccountId}, messageId={MessageId})", accountId, message.id); + } + } + + private static IReadOnlyList ExtractInlineButtons(ReplyMarkup? replyMarkup) + { + if (replyMarkup is not ReplyInlineMarkup inlineMarkup || inlineMarkup.rows == null || inlineMarkup.rows.Length == 0) + return Array.Empty(); + + var result = new List(); + var index = 0; + for (var rowIndex = 0; rowIndex < inlineMarkup.rows.Length; rowIndex++) + { + var row = inlineMarkup.rows[rowIndex]; + var buttons = row?.buttons; + if (buttons == null || buttons.Length == 0) + continue; + + for (var columnIndex = 0; columnIndex < buttons.Length; columnIndex++) + { + if (buttons[columnIndex] is not KeyboardButtonCallback callback) + continue; + + var text = (callback.text ?? string.Empty).Trim(); + if (text.Length == 0 || callback.data == null || callback.data.Length == 0) + continue; + + result.Add(new TelegramInlineButton(index, rowIndex, columnIndex, text, callback.data)); + index++; + } + } + + return result; + } } diff --git a/src/TelegramPanel.Core/Services/Telegram/TelegramImageProcessor.cs b/src/TelegramPanel.Core/Services/Telegram/TelegramImageProcessor.cs new file mode 100644 index 0000000..ad30c09 --- /dev/null +++ b/src/TelegramPanel.Core/Services/Telegram/TelegramImageProcessor.cs @@ -0,0 +1,73 @@ +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Jpeg; +using SixLabors.ImageSharp.Processing; + +namespace TelegramPanel.Core.Services.Telegram; + +/// +/// Telegram 图片处理工具。 +/// +public static class TelegramImageProcessor +{ + public static async Task PrepareAvatarJpegAsync(Stream fileStream, CancellationToken cancellationToken = default) + { + if (fileStream == null) + throw new ArgumentNullException(nameof(fileStream)); + + await using var raw = new MemoryStream(); + if (fileStream.CanSeek) + fileStream.Position = 0; + + await fileStream.CopyToAsync(raw, cancellationToken); + raw.Position = 0; + + using var image = await Image.LoadAsync(raw, cancellationToken); + image.Mutate(x => x.AutoOrient()); + image.Mutate(x => x.Resize(new ResizeOptions + { + Mode = ResizeMode.Crop, + Size = new Size(512, 512) + })); + + var encoded = new MemoryStream(); + await image.SaveAsJpegAsync(encoded, new JpegEncoder { Quality = 85 }, cancellationToken); + encoded.Position = 0; + return encoded; + } + + public static async Task PrepareStoredImageJpegAsync( + Stream fileStream, + int maxDimension = 2048, + CancellationToken cancellationToken = default) + { + if (fileStream == null) + throw new ArgumentNullException(nameof(fileStream)); + + if (maxDimension < 256) + maxDimension = 256; + + await using var raw = new MemoryStream(); + if (fileStream.CanSeek) + fileStream.Position = 0; + + await fileStream.CopyToAsync(raw, cancellationToken); + raw.Position = 0; + + using var image = await Image.LoadAsync(raw, cancellationToken); + image.Mutate(x => x.AutoOrient()); + + if (image.Width > maxDimension || image.Height > maxDimension) + { + image.Mutate(x => x.Resize(new ResizeOptions + { + Mode = ResizeMode.Max, + Size = new Size(maxDimension, maxDimension) + })); + } + + var encoded = new MemoryStream(); + await image.SaveAsJpegAsync(encoded, new JpegEncoder { Quality = 88 }, cancellationToken); + encoded.Position = 0; + return encoded; + } +} diff --git a/src/TelegramPanel.Data/AppDbContext.cs b/src/TelegramPanel.Data/AppDbContext.cs index 0a3ab3b..de95058 100644 --- a/src/TelegramPanel.Data/AppDbContext.cs +++ b/src/TelegramPanel.Data/AppDbContext.cs @@ -22,6 +22,9 @@ public class AppDbContext : DbContext public DbSet Groups => Set(); public DbSet GroupCategories => Set(); public DbSet BatchTasks => Set(); + public DbSet ScheduledTasks => Set(); + public DbSet DataDictionaries => Set(); + public DbSet DataDictionaryItems => Set(); public DbSet Bots => Set(); public DbSet BotChannels => Set(); public DbSet BotChannelCategories => Set(); @@ -72,6 +75,7 @@ public class AppDbContext : DbContext entity.Property(e => e.Title).IsRequired().HasMaxLength(200); entity.Property(e => e.Username).HasMaxLength(100); entity.Property(e => e.About).HasMaxLength(1000); + entity.Property(e => e.SystemCreatedAtUtc); entity.HasIndex(e => e.TelegramId).IsUnique(); entity.HasIndex(e => e.Username); @@ -126,6 +130,7 @@ public class AppDbContext : DbContext entity.Property(e => e.Title).IsRequired().HasMaxLength(200); entity.Property(e => e.Username).HasMaxLength(100); entity.Property(e => e.About).HasMaxLength(1000); + entity.Property(e => e.SystemCreatedAtUtc); entity.HasIndex(e => e.TelegramId).IsUnique(); entity.HasIndex(e => e.Username); @@ -184,6 +189,52 @@ public class AppDbContext : DbContext entity.HasIndex(e => e.CreatedAt); }); + // ScheduledTask配置 + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.TaskType).IsRequired().HasMaxLength(100); + entity.Property(e => e.Status).IsRequired().HasMaxLength(20); + entity.Property(e => e.CronExpression).IsRequired().HasMaxLength(100); + entity.Property(e => e.OwnedAssetScopeId).HasMaxLength(120); + + entity.HasIndex(e => e.Status); + entity.HasIndex(e => e.NextRunAtUtc); + entity.HasIndex(e => e.CreatedAt); + }); + + // DataDictionary配置 + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Name).IsRequired().HasMaxLength(100); + entity.Property(e => e.DisplayName).IsRequired().HasMaxLength(100); + entity.Property(e => e.Description).HasMaxLength(500); + entity.Property(e => e.Type).IsRequired().HasMaxLength(20); + entity.Property(e => e.ReadMode).IsRequired().HasMaxLength(20); + + entity.HasIndex(e => e.Name).IsUnique(); + entity.HasIndex(e => e.Type); + entity.HasIndex(e => e.IsEnabled); + }); + + // DataDictionaryItem配置 + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.TextValue).HasMaxLength(4000); + entity.Property(e => e.AssetPath).HasMaxLength(500); + entity.Property(e => e.FileName).HasMaxLength(255); + + entity.HasIndex(e => e.DataDictionaryId); + entity.HasIndex(e => new { e.DataDictionaryId, e.SortOrder }); + + entity.HasOne(e => e.Dictionary) + .WithMany(d => d.Items) + .HasForeignKey(e => e.DataDictionaryId) + .OnDelete(DeleteBehavior.Cascade); + }); + // Bot配置 modelBuilder.Entity(entity => { diff --git a/src/TelegramPanel.Data/Entities/Channel.cs b/src/TelegramPanel.Data/Entities/Channel.cs index d76deda..88b9439 100644 --- a/src/TelegramPanel.Data/Entities/Channel.cs +++ b/src/TelegramPanel.Data/Entities/Channel.cs @@ -16,6 +16,7 @@ public class Channel public int? CreatorAccountId { get; set; } public int? GroupId { get; set; } public DateTime? CreatedAt { get; set; } + public DateTime? SystemCreatedAtUtc { get; set; } public DateTime SyncedAt { get; set; } = DateTime.UtcNow; // 导航属性 diff --git a/src/TelegramPanel.Data/Entities/DataDictionary.cs b/src/TelegramPanel.Data/Entities/DataDictionary.cs new file mode 100644 index 0000000..3daa57e --- /dev/null +++ b/src/TelegramPanel.Data/Entities/DataDictionary.cs @@ -0,0 +1,38 @@ +namespace TelegramPanel.Data.Entities; + +/// +/// 数据字典类型常量。 +/// +public static class DataDictionaryTypes +{ + public const string Text = "text"; + public const string Image = "image"; +} + +/// +/// 数据字典读取模式常量。 +/// +public static class DataDictionaryReadModes +{ + public const string Random = "random"; + public const string Queue = "queue"; +} + +/// +/// 数据字典。 +/// +public class DataDictionary +{ + public int Id { get; set; } + public string Name { get; set; } = null!; + public string DisplayName { get; set; } = null!; + public string? Description { get; set; } + public string Type { get; set; } = DataDictionaryTypes.Text; + public string ReadMode { get; set; } = DataDictionaryReadModes.Random; + public bool IsEnabled { get; set; } = true; + public int NextIndex { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + public ICollection Items { get; set; } = new List(); +} diff --git a/src/TelegramPanel.Data/Entities/DataDictionaryItem.cs b/src/TelegramPanel.Data/Entities/DataDictionaryItem.cs new file mode 100644 index 0000000..9ab6045 --- /dev/null +++ b/src/TelegramPanel.Data/Entities/DataDictionaryItem.cs @@ -0,0 +1,18 @@ +namespace TelegramPanel.Data.Entities; + +/// +/// 数据字典项。 +/// +public class DataDictionaryItem +{ + public int Id { get; set; } + public int DataDictionaryId { get; set; } + public string? TextValue { get; set; } + public string? AssetPath { get; set; } + public string? FileName { get; set; } + public int SortOrder { get; set; } + public bool IsEnabled { get; set; } = true; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DataDictionary Dictionary { get; set; } = null!; +} diff --git a/src/TelegramPanel.Data/Entities/Group.cs b/src/TelegramPanel.Data/Entities/Group.cs index e34eb8e..a78c5f1 100644 --- a/src/TelegramPanel.Data/Entities/Group.cs +++ b/src/TelegramPanel.Data/Entities/Group.cs @@ -15,6 +15,7 @@ public class Group public int? CreatorAccountId { get; set; } public int? CategoryId { get; set; } public DateTime? CreatedAt { get; set; } + public DateTime? SystemCreatedAtUtc { get; set; } public DateTime SyncedAt { get; set; } = DateTime.UtcNow; // 导航属性 diff --git a/src/TelegramPanel.Data/Entities/ScheduledTask.cs b/src/TelegramPanel.Data/Entities/ScheduledTask.cs new file mode 100644 index 0000000..f344122 --- /dev/null +++ b/src/TelegramPanel.Data/Entities/ScheduledTask.cs @@ -0,0 +1,29 @@ +namespace TelegramPanel.Data.Entities; + +/// +/// 计划任务状态常量。 +/// +public static class ScheduledTaskStatuses +{ + public const string Enabled = "enabled"; + public const string Paused = "paused"; +} + +/// +/// Cron 计划任务定义。 +/// +public class ScheduledTask +{ + public int Id { get; set; } + public string TaskType { get; set; } = null!; + public string Status { get; set; } = ScheduledTaskStatuses.Enabled; + public int Total { get; set; } + public string? ConfigJson { get; set; } + public string CronExpression { get; set; } = null!; + public DateTime? NextRunAtUtc { get; set; } + public DateTime? LastRunAtUtc { get; set; } + public int? LastBatchTaskId { get; set; } + public string? OwnedAssetScopeId { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/TelegramPanel.Data/Migrations/20260309132915_AddScheduledTasksAndDataDictionaries.Designer.cs b/src/TelegramPanel.Data/Migrations/20260309132915_AddScheduledTasksAndDataDictionaries.Designer.cs new file mode 100644 index 0000000..32d0188 --- /dev/null +++ b/src/TelegramPanel.Data/Migrations/20260309132915_AddScheduledTasksAndDataDictionaries.Designer.cs @@ -0,0 +1,917 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TelegramPanel.Data; + +#nullable disable + +namespace TelegramPanel.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260309132915_AddScheduledTasksAndDataDictionaries")] + partial class AddScheduledTasksAndDataDictionaries + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.24"); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ApiId") + .HasColumnType("INTEGER"); + + b.Property("CategoryId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EstimatedRegistrationAt") + .HasColumnType("TEXT"); + + b.Property("EstimatedRegistrationCheckedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncAt") + .HasColumnType("TEXT"); + + b.Property("Nickname") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("SessionPath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("TelegramStatusCheckedAtUtc") + .HasColumnType("TEXT"); + + b.Property("TelegramStatusDetails") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("TelegramStatusOk") + .HasColumnType("INTEGER"); + + b.Property("TelegramStatusSummary") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TwoFactorPassword") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("Phone") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.AccountCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Color") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ExcludeFromOperations") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("AccountCategories"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.AccountChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountId") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsCreator") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("AccountId", "ChannelId") + .IsUnique(); + + b.HasIndex("ChannelId", "IsCreator", "IsAdmin"); + + b.ToTable("AccountChannels"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.AccountGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountId") + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsCreator") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("AccountId", "GroupId") + .IsUnique(); + + b.HasIndex("GroupId", "IsCreator", "IsAdmin"); + + b.ToTable("AccountGroups"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BatchTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Completed") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("Config") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Failed") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TaskType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Total") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("BatchTasks"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Bot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastSyncAt") + .HasColumnType("TEXT"); + + b.Property("LastUpdateId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Username"); + + b.ToTable("Bots"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("About") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("AccessHash") + .HasColumnType("INTEGER"); + + b.Property("CategoryId") + .HasColumnType("INTEGER"); + + b.Property("ChannelStatusCheckedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ChannelStatusError") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ChannelStatusOk") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsBroadcast") + .HasColumnType("INTEGER"); + + b.Property("MemberCount") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.Property("TelegramId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("ChannelStatusOk"); + + b.HasIndex("TelegramId") + .IsUnique(); + + b.HasIndex("Username"); + + b.ToTable("BotChannels"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("BotChannelCategories"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BotChannelId") + .HasColumnType("INTEGER"); + + b.Property("BotId") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BotChannelId"); + + b.HasIndex("BotId"); + + b.HasIndex("BotId", "BotChannelId") + .IsUnique(); + + b.ToTable("BotChannelMembers"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("About") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("AccessHash") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorAccountId") + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("IsBroadcast") + .HasColumnType("INTEGER"); + + b.Property("MemberCount") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.Property("SystemCreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("TelegramId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorAccountId"); + + b.HasIndex("GroupId"); + + b.HasIndex("SyncedAt"); + + b.HasIndex("TelegramId") + .IsUnique(); + + b.HasIndex("Username"); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.ChannelGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ChannelGroups"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.DataDictionary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NextIndex") + .HasColumnType("INTEGER"); + + b.Property("ReadMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsEnabled"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Type"); + + b.ToTable("DataDictionaries"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.DataDictionaryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssetPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DataDictionaryId") + .HasColumnType("INTEGER"); + + b.Property("FileName") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("TextValue") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DataDictionaryId"); + + b.HasIndex("DataDictionaryId", "SortOrder"); + + b.ToTable("DataDictionaryItems"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("About") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("AccessHash") + .HasColumnType("INTEGER"); + + b.Property("CategoryId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorAccountId") + .HasColumnType("INTEGER"); + + b.Property("MemberCount") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.Property("SystemCreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("TelegramId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("CreatorAccountId"); + + b.HasIndex("SyncedAt"); + + b.HasIndex("TelegramId") + .IsUnique(); + + b.HasIndex("Username"); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.GroupCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("GroupCategories"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.ScheduledTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConfigJson") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CronExpression") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastBatchTaskId") + .HasColumnType("INTEGER"); + + b.Property("LastRunAtUtc") + .HasColumnType("TEXT"); + + b.Property("NextRunAtUtc") + .HasColumnType("TEXT"); + + b.Property("OwnedAssetScopeId") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TaskType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Total") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("NextRunAtUtc"); + + b.HasIndex("Status"); + + b.ToTable("ScheduledTasks"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b => + { + b.HasOne("TelegramPanel.Data.Entities.AccountCategory", "Category") + .WithMany("Accounts") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.AccountChannel", b => + { + b.HasOne("TelegramPanel.Data.Entities.Account", "Account") + .WithMany("AccountChannels") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TelegramPanel.Data.Entities.Channel", "Channel") + .WithMany("AccountChannels") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.AccountGroup", b => + { + b.HasOne("TelegramPanel.Data.Entities.Account", "Account") + .WithMany("AccountGroups") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TelegramPanel.Data.Entities.Group", "Group") + .WithMany("AccountGroups") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b => + { + b.HasOne("TelegramPanel.Data.Entities.BotChannelCategory", "Category") + .WithMany("Channels") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelMember", b => + { + b.HasOne("TelegramPanel.Data.Entities.BotChannel", "BotChannel") + .WithMany("Members") + .HasForeignKey("BotChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TelegramPanel.Data.Entities.Bot", "Bot") + .WithMany("ChannelMembers") + .HasForeignKey("BotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Bot"); + + b.Navigation("BotChannel"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b => + { + b.HasOne("TelegramPanel.Data.Entities.Account", "CreatorAccount") + .WithMany("Channels") + .HasForeignKey("CreatorAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("TelegramPanel.Data.Entities.ChannelGroup", "Group") + .WithMany("Channels") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatorAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.DataDictionaryItem", b => + { + b.HasOne("TelegramPanel.Data.Entities.DataDictionary", "Dictionary") + .WithMany("Items") + .HasForeignKey("DataDictionaryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Dictionary"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b => + { + b.HasOne("TelegramPanel.Data.Entities.GroupCategory", "Category") + .WithMany("Groups") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("TelegramPanel.Data.Entities.Account", "CreatorAccount") + .WithMany("Groups") + .HasForeignKey("CreatorAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Category"); + + b.Navigation("CreatorAccount"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b => + { + b.Navigation("AccountChannels"); + + b.Navigation("AccountGroups"); + + b.Navigation("Channels"); + + b.Navigation("Groups"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.AccountCategory", b => + { + b.Navigation("Accounts"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Bot", b => + { + b.Navigation("ChannelMembers"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelCategory", b => + { + b.Navigation("Channels"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b => + { + b.Navigation("AccountChannels"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.ChannelGroup", b => + { + b.Navigation("Channels"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.DataDictionary", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b => + { + b.Navigation("AccountGroups"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.GroupCategory", b => + { + b.Navigation("Groups"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/TelegramPanel.Data/Migrations/20260309132915_AddScheduledTasksAndDataDictionaries.cs b/src/TelegramPanel.Data/Migrations/20260309132915_AddScheduledTasksAndDataDictionaries.cs new file mode 100644 index 0000000..91c5481 --- /dev/null +++ b/src/TelegramPanel.Data/Migrations/20260309132915_AddScheduledTasksAndDataDictionaries.cs @@ -0,0 +1,158 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TelegramPanel.Data.Migrations +{ + /// + public partial class AddScheduledTasksAndDataDictionaries : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SystemCreatedAtUtc", + table: "Groups", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "SystemCreatedAtUtc", + table: "Channels", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateTable( + name: "DataDictionaries", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", maxLength: 100, nullable: false), + DisplayName = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Description = table.Column(type: "TEXT", maxLength: 500, nullable: true), + Type = table.Column(type: "TEXT", maxLength: 20, nullable: false), + ReadMode = table.Column(type: "TEXT", maxLength: 20, nullable: false), + IsEnabled = table.Column(type: "INTEGER", nullable: false), + NextIndex = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DataDictionaries", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ScheduledTasks", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + TaskType = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Status = table.Column(type: "TEXT", maxLength: 20, nullable: false), + Total = table.Column(type: "INTEGER", nullable: false), + ConfigJson = table.Column(type: "TEXT", nullable: true), + CronExpression = table.Column(type: "TEXT", maxLength: 100, nullable: false), + NextRunAtUtc = table.Column(type: "TEXT", nullable: true), + LastRunAtUtc = table.Column(type: "TEXT", nullable: true), + LastBatchTaskId = table.Column(type: "INTEGER", nullable: true), + OwnedAssetScopeId = table.Column(type: "TEXT", maxLength: 120, nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ScheduledTasks", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "DataDictionaryItems", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + DataDictionaryId = table.Column(type: "INTEGER", nullable: false), + TextValue = table.Column(type: "TEXT", maxLength: 4000, nullable: true), + AssetPath = table.Column(type: "TEXT", maxLength: 500, nullable: true), + FileName = table.Column(type: "TEXT", maxLength: 255, nullable: true), + SortOrder = table.Column(type: "INTEGER", nullable: false), + IsEnabled = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DataDictionaryItems", x => x.Id); + table.ForeignKey( + name: "FK_DataDictionaryItems_DataDictionaries_DataDictionaryId", + column: x => x.DataDictionaryId, + principalTable: "DataDictionaries", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_DataDictionaries_IsEnabled", + table: "DataDictionaries", + column: "IsEnabled"); + + migrationBuilder.CreateIndex( + name: "IX_DataDictionaries_Name", + table: "DataDictionaries", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_DataDictionaries_Type", + table: "DataDictionaries", + column: "Type"); + + migrationBuilder.CreateIndex( + name: "IX_DataDictionaryItems_DataDictionaryId", + table: "DataDictionaryItems", + column: "DataDictionaryId"); + + migrationBuilder.CreateIndex( + name: "IX_DataDictionaryItems_DataDictionaryId_SortOrder", + table: "DataDictionaryItems", + columns: new[] { "DataDictionaryId", "SortOrder" }); + + migrationBuilder.CreateIndex( + name: "IX_ScheduledTasks_CreatedAt", + table: "ScheduledTasks", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_ScheduledTasks_NextRunAtUtc", + table: "ScheduledTasks", + column: "NextRunAtUtc"); + + migrationBuilder.CreateIndex( + name: "IX_ScheduledTasks_Status", + table: "ScheduledTasks", + column: "Status"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DataDictionaryItems"); + + migrationBuilder.DropTable( + name: "ScheduledTasks"); + + migrationBuilder.DropTable( + name: "DataDictionaries"); + + migrationBuilder.DropColumn( + name: "SystemCreatedAtUtc", + table: "Groups"); + + migrationBuilder.DropColumn( + name: "SystemCreatedAtUtc", + table: "Channels"); + } + } +} diff --git a/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs b/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs index 826e1c4..36e4691 100644 --- a/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs @@ -439,6 +439,9 @@ namespace TelegramPanel.Data.Migrations b.Property("SyncedAt") .HasColumnType("TEXT"); + b.Property("SystemCreatedAtUtc") + .HasColumnType("TEXT"); + b.Property("TelegramId") .HasColumnType("INTEGER"); @@ -493,6 +496,99 @@ namespace TelegramPanel.Data.Migrations b.ToTable("ChannelGroups"); }); + modelBuilder.Entity("TelegramPanel.Data.Entities.DataDictionary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NextIndex") + .HasColumnType("INTEGER"); + + b.Property("ReadMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsEnabled"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Type"); + + b.ToTable("DataDictionaries"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.DataDictionaryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssetPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DataDictionaryId") + .HasColumnType("INTEGER"); + + b.Property("FileName") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("TextValue") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DataDictionaryId"); + + b.HasIndex("DataDictionaryId", "SortOrder"); + + b.ToTable("DataDictionaryItems"); + }); + modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b => { b.Property("Id") @@ -521,6 +617,9 @@ namespace TelegramPanel.Data.Migrations b.Property("SyncedAt") .HasColumnType("TEXT"); + b.Property("SystemCreatedAtUtc") + .HasColumnType("TEXT"); + b.Property("TelegramId") .HasColumnType("INTEGER"); @@ -575,6 +674,63 @@ namespace TelegramPanel.Data.Migrations b.ToTable("GroupCategories"); }); + modelBuilder.Entity("TelegramPanel.Data.Entities.ScheduledTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConfigJson") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CronExpression") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastBatchTaskId") + .HasColumnType("INTEGER"); + + b.Property("LastRunAtUtc") + .HasColumnType("TEXT"); + + b.Property("NextRunAtUtc") + .HasColumnType("TEXT"); + + b.Property("OwnedAssetScopeId") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TaskType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Total") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("NextRunAtUtc"); + + b.HasIndex("Status"); + + b.ToTable("ScheduledTasks"); + }); + modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b => { b.HasOne("TelegramPanel.Data.Entities.AccountCategory", "Category") @@ -669,6 +825,17 @@ namespace TelegramPanel.Data.Migrations b.Navigation("Group"); }); + modelBuilder.Entity("TelegramPanel.Data.Entities.DataDictionaryItem", b => + { + b.HasOne("TelegramPanel.Data.Entities.DataDictionary", "Dictionary") + .WithMany("Items") + .HasForeignKey("DataDictionaryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Dictionary"); + }); + modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b => { b.HasOne("TelegramPanel.Data.Entities.GroupCategory", "Category") @@ -727,6 +894,11 @@ namespace TelegramPanel.Data.Migrations b.Navigation("Channels"); }); + modelBuilder.Entity("TelegramPanel.Data.Entities.DataDictionary", b => + { + b.Navigation("Items"); + }); + modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b => { b.Navigation("AccountGroups"); diff --git a/src/TelegramPanel.Data/Repositories/BatchTaskRepository.cs b/src/TelegramPanel.Data/Repositories/BatchTaskRepository.cs index e33169c..839841a 100644 --- a/src/TelegramPanel.Data/Repositories/BatchTaskRepository.cs +++ b/src/TelegramPanel.Data/Repositories/BatchTaskRepository.cs @@ -12,6 +12,19 @@ public class BatchTaskRepository : Repository, IBatchTaskRepository { } + public async Task GetFreshByIdAsync(int id) + { + DetachTrackedEntity(id); + return await _dbSet.AsNoTracking().FirstOrDefaultAsync(t => t.Id == id); + } + + public async Task UpdateFreshAsync(BatchTask entity) + { + DetachTrackedEntity(entity.Id); + _dbSet.Update(entity); + await SaveChangesWithSqliteLockRetryAsync(); + } + public async Task> GetByStatusAsync(string status) { return await _dbSet @@ -55,4 +68,13 @@ public class BatchTaskRepository : Repository, IBatchTaskRepository await SaveChangesWithSqliteLockRetryAsync(cancellationToken); return staleTasks.Count; } + + private void DetachTrackedEntity(int id) + { + foreach (var entry in _context.ChangeTracker.Entries()) + { + if (entry.Entity.Id == id) + entry.State = EntityState.Detached; + } + } } diff --git a/src/TelegramPanel.Data/Repositories/DataDictionaryItemRepository.cs b/src/TelegramPanel.Data/Repositories/DataDictionaryItemRepository.cs new file mode 100644 index 0000000..afb8774 --- /dev/null +++ b/src/TelegramPanel.Data/Repositories/DataDictionaryItemRepository.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore; +using TelegramPanel.Data.Entities; + +namespace TelegramPanel.Data.Repositories; + +/// +/// 数据字典项仓储实现。 +/// +public sealed class DataDictionaryItemRepository : Repository, IDataDictionaryItemRepository +{ + public DataDictionaryItemRepository(AppDbContext context) : base(context) + { + } + + public async Task> GetByDictionaryIdAsync(int dictionaryId, CancellationToken cancellationToken = default) + { + return await _dbSet + .Where(x => x.DataDictionaryId == dictionaryId) + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.Id) + .ToListAsync(cancellationToken); + } + + public async Task DeleteByDictionaryIdAsync(int dictionaryId, CancellationToken cancellationToken = default) + { + var items = await _dbSet + .Where(x => x.DataDictionaryId == dictionaryId) + .ToListAsync(cancellationToken); + + if (items.Count == 0) + return; + + _dbSet.RemoveRange(items); + await SaveChangesWithSqliteLockRetryAsync(cancellationToken); + } +} diff --git a/src/TelegramPanel.Data/Repositories/DataDictionaryRepository.cs b/src/TelegramPanel.Data/Repositories/DataDictionaryRepository.cs new file mode 100644 index 0000000..df8a1b0 --- /dev/null +++ b/src/TelegramPanel.Data/Repositories/DataDictionaryRepository.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore; +using TelegramPanel.Data.Entities; + +namespace TelegramPanel.Data.Repositories; + +/// +/// 数据字典仓储实现。 +/// +public sealed class DataDictionaryRepository : Repository, IDataDictionaryRepository +{ + public DataDictionaryRepository(AppDbContext context) : base(context) + { + } + + public override async Task GetByIdAsync(int id) + { + return await GetWithItemsAsync(id); + } + + public override async Task> GetAllAsync() + { + return await GetAllWithItemsAsync(); + } + + public async Task GetByNameAsync(string name, CancellationToken cancellationToken = default) + { + name = (name ?? string.Empty).Trim(); + if (name.Length == 0) + return null; + + return await _dbSet + .Include(x => x.Items.OrderBy(i => i.SortOrder).ThenBy(i => i.Id)) + .AsSplitQuery() + .FirstOrDefaultAsync(x => x.Name == name, cancellationToken); + } + + public async Task GetWithItemsAsync(int id, CancellationToken cancellationToken = default) + { + return await _dbSet + .Include(x => x.Items.OrderBy(i => i.SortOrder).ThenBy(i => i.Id)) + .AsSplitQuery() + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + } + + public async Task> GetAllWithItemsAsync(CancellationToken cancellationToken = default) + { + return await _dbSet + .Include(x => x.Items.OrderBy(i => i.SortOrder).ThenBy(i => i.Id)) + .AsSplitQuery() + .OrderByDescending(x => x.CreatedAt) + .ThenByDescending(x => x.Id) + .ToListAsync(cancellationToken); + } +} diff --git a/src/TelegramPanel.Data/Repositories/IBatchTaskRepository.cs b/src/TelegramPanel.Data/Repositories/IBatchTaskRepository.cs index 68c6a5a..0a3f557 100644 --- a/src/TelegramPanel.Data/Repositories/IBatchTaskRepository.cs +++ b/src/TelegramPanel.Data/Repositories/IBatchTaskRepository.cs @@ -7,6 +7,8 @@ namespace TelegramPanel.Data.Repositories; /// public interface IBatchTaskRepository : IRepository { + Task GetFreshByIdAsync(int id); + Task UpdateFreshAsync(BatchTask entity); Task> GetByStatusAsync(string status); Task> GetRunningTasksAsync(); Task> GetRecentTasksAsync(int count = 20); diff --git a/src/TelegramPanel.Data/Repositories/IDataDictionaryItemRepository.cs b/src/TelegramPanel.Data/Repositories/IDataDictionaryItemRepository.cs new file mode 100644 index 0000000..b6a62d5 --- /dev/null +++ b/src/TelegramPanel.Data/Repositories/IDataDictionaryItemRepository.cs @@ -0,0 +1,12 @@ +using TelegramPanel.Data.Entities; + +namespace TelegramPanel.Data.Repositories; + +/// +/// 数据字典项仓储接口。 +/// +public interface IDataDictionaryItemRepository : IRepository +{ + Task> GetByDictionaryIdAsync(int dictionaryId, CancellationToken cancellationToken = default); + Task DeleteByDictionaryIdAsync(int dictionaryId, CancellationToken cancellationToken = default); +} diff --git a/src/TelegramPanel.Data/Repositories/IDataDictionaryRepository.cs b/src/TelegramPanel.Data/Repositories/IDataDictionaryRepository.cs new file mode 100644 index 0000000..c4da936 --- /dev/null +++ b/src/TelegramPanel.Data/Repositories/IDataDictionaryRepository.cs @@ -0,0 +1,13 @@ +using TelegramPanel.Data.Entities; + +namespace TelegramPanel.Data.Repositories; + +/// +/// 数据字典仓储接口。 +/// +public interface IDataDictionaryRepository : IRepository +{ + Task GetByNameAsync(string name, CancellationToken cancellationToken = default); + Task GetWithItemsAsync(int id, CancellationToken cancellationToken = default); + Task> GetAllWithItemsAsync(CancellationToken cancellationToken = default); +} diff --git a/src/TelegramPanel.Data/Repositories/IScheduledTaskRepository.cs b/src/TelegramPanel.Data/Repositories/IScheduledTaskRepository.cs new file mode 100644 index 0000000..5216136 --- /dev/null +++ b/src/TelegramPanel.Data/Repositories/IScheduledTaskRepository.cs @@ -0,0 +1,12 @@ +using TelegramPanel.Data.Entities; + +namespace TelegramPanel.Data.Repositories; + +/// +/// 计划任务仓储接口。 +/// +public interface IScheduledTaskRepository : IRepository +{ + Task> GetAllOrderedAsync(CancellationToken cancellationToken = default); + Task> GetEnabledAsync(CancellationToken cancellationToken = default); +} diff --git a/src/TelegramPanel.Data/Repositories/ScheduledTaskRepository.cs b/src/TelegramPanel.Data/Repositories/ScheduledTaskRepository.cs new file mode 100644 index 0000000..7e083bd --- /dev/null +++ b/src/TelegramPanel.Data/Repositories/ScheduledTaskRepository.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using TelegramPanel.Data.Entities; + +namespace TelegramPanel.Data.Repositories; + +/// +/// 计划任务仓储实现。 +/// +public sealed class ScheduledTaskRepository : Repository, IScheduledTaskRepository +{ + public ScheduledTaskRepository(AppDbContext context) : base(context) + { + } + + public async Task> GetAllOrderedAsync(CancellationToken cancellationToken = default) + { + return await _dbSet + .OrderByDescending(x => x.CreatedAt) + .ThenByDescending(x => x.Id) + .ToListAsync(cancellationToken); + } + + public async Task> GetEnabledAsync(CancellationToken cancellationToken = default) + { + return await _dbSet + .Where(x => x.Status == ScheduledTaskStatuses.Enabled) + .OrderBy(x => x.NextRunAtUtc ?? DateTime.MaxValue) + .ThenBy(x => x.Id) + .ToListAsync(cancellationToken); + } +} diff --git a/src/TelegramPanel.Data/ServiceCollectionExtensions.cs b/src/TelegramPanel.Data/ServiceCollectionExtensions.cs index 74c112e..41a8356 100644 --- a/src/TelegramPanel.Data/ServiceCollectionExtensions.cs +++ b/src/TelegramPanel.Data/ServiceCollectionExtensions.cs @@ -28,6 +28,9 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/TelegramPanel.Modules.Abstractions/AiServices.cs b/src/TelegramPanel.Modules.Abstractions/AiServices.cs new file mode 100644 index 0000000..45c36ea --- /dev/null +++ b/src/TelegramPanel.Modules.Abstractions/AiServices.cs @@ -0,0 +1,52 @@ +namespace TelegramPanel.Modules; + +/// +/// 面板统一 AI 服务接口,供宿主任务与扩展模块复用。 +/// +public interface ITelegramPanelAiService +{ + /// + /// 根据消息文本、按钮列表与可选图片,决定下一步动作。 + /// + Task ChooseActionAsync( + TelegramPanelAiChooseActionRequest request, + CancellationToken cancellationToken = default); + + /// + /// 根据题目或上下文生成要发送的文本答案。 + /// + Task ReplyTextAsync( + TelegramPanelAiReplyTextRequest request, + CancellationToken cancellationToken = default); +} + +public sealed record TelegramPanelAiButtonOption(int Index, string Text); + +public sealed record TelegramPanelAiImageInput(byte[] Content, string MimeType = "image/jpeg"); + +public sealed record TelegramPanelAiChooseActionRequest( + string? Model, + string? MessageText, + IReadOnlyList Buttons, + TelegramPanelAiImageInput? Image, + string? Context = null); + +public sealed record TelegramPanelAiChooseActionResult( + bool Success, + string? Mode, + int? ButtonIndex, + string? ReplyText, + string? Reason, + string? Error); + +public sealed record TelegramPanelAiReplyTextRequest( + string? Model, + string Prompt, + string Query, + TelegramPanelAiImageInput? Image = null, + string? Context = null); + +public sealed record TelegramPanelAiReplyTextResult( + bool Success, + string? ReplyText, + string? Error); diff --git a/src/TelegramPanel.Modules.Abstractions/ModuleContributions.cs b/src/TelegramPanel.Modules.Abstractions/ModuleContributions.cs index bc0c9f7..0ef4331 100644 --- a/src/TelegramPanel.Modules.Abstractions/ModuleContributions.cs +++ b/src/TelegramPanel.Modules.Abstractions/ModuleContributions.cs @@ -1,4 +1,4 @@ -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; namespace TelegramPanel.Modules; @@ -132,7 +132,17 @@ public sealed class ModuleTaskCenterCapabilities public bool AutoPauseBeforeEdit { get; set; } } -public readonly record struct ModuleTaskDraft(int Total, string? Config, bool CanSubmit, string? ValidationError); +public readonly record struct ModuleTaskDraft(int Total, string? Config, bool CanSubmit, string? ValidationError) +{ + [JsonPropertyName("submissionMode")] + public string? SubmissionMode { get; init; } + + [JsonPropertyName("cronExpression")] + public string? CronExpression { get; init; } + + [JsonPropertyName("scheduledStatus")] + public string? ScheduledStatus { get; init; } +} public sealed class ModuleTaskSnapshot { @@ -217,3 +227,5 @@ public interface IModuleTaskExecutionHost Task IsStillRunningAsync(CancellationToken cancellationToken); Task UpdateProgressAsync(int completed, int failed, CancellationToken cancellationToken); } + + diff --git a/src/TelegramPanel.Web/Components/Dialogs/BatchChangeAvatarDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/BatchChangeAvatarDialog.razor new file mode 100644 index 0000000..ae05e07 --- /dev/null +++ b/src/TelegramPanel.Web/Components/Dialogs/BatchChangeAvatarDialog.razor @@ -0,0 +1,316 @@ +@namespace TelegramPanel.Web.Components.Dialogs +@inject AccountManagementService AccountManagement +@inject TelegramPanel.Core.Services.Telegram.AccountTelegramToolsService AccountTelegramTools +@inject TelegramPanel.Core.Services.AccountRiskService RiskService +@inject ImageAssetStorageService AssetStorage +@inject TemplateRenderingService TemplateRendering +@inject DataDictionaryService DictionaryService +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@using Microsoft.AspNetCore.Components.Forms +@using TelegramPanel.Data.Entities + + + + + + 将对选中的账号批量修改头像。支持固定上传单个头像,或选择图片字典变量按字典读取方式为每个账号取图。 + + + 账号数量:@accounts.Count + + + 固定上传 + 图片字典变量 + + + @if (IsFixedSource) + { + + + + 选择头像图片 + + + + + @if (fixedAvatarBytes is { Length: > 0 }) + { + 已选择:@fixedAvatarFileName(@fixedAvatarBytes.Length bytes) + } + } + else + { + + -- 请选择图片字典 -- + @foreach (var item in imageDictionaryOptions) + { + @item.DisplayText + } + + + @if (imageDictionaryOptions.Count > 0) + { + 可用图片变量:@string.Join("、", imageDictionaryOptions.Select(x => $"{{{x.Name}}}")) + } + } + + @if (running || results.Count > 0) + { + + + + 进度:@processed / @accounts.Count + 成功:@success + 失败:@failed + @if (running) + { + + } + + + + + 账号 + 头像来源 + 结果 + 原因 + + + @context.Phone + @context.Source + + + @(context.Success ? "成功" : "失败") + + + @((context.Error ?? "").Trim()) + + + } + + + + 关闭 + + @if (running) + { + + 执行中... + } + else + { + 开始修改 + } + + + + +@code +{ + private const string AvatarSourceFixed = "fixed"; + private const string AvatarSourceDictionary = "dictionary"; + + [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public IReadOnlyList AccountIds { get; set; } = Array.Empty(); + + private List accounts = new(); + private List imageDictionaryOptions = new(); + + private string avatarSource = AvatarSourceFixed; + private string selectedImageDictionaryName = string.Empty; + private byte[]? fixedAvatarBytes; + private string fixedAvatarFileName = "avatar.jpg"; + + private bool running; + private int processed; + private int success; + private int failed; + private readonly List results = new(); + + private bool IsFixedSource => string.Equals(avatarSource, AvatarSourceFixed, StringComparison.OrdinalIgnoreCase); + + protected override async Task OnInitializedAsync() + { + var ids = AccountIds?.Distinct().Where(x => x > 0).ToList() ?? new List(); + if (ids.Count == 0) + return; + + var all = await AccountManagement.GetAllAccountsAsync(); + accounts = all.Where(a => ids.Contains(a.Id)).OrderBy(a => a.Id).ToList(); + + var dictionaries = await DictionaryService.GetAllAsync(); + imageDictionaryOptions = dictionaries + .Where(x => x.IsEnabled && string.Equals(x.Type, DataDictionaryTypes.Image, StringComparison.OrdinalIgnoreCase)) + .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase) + .Select(x => new ImageDictionaryOption(x.Name, GetReadModeText(x.ReadMode))) + .ToList(); + } + + private async Task OnAvatarChanged(IReadOnlyList files) + { + var file = files?.FirstOrDefault(); + if (file == null) + { + fixedAvatarBytes = null; + fixedAvatarFileName = "avatar.jpg"; + return; + } + + try + { + const long maxBytes = 20 * 1024 * 1024; + await using var stream = file.OpenReadStream(maxBytes); + using var memory = new MemoryStream(); + await stream.CopyToAsync(memory); + fixedAvatarBytes = memory.ToArray(); + fixedAvatarFileName = string.IsNullOrWhiteSpace(file.Name) ? "avatar.jpg" : file.Name.Trim(); + avatarSource = AvatarSourceFixed; + } + catch (Exception ex) + { + fixedAvatarBytes = null; + fixedAvatarFileName = "avatar.jpg"; + Snackbar.Add($"读取头像失败:{ex.Message}", Severity.Error); + } + } + + private async Task Start() + { + if (running) + return; + + if (accounts.Count == 0) + { + Snackbar.Add("未选择账号", Severity.Warning); + return; + } + + if (IsFixedSource) + { + if (fixedAvatarBytes is not { Length: > 0 }) + { + Snackbar.Add("请先上传固定头像", Severity.Warning); + return; + } + } + else if (string.IsNullOrWhiteSpace(selectedImageDictionaryName)) + { + Snackbar.Add("请选择图片字典", Severity.Warning); + return; + } + + var batchCheck = RiskService.CheckBatchAccounts(accounts); + if (batchCheck.HasRiskyAccounts) + { + var parameters = new DialogParameters + { + ["Message"] = $"检测到 {batchCheck.RiskyCount} 个风险账号", + ["DetailedMessage"] = batchCheck.GetRiskySummary(), + ["ShowRecommendations"] = true, + ["ShowExcludeOption"] = true, + ["RiskyAccounts"] = batchCheck.RiskyAccounts.ToList() + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Large }; + var dialog = DialogService.Show("批量操作风控警告", parameters, options); + var result = await dialog.Result; + + if (result?.Canceled ?? true) + return; + + if (result.Data is TelegramPanel.Core.Services.RiskWarningAction action && action == TelegramPanel.Core.Services.RiskWarningAction.ExcludeRisky) + { + accounts = batchCheck.SafeAccounts.ToList(); + if (accounts.Count == 0) + { + Snackbar.Add("排除风险账号后无剩余账号", Severity.Info); + return; + } + } + } + + var sourceSummary = IsFixedSource ? "固定头像" : $"图片字典变量 {{{selectedImageDictionaryName}}}"; + bool? confirm = await DialogService.ShowMessageBox( + "确认修改", + $"将对 {accounts.Count} 个账号批量修改头像(来源:{sourceSummary})。是否继续?", + yesText: "继续", cancelText: "取消"); + + if (confirm != true) + return; + + running = true; + processed = 0; + success = 0; + failed = 0; + results.Clear(); + + try + { + foreach (var account in accounts) + { + string source; + bool ok; + string? err; + + try + { + if (IsFixedSource) + { + source = fixedAvatarFileName; + await using var stream = new MemoryStream(fixedAvatarBytes!, writable: false); + (ok, err) = await AccountTelegramTools.UpdateProfilePhotoAsync(account.Id, stream, fixedAvatarFileName, CancellationToken.None); + } + else + { + var token = $"{{{selectedImageDictionaryName}}}"; + var asset = await TemplateRendering.ResolveImageTemplateAsync(token, CancellationToken.None); + source = $"{token} → {asset.FileName}"; + await using var stream = await AssetStorage.OpenReadAsync(asset.AssetPath, CancellationToken.None); + (ok, err) = await AccountTelegramTools.UpdateProfilePhotoAsync(account.Id, stream, asset.FileName, CancellationToken.None); + } + } + catch (Exception ex) + { + source = IsFixedSource ? fixedAvatarFileName : $"{{{selectedImageDictionaryName}}}"; + ok = false; + err = ex.Message; + } + + processed++; + if (ok) + success++; + else + failed++; + + results.Add(new ResultRow(account.DisplayPhone, source, ok, err)); + StateHasChanged(); + } + + Snackbar.Add($"头像批量修改完成:成功 {success}/{accounts.Count},失败 {failed}/{accounts.Count}", failed == 0 ? Severity.Success : Severity.Warning); + } + catch (Exception ex) + { + Snackbar.Add($"执行失败:{ex.Message}", Severity.Error); + } + finally + { + running = false; + } + } + + private static string GetReadModeText(string? readMode) + { + return string.Equals(readMode, DataDictionaryReadModes.Queue, StringComparison.OrdinalIgnoreCase) + ? "轮询" + : "随机"; + } + + private void Cancel() => MudDialog.Close(); + + private sealed record ImageDictionaryOption(string Name, string ReadMode) + { + public string DisplayText => $"{Name}({ReadMode})"; + } + + private sealed record ResultRow(string Phone, string Source, bool Success, string? Error); +} diff --git a/src/TelegramPanel.Web/Components/Dialogs/BatchChangeNicknameDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/BatchChangeNicknameDialog.razor index 947a127..aa60aac 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/BatchChangeNicknameDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/BatchChangeNicknameDialog.razor @@ -2,30 +2,41 @@ @inject AccountManagementService AccountManagement @inject TelegramPanel.Core.Services.Telegram.AccountTelegramToolsService AccountTelegramTools @inject TelegramPanel.Core.Services.AccountRiskService RiskService +@inject TemplateRenderingService TemplateRendering +@inject DataDictionaryService DictionaryService @inject ISnackbar Snackbar @inject IDialogService DialogService +@using System.Text.RegularExpressions @using TelegramPanel.Data.Entities - 将对选中的账号批量修改昵称。支持两种模式:单个“批量昵称”(可自动追加手机号后 4 位),或“昵称列表”(按行轮询分配)。 + 将对选中的账号批量修改昵称。只需要填写“昵称模板”,支持一行一个模板,并支持多个变量组合,例如:{diqu}歌神{geshou}。 账号数量:@accounts.Count - - + - - + - 例如:昵称列表只有 1 行或轮询后重复时,为避免重复昵称可追加手机号后 4 位。 + 当同一批次生成出重复昵称时,自动在后面追加该账号手机号后 4 位。 + @if (textDictionaryNames.Count > 0) + { + 可用文本变量:@string.Join("、", textDictionaryNames.Select(x => $"{{{x}}}")) + } + @if (running || results.Count > 0) { @@ -82,11 +93,13 @@ [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; [Parameter] public IReadOnlyList AccountIds { get; set; } = Array.Empty(); - private List accounts = new(); + private static readonly Regex TemplateTokenRegex = new(@"\{(?[a-zA-Z0-9_]+)\}", RegexOptions.Compiled); - private string baseNickname = ""; - private bool appendPhoneLast4 = true; - private string nicknameLines = ""; + private readonly HashSet supportedTextTokens = new(StringComparer.OrdinalIgnoreCase); + private List accounts = new(); + private List textDictionaryNames = new(); + + private string nicknameTemplatesText = string.Empty; private bool appendPhoneLast4WhenDuplicate = true; private bool running; @@ -103,6 +116,17 @@ var all = await AccountManagement.GetAllAccountsAsync(); accounts = all.Where(a => ids.Contains(a.Id)).OrderBy(a => a.Id).ToList(); + + var dictionaries = await DictionaryService.GetAllAsync(); + textDictionaryNames = dictionaries + .Where(x => x.IsEnabled && string.Equals(x.Type, DataDictionaryTypes.Text, StringComparison.OrdinalIgnoreCase)) + .Select(x => x.Name) + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .ToList(); + + supportedTextTokens.Clear(); + foreach (var item in textDictionaryNames) + supportedTextTokens.Add(item); } private async Task Start() @@ -116,16 +140,19 @@ return; } - var nicknameList = ParseNicknameLines(nicknameLines); - var useList = nicknameList.Count > 0; - - if (!useList && string.IsNullOrWhiteSpace(baseNickname)) + var templates = ParseNicknameLines(nicknameTemplatesText); + if (templates.Count == 0) { - Snackbar.Add("批量昵称不能为空", Severity.Warning); + Snackbar.Add("请至少填写一行昵称模板", Severity.Warning); + return; + } + + if (!ValidateTemplates(templates, out var templateError)) + { + Snackbar.Add(templateError ?? "昵称模板不合法", Severity.Warning); return; } - // 批量风控检查:修改昵称是敏感操作 var batchCheck = RiskService.CheckBatchAccounts(accounts); if (batchCheck.HasRiskyAccounts) { @@ -141,12 +168,11 @@ var dialog = DialogService.Show("批量操作风控警告", parameters, options); var result = await dialog.Result; - if (result.Canceled) - return; // 用户取消操作 + if (result?.Canceled ?? true) + return; if (result.Data is TelegramPanel.Core.Services.RiskWarningAction action && action == TelegramPanel.Core.Services.RiskWarningAction.ExcludeRisky) { - // 排除风险账号后继续 accounts = batchCheck.SafeAccounts.ToList(); if (accounts.Count == 0) { @@ -154,7 +180,6 @@ return; } } - // else: 用户选择继续所有账号 } bool? confirm = await DialogService.ShowMessageBox( @@ -173,36 +198,57 @@ try { - var baseUsedCount = new Dictionary(StringComparer.Ordinal); - foreach (var a in accounts) + var usedNicknames = new Dictionary(StringComparer.Ordinal); + foreach (var account in accounts) { - var baseName = useList ? nicknameList[processed % nicknameList.Count] : baseNickname; - - var needAppend = appendPhoneLast4; - if (useList) + var nicknameTemplate = templates[processed % templates.Count]; + string resolvedNickname; + try { - baseName = (baseName ?? string.Empty).Trim(); - baseUsedCount.TryGetValue(baseName, out var used); - baseUsedCount[baseName] = used + 1; - needAppend = appendPhoneLast4WhenDuplicate && used > 0; + resolvedNickname = (await TemplateRendering.RenderTextTemplateAsync(nicknameTemplate, CancellationToken.None)).Trim(); + } + catch (Exception ex) + { + processed++; + failed++; + results.Add(new ResultRow(account.DisplayPhone, string.Empty, false, $"昵称模板解析失败:{ex.Message}")); + StateHasChanged(); + continue; } - var newNickname = BuildNickname(baseName, a.Phone, needAppend); + if (string.IsNullOrWhiteSpace(resolvedNickname)) + { + processed++; + failed++; + results.Add(new ResultRow(account.DisplayPhone, string.Empty, false, "生成的昵称为空,请检查模板或字典内容")); + StateHasChanged(); + continue; + } + + usedNicknames.TryGetValue(resolvedNickname, out var usedCount); + usedNicknames[resolvedNickname] = usedCount + 1; + + var shouldAppendPhoneTail = appendPhoneLast4WhenDuplicate && usedCount > 0; + var newNickname = BuildNickname(resolvedNickname, account.Phone, shouldAppendPhoneTail); var (ok, err) = await AccountTelegramTools.UpdateUserProfileAsync( - accountId: a.Id, + accountId: account.Id, nickname: newNickname, bio: null, cancellationToken: CancellationToken.None); processed++; - if (ok) success++; else failed++; - results.Add(new ResultRow(a.DisplayPhone, newNickname, ok, err)); + if (ok) + success++; + else + failed++; + + results.Add(new ResultRow(account.DisplayPhone, newNickname, ok, err)); if (ok) { - a.Nickname = newNickname; - await AccountManagement.UpdateAccountAsync(a); + account.Nickname = newNickname; + await AccountManagement.UpdateAccountAsync(account); } StateHasChanged(); @@ -220,6 +266,37 @@ } } + private bool ValidateTemplates(IEnumerable templates, out string? error) + { + foreach (var template in templates) + { + if (!ValidateTemplate(template, out error)) + return false; + } + + error = null; + return true; + } + + private bool ValidateTemplate(string? template, out string? error) + { + error = null; + foreach (Match token in TemplateTokenRegex.Matches(template ?? string.Empty)) + { + var tokenName = token.Groups["name"].Value; + if (string.Equals(tokenName, "time", StringComparison.OrdinalIgnoreCase)) + continue; + + if (!supportedTextTokens.Contains(tokenName)) + { + error = $"不支持的文本变量:{{{tokenName}}}。请使用 {{time}} 或已启用的文本字典变量。"; + return false; + } + } + + return true; + } + private static string BuildNickname(string baseName, string phone, bool appendLast4) { var name = (baseName ?? string.Empty).Trim(); @@ -266,4 +343,3 @@ private sealed record ResultRow(string Phone, string NewNickname, bool Success, string? Error); } - diff --git a/src/TelegramPanel.Web/Components/Dialogs/BatchChangeUsernameDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/BatchChangeUsernameDialog.razor index 1e93b99..7178677 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/BatchChangeUsernameDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/BatchChangeUsernameDialog.razor @@ -2,9 +2,10 @@ @inject AccountManagementService AccountManagement @inject TelegramPanel.Core.Services.Telegram.AccountTelegramToolsService AccountTelegramTools @inject TelegramPanel.Core.Services.AccountRiskService RiskService +@inject TemplateRenderingService TemplateRendering +@inject DataDictionaryService DictionaryService @inject ISnackbar Snackbar @inject IDialogService DialogService -@using System.Security.Cryptography @using System.Text.RegularExpressions @using TelegramPanel.Data.Entities @@ -12,19 +13,18 @@ - 将对选中的账号批量修改用户名(t.me/xxx)。用户名模板支持变量: - - {number} / {letter} 表示 1 位随机数字/字母;{number3} 表示 3 位随机数字;{letter4} 表示 4 位随机字母。可组合使用。 - - - 示例:test_{number3}{letter4} → test_123abcd - + 将对选中的账号批量修改用户名(t.me/xxx)。用户名模板支持 `{time}` 和文本字典变量;批量执行时建议使用文本字典提供不同值,避免重复。 账号数量:@accounts.Count + HelperText="请输入用户名模板(不要带开头的 at 符号)。例如:tg_{city}_{time}" /> + + @if (textDictionaryNames.Count > 0) + { + 可用文本变量:@string.Join("、", textDictionaryNames.Select(x => $"{{{x}}}")) + } @if (running || results.Count > 0) { @@ -82,11 +82,12 @@ [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; [Parameter] public IReadOnlyList AccountIds { get; set; } = Array.Empty(); - private static readonly Regex SupportedTokenRegex = new(@"\{(?number|nuber|letter)(?\d*)\}", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static readonly Regex AnyTokenRegex = new(@"\{[^}]*\}", RegexOptions.Compiled); + private static readonly Regex TemplateTokenRegex = new(@"\{(?[a-zA-Z0-9_]+)\}", RegexOptions.Compiled); private static readonly Regex UsernameRegex = new(@"^[A-Za-z][A-Za-z0-9_]{4,31}$", RegexOptions.Compiled); + private readonly HashSet supportedTextTokens = new(StringComparer.OrdinalIgnoreCase); private List accounts = new(); + private List textDictionaryNames = new(); private string usernameTemplate = ""; private bool running; @@ -103,6 +104,17 @@ var all = await AccountManagement.GetAllAccountsAsync(); accounts = all.Where(a => ids.Contains(a.Id)).OrderBy(a => a.Id).ToList(); + + var dictionaries = await DictionaryService.GetAllAsync(); + textDictionaryNames = dictionaries + .Where(x => x.IsEnabled && string.Equals(x.Type, DataDictionaryTypes.Text, StringComparison.OrdinalIgnoreCase)) + .Select(x => x.Name) + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .ToList(); + + supportedTextTokens.Clear(); + foreach (var item in textDictionaryNames) + supportedTextTokens.Add(item); } private async Task Start() @@ -123,19 +135,18 @@ return; } - if (accounts.Count > 1 && !SupportedTokenRegex.IsMatch(template)) + if (accounts.Count > 1 && !TemplateTokenRegex.IsMatch(template)) { - Snackbar.Add("批量修改多个账号时,用户名模板需要包含至少一个随机变量(例如 {number3} / {letter4})", Severity.Warning); + Snackbar.Add("批量修改多个账号时,用户名模板至少需要包含一个变量(例如 {time} 或 {username_pool})", Severity.Warning); return; } - if (!ValidateTemplateTokens(template, out var templateErr)) + if (!ValidateTemplate(template, out var templateErr)) { Snackbar.Add(templateErr ?? "用户名模板不合法", Severity.Warning); return; } - // 批量风控检查:修改用户名是敏感操作 var batchCheck = RiskService.CheckBatchAccounts(accounts); if (batchCheck.HasRiskyAccounts) { @@ -151,7 +162,7 @@ var dialog = DialogService.Show("批量操作风控警告", parameters, options); var result = await dialog.Result; - if (result.Canceled) + if (result?.Canceled ?? true) return; if (result.Data is TelegramPanel.Core.Services.RiskWarningAction action && action == TelegramPanel.Core.Services.RiskWarningAction.ExcludeRisky) @@ -182,30 +193,48 @@ try { var generated = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var a in accounts) + foreach (var account in accounts) { - if (!TryGenerateUniqueUsername(template, generated, out var newUsername, out var genErr)) + string renderedTemplate; + try + { + renderedTemplate = await TemplateRendering.RenderTextTemplateAsync(template, CancellationToken.None); + } + catch (Exception ex) { processed++; failed++; - results.Add(new ResultRow(a.DisplayPhone, "", false, genErr ?? "生成用户名失败")); + results.Add(new ResultRow(account.DisplayPhone, string.Empty, false, $"用户名模板解析失败:{ex.Message}")); + StateHasChanged(); + continue; + } + + if (!TryPrepareUsername(renderedTemplate, generated, out var newUsername, out var prepareError)) + { + processed++; + failed++; + results.Add(new ResultRow(account.DisplayPhone, newUsername, false, prepareError ?? "生成用户名失败")); StateHasChanged(); continue; } var (ok, err, username) = await AccountTelegramTools.UpdateUsernameAsync( - accountId: a.Id, + accountId: account.Id, username: newUsername, cancellationToken: CancellationToken.None); processed++; - if (ok) success++; else failed++; - results.Add(new ResultRow(a.DisplayPhone, newUsername, ok, err)); + if (ok) + success++; + else + failed++; + + results.Add(new ResultRow(account.DisplayPhone, newUsername, ok, err)); if (ok) { - a.Username = username; - await AccountManagement.UpdateAccountAsync(a); + account.Username = username; + await AccountManagement.UpdateAccountAsync(account); } StateHasChanged(); @@ -223,27 +252,18 @@ } } - private static bool ValidateTemplateTokens(string template, out string? error) + private bool ValidateTemplate(string? template, out string? error) { error = null; - - var tokens = AnyTokenRegex.Matches(template); - foreach (Match t in tokens) + foreach (Match token in TemplateTokenRegex.Matches(template ?? string.Empty)) { - if (!SupportedTokenRegex.IsMatch(t.Value)) - { - error = $"不支持的变量:{t.Value}。仅支持 {{number}}/{{letter}}/{{numberN}}/{{letterN}}。"; - return false; - } - - var m = SupportedTokenRegex.Match(t.Value); - var lenStr = m.Groups["len"].Value; - if (string.IsNullOrWhiteSpace(lenStr)) + var tokenName = token.Groups["name"].Value; + if (string.Equals(tokenName, "time", StringComparison.OrdinalIgnoreCase)) continue; - if (!int.TryParse(lenStr, out var len) || len <= 0 || len > 32) + if (!supportedTextTokens.Contains(tokenName)) { - error = $"变量长度不合法:{t.Value}。长度需为 1-32。"; + error = $"不支持的文本变量:{{{tokenName}}}。请使用 {{time}} 或已启用的文本字典变量。"; return false; } } @@ -251,67 +271,28 @@ return true; } - private static bool TryGenerateUniqueUsername( - string template, + private static bool TryPrepareUsername( + string templateResult, HashSet reserved, out string username, out string? error) { - username = ""; + username = (templateResult ?? string.Empty).Trim().TrimStart('@'); error = null; - for (var i = 0; i < 25; i++) + if (!UsernameRegex.IsMatch(username)) { - var candidate = ExpandTemplate(template); - candidate = (candidate ?? string.Empty).Trim().TrimStart('@'); - - if (!UsernameRegex.IsMatch(candidate)) - { - error = "生成的用户名不符合 Telegram 规则:仅允许字母/数字/下划线,长度 5-32,且必须以字母开头。"; - return false; - } - - if (reserved.Add(candidate)) - { - username = candidate; - return true; - } + error = "生成的用户名不符合 Telegram 规则:仅允许字母/数字/下划线,长度 5-32,且必须以字母开头。"; + return false; } - error = "生成的用户名在本次批量操作中重复次数过多,请增加随机位数(例如 {number6} / {letter6})。"; - return false; - } - - private static string ExpandTemplate(string template) - { - return SupportedTokenRegex.Replace(template, m => + if (!reserved.Add(username)) { - var kind = (m.Groups["kind"].Value ?? string.Empty).ToLowerInvariant(); - if (kind == "nuber") - kind = "number"; - var lenStr = m.Groups["len"].Value; - var len = string.IsNullOrWhiteSpace(lenStr) ? 1 : int.Parse(lenStr); + error = "本次批量操作生成了重复用户名,请调整文本字典内容或模板。"; + return false; + } - return kind == "number" - ? RandomDigits(len) - : RandomLetters(len); - }); - } - - private static string RandomDigits(int length) - { - var chars = new char[length]; - for (var i = 0; i < length; i++) - chars[i] = (char)('0' + RandomNumberGenerator.GetInt32(0, 10)); - return new string(chars); - } - - private static string RandomLetters(int length) - { - var chars = new char[length]; - for (var i = 0; i < length; i++) - chars[i] = (char)('a' + RandomNumberGenerator.GetInt32(0, 26)); - return new string(chars); + return true; } private void Cancel() => MudDialog.Close(); diff --git a/src/TelegramPanel.Web/Components/Dialogs/ChannelGroupPrivateCreateTaskEditor.razor b/src/TelegramPanel.Web/Components/Dialogs/ChannelGroupPrivateCreateTaskEditor.razor new file mode 100644 index 0000000..770bb6b --- /dev/null +++ b/src/TelegramPanel.Web/Components/Dialogs/ChannelGroupPrivateCreateTaskEditor.razor @@ -0,0 +1,344 @@ +@inject AccountCategoryManagementService CategoryManagement +@inject AccountManagementService AccountManagement +@inject ChannelGroupManagementService ChannelGroupManagement +@inject GroupCategoryManagementService GroupCategoryManagement +@inject DataDictionaryService DictionaryService +@inject ImageAssetStorageService AssetStorage +@using System.Text.Json +@using TelegramPanel.Data.Entities +@using TelegramPanel.Modules +@using TelegramPanel.Web.Services +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Forms + + + @if (loading) + { + + 正在加载配置项... + } + else + { + + @foreach (var item in categories) + { + @item.Name + } + + + + 频道 + 群组 + + + @if (IsChannel) + { + + 未分组 + @foreach (var item in channelGroups) + { + @item.Name + } + + } + else + { + + 未分类 + @foreach (var item in groupCategories) + { + @item.Name + } + + } + + + + + + + + + + + + + @if (textDictionaryNames.Count > 0) + { + 可用文本变量:@string.Join("、", textDictionaryNames.Select(x => $"{{{x}}}")) + } + + + 不设置 + 固定上传 + 图片字典变量 + + + @if (avatarSource == ChannelGroupAutomationAvatarSourceModes.Fixed) + { + + + 上传固定头像 + + + @if (!string.IsNullOrWhiteSpace(fixedAvatarAssetPath)) + { + 已保存头像:@fixedAvatarAssetPath + } + } + else if (avatarSource == ChannelGroupAutomationAvatarSourceModes.Dictionary) + { + + -- 请选择图片字典 -- + @foreach (var item in imageDictionaryNames) + { + @item + } + + } + + + + + + + + + + + + + } + + +@code { + [Parameter] public ModuleTaskDraft Draft { get; set; } + [Parameter] public EventCallback DraftChanged { get; set; } + [Parameter] public string? InitialConfigJson { get; set; } + + private bool loading = true; + private bool initialized; + private List categories = new(); + private List activeAccounts = new(); + private List channelGroups = new(); + private List groupCategories = new(); + private List textDictionaryNames = new(); + private List imageDictionaryNames = new(); + + private IEnumerable selectedCategoryIds = Array.Empty(); + private string createType = ChannelGroupAutomationTaskObjectTypes.Channel; + private int selectedChannelGroupId; + private int selectedGroupCategoryId; + private int systemCreatedLimit = 10; + private int perAccountBatchSize = 1; + private string titleTemplate = string.Empty; + private string avatarSource = ChannelGroupAutomationAvatarSourceModes.None; + private string? fixedAvatarAssetPath; + private string avatarDictionaryToken = string.Empty; + private string selectedImageDictionaryName = string.Empty; + private int minDelaySeconds = 10; + private int maxDelaySeconds = 30; + private int jitterPercent = 20; + private string assetScopeId = Guid.NewGuid().ToString("N"); + + private bool IsChannel => string.Equals(createType, ChannelGroupAutomationTaskObjectTypes.Channel, StringComparison.OrdinalIgnoreCase); + + protected override async Task OnInitializedAsync() + { + await LoadDataAsync(); + ApplyInitialConfig(); + initialized = true; + loading = false; + await PushDraftAsync(); + } + + private async Task LoadDataAsync() + { + categories = (await CategoryManagement.GetAllCategoriesAsync()) + .Where(x => !x.ExcludeFromOperations) + .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + activeAccounts = (await AccountManagement.GetActiveAccountsAsync()).ToList(); + channelGroups = (await ChannelGroupManagement.GetAllGroupsAsync()).ToList(); + groupCategories = (await GroupCategoryManagement.GetAllCategoriesAsync()).ToList(); + + var dictionaries = await DictionaryService.GetAllAsync(); + textDictionaryNames = dictionaries.Where(x => x.IsEnabled && x.Type == DataDictionaryTypes.Text).Select(x => x.Name).OrderBy(x => x).ToList(); + imageDictionaryNames = dictionaries.Where(x => x.IsEnabled && x.Type == DataDictionaryTypes.Image).Select(x => x.Name).OrderBy(x => x).ToList(); + } + + private void ApplyInitialConfig() + { + if (string.IsNullOrWhiteSpace(InitialConfigJson)) + return; + + try + { + var config = JsonSerializer.Deserialize(InitialConfigJson!); + if (config == null) + return; + + selectedCategoryIds = (config.CategoryIds ?? new List()).Where(x => x > 0).Distinct().ToList(); + createType = string.Equals(config.CreateType, ChannelGroupAutomationTaskObjectTypes.Group, StringComparison.OrdinalIgnoreCase) + ? ChannelGroupAutomationTaskObjectTypes.Group + : ChannelGroupAutomationTaskObjectTypes.Channel; + selectedChannelGroupId = config.ChannelGroupId ?? 0; + selectedGroupCategoryId = config.GroupCategoryId ?? 0; + systemCreatedLimit = Math.Max(1, config.SystemCreatedLimit); + perAccountBatchSize = Math.Max(1, config.PerAccountBatchSize); + titleTemplate = config.TitleTemplate ?? string.Empty; + avatarSource = config.AvatarSource ?? ChannelGroupAutomationAvatarSourceModes.None; + fixedAvatarAssetPath = string.IsNullOrWhiteSpace(config.FixedAvatarAssetPath) ? null : config.FixedAvatarAssetPath.Trim(); + avatarDictionaryToken = config.AvatarDictionaryToken ?? string.Empty; + selectedImageDictionaryName = ExtractDictionaryName(avatarDictionaryToken); + minDelaySeconds = config.MinDelaySeconds; + maxDelaySeconds = config.MaxDelaySeconds; + jitterPercent = config.JitterPercent; + if (!string.IsNullOrWhiteSpace(config.AssetScopeId)) + assetScopeId = config.AssetScopeId.Trim(); + } + catch + { + // 忽略旧配置解析失败,使用默认值。 + } + } + + private async Task OnSelectedCategoriesChanged(IEnumerable values) + { + selectedCategoryIds = (values ?? Array.Empty()).Where(x => x > 0).Distinct().ToList(); + await OnInputsChanged(); + } + + private async Task OnSelectedImageDictionaryChanged() + { + avatarDictionaryToken = string.IsNullOrWhiteSpace(selectedImageDictionaryName) ? string.Empty : $"{{{selectedImageDictionaryName}}}"; + await OnInputsChanged(); + } + + private async Task OnAvatarChanged(IReadOnlyList files) + { + var file = files?.FirstOrDefault(); + if (file == null) + return; + + if (!string.IsNullOrWhiteSpace(fixedAvatarAssetPath)) + await AssetStorage.DeleteAssetAsync(fixedAvatarAssetPath); + + var stored = await AssetStorage.SaveBrowserFileAsync(file, $"task-assets/{assetScopeId}", ImageAssetKind.Avatar); + fixedAvatarAssetPath = stored.AssetPath; + avatarSource = ChannelGroupAutomationAvatarSourceModes.Fixed; + await OnInputsChanged(); + } + + private async Task OnInputsChanged() + { + if (!initialized) + return; + await PushDraftAsync(); + } + + private async Task PushDraftAsync() + { + if (loading) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "配置加载中")); + return; + } + + var selectedIds = selectedCategoryIds.Where(x => x > 0).Distinct().ToList(); + if (selectedIds.Count == 0) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请至少选择一个账号分类")); + return; + } + + titleTemplate = (titleTemplate ?? string.Empty).Trim(); + if (titleTemplate.Length == 0) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "标题模板不能为空")); + return; + } + + if (systemCreatedLimit < 1) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "系统创建数量上限必须大于 0")); + return; + } + + if (perAccountBatchSize < 1) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "每账号本轮处理数量必须大于 0")); + return; + } + + minDelaySeconds = ChannelGroupTaskDelayHelper.NormalizeMinDelay(minDelaySeconds); + maxDelaySeconds = ChannelGroupTaskDelayHelper.NormalizeMaxDelay(minDelaySeconds, maxDelaySeconds); + jitterPercent = ChannelGroupTaskDelayHelper.NormalizeJitterPercent(jitterPercent); + + avatarSource = NormalizeAvatarSource(avatarSource); + if (avatarSource == ChannelGroupAutomationAvatarSourceModes.Fixed && string.IsNullOrWhiteSpace(fixedAvatarAssetPath)) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请先上传固定头像")); + return; + } + + if (avatarSource == ChannelGroupAutomationAvatarSourceModes.Dictionary && string.IsNullOrWhiteSpace(avatarDictionaryToken)) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请选择图片字典")); + return; + } + + var selectedCategoryNames = categories.Where(x => selectedIds.Contains(x.Id)).Select(x => x.Name).ToList(); + var selectedAccountCount = activeAccounts.Count(x => x.IsActive && x.CategoryId.HasValue && selectedIds.Contains(x.CategoryId.Value) && x.Category?.ExcludeFromOperations != true); + var total = Math.Max(0, selectedAccountCount * Math.Max(1, perAccountBatchSize)); + + var config = new ChannelGroupPrivateCreateTaskConfig + { + CategoryIds = selectedIds, + CategoryNames = selectedCategoryNames, + CreateType = IsChannel ? ChannelGroupAutomationTaskObjectTypes.Channel : ChannelGroupAutomationTaskObjectTypes.Group, + ChannelGroupId = IsChannel && selectedChannelGroupId > 0 ? selectedChannelGroupId : null, + ChannelGroupName = IsChannel && selectedChannelGroupId > 0 ? channelGroups.FirstOrDefault(x => x.Id == selectedChannelGroupId)?.Name : null, + GroupCategoryId = !IsChannel && selectedGroupCategoryId > 0 ? selectedGroupCategoryId : null, + GroupCategoryName = !IsChannel && selectedGroupCategoryId > 0 ? groupCategories.FirstOrDefault(x => x.Id == selectedGroupCategoryId)?.Name : null, + SystemCreatedLimit = systemCreatedLimit, + PerAccountBatchSize = perAccountBatchSize, + MinDelaySeconds = minDelaySeconds, + MaxDelaySeconds = maxDelaySeconds, + JitterPercent = jitterPercent, + TitleTemplate = titleTemplate, + AvatarSource = avatarSource, + FixedAvatarAssetPath = fixedAvatarAssetPath, + AvatarDictionaryToken = string.IsNullOrWhiteSpace(avatarDictionaryToken) ? null : avatarDictionaryToken.Trim(), + AssetScopeId = avatarSource == ChannelGroupAutomationAvatarSourceModes.Fixed ? assetScopeId : null + }; + + var json = JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true }); + await DraftChanged.InvokeAsync(new ModuleTaskDraft(total, json, true, null)); + } + + private static string NormalizeAvatarSource(string? value) + { + return string.Equals((value ?? string.Empty).Trim(), ChannelGroupAutomationAvatarSourceModes.Fixed, StringComparison.OrdinalIgnoreCase) + ? ChannelGroupAutomationAvatarSourceModes.Fixed + : string.Equals((value ?? string.Empty).Trim(), ChannelGroupAutomationAvatarSourceModes.Dictionary, StringComparison.OrdinalIgnoreCase) + ? ChannelGroupAutomationAvatarSourceModes.Dictionary + : ChannelGroupAutomationAvatarSourceModes.None; + } + + private static string ExtractDictionaryName(string? token) + { + var text = (token ?? string.Empty).Trim(); + if (text.StartsWith("{") && text.EndsWith("}") && text.Length > 2) + return text[1..^1]; + return string.Empty; + } +} + diff --git a/src/TelegramPanel.Web/Components/Dialogs/ChannelGroupPublicizeTaskEditor.razor b/src/TelegramPanel.Web/Components/Dialogs/ChannelGroupPublicizeTaskEditor.razor new file mode 100644 index 0000000..bc84a11 --- /dev/null +++ b/src/TelegramPanel.Web/Components/Dialogs/ChannelGroupPublicizeTaskEditor.razor @@ -0,0 +1,372 @@ +@inject AccountCategoryManagementService CategoryManagement +@inject AccountManagementService AccountManagement +@inject ChannelGroupManagementService ChannelGroupManagement +@inject GroupCategoryManagementService GroupCategoryManagement +@inject DataDictionaryService DictionaryService +@inject ImageAssetStorageService AssetStorage +@using System.Text.Json +@using TelegramPanel.Data.Entities +@using TelegramPanel.Modules +@using TelegramPanel.Web.Services +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Forms + + + @if (loading) + { + + 正在加载配置项... + } + else + { + + @foreach (var item in categories) + { + @item.Name + } + + + + 频道 + 群组 + + + @if (IsChannel) + { + + 未分组 + @foreach (var item in channelGroups) + { + @item.Name + } + + } + else + { + + 未分类 + @foreach (var item in groupCategories) + { + @item.Name + } + + } + + + + + + + + + + + + + + + + + + @if (textDictionaryNames.Count > 0) + { + 可用文本变量:@string.Join("、", textDictionaryNames.Select(x => $"{{{x}}}")) + } + + + 不设置 + 固定上传 + 图片字典变量 + + + @if (avatarSource == ChannelGroupAutomationAvatarSourceModes.Fixed) + { + + + 上传固定头像 + + + @if (!string.IsNullOrWhiteSpace(fixedAvatarAssetPath)) + { + 已保存头像:@fixedAvatarAssetPath + } + } + else if (avatarSource == ChannelGroupAutomationAvatarSourceModes.Dictionary) + { + + -- 请选择图片字典 -- + @foreach (var item in imageDictionaryNames) + { + @item + } + + } + + + + + + + + + + + + + } + + +@code { + [Parameter] public ModuleTaskDraft Draft { get; set; } + [Parameter] public EventCallback DraftChanged { get; set; } + [Parameter] public string? InitialConfigJson { get; set; } + + private bool loading = true; + private bool initialized; + private List categories = new(); + private List activeAccounts = new(); + private List channelGroups = new(); + private List groupCategories = new(); + private List textDictionaryNames = new(); + private List imageDictionaryNames = new(); + + private IEnumerable selectedCategoryIds = Array.Empty(); + private string targetType = ChannelGroupAutomationTaskObjectTypes.Channel; + private int selectedChannelGroupId; + private int selectedGroupCategoryId; + private int minSystemCreatedDays; + private int maxPublicCount = 10; + private int perAccountBatchSize = 1; + private string titleTemplate = string.Empty; + private string descriptionTemplate = string.Empty; + private string usernameTemplate = string.Empty; + private string avatarSource = ChannelGroupAutomationAvatarSourceModes.None; + private string? fixedAvatarAssetPath; + private string avatarDictionaryToken = string.Empty; + private string selectedImageDictionaryName = string.Empty; + private int minDelaySeconds = 10; + private int maxDelaySeconds = 30; + private int jitterPercent = 20; + private string assetScopeId = Guid.NewGuid().ToString("N"); + + private bool IsChannel => string.Equals(targetType, ChannelGroupAutomationTaskObjectTypes.Channel, StringComparison.OrdinalIgnoreCase); + + protected override async Task OnInitializedAsync() + { + await LoadDataAsync(); + ApplyInitialConfig(); + initialized = true; + loading = false; + await PushDraftAsync(); + } + + private async Task LoadDataAsync() + { + categories = (await CategoryManagement.GetAllCategoriesAsync()) + .Where(x => !x.ExcludeFromOperations) + .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + activeAccounts = (await AccountManagement.GetActiveAccountsAsync()).ToList(); + channelGroups = (await ChannelGroupManagement.GetAllGroupsAsync()).ToList(); + groupCategories = (await GroupCategoryManagement.GetAllCategoriesAsync()).ToList(); + + var dictionaries = await DictionaryService.GetAllAsync(); + textDictionaryNames = dictionaries.Where(x => x.IsEnabled && x.Type == DataDictionaryTypes.Text).Select(x => x.Name).OrderBy(x => x).ToList(); + imageDictionaryNames = dictionaries.Where(x => x.IsEnabled && x.Type == DataDictionaryTypes.Image).Select(x => x.Name).OrderBy(x => x).ToList(); + } + + private void ApplyInitialConfig() + { + if (string.IsNullOrWhiteSpace(InitialConfigJson)) + return; + + try + { + var config = JsonSerializer.Deserialize(InitialConfigJson!); + if (config == null) + return; + + selectedCategoryIds = (config.CategoryIds ?? new List()).Where(x => x > 0).Distinct().ToList(); + targetType = string.Equals(config.TargetType, ChannelGroupAutomationTaskObjectTypes.Group, StringComparison.OrdinalIgnoreCase) + ? ChannelGroupAutomationTaskObjectTypes.Group + : ChannelGroupAutomationTaskObjectTypes.Channel; + selectedChannelGroupId = config.ChannelGroupId ?? 0; + selectedGroupCategoryId = config.GroupCategoryId ?? 0; + minSystemCreatedDays = Math.Max(0, config.MinSystemCreatedDays); + maxPublicCount = Math.Max(1, config.MaxPublicCount); + perAccountBatchSize = Math.Max(1, config.PerAccountBatchSize); + titleTemplate = config.TitleTemplate ?? string.Empty; + descriptionTemplate = config.DescriptionTemplate ?? string.Empty; + usernameTemplate = config.UsernameTemplate ?? string.Empty; + avatarSource = config.AvatarSource ?? ChannelGroupAutomationAvatarSourceModes.None; + fixedAvatarAssetPath = string.IsNullOrWhiteSpace(config.FixedAvatarAssetPath) ? null : config.FixedAvatarAssetPath.Trim(); + avatarDictionaryToken = config.AvatarDictionaryToken ?? string.Empty; + selectedImageDictionaryName = ExtractDictionaryName(avatarDictionaryToken); + minDelaySeconds = config.MinDelaySeconds; + maxDelaySeconds = config.MaxDelaySeconds; + jitterPercent = config.JitterPercent; + if (!string.IsNullOrWhiteSpace(config.AssetScopeId)) + assetScopeId = config.AssetScopeId.Trim(); + } + catch + { + // 忽略旧配置解析失败,使用默认值。 + } + } + + private async Task OnSelectedCategoriesChanged(IEnumerable values) + { + selectedCategoryIds = (values ?? Array.Empty()).Where(x => x > 0).Distinct().ToList(); + await OnInputsChanged(); + } + + private async Task OnSelectedImageDictionaryChanged() + { + avatarDictionaryToken = string.IsNullOrWhiteSpace(selectedImageDictionaryName) ? string.Empty : $"{{{selectedImageDictionaryName}}}"; + await OnInputsChanged(); + } + + private async Task OnAvatarChanged(IReadOnlyList files) + { + var file = files?.FirstOrDefault(); + if (file == null) + return; + + if (!string.IsNullOrWhiteSpace(fixedAvatarAssetPath)) + await AssetStorage.DeleteAssetAsync(fixedAvatarAssetPath); + + var stored = await AssetStorage.SaveBrowserFileAsync(file, $"task-assets/{assetScopeId}", ImageAssetKind.Avatar); + fixedAvatarAssetPath = stored.AssetPath; + avatarSource = ChannelGroupAutomationAvatarSourceModes.Fixed; + await OnInputsChanged(); + } + + private async Task OnInputsChanged() + { + if (!initialized) + return; + await PushDraftAsync(); + } + + private async Task PushDraftAsync() + { + if (loading) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "配置加载中")); + return; + } + + var selectedIds = selectedCategoryIds.Where(x => x > 0).Distinct().ToList(); + if (selectedIds.Count == 0) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请至少选择一个账号分类")); + return; + } + + titleTemplate = (titleTemplate ?? string.Empty).Trim(); + usernameTemplate = (usernameTemplate ?? string.Empty).Trim(); + descriptionTemplate = (descriptionTemplate ?? string.Empty).Trim(); + if (titleTemplate.Length == 0) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "标题模板不能为空")); + return; + } + + if (usernameTemplate.Length == 0) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "公开用户名模板不能为空")); + return; + } + + if (maxPublicCount < 1) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "保持公开最大数量必须大于 0")); + return; + } + + if (perAccountBatchSize < 1) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "每账号本轮处理数量必须大于 0")); + return; + } + + minSystemCreatedDays = Math.Max(0, minSystemCreatedDays); + minDelaySeconds = ChannelGroupTaskDelayHelper.NormalizeMinDelay(minDelaySeconds); + maxDelaySeconds = ChannelGroupTaskDelayHelper.NormalizeMaxDelay(minDelaySeconds, maxDelaySeconds); + jitterPercent = ChannelGroupTaskDelayHelper.NormalizeJitterPercent(jitterPercent); + + avatarSource = NormalizeAvatarSource(avatarSource); + if (avatarSource == ChannelGroupAutomationAvatarSourceModes.Fixed && string.IsNullOrWhiteSpace(fixedAvatarAssetPath)) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请先上传固定头像")); + return; + } + + if (avatarSource == ChannelGroupAutomationAvatarSourceModes.Dictionary && string.IsNullOrWhiteSpace(avatarDictionaryToken)) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请选择图片字典")); + return; + } + + var selectedCategoryNames = categories.Where(x => selectedIds.Contains(x.Id)).Select(x => x.Name).ToList(); + var selectedAccountCount = activeAccounts.Count(x => x.IsActive && x.CategoryId.HasValue && selectedIds.Contains(x.CategoryId.Value) && x.Category?.ExcludeFromOperations != true); + var total = Math.Max(0, selectedAccountCount * Math.Max(1, perAccountBatchSize)); + + var config = new ChannelGroupPublicizeTaskConfig + { + CategoryIds = selectedIds, + CategoryNames = selectedCategoryNames, + TargetType = IsChannel ? ChannelGroupAutomationTaskObjectTypes.Channel : ChannelGroupAutomationTaskObjectTypes.Group, + ChannelGroupId = IsChannel && selectedChannelGroupId > 0 ? selectedChannelGroupId : null, + ChannelGroupName = IsChannel && selectedChannelGroupId > 0 ? channelGroups.FirstOrDefault(x => x.Id == selectedChannelGroupId)?.Name : null, + GroupCategoryId = !IsChannel && selectedGroupCategoryId > 0 ? selectedGroupCategoryId : null, + GroupCategoryName = !IsChannel && selectedGroupCategoryId > 0 ? groupCategories.FirstOrDefault(x => x.Id == selectedGroupCategoryId)?.Name : null, + MinSystemCreatedDays = minSystemCreatedDays, + MaxPublicCount = maxPublicCount, + PerAccountBatchSize = perAccountBatchSize, + MinDelaySeconds = minDelaySeconds, + MaxDelaySeconds = maxDelaySeconds, + JitterPercent = jitterPercent, + TitleTemplate = titleTemplate, + DescriptionTemplate = descriptionTemplate, + UsernameTemplate = usernameTemplate, + AvatarSource = avatarSource, + FixedAvatarAssetPath = fixedAvatarAssetPath, + AvatarDictionaryToken = string.IsNullOrWhiteSpace(avatarDictionaryToken) ? null : avatarDictionaryToken.Trim(), + AssetScopeId = avatarSource == ChannelGroupAutomationAvatarSourceModes.Fixed ? assetScopeId : null + }; + + var json = JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true }); + await DraftChanged.InvokeAsync(new ModuleTaskDraft(total, json, true, null)); + } + + private static string NormalizeAvatarSource(string? value) + { + return string.Equals((value ?? string.Empty).Trim(), ChannelGroupAutomationAvatarSourceModes.Fixed, StringComparison.OrdinalIgnoreCase) + ? ChannelGroupAutomationAvatarSourceModes.Fixed + : string.Equals((value ?? string.Empty).Trim(), ChannelGroupAutomationAvatarSourceModes.Dictionary, StringComparison.OrdinalIgnoreCase) + ? ChannelGroupAutomationAvatarSourceModes.Dictionary + : ChannelGroupAutomationAvatarSourceModes.None; + } + + private static string ExtractDictionaryName(string? token) + { + var text = (token ?? string.Empty).Trim(); + if (text.StartsWith("{") && text.EndsWith("}") && text.Length > 2) + return text[1..^1]; + return string.Empty; + } +} + diff --git a/src/TelegramPanel.Web/Components/Dialogs/CreateBatchTaskDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/CreateBatchTaskDialog.razor index 23f407d..ab3256e 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/CreateBatchTaskDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/CreateBatchTaskDialog.razor @@ -1,5 +1,6 @@ -@namespace TelegramPanel.Web.Components.Dialogs +@namespace TelegramPanel.Web.Components.Dialogs @inject BatchTaskManagementService TaskManagement +@inject ScheduledTaskService ScheduledTaskManagement @inject TelegramPanel.Web.Modules.ModuleContributionRegistry Contributions @inject ISnackbar Snackbar @inject IDialogService DialogService @@ -7,25 +8,28 @@ @using System.Text.Json @using Microsoft.AspNetCore.Components @using TelegramPanel.Data.Entities +@using TelegramPanel.Modules +@using TelegramPanel.Web.Modules +@using TelegramPanel.Web.Services - 任务会提交到后台执行,可在【任务中心】查看进度与结果。 + 立即执行会创建一条后台执行记录;Cron 计划会在任务中心的【计划任务】区域持续调度。 - - @foreach (var c in AvailableCategories) - { - @GetCategoryDisplayName(c) - } - + + @foreach (var c in AvailableCategories) + { + @GetCategoryDisplayName(c) + } + - + @foreach (var opt in AvailableOptions) { @opt.DisplayName @@ -34,11 +38,38 @@ - @if (CurrentOption != null && !string.IsNullOrWhiteSpace(CurrentOption.Description)) + + 立即执行 + Cron 计划 + + + @if (!string.IsNullOrWhiteSpace(CurrentOption?.Description)) { @CurrentOption.Description } + @if (IsScheduledMode) + { + + Cron 按服务器本地时区解析:@TimeZoneInfo.Local.Id + + + + + + @foreach (var preset in CronPresets) + { + + @preset.Label + + } + + } + @if (CurrentOption != null && !string.IsNullOrWhiteSpace(CurrentOption.CreateRoute)) { @@ -56,7 +87,7 @@ else if (CurrentOption != null && !string.IsNullOrWhiteSpace(CurrentOption.EditorComponentType) && editorComponentType != null) { - 该任务由模块提供自定义创建器。 + 该任务由模块提供自定义配置表单。 @@ -72,10 +103,18 @@ 该任务类型暂无专用创建表单。你可以填写通用配置(JSON 字符串)提交任务;对应模块需要在后台实现该 TaskType 的执行器。 - - } @@ -88,6 +127,10 @@ 提交中... } + else if (IsScheduledMode) + { + 保存计划 + } else { 提交任务 @@ -98,28 +141,47 @@ @code { + private sealed record CronPresetItem(string Label, string Expression); + + private static class SubmissionModes + { + public const string Once = "once"; + public const string Scheduled = "scheduled"; + } + + private static readonly IReadOnlyList CronPresets = new[] + { + new CronPresetItem("每30分钟", "*/30 * * * *"), + new CronPresetItem("每小时", "0 * * * *"), + new CronPresetItem("每天9点", "0 9 * * *"), + new CronPresetItem("每天12点", "0 12 * * *"), + new CronPresetItem("每天21点", "0 21 * * *") + }; + [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; - private string _selectedCategory = ""; + private string _selectedCategory = string.Empty; private string selectedCategory { get => _selectedCategory; set { - value = (value ?? "").Trim(); + value = (value ?? string.Empty).Trim(); if (string.Equals(_selectedCategory, value, StringComparison.OrdinalIgnoreCase)) return; + _selectedCategory = value; EnsureTaskTypeValid(); } } - private string _selectedTaskType = ""; + + private string _selectedTaskType = string.Empty; private string selectedTaskType { get => _selectedTaskType; set { - value = (value ?? "").Trim(); + value = (value ?? string.Empty).Trim(); if (string.Equals(_selectedTaskType, value, StringComparison.OrdinalIgnoreCase)) return; @@ -128,46 +190,46 @@ } } + private string submissionMode = SubmissionModes.Once; + private string cronExpression = "0 9 * * *"; private bool creating; - - // generic task config - private string genericConfigJson = ""; - private int genericTotal = 0; - - // module editor draft - private TelegramPanel.Modules.ModuleTaskDraft editorDraft = new(0, null, false, null); + private string genericConfigJson = string.Empty; + private int genericTotal; + private ModuleTaskDraft editorDraft = new(0, null, false, null); private Type? editorComponentType; private Dictionary editorParameters = new(); + private bool IsScheduledMode => string.Equals(submissionMode, SubmissionModes.Scheduled, StringComparison.OrdinalIgnoreCase); + private IEnumerable AvailableCategories => Contributions.Tasks - .Select(x => (x.Definition.Category ?? "").Trim()) + .Select(x => (x.Definition.Category ?? string.Empty).Trim()) .Where(x => x.Length > 0) .Where(x => !IsSystemCategory(x)) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(x => x, StringComparer.OrdinalIgnoreCase); - private IEnumerable AvailableOptions => + private IEnumerable AvailableOptions => Contributions.Tasks .Select(x => x.Definition) .Where(o => !IsSystemCategory(o.Category)) .Where(o => string.Equals(o.Category, selectedCategory, StringComparison.OrdinalIgnoreCase)) .OrderBy(o => o.Order) - .ThenBy(o => o.DisplayName); + .ThenBy(o => o.DisplayName, StringComparer.OrdinalIgnoreCase); - private TelegramPanel.Modules.ModuleTaskDefinition? CurrentOption => + private ModuleTaskDefinition? CurrentOption => !string.IsNullOrWhiteSpace(selectedTaskType) && Contributions.TaskTypeToDefinition.TryGetValue(selectedTaskType, out var reg) ? reg.Definition : null; - protected override async Task OnInitializedAsync() + protected override Task OnInitializedAsync() { if (string.IsNullOrWhiteSpace(selectedCategory)) selectedCategory = AvailableCategories.FirstOrDefault() ?? "user"; EnsureTaskTypeValid(); - await Task.CompletedTask; + return Task.CompletedTask; } private void EnsureTaskTypeValid() @@ -175,8 +237,8 @@ if (AvailableOptions.All(o => !string.Equals(o.TaskType, selectedTaskType, StringComparison.OrdinalIgnoreCase))) { selectedTaskType = AvailableOptions.FirstOrDefault()?.TaskType - ?? Contributions.Tasks.FirstOrDefault()?.Definition.TaskType - ?? ""; + ?? Contributions.Tasks.FirstOrDefault(x => !IsSystemCategory(x.Definition.Category))?.Definition.TaskType + ?? string.Empty; } } @@ -189,37 +251,42 @@ }; private static bool IsSystemCategory(string? category) => - string.Equals((category ?? "").Trim(), "system", StringComparison.OrdinalIgnoreCase); + string.Equals((category ?? string.Empty).Trim(), "system", StringComparison.OrdinalIgnoreCase); private void ResolveEditorComponent() { editorComponentType = null; editorParameters = new Dictionary(); - editorDraft = new TelegramPanel.Modules.ModuleTaskDraft(0, null, false, null); + editorDraft = new ModuleTaskDraft(0, null, false, null); - var opt = CurrentOption; - if (opt == null || string.IsNullOrWhiteSpace(opt.EditorComponentType)) + var option = CurrentOption; + if (option == null || string.IsNullOrWhiteSpace(option.EditorComponentType)) return; - if (!Contributions.TaskTypeToDefinition.TryGetValue(opt.TaskType, out var reg)) + if (!Contributions.TaskTypeToDefinition.TryGetValue(option.TaskType, out var reg)) return; editorComponentType = ModuleTaskEditorComponentResolver.ResolveCreateEditor(reg); - if (editorComponentType == null) return; editorParameters = new Dictionary { ["Draft"] = editorDraft, - ["DraftChanged"] = EventCallback.Factory.Create(this, OnEditorDraftChanged) + ["DraftChanged"] = EventCallback.Factory.Create(this, OnEditorDraftChanged) }; } - private void OnEditorDraftChanged(TelegramPanel.Modules.ModuleTaskDraft draft) + private void OnEditorDraftChanged(ModuleTaskDraft draft) { editorDraft = draft; } + + private void ApplyCronPreset(string expression) + { + cronExpression = expression; + } + private async Task Create() { if (creating) @@ -231,102 +298,68 @@ return; } - if (CurrentOption != null && !string.IsNullOrWhiteSpace(CurrentOption.EditorComponentType) && editorComponentType != null) + string? config; + int total; + + try { - if (!editorDraft.CanSubmit) - { - Snackbar.Add(string.IsNullOrWhiteSpace(editorDraft.ValidationError) ? "请先完善任务配置" : editorDraft.ValidationError!, Severity.Warning); - return; - } - - var config = string.IsNullOrWhiteSpace(editorDraft.Config) ? null : editorDraft.Config.Trim(); - if (!string.IsNullOrWhiteSpace(config)) - { - try - { - JsonDocument.Parse(config); - } - catch (Exception ex) - { - Snackbar.Add($"配置 JSON 无效:{ex.Message}", Severity.Warning); - return; - } - } - - var total = editorDraft.Total < 0 ? 0 : editorDraft.Total; - bool? confirm = await DialogService.ShowMessageBox( - "确认提交任务", - $"将提交任务类型:{selectedTaskType},总数:{total}。是否继续?", - yesText: "继续", cancelText: "取消"); - - if (confirm != true) - return; - - creating = true; - try - { - var task = new BatchTask - { - TaskType = selectedTaskType, - Total = total, - Completed = 0, - Failed = 0, - Config = config - }; - - var created = await TaskManagement.CreateTaskAsync(task); - Snackbar.Add($"已提交后台任务 #{created.Id}({selectedTaskType})。请到【任务中心】查看进度。", Severity.Success); - MudDialog.Close(DialogResult.Ok(created.Id)); - } - catch (Exception ex) - { - Snackbar.Add($"提交失败:{ex.Message}", Severity.Error); - } - finally - { - creating = false; - } - + (config, total) = ResolveSubmissionDraft(); + } + catch (Exception ex) + { + Snackbar.Add(ex.Message, Severity.Warning); return; } - // Generic task create - if (!string.IsNullOrWhiteSpace(genericConfigJson)) - { - try - { - JsonDocument.Parse(genericConfigJson); - } - catch (Exception ex) - { - Snackbar.Add($"配置 JSON 无效:{ex.Message}", Severity.Warning); - return; - } - } - - bool? genericConfirm = await DialogService.ShowMessageBox( - "确认提交任务", - $"将提交任务类型:{selectedTaskType}。是否继续?", - yesText: "继续", cancelText: "取消"); - - if (genericConfirm != true) - return; - creating = true; try { + if (IsScheduledMode) + { + bool? confirm = await DialogService.ShowMessageBox( + "确认创建计划任务", + $"将保存计划任务:{selectedTaskType},Cron:{cronExpression}。是否继续?", + yesText: "继续", cancelText: "取消"); + + if (confirm != true) + return; + + var scheduledTask = new ScheduledTask + { + TaskType = selectedTaskType, + Total = total, + ConfigJson = config, + CronExpression = cronExpression.Trim(), + Status = ScheduledTaskStatuses.Enabled, + OwnedAssetScopeId = TaskAssetScopeHelper.GetAssetScopeId(config) + }; + + var created = await ScheduledTaskManagement.CreateAsync(scheduledTask); + Snackbar.Add($"已创建计划任务 #{created.Id}({selectedTaskType})。", Severity.Success); + MudDialog.Close(DialogResult.Ok(created.Id)); + return; + } + + bool? taskConfirm = await DialogService.ShowMessageBox( + "确认提交任务", + $"将提交任务类型:{selectedTaskType},总数:{total}。是否继续?", + yesText: "继续", cancelText: "取消"); + + if (taskConfirm != true) + return; + var task = new BatchTask { TaskType = selectedTaskType, - Total = genericTotal, + Total = total, Completed = 0, Failed = 0, - Config = string.IsNullOrWhiteSpace(genericConfigJson) ? null : genericConfigJson.Trim() + Config = config }; - var created = await TaskManagement.CreateTaskAsync(task); - Snackbar.Add($"已提交后台任务 #{created.Id}({selectedTaskType})。请到【任务中心】查看进度。", Severity.Success); - MudDialog.Close(DialogResult.Ok(created.Id)); + var createdTask = await TaskManagement.CreateTaskAsync(task); + Snackbar.Add($"已提交后台任务 #{createdTask.Id}({selectedTaskType})。请到【任务中心】查看进度。", Severity.Success); + MudDialog.Close(DialogResult.Ok(createdTask.Id)); } catch (Exception ex) { @@ -338,6 +371,33 @@ } } + private (string? Config, int Total) ResolveSubmissionDraft() + { + if (string.IsNullOrWhiteSpace(selectedTaskType)) + throw new InvalidOperationException("请先选择任务类型"); + + if (IsScheduledMode && string.IsNullOrWhiteSpace(cronExpression)) + throw new InvalidOperationException("请填写 Cron 表达式"); + + if (editorComponentType != null) + { + if (!editorDraft.CanSubmit) + throw new InvalidOperationException(string.IsNullOrWhiteSpace(editorDraft.ValidationError) ? "请先完善任务配置" : editorDraft.ValidationError!); + + var editorConfig = string.IsNullOrWhiteSpace(editorDraft.Config) ? null : editorDraft.Config.Trim(); + if (!string.IsNullOrWhiteSpace(editorConfig)) + JsonDocument.Parse(editorConfig); + + return (editorConfig, Math.Max(0, editorDraft.Total)); + } + + var genericConfig = string.IsNullOrWhiteSpace(genericConfigJson) ? null : genericConfigJson.Trim(); + if (!string.IsNullOrWhiteSpace(genericConfig)) + JsonDocument.Parse(genericConfig); + + return (genericConfig, Math.Max(0, genericTotal)); + } + private void NavigateTo(string url) { MudDialog.Close(); @@ -346,11 +406,10 @@ private async Task OpenCreateDialog(string route) { - route = (route ?? "").Trim(); + route = (route ?? string.Empty).Trim(); if (route.Length == 0) return; - // 仅对模块扩展页面提供“窗口打开”:/ext/{ModuleId}/{PageKey} if (!TryParseExtRoute(route, out var moduleId, out var pageKey)) { Snackbar.Add("该任务未提供可窗口打开的模块页面,请使用“前往页面”。", Severity.Info); @@ -370,19 +429,15 @@ private static bool TryParseExtRoute(string route, out string moduleId, out string pageKey) { - moduleId = ""; - pageKey = ""; + moduleId = string.Empty; + pageKey = string.Empty; - route = (route ?? "").Trim(); + route = (route ?? string.Empty).Trim(); if (!route.StartsWith("/ext/", StringComparison.OrdinalIgnoreCase)) return false; - // /ext/{moduleId}/{pageKey} var parts = route.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (parts.Length < 3) - return false; - - if (!string.Equals(parts[0], "ext", StringComparison.OrdinalIgnoreCase)) + if (parts.Length < 3 || !string.Equals(parts[0], "ext", StringComparison.OrdinalIgnoreCase)) return false; moduleId = parts[1]; diff --git a/src/TelegramPanel.Web/Components/Dialogs/DataDictionaryEditDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/DataDictionaryEditDialog.razor new file mode 100644 index 0000000..4a92eeb --- /dev/null +++ b/src/TelegramPanel.Web/Components/Dialogs/DataDictionaryEditDialog.razor @@ -0,0 +1,323 @@ +@namespace TelegramPanel.Web.Components.Dialogs +@inject DataDictionaryService DictionaryService +@inject ImageAssetStorageService AssetStorage +@inject ISnackbar Snackbar +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Forms +@using TelegramPanel.Data.Entities +@using TelegramPanel.Web.Services + + + + @if (loading) + { + + + 正在加载字典配置... + + } + else if (!string.IsNullOrWhiteSpace(loadError)) + { + + @loadError + + } + else + { + + + + + + + + + + + + + + + + 文本字典 + 图片字典 + + + + + 随机 + 队列轮询 + + + + 启用字典 + + + + @if (IsTextDictionary) + { + + } + else + { + + 图片字典支持一次上传多张图;删除字典或删除字典项时会同步清理图片文件。 + + + + + + 选择图片 + + + + + @if (existingImages.Count > 0) + { + 现有图片 + + + 文件名 + 预览 + 操作 + + + @context.FileName + 查看 + + + + + + } + + @if (newImageFiles.Count > 0) + { + 待上传图片 + + + 文件名 + 大小 + 操作 + + + @context.FileName + @FormatBytes(context.Size) + + + + + + } + } + + } + + + 关闭 + + @if (saving) + { + + 保存中... + } + else + { + 保存字典 + } + + + + +@code { + private sealed record ExistingImageRow(int Id, string AssetPath, string FileName); + private sealed record PendingImageRow(int Index, string FileName, long Size); + + [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public int? DictionaryId { get; set; } + [Parameter] public string? InitialType { get; set; } + + private bool loading = true; + private bool saving; + private string? loadError; + private string name = string.Empty; + private string displayName = string.Empty; + private string? description; + private string type = DataDictionaryTypes.Text; + private string readMode = DataDictionaryReadModes.Random; + private bool isEnabled = true; + private string textLines = string.Empty; + private List existingImages = new(); + private List newImageFiles = new(); + private readonly string uploadScope = $"dictionaries/draft-{Guid.NewGuid():N}"; + + private bool IsEdit => DictionaryId.HasValue && DictionaryId.Value > 0; + private bool IsTextDictionary => string.Equals(type, DataDictionaryTypes.Text, StringComparison.OrdinalIgnoreCase); + + protected override async Task OnInitializedAsync() + { + try + { + type = string.Equals((InitialType ?? string.Empty).Trim(), DataDictionaryTypes.Image, StringComparison.OrdinalIgnoreCase) + ? DataDictionaryTypes.Image + : DataDictionaryTypes.Text; + + if (!IsEdit) + return; + + var dictionary = await DictionaryService.GetAsync(DictionaryId!.Value); + if (dictionary == null) + { + loadError = "字典不存在或已被删除。"; + return; + } + + name = dictionary.Name; + displayName = dictionary.DisplayName; + description = dictionary.Description; + type = dictionary.Type; + readMode = dictionary.ReadMode; + isEnabled = dictionary.IsEnabled; + + if (string.Equals(dictionary.Type, DataDictionaryTypes.Text, StringComparison.OrdinalIgnoreCase)) + { + textLines = string.Join(Environment.NewLine, + dictionary.Items + .Where(x => x.IsEnabled && !string.IsNullOrWhiteSpace(x.TextValue)) + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.Id) + .Select(x => x.TextValue)); + } + else + { + existingImages = dictionary.Items + .Where(x => x.IsEnabled && !string.IsNullOrWhiteSpace(x.AssetPath)) + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.Id) + .Select(x => new ExistingImageRow(x.Id, x.AssetPath!, x.FileName ?? $"image-{x.Id}.jpg")) + .ToList(); + } + } + catch (Exception ex) + { + loadError = ex.Message; + } + finally + { + loading = false; + } + } + + private Task OnFilesChanged(IReadOnlyList files) + { + if (files != null) + newImageFiles.AddRange(files.Where(x => x != null)); + return Task.CompletedTask; + } + + private void RemoveExistingImage(int itemId) + { + existingImages.RemoveAll(x => x.Id == itemId); + } + + private void RemovePendingImage(int index) + { + if (index >= 0 && index < newImageFiles.Count) + newImageFiles.RemoveAt(index); + } + + private IEnumerable GetPendingImageRows() + { + for (var index = 0; index < newImageFiles.Count; index++) + { + var file = newImageFiles[index]; + yield return new PendingImageRow(index, file.Name, file.Size); + } + } + + private async Task Save() + { + if (saving) + return; + + saving = true; + var savedAssets = new List(); + try + { + if (string.IsNullOrWhiteSpace(name)) + throw new InvalidOperationException("字典名称不能为空"); + if (string.IsNullOrWhiteSpace(displayName)) + throw new InvalidOperationException("显示名称不能为空"); + + if (IsTextDictionary) + { + var items = textLines + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(x => new DataDictionaryTextItemInput(x)) + .ToList(); + + var saved = await DictionaryService.SaveTextDictionaryAsync(DictionaryId, name, displayName, description, readMode, isEnabled, items); + Snackbar.Add($"字典 {saved.DisplayName} 已保存", Severity.Success); + MudDialog.Close(DialogResult.Ok(saved.Id)); + return; + } + + var scope = IsEdit ? $"dictionaries/{DictionaryId!.Value}" : uploadScope; + var imageInputs = new List(); + foreach (var file in newImageFiles) + { + var asset = await AssetStorage.SaveBrowserFileAsync(file, scope, ImageAssetKind.General); + savedAssets.Add(asset.AssetPath); + imageInputs.Add(new DataDictionaryImageItemInput(asset.AssetPath, asset.FileName)); + } + + var keepItemIds = existingImages.Select(x => x.Id).ToList(); + var savedDictionary = await DictionaryService.SaveImageDictionaryAsync(DictionaryId, name, displayName, description, readMode, isEnabled, keepItemIds, imageInputs); + Snackbar.Add($"字典 {savedDictionary.DisplayName} 已保存", Severity.Success); + MudDialog.Close(DialogResult.Ok(savedDictionary.Id)); + } + catch (Exception ex) + { + foreach (var assetPath in savedAssets) + await AssetStorage.DeleteAssetAsync(assetPath); + + Snackbar.Add($"保存失败:{ex.Message}", Severity.Error); + } + finally + { + saving = false; + } + } + + private static string FormatBytes(long bytes) + { + if (bytes < 1024) + return $"{bytes} B"; + if (bytes < 1024 * 1024) + return $"{bytes / 1024d:0.##} KB"; + return $"{bytes / 1024d / 1024d:0.##} MB"; + } + + private static string GetImageUrl(string assetPath) + { + return "/" + (assetPath ?? string.Empty).TrimStart('/').Replace('\\', '/'); + } + + private void Cancel() => MudDialog.Close(); +} diff --git a/src/TelegramPanel.Web/Components/Dialogs/EditGenericApiDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/EditGenericApiDialog.razor index b061a6b..2743a9b 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/EditGenericApiDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/EditGenericApiDialog.razor @@ -57,7 +57,7 @@ { model = Clone(Api); var cfg = model.Config ?? new JsonObject(); - configJson = cfg.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); + configJson = TelegramPanel.Web.Services.LocalConfigFile.ToIndentedJson(cfg); } private void Cancel() => MudDialog.Cancel(); diff --git a/src/TelegramPanel.Web/Components/Dialogs/EditScheduledTaskDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/EditScheduledTaskDialog.razor new file mode 100644 index 0000000..e3108f6 --- /dev/null +++ b/src/TelegramPanel.Web/Components/Dialogs/EditScheduledTaskDialog.razor @@ -0,0 +1,259 @@ +@namespace TelegramPanel.Web.Components.Dialogs +@inject ScheduledTaskService ScheduledTaskManagement +@inject TelegramPanel.Web.Modules.ModuleContributionRegistry Contributions +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@using System.Reflection +@using System.Text.Json +@using Microsoft.AspNetCore.Components +@using TelegramPanel.Data.Entities +@using TelegramPanel.Modules +@using TelegramPanel.Web.Modules +@using TelegramPanel.Web.Services + + + + @if (loading) + { + + + 正在加载计划任务配置... + + } + else if (!string.IsNullOrWhiteSpace(loadError)) + { + + @loadError + + } + else + { + + + Cron 按服务器本地时区解析:@TimeZoneInfo.Local.Id。 + + + + + + @foreach (var preset in CronPresets) + { + + @preset.Label + + } + + + @if (editorComponentType != null) + { + + + @if (!string.IsNullOrWhiteSpace(draft.ValidationError)) + { + @draft.ValidationError + } + } + else if (!hasDedicatedCreateRoute) + { + + + + } + else + { + + 该任务使用独立页面创建,当前不支持在此窗口编辑配置。 + + } + + } + + + 关闭 + + @if (saving) + { + + 保存中... + } + else + { + 保存计划任务 + } + + + + +@code { + private sealed record CronPresetItem(string Label, string Expression); + + private static readonly IReadOnlyList CronPresets = new[] + { + new CronPresetItem("每30分钟", "*/30 * * * *"), + new CronPresetItem("每小时", "0 * * * *"), + new CronPresetItem("每天9点", "0 9 * * *"), + new CronPresetItem("每天12点", "0 12 * * *"), + new CronPresetItem("每天21点", "0 21 * * *") + }; + + [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public int TaskId { get; set; } + + private ScheduledTask? scheduledTask; + private RegisteredTaskDefinition? taskDefinition; + private Type? editorComponentType; + private Dictionary editorParameters = new(); + private ModuleTaskDraft draft = new(0, null, false, null); + private bool loading = true; + private bool saving; + private string? loadError; + private string cronExpression = string.Empty; + private string genericConfigJson = string.Empty; + private int genericTotal; + + private bool hasDedicatedCreateRoute => !string.IsNullOrWhiteSpace(taskDefinition?.Definition.CreateRoute); + + private bool CanSave => + scheduledTask != null + && !string.IsNullOrWhiteSpace(cronExpression) + && !hasDedicatedCreateRoute + && (editorComponentType == null || draft.CanSubmit); + + protected override async Task OnInitializedAsync() + { + try + { + if (TaskId <= 0) + { + loadError = "计划任务参数无效。"; + return; + } + + scheduledTask = await ScheduledTaskManagement.GetAsync(TaskId); + if (scheduledTask == null) + { + loadError = "计划任务不存在或已被删除。"; + return; + } + + cronExpression = scheduledTask.CronExpression; + genericConfigJson = scheduledTask.ConfigJson ?? string.Empty; + genericTotal = Math.Max(0, scheduledTask.Total); + + if (Contributions.TaskTypeToDefinition.TryGetValue((scheduledTask.TaskType ?? string.Empty).Trim(), out var reg)) + { + taskDefinition = reg; + editorComponentType = ModuleTaskEditorComponentResolver.ResolveEditEditor(reg); + if (editorComponentType != null) + editorParameters = BuildEditorParameters(editorComponentType, scheduledTask); + } + } + catch (Exception ex) + { + loadError = ex.Message; + } + finally + { + loading = false; + } + } + + private Dictionary BuildEditorParameters(Type componentType, ScheduledTask task) + { + var parameters = new Dictionary + { + ["Draft"] = draft, + ["DraftChanged"] = EventCallback.Factory.Create(this, OnDraftChanged) + }; + + AddOptionalParameter(parameters, componentType, "InitialConfigJson", task.ConfigJson); + AddOptionalParameter(parameters, componentType, "TaskId", task.Id); + return parameters; + } + + private static void AddOptionalParameter(Dictionary parameters, Type componentType, string parameterName, object? value) + { + var property = componentType.GetProperty(parameterName, BindingFlags.Public | BindingFlags.Instance); + if (property == null) + return; + + parameters[parameterName] = value; + } + + private void OnDraftChanged(ModuleTaskDraft value) + { + draft = value; + } + + private void ApplyCronPreset(string expression) + { + cronExpression = expression; + } + + private async Task Save() + { + if (!CanSave || scheduledTask == null || saving) + return; + + bool? confirm = await DialogService.ShowMessageBox( + "确认保存", + $"将更新计划任务 #{scheduledTask.Id} 的配置,是否继续?", + yesText: "保存", cancelText: "取消"); + + if (confirm != true) + return; + + saving = true; + try + { + var config = genericConfigJson?.Trim(); + var total = Math.Max(0, genericTotal); + + if (editorComponentType != null) + { + if (!draft.CanSubmit) + throw new InvalidOperationException(string.IsNullOrWhiteSpace(draft.ValidationError) ? "请先完善任务配置" : draft.ValidationError); + + config = draft.Config?.Trim(); + total = Math.Max(0, draft.Total); + } + else if (!string.IsNullOrWhiteSpace(config)) + { + JsonDocument.Parse(config); + } + + scheduledTask.CronExpression = cronExpression.Trim(); + scheduledTask.ConfigJson = string.IsNullOrWhiteSpace(config) ? null : config; + scheduledTask.Total = total; + scheduledTask.OwnedAssetScopeId = TaskAssetScopeHelper.GetAssetScopeId(scheduledTask.ConfigJson); + + await ScheduledTaskManagement.UpdateAsync(scheduledTask); + Snackbar.Add($"计划任务 #{scheduledTask.Id} 已更新", Severity.Success); + MudDialog.Close(DialogResult.Ok(scheduledTask.Id)); + } + catch (Exception ex) + { + Snackbar.Add($"保存失败:{ex.Message}", Severity.Error); + } + finally + { + saving = false; + } + } + + private void Cancel() => MudDialog.Close(); +} + diff --git a/src/TelegramPanel.Web/Components/Dialogs/UserChatActiveTaskEditor.razor b/src/TelegramPanel.Web/Components/Dialogs/UserChatActiveTaskEditor.razor index 9e7d4aa..5e282d2 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/UserChatActiveTaskEditor.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/UserChatActiveTaskEditor.razor @@ -1,4 +1,7 @@ @inject AccountCategoryManagementService CategoryManagement +@inject DataDictionaryService DictionaryService +@inject TemplateRenderingService TemplateRendering +@inject Microsoft.Extensions.Options.IOptionsMonitor AiOptionsMonitor @using System.Text.Json @using TelegramPanel.Data.Entities @using TelegramPanel.Modules @@ -8,6 +11,7 @@ 持续活跃任务会在后台循环执行并按间隔发送内容。MaxMessages=0 表示持续运行,直到你在任务中心手动取消。 + 词典消息现在支持 {time} 和文本字典变量;任务仍会先按“随机 / 队列”模式选中一行,再解析其中变量后发送。 @if (loading) @@ -36,14 +40,23 @@ Variant="Variant.Outlined" Lines="5" Immediate="true" - HelperText="支持:用户名(可带 at 前缀)、https://t.me/xxx、频道ID(如 -1001234567890 或 1234567890),如私密群组无法复制链接可登录Telegram Web面板打开群组聊天界面查看链接,数字就是ID" /> + HelperText="支持:用户名(可带 at 前缀)、https://t.me/xxx、频道ID(如 -1001234567890 或 1234567890),如私密群组无法复制链接可登录 Telegram Web 面板打开群组聊天界面查看链接,数字就是 ID" /> + HelperText="支持 {time} 和文本字典变量。任务会先按“随机 / 队列”模式选中一行,再解析变量后发送。" /> + + @if (textDictionaryNames.Count > 0) + { + 可用文本变量:{time}、@(string.Join("、", textDictionaryNames.Select(x => $"{{{x}}}"))) + } + else + { + 当前可用内置变量:{time} + } @@ -84,6 +97,46 @@ + + + + + + @if (enableAiVerification) + { + + + + @UseGlobalDefaultModelLabel + @foreach (var model in selectableAiModels) + { + @model + } + 自定义模型 + + + + + + + + @if (IsCustomAiModelSelected) + { + + } + + + 仅处理同一目标群里的 bot 验证消息,并要求它 @当前执行账号 或 直接回复刚发出的消息。 + 有按钮时会优先让 AI 决定点击哪个按钮;没有按钮时会让 AI 直接生成回复文本。 + + } } @@ -92,21 +145,35 @@ [Parameter] public EventCallback DraftChanged { get; set; } [Parameter] public string? InitialConfigJson { get; set; } + private const string UseGlobalDefaultModelOption = "__use_global_default__"; + private const string CustomModelOption = "__custom_model__"; + private List categories = new(); + private List textDictionaryNames = new(); + private List selectableAiModels = new(); private bool loading = true; private bool initializedFromConfig; private IEnumerable selectedCategoryIds = Array.Empty(); - private string targetsText = ""; - private string dictionaryText = ""; + private string targetsText = string.Empty; + private string dictionaryText = string.Empty; private int delayMinMs = 15; private int delayMaxMs = 45; - private int maxMessages = 0; + private int maxMessages; + private bool enableAiVerification; + private int verificationTimeoutSeconds = 15; + private string? initialAiModel; + private string selectedAiModelOption = UseGlobalDefaultModelOption; + private string customAiModel = string.Empty; + private string globalDefaultAiModelLabel = "使用全局默认(未配置)"; private string accountMode = UserChatActiveTaskModes.Random; private string targetMode = UserChatActiveTaskModes.Queue; private string messageMode = UserChatActiveTaskModes.Random; + private bool IsCustomAiModelSelected => string.Equals(selectedAiModelOption, CustomModelOption, StringComparison.Ordinal); + private string UseGlobalDefaultModelLabel => globalDefaultAiModelLabel; + protected override async Task OnInitializedAsync() { ApplyInitialConfigIfNeeded(); @@ -118,17 +185,21 @@ .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase) .ToList(); - if (!selectedCategoryIds.Any()) - { - var firstCategoryId = categories.FirstOrDefault()?.Id ?? 0; - selectedCategoryIds = firstCategoryId > 0 ? new[] { firstCategoryId } : Array.Empty(); - } + textDictionaryNames = (await DictionaryService.GetAllAsync()) + .Where(x => x.IsEnabled && string.Equals(x.Type, DataDictionaryTypes.Text, StringComparison.OrdinalIgnoreCase)) + .Where(x => x.Items.Any(i => i.IsEnabled && !string.IsNullOrWhiteSpace(i.TextValue))) + .Select(x => x.Name) + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .ToList(); + + LoadAiSettings(); } finally { loading = false; - await PushDraftAsync(); } + + await PushDraftAsync(); } private void ApplyInitialConfigIfNeeded() @@ -148,10 +219,7 @@ if (cfg == null) return; - var categoryIds = NormalizeCategoryIds(cfg.CategoryIds, cfg.CategoryId); - if (categoryIds.Count > 0) - selectedCategoryIds = categoryIds; - + selectedCategoryIds = NormalizeCategoryIds(cfg.CategoryIds, cfg.CategoryId); targetsText = string.Join(Environment.NewLine, cfg.Targets ?? new List()); dictionaryText = string.Join(Environment.NewLine, cfg.Dictionary ?? new List()); delayMinMs = ConvertMillisecondsToSeconds(cfg.DelayMinMs); @@ -160,6 +228,9 @@ accountMode = NormalizeMode(cfg.AccountMode); targetMode = NormalizeMode(cfg.TargetMode); messageMode = NormalizeMode(cfg.MessageMode); + enableAiVerification = cfg.EnableAiVerification; + verificationTimeoutSeconds = cfg.VerificationTimeoutSeconds < 3 ? 15 : cfg.VerificationTimeoutSeconds; + initialAiModel = AiOpenAiSettingsSnapshot.NormalizeModel(cfg.AiModel); } catch { @@ -167,6 +238,50 @@ } } + private void LoadAiSettings() + { + var snapshot = AiOptionsMonitor.CurrentValue.ToSnapshot(); + selectableAiModels = snapshot.GetSelectableModels().ToList(); + var defaultModel = AiOpenAiSettingsSnapshot.NormalizeModel(snapshot.DefaultModel); + globalDefaultAiModelLabel = string.IsNullOrWhiteSpace(defaultModel) + ? "使用全局默认(未配置)" + : $"使用全局默认({defaultModel})"; + + ApplyAiModelSelection(initialAiModel); + } + + private void ApplyAiModelSelection(string? model) + { + var normalized = AiOpenAiSettingsSnapshot.NormalizeModel(model); + if (string.IsNullOrWhiteSpace(normalized)) + { + selectedAiModelOption = UseGlobalDefaultModelOption; + customAiModel = string.Empty; + return; + } + + if (selectableAiModels.Any(x => string.Equals(x, normalized, StringComparison.OrdinalIgnoreCase))) + { + selectedAiModelOption = selectableAiModels.First(x => string.Equals(x, normalized, StringComparison.OrdinalIgnoreCase)); + customAiModel = string.Empty; + return; + } + + selectedAiModelOption = CustomModelOption; + customAiModel = normalized; + } + + private string? ResolveSelectedAiModel() + { + if (string.Equals(selectedAiModelOption, UseGlobalDefaultModelOption, StringComparison.Ordinal)) + return null; + + if (string.Equals(selectedAiModelOption, CustomModelOption, StringComparison.Ordinal)) + return AiOpenAiSettingsSnapshot.NormalizeModel(customAiModel); + + return AiOpenAiSettingsSnapshot.NormalizeModel(selectedAiModelOption); + } + private async Task OnSelectedCategoriesChanged(IEnumerable values) { selectedCategoryIds = NormalizeCategoryIds(values, 0); @@ -215,6 +330,19 @@ return; } + foreach (var template in dictionary) + { + try + { + await TemplateRendering.ValidateTextTemplateAsync(template); + } + catch (Exception ex) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, $"词典消息模板无效:{ex.Message}")); + return; + } + } + if (delayMinMs < 0 || delayMaxMs < 0) { await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "间隔不能为负数")); @@ -239,6 +367,22 @@ return; } + var resolvedAiModel = ResolveSelectedAiModel(); + if (enableAiVerification) + { + if (verificationTimeoutSeconds < 3 || verificationTimeoutSeconds > 300) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "验证等待超时范围应为 3-300 秒")); + return; + } + + if (IsCustomAiModelSelected && string.IsNullOrWhiteSpace(resolvedAiModel)) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请选择有效的自定义模型名")); + return; + } + } + var categoryNames = selectedCategories.Select(x => x.Name).ToList(); var cfg = new UserChatActiveTaskConfig { @@ -253,7 +397,10 @@ MaxMessages = maxMessages, AccountMode = accountMode, TargetMode = targetMode, - MessageMode = messageMode + MessageMode = messageMode, + EnableAiVerification = enableAiVerification, + AiModel = resolvedAiModel, + VerificationTimeoutSeconds = enableAiVerification ? verificationTimeoutSeconds : 15 }; var total = maxMessages > 0 ? maxMessages : 0; diff --git a/src/TelegramPanel.Web/Components/Layout/NavMenu.razor b/src/TelegramPanel.Web/Components/Layout/NavMenu.razor index 9352e23..630db95 100644 --- a/src/TelegramPanel.Web/Components/Layout/NavMenu.razor +++ b/src/TelegramPanel.Web/Components/Layout/NavMenu.razor @@ -1,4 +1,4 @@ -@inject TelegramPanel.Web.Modules.ModuleContributionRegistry Contributions +@inject TelegramPanel.Web.Modules.ModuleContributionRegistry Contributions @@ -33,6 +33,10 @@ 任务中心 + + 数据字典 + + 模块管理 @@ -71,3 +75,5 @@ 退出登录 + + diff --git a/src/TelegramPanel.Web/Components/Pages/AccountImport.razor b/src/TelegramPanel.Web/Components/Pages/AccountImport.razor index ad04068..6271ad5 100644 --- a/src/TelegramPanel.Web/Components/Pages/AccountImport.razor +++ b/src/TelegramPanel.Web/Components/Pages/AccountImport.razor @@ -213,6 +213,7 @@ 批量换绑邮箱(找回+登录)(Cloud Mail)(已选) 批量修改分类(已选) 批量改昵称(已选) + 批量改头像(已选) 批量改用户名(已选) 批量改Bio(已选) @@ -682,6 +683,29 @@ await ReloadImportedAccountsAsync(); } + private async Task OpenBatchChangeAvatar() + { + if (Busy) + return; + + var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList(); + if (ids.Count == 0) + { + Snackbar.Add("请先选择账号", Severity.Info); + return; + } + + var parameters = new DialogParameters + { + ["AccountIds"] = ids + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true }; + var dialog = DialogService.Show("批量修改头像", parameters, options)!; + var result = await dialog.Result; + if (result is { Canceled: false }) + await ReloadImportedAccountsAsync(); + } + private async Task OpenBatchChangeUsername() { if (Busy) diff --git a/src/TelegramPanel.Web/Components/Pages/Accounts.razor b/src/TelegramPanel.Web/Components/Pages/Accounts.razor index 4470f7c..c0e7ef8 100644 --- a/src/TelegramPanel.Web/Components/Pages/Accounts.razor +++ b/src/TelegramPanel.Web/Components/Pages/Accounts.razor @@ -84,6 +84,7 @@ 批量踢出所有其他设备(已选) 批量修改分类(已选) 批量改昵称(已选) + 批量改头像(已选) 批量改用户名(已选) 批量改Bio(已选) 删除已选 @@ -522,6 +523,29 @@ await ReloadTable(); } + private async Task OpenBatchChangeAvatar() + { + if (loading) + return; + + var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList(); + if (ids.Count == 0) + { + Snackbar.Add("请先选择账号", Severity.Info); + return; + } + + var parameters = new DialogParameters + { + ["AccountIds"] = ids + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true }; + var dialog = DialogService.Show("批量修改头像", parameters, options)!; + var result = await dialog.Result; + if (result is { Canceled: false }) + await ReloadTable(); + } + private async Task OpenBatchChangeUsername() { if (loading) diff --git a/src/TelegramPanel.Web/Components/Pages/ApiCenter.razor b/src/TelegramPanel.Web/Components/Pages/ApiCenter.razor index 92dc19e..2df5e6a 100644 --- a/src/TelegramPanel.Web/Components/Pages/ApiCenter.razor +++ b/src/TelegramPanel.Web/Components/Pages/ApiCenter.razor @@ -311,7 +311,7 @@ var root = await LoadOrCreateLocalConfigAsync(); var external = EnsureObject(root, "ExternalApi"); - var options = new JsonSerializerOptions { WriteIndented = true }; + var options = LocalConfigFile.CreateIndentedJsonSerializerOptions(); external["Apis"] = JsonSerializer.SerializeToNode(list, options) ?? new JsonArray(); await SaveLocalConfigAsync(root); @@ -333,7 +333,7 @@ private async Task SaveLocalConfigAsync(JsonObject root) { - var json = root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); + var json = LocalConfigFile.ToIndentedJson(root); var path = LocalConfigPath; await LocalConfigFile.EnsureExistsAsync(path); await LocalConfigFile.WriteJsonAtomicallyAsync(path, json); diff --git a/src/TelegramPanel.Web/Components/Pages/ChannelCreate.razor b/src/TelegramPanel.Web/Components/Pages/ChannelCreate.razor index f31c624..b7dd59b 100644 --- a/src/TelegramPanel.Web/Components/Pages/ChannelCreate.razor +++ b/src/TelegramPanel.Web/Components/Pages/ChannelCreate.razor @@ -1,4 +1,4 @@ -@page "/channels/create" +@page "/channels/create" @inject IChannelService ChannelService @inject AccountManagementService AccountManagement @inject TelegramPanel.Core.Services.AccountRiskService RiskService @@ -185,6 +185,7 @@ } // 保存到数据库 + var now = DateTime.UtcNow; var channel = new Channel { TelegramId = channelInfo.TelegramId, @@ -196,8 +197,9 @@ About = channelAbout, CreatorAccountId = selectedAccountId, GroupId = selectedGroupId > 0 ? selectedGroupId : null, - CreatedAt = DateTime.UtcNow, - SyncedAt = DateTime.UtcNow + CreatedAt = now, + SyncedAt = now, + SystemCreatedAtUtc = now }; await ChannelManagement.CreateOrUpdateChannelAsync(channel); @@ -232,3 +234,5 @@ return namePart; } } + + diff --git a/src/TelegramPanel.Web/Components/Pages/DataDictionaries.razor b/src/TelegramPanel.Web/Components/Pages/DataDictionaries.razor new file mode 100644 index 0000000..ea7872d --- /dev/null +++ b/src/TelegramPanel.Web/Components/Pages/DataDictionaries.razor @@ -0,0 +1,215 @@ +@page "/data-dictionaries" +@inject DataDictionaryService DictionaryService +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@using TelegramPanel.Data.Entities + +数据字典 - Telegram Panel + +数据字典管理 + + + 当前系统内置变量:{time},格式为 yyyyMMddHHmmss,例如 20260310143059。 + 它不是 Unix 时间戳,而是可直接阅读的时间字符串。 + 文本字段通常支持 {time} 和 {字典名};图片字段通常只能使用单个图片字典变量,例如 {avatar}。 + + + + + + + + + 新建文本字典 + + + 新建图片字典 + + + + + + + + + + + 显示名称 + 变量名 + 类型 + 读取模式 + 状态 + 内容数 + 轮询游标 + 更新时间 + 操作 + + + @context.DisplayName + @($"{{{context.Name}}}") + + + @(IsImageDictionary(context) ? "图片" : "文本") + + + @(IsQueueMode(context) ? "队列轮询" : "随机") + + + @(context.IsEnabled ? "启用" : "停用") + + + @context.Items.Count(x => x.IsEnabled) + @context.NextIndex + + + + + + + + + + + + 暂无数据字典 + + + + + +@code { + private List dictionaries = new(); + private bool loading = true; + private string searchText = string.Empty; + + private IEnumerable FilteredDictionaries => + dictionaries.Where(MatchSearch) + .OrderBy(x => x.DisplayName, StringComparer.OrdinalIgnoreCase) + .ThenBy(x => x.Name, StringComparer.OrdinalIgnoreCase); + + protected override async Task OnInitializedAsync() + { + await LoadDictionaries(); + } + + private async Task LoadDictionaries() + { + loading = true; + try + { + dictionaries = (await DictionaryService.GetAllAsync()).ToList(); + } + catch (Exception ex) + { + Snackbar.Add($"加载字典失败:{ex.Message}", Severity.Error); + } + finally + { + loading = false; + } + } + + private bool MatchSearch(DataDictionary dictionary) + { + var text = (searchText ?? string.Empty).Trim(); + if (text.Length == 0) + return true; + + return (dictionary.Name?.Contains(text, StringComparison.OrdinalIgnoreCase) ?? false) + || (dictionary.DisplayName?.Contains(text, StringComparison.OrdinalIgnoreCase) ?? false) + || (dictionary.Description?.Contains(text, StringComparison.OrdinalIgnoreCase) ?? false); + } + + private async Task OpenCreateDialog(string type) + { + var parameters = new DialogParameters + { + ["InitialType"] = type + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Large, FullWidth = true }; + var dialog = DialogService.Show(type == DataDictionaryTypes.Image ? "新建图片字典" : "新建文本字典", parameters, options)!; + var result = await dialog.Result; + if (result is { Canceled: false }) + await LoadDictionaries(); + } + + private async Task OpenEditDialog(DataDictionary dictionary) + { + var parameters = new DialogParameters + { + ["DictionaryId"] = dictionary.Id, + ["InitialType"] = dictionary.Type + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Large, FullWidth = true }; + var dialog = DialogService.Show($"编辑字典:{dictionary.DisplayName}", parameters, options)!; + var result = await dialog.Result; + if (result is { Canceled: false }) + await LoadDictionaries(); + } + + private async Task ToggleEnabled(DataDictionary dictionary) + { + try + { + await DictionaryService.SetEnabledAsync(dictionary.Id, !dictionary.IsEnabled); + await LoadDictionaries(); + Snackbar.Add($"已{(!dictionary.IsEnabled ? "启用" : "停用")}字典 {dictionary.DisplayName}", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"操作失败:{ex.Message}", Severity.Error); + } + } + + private async Task ResetQueue(DataDictionary dictionary) + { + try + { + await DictionaryService.ResetQueueAsync(dictionary.Id); + await LoadDictionaries(); + Snackbar.Add($"已重置字典 {dictionary.DisplayName} 的轮询游标", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"重置失败:{ex.Message}", Severity.Error); + } + } + + private async Task DeleteDictionary(DataDictionary dictionary) + { + bool? confirm = await DialogService.ShowMessageBox( + "确认删除", + $"确定要删除字典 {dictionary.DisplayName} 吗?删除后将同步清理关联图片文件。", + yesText: "删除", cancelText: "取消"); + + if (confirm != true) + return; + + try + { + await DictionaryService.DeleteAsync(dictionary.Id); + await LoadDictionaries(); + Snackbar.Add($"字典 {dictionary.DisplayName} 已删除", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"删除失败:{ex.Message}", Severity.Error); + } + } + + private static bool IsImageDictionary(DataDictionary dictionary) => + string.Equals(dictionary.Type, DataDictionaryTypes.Image, StringComparison.OrdinalIgnoreCase); + + private static bool IsQueueMode(DataDictionary dictionary) => + string.Equals(dictionary.ReadMode, DataDictionaryReadModes.Queue, StringComparison.OrdinalIgnoreCase); +} + diff --git a/src/TelegramPanel.Web/Components/Pages/GroupCreate.razor b/src/TelegramPanel.Web/Components/Pages/GroupCreate.razor index e84c1ba..06d3513 100644 --- a/src/TelegramPanel.Web/Components/Pages/GroupCreate.razor +++ b/src/TelegramPanel.Web/Components/Pages/GroupCreate.razor @@ -1,4 +1,4 @@ -@page "/groups/create" +@page "/groups/create" @inject IGroupService GroupService @inject AccountManagementService AccountManagement @inject TelegramPanel.Core.Services.AccountRiskService RiskService @@ -180,7 +180,8 @@ CreatorAccountId = selectedAccountId, CategoryId = selectedCategoryId > 0 ? selectedCategoryId : null, CreatedAt = groupInfo.CreatedAt ?? now, - SyncedAt = now + SyncedAt = now, + SystemCreatedAtUtc = now }; var savedGroup = await GroupManagement.CreateOrUpdateGroupAsync(group); @@ -222,3 +223,5 @@ return namePart; } } + + diff --git a/src/TelegramPanel.Web/Components/Pages/Settings.razor b/src/TelegramPanel.Web/Components/Pages/Settings.razor index a50e46a..62df050 100644 --- a/src/TelegramPanel.Web/Components/Pages/Settings.razor +++ b/src/TelegramPanel.Web/Components/Pages/Settings.razor @@ -10,10 +10,11 @@ @inject BatchTaskManagementService TaskManagement @inject ISnackbar Snackbar @inject IDialogService DialogService -@inject IWebHostEnvironment Environment +@inject IWebHostEnvironment HostEnvironment @inject PanelTimeZoneService TimeZone @inject NavigationManager Navigation @inject CloudMailClient CloudMail +@inject TelegramPanelAiService PanelAiService @using TelegramPanel.Web.Services @using TelegramPanel.Core.Utils @@ -81,6 +82,49 @@ + + + + AI 设置 + + + + + + + + + 测试连通会直接使用当前表单里的端点、Key、模型和重试次数,不必先保存。 + + + + + 保存 AI 配置 + + + @if (aiTesting) + { + + 测试中... + } + else + { + 测试连通 + } + + + @@ -286,6 +330,12 @@ private string cloudMailAdminEmail = ""; private string cloudMailAdminPassword = ""; private bool cloudMailGeneratingToken = false; + private string aiEndpoint = ""; + private string aiApiKey = ""; + private string aiDefaultModel = ""; + private string aiPresetModelsText = ""; + private int aiRetryCount = 2; + private bool aiTesting = false; private sealed record TimeZoneChoice(string Id, string Label); @@ -308,7 +358,7 @@ } } - private string LocalConfigPath => LocalConfigFile.ResolvePath(Configuration, Environment); + private string LocalConfigPath => LocalConfigFile.ResolvePath(Configuration, HostEnvironment); private bool LocalConfigExists => System.IO.File.Exists(LocalConfigPath); protected override void OnInitialized() @@ -333,6 +383,11 @@ cloudMailBaseUrl = Configuration["CloudMail:BaseUrl"] ?? ""; cloudMailDomain = Configuration["CloudMail:Domain"] ?? ""; cloudMailToken = Configuration["CloudMail:Token"] ?? ""; + aiEndpoint = Configuration["AI:OpenAI:Endpoint"] ?? ""; + aiApiKey = Configuration["AI:OpenAI:ApiKey"] ?? ""; + aiDefaultModel = Configuration["AI:OpenAI:DefaultModel"] ?? ""; + aiPresetModelsText = string.Join(System.Environment.NewLine, Configuration.GetSection("AI:OpenAI:PresetModels").GetChildren().Select(x => x.Value).Where(x => !string.IsNullOrWhiteSpace(x))); + aiRetryCount = AiOpenAiSettingsSnapshot.NormalizeRetryCount(Configuration.GetValue("AI:OpenAI:RetryCount", 2)); } private async Task SaveApiConfig() @@ -432,6 +487,112 @@ } } + private async Task SaveAiConfig() + { + if (aiRetryCount < 0 || aiRetryCount > 5) + { + Snackbar.Add("AI 失败重试次数范围应为 0-5", Severity.Error); + return; + } + + try + { + var normalizedEndpoint = AiOpenAiSettingsSnapshot.NormalizeEndpoint(aiEndpoint); + var normalizedApiKey = AiOpenAiSettingsSnapshot.NormalizeApiKey(aiApiKey); + var normalizedDefaultModel = AiOpenAiSettingsSnapshot.NormalizeModel(aiDefaultModel); + var presetModels = AiOpenAiSettingsSnapshot.NormalizeModelEntries(ParseLines(aiPresetModelsText), normalizedDefaultModel); + var normalizedRetryCount = AiOpenAiSettingsSnapshot.NormalizeRetryCount(aiRetryCount); + + var root = await LoadOrCreateLocalConfigAsync(); + var ai = EnsureObject(root, "AI"); + var openAi = EnsureObject(ai, "OpenAI"); + + openAi["Endpoint"] = normalizedEndpoint ?? string.Empty; + openAi["ApiKey"] = normalizedApiKey ?? string.Empty; + openAi["DefaultModel"] = normalizedDefaultModel ?? string.Empty; + openAi["RetryCount"] = normalizedRetryCount; + + var modelsArray = new System.Text.Json.Nodes.JsonArray(); + foreach (var model in presetModels) + modelsArray.Add(model); + openAi["PresetModels"] = modelsArray; + + await SaveLocalConfigAsync(root); + + aiEndpoint = normalizedEndpoint ?? string.Empty; + aiApiKey = normalizedApiKey ?? string.Empty; + aiDefaultModel = normalizedDefaultModel ?? string.Empty; + aiPresetModelsText = string.Join(System.Environment.NewLine, presetModels); + aiRetryCount = normalizedRetryCount; + + Snackbar.Add("AI 配置已保存(配置热重载后生效)", Severity.Success); + StateHasChanged(); + } + catch (Exception ex) + { + Snackbar.Add($"保存失败:{ex.Message}(路径:{LocalConfigPath})", Severity.Error); + } + } + + private async Task TestAiConnectionAsync() + { + if (aiTesting) + return; + + if (aiRetryCount < 0 || aiRetryCount > 5) + { + Snackbar.Add("AI 失败重试次数范围应为 0-5", Severity.Error); + return; + } + + aiTesting = true; + try + { + var normalizedEndpoint = AiOpenAiSettingsSnapshot.NormalizeEndpoint(aiEndpoint); + var normalizedApiKey = AiOpenAiSettingsSnapshot.NormalizeApiKey(aiApiKey); + var normalizedDefaultModel = AiOpenAiSettingsSnapshot.NormalizeModel(aiDefaultModel); + var presetModels = AiOpenAiSettingsSnapshot.NormalizeModelEntries(ParseLines(aiPresetModelsText), normalizedDefaultModel); + var normalizedRetryCount = AiOpenAiSettingsSnapshot.NormalizeRetryCount(aiRetryCount); + var snapshot = new AiOpenAiSettingsSnapshot(normalizedEndpoint, normalizedApiKey, normalizedDefaultModel, presetModels, normalizedRetryCount); + var testModel = ResolveAiTestModel(snapshot); + + if (!snapshot.TryValidateForTask(testModel, out var validateError)) + { + Snackbar.Add($"AI 连通测试失败:{validateError}", Severity.Warning); + return; + } + + var result = await PanelAiService.TestConnectionAsync(snapshot, testModel, CancellationToken.None); + if (!result.Success) + { + Snackbar.Add($"AI 连通测试失败:{result.Error}", Severity.Error); + return; + } + + var modelName = result.Model ?? snapshot.ResolveModel(testModel) ?? "(未知模型)"; + var preview = string.IsNullOrWhiteSpace(result.ResponseText) ? "(空响应)" : result.ResponseText; + Snackbar.Add($"AI 连通成功:模型 {modelName},返回 {preview}", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"AI 连通测试失败:{ex.Message}", Severity.Error); + } + finally + { + aiTesting = false; + StateHasChanged(); + } + } + + private string? ResolveAiTestModel(AiOpenAiSettingsSnapshot snapshot) + { + var defaultModel = AiOpenAiSettingsSnapshot.NormalizeModel(aiDefaultModel); + if (!string.IsNullOrWhiteSpace(defaultModel)) + return defaultModel; + + return snapshot.PresetModels.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)); + } + private async Task GenerateCloudMailToken() { if (cloudMailGeneratingToken) @@ -536,7 +697,7 @@ private async Task SaveLocalConfigAsync(System.Text.Json.Nodes.JsonObject root) { - var json = root.ToJsonString(new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + var json = LocalConfigFile.ToIndentedJson(root); var path = LocalConfigPath; await LocalConfigFile.EnsureExistsAsync(path); await LocalConfigFile.WriteJsonAtomicallyAsync(path, json); @@ -552,6 +713,18 @@ return created; } + private static List ParseLines(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return new List(); + + return text + .Split(new[] { "\r\n", "\n", "\r" }, StringSplitOptions.RemoveEmptyEntries) + .Select(x => (x ?? string.Empty).Trim()) + .Where(x => x.Length > 0) + .ToList(); + } + private async Task SaveBatchConfig() { if (defaultDelay < 1000 || defaultDelay > 10000) diff --git a/src/TelegramPanel.Web/Components/Pages/Tasks.razor b/src/TelegramPanel.Web/Components/Pages/Tasks.razor index bb13b36..40ae804 100644 --- a/src/TelegramPanel.Web/Components/Pages/Tasks.razor +++ b/src/TelegramPanel.Web/Components/Pages/Tasks.razor @@ -1,5 +1,7 @@ @page "/tasks" @inject BatchTaskManagementService TaskManagement +@inject ScheduledTaskService ScheduledTaskManagement +@inject ImageAssetStorageService AssetStorage @inject TelegramPanel.Web.Modules.ModuleContributionRegistry Contributions @inject IEnumerable TaskRerunBuilders @inject PanelTimeZoneService TimeZone @@ -48,6 +50,83 @@ + + + + + 计划任务 (@scheduledTasks.Count) + + + ID + 归属 + 任务类型 + 状态 + Cron + 下次运行 + 上次运行 + 操作 + + + @context.Id + + @GetCategoryName(context.TaskType) + + + @GetTaskTypeName(context.TaskType) + + + @GetScheduledStatusName(context.Status) + + + @context.CronExpression + 时区:@TimeZone.Current.Id + + + @if (context.NextRunAtUtc.HasValue) + { + + } + else + { + - + } + + + @if (context.LastRunAtUtc.HasValue) + { + + } + else + { + - + } + + + + + @if (string.Equals(context.Status, ScheduledTaskStatuses.Enabled, StringComparison.OrdinalIgnoreCase)) + { + + } + else + { + + } + + + + + 暂无计划任务 + + + + + @@ -192,6 +271,7 @@ @code { private List tasks = new(); + private List scheduledTasks = new(); private bool loading = true; private string historyStatusFilter = "all"; private string filterCategory = "all"; @@ -232,8 +312,11 @@ { while (await refreshTimer.WaitForNextTickAsync(refreshCts.Token)) { - if (tasks.Any(t => t.Status is "pending" or "running")) + if (tasks.Any(t => t.Status is "pending" or "running") + || scheduledTasks.Any(t => string.Equals(t.Status, ScheduledTaskStatuses.Enabled, StringComparison.OrdinalIgnoreCase))) + { await InvokeAsync(LoadTasks); + } } } catch (OperationCanceledException) @@ -248,8 +331,11 @@ loading = true; try { - var taskList = await TaskManagement.GetRecentTasksAsync(50); + var taskList = await TaskManagement.GetRecentTasksAsync(100); tasks = taskList.OrderByDescending(t => t.CreatedAt).ToList(); + scheduledTasks = (await ScheduledTaskManagement.GetAllAsync()) + .OrderByDescending(x => x.CreatedAt) + .ToList(); } catch (Exception ex) { @@ -388,13 +474,6 @@ private static string GetDisplayStatus(BatchTask task) { - if (task.Status == "pending" - && IsUserChatActiveTask(task.TaskType) - && task.Total == 0) - { - return "running"; - } - if (task.Status == "failed" && task.CompletedAt.HasValue && task.Total > 0 && task.Completed >= task.Total) return "completed"; @@ -407,6 +486,16 @@ private static bool IsHistoryStatus(string status) => status is "completed" or "failed"; + private static string GetScheduledStatusName(string status) => + string.Equals((status ?? string.Empty).Trim(), ScheduledTaskStatuses.Paused, StringComparison.OrdinalIgnoreCase) + ? "已暂停" + : "启用中"; + + private static Color GetScheduledStatusColor(string status) => + string.Equals((status ?? string.Empty).Trim(), ScheduledTaskStatuses.Paused, StringComparison.OrdinalIgnoreCase) + ? Color.Warning + : Color.Success; + private void ShowTaskDetails(BatchTask task) { var createdAt = TimeZone.Format(task.CreatedAt, "yyyy-MM-dd HH:mm"); @@ -469,7 +558,7 @@ private bool CanPauseTask(BatchTask task) { var capabilities = GetTaskDefinition(task)?.Definition.TaskCenter; - return capabilities?.CanPause == true && HasStatus(task, "running"); + return capabilities?.CanPause == true && task.Status is "running" or "pending"; } private bool CanResumeTask(BatchTask task) @@ -811,6 +900,7 @@ { try { + await CleanupBatchTaskAssetsAsync(task); await TaskManagement.DeleteTaskAsync(task.Id); await LoadTasks(); Snackbar.Add(isActive ? "任务已删除,后台执行将在下一次状态检查时停止" : "任务已删除", Severity.Success); @@ -861,7 +951,10 @@ } foreach (var item in historyTaskList) + { + await CleanupBatchTaskAssetsAsync(item); await TaskManagement.DeleteTaskAsync(item.Id); + } Snackbar.Add($"已清理 {historyTaskList.Count} 条历史任务记录", Severity.Success); } @@ -871,7 +964,10 @@ await TaskManagement.CompleteTaskAsync(activeTask.Id, success: false); foreach (var item in allTasks) + { + await CleanupBatchTaskAssetsAsync(item); await TaskManagement.DeleteTaskAsync(item.Id); + } tasks.Clear(); Snackbar.Add($"已清理 {allTasks.Count} 条任务记录", Severity.Success); @@ -885,6 +981,114 @@ } } + private async Task CleanupBatchTaskAssetsAsync(BatchTask task) + { + var scopeId = TaskAssetScopeHelper.GetAssetScopeId(task.Config); + if (!string.IsNullOrWhiteSpace(scopeId)) + await AssetStorage.DeleteScopeAsync(scopeId); + } + + private void ShowScheduledTaskDetails(ScheduledTask task) + { + var details = $"任务类型: {GetTaskTypeName(task.TaskType)}\n" + + $"状态: {GetScheduledStatusName(task.Status)}\n" + + $"Cron: {task.CronExpression}\n" + + $"时区: {TimeZone.Current.Id}\n" + + $"总数: {task.Total}\n" + + $"创建时间: {TimeZone.Format(task.CreatedAt, "yyyy-MM-dd HH:mm")}\n" + + $"更新时间: {TimeZone.Format(task.UpdatedAt, "yyyy-MM-dd HH:mm")}\n"; + + if (task.NextRunAtUtc.HasValue) + details += $"下次运行: {TimeZone.Format(task.NextRunAtUtc.Value, "yyyy-MM-dd HH:mm")}\n"; + if (task.LastRunAtUtc.HasValue) + details += $"上次运行: {TimeZone.Format(task.LastRunAtUtc.Value, "yyyy-MM-dd HH:mm")}\n"; + if (task.LastBatchTaskId.HasValue) + details += $"最近批次任务: #{task.LastBatchTaskId.Value}\n"; + if (!string.IsNullOrWhiteSpace(task.ConfigJson)) + details += $"\n配置信息:\n{task.ConfigJson}"; + + var html = System.Net.WebUtility.HtmlEncode(details) + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace("\n", "
", StringComparison.Ordinal); + DialogService.ShowMessageBox("计划任务详情", (MarkupString)html); + } + + private async Task OpenEditScheduledTask(ScheduledTask task) + { + var parameters = new DialogParameters + { + ["TaskId"] = task.Id + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Large, FullWidth = true }; + var dialog = DialogService.Show($"编辑计划任务 #{task.Id}", parameters, options)!; + var result = await dialog.Result; + if (result is { Canceled: false }) + await LoadTasks(); + } + + private async Task PauseScheduledTask(ScheduledTask task) + { + if (string.Equals(task.Status, ScheduledTaskStatuses.Paused, StringComparison.OrdinalIgnoreCase)) + return; + + bool? result = await DialogService.ShowMessageBox( + "确认暂停", + $"确定要暂停计划任务 #{task.Id} 吗?", + yesText: "暂停", cancelText: "返回"); + + if (result != true) + return; + + try + { + await ScheduledTaskManagement.PauseAsync(task.Id); + await LoadTasks(); + Snackbar.Add("计划任务已暂停", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"暂停失败:{ex.Message}", Severity.Error); + } + } + + private async Task ResumeScheduledTask(ScheduledTask task) + { + if (string.Equals(task.Status, ScheduledTaskStatuses.Enabled, StringComparison.OrdinalIgnoreCase)) + return; + + try + { + await ScheduledTaskManagement.ResumeAsync(task.Id); + await LoadTasks(); + Snackbar.Add("计划任务已恢复", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"恢复失败:{ex.Message}", Severity.Error); + } + } + + private async Task DeleteScheduledTask(ScheduledTask task) + { + bool? result = await DialogService.ShowMessageBox( + "确认删除", + $"确定要删除计划任务 #{task.Id} 吗?删除后不会影响已经触发的执行记录。", + yesText: "删除", cancelText: "取消"); + + if (result != true) + return; + + try + { + await ScheduledTaskManagement.DeleteAsync(task.Id); + await LoadTasks(); + Snackbar.Add("计划任务已删除", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"删除失败:{ex.Message}", Severity.Error); + } + } private async Task OpenCreateTaskDialog() { var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Large, FullWidth = true }; @@ -1039,3 +1243,4 @@ + diff --git a/src/TelegramPanel.Web/Modules/BuiltIn/TaskCatalogModule.cs b/src/TelegramPanel.Web/Modules/BuiltIn/TaskCatalogModule.cs index 077c040..2d7519e 100644 --- a/src/TelegramPanel.Web/Modules/BuiltIn/TaskCatalogModule.cs +++ b/src/TelegramPanel.Web/Modules/BuiltIn/TaskCatalogModule.cs @@ -54,6 +54,46 @@ public sealed class TaskCatalogModule : ITelegramPanelModule, IModuleTaskProvide Order = 120 }; + yield return new ModuleTaskDefinition + { + Category = "user", + TaskType = BatchTaskTypes.ChannelGroupPrivateCreate, + DisplayName = "自动创建私密频道/群组", + Description = "按账号分类批量创建私密频道或群组,支持标题变量、固定头像/图片字典头像、数量上限与延时抖动。", + Icon = Icons.Material.Filled.AddCircle, + EditorComponentType = typeof(TelegramPanel.Web.Components.Dialogs.ChannelGroupPrivateCreateTaskEditor).AssemblyQualifiedName ?? "", + TaskCenter = new ModuleTaskCenterCapabilities + { + CanPause = true, + CanResume = true, + CanEdit = true, + CanRerun = true, + EditComponentType = typeof(TelegramPanel.Web.Components.Dialogs.ChannelGroupPrivateCreateTaskEditor).AssemblyQualifiedName ?? "", + AutoPauseBeforeEdit = true + }, + Order = 130 + }; + + yield return new ModuleTaskDefinition + { + Category = "user", + TaskType = BatchTaskTypes.ChannelGroupPublicize, + DisplayName = "私密频道/群组公开化", + Description = "从系统创建的私密频道/群组中按分类挑选候选对象,批量设置标题、描述、用户名与头像后公开。", + Icon = Icons.Material.Filled.Public, + EditorComponentType = typeof(TelegramPanel.Web.Components.Dialogs.ChannelGroupPublicizeTaskEditor).AssemblyQualifiedName ?? "", + TaskCenter = new ModuleTaskCenterCapabilities + { + CanPause = true, + CanResume = true, + CanEdit = true, + CanRerun = true, + EditComponentType = typeof(TelegramPanel.Web.Components.Dialogs.ChannelGroupPublicizeTaskEditor).AssemblyQualifiedName ?? "", + AutoPauseBeforeEdit = true + }, + Order = 140 + }; + yield return new ModuleTaskDefinition { Category = "system", diff --git a/src/TelegramPanel.Web/Program.cs b/src/TelegramPanel.Web/Program.cs index 8559b0c..85345c6 100644 --- a/src/TelegramPanel.Web/Program.cs +++ b/src/TelegramPanel.Web/Program.cs @@ -409,21 +409,34 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.Configure(builder.Configuration.GetSection("UpdateCheck")); builder.Services.Configure(builder.Configuration.GetSection("SelfUpdate")); +builder.Services.Configure(builder.Configuration.GetSection("AI:OpenAI")); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.Configure(builder.Configuration.GetSection("System")); builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHostedService(); +builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHttpClient(); builder.Services.AddHttpClient(); +builder.Services.AddHttpClient(nameof(TelegramPanelAiService)); builder.Services.AddScoped(); builder.Services.AddModuleSystem(builder.Configuration, builder.Environment); builder.Services.AddSingleton(); diff --git a/src/TelegramPanel.Web/Services/AccountDataAutoSyncBackgroundService.cs b/src/TelegramPanel.Web/Services/AccountDataAutoSyncBackgroundService.cs index 0785b60..2729587 100644 --- a/src/TelegramPanel.Web/Services/AccountDataAutoSyncBackgroundService.cs +++ b/src/TelegramPanel.Web/Services/AccountDataAutoSyncBackgroundService.cs @@ -16,6 +16,7 @@ public sealed class AccountDataAutoSyncBackgroundService : BackgroundService private readonly IConfiguration _configuration; private readonly IWebHostEnvironment _environment; private readonly ILogger _logger; + private DateTime? _lastAutoSyncAtUtc; public AccountDataAutoSyncBackgroundService( IServiceScopeFactory scopeFactory, @@ -48,7 +49,7 @@ public sealed class AccountDataAutoSyncBackgroundService : BackgroundService } var nowUtc = DateTime.UtcNow; - var lastUtc = ReadLastAutoSyncAtUtcFromConfig(); + var lastUtc = GetLastAutoSyncAtUtc(); if (lastUtc.HasValue) { var nextUtc = lastUtc.Value.AddHours(hours); @@ -86,7 +87,9 @@ public sealed class AccountDataAutoSyncBackgroundService : BackgroundService summary.AccountFailures.Count); // 记录“上次自动同步时间”,用于容器重启后继续按间隔调度,避免“重启即跑一轮”导致限流。 - await PersistLastAutoSyncAtUtcAsync(DateTime.UtcNow, stoppingToken); + var completedAtUtc = DateTime.UtcNow; + _lastAutoSyncAtUtc = completedAtUtc; + await PersistLastAutoSyncAtUtcAsync(completedAtUtc, stoppingToken); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { @@ -107,13 +110,43 @@ public sealed class AccountDataAutoSyncBackgroundService : BackgroundService } } - private DateTime? ReadLastAutoSyncAtUtcFromConfig() + private DateTime? GetLastAutoSyncAtUtc() { - var raw = (_configuration["Sync:LastAutoSyncAtUtc"] ?? "").Trim(); - if (string.IsNullOrWhiteSpace(raw)) + var configValue = ReadUtcValue(_configuration["Sync:LastAutoSyncAtUtc"]); + var fileValue = ReadLastAutoSyncAtUtcFromLocalConfig(); + return MaxUtc(_lastAutoSyncAtUtc, fileValue, configValue); + } + + private DateTime? ReadLastAutoSyncAtUtcFromLocalConfig() + { + try + { + var path = LocalConfigFile.ResolvePath(_configuration, _environment); + if (!File.Exists(path)) + return null; + + var json = File.ReadAllText(path); + if (string.IsNullOrWhiteSpace(json)) + return null; + + var root = JsonNode.Parse(json)?.AsObject(); + var raw = root?["Sync"]?["LastAutoSyncAtUtc"]?.GetValue(); + return ReadUtcValue(raw); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to read Sync:LastAutoSyncAtUtc from local config (ignored)"); + return null; + } + } + + private static DateTime? ReadUtcValue(string? raw) + { + var value = (raw ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(value)) return null; - if (DateTime.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsed)) + if (DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsed)) { if (parsed.Kind == DateTimeKind.Unspecified) parsed = DateTime.SpecifyKind(parsed, DateTimeKind.Utc); @@ -123,6 +156,22 @@ public sealed class AccountDataAutoSyncBackgroundService : BackgroundService return null; } + private static DateTime? MaxUtc(params DateTime?[] values) + { + DateTime? max = null; + foreach (var value in values) + { + if (!value.HasValue) + continue; + + var utc = value.Value.Kind == DateTimeKind.Utc ? value.Value : value.Value.ToUniversalTime(); + if (!max.HasValue || utc > max.Value) + max = utc; + } + + return max; + } + private async Task PersistLastAutoSyncAtUtcAsync(DateTime utcNow, CancellationToken cancellationToken) { try @@ -144,7 +193,7 @@ public sealed class AccountDataAutoSyncBackgroundService : BackgroundService var sync = EnsureObject(root, "Sync"); sync["LastAutoSyncAtUtc"] = utcNow.ToString("O", CultureInfo.InvariantCulture); - var updatedJson = root.ToJsonString(new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + var updatedJson = LocalConfigFile.ToIndentedJson(root); await LocalConfigFile.WriteJsonAtomicallyAsync(path, updatedJson, cancellationToken); } catch (Exception ex) diff --git a/src/TelegramPanel.Web/Services/AiOpenAiOptions.cs b/src/TelegramPanel.Web/Services/AiOpenAiOptions.cs new file mode 100644 index 0000000..03722e7 --- /dev/null +++ b/src/TelegramPanel.Web/Services/AiOpenAiOptions.cs @@ -0,0 +1,199 @@ +namespace TelegramPanel.Web.Services; + +public sealed class AiOpenAiOptions +{ + public string? Endpoint { get; set; } + + public string? ApiKey { get; set; } + + public string? DefaultModel { get; set; } + + public List PresetModels { get; set; } = new(); + + public int RetryCount { get; set; } = 2; + + public AiOpenAiSettingsSnapshot ToSnapshot() + { + return AiOpenAiSettingsSnapshot.From(this); + } +} + +public sealed record AiOpenAiSettingsSnapshot( + string? Endpoint, + string? ApiKey, + string? DefaultModel, + IReadOnlyList PresetModels, + int RetryCount) +{ + public static AiOpenAiSettingsSnapshot Empty { get; } = new(null, null, null, Array.Empty(), 2); + + public string CompletionsPath => "/chat/completions"; + + public bool HasEndpoint => !string.IsNullOrWhiteSpace(Endpoint); + + public bool HasApiKey => !string.IsNullOrWhiteSpace(ApiKey); + + public bool IsConfigured => HasEndpoint && HasApiKey; + + public string? ResolveModel(string? overrideModel) + { + var preferred = NormalizeModel(overrideModel); + if (!string.IsNullOrWhiteSpace(preferred)) + return preferred; + + return NormalizeModel(DefaultModel); + } + + public string? GetCompletionsEndpoint() + { + if (!HasEndpoint) + return null; + + return $"{Endpoint!.TrimEnd('/')}{CompletionsPath}"; + } + + public IReadOnlyList GetSelectableModels() + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var list = new List(); + + var defaultModel = NormalizeModel(DefaultModel); + if (!string.IsNullOrWhiteSpace(defaultModel) && seen.Add(defaultModel)) + list.Add(defaultModel); + + foreach (var item in PresetModels) + { + var normalized = NormalizeModel(item); + if (string.IsNullOrWhiteSpace(normalized)) + continue; + + if (seen.Add(normalized)) + list.Add(normalized); + } + + return list; + } + + public bool TryValidateForTask(string? overrideModel, out string? error) + { + if (!HasEndpoint) + { + error = "未配置 AI 端点(AI:OpenAI:Endpoint)"; + return false; + } + + var completionsEndpoint = GetCompletionsEndpoint(); + if (string.IsNullOrWhiteSpace(completionsEndpoint) + || !Uri.TryCreate(completionsEndpoint, UriKind.Absolute, out var endpointUri) + || (endpointUri.Scheme != Uri.UriSchemeHttp && endpointUri.Scheme != Uri.UriSchemeHttps)) + { + error = "AI 端点格式无效,请填写如 https://xxx.com/v1"; + return false; + } + + if (!HasApiKey) + { + error = "未配置 AI Key(AI:OpenAI:ApiKey)"; + return false; + } + + if (string.IsNullOrWhiteSpace(ResolveModel(overrideModel))) + { + error = "未配置 AI 模型:请在系统设置填写默认模型,或在任务里选择自定义模型"; + return false; + } + + error = null; + return true; + } + + public static AiOpenAiSettingsSnapshot From(AiOpenAiOptions? options) + { + if (options == null) + return Empty; + + var defaultModel = NormalizeModel(options.DefaultModel); + var presetModels = NormalizeModelEntries(options.PresetModels, defaultModel); + return new AiOpenAiSettingsSnapshot( + NormalizeEndpoint(options.Endpoint), + NormalizeApiKey(options.ApiKey), + defaultModel, + presetModels, + NormalizeRetryCount(options.RetryCount)); + } + + public static string? NormalizeEndpoint(string? value) + { + var endpoint = (value ?? string.Empty).Trim(); + if (endpoint.Length == 0) + return null; + + if (!endpoint.Contains("://", StringComparison.Ordinal)) + endpoint = $"https://{endpoint.TrimStart('/')}"; + + endpoint = endpoint.TrimEnd('/'); + + var suffixes = new[] + { + "/chat/completions", + "/responses", + "/completions" + }; + + foreach (var suffix in suffixes) + { + if (endpoint.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + { + endpoint = endpoint[..^suffix.Length].TrimEnd('/'); + break; + } + } + + return endpoint.Length == 0 ? null : endpoint; + } + + public static string? NormalizeApiKey(string? value) + { + var apiKey = (value ?? string.Empty).Trim(); + return apiKey.Length == 0 ? null : apiKey; + } + + public static string? NormalizeModel(string? value) + { + var model = (value ?? string.Empty).Trim(); + return model.Length == 0 ? null : model; + } + + public static int NormalizeRetryCount(int value) + { + if (value < 0) + return 0; + + return value > 5 ? 5 : value; + } + + public static List NormalizeModelEntries(IEnumerable? values, string? defaultModel = null) + { + var normalizedDefault = NormalizeModel(defaultModel); + var result = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var item in values ?? Array.Empty()) + { + var normalized = NormalizeModel(item); + if (string.IsNullOrWhiteSpace(normalized)) + continue; + + if (!string.IsNullOrWhiteSpace(normalizedDefault) + && string.Equals(normalized, normalizedDefault, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (seen.Add(normalized)) + result.Add(normalized); + } + + return result; + } +} diff --git a/src/TelegramPanel.Web/Services/ChannelGroupAutomationTaskModels.cs b/src/TelegramPanel.Web/Services/ChannelGroupAutomationTaskModels.cs new file mode 100644 index 0000000..d7fb197 --- /dev/null +++ b/src/TelegramPanel.Web/Services/ChannelGroupAutomationTaskModels.cs @@ -0,0 +1,133 @@ +using System.Text.Json.Serialization; + +namespace TelegramPanel.Web.Services; + +public static class ChannelGroupAutomationTaskObjectTypes +{ + public const string Channel = "channel"; + public const string Group = "group"; +} + +public static class ChannelGroupAutomationAvatarSourceModes +{ + public const string None = "none"; + public const string Fixed = "fixed"; + public const string Dictionary = "dictionary"; +} + +public sealed class ChannelGroupPrivateCreateTaskConfig +{ + [JsonPropertyName("category_ids")] + public List CategoryIds { get; set; } = new(); + + [JsonPropertyName("category_names")] + public List CategoryNames { get; set; } = new(); + + [JsonPropertyName("create_type")] + public string CreateType { get; set; } = ChannelGroupAutomationTaskObjectTypes.Channel; + + [JsonPropertyName("channel_group_id")] + public int? ChannelGroupId { get; set; } + + [JsonPropertyName("channel_group_name")] + public string? ChannelGroupName { get; set; } + + [JsonPropertyName("group_category_id")] + public int? GroupCategoryId { get; set; } + + [JsonPropertyName("group_category_name")] + public string? GroupCategoryName { get; set; } + + [JsonPropertyName("system_created_limit")] + public int SystemCreatedLimit { get; set; } = 10; + + [JsonPropertyName("per_account_batch_size")] + public int PerAccountBatchSize { get; set; } = 1; + + [JsonPropertyName("min_delay_seconds")] + public int MinDelaySeconds { get; set; } = 10; + + [JsonPropertyName("max_delay_seconds")] + public int MaxDelaySeconds { get; set; } = 30; + + [JsonPropertyName("jitter_percent")] + public int JitterPercent { get; set; } = 20; + + [JsonPropertyName("title_template")] + public string TitleTemplate { get; set; } = string.Empty; + + [JsonPropertyName("avatar_source")] + public string AvatarSource { get; set; } = ChannelGroupAutomationAvatarSourceModes.None; + + [JsonPropertyName("fixed_avatar_asset_path")] + public string? FixedAvatarAssetPath { get; set; } + + [JsonPropertyName("avatar_dictionary_token")] + public string? AvatarDictionaryToken { get; set; } + + [JsonPropertyName("asset_scope_id")] + public string? AssetScopeId { get; set; } +} + +public sealed class ChannelGroupPublicizeTaskConfig +{ + [JsonPropertyName("category_ids")] + public List CategoryIds { get; set; } = new(); + + [JsonPropertyName("category_names")] + public List CategoryNames { get; set; } = new(); + + [JsonPropertyName("target_type")] + public string TargetType { get; set; } = ChannelGroupAutomationTaskObjectTypes.Channel; + + [JsonPropertyName("channel_group_id")] + public int? ChannelGroupId { get; set; } + + [JsonPropertyName("channel_group_name")] + public string? ChannelGroupName { get; set; } + + [JsonPropertyName("group_category_id")] + public int? GroupCategoryId { get; set; } + + [JsonPropertyName("group_category_name")] + public string? GroupCategoryName { get; set; } + + [JsonPropertyName("min_system_created_days")] + public int MinSystemCreatedDays { get; set; } + + [JsonPropertyName("max_public_count")] + public int MaxPublicCount { get; set; } = 10; + + [JsonPropertyName("per_account_batch_size")] + public int PerAccountBatchSize { get; set; } = 1; + + [JsonPropertyName("min_delay_seconds")] + public int MinDelaySeconds { get; set; } = 10; + + [JsonPropertyName("max_delay_seconds")] + public int MaxDelaySeconds { get; set; } = 30; + + [JsonPropertyName("jitter_percent")] + public int JitterPercent { get; set; } = 20; + + [JsonPropertyName("title_template")] + public string TitleTemplate { get; set; } = string.Empty; + + [JsonPropertyName("description_template")] + public string DescriptionTemplate { get; set; } = string.Empty; + + [JsonPropertyName("username_template")] + public string UsernameTemplate { get; set; } = string.Empty; + + [JsonPropertyName("avatar_source")] + public string AvatarSource { get; set; } = ChannelGroupAutomationAvatarSourceModes.None; + + [JsonPropertyName("fixed_avatar_asset_path")] + public string? FixedAvatarAssetPath { get; set; } + + [JsonPropertyName("avatar_dictionary_token")] + public string? AvatarDictionaryToken { get; set; } + + [JsonPropertyName("asset_scope_id")] + public string? AssetScopeId { get; set; } +} diff --git a/src/TelegramPanel.Web/Services/ChannelGroupPrivateCreateTaskHandler.cs b/src/TelegramPanel.Web/Services/ChannelGroupPrivateCreateTaskHandler.cs new file mode 100644 index 0000000..349eade --- /dev/null +++ b/src/TelegramPanel.Web/Services/ChannelGroupPrivateCreateTaskHandler.cs @@ -0,0 +1,229 @@ +using System.Text.Json; +using TelegramPanel.Core.BatchTasks; +using TelegramPanel.Core.Interfaces; +using TelegramPanel.Core.Services; +using TelegramPanel.Data.Entities; +using TelegramPanel.Modules; + +namespace TelegramPanel.Web.Services; + +/// +/// 自动创建私密频道/群组任务处理器。 +/// +public sealed class ChannelGroupPrivateCreateTaskHandler : IModuleTaskHandler +{ + public string TaskType => BatchTaskTypes.ChannelGroupPrivateCreate; + + public async Task ExecuteAsync(IModuleTaskExecutionHost host, CancellationToken cancellationToken) + { + var config = Deserialize(host.Config); + Validate(config); + + var accountManagement = host.Services.GetRequiredService(); + var channelManagement = host.Services.GetRequiredService(); + var groupManagement = host.Services.GetRequiredService(); + var channelService = host.Services.GetRequiredService(); + var groupService = host.Services.GetRequiredService(); + var taskManagement = host.Services.GetRequiredService(); + var templateRendering = host.Services.GetRequiredService(); + var assetStorage = host.Services.GetRequiredService(); + + var selectedCategoryIds = config.CategoryIds.Where(x => x > 0).ToHashSet(); + var accounts = (await accountManagement.GetActiveAccountsAsync()) + .Where(x => x.UserId > 0) + .Where(x => x.CategoryId.HasValue && selectedCategoryIds.Contains(x.CategoryId.Value)) + .Where(x => x.Category?.ExcludeFromOperations != true) + .OrderBy(x => x.Id) + .ToList(); + if (accounts.Count == 0) + throw new InvalidOperationException("所选账号分类下没有可用账号"); + + var created = 0; + var failed = 0; + var systemCreatedCountByAccount = string.Equals(config.CreateType, ChannelGroupAutomationTaskObjectTypes.Channel, StringComparison.OrdinalIgnoreCase) + ? (await channelManagement.GetAllChannelsAsync()) + .Where(x => x.CreatorAccountId.HasValue && x.SystemCreatedAtUtc.HasValue) + .GroupBy(x => x.CreatorAccountId!.Value) + .ToDictionary(x => x.Key, x => x.Count()) + : (await groupManagement.GetAllGroupsAsync()) + .Where(x => x.CreatorAccountId.HasValue && x.SystemCreatedAtUtc.HasValue) + .GroupBy(x => x.CreatorAccountId!.Value) + .ToDictionary(x => x.Key, x => x.Count()); + + foreach (var account in accounts) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!await host.IsStillRunningAsync(cancellationToken)) + return; + + var createdCount = systemCreatedCountByAccount.TryGetValue(account.Id, out var current) ? current : 0; + var processedForAccount = 0; + while (processedForAccount < config.PerAccountBatchSize && createdCount < config.SystemCreatedLimit) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!await host.IsStillRunningAsync(cancellationToken)) + return; + + try + { + var title = (await templateRendering.RenderTextTemplateAsync(config.TitleTemplate, cancellationToken)).Trim(); + if (title.Length == 0) + throw new InvalidOperationException("标题模板解析结果为空"); + + if (string.Equals(config.CreateType, ChannelGroupAutomationTaskObjectTypes.Channel, StringComparison.OrdinalIgnoreCase)) + { + var info = await channelService.CreateChannelAsync(account.Id, title, string.Empty, isPublic: false); + var now = DateTime.UtcNow; + var channel = new Channel + { + TelegramId = info.TelegramId, + AccessHash = info.AccessHash, + Title = title, + Username = null, + IsBroadcast = true, + MemberCount = info.MemberCount, + About = string.Empty, + CreatorAccountId = account.Id, + GroupId = config.ChannelGroupId, + CreatedAt = info.CreatedAt ?? now, + SystemCreatedAtUtc = now, + SyncedAt = now + }; + var saved = await channelManagement.CreateOrUpdateChannelAsync(channel); + await channelManagement.UpsertAccountChannelAsync(account.Id, saved.Id, true, true, now); + await ApplyAvatarIfNeededAsync(config, saved.TelegramId, isChannel: true, account.Id, templateRendering, assetStorage, channelService, groupService, cancellationToken); + } + else + { + var info = await groupService.CreateGroupAsync(account.Id, title, string.Empty, isPublic: false, username: null); + var now = DateTime.UtcNow; + var group = new Group + { + TelegramId = info.TelegramId, + AccessHash = info.AccessHash, + Title = title, + Username = null, + MemberCount = Math.Max(info.MemberCount, 1), + About = string.Empty, + CreatorAccountId = account.Id, + CategoryId = config.GroupCategoryId, + CreatedAt = info.CreatedAt ?? now, + SystemCreatedAtUtc = now, + SyncedAt = now + }; + var saved = await groupManagement.CreateOrUpdateGroupAsync(group); + await groupManagement.UpsertAccountGroupAsync(account.Id, saved.Id, true, true, now); + await ApplyAvatarIfNeededAsync(config, saved.TelegramId, isChannel: false, account.Id, templateRendering, assetStorage, channelService, groupService, cancellationToken); + } + + processedForAccount++; + createdCount++; + systemCreatedCountByAccount[account.Id] = createdCount; + created++; + await host.UpdateProgressAsync(created, failed, cancellationToken); + } + catch + { + processedForAccount++; + created++; + failed++; + await host.UpdateProgressAsync(created, failed, cancellationToken); + } + + if (processedForAccount < config.PerAccountBatchSize && createdCount < config.SystemCreatedLimit) + { + var continued = await ChannelGroupTaskDelayHelper.DelayAsync( + host, + config.MinDelaySeconds, + config.MaxDelaySeconds, + config.JitterPercent, + cancellationToken); + if (!continued) + return; + } + } + } + + await taskManagement.UpdateTaskDraftAsync(host.TaskId, created, host.Config); + } + + private static async Task ApplyAvatarIfNeededAsync( + ChannelGroupPrivateCreateTaskConfig config, + long telegramId, + bool isChannel, + int accountId, + TemplateRenderingService templateRendering, + ImageAssetStorageService assetStorage, + IChannelService channelService, + IGroupService groupService, + CancellationToken cancellationToken) + { + if (string.Equals(config.AvatarSource, ChannelGroupAutomationAvatarSourceModes.None, StringComparison.OrdinalIgnoreCase)) + return; + + StoredImageAssetInfo? asset = null; + if (string.Equals(config.AvatarSource, ChannelGroupAutomationAvatarSourceModes.Fixed, StringComparison.OrdinalIgnoreCase)) + { + var path = (config.FixedAvatarAssetPath ?? string.Empty).Trim(); + if (path.Length == 0) + throw new InvalidOperationException("固定头像路径为空"); + asset = new StoredImageAssetInfo(path, Path.GetFileName(path)); + } + else if (string.Equals(config.AvatarSource, ChannelGroupAutomationAvatarSourceModes.Dictionary, StringComparison.OrdinalIgnoreCase)) + { + asset = await templateRendering.ResolveImageTemplateAsync(config.AvatarDictionaryToken ?? string.Empty, cancellationToken); + } + + if (asset == null) + return; + + await using var stream = await assetStorage.OpenReadAsync(asset.AssetPath, cancellationToken); + if (isChannel) + await channelService.SetChannelPhotoAsync(accountId, telegramId, stream, asset.FileName, cancellationToken); + else + await groupService.SetGroupPhotoAsync(accountId, telegramId, stream, asset.FileName, cancellationToken); + } + + private static ChannelGroupPrivateCreateTaskConfig Deserialize(string? config) + { + if (string.IsNullOrWhiteSpace(config)) + throw new InvalidOperationException("任务配置为空"); + + return JsonSerializer.Deserialize(config) + ?? throw new InvalidOperationException("任务配置解析失败"); + } + + private static void Validate(ChannelGroupPrivateCreateTaskConfig config) + { + config.CategoryIds = (config.CategoryIds ?? new List()) + .Where(x => x > 0) + .Distinct() + .ToList(); + if (config.CategoryIds.Count == 0) + throw new InvalidOperationException("请至少选择一个账号分类"); + + config.CreateType = string.Equals(config.CreateType, ChannelGroupAutomationTaskObjectTypes.Group, StringComparison.OrdinalIgnoreCase) + ? ChannelGroupAutomationTaskObjectTypes.Group + : ChannelGroupAutomationTaskObjectTypes.Channel; + + config.TitleTemplate = (config.TitleTemplate ?? string.Empty).Trim(); + if (config.TitleTemplate.Length == 0) + throw new InvalidOperationException("标题模板不能为空"); + + config.SystemCreatedLimit = Math.Max(1, config.SystemCreatedLimit); + config.PerAccountBatchSize = Math.Max(1, config.PerAccountBatchSize); + config.MinDelaySeconds = ChannelGroupTaskDelayHelper.NormalizeMinDelay(config.MinDelaySeconds); + config.MaxDelaySeconds = ChannelGroupTaskDelayHelper.NormalizeMaxDelay(config.MinDelaySeconds, config.MaxDelaySeconds); + config.JitterPercent = ChannelGroupTaskDelayHelper.NormalizeJitterPercent(config.JitterPercent); + config.AvatarSource = NormalizeAvatarSource(config.AvatarSource); + } + + private static string NormalizeAvatarSource(string? value) + { + return string.Equals((value ?? string.Empty).Trim(), ChannelGroupAutomationAvatarSourceModes.Fixed, StringComparison.OrdinalIgnoreCase) + ? ChannelGroupAutomationAvatarSourceModes.Fixed + : string.Equals((value ?? string.Empty).Trim(), ChannelGroupAutomationAvatarSourceModes.Dictionary, StringComparison.OrdinalIgnoreCase) + ? ChannelGroupAutomationAvatarSourceModes.Dictionary + : ChannelGroupAutomationAvatarSourceModes.None; + } +} diff --git a/src/TelegramPanel.Web/Services/ChannelGroupPublicizeTaskHandler.cs b/src/TelegramPanel.Web/Services/ChannelGroupPublicizeTaskHandler.cs new file mode 100644 index 0000000..5248f90 --- /dev/null +++ b/src/TelegramPanel.Web/Services/ChannelGroupPublicizeTaskHandler.cs @@ -0,0 +1,288 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using TelegramPanel.Core.BatchTasks; +using TelegramPanel.Core.Interfaces; +using TelegramPanel.Core.Services; +using TelegramPanel.Data.Entities; +using TelegramPanel.Modules; + +namespace TelegramPanel.Web.Services; + +/// +/// 将私密频道/群组公开化任务处理器。 +/// +public sealed class ChannelGroupPublicizeTaskHandler : IModuleTaskHandler +{ + private static readonly Regex UsernameRegex = new("^[a-z][a-z0-9_]{4,31}$", RegexOptions.Compiled); + + public string TaskType => BatchTaskTypes.ChannelGroupPublicize; + + public async Task ExecuteAsync(IModuleTaskExecutionHost host, CancellationToken cancellationToken) + { + var config = Deserialize(host.Config); + Validate(config); + + var accountManagement = host.Services.GetRequiredService(); + var channelManagement = host.Services.GetRequiredService(); + var groupManagement = host.Services.GetRequiredService(); + var channelService = host.Services.GetRequiredService(); + var groupService = host.Services.GetRequiredService(); + var taskManagement = host.Services.GetRequiredService(); + var templateRendering = host.Services.GetRequiredService(); + var assetStorage = host.Services.GetRequiredService(); + + var selectedCategoryIds = config.CategoryIds.Where(x => x > 0).ToHashSet(); + var accounts = (await accountManagement.GetActiveAccountsAsync()) + .Where(x => x.UserId > 0) + .Where(x => x.CategoryId.HasValue && selectedCategoryIds.Contains(x.CategoryId.Value)) + .Where(x => x.Category?.ExcludeFromOperations != true) + .OrderBy(x => x.Id) + .ToList(); + if (accounts.Count == 0) + throw new InvalidOperationException("所选账号分类下没有可用账号"); + + var now = DateTime.UtcNow; + var processed = 0; + var failed = 0; + if (string.Equals(config.TargetType, ChannelGroupAutomationTaskObjectTypes.Channel, StringComparison.OrdinalIgnoreCase)) + { + var channels = (await channelManagement.GetAllChannelsAsync()).ToList(); + foreach (var account in accounts) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!await host.IsStillRunningAsync(cancellationToken)) + return; + + var publicCount = channels.Count(x => x.CreatorAccountId == account.Id && !string.IsNullOrWhiteSpace(x.Username)); + var candidates = channels + .Where(x => x.CreatorAccountId == account.Id) + .Where(x => string.IsNullOrWhiteSpace(x.Username)) + .Where(x => x.GroupId == config.ChannelGroupId) + .Where(x => x.SystemCreatedAtUtc.HasValue && x.SystemCreatedAtUtc.Value <= now.AddDays(-config.MinSystemCreatedDays)) + .OrderBy(x => x.SystemCreatedAtUtc) + .ThenBy(x => x.Id) + .ToList(); + + var processedForAccount = 0; + foreach (var channel in candidates) + { + if (processedForAccount >= config.PerAccountBatchSize || publicCount >= config.MaxPublicCount) + break; + + try + { + var title = (await templateRendering.RenderTextTemplateAsync(config.TitleTemplate, cancellationToken)).Trim(); + var about = string.IsNullOrWhiteSpace(config.DescriptionTemplate) + ? channel.About + : (await templateRendering.RenderTextTemplateAsync(config.DescriptionTemplate, cancellationToken)).Trim(); + var username = NormalizeUsername(await templateRendering.RenderTextTemplateAsync(config.UsernameTemplate, cancellationToken)); + ValidateUsername(username); + + await channelService.UpdateChannelInfoAsync(account.Id, channel.TelegramId, title, about); + await ApplyAvatarIfNeededAsync(config, channel.TelegramId, true, account.Id, templateRendering, assetStorage, channelService, groupService, cancellationToken); + await channelService.SetChannelVisibilityAsync(account.Id, channel.TelegramId, true, username); + + channel.Title = title; + channel.About = about; + channel.Username = username; + channel.SyncedAt = DateTime.UtcNow; + await channelManagement.UpdateChannelAsync(channel); + + publicCount++; + processedForAccount++; + processed++; + await host.UpdateProgressAsync(processed, failed, cancellationToken); + } + catch + { + processedForAccount++; + processed++; + failed++; + await host.UpdateProgressAsync(processed, failed, cancellationToken); + } + + if (processedForAccount < config.PerAccountBatchSize && publicCount < config.MaxPublicCount) + { + var continued = await ChannelGroupTaskDelayHelper.DelayAsync( + host, + config.MinDelaySeconds, + config.MaxDelaySeconds, + config.JitterPercent, + cancellationToken); + if (!continued) + return; + } + } + } + } + else + { + var groups = (await groupManagement.GetAllGroupsAsync()).ToList(); + foreach (var account in accounts) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!await host.IsStillRunningAsync(cancellationToken)) + return; + + var publicCount = groups.Count(x => x.CreatorAccountId == account.Id && !string.IsNullOrWhiteSpace(x.Username)); + var candidates = groups + .Where(x => x.CreatorAccountId == account.Id) + .Where(x => string.IsNullOrWhiteSpace(x.Username)) + .Where(x => x.CategoryId == config.GroupCategoryId) + .Where(x => x.SystemCreatedAtUtc.HasValue && x.SystemCreatedAtUtc.Value <= now.AddDays(-config.MinSystemCreatedDays)) + .OrderBy(x => x.SystemCreatedAtUtc) + .ThenBy(x => x.Id) + .ToList(); + + var processedForAccount = 0; + foreach (var group in candidates) + { + if (processedForAccount >= config.PerAccountBatchSize || publicCount >= config.MaxPublicCount) + break; + + try + { + var title = (await templateRendering.RenderTextTemplateAsync(config.TitleTemplate, cancellationToken)).Trim(); + var about = string.IsNullOrWhiteSpace(config.DescriptionTemplate) + ? group.About + : (await templateRendering.RenderTextTemplateAsync(config.DescriptionTemplate, cancellationToken)).Trim(); + var username = NormalizeUsername(await templateRendering.RenderTextTemplateAsync(config.UsernameTemplate, cancellationToken)); + ValidateUsername(username); + + await groupService.UpdateGroupInfoAsync(account.Id, group.TelegramId, title, about); + await ApplyAvatarIfNeededAsync(config, group.TelegramId, false, account.Id, templateRendering, assetStorage, channelService, groupService, cancellationToken); + await groupService.SetGroupVisibilityAsync(account.Id, group.TelegramId, true, username); + + group.Title = title; + group.About = about; + group.Username = username; + group.SyncedAt = DateTime.UtcNow; + await groupManagement.UpdateGroupAsync(group); + + publicCount++; + processedForAccount++; + processed++; + await host.UpdateProgressAsync(processed, failed, cancellationToken); + } + catch + { + processedForAccount++; + processed++; + failed++; + await host.UpdateProgressAsync(processed, failed, cancellationToken); + } + + if (processedForAccount < config.PerAccountBatchSize && publicCount < config.MaxPublicCount) + { + var continued = await ChannelGroupTaskDelayHelper.DelayAsync( + host, + config.MinDelaySeconds, + config.MaxDelaySeconds, + config.JitterPercent, + cancellationToken); + if (!continued) + return; + } + } + } + } + + await taskManagement.UpdateTaskDraftAsync(host.TaskId, processed, host.Config); + } + + private static async Task ApplyAvatarIfNeededAsync( + ChannelGroupPublicizeTaskConfig config, + long telegramId, + bool isChannel, + int accountId, + TemplateRenderingService templateRendering, + ImageAssetStorageService assetStorage, + IChannelService channelService, + IGroupService groupService, + CancellationToken cancellationToken) + { + if (string.Equals(config.AvatarSource, ChannelGroupAutomationAvatarSourceModes.None, StringComparison.OrdinalIgnoreCase)) + return; + + StoredImageAssetInfo? asset = null; + if (string.Equals(config.AvatarSource, ChannelGroupAutomationAvatarSourceModes.Fixed, StringComparison.OrdinalIgnoreCase)) + { + var path = (config.FixedAvatarAssetPath ?? string.Empty).Trim(); + if (path.Length == 0) + throw new InvalidOperationException("固定头像路径为空"); + asset = new StoredImageAssetInfo(path, Path.GetFileName(path)); + } + else if (string.Equals(config.AvatarSource, ChannelGroupAutomationAvatarSourceModes.Dictionary, StringComparison.OrdinalIgnoreCase)) + { + asset = await templateRendering.ResolveImageTemplateAsync(config.AvatarDictionaryToken ?? string.Empty, cancellationToken); + } + + if (asset == null) + return; + + await using var stream = await assetStorage.OpenReadAsync(asset.AssetPath, cancellationToken); + if (isChannel) + await channelService.SetChannelPhotoAsync(accountId, telegramId, stream, asset.FileName, cancellationToken); + else + await groupService.SetGroupPhotoAsync(accountId, telegramId, stream, asset.FileName, cancellationToken); + } + + private static ChannelGroupPublicizeTaskConfig Deserialize(string? config) + { + if (string.IsNullOrWhiteSpace(config)) + throw new InvalidOperationException("任务配置为空"); + + return JsonSerializer.Deserialize(config) + ?? throw new InvalidOperationException("任务配置解析失败"); + } + + private static void Validate(ChannelGroupPublicizeTaskConfig config) + { + config.CategoryIds = (config.CategoryIds ?? new List()) + .Where(x => x > 0) + .Distinct() + .ToList(); + if (config.CategoryIds.Count == 0) + throw new InvalidOperationException("请至少选择一个账号分类"); + + config.TargetType = string.Equals(config.TargetType, ChannelGroupAutomationTaskObjectTypes.Group, StringComparison.OrdinalIgnoreCase) + ? ChannelGroupAutomationTaskObjectTypes.Group + : ChannelGroupAutomationTaskObjectTypes.Channel; + + config.TitleTemplate = (config.TitleTemplate ?? string.Empty).Trim(); + config.DescriptionTemplate = (config.DescriptionTemplate ?? string.Empty).Trim(); + config.UsernameTemplate = (config.UsernameTemplate ?? string.Empty).Trim(); + if (config.TitleTemplate.Length == 0) + throw new InvalidOperationException("标题模板不能为空"); + if (config.UsernameTemplate.Length == 0) + throw new InvalidOperationException("公开用户名模板不能为空"); + + config.MinSystemCreatedDays = Math.Max(0, config.MinSystemCreatedDays); + config.MaxPublicCount = Math.Max(1, config.MaxPublicCount); + config.PerAccountBatchSize = Math.Max(1, config.PerAccountBatchSize); + config.MinDelaySeconds = ChannelGroupTaskDelayHelper.NormalizeMinDelay(config.MinDelaySeconds); + config.MaxDelaySeconds = ChannelGroupTaskDelayHelper.NormalizeMaxDelay(config.MinDelaySeconds, config.MaxDelaySeconds); + config.JitterPercent = ChannelGroupTaskDelayHelper.NormalizeJitterPercent(config.JitterPercent); + config.AvatarSource = NormalizeAvatarSource(config.AvatarSource); + } + + private static string NormalizeAvatarSource(string? value) + { + return string.Equals((value ?? string.Empty).Trim(), ChannelGroupAutomationAvatarSourceModes.Fixed, StringComparison.OrdinalIgnoreCase) + ? ChannelGroupAutomationAvatarSourceModes.Fixed + : string.Equals((value ?? string.Empty).Trim(), ChannelGroupAutomationAvatarSourceModes.Dictionary, StringComparison.OrdinalIgnoreCase) + ? ChannelGroupAutomationAvatarSourceModes.Dictionary + : ChannelGroupAutomationAvatarSourceModes.None; + } + + private static string NormalizeUsername(string value) + { + return (value ?? string.Empty).Trim().TrimStart('@').ToLowerInvariant(); + } + + private static void ValidateUsername(string username) + { + if (!UsernameRegex.IsMatch(username)) + throw new InvalidOperationException("公开用户名不合法,必须为 5-32 位小写字母/数字/下划线,且以字母开头"); + } +} diff --git a/src/TelegramPanel.Web/Services/ChannelGroupTaskDelayHelper.cs b/src/TelegramPanel.Web/Services/ChannelGroupTaskDelayHelper.cs new file mode 100644 index 0000000..0a03da9 --- /dev/null +++ b/src/TelegramPanel.Web/Services/ChannelGroupTaskDelayHelper.cs @@ -0,0 +1,68 @@ +using TelegramPanel.Modules; + +namespace TelegramPanel.Web.Services; + +/// +/// 频道/群组任务延时辅助工具。 +/// +public static class ChannelGroupTaskDelayHelper +{ + public static int NormalizeMinDelay(int value) => Math.Max(0, value); + + public static int NormalizeMaxDelay(int minValue, int maxValue) + { + minValue = NormalizeMinDelay(minValue); + maxValue = Math.Max(0, maxValue); + return Math.Max(minValue, maxValue); + } + + public static int NormalizeJitterPercent(int value) => Math.Clamp(value, 0, 100); + + public static int ComputeDelayMilliseconds(int minSeconds, int maxSeconds, int jitterPercent) + { + minSeconds = NormalizeMinDelay(minSeconds); + maxSeconds = NormalizeMaxDelay(minSeconds, maxSeconds); + jitterPercent = NormalizeJitterPercent(jitterPercent); + + var baseSeconds = maxSeconds <= minSeconds + ? minSeconds + : Random.Shared.Next(minSeconds, maxSeconds + 1); + + var factor = 1d; + if (jitterPercent > 0) + { + var minFactor = 1d - jitterPercent / 100d; + var maxFactor = 1d + jitterPercent / 100d; + factor = minFactor + Random.Shared.NextDouble() * (maxFactor - minFactor); + } + + var seconds = Math.Max(0, (int)Math.Ceiling(baseSeconds * factor)); + return seconds * 1000; + } + + public static async Task DelayAsync( + IModuleTaskExecutionHost host, + int minSeconds, + int maxSeconds, + int jitterPercent, + CancellationToken cancellationToken) + { + var delayMs = ComputeDelayMilliseconds(minSeconds, maxSeconds, jitterPercent); + if (delayMs <= 0) + return true; + + var remaining = delayMs; + while (remaining > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!await host.IsStillRunningAsync(cancellationToken)) + return false; + + var chunk = Math.Min(remaining, 1000); + await Task.Delay(chunk, cancellationToken); + remaining -= chunk; + } + + return true; + } +} diff --git a/src/TelegramPanel.Web/Services/CronExpressionService.cs b/src/TelegramPanel.Web/Services/CronExpressionService.cs new file mode 100644 index 0000000..364a20a --- /dev/null +++ b/src/TelegramPanel.Web/Services/CronExpressionService.cs @@ -0,0 +1,269 @@ +namespace TelegramPanel.Web.Services; + +/// +/// 轻量 Cron 表达式服务(5 位:分 时 日 月 周)。 +/// +public sealed class CronExpressionService +{ + public DateTime? GetNextOccurrenceUtc(string expression, DateTime fromUtc) + { + var schedule = CronSchedule.Parse(expression); + return schedule.GetNextOccurrenceUtc(fromUtc, TimeZoneInfo.Local); + } + + public bool TryValidate(string expression, out string? error) + { + try + { + _ = CronSchedule.Parse(expression); + error = null; + return true; + } + catch (Exception ex) + { + error = ex.Message; + return false; + } + } + + private sealed class CronSchedule + { + private readonly CronField _minutes; + private readonly CronField _hours; + private readonly CronField _days; + private readonly CronField _months; + private readonly CronField _weekdays; + + private CronSchedule(CronField minutes, CronField hours, CronField days, CronField months, CronField weekdays) + { + _minutes = minutes; + _hours = hours; + _days = days; + _months = months; + _weekdays = weekdays; + } + + public static CronSchedule Parse(string expression) + { + var parts = (expression ?? string.Empty) + .Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length != 5) + throw new InvalidOperationException("Cron 表达式必须是 5 段:分 时 日 月 周"); + + return new CronSchedule( + CronField.Parse(parts[0], 0, 59, FieldKind.Minute), + CronField.Parse(parts[1], 0, 23, FieldKind.Hour), + CronField.Parse(parts[2], 1, 31, FieldKind.Day), + CronField.Parse(parts[3], 1, 12, FieldKind.Month), + CronField.Parse(parts[4], 0, 6, FieldKind.Weekday)); + } + + public DateTime? GetNextOccurrenceUtc(DateTime fromUtc, TimeZoneInfo timeZone) + { + var local = TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(fromUtc, DateTimeKind.Utc), timeZone); + var candidate = new DateTime(local.Year, local.Month, local.Day, local.Hour, local.Minute, 0, DateTimeKind.Unspecified) + .AddMinutes(1); + + for (var i = 0; i < 60 * 24 * 366 * 5; i++) + { + if (Matches(candidate)) + { + try + { + return TimeZoneInfo.ConvertTimeToUtc(candidate, timeZone); + } + catch (ArgumentException) + { + // 跳过夏令时无效时间。 + } + } + + candidate = candidate.AddMinutes(1); + } + + return null; + } + + private bool Matches(DateTime candidate) + { + if (!_minutes.Contains(candidate.Minute)) + return false; + if (!_hours.Contains(candidate.Hour)) + return false; + if (!_months.Contains(candidate.Month)) + return false; + + var dayMatch = _days.Contains(candidate.Day); + var weekMatch = _weekdays.Contains((int)candidate.DayOfWeek); + var calendarMatch = _days.IsAny && _weekdays.IsAny + ? true + : _days.IsAny + ? weekMatch + : _weekdays.IsAny + ? dayMatch + : dayMatch || weekMatch; + + return calendarMatch; + } + + private enum FieldKind + { + Minute, + Hour, + Day, + Month, + Weekday + } + + private sealed class CronField + { + private readonly bool[] _allowed; + + private CronField(bool[] allowed, bool isAny) + { + _allowed = allowed; + IsAny = isAny; + } + + public bool IsAny { get; } + + public bool Contains(int value) + { + if (value < 0 || value >= _allowed.Length) + return false; + return _allowed[value]; + } + + public static CronField Parse(string text, int min, int max, FieldKind kind) + { + text = (text ?? string.Empty).Trim(); + if (text.Length == 0) + throw new InvalidOperationException("Cron 字段不能为空"); + + var size = max + 1; + var allowed = new bool[size]; + var isAny = text is "*" or "?"; + if (isAny) + { + for (var value = min; value <= max; value++) + allowed[value] = true; + return new CronField(allowed, true); + } + + foreach (var part in text.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + ApplyPart(part, allowed, min, max, kind); + } + + if (!allowed.Any(x => x)) + throw new InvalidOperationException($"Cron 字段无可用值:{text}"); + + return new CronField(allowed, false); + } + + private static void ApplyPart(string part, bool[] allowed, int min, int max, FieldKind kind) + { + var step = 1; + var rangePart = part; + + var slashIndex = part.IndexOf('/'); + if (slashIndex >= 0) + { + rangePart = part[..slashIndex]; + var stepPart = part[(slashIndex + 1)..]; + if (!int.TryParse(stepPart, out step) || step <= 0) + throw new InvalidOperationException($"Cron 步长无效:{part}"); + } + + int start; + int end; + if (rangePart is "*" or "?") + { + start = min; + end = max; + } + else if (rangePart.Contains('-')) + { + var segments = rangePart.Split('-', 2, StringSplitOptions.TrimEntries); + start = ParseValue(segments[0], kind); + end = ParseValue(segments[1], kind); + } + else + { + start = ParseValue(rangePart, kind); + end = slashIndex >= 0 ? max : start; + } + + if (kind == FieldKind.Weekday) + { + start = NormalizeWeekday(start); + end = NormalizeWeekday(end); + } + + if (start < min || start > max || end < min || end > max) + throw new InvalidOperationException($"Cron 取值超出范围:{part}"); + if (end < start) + throw new InvalidOperationException($"Cron 范围无效:{part}"); + + for (var value = start; value <= end; value += step) + allowed[value] = true; + } + + private static int ParseValue(string raw, FieldKind kind) + { + raw = (raw ?? string.Empty).Trim().ToUpperInvariant(); + if (raw.Length == 0) + throw new InvalidOperationException("Cron 字段取值不能为空"); + + if (kind == FieldKind.Month) + { + var month = raw switch + { + "JAN" => 1, + "FEB" => 2, + "MAR" => 3, + "APR" => 4, + "MAY" => 5, + "JUN" => 6, + "JUL" => 7, + "AUG" => 8, + "SEP" => 9, + "OCT" => 10, + "NOV" => 11, + "DEC" => 12, + _ => (int?)null + }; + if (month.HasValue) + return month.Value; + } + + if (kind == FieldKind.Weekday) + { + var weekday = raw switch + { + "SUN" => 0, + "MON" => 1, + "TUE" => 2, + "WED" => 3, + "THU" => 4, + "FRI" => 5, + "SAT" => 6, + _ => (int?)null + }; + if (weekday.HasValue) + return weekday.Value; + } + + if (!int.TryParse(raw, out var value)) + throw new InvalidOperationException($"Cron 字段取值无效:{raw}"); + + return value; + } + + private static int NormalizeWeekday(int value) + { + return value == 7 ? 0 : value; + } + } + } +} diff --git a/src/TelegramPanel.Web/Services/DataDictionaryService.cs b/src/TelegramPanel.Web/Services/DataDictionaryService.cs new file mode 100644 index 0000000..7ae614b --- /dev/null +++ b/src/TelegramPanel.Web/Services/DataDictionaryService.cs @@ -0,0 +1,341 @@ +using System.Collections.Concurrent; +using TelegramPanel.Data.Entities; +using TelegramPanel.Data.Repositories; + +namespace TelegramPanel.Web.Services; + +public sealed record DataDictionaryTextItemInput(string Value); +public sealed record DataDictionaryImageItemInput(string AssetPath, string FileName); + +/// +/// 数据字典业务服务。 +/// +public sealed class DataDictionaryService +{ + private static readonly ConcurrentDictionary QueueLocks = new(); + + private readonly IDataDictionaryRepository _dictionaryRepository; + private readonly IDataDictionaryItemRepository _itemRepository; + private readonly ImageAssetStorageService _assetStorage; + + public DataDictionaryService( + IDataDictionaryRepository dictionaryRepository, + IDataDictionaryItemRepository itemRepository, + ImageAssetStorageService assetStorage) + { + _dictionaryRepository = dictionaryRepository; + _itemRepository = itemRepository; + _assetStorage = assetStorage; + } + + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + return await _dictionaryRepository.GetAllWithItemsAsync(cancellationToken); + } + + public async Task GetAsync(int id, CancellationToken cancellationToken = default) + { + return await _dictionaryRepository.GetWithItemsAsync(id, cancellationToken); + } + + public async Task GetByNameAsync(string name, CancellationToken cancellationToken = default) + { + return await _dictionaryRepository.GetByNameAsync(NormalizeName(name), cancellationToken); + } + + public async Task SaveTextDictionaryAsync( + int? id, + string name, + string displayName, + string? description, + string readMode, + bool isEnabled, + IReadOnlyList items, + CancellationToken cancellationToken = default) + { + name = NormalizeName(name); + displayName = NormalizeDisplayName(displayName); + description = NormalizeNullable(description); + readMode = NormalizeReadMode(readMode); + + if (name.Length == 0) + throw new InvalidOperationException("字典名称不能为空"); + if (displayName.Length == 0) + throw new InvalidOperationException("显示名称不能为空"); + + var values = (items ?? Array.Empty()) + .Select(x => NormalizeNullable(x.Value)) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x!) + .ToList(); + + if (values.Count == 0) + throw new InvalidOperationException("文本字典至少需要一条内容"); + + var entity = await LoadOrCreateAsync(id, DataDictionaryTypes.Text, cancellationToken); + entity.Name = name; + entity.DisplayName = displayName; + entity.Description = description; + entity.Type = DataDictionaryTypes.Text; + entity.ReadMode = readMode; + entity.IsEnabled = isEnabled; + entity.UpdatedAt = DateTime.UtcNow; + if (entity.Id == 0) + entity = await _dictionaryRepository.AddAsync(entity); + else + await _dictionaryRepository.UpdateAsync(entity); + + var oldItems = await _itemRepository.GetByDictionaryIdAsync(entity.Id, cancellationToken); + foreach (var item in oldItems) + await _itemRepository.DeleteAsync(item); + + for (var index = 0; index < values.Count; index++) + { + await _itemRepository.AddAsync(new DataDictionaryItem + { + DataDictionaryId = entity.Id, + TextValue = values[index], + SortOrder = index, + IsEnabled = true + }); + } + + return (await _dictionaryRepository.GetWithItemsAsync(entity.Id, cancellationToken))!; + } + + public async Task SaveImageDictionaryAsync( + int? id, + string name, + string displayName, + string? description, + string readMode, + bool isEnabled, + IReadOnlyCollection keepItemIds, + IReadOnlyList newImages, + CancellationToken cancellationToken = default) + { + name = NormalizeName(name); + displayName = NormalizeDisplayName(displayName); + description = NormalizeNullable(description); + readMode = NormalizeReadMode(readMode); + + if (name.Length == 0) + throw new InvalidOperationException("字典名称不能为空"); + if (displayName.Length == 0) + throw new InvalidOperationException("显示名称不能为空"); + + var entity = await LoadOrCreateAsync(id, DataDictionaryTypes.Image, cancellationToken); + entity.Name = name; + entity.DisplayName = displayName; + entity.Description = description; + entity.Type = DataDictionaryTypes.Image; + entity.ReadMode = readMode; + entity.IsEnabled = isEnabled; + entity.UpdatedAt = DateTime.UtcNow; + if (entity.Id == 0) + entity = await _dictionaryRepository.AddAsync(entity); + else + await _dictionaryRepository.UpdateAsync(entity); + + var keepSet = (keepItemIds ?? Array.Empty()).ToHashSet(); + var oldItems = await _itemRepository.GetByDictionaryIdAsync(entity.Id, cancellationToken); + foreach (var item in oldItems.Where(x => !keepSet.Contains(x.Id))) + { + await _assetStorage.DeleteAssetAsync(item.AssetPath, cancellationToken); + await _itemRepository.DeleteAsync(item); + } + + var currentItems = await _itemRepository.GetByDictionaryIdAsync(entity.Id, cancellationToken); + var nextOrder = currentItems.Count == 0 ? 0 : currentItems.Max(x => x.SortOrder) + 1; + foreach (var image in newImages ?? Array.Empty()) + { + await _itemRepository.AddAsync(new DataDictionaryItem + { + DataDictionaryId = entity.Id, + AssetPath = NormalizeNullable(image.AssetPath), + FileName = NormalizeNullable(image.FileName), + SortOrder = nextOrder++, + IsEnabled = true + }); + } + + var finalItems = await _itemRepository.GetByDictionaryIdAsync(entity.Id, cancellationToken); + if (finalItems.Count == 0) + throw new InvalidOperationException("图片字典至少需要一张图片"); + + return (await _dictionaryRepository.GetWithItemsAsync(entity.Id, cancellationToken))!; + } + + public async Task SetEnabledAsync(int id, bool enabled, CancellationToken cancellationToken = default) + { + var entity = await _dictionaryRepository.GetWithItemsAsync(id, cancellationToken) + ?? throw new InvalidOperationException("字典不存在"); + entity.IsEnabled = enabled; + entity.UpdatedAt = DateTime.UtcNow; + await _dictionaryRepository.UpdateAsync(entity); + } + + public async Task ResetQueueAsync(int id, CancellationToken cancellationToken = default) + { + var entity = await _dictionaryRepository.GetWithItemsAsync(id, cancellationToken) + ?? throw new InvalidOperationException("字典不存在"); + entity.NextIndex = 0; + entity.UpdatedAt = DateTime.UtcNow; + await _dictionaryRepository.UpdateAsync(entity); + } + + public async Task DeleteAsync(int id, CancellationToken cancellationToken = default) + { + var entity = await _dictionaryRepository.GetWithItemsAsync(id, cancellationToken); + if (entity == null) + return; + + foreach (var item in entity.Items) + await _assetStorage.DeleteAssetAsync(item.AssetPath, cancellationToken); + + await _assetStorage.DeleteScopeAsync($"dictionaries/{entity.Id}", cancellationToken); + await _dictionaryRepository.DeleteAsync(entity); + } + + public async Task ResolveTextValueAsync(string name, CancellationToken cancellationToken = default) + { + var dictionary = await _dictionaryRepository.GetByNameAsync(NormalizeName(name), cancellationToken) + ?? throw new InvalidOperationException($"字典不存在:{name}"); + + if (!dictionary.IsEnabled) + throw new InvalidOperationException($"字典已停用:{dictionary.Name}"); + if (!string.Equals(dictionary.Type, DataDictionaryTypes.Text, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"字典不是文本类型:{dictionary.Name}"); + + var items = dictionary.Items + .Where(x => x.IsEnabled) + .Select(x => NormalizeNullable(x.TextValue)) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x!) + .ToList(); + if (items.Count == 0) + throw new InvalidOperationException($"字典没有可用内容:{dictionary.Name}"); + + if (string.Equals(dictionary.ReadMode, DataDictionaryReadModes.Queue, StringComparison.OrdinalIgnoreCase)) + { + var gate = QueueLocks.GetOrAdd(dictionary.Id, _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(cancellationToken); + try + { + dictionary = await _dictionaryRepository.GetByNameAsync(dictionary.Name, cancellationToken) + ?? throw new InvalidOperationException($"字典不存在:{dictionary.Name}"); + items = dictionary.Items + .Where(x => x.IsEnabled) + .Select(x => NormalizeNullable(x.TextValue)) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x!) + .ToList(); + if (items.Count == 0) + throw new InvalidOperationException($"字典没有可用内容:{dictionary.Name}"); + + var index = dictionary.NextIndex; + if (index < 0) + index = 0; + var picked = items[index % items.Count]; + dictionary.NextIndex = (index + 1) % items.Count; + dictionary.UpdatedAt = DateTime.UtcNow; + await _dictionaryRepository.UpdateAsync(dictionary); + return picked; + } + finally + { + gate.Release(); + } + } + + return items[Random.Shared.Next(items.Count)]; + } + + public async Task ResolveImageValueAsync(string name, CancellationToken cancellationToken = default) + { + var dictionary = await _dictionaryRepository.GetByNameAsync(NormalizeName(name), cancellationToken) + ?? throw new InvalidOperationException($"字典不存在:{name}"); + + if (!dictionary.IsEnabled) + throw new InvalidOperationException($"字典已停用:{dictionary.Name}"); + if (!string.Equals(dictionary.Type, DataDictionaryTypes.Image, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"字典不是图片类型:{dictionary.Name}"); + + var items = dictionary.Items + .Where(x => x.IsEnabled && !string.IsNullOrWhiteSpace(x.AssetPath)) + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.Id) + .ToList(); + if (items.Count == 0) + throw new InvalidOperationException($"字典没有可用图片:{dictionary.Name}"); + + if (string.Equals(dictionary.ReadMode, DataDictionaryReadModes.Queue, StringComparison.OrdinalIgnoreCase)) + { + var gate = QueueLocks.GetOrAdd(dictionary.Id, _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(cancellationToken); + try + { + dictionary = await _dictionaryRepository.GetByNameAsync(dictionary.Name, cancellationToken) + ?? throw new InvalidOperationException($"字典不存在:{dictionary.Name}"); + items = dictionary.Items + .Where(x => x.IsEnabled && !string.IsNullOrWhiteSpace(x.AssetPath)) + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.Id) + .ToList(); + if (items.Count == 0) + throw new InvalidOperationException($"字典没有可用图片:{dictionary.Name}"); + + var index = dictionary.NextIndex; + if (index < 0) + index = 0; + var picked = items[index % items.Count]; + dictionary.NextIndex = (index + 1) % items.Count; + dictionary.UpdatedAt = DateTime.UtcNow; + await _dictionaryRepository.UpdateAsync(dictionary); + return new StoredImageAssetInfo(picked.AssetPath!, picked.FileName ?? "image.jpg"); + } + finally + { + gate.Release(); + } + } + + var randomItem = items[Random.Shared.Next(items.Count)]; + return new StoredImageAssetInfo(randomItem.AssetPath!, randomItem.FileName ?? "image.jpg"); + } + + private async Task LoadOrCreateAsync(int? id, string type, CancellationToken cancellationToken) + { + if (!id.HasValue || id.Value <= 0) + return new DataDictionary { Type = type, ReadMode = DataDictionaryReadModes.Random, IsEnabled = true }; + + var entity = await _dictionaryRepository.GetWithItemsAsync(id.Value, cancellationToken) + ?? throw new InvalidOperationException("字典不存在"); + if (!string.Equals(entity.Type, type, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("字典类型不匹配,不能跨类型修改"); + return entity; + } + + private static string NormalizeName(string? value) + { + return (value ?? string.Empty).Trim().ToLowerInvariant(); + } + + private static string NormalizeDisplayName(string? value) + { + return (value ?? string.Empty).Trim(); + } + + private static string NormalizeReadMode(string? value) + { + return string.Equals((value ?? string.Empty).Trim(), DataDictionaryReadModes.Queue, StringComparison.OrdinalIgnoreCase) + ? DataDictionaryReadModes.Queue + : DataDictionaryReadModes.Random; + } + + private static string? NormalizeNullable(string? value) + { + var text = (value ?? string.Empty).Trim(); + return text.Length == 0 ? null : text; + } +} diff --git a/src/TelegramPanel.Web/Services/ImageAssetStorageService.cs b/src/TelegramPanel.Web/Services/ImageAssetStorageService.cs new file mode 100644 index 0000000..f5705a0 --- /dev/null +++ b/src/TelegramPanel.Web/Services/ImageAssetStorageService.cs @@ -0,0 +1,175 @@ +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Logging; +using TelegramPanel.Core.Services.Telegram; + +namespace TelegramPanel.Web.Services; + +public enum ImageAssetKind +{ + General, + Avatar +} + +public sealed record StoredImageAssetInfo(string AssetPath, string FileName) +{ + public string PublicUrl => "/" + AssetPath.Replace('\\', '/'); +} + +/// +/// 图片资产存储服务。 +/// +public sealed class ImageAssetStorageService +{ + private readonly IWebHostEnvironment _environment; + private readonly ILogger _logger; + + public ImageAssetStorageService(IWebHostEnvironment environment, ILogger logger) + { + _environment = environment; + _logger = logger; + } + + public async Task SaveAsync( + Stream fileStream, + string fileName, + string scope, + ImageAssetKind kind, + CancellationToken cancellationToken = default) + { + if (fileStream == null) + throw new ArgumentNullException(nameof(fileStream)); + + scope = NormalizeScope(scope); + if (scope.Length == 0) + throw new ArgumentException("图片作用域不能为空", nameof(scope)); + + fileName = (fileName ?? "image.jpg").Trim(); + if (fileName.Length == 0) + fileName = "image.jpg"; + + var extension = ".jpg"; + var relativeDir = Path.Combine("uploads", scope).Replace('\\', '/'); + var root = GetWebRootPath(); + var fullDir = Path.Combine(root, "uploads", scope.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(fullDir); + + var storedName = $"{Guid.NewGuid():N}{extension}"; + var relativePath = Path.Combine(relativeDir, storedName).Replace('\\', '/'); + var fullPath = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar)); + + await using var normalized = kind == ImageAssetKind.Avatar + ? await TelegramImageProcessor.PrepareAvatarJpegAsync(fileStream, cancellationToken) + : await TelegramImageProcessor.PrepareStoredImageJpegAsync(fileStream, cancellationToken: cancellationToken); + + await using var output = File.Create(fullPath); + normalized.Position = 0; + await normalized.CopyToAsync(output, cancellationToken); + + return new StoredImageAssetInfo(relativePath, fileName); + } + + public async Task SaveBrowserFileAsync( + IBrowserFile file, + string scope, + ImageAssetKind kind, + long maxBytes = 20 * 1024 * 1024, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(file); + await using var stream = file.OpenReadStream(maxBytes, cancellationToken); + return await SaveAsync(stream, file.Name, scope, kind, cancellationToken); + } + + public Task OpenReadAsync(string assetPath, CancellationToken cancellationToken = default) + { + assetPath = NormalizeAssetPath(assetPath); + var fullPath = Path.Combine(GetWebRootPath(), assetPath.Replace('/', Path.DirectorySeparatorChar)); + if (!File.Exists(fullPath)) + throw new FileNotFoundException("图片资产不存在", fullPath); + + Stream stream = File.OpenRead(fullPath); + return Task.FromResult(stream); + } + + public Task DeleteAssetAsync(string? assetPath, CancellationToken cancellationToken = default) + { + assetPath = NormalizeAssetPath(assetPath); + if (assetPath.Length == 0) + return Task.CompletedTask; + + var fullPath = Path.Combine(GetWebRootPath(), assetPath.Replace('/', Path.DirectorySeparatorChar)); + try + { + if (File.Exists(fullPath)) + File.Delete(fullPath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "删除图片资产失败:{AssetPath}", assetPath); + } + + return Task.CompletedTask; + } + + public Task DeleteScopeAsync(string? scope, CancellationToken cancellationToken = default) + { + scope = NormalizeScope(scope); + if (scope.Length == 0) + return Task.CompletedTask; + + var fullDir = Path.Combine(GetWebRootPath(), "uploads", scope.Replace('/', Path.DirectorySeparatorChar)); + try + { + if (Directory.Exists(fullDir)) + Directory.Delete(fullDir, recursive: true); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "删除图片作用域失败:{Scope}", scope); + } + + return Task.CompletedTask; + } + + private string GetWebRootPath() + { + var path = _environment.WebRootPath; + if (string.IsNullOrWhiteSpace(path)) + path = Path.Combine(AppContext.BaseDirectory, "wwwroot"); + + Directory.CreateDirectory(path); + return path; + } + + private static string NormalizeScope(string? scope) + { + var raw = (scope ?? string.Empty).Trim().Replace('\\', '/'); + if (raw.Length == 0) + return string.Empty; + + var parts = raw.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(SanitizeSegment) + .Where(x => x.Length > 0) + .ToArray(); + + return string.Join('/', parts); + } + + private static string NormalizeAssetPath(string? assetPath) + { + var raw = (assetPath ?? string.Empty).Trim().Replace('\\', '/').TrimStart('/'); + if (raw.Contains("..", StringComparison.Ordinal)) + throw new InvalidOperationException("非法图片路径"); + + return raw; + } + + private static string SanitizeSegment(string value) + { + var chars = value + .Where(ch => char.IsLetterOrDigit(ch) || ch is '-' or '_' or '.') + .ToArray(); + return new string(chars); + } +} diff --git a/src/TelegramPanel.Web/Services/LocalConfigFile.cs b/src/TelegramPanel.Web/Services/LocalConfigFile.cs index 412f153..97afc39 100644 --- a/src/TelegramPanel.Web/Services/LocalConfigFile.cs +++ b/src/TelegramPanel.Web/Services/LocalConfigFile.cs @@ -1,4 +1,7 @@ using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; @@ -6,6 +9,20 @@ namespace TelegramPanel.Web.Services; public static class LocalConfigFile { + public static JsonSerializerOptions CreateIndentedJsonSerializerOptions() + { + return new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + WriteIndented = true, + TypeInfoResolver = new DefaultJsonTypeInfoResolver() + }; + } + + public static string ToIndentedJson(JsonNode? node) + { + return (node ?? new JsonObject()).ToJsonString(CreateIndentedJsonSerializerOptions()); + } + public static string ResolvePath(IConfiguration configuration, IWebHostEnvironment environment) { var configured = (configuration["LocalConfig:Path"] ?? "").Trim(); diff --git a/src/TelegramPanel.Web/Services/ScheduledTaskBackgroundService.cs b/src/TelegramPanel.Web/Services/ScheduledTaskBackgroundService.cs new file mode 100644 index 0000000..6975ea9 --- /dev/null +++ b/src/TelegramPanel.Web/Services/ScheduledTaskBackgroundService.cs @@ -0,0 +1,95 @@ +using TelegramPanel.Core.Services; +using TelegramPanel.Data.Entities; + +namespace TelegramPanel.Web.Services; + +/// +/// Cron 计划任务后台调度器。 +/// +public sealed class ScheduledTaskBackgroundService : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + public ScheduledTaskBackgroundService( + IServiceScopeFactory scopeFactory, + ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await ProcessOnceAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "计划任务调度循环执行失败"); + } + + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); + } + } + + private async Task ProcessOnceAsync(CancellationToken cancellationToken) + { + using var scope = _scopeFactory.CreateScope(); + var scheduledTaskService = scope.ServiceProvider.GetRequiredService(); + var batchTaskManagement = scope.ServiceProvider.GetRequiredService(); + + var enabledTasks = await scheduledTaskService.GetEnabledAsync(cancellationToken); + if (enabledTasks.Count == 0) + return; + + var nowUtc = DateTime.UtcNow; + foreach (var scheduledTask in enabledTasks) + { + cancellationToken.ThrowIfCancellationRequested(); + + var nextRunAtUtc = scheduledTask.NextRunAtUtc; + if (!nextRunAtUtc.HasValue) + { + await scheduledTaskService.AdvanceNextRunAsync(scheduledTask.Id, nowUtc, cancellationToken); + continue; + } + + if (nextRunAtUtc.Value > nowUtc) + continue; + + if (scheduledTask.LastBatchTaskId.HasValue) + { + var lastBatchTask = await batchTaskManagement.GetTaskAsync(scheduledTask.LastBatchTaskId.Value); + if (lastBatchTask != null && lastBatchTask.Status is "pending" or "running" or "paused") + { + _logger.LogInformation("计划任务 {ScheduledTaskId} 上次执行尚未结束,本轮跳过", scheduledTask.Id); + await scheduledTaskService.AdvanceNextRunAsync(scheduledTask.Id, nowUtc, cancellationToken); + continue; + } + } + + var task = new BatchTask + { + TaskType = scheduledTask.TaskType, + Total = Math.Max(0, scheduledTask.Total), + Completed = 0, + Failed = 0, + Config = TaskAssetScopeHelper.RemoveAssetScopeId(scheduledTask.ConfigJson) + }; + + var created = await batchTaskManagement.CreateTaskAsync(task); + _logger.LogInformation("计划任务触发:schedule={ScheduledTaskId}, batch={BatchTaskId}, type={TaskType}", scheduledTask.Id, created.Id, created.TaskType); + await scheduledTaskService.MarkTriggeredAsync(scheduledTask.Id, nowUtc, created.Id, cancellationToken); + } + } +} diff --git a/src/TelegramPanel.Web/Services/ScheduledTaskService.cs b/src/TelegramPanel.Web/Services/ScheduledTaskService.cs new file mode 100644 index 0000000..4d480f2 --- /dev/null +++ b/src/TelegramPanel.Web/Services/ScheduledTaskService.cs @@ -0,0 +1,143 @@ +using TelegramPanel.Core.Services; +using TelegramPanel.Data.Entities; +using TelegramPanel.Data.Repositories; + +namespace TelegramPanel.Web.Services; + +/// +/// 计划任务业务服务。 +/// +public sealed class ScheduledTaskService +{ + private readonly IScheduledTaskRepository _scheduledTaskRepository; + private readonly CronExpressionService _cronExpressionService; + private readonly ImageAssetStorageService _assetStorage; + + public ScheduledTaskService( + IScheduledTaskRepository scheduledTaskRepository, + CronExpressionService cronExpressionService, + ImageAssetStorageService assetStorage) + { + _scheduledTaskRepository = scheduledTaskRepository; + _cronExpressionService = cronExpressionService; + _assetStorage = assetStorage; + } + + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + return await _scheduledTaskRepository.GetAllOrderedAsync(cancellationToken); + } + + public async Task GetAsync(int id, CancellationToken cancellationToken = default) + { + return await _scheduledTaskRepository.GetByIdAsync(id); + } + + public async Task CreateAsync(ScheduledTask task, CancellationToken cancellationToken = default) + { + Normalize(task); + task.CreatedAt = DateTime.UtcNow; + task.UpdatedAt = DateTime.UtcNow; + task.NextRunAtUtc = _cronExpressionService.GetNextOccurrenceUtc(task.CronExpression, DateTime.UtcNow); + return await _scheduledTaskRepository.AddAsync(task); + } + + public async Task UpdateAsync(ScheduledTask task, CancellationToken cancellationToken = default) + { + Normalize(task); + task.UpdatedAt = DateTime.UtcNow; + task.NextRunAtUtc = _cronExpressionService.GetNextOccurrenceUtc(task.CronExpression, DateTime.UtcNow); + await _scheduledTaskRepository.UpdateAsync(task); + return task; + } + + public async Task PauseAsync(int id, CancellationToken cancellationToken = default) + { + var task = await _scheduledTaskRepository.GetByIdAsync(id); + if (task == null) + return; + + task.Status = ScheduledTaskStatuses.Paused; + task.UpdatedAt = DateTime.UtcNow; + await _scheduledTaskRepository.UpdateAsync(task); + } + + public async Task ResumeAsync(int id, CancellationToken cancellationToken = default) + { + var task = await _scheduledTaskRepository.GetByIdAsync(id); + if (task == null) + return; + + task.Status = ScheduledTaskStatuses.Enabled; + task.UpdatedAt = DateTime.UtcNow; + task.NextRunAtUtc = _cronExpressionService.GetNextOccurrenceUtc(task.CronExpression, DateTime.UtcNow); + await _scheduledTaskRepository.UpdateAsync(task); + } + + public async Task DeleteAsync(int id, CancellationToken cancellationToken = default) + { + var task = await _scheduledTaskRepository.GetByIdAsync(id); + if (task == null) + return; + + if (!string.IsNullOrWhiteSpace(task.OwnedAssetScopeId)) + await _assetStorage.DeleteScopeAsync(task.OwnedAssetScopeId, cancellationToken); + + await _scheduledTaskRepository.DeleteAsync(task); + } + + public async Task> GetEnabledAsync(CancellationToken cancellationToken = default) + { + return await _scheduledTaskRepository.GetEnabledAsync(cancellationToken); + } + + public async Task MarkTriggeredAsync(int id, DateTime triggeredAtUtc, int batchTaskId, CancellationToken cancellationToken = default) + { + var task = await _scheduledTaskRepository.GetByIdAsync(id); + if (task == null) + return; + + task.LastRunAtUtc = triggeredAtUtc; + task.LastBatchTaskId = batchTaskId; + task.UpdatedAt = DateTime.UtcNow; + task.NextRunAtUtc = _cronExpressionService.GetNextOccurrenceUtc(task.CronExpression, triggeredAtUtc); + await _scheduledTaskRepository.UpdateAsync(task); + } + + public async Task AdvanceNextRunAsync(int id, DateTime fromUtc, CancellationToken cancellationToken = default) + { + var task = await _scheduledTaskRepository.GetByIdAsync(id); + if (task == null) + return; + + task.UpdatedAt = DateTime.UtcNow; + task.NextRunAtUtc = _cronExpressionService.GetNextOccurrenceUtc(task.CronExpression, fromUtc); + await _scheduledTaskRepository.UpdateAsync(task); + } + + public string ValidateCronOrThrow(string expression) + { + if (!_cronExpressionService.TryValidate(expression, out var error)) + throw new InvalidOperationException(error ?? "Cron 表达式无效"); + return expression.Trim(); + } + + private void Normalize(ScheduledTask task) + { + task.TaskType = (task.TaskType ?? string.Empty).Trim(); + task.Status = string.Equals((task.Status ?? string.Empty).Trim(), ScheduledTaskStatuses.Paused, StringComparison.OrdinalIgnoreCase) + ? ScheduledTaskStatuses.Paused + : ScheduledTaskStatuses.Enabled; + task.CronExpression = ValidateCronOrThrow(task.CronExpression); + task.ConfigJson = string.IsNullOrWhiteSpace(task.ConfigJson) ? null : task.ConfigJson.Trim(); + task.OwnedAssetScopeId = NormalizeNullable(task.OwnedAssetScopeId); + if (task.Total < 0) + task.Total = 0; + } + + private static string? NormalizeNullable(string? value) + { + var text = (value ?? string.Empty).Trim(); + return text.Length == 0 ? null : text; + } +} diff --git a/src/TelegramPanel.Web/Services/TaskAssetScopeHelper.cs b/src/TelegramPanel.Web/Services/TaskAssetScopeHelper.cs new file mode 100644 index 0000000..a1bc61b --- /dev/null +++ b/src/TelegramPanel.Web/Services/TaskAssetScopeHelper.cs @@ -0,0 +1,79 @@ +using System.Text.Json.Nodes; + +namespace TelegramPanel.Web.Services; + +/// +/// 任务配置中的图片资产作用域辅助工具。 +/// +public static class TaskAssetScopeHelper +{ + private const string AssetScopeIdProperty = "asset_scope_id"; + + public static string? GetAssetScopeId(string? configJson) + { + if (string.IsNullOrWhiteSpace(configJson)) + return null; + + try + { + var node = JsonNode.Parse(configJson) as JsonObject; + var scopeId = node?[AssetScopeIdProperty]?.GetValue(); + return Normalize(scopeId); + } + catch + { + return null; + } + } + + public static string? RemoveAssetScopeId(string? configJson) + { + if (string.IsNullOrWhiteSpace(configJson)) + return configJson; + + try + { + var node = JsonNode.Parse(configJson) as JsonObject; + if (node == null) + return configJson; + + node.Remove(AssetScopeIdProperty); + return LocalConfigFile.ToIndentedJson(node); + } + catch + { + return configJson; + } + } + + public static string? SetAssetScopeId(string? configJson, string? scopeId) + { + if (string.IsNullOrWhiteSpace(configJson)) + return configJson; + + try + { + var node = JsonNode.Parse(configJson) as JsonObject; + if (node == null) + return configJson; + + var normalized = Normalize(scopeId); + if (normalized == null) + node.Remove(AssetScopeIdProperty); + else + node[AssetScopeIdProperty] = normalized; + + return LocalConfigFile.ToIndentedJson(node); + } + catch + { + return configJson; + } + } + + private static string? Normalize(string? value) + { + var text = (value ?? string.Empty).Trim(); + return text.Length == 0 ? null : text; + } +} diff --git a/src/TelegramPanel.Web/Services/TelegramPanelAiService.cs b/src/TelegramPanel.Web/Services/TelegramPanelAiService.cs new file mode 100644 index 0000000..fbac53f --- /dev/null +++ b/src/TelegramPanel.Web/Services/TelegramPanelAiService.cs @@ -0,0 +1,432 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Options; +using TelegramPanel.Modules; + +namespace TelegramPanel.Web.Services; + +public sealed class TelegramPanelAiService : ITelegramPanelAiService +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly IOptionsMonitor _optionsMonitor; + private readonly ILogger _logger; + + public TelegramPanelAiService( + IHttpClientFactory httpClientFactory, + IOptionsMonitor optionsMonitor, + ILogger logger) + { + _httpClientFactory = httpClientFactory; + _optionsMonitor = optionsMonitor; + _logger = logger; + } + + public async Task ChooseActionAsync( + TelegramPanelAiChooseActionRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var settings = _optionsMonitor.CurrentValue.ToSnapshot(); + if (!settings.TryValidateForTask(request.Model, out var validateError)) + { + return new TelegramPanelAiChooseActionResult( + Success: false, + Mode: null, + ButtonIndex: null, + ReplyText: null, + Reason: null, + Error: validateError); + } + + var model = settings.ResolveModel(request.Model)!; + var buttons = request.Buttons ?? Array.Empty(); + var buttonLines = buttons.Select(x => $"{x.Index}. {x.Text}").ToArray(); + var hasButtons = buttonLines.Length > 0; + var systemPrompt = hasButtons + ? "你是 Telegram 验证助手。当前消息存在可点击按钮。请根据消息文本、按钮列表和可选图片,只输出唯一 JSON 决策。" + + "只允许输出 JSON,不要输出 Markdown,不要解释。JSON 格式:" + + "{\"mode\":\"click_button\",\"button_index\":0,\"reply_text\":null,\"reason\":\"30字内\"}。" + + "注意:当前存在按钮时,不允许返回 reply_text,必须返回 click_button,button_index 必须是 0 基索引。" + : "你是 Telegram 验证助手。请根据消息文本、按钮列表和可选图片,输出唯一 JSON 决策。" + + "只允许输出 JSON,不要输出 Markdown,不要解释。JSON 格式:" + + "{\"mode\":\"click_button\"|\"reply_text\",\"button_index\":0,\"reply_text\":null,\"reason\":\"30字内\"}。" + + "如果需要点击按钮,mode=click_button,button_index 必须是 0 基索引;如果更适合直接回答,mode=reply_text,reply_text 必须填写最终回复内容。"; + + var text = new StringBuilder(); + text.AppendLine("请处理下面这条 Telegram 验证消息。"); + if (!string.IsNullOrWhiteSpace(request.Context)) + text.AppendLine($"上下文:{request.Context.Trim()}"); + text.AppendLine($"消息文本:{(string.IsNullOrWhiteSpace(request.MessageText) ? "(空)" : request.MessageText.Trim())}"); + text.AppendLine(buttonLines.Length == 0 + ? "按钮列表:无" + : $"按钮列表:{string.Join(";", buttonLines)}"); + text.AppendLine("要求:优先输出最可靠的结果;如果图片信息重要,请结合图片判断。只输出 JSON。 "); + + object userContent = request.Image is { Content.Length: > 0 } + ? new object[] + { + new { type = "text", text = text.ToString() }, + new + { + type = "image_url", + image_url = new + { + url = $"data:{request.Image.MimeType};base64,{Convert.ToBase64String(request.Image.Content)}" + } + } + } + : text.ToString(); + + try + { + return await ExecuteWithRetryAsync( + operationName: "验证决策", + model: model, + retryCount: settings.RetryCount, + action: ct => ChooseActionCoreAsync(settings, model, systemPrompt, userContent, ct), + cancellationToken: cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to parse AI choice response after retries"); + return new TelegramPanelAiChooseActionResult(false, null, null, null, null, $"AI 响应解析失败:{ex.Message}"); + } + } + + public async Task ReplyTextAsync( + TelegramPanelAiReplyTextRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var settings = _optionsMonitor.CurrentValue.ToSnapshot(); + if (!settings.TryValidateForTask(request.Model, out var validateError)) + return new TelegramPanelAiReplyTextResult(false, null, validateError); + + var model = settings.ResolveModel(request.Model)!; + var systemPrompt = (request.Prompt ?? string.Empty).Trim(); + if (systemPrompt.Length == 0) + systemPrompt = "你是 Telegram 验证/答题助手。请只回复最终答案,不要解释,不要输出多余内容。"; + + var text = new StringBuilder(); + text.AppendLine((request.Query ?? string.Empty).Trim()); + if (!string.IsNullOrWhiteSpace(request.Context)) + text.AppendLine($"上下文:{request.Context.Trim()}"); + text.AppendLine("只回复最终答案。不要解释。不要添加引号。 "); + + object userContent = request.Image is { Content.Length: > 0 } + ? new object[] + { + new { type = "text", text = text.ToString() }, + new + { + type = "image_url", + image_url = new + { + url = $"data:{request.Image.MimeType};base64,{Convert.ToBase64String(request.Image.Content)}" + } + } + } + : text.ToString(); + + try + { + return await ExecuteWithRetryAsync( + operationName: "文本作答", + model: model, + retryCount: settings.RetryCount, + action: async ct => + { + var content = await SendChatCompletionAsync(settings, model, systemPrompt, userContent, ct); + var normalized = StripMarkdownFence(content).Trim(); + if (normalized.Length == 0) + throw new InvalidOperationException("AI 返回了空回复"); + + return new TelegramPanelAiReplyTextResult(true, normalized, null); + }, + cancellationToken: cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to generate AI reply text after retries"); + return new TelegramPanelAiReplyTextResult(false, null, $"AI 请求失败:{ex.Message}"); + } + } + + public async Task<(bool Success, string? Model, string? ResponseText, string? Error)> TestConnectionAsync( + AiOpenAiSettingsSnapshot settings, + string? overrideModel = null, + CancellationToken cancellationToken = default) + { + if (!settings.TryValidateForTask(overrideModel, out var validateError)) + return (false, null, null, validateError); + + var model = settings.ResolveModel(overrideModel)!; + + try + { + var content = await ExecuteWithRetryAsync( + operationName: "连通测试", + model: model, + retryCount: settings.RetryCount, + action: ct => SendChatCompletionAsync( + settings, + model, + "你是 AI 连通性测试助手。请只回复 pong。", + "ping", + ct), + cancellationToken: cancellationToken); + + var normalized = StripMarkdownFence(content).Trim(); + if (normalized.Length == 0) + return (false, model, null, "AI 返回了空响应"); + + return (true, model, TrimForPreview(normalized), null); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "AI connectivity test failed after retries"); + return (false, model, null, ex.Message); + } + } + + private async Task ChooseActionCoreAsync( + AiOpenAiSettingsSnapshot settings, + string model, + string systemPrompt, + object userContent, + CancellationToken cancellationToken) + { + var content = await SendChatCompletionAsync(settings, model, systemPrompt, userContent, cancellationToken); + var json = ExtractJsonObject(content); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + var mode = root.TryGetProperty("mode", out var modeElement) + ? (modeElement.GetString() ?? string.Empty).Trim() + : string.Empty; + var reason = root.TryGetProperty("reason", out var reasonElement) + ? reasonElement.GetString() + : null; + + if (string.Equals(mode, "click_button", StringComparison.OrdinalIgnoreCase)) + { + if (!root.TryGetProperty("button_index", out var buttonIndexElement) + || buttonIndexElement.ValueKind != JsonValueKind.Number + || !buttonIndexElement.TryGetInt32(out var buttonIndex)) + { + throw new InvalidOperationException("AI 返回了点击按钮模式,但缺少有效的 button_index"); + } + + return new TelegramPanelAiChooseActionResult(true, "click_button", buttonIndex, null, reason, null); + } + + if (string.Equals(mode, "reply_text", StringComparison.OrdinalIgnoreCase)) + { + var replyText = root.TryGetProperty("reply_text", out var replyElement) + ? (replyElement.GetString() ?? string.Empty).Trim() + : string.Empty; + + if (replyText.Length == 0) + throw new InvalidOperationException("AI 返回了 reply_text 模式,但 reply_text 为空"); + + return new TelegramPanelAiChooseActionResult(true, "reply_text", null, replyText, reason, null); + } + + throw new InvalidOperationException($"AI 返回了未知模式:{mode}"); + } + + private async Task ExecuteWithRetryAsync( + string operationName, + string model, + int retryCount, + Func> action, + CancellationToken cancellationToken) + { + var normalizedRetryCount = AiOpenAiSettingsSnapshot.NormalizeRetryCount(retryCount); + var maxAttempts = Math.Max(1, normalizedRetryCount + 1); + Exception? lastException = null; + + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + try + { + return await action(cancellationToken); + } + catch (Exception ex) + { + lastException = ex; + if (attempt >= maxAttempts || !IsRetryableAiFailure(ex, cancellationToken)) + break; + + var delay = GetRetryDelay(attempt); + _logger.LogWarning( + ex, + "AI {OperationName} 失败,准备重试:第 {NextAttempt}/{MaxAttempts} 次,模型={Model},等待 {DelayMs}ms", + operationName, + attempt + 1, + maxAttempts, + model, + (int)delay.TotalMilliseconds); + + if (delay > TimeSpan.Zero) + await Task.Delay(delay, cancellationToken); + } + } + + throw lastException ?? new InvalidOperationException($"AI {operationName} 失败"); + } + + private async Task SendChatCompletionAsync( + AiOpenAiSettingsSnapshot settings, + string model, + string systemPrompt, + object userContent, + CancellationToken cancellationToken) + { + var endpoint = settings.GetCompletionsEndpoint() + ?? throw new InvalidOperationException("OpenAI 端点未配置"); + var apiKey = settings.ApiKey + ?? throw new InvalidOperationException("OpenAI Key 未配置"); + + var client = _httpClientFactory.CreateClient(nameof(TelegramPanelAiService)); + using var request = new HttpRequestMessage(HttpMethod.Post, endpoint); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + request.Content = JsonContent.Create(new + { + model, + temperature = 0.1, + stream = false, + messages = new object[] + { + new { role = "system", content = systemPrompt }, + new { role = "user", content = userContent } + } + }); + + using var response = await client.SendAsync(request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + if (!response.IsSuccessStatusCode) + throw new InvalidOperationException($"AI 请求失败(HTTP {(int)response.StatusCode}):{TrimForError(body)}"); + + try + { + using var document = JsonDocument.Parse(body); + var contentElement = document.RootElement + .GetProperty("choices")[0] + .GetProperty("message") + .GetProperty("content"); + + return contentElement.ValueKind switch + { + JsonValueKind.String => contentElement.GetString() ?? string.Empty, + JsonValueKind.Array => string.Concat(contentElement.EnumerateArray() + .Where(x => x.TryGetProperty("type", out var type) && string.Equals(type.GetString(), "text", StringComparison.OrdinalIgnoreCase)) + .Select(x => x.TryGetProperty("text", out var text) ? text.GetString() ?? string.Empty : string.Empty)), + _ => throw new InvalidOperationException("AI 返回的 content 结构不受支持") + }; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to parse AI completion response: {Body}", body); + throw new InvalidOperationException($"AI 响应结构无效:{ex.Message}"); + } + } + + private static bool IsRetryableAiFailure(Exception ex, CancellationToken cancellationToken) + { + if (ex is OperationCanceledException && cancellationToken.IsCancellationRequested) + return false; + + if (ex is HttpRequestException) + return true; + + if (ex is TaskCanceledException) + return true; + + var message = ex.Message ?? string.Empty; + if (message.Contains("端点未配置", StringComparison.OrdinalIgnoreCase) + || message.Contains("Key 未配置", StringComparison.OrdinalIgnoreCase) + || message.Contains("未配置 AI", StringComparison.OrdinalIgnoreCase) + || message.Contains("端点格式无效", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (TryGetHttpStatusCode(ex, out var statusCode)) + return statusCode == 408 || statusCode == 409 || statusCode == 429 || statusCode >= 500; + + return true; + } + + private static bool TryGetHttpStatusCode(Exception ex, out int statusCode) + { + const string marker = "AI 请求失败(HTTP "; + var message = ex.Message ?? string.Empty; + var start = message.IndexOf(marker, StringComparison.Ordinal); + if (start < 0) + { + statusCode = 0; + return false; + } + + start += marker.Length; + var end = message.IndexOf(')', start); + if (end < 0) + end = message.IndexOf(')', start); + + if (end <= start) + { + statusCode = 0; + return false; + } + + return int.TryParse(message[start..end], out statusCode); + } + + private static TimeSpan GetRetryDelay(int attempt) + { + var delayMs = Math.Min(3000, 800 * Math.Max(1, attempt)); + return TimeSpan.FromMilliseconds(delayMs); + } + + private static string ExtractJsonObject(string content) + { + var normalized = StripMarkdownFence(content).Trim(); + var start = normalized.IndexOf('{'); + var end = normalized.LastIndexOf('}'); + if (start < 0 || end <= start) + throw new InvalidOperationException("AI 未返回 JSON 对象"); + + return normalized[start..(end + 1)]; + } + + private static string StripMarkdownFence(string content) + { + var text = (content ?? string.Empty).Trim(); + if (!text.StartsWith("```", StringComparison.Ordinal)) + return text; + + var firstLineEnd = text.IndexOf('\n'); + if (firstLineEnd < 0) + return text.Trim('`').Trim(); + + var body = text[(firstLineEnd + 1)..]; + var lastFence = body.LastIndexOf("```", StringComparison.Ordinal); + return lastFence >= 0 ? body[..lastFence].Trim() : body.Trim(); + } + + private static string TrimForError(string text) + { + var normalized = (text ?? string.Empty).Replace("\r", " ").Replace("\n", " ").Trim(); + return normalized.Length <= 400 ? normalized : normalized[..400]; + } + + private static string TrimForPreview(string text) + { + var normalized = (text ?? string.Empty).Replace("\r", " ").Replace("\n", " ").Trim(); + return normalized.Length <= 80 ? normalized : normalized[..80]; + } +} diff --git a/src/TelegramPanel.Web/Services/TemplateRenderingService.cs b/src/TelegramPanel.Web/Services/TemplateRenderingService.cs new file mode 100644 index 0000000..21d318a --- /dev/null +++ b/src/TelegramPanel.Web/Services/TemplateRenderingService.cs @@ -0,0 +1,143 @@ +using System.Text; +using System.Text.RegularExpressions; +using TelegramPanel.Data.Entities; + +namespace TelegramPanel.Web.Services; + +/// +/// 模板变量解析服务。 +/// +public sealed class TemplateRenderingService +{ + private static readonly Regex TokenRegex = new("\\{(?[a-zA-Z0-9_]+)\\}", RegexOptions.Compiled); + private readonly DataDictionaryService _dataDictionaryService; + + public TemplateRenderingService(DataDictionaryService dataDictionaryService) + { + _dataDictionaryService = dataDictionaryService; + } + + public IReadOnlyList ExtractTokenNames(string? template) + { + if (string.IsNullOrWhiteSpace(template)) + return Array.Empty(); + + var names = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (Match match in TokenRegex.Matches(template)) + { + if (!match.Success) + continue; + + var tokenName = (match.Groups["name"].Value ?? string.Empty).Trim(); + if (tokenName.Length == 0 || !seen.Add(tokenName)) + continue; + + names.Add(tokenName); + } + + return names; + } + + public async Task ValidateTextTemplateAsync(string? template, CancellationToken cancellationToken = default) + { + foreach (var tokenName in ExtractTokenNames(template)) + { + if (string.Equals(tokenName, "time", StringComparison.OrdinalIgnoreCase)) + continue; + + await EnsureDictionaryAvailableAsync(tokenName, DataDictionaryTypes.Text, cancellationToken); + } + } + + public async Task ValidateImageTemplateAsync(string? tokenExpression, CancellationToken cancellationToken = default) + { + var tokenName = ExtractSingleTokenName(tokenExpression) + ?? throw new InvalidOperationException("图片变量必须是单个字典变量,例如 {avatar}"); + + await EnsureDictionaryAvailableAsync(tokenName, DataDictionaryTypes.Image, cancellationToken); + } + + public async Task RenderTextTemplateAsync(string template, CancellationToken cancellationToken = default) + { + template = template ?? string.Empty; + var matches = TokenRegex.Matches(template); + if (matches.Count == 0) + return template; + + var builder = new StringBuilder(); + var lastIndex = 0; + + foreach (Match match in matches) + { + if (!match.Success) + continue; + + builder.Append(template, lastIndex, match.Index - lastIndex); + var tokenName = match.Groups["name"].Value; + var resolved = await ResolveTextTokenAsync(tokenName, cancellationToken); + builder.Append(resolved); + lastIndex = match.Index + match.Length; + } + + builder.Append(template, lastIndex, template.Length - lastIndex); + return builder.ToString(); + } + + public async Task ResolveImageTemplateAsync(string tokenExpression, CancellationToken cancellationToken = default) + { + var tokenName = ExtractSingleTokenName(tokenExpression) + ?? throw new InvalidOperationException("图片变量必须是单个字典变量,例如 {avatar}"); + return await _dataDictionaryService.ResolveImageValueAsync(tokenName, cancellationToken); + } + + public string? ExtractSingleTokenName(string? tokenExpression) + { + var text = (tokenExpression ?? string.Empty).Trim(); + if (text.Length == 0) + return null; + + var match = TokenRegex.Match(text); + if (!match.Success || match.Index != 0 || match.Length != text.Length) + return null; + + return match.Groups["name"].Value; + } + + private async Task ResolveTextTokenAsync(string tokenName, CancellationToken cancellationToken) + { + if (string.Equals(tokenName, "time", StringComparison.OrdinalIgnoreCase)) + return DateTime.Now.ToString("yyyyMMddHHmmss"); + + return await _dataDictionaryService.ResolveTextValueAsync(tokenName, cancellationToken); + } + + private async Task EnsureDictionaryAvailableAsync(string tokenName, string expectedType, CancellationToken cancellationToken) + { + var dictionary = await _dataDictionaryService.GetByNameAsync(tokenName, cancellationToken) + ?? throw new InvalidOperationException($"未找到变量:{{{tokenName}}}"); + + if (!dictionary.IsEnabled) + throw new InvalidOperationException($"变量已停用:{{{tokenName}}}"); + + if (!string.Equals(dictionary.Type, expectedType, StringComparison.OrdinalIgnoreCase)) + { + var expectedName = string.Equals(expectedType, DataDictionaryTypes.Image, StringComparison.OrdinalIgnoreCase) + ? "图片字典" + : "文本字典"; + throw new InvalidOperationException($"变量类型不匹配:{{{tokenName}}} 需要使用{expectedName}"); + } + + var hasAvailableItem = string.Equals(expectedType, DataDictionaryTypes.Image, StringComparison.OrdinalIgnoreCase) + ? dictionary.Items.Any(x => x.IsEnabled && !string.IsNullOrWhiteSpace(x.AssetPath)) + : dictionary.Items.Any(x => x.IsEnabled && !string.IsNullOrWhiteSpace(x.TextValue)); + + if (!hasAvailableItem) + { + var valueName = string.Equals(expectedType, DataDictionaryTypes.Image, StringComparison.OrdinalIgnoreCase) + ? "图片" + : "内容"; + throw new InvalidOperationException($"变量没有可用{valueName}:{{{tokenName}}}"); + } + } +} diff --git a/src/TelegramPanel.Web/Services/UserChatActiveAiVerificationService.cs b/src/TelegramPanel.Web/Services/UserChatActiveAiVerificationService.cs new file mode 100644 index 0000000..3629b8c --- /dev/null +++ b/src/TelegramPanel.Web/Services/UserChatActiveAiVerificationService.cs @@ -0,0 +1,179 @@ +using TelegramPanel.Core.Models; +using TelegramPanel.Core.Services.Telegram; +using TelegramPanel.Data.Entities; +using TelegramPanel.Modules; + +namespace TelegramPanel.Web.Services; + +public sealed class UserChatActiveAiVerificationService +{ + private readonly AccountTelegramToolsService _accountTools; + private readonly ITelegramPanelAiService _aiService; + private readonly ILogger _logger; + + public UserChatActiveAiVerificationService( + AccountTelegramToolsService accountTools, + ITelegramPanelAiService aiService, + ILogger logger) + { + _accountTools = accountTools; + _aiService = aiService; + _logger = logger; + } + + public async Task<(bool Success, string? Error, string? ActionSummary)> TryHandleAsync( + Account account, + AccountTelegramToolsService.ResolvedChatTarget target, + int sentMessageId, + UserChatActiveTaskConfig config, + CancellationToken cancellationToken) + { + var wait = await _accountTools.WaitForBotVerificationMessageAsync( + account.Id, + target, + sentMessageId, + account.Username, + config.VerificationTimeoutSeconds, + cancellationToken); + + if (!wait.Success || wait.Candidate == null) + return (false, wait.Error, null); + + var candidate = wait.Candidate; + var image = candidate.ImageJpegBytes is { Length: > 0 } + ? new TelegramPanelAiImageInput(candidate.ImageJpegBytes) + : null; + + if (candidate.Buttons.Count > 0) + { + var decision = await _aiService.ChooseActionAsync( + new TelegramPanelAiChooseActionRequest( + Model: config.AiModel, + MessageText: candidate.Text, + Buttons: candidate.Buttons.Select(x => new TelegramPanelAiButtonOption(x.Index, x.Text)).ToList(), + Image: image, + Context: "这是 Telegram 群里的验证消息,请根据按钮文案、题目文本和图片决定最合适动作。"), + cancellationToken); + + if (!decision.Success) + return (false, decision.Error, null); + + if (string.Equals(decision.Mode, "reply_text", StringComparison.OrdinalIgnoreCase)) + { + var replyText = (decision.ReplyText ?? string.Empty).Trim(); + if (replyText.Length == 0) + return (false, "AI 返回了 reply_text,但内容为空", null); + + var mappedButton = TryMatchButtonByReplyText(candidate.Buttons, replyText); + if (mappedButton != null) + { + var mappedClick = await _accountTools.ClickInlineButtonAsync( + account.Id, + target, + candidate.MessageId, + mappedButton.CallbackData, + cancellationToken); + + if (!mappedClick.Success) + return (false, mappedClick.Error, null); + + return (true, null, $"AI 文本映射按钮:{mappedButton.Text}"); + } + + return (false, $"验证码消息存在按钮,但 AI 返回了文本:{replyText}", null); + } + + var button = candidate.Buttons.FirstOrDefault(x => x.Index == decision.ButtonIndex); + if (button == null) + return (false, $"AI 返回的按钮索引无效:{decision.ButtonIndex}", null); + + var click = await _accountTools.ClickInlineButtonAsync( + account.Id, + target, + candidate.MessageId, + button.CallbackData, + cancellationToken); + + if (!click.Success) + return (false, click.Error, null); + + _logger.LogInformation( + "AI verification button clicked for account {AccountId}, chat {ChatId}, message {MessageId}", + account.Id, + target.CanonicalId, + candidate.MessageId); + + return (true, null, $"AI 点击按钮:{button.Text}"); + } + + var replyDecision = await _aiService.ReplyTextAsync( + new TelegramPanelAiReplyTextRequest( + Model: config.AiModel, + Prompt: "这是 Telegram 群里的验证消息,请直接给出最终答案文本。", + Query: candidate.Text ?? string.Empty, + Image: image, + Context: "只返回最终答案,不要解释。"), + cancellationToken); + + if (!replyDecision.Success) + return (false, replyDecision.Error, null); + + var replyContent = (replyDecision.ReplyText ?? string.Empty).Trim(); + if (replyContent.Length == 0) + return (false, "AI 返回答案为空", null); + + var sendReply = await _accountTools.SendMessageToResolvedChatAsync( + account.Id, + target, + replyContent, + replyToMessageId: candidate.MessageId, + cancellationToken: cancellationToken); + + if (!sendReply.Success) + return (false, sendReply.Error, null); + + _logger.LogInformation( + "AI verification reply sent for account {AccountId}, chat {ChatId}, message {MessageId}", + account.Id, + target.CanonicalId, + candidate.MessageId); + + return (true, null, $"AI 文本回复:{replyContent}"); + } + + private static TelegramInlineButtonOption? TryMatchButtonByReplyText( + IReadOnlyList buttons, + string replyText) + { + var normalizedReply = NormalizeButtonText(replyText); + if (normalizedReply.Length == 0) + return null; + + foreach (var button in buttons) + { + if (NormalizeButtonText(button.Text) == normalizedReply) + return button; + } + + foreach (var button in buttons) + { + var normalizedButton = NormalizeButtonText(button.Text); + if (normalizedButton.Contains(normalizedReply, StringComparison.OrdinalIgnoreCase) + || normalizedReply.Contains(normalizedButton, StringComparison.OrdinalIgnoreCase)) + { + return button; + } + } + + return null; + } + + private static string NormalizeButtonText(string? value) + { + return (value ?? string.Empty) + .Trim() + .Replace(" ", string.Empty, StringComparison.Ordinal) + .Replace("\r", string.Empty, StringComparison.Ordinal) + .Replace("\n", string.Empty, StringComparison.Ordinal); + } +} diff --git a/src/TelegramPanel.Web/Services/UserChatActiveTaskHandler.cs b/src/TelegramPanel.Web/Services/UserChatActiveTaskHandler.cs index 53ee9fd..adbad1b 100644 --- a/src/TelegramPanel.Web/Services/UserChatActiveTaskHandler.cs +++ b/src/TelegramPanel.Web/Services/UserChatActiveTaskHandler.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using TelegramPanel.Core.BatchTasks; using TelegramPanel.Core.Services; using TelegramPanel.Core.Services.Telegram; @@ -21,9 +22,21 @@ public sealed class UserChatActiveTaskHandler : IModuleTaskHandler var taskManagement = host.Services.GetRequiredService(); var accountManagement = host.Services.GetRequiredService(); var accountTools = host.Services.GetRequiredService(); + var templateRendering = host.Services.GetRequiredService(); + var aiVerification = host.Services.GetRequiredService(); + var aiOptions = host.Services.GetRequiredService>(); var config = DeserializeConfig(host.Config); ValidateAndNormalizeConfig(config); + config.Canceled = false; + config.Error = null; + + if (config.EnableAiVerification) + { + var settings = aiOptions.CurrentValue.ToSnapshot(); + if (!settings.TryValidateForTask(config.AiModel, out var aiError)) + throw new InvalidOperationException($"AI 验证已启用,但全局 AI 配置无效:{aiError}"); + } var selectedCategoryIds = NormalizeSelectedCategoryIds(config).ToHashSet(); var allAccounts = (await accountManagement.GetAllAccountsAsync()) @@ -114,13 +127,78 @@ public sealed class UserChatActiveTaskHandler : IModuleTaskHandler var targetSlot = accountSlot.Targets[targetIdx]; var messageIdx = SelectIndex(config.MessageMode, config.Dictionary.Count, ref messageQueueIndex); - var text = config.Dictionary[messageIdx]; + var textTemplate = config.Dictionary[messageIdx]; + + if (!await host.IsStillRunningAsync(cancellationToken)) + { + config.Canceled = true; + break; + } + + string text; + try + { + text = (await templateRendering.RenderTextTemplateAsync(textTemplate, cancellationToken)).Trim(); + } + catch (Exception ex) + { + completed++; + failed++; + var hadTemplateFailure = true; + AddFailure(config, accountSlot.Account, targetSlot.RawTarget, $"词典模板解析失败:{ex.Message}"); + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); + + if (ShouldPersistProgress(completed, hadTemplateFailure, lastProgressPersistAt)) + { + await host.UpdateProgressAsync(completed, failed, cancellationToken); + lastProgressPersistAt = DateTime.UtcNow; + } + + if (config.MaxMessages > 0 && completed >= config.MaxMessages) + break; + + var templateFailDelayMs = NextDelayMilliseconds(config.DelayMinMs, config.DelayMaxMs); + if (templateFailDelayMs > 0 && !await DelayWithPauseCheckAsync(host, templateFailDelayMs, cancellationToken)) + { + config.Canceled = true; + break; + } + + continue; + } + + if (text.Length == 0) + { + completed++; + failed++; + var hadEmptyMessageFailure = true; + AddFailure(config, accountSlot.Account, targetSlot.RawTarget, "词典模板解析结果为空,无法发送"); + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); + + if (ShouldPersistProgress(completed, hadEmptyMessageFailure, lastProgressPersistAt)) + { + await host.UpdateProgressAsync(completed, failed, cancellationToken); + lastProgressPersistAt = DateTime.UtcNow; + } + + if (config.MaxMessages > 0 && completed >= config.MaxMessages) + break; + + var emptyDelayMs = NextDelayMilliseconds(config.DelayMinMs, config.DelayMaxMs); + if (emptyDelayMs > 0 && !await DelayWithPauseCheckAsync(host, emptyDelayMs, cancellationToken)) + { + config.Canceled = true; + break; + } + + continue; + } var send = await accountTools.SendMessageToResolvedChatAsync( accountSlot.Account.Id, targetSlot.Resolved, text, - cancellationToken); + cancellationToken: cancellationToken); completed++; var hadFailureThisRound = false; @@ -144,6 +222,42 @@ public sealed class UserChatActiveTaskHandler : IModuleTaskHandler await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); } + else if (config.EnableAiVerification) + { + if (!send.MessageId.HasValue || send.MessageId.Value <= 0) + { + failed++; + hadFailureThisRound = true; + AddFailure(config, accountSlot.Account, targetSlot.RawTarget, "消息已发送,但未获取到消息 ID,无法执行 AI 验证"); + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); + } + else + { + var verification = await aiVerification.TryHandleAsync( + accountSlot.Account, + targetSlot.Resolved, + send.MessageId.Value, + config, + cancellationToken); + + if (!verification.Success) + { + failed++; + hadFailureThisRound = true; + AddFailure(config, accountSlot.Account, targetSlot.RawTarget, NormalizeReason(verification.Error)); + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); + } + else + { + logger.LogInformation( + "UserChatActive AI verification completed: taskId={TaskId}, accountId={AccountId}, target={Target}, action={Action}", + host.TaskId, + accountSlot.Account.Id, + targetSlot.RawTarget, + verification.ActionSummary ?? "(none)"); + } + } + } if (ShouldPersistProgress(completed, hadFailureThisRound, lastProgressPersistAt)) { @@ -155,8 +269,11 @@ public sealed class UserChatActiveTaskHandler : IModuleTaskHandler break; var delayMs = NextDelayMilliseconds(config.DelayMinMs, config.DelayMaxMs); - if (delayMs > 0) - await Task.Delay(delayMs, cancellationToken); + if (delayMs > 0 && !await DelayWithPauseCheckAsync(host, delayMs, cancellationToken)) + { + config.Canceled = true; + break; + } } } catch (Exception ex) @@ -229,6 +346,10 @@ public sealed class UserChatActiveTaskHandler : IModuleTaskHandler if (config.DelayMaxMs < config.DelayMinMs) config.DelayMaxMs = config.DelayMinMs; if (config.MaxMessages < 0) config.MaxMessages = 0; + if (config.VerificationTimeoutSeconds < 3) config.VerificationTimeoutSeconds = 15; + if (config.VerificationTimeoutSeconds > 300) config.VerificationTimeoutSeconds = 300; + + config.AiModel = AiOpenAiSettingsSnapshot.NormalizeModel(config.AiModel); config.AccountMode = NormalizeMode(config.AccountMode); config.TargetMode = NormalizeMode(config.TargetMode); @@ -298,6 +419,29 @@ public sealed class UserChatActiveTaskHandler : IModuleTaskHandler return Random.Shared.Next(minMs, maxMs + 1); } + private static async Task DelayWithPauseCheckAsync( + IModuleTaskExecutionHost host, + int delayMs, + CancellationToken cancellationToken) + { + if (delayMs <= 0) + return true; + + var remaining = delayMs; + while (remaining > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!await host.IsStillRunningAsync(cancellationToken)) + return false; + + var chunk = Math.Min(remaining, 1000); + await Task.Delay(chunk, cancellationToken); + remaining -= chunk; + } + + return true; + } + private static bool ShouldPersistProgress(int completed, bool hadFailureThisRound, DateTime lastPersistAt) { if (completed <= 1) diff --git a/src/TelegramPanel.Web/Services/UserChatActiveTaskModels.cs b/src/TelegramPanel.Web/Services/UserChatActiveTaskModels.cs index 7b7744a..08bb6b3 100644 --- a/src/TelegramPanel.Web/Services/UserChatActiveTaskModels.cs +++ b/src/TelegramPanel.Web/Services/UserChatActiveTaskModels.cs @@ -46,6 +46,15 @@ public sealed class UserChatActiveTaskConfig [JsonPropertyName("max_messages")] public int MaxMessages { get; set; } + [JsonPropertyName("enable_ai_verification")] + public bool EnableAiVerification { get; set; } + + [JsonPropertyName("ai_model")] + public string? AiModel { get; set; } + + [JsonPropertyName("verification_timeout_seconds")] + public int VerificationTimeoutSeconds { get; set; } = 15; + [JsonPropertyName("canceled")] public bool Canceled { get; set; } diff --git a/src/TelegramPanel.Web/Services/UserChatActiveTaskRerunBuilder.cs b/src/TelegramPanel.Web/Services/UserChatActiveTaskRerunBuilder.cs index ade6479..87a4d7e 100644 --- a/src/TelegramPanel.Web/Services/UserChatActiveTaskRerunBuilder.cs +++ b/src/TelegramPanel.Web/Services/UserChatActiveTaskRerunBuilder.cs @@ -71,6 +71,10 @@ public sealed class UserChatActiveTaskRerunBuilder : IModuleTaskRerunBuilder if (cfg.DelayMaxMs > 600000) cfg.DelayMaxMs = 600000; if (cfg.DelayMaxMs < cfg.DelayMinMs) cfg.DelayMaxMs = cfg.DelayMinMs; if (cfg.MaxMessages < 0) cfg.MaxMessages = 0; + if (cfg.VerificationTimeoutSeconds < 3) cfg.VerificationTimeoutSeconds = 15; + if (cfg.VerificationTimeoutSeconds > 300) cfg.VerificationTimeoutSeconds = 300; + + cfg.AiModel = AiOpenAiSettingsSnapshot.NormalizeModel(cfg.AiModel); cfg.AccountMode = NormalizeModeValue(cfg.AccountMode); cfg.TargetMode = NormalizeModeValue(cfg.TargetMode);