member_center/src/MemberCenter.Infrastructure/Services/AccountProvisioningService.cs
warrenchen 75e235b8e3 Add admin area controllers and views for managing OAuth clients, security settings, subscriptions, and tenants
- Implemented OAuthClientsController for CRUD operations on OAuth clients.
- Added SecurityController to manage security settings.
- Created SubscriptionsController for handling subscriptions with export functionality.
- Developed TenantsController for tenant management including create, edit, and delete operations.
- Added views for each controller to facilitate user interaction.
- Introduced layout and shared views for consistent admin UI.
- Implemented model classes for handling data in views.
- Added validation and error handling in forms.
2026-04-01 17:40:45 +09:00

245 lines
7.8 KiB
C#

using System.Text.Json;
using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Models.Account;
using MemberCenter.Domain.Entities;
using MemberCenter.Infrastructure.Identity;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using MemberCenter.Application.Models.Newsletter;
namespace MemberCenter.Infrastructure.Services;
public sealed class AccountProvisioningService : IAccountProvisioningService
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly MemberCenterDbContext _dbContext;
private readonly ISendEngineWebhookPublisher _webhookPublisher;
private readonly ILogger<AccountProvisioningService> _logger;
public AccountProvisioningService(
UserManager<ApplicationUser> userManager,
MemberCenterDbContext dbContext,
ISendEngineWebhookPublisher webhookPublisher,
ILogger<AccountProvisioningService> logger)
{
_userManager = userManager;
_dbContext = dbContext;
_webhookPublisher = webhookPublisher;
_logger = logger;
}
public async Task<AccountProvisioningResult> RegisterLocalAsync(string email, string password)
{
var normalizedEmail = NormalizeEmail(email);
var user = new ApplicationUser
{
Id = Guid.NewGuid(),
UserName = normalizedEmail,
Email = normalizedEmail,
EmailConfirmed = false
};
var result = await _userManager.CreateAsync(user, password);
if (!result.Succeeded)
{
return Failed(result);
}
var linkedSubscriptionsCount = await LinkSubscriptionsAndAuditAsync(user, "local_registration", new
{
email = normalizedEmail
});
return new AccountProvisioningResult(
true,
user.Id,
user.Email,
user.EmailConfirmed,
true,
false,
linkedSubscriptionsCount,
Array.Empty<string>());
}
public async Task<AccountProvisioningResult> ProvisionExternalLoginAsync(
string loginProvider,
string providerKey,
string? email,
bool emailVerified)
{
var existingByLogin = await _userManager.FindByLoginAsync(loginProvider, providerKey);
if (existingByLogin is not null)
{
var linkedSubscriptionsCount = await LinkSubscriptionsAndAuditAsync(existingByLogin, "external_login_reuse", new
{
login_provider = loginProvider
});
return new AccountProvisioningResult(
true,
existingByLogin.Id,
existingByLogin.Email,
existingByLogin.EmailConfirmed,
false,
false,
linkedSubscriptionsCount,
Array.Empty<string>());
}
if (string.IsNullOrWhiteSpace(email))
{
return new AccountProvisioningResult(
false,
null,
null,
false,
false,
false,
0,
new[] { "External login did not provide an email address." });
}
var normalizedEmail = NormalizeEmail(email);
var user = await _userManager.FindByEmailAsync(normalizedEmail);
var createdUser = false;
if (user is null)
{
user = new ApplicationUser
{
Id = Guid.NewGuid(),
UserName = normalizedEmail,
Email = normalizedEmail,
EmailConfirmed = emailVerified
};
var createResult = await _userManager.CreateAsync(user);
if (!createResult.Succeeded)
{
return Failed(createResult);
}
createdUser = true;
}
else if (emailVerified && !user.EmailConfirmed)
{
user.EmailConfirmed = true;
await _userManager.UpdateAsync(user);
}
var addLoginResult = await _userManager.AddLoginAsync(user, new UserLoginInfo(loginProvider, providerKey, loginProvider));
if (!addLoginResult.Succeeded
&& addLoginResult.Errors.All(x => x.Code != nameof(IdentityErrorDescriber.LoginAlreadyAssociated)))
{
return Failed(addLoginResult);
}
var linkedSubscriptions = await LinkSubscriptionsAndAuditAsync(user, "external_login_linked", new
{
login_provider = loginProvider,
created_user = createdUser
});
return new AccountProvisioningResult(
true,
user.Id,
user.Email,
user.EmailConfirmed,
createdUser,
true,
linkedSubscriptions,
Array.Empty<string>());
}
private async Task<int> LinkSubscriptionsAndAuditAsync(ApplicationUser user, string source, object payload)
{
var email = NormalizeEmail(user.Email ?? user.UserName ?? string.Empty);
var subscriptions = await _dbContext.NewsletterSubscriptions
.Where(x => x.UserId == null && x.Email.ToLower() == email)
.ToListAsync();
if (subscriptions.Count == 0)
{
return 0;
}
foreach (var subscription in subscriptions)
{
subscription.UserId = user.Id;
}
var linkedSubscriptions = subscriptions
.Select(subscription => new SubscriptionDto(
subscription.Id,
subscription.ListId,
subscription.Email,
subscription.Status,
subscription.Preferences.RootElement.Clone(),
subscription.CreatedAt))
.ToList();
_dbContext.AuditLogs.Add(new AuditLog
{
Id = Guid.NewGuid(),
ActorType = "system",
ActorId = user.Id,
Action = "subscription.linked_to_user",
Payload = JsonDocument.Parse(JsonSerializer.Serialize(new
{
user_id = user.Id,
email,
linked_subscriptions = subscriptions.Count,
source,
metadata = payload
}))
});
await _dbContext.SaveChangesAsync();
await PublishLinkedSubscriptionEventsAsync(linkedSubscriptions);
return subscriptions.Count;
}
private async Task PublishLinkedSubscriptionEventsAsync(IReadOnlyList<SubscriptionDto> subscriptions)
{
if (subscriptions.Count == 0)
{
return;
}
var listIds = subscriptions.Select(x => x.ListId).Distinct().ToList();
var tenantMap = await _dbContext.NewsletterLists
.Where(x => listIds.Contains(x.Id))
.ToDictionaryAsync(x => x.Id, x => x.TenantId);
foreach (var subscription in subscriptions)
{
if (!tenantMap.TryGetValue(subscription.ListId, out var tenantId) || tenantId == Guid.Empty)
{
_logger.LogWarning(
"Skip linked subscription event because list {ListId} has no tenant mapping",
subscription.ListId);
continue;
}
await _webhookPublisher.PublishSubscriptionEventAsync("subscription.linked_to_user", tenantId, subscription);
}
}
private static AccountProvisioningResult Failed(IdentityResult result) =>
new(
false,
null,
null,
false,
false,
false,
0,
result.Errors.Select(x => x.Description).ToArray());
private static string NormalizeEmail(string email) =>
email.Trim().ToLowerInvariant();
}