169 lines
6.3 KiB
C#
169 lines
6.3 KiB
C#
using System.Security.Cryptography;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using ROLAC.API.Data;
|
|
using ROLAC.API.Entities.Notifications;
|
|
|
|
namespace ROLAC.API.Services.Notifications;
|
|
|
|
/// <summary>
|
|
/// Line outbound push + webhook-driven binding/group operations. All sends write a
|
|
/// NotificationLog row; binding consumes a short-lived, single-use code.
|
|
/// </summary>
|
|
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<NotificationResult> SendLineAsync(string body, int[] memberIds, int[] groupIds,
|
|
string sentByUserId, CancellationToken ct = default)
|
|
{
|
|
var failures = new List<NotificationFailure>();
|
|
var sentCount = 0;
|
|
|
|
var liveMemberIds = await _db.Members
|
|
.Where(m => memberIds.Contains(m.Id))
|
|
.Select(m => m.Id)
|
|
.ToListAsync(ct);
|
|
var bindings = await _db.MemberChannelBindings
|
|
.Where(b => b.Channel == Channel && liveMemberIds.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<string> 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<LineBindingResult> 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 = NotificationLogText.Truncate(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);
|
|
}
|
|
}
|