using System.Security.Cryptography; using Microsoft.EntityFrameworkCore; using ROLAC.API.Data; using ROLAC.API.Entities.Notifications; namespace ROLAC.API.Services.Notifications; /// /// Line outbound push + webhook-driven binding/group operations. All sends write a /// NotificationLog row; binding consumes a short-lived, single-use code. /// public sealed class LineNotificationService : ILineNotificationService { private const string Channel = NotificationChannels.Line; private const string CodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no I/O/0/1 private const int CodeLength = 6; private static readonly TimeSpan CodeLifetime = TimeSpan.FromMinutes(15); private readonly AppDbContext _db; private readonly IMessageChannel _channel; public LineNotificationService(AppDbContext db, IMessageChannel channel) { _db = db; _channel = channel; } public async Task SendLineAsync(string body, int[] memberIds, int[] groupIds, string sentByUserId, CancellationToken ct = default) { var failures = new List(); var sentCount = 0; var bindings = await _db.MemberChannelBindings .Where(b => b.Channel == Channel && memberIds.Contains(b.MemberId)) .ToListAsync(ct); foreach (var binding in bindings) { var result = await _channel.PushToUserAsync(binding.ExternalId, body, ct); _db.NotificationLogs.Add(BuildLog(NotificationTargetTypes.User, binding.ExternalId, body, sentByUserId, result, memberId: binding.MemberId)); if (result.Success) sentCount++; else failures.Add(new NotificationFailure($"member:{binding.MemberId}", result.Error ?? "unknown")); } var groups = await _db.MessagingGroups .Where(g => g.Channel == Channel && g.IsActive && groupIds.Contains(g.Id)) .ToListAsync(ct); foreach (var group in groups) { var result = await _channel.PushToGroupAsync(group.ExternalId, body, ct); _db.NotificationLogs.Add(BuildLog(NotificationTargetTypes.Group, group.ExternalId, body, sentByUserId, result, groupId: group.Id)); if (result.Success) sentCount++; else failures.Add(new NotificationFailure($"group:{group.Id}", result.Error ?? "unknown")); } await _db.SaveChangesAsync(ct); return new NotificationResult(sentCount, failures.Count, failures); } public async Task GenerateLineBindingCodeAsync(int memberId, CancellationToken ct = default) { var code = GenerateCode(); _db.LineBindingCodes.Add(new LineBindingCode { Code = code, MemberId = memberId, ExpiresAt = DateTime.UtcNow.Add(CodeLifetime), }); await _db.SaveChangesAsync(ct); return code; } public async Task TryBindMemberAsync(string externalId, string code, CancellationToken ct = default) { var normalized = code.Trim().ToUpperInvariant(); var now = DateTime.UtcNow; var bindingCode = await _db.LineBindingCodes .FirstOrDefaultAsync(c => c.Code == normalized && c.ConsumedAt == null && c.ExpiresAt > now, ct); if (bindingCode is null) return new LineBindingResult(false, "綁定碼無效或已過期。", null); bindingCode.ConsumedAt = now; var existing = await _db.MemberChannelBindings .FirstOrDefaultAsync(b => b.Channel == Channel && b.MemberId == bindingCode.MemberId, ct); if (existing is null) { _db.MemberChannelBindings.Add(new MemberChannelBinding { MemberId = bindingCode.MemberId, Channel = Channel, ExternalId = externalId, BoundAt = now, }); } else { existing.ExternalId = externalId; existing.BoundAt = now; } await _db.SaveChangesAsync(ct); return new LineBindingResult(true, "綁定成功!", bindingCode.MemberId); } public async Task RegisterGroupAsync(string externalId, CancellationToken ct = default) { var group = await _db.MessagingGroups .FirstOrDefaultAsync(g => g.Channel == Channel && g.ExternalId == externalId, ct); if (group is null) { _db.MessagingGroups.Add(new MessagingGroup { Channel = Channel, ExternalId = externalId, IsActive = true, RegisteredAt = DateTime.UtcNow, }); } else { group.IsActive = true; } await _db.SaveChangesAsync(ct); } public async Task DeactivateGroupAsync(string externalId, CancellationToken ct = default) { var group = await _db.MessagingGroups .FirstOrDefaultAsync(g => g.Channel == Channel && g.ExternalId == externalId, ct); if (group is not null) { group.IsActive = false; await _db.SaveChangesAsync(ct); } } private static NotificationLog BuildLog(string targetType, string externalId, string body, string sentBy, MessageSendResult result, int? memberId = null, int? groupId = null) => new() { Channel = Channel, TargetType = targetType, TargetExternalId = externalId, MemberId = memberId, MessagingGroupId = groupId, Body = body, Status = result.Success ? NotificationStatuses.Sent : NotificationStatuses.Failed, Error = result.Error, SentByUserId = sentBy, SentAt = DateTime.UtcNow, }; private static string GenerateCode() { var bytes = RandomNumberGenerator.GetBytes(CodeLength); var chars = new char[CodeLength]; for (var i = 0; i < CodeLength; i++) chars[i] = CodeAlphabet[bytes[i] % CodeAlphabet.Length]; return new string(chars); } }