From c8bc7103ba5d732bf7fdaea98a523c178ebbcd93 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Tue, 23 Jun 2026 19:17:10 -0700 Subject: [PATCH] Add LineNotificationService with send, binding, and group ops Co-Authored-By: Claude Sonnet 4.6 --- .../LineNotificationServiceTests.cs | 186 ++++++++++++++++++ .../Notifications/ILineNotificationService.cs | 20 ++ .../Notifications/LineNotificationService.cs | 164 +++++++++++++++ 3 files changed, 370 insertions(+) create mode 100644 API/ROLAC.API.Tests/Services/Notifications/LineNotificationServiceTests.cs create mode 100644 API/ROLAC.API/Services/Notifications/ILineNotificationService.cs create mode 100644 API/ROLAC.API/Services/Notifications/LineNotificationService.cs diff --git a/API/ROLAC.API.Tests/Services/Notifications/LineNotificationServiceTests.cs b/API/ROLAC.API.Tests/Services/Notifications/LineNotificationServiceTests.cs new file mode 100644 index 0000000..328ac73 --- /dev/null +++ b/API/ROLAC.API.Tests/Services/Notifications/LineNotificationServiceTests.cs @@ -0,0 +1,186 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Moq; +using ROLAC.API.Data; +using ROLAC.API.Data.Interceptors; +using ROLAC.API.Entities; +using ROLAC.API.Entities.Notifications; +using ROLAC.API.Services.Logging; +using ROLAC.API.Services.Notifications; +using Xunit; + +namespace ROLAC.API.Tests.Services.Notifications; + +public class LineNotificationServiceTests +{ + // Records pushes; can be told to fail every call. + private sealed class FakeMessageChannel : IMessageChannel + { + public List<(string Target, string Text)> UserPushes { get; } = new(); + public List<(string Target, string Text)> GroupPushes { get; } = new(); + public bool Fail { get; set; } + + public Task PushToUserAsync(string externalId, string text, CancellationToken ct = default) + { + UserPushes.Add((externalId, text)); + return Task.FromResult(new MessageSendResult(!Fail, Fail ? "boom" : null)); + } + public Task PushToGroupAsync(string externalId, string text, CancellationToken ct = default) + { + GroupPushes.Add((externalId, text)); + return Task.FromResult(new MessageSendResult(!Fail, Fail ? "boom" : null)); + } + public Task ReplyAsync(string replyToken, string text, CancellationToken ct = default) + => Task.FromResult(new MessageSendResult(true, null)); + } + + private static CurrentUserAccessor BuildAccessor(string userId = "test-user") + { + var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) }; + var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) }; + var mock = new Mock(); + mock.Setup(x => x.HttpContext).Returns(ctx); + return new CurrentUserAccessor(mock.Object); + } + + private static AppDbContext BuildDb() + { + var interceptor = new AuditSaveChangesInterceptor(BuildAccessor()); + return new AppDbContext( + new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .AddInterceptors(interceptor) + .Options); + } + + private static async Task SeedMemberAsync(AppDbContext db) + { + var member = new Member { FirstName_en = "Test", LastName_en = "User" }; + db.Members.Add(member); + await db.SaveChangesAsync(); + return member.Id; + } + + [Fact] + public async Task GenerateLineBindingCodeAsync_PersistsUnconsumedCode() + { + using var db = BuildDb(); + var memberId = await SeedMemberAsync(db); + var service = new LineNotificationService(db, new FakeMessageChannel()); + + var code = await service.GenerateLineBindingCodeAsync(memberId); + + var stored = await db.LineBindingCodes.SingleAsync(); + Assert.Equal(code, stored.Code); + Assert.Null(stored.ConsumedAt); + Assert.True(stored.ExpiresAt > DateTime.UtcNow); + } + + [Fact] + public async Task TryBindMemberAsync_BindsMember_AndConsumesCode() + { + using var db = BuildDb(); + var memberId = await SeedMemberAsync(db); + var service = new LineNotificationService(db, new FakeMessageChannel()); + var code = await service.GenerateLineBindingCodeAsync(memberId); + + var result = await service.TryBindMemberAsync("U999", code); + + Assert.True(result.Success); + Assert.Equal(memberId, result.MemberId); + var binding = await db.MemberChannelBindings.SingleAsync(); + Assert.Equal("U999", binding.ExternalId); + Assert.NotNull((await db.LineBindingCodes.SingleAsync()).ConsumedAt); + } + + [Fact] + public async Task TryBindMemberAsync_Fails_ForExpiredOrUsedOrUnknownCode() + { + using var db = BuildDb(); + var memberId = await SeedMemberAsync(db); + db.LineBindingCodes.Add(new LineBindingCode + { + Code = "EXPIRE", MemberId = memberId, ExpiresAt = DateTime.UtcNow.AddMinutes(-1), + }); + await db.SaveChangesAsync(); + var service = new LineNotificationService(db, new FakeMessageChannel()); + + Assert.False((await service.TryBindMemberAsync("U1", "EXPIRE")).Success); // expired + Assert.False((await service.TryBindMemberAsync("U1", "NOPE")).Success); // unknown + Assert.Empty(await db.MemberChannelBindings.ToListAsync()); + } + + [Fact] + public async Task TryBindMemberAsync_Rebinds_UpdatesExistingBinding() + { + using var db = BuildDb(); + var memberId = await SeedMemberAsync(db); + var service = new LineNotificationService(db, new FakeMessageChannel()); + await service.TryBindMemberAsync("U-OLD", await service.GenerateLineBindingCodeAsync(memberId)); + + await service.TryBindMemberAsync("U-NEW", await service.GenerateLineBindingCodeAsync(memberId)); + + var binding = await db.MemberChannelBindings.SingleAsync(); + Assert.Equal("U-NEW", binding.ExternalId); + } + + [Fact] + public async Task RegisterGroupAsync_IsIdempotent_AndDeactivateFlips() + { + using var db = BuildDb(); + var service = new LineNotificationService(db, new FakeMessageChannel()); + + await service.RegisterGroupAsync("G1"); + await service.RegisterGroupAsync("G1"); // second call must not duplicate + Assert.Equal(1, await db.MessagingGroups.CountAsync()); + Assert.True((await db.MessagingGroups.SingleAsync()).IsActive); + + await service.DeactivateGroupAsync("G1"); + Assert.False((await db.MessagingGroups.SingleAsync()).IsActive); + } + + [Fact] + public async Task SendLineAsync_PushesToBoundMembersAndActiveGroups_AndLogs() + { + using var db = BuildDb(); + var memberId = await SeedMemberAsync(db); + db.MemberChannelBindings.Add(new MemberChannelBinding + { + MemberId = memberId, Channel = "line", ExternalId = "U-MEM", BoundAt = DateTime.UtcNow, + }); + var activeGroup = new MessagingGroup { Channel = "line", ExternalId = "G-ON", IsActive = true, RegisteredAt = DateTime.UtcNow }; + var deadGroup = new MessagingGroup { Channel = "line", ExternalId = "G-OFF", IsActive = false, RegisteredAt = DateTime.UtcNow }; + db.MessagingGroups.AddRange(activeGroup, deadGroup); + await db.SaveChangesAsync(); + var channel = new FakeMessageChannel(); + var service = new LineNotificationService(db, channel); + + var result = await service.SendLineAsync("notice", new[] { memberId }, + new[] { activeGroup.Id, deadGroup.Id }, "admin-1"); + + Assert.Equal(2, result.SentCount); // member + active group only + Assert.Single(channel.UserPushes); + Assert.Single(channel.GroupPushes); // inactive group skipped + Assert.Equal(2, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Sent)); + } + + [Fact] + public async Task SendLineAsync_RecordsFailures_WhenChannelFails() + { + using var db = BuildDb(); + var memberId = await SeedMemberAsync(db); + db.MemberChannelBindings.Add(new MemberChannelBinding + { + MemberId = memberId, Channel = "line", ExternalId = "U-MEM", BoundAt = DateTime.UtcNow, + }); + await db.SaveChangesAsync(); + var service = new LineNotificationService(db, new FakeMessageChannel { Fail = true }); + + var result = await service.SendLineAsync("notice", new[] { memberId }, Array.Empty(), "admin-1"); + + Assert.Equal(0, result.SentCount); + Assert.Equal(1, result.FailedCount); + Assert.Equal(1, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Failed)); + } +} diff --git a/API/ROLAC.API/Services/Notifications/ILineNotificationService.cs b/API/ROLAC.API/Services/Notifications/ILineNotificationService.cs new file mode 100644 index 0000000..14530df --- /dev/null +++ b/API/ROLAC.API/Services/Notifications/ILineNotificationService.cs @@ -0,0 +1,20 @@ +namespace ROLAC.API.Services.Notifications; + +/// Outcome of a webhook-driven binding attempt. +public sealed record LineBindingResult(bool Success, string Message, int? MemberId); + +/// +/// Line-specific notification operations: outbound push to bound members/groups, plus the +/// webhook-driven binding-code generation/consumption and group registration. +/// +public interface ILineNotificationService +{ + Task SendLineAsync(string body, int[] memberIds, int[] groupIds, + string sentByUserId, CancellationToken ct = default); + + Task GenerateLineBindingCodeAsync(int memberId, CancellationToken ct = default); + + Task TryBindMemberAsync(string externalId, string code, CancellationToken ct = default); + Task RegisterGroupAsync(string externalId, CancellationToken ct = default); + Task DeactivateGroupAsync(string externalId, CancellationToken ct = default); +} diff --git a/API/ROLAC.API/Services/Notifications/LineNotificationService.cs b/API/ROLAC.API/Services/Notifications/LineNotificationService.cs new file mode 100644 index 0000000..fdb83b1 --- /dev/null +++ b/API/ROLAC.API/Services/Notifications/LineNotificationService.cs @@ -0,0 +1,164 @@ +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); + } +}