feat: Implement Send Engine webhook client management and update related API documentation
This commit is contained in:
parent
035a7ca821
commit
5c7d4cdf5a
@ -2,3 +2,4 @@ ASPNETCORE_ENVIRONMENT=Development
|
||||
ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgres;Password=postgres
|
||||
SendEngine__BaseUrl=http://localhost:6060
|
||||
SendEngine__WebhookSecret=change-me
|
||||
Testing__DisableSubscriptionDryRunNoDb=false
|
||||
|
||||
@ -135,6 +135,7 @@
|
||||
- POST `/webhooks/subscriptions`(Send Engine 端點,Member Center 呼叫)
|
||||
- POST `/webhooks/lists/full-sync`(Send Engine 端點,Member Center 呼叫)
|
||||
- POST `/subscriptions/disable`(Member Center 端點,Send Engine 呼叫)
|
||||
- POST `/integrations/send-engine/webhook-clients/upsert`(Member Center 端點,Send Engine 呼叫)
|
||||
|
||||
## 7.1 待新增 API / Auth(規劃中)
|
||||
### API
|
||||
|
||||
@ -40,7 +40,7 @@
|
||||
- [API] Member Center 以 webhook 推送 `subscription.activated/unsubscribed/preferences.updated`(scope: `newsletter:events.write`)
|
||||
- [API] Header 使用 `X-Signature` / `X-Timestamp` / `X-Nonce` / `X-Client-Id`
|
||||
- [API] `X-Client-Id` 對應 Send Engine `auth_clients.id`(UUID)
|
||||
- [API] Member Center 以 `SendEngine__TenantWebhookClientIds__{tenant_uuid}` 選擇 header 值
|
||||
- [API] Member Center 從 DB 的 tenant 設定讀取對應 webhook client id
|
||||
- [API] Send Engine 驗證簽章 + timestamp + nonce(重放防護)後入庫
|
||||
- [API] Send Engine 更新名單快照
|
||||
|
||||
@ -54,6 +54,11 @@
|
||||
- [API] body 需含 `tenant_id + subscriber_id + list_id + reason + disabled_by + occurred_at`
|
||||
- [API] Member Center 將 email 寫入 `email_blacklist`,停用寄送並停止事件推送
|
||||
|
||||
## F-12 Webhook Client Mapping 回填(Send Engine → Member Center)
|
||||
- [API] Send Engine 建立/更新 tenant 對應的 webhook client(`auth_clients.id`)
|
||||
- [API] 呼叫 `POST /integrations/send-engine/webhook-clients/upsert`(scope: `newsletter:events.write.global`)
|
||||
- [API] Member Center 更新 tenant 設定(DB)
|
||||
|
||||
## F-07 會員資料查看
|
||||
- [API] 站點讀取 `/user/profile`
|
||||
- [UI] 會員中心提供個人資料頁
|
||||
|
||||
@ -42,13 +42,19 @@ ASPNETCORE_ENVIRONMENT=Development
|
||||
ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgres;Password=postgres
|
||||
SendEngine__BaseUrl=http://localhost:6060
|
||||
SendEngine__WebhookSecret=change-me
|
||||
SendEngine__TenantWebhookClientIds__REPLACE_WITH_TENANT_ID=11111111-1111-1111-1111-111111111111
|
||||
```
|
||||
|
||||
`SendEngine` 設定說明:
|
||||
- `SendEngine__BaseUrl`: Send Engine API base URL
|
||||
- `SendEngine__WebhookSecret`: 與 Send Engine `Webhook:Secrets:member_center` 一致
|
||||
- `SendEngine__TenantWebhookClientIds__{tenant_uuid}`: 每個 tenant 對應的 `X-Client-Id`(Send Engine `auth_clients.id`)
|
||||
- tenant 對應 `X-Client-Id` 改由 DB 管理(Tenant 設定)
|
||||
- 可透過管理 UI 設定,或由 Send Engine 呼叫 `POST /integrations/send-engine/webhook-clients/upsert` 自動回填
|
||||
|
||||
測試旗標(TEST-ONLY):
|
||||
- `Testing__DisableSubscriptionDryRunNoDb=true`
|
||||
- 作用:`POST /subscriptions/disable` 完全不做 DB read/write,只寫 log(含預計執行的 DB 動作)
|
||||
- 用途:SES/Send Engine 串接初次測試,避免測試資料污染
|
||||
- 測試結束請改回 `false`
|
||||
|
||||
### 1) `installer init`
|
||||
用途:首次安裝(含 migrations + seed + superuser)
|
||||
|
||||
@ -49,7 +49,8 @@
|
||||
- `X-Client-Id`
|
||||
- `X-Client-Id` 來源:
|
||||
- 由 Send Engine 的 `auth_clients.id`(UUID)提供
|
||||
- Member Center 以 `SendEngine__TenantWebhookClientIds__{tenant_uuid}` 設定每個租戶的對應值
|
||||
- Member Center 以 DB 設定(tenant 設定欄位)保存每個租戶的對應值
|
||||
- 可由管理 UI(Tenant 編輯)或整合 API `POST /integrations/send-engine/webhook-clients/upsert` 更新
|
||||
- 簽章建議:
|
||||
- `HMAC-SHA256(secret, "{raw_body}")`(對齊 Send Engine 驗證器)
|
||||
- 驗證規則:
|
||||
@ -83,6 +84,7 @@
|
||||
- `POST /webhooks/subscriptions`:已實作(Member Center 發送;Send Engine 接收)
|
||||
- `POST /webhooks/lists/full-sync`:規格已定義(Member Center 發送;Send Engine 接收)
|
||||
- `POST /subscriptions/disable`:已實作(Send Engine 回寫黑名單)
|
||||
- `POST /integrations/send-engine/webhook-clients/upsert`:已實作(Send Engine 回填 tenant webhook client id)
|
||||
|
||||
### Auth / Scope
|
||||
- `tenant_api` / `webhook_outbound` 類型需綁定 `tenant_id`
|
||||
|
||||
@ -48,6 +48,7 @@
|
||||
|
||||
### 管理者端(統一 UI)
|
||||
- UC-11 租戶管理: `/admin/tenants`
|
||||
- UC-11.1 Tenant 可設定 `Send Engine Webhook Client Id`(UUID)
|
||||
- UC-12 OAuth Client 管理: `/admin/oauth-clients`(建立時顯示一次 client_secret,可旋轉;可選 `usage=tenant_api` / `webhook_outbound` / `platform_service`;`platform_service` 可不指定 tenant)
|
||||
- UC-13 電子報清單管理: `/admin/newsletter-lists`
|
||||
- UC-14 訂閱查詢 / 匯出: `/admin/subscriptions`, `/admin/subscriptions/export`
|
||||
|
||||
@ -463,6 +463,29 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/integrations/send-engine/webhook-clients/upsert:
|
||||
post:
|
||||
summary: Upsert Send Engine webhook client mapping for tenant
|
||||
security: [{ BearerAuth: [] }]
|
||||
description: Requires scope `newsletter:events.write.global`.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UpsertSendEngineWebhookClientRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Updated
|
||||
'400':
|
||||
description: Bad request
|
||||
'401':
|
||||
description: Unauthorized
|
||||
'403':
|
||||
description: Forbidden
|
||||
'404':
|
||||
description: Tenant not found
|
||||
|
||||
/webhooks/subscriptions:
|
||||
post:
|
||||
summary: (NOTE) Member Center -> Send Engine subscription events webhook
|
||||
@ -898,6 +921,17 @@ components:
|
||||
name: { type: string }
|
||||
domains: { type: array, items: { type: string } }
|
||||
status: { type: string }
|
||||
send_engine_webhook_client_id:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
|
||||
UpsertSendEngineWebhookClientRequest:
|
||||
type: object
|
||||
required: [tenant_id, webhook_client_id]
|
||||
properties:
|
||||
tenant_id: { type: string, format: uuid }
|
||||
webhook_client_id: { type: string, format: uuid }
|
||||
|
||||
NewsletterList:
|
||||
type: object
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
namespace MemberCenter.Api.Contracts;
|
||||
|
||||
public sealed record TenantRequest(string Name, List<string> Domains, string Status);
|
||||
public sealed record TenantRequest(string Name, List<string> Domains, string Status, Guid? SendEngineWebhookClientId = null);
|
||||
|
||||
public sealed record NewsletterListRequest(Guid TenantId, string Name, string Status);
|
||||
|
||||
|
||||
7
src/MemberCenter.Api/Contracts/IntegrationRequests.cs
Normal file
7
src/MemberCenter.Api/Contracts/IntegrationRequests.cs
Normal file
@ -0,0 +1,7 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace MemberCenter.Api.Contracts;
|
||||
|
||||
public sealed record UpsertSendEngineWebhookClientRequest(
|
||||
[property: JsonPropertyName("tenant_id")] Guid TenantId,
|
||||
[property: JsonPropertyName("webhook_client_id")] Guid WebhookClientId);
|
||||
@ -27,7 +27,7 @@ public class AdminTenantsController : ControllerBase
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] TenantRequest request)
|
||||
{
|
||||
var tenant = await _tenantService.CreateAsync(request.Name, request.Domains, request.Status);
|
||||
var tenant = await _tenantService.CreateAsync(request.Name, request.Domains, request.Status, request.SendEngineWebhookClientId);
|
||||
return Created($"/admin/tenants/{tenant.Id}", tenant);
|
||||
}
|
||||
|
||||
@ -46,7 +46,7 @@ public class AdminTenantsController : ControllerBase
|
||||
[HttpPut("{id:guid}")]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] TenantRequest request)
|
||||
{
|
||||
var tenant = await _tenantService.UpdateAsync(id, request.Name, request.Domains, request.Status);
|
||||
var tenant = await _tenantService.UpdateAsync(id, request.Name, request.Domains, request.Status, request.SendEngineWebhookClientId);
|
||||
if (tenant is null)
|
||||
{
|
||||
return NotFound();
|
||||
|
||||
@ -0,0 +1,54 @@
|
||||
using MemberCenter.Api.Contracts;
|
||||
using MemberCenter.Application.Abstractions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OpenIddict.Abstractions;
|
||||
|
||||
namespace MemberCenter.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("integrations/send-engine")]
|
||||
public class SendEngineIntegrationController : ControllerBase
|
||||
{
|
||||
private readonly ITenantService _tenantService;
|
||||
|
||||
public SendEngineIntegrationController(ITenantService tenantService)
|
||||
{
|
||||
_tenantService = tenantService;
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpPost("webhook-clients/upsert")]
|
||||
public async Task<IActionResult> UpsertWebhookClient([FromBody] UpsertSendEngineWebhookClientRequest request)
|
||||
{
|
||||
if (!HasScope(User, "newsletter:events.write.global"))
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
if (request.TenantId == Guid.Empty || request.WebhookClientId == Guid.Empty)
|
||||
{
|
||||
return BadRequest("tenant_id and webhook_client_id are required.");
|
||||
}
|
||||
|
||||
var updated = await _tenantService.SetSendEngineWebhookClientAsync(request.TenantId, request.WebhookClientId);
|
||||
if (!updated)
|
||||
{
|
||||
return NotFound("Tenant not found.");
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
tenant_id = request.TenantId,
|
||||
webhook_client_id = request.WebhookClientId,
|
||||
status = "updated"
|
||||
});
|
||||
}
|
||||
|
||||
private static bool HasScope(System.Security.Claims.ClaimsPrincipal user, string scope)
|
||||
{
|
||||
var values = user.FindAll(OpenIddictConstants.Claims.Scope)
|
||||
.SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries));
|
||||
return values.Contains(scope, StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,8 @@ using MemberCenter.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenIddict.Abstractions;
|
||||
|
||||
namespace MemberCenter.Api.Controllers;
|
||||
@ -20,15 +22,24 @@ public class SubscriptionsController : ControllerBase
|
||||
"suppression"
|
||||
};
|
||||
|
||||
// TEST-ONLY SWITCH: keep this key stable so it is easy to find/remove after SES integration test.
|
||||
private const string DisableSubscriptionDryRunNoDbKey = "Testing:DisableSubscriptionDryRunNoDb";
|
||||
|
||||
private readonly IEmailBlacklistService _emailBlacklistService;
|
||||
private readonly MemberCenterDbContext _dbContext;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<SubscriptionsController> _logger;
|
||||
|
||||
public SubscriptionsController(
|
||||
IEmailBlacklistService emailBlacklistService,
|
||||
MemberCenterDbContext dbContext)
|
||||
MemberCenterDbContext dbContext,
|
||||
IConfiguration configuration,
|
||||
ILogger<SubscriptionsController> logger)
|
||||
{
|
||||
_emailBlacklistService = emailBlacklistService;
|
||||
_dbContext = dbContext;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
@ -56,6 +67,34 @@ public class SubscriptionsController : ControllerBase
|
||||
return BadRequest("reason must be one of: hard_bounce, soft_bounce_threshold, complaint, suppression.");
|
||||
}
|
||||
|
||||
// TEST-ONLY BEHAVIOR: in dry-run mode, do not execute DB read/write; only emit planned operations.
|
||||
if (_configuration.GetValue<bool>(DisableSubscriptionDryRunNoDbKey))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"TEST-ONLY DRY RUN ENABLED ({ConfigKey}). Skip DB access for /subscriptions/disable. Incoming payload: {@Payload}",
|
||||
DisableSubscriptionDryRunNoDbKey,
|
||||
request);
|
||||
|
||||
_logger.LogInformation(
|
||||
"TEST-ONLY PLAN: would query newsletter_subscriptions by subscriber_id={SubscriberId}, list_id={ListId}, then verify tenant_id={TenantId}.",
|
||||
request.SubscriberId,
|
||||
request.ListId,
|
||||
request.TenantId);
|
||||
|
||||
_logger.LogInformation(
|
||||
"TEST-ONLY PLAN: would blacklist resolved email with reason={Reason}, disabled_by={DisabledBy}, occurred_at={OccurredAt}.",
|
||||
request.Reason,
|
||||
request.DisabledBy,
|
||||
request.OccurredAt);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
status = "dry_run_no_db",
|
||||
message = "DB access skipped by test flag.",
|
||||
config_key = DisableSubscriptionDryRunNoDbKey
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasGlobalScope && TryGetTenantId(User, out var tokenTenantId) && tokenTenantId != request.TenantId)
|
||||
{
|
||||
return BadRequest("tenant_id does not match token tenant scope.");
|
||||
|
||||
@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server.AspNetCore;
|
||||
using OpenIddict.Validation.AspNetCore;
|
||||
|
||||
EnvLoader.LoadDotEnvIfDevelopment();
|
||||
|
||||
@ -36,8 +37,9 @@ builder.Services
|
||||
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = IdentityConstants.ApplicationScheme;
|
||||
options.DefaultChallengeScheme = IdentityConstants.ApplicationScheme;
|
||||
options.DefaultScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
|
||||
options.DefaultAuthenticateScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
|
||||
});
|
||||
|
||||
builder.Services.AddOpenIddict()
|
||||
@ -72,11 +74,17 @@ builder.Services.AddOpenIddict()
|
||||
options.AddDevelopmentEncryptionCertificate();
|
||||
options.AddDevelopmentSigningCertificate();
|
||||
|
||||
options.UseAspNetCore()
|
||||
var aspNetCore = options.UseAspNetCore()
|
||||
.EnableAuthorizationEndpointPassthrough()
|
||||
.EnableTokenEndpointPassthrough()
|
||||
.EnableLogoutEndpointPassthrough()
|
||||
.EnableStatusCodePagesIntegration();
|
||||
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
// TEST/LOCAL ONLY: allow HTTP for local Docker integration testing.
|
||||
aspNetCore.DisableTransportSecurityRequirement();
|
||||
}
|
||||
})
|
||||
.AddValidation(options =>
|
||||
{
|
||||
|
||||
@ -6,7 +6,8 @@ public interface ITenantService
|
||||
{
|
||||
Task<IReadOnlyList<TenantDto>> ListAsync();
|
||||
Task<TenantDto?> GetAsync(Guid id);
|
||||
Task<TenantDto> CreateAsync(string name, List<string> domains, string status);
|
||||
Task<TenantDto?> UpdateAsync(Guid id, string name, List<string> domains, string status);
|
||||
Task<TenantDto> CreateAsync(string name, List<string> domains, string status, Guid? sendEngineWebhookClientId = null);
|
||||
Task<TenantDto?> UpdateAsync(Guid id, string name, List<string> domains, string status, Guid? sendEngineWebhookClientId = null);
|
||||
Task<bool> SetSendEngineWebhookClientAsync(Guid tenantId, Guid? webhookClientId);
|
||||
Task<bool> DeleteAsync(Guid id);
|
||||
}
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
namespace MemberCenter.Application.Models.Admin;
|
||||
|
||||
public sealed record TenantDto(Guid Id, string Name, List<string> Domains, string Status);
|
||||
public sealed record TenantDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
List<string> Domains,
|
||||
string Status,
|
||||
Guid? SendEngineWebhookClientId);
|
||||
|
||||
@ -5,5 +5,4 @@ public sealed class SendEngineWebhookOptions
|
||||
public string? BaseUrl { get; set; }
|
||||
public string SubscriptionEventsPath { get; set; } = "/webhooks/subscriptions";
|
||||
public string? WebhookSecret { get; set; }
|
||||
public Dictionary<string, string> TenantWebhookClientIds { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
@ -37,14 +37,19 @@ public sealed class NewsletterService : INewsletterService
|
||||
|
||||
public async Task<PendingSubscriptionResult?> SubscribeAsync(Guid listId, string email, Dictionary<string, object>? preferences)
|
||||
{
|
||||
var normalizedEmail = email.Trim().ToLowerInvariant();
|
||||
_logger.LogInformation("Newsletter subscribe requested. list_id={ListId}, email={Email}", listId, normalizedEmail);
|
||||
|
||||
if (await _emailBlacklist.IsBlacklistedAsync(email))
|
||||
{
|
||||
_logger.LogWarning("Newsletter subscribe blocked by blacklist. list_id={ListId}, email={Email}", listId, normalizedEmail);
|
||||
return null;
|
||||
}
|
||||
|
||||
var list = await _dbContext.NewsletterLists.FirstOrDefaultAsync(l => l.Id == listId);
|
||||
if (list is null)
|
||||
{
|
||||
_logger.LogWarning("Newsletter subscribe failed: list not found. list_id={ListId}, email={Email}", listId, normalizedEmail);
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -85,11 +90,19 @@ public sealed class NewsletterService : INewsletterService
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Newsletter subscribe pending token issued. subscription_id={SubscriptionId}, list_id={ListId}, email={Email}, status={Status}",
|
||||
subscription.Id,
|
||||
subscription.ListId,
|
||||
normalizedEmail,
|
||||
subscription.Status);
|
||||
|
||||
return new PendingSubscriptionResult(MapSubscription(subscription), confirmToken);
|
||||
}
|
||||
|
||||
public async Task<SubscriptionDto?> ConfirmAsync(string token)
|
||||
{
|
||||
_logger.LogInformation("Newsletter confirm requested.");
|
||||
var confirmToken = await FindTokenAsync(token, ConfirmTokenPurpose);
|
||||
if (confirmToken is null && token.Contains(' '))
|
||||
{
|
||||
@ -99,16 +112,27 @@ public sealed class NewsletterService : INewsletterService
|
||||
|
||||
if (confirmToken?.Subscription is null)
|
||||
{
|
||||
_logger.LogWarning("Newsletter confirm failed: token not found or already consumed.");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (await _emailBlacklist.IsBlacklistedAsync(confirmToken.Subscription.Email))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Newsletter confirm blocked by blacklist. subscription_id={SubscriptionId}, list_id={ListId}, email={Email}",
|
||||
confirmToken.Subscription.Id,
|
||||
confirmToken.Subscription.ListId,
|
||||
confirmToken.Subscription.Email);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (confirmToken.ExpiresAt < DateTimeOffset.UtcNow)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Newsletter confirm failed: token expired. subscription_id={SubscriptionId}, list_id={ListId}, email={Email}",
|
||||
confirmToken.Subscription.Id,
|
||||
confirmToken.Subscription.ListId,
|
||||
confirmToken.Subscription.Email);
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -117,12 +141,19 @@ public sealed class NewsletterService : INewsletterService
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
var confirmed = MapSubscription(confirmToken.Subscription);
|
||||
_logger.LogInformation(
|
||||
"Newsletter confirm succeeded. subscription_id={SubscriptionId}, list_id={ListId}, email={Email}, status={Status}",
|
||||
confirmed.Id,
|
||||
confirmed.ListId,
|
||||
confirmed.Email,
|
||||
confirmed.Status);
|
||||
await PublishSubscriptionEventSafeAsync("subscription.activated", confirmed);
|
||||
return confirmed;
|
||||
}
|
||||
|
||||
public async Task<SubscriptionDto?> UnsubscribeAsync(string token)
|
||||
{
|
||||
_logger.LogInformation("Newsletter unsubscribe requested.");
|
||||
var tokenHash = HashToken(token, UnsubscribeTokenPurpose);
|
||||
var unsubscribeToken = await _dbContext.UnsubscribeTokens
|
||||
.Include(t => t.Subscription)
|
||||
@ -130,11 +161,17 @@ public sealed class NewsletterService : INewsletterService
|
||||
|
||||
if (unsubscribeToken?.Subscription is null)
|
||||
{
|
||||
_logger.LogWarning("Newsletter unsubscribe failed: token not found or already consumed.");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (await _emailBlacklist.IsBlacklistedAsync(unsubscribeToken.Subscription.Email))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Newsletter unsubscribe blocked by blacklist. subscription_id={SubscriptionId}, list_id={ListId}, email={Email}",
|
||||
unsubscribeToken.Subscription.Id,
|
||||
unsubscribeToken.Subscription.ListId,
|
||||
unsubscribeToken.Subscription.Email);
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -143,14 +180,24 @@ public sealed class NewsletterService : INewsletterService
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
var unsubscribed = MapSubscription(unsubscribeToken.Subscription);
|
||||
_logger.LogInformation(
|
||||
"Newsletter unsubscribe succeeded. subscription_id={SubscriptionId}, list_id={ListId}, email={Email}, status={Status}",
|
||||
unsubscribed.Id,
|
||||
unsubscribed.ListId,
|
||||
unsubscribed.Email,
|
||||
unsubscribed.Status);
|
||||
await PublishSubscriptionEventSafeAsync("subscription.unsubscribed", unsubscribed);
|
||||
return unsubscribed;
|
||||
}
|
||||
|
||||
public async Task<string?> IssueUnsubscribeTokenAsync(Guid listId, string email)
|
||||
{
|
||||
var normalizedEmail = email.Trim().ToLowerInvariant();
|
||||
_logger.LogInformation("Newsletter unsubscribe-token requested. list_id={ListId}, email={Email}", listId, normalizedEmail);
|
||||
|
||||
if (await _emailBlacklist.IsBlacklistedAsync(email))
|
||||
{
|
||||
_logger.LogWarning("Newsletter unsubscribe-token blocked by blacklist. list_id={ListId}, email={Email}", listId, normalizedEmail);
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -161,6 +208,7 @@ public sealed class NewsletterService : INewsletterService
|
||||
|
||||
if (subscription is null)
|
||||
{
|
||||
_logger.LogWarning("Newsletter unsubscribe-token failed: subscription not found. list_id={ListId}, email={Email}", listId, normalizedEmail);
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -174,6 +222,11 @@ public sealed class NewsletterService : INewsletterService
|
||||
});
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
_logger.LogInformation(
|
||||
"Newsletter unsubscribe-token issued. subscription_id={SubscriptionId}, list_id={ListId}, email={Email}",
|
||||
subscription.Id,
|
||||
subscription.ListId,
|
||||
normalizedEmail);
|
||||
return token;
|
||||
}
|
||||
|
||||
|
||||
@ -4,24 +4,31 @@ using System.Text.Json;
|
||||
using MemberCenter.Application.Abstractions;
|
||||
using MemberCenter.Application.Models.Newsletter;
|
||||
using MemberCenter.Infrastructure.Configuration;
|
||||
using MemberCenter.Infrastructure.Persistence;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MemberCenter.Infrastructure.Services;
|
||||
|
||||
public sealed class SendEngineWebhookPublisher : ISendEngineWebhookPublisher
|
||||
{
|
||||
private const string SendEngineWebhookClientKeyPrefix = "send_engine:webhook_client:";
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IOptions<SendEngineWebhookOptions> _options;
|
||||
private readonly MemberCenterDbContext _dbContext;
|
||||
private readonly ILogger<SendEngineWebhookPublisher> _logger;
|
||||
|
||||
public SendEngineWebhookPublisher(
|
||||
HttpClient httpClient,
|
||||
IOptions<SendEngineWebhookOptions> options,
|
||||
MemberCenterDbContext dbContext,
|
||||
ILogger<SendEngineWebhookPublisher> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_options = options;
|
||||
_dbContext = dbContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@ -38,7 +45,7 @@ public sealed class SendEngineWebhookPublisher : ISendEngineWebhookPublisher
|
||||
return;
|
||||
}
|
||||
|
||||
var clientId = ResolveWebhookClientId(options, tenantId);
|
||||
var clientId = await ResolveWebhookClientIdAsync(tenantId, cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(clientId))
|
||||
{
|
||||
_logger.LogWarning("Skip webhook publish: missing webhook client id for tenant {TenantId}", tenantId);
|
||||
@ -105,15 +112,18 @@ public sealed class SendEngineWebhookPublisher : ISendEngineWebhookPublisher
|
||||
return normalizedBase + normalizedPath;
|
||||
}
|
||||
|
||||
private static string? ResolveWebhookClientId(SendEngineWebhookOptions options, Guid tenantId)
|
||||
private async Task<string?> ResolveWebhookClientIdAsync(Guid tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantKey = tenantId.ToString();
|
||||
if (options.TenantWebhookClientIds.TryGetValue(tenantKey, out var tenantClientId)
|
||||
&& !string.IsNullOrWhiteSpace(tenantClientId))
|
||||
var key = $"{SendEngineWebhookClientKeyPrefix}{tenantId:D}";
|
||||
var flag = await _dbContext.SystemFlags
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Key == key, cancellationToken);
|
||||
if (flag is null || string.IsNullOrWhiteSpace(flag.Value))
|
||||
{
|
||||
return tenantClientId;
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
|
||||
return flag.Value.Trim();
|
||||
}
|
||||
|
||||
private static string ComputeHmacHex(string secret, string payload)
|
||||
|
||||
@ -8,6 +8,8 @@ namespace MemberCenter.Infrastructure.Services;
|
||||
|
||||
public sealed class TenantService : ITenantService
|
||||
{
|
||||
private const string SendEngineWebhookClientKeyPrefix = "send_engine:webhook_client:";
|
||||
|
||||
private readonly MemberCenterDbContext _dbContext;
|
||||
|
||||
public TenantService(MemberCenterDbContext dbContext)
|
||||
@ -18,16 +20,23 @@ public sealed class TenantService : ITenantService
|
||||
public async Task<IReadOnlyList<TenantDto>> ListAsync()
|
||||
{
|
||||
var tenants = await _dbContext.Tenants.ToListAsync();
|
||||
return tenants.Select(MapTenant).ToList();
|
||||
var mappings = await LoadWebhookClientMappingsAsync(tenants.Select(t => t.Id));
|
||||
return tenants.Select(t => MapTenant(t, mappings.TryGetValue(t.Id, out var clientId) ? clientId : null)).ToList();
|
||||
}
|
||||
|
||||
public async Task<TenantDto?> GetAsync(Guid id)
|
||||
{
|
||||
var tenant = await _dbContext.Tenants.FindAsync(id);
|
||||
return tenant is null ? null : MapTenant(tenant);
|
||||
if (tenant is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var mapping = await LoadWebhookClientMappingAsync(id);
|
||||
return MapTenant(tenant, mapping);
|
||||
}
|
||||
|
||||
public async Task<TenantDto> CreateAsync(string name, List<string> domains, string status)
|
||||
public async Task<TenantDto> CreateAsync(string name, List<string> domains, string status, Guid? sendEngineWebhookClientId = null)
|
||||
{
|
||||
var tenant = new Tenant
|
||||
{
|
||||
@ -39,10 +48,11 @@ public sealed class TenantService : ITenantService
|
||||
|
||||
_dbContext.Tenants.Add(tenant);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
return MapTenant(tenant);
|
||||
await SetWebhookClientMappingInternalAsync(tenant.Id, sendEngineWebhookClientId);
|
||||
return MapTenant(tenant, sendEngineWebhookClientId);
|
||||
}
|
||||
|
||||
public async Task<TenantDto?> UpdateAsync(Guid id, string name, List<string> domains, string status)
|
||||
public async Task<TenantDto?> UpdateAsync(Guid id, string name, List<string> domains, string status, Guid? sendEngineWebhookClientId = null)
|
||||
{
|
||||
var tenant = await _dbContext.Tenants.FindAsync(id);
|
||||
if (tenant is null)
|
||||
@ -54,7 +64,20 @@ public sealed class TenantService : ITenantService
|
||||
tenant.Domains = domains;
|
||||
tenant.Status = status;
|
||||
await _dbContext.SaveChangesAsync();
|
||||
return MapTenant(tenant);
|
||||
await SetWebhookClientMappingInternalAsync(id, sendEngineWebhookClientId);
|
||||
return MapTenant(tenant, sendEngineWebhookClientId);
|
||||
}
|
||||
|
||||
public async Task<bool> SetSendEngineWebhookClientAsync(Guid tenantId, Guid? webhookClientId)
|
||||
{
|
||||
var exists = await _dbContext.Tenants.AnyAsync(t => t.Id == tenantId);
|
||||
if (!exists)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await SetWebhookClientMappingInternalAsync(tenantId, webhookClientId);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id)
|
||||
@ -65,13 +88,104 @@ public sealed class TenantService : ITenantService
|
||||
return false;
|
||||
}
|
||||
|
||||
var mapping = await _dbContext.SystemFlags.FirstOrDefaultAsync(x => x.Key == BuildWebhookClientKey(id));
|
||||
if (mapping is not null)
|
||||
{
|
||||
_dbContext.SystemFlags.Remove(mapping);
|
||||
}
|
||||
|
||||
_dbContext.Tenants.Remove(tenant);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static TenantDto MapTenant(Tenant tenant)
|
||||
private static TenantDto MapTenant(Tenant tenant, Guid? webhookClientId)
|
||||
{
|
||||
return new TenantDto(tenant.Id, tenant.Name, tenant.Domains, tenant.Status);
|
||||
return new TenantDto(tenant.Id, tenant.Name, tenant.Domains, tenant.Status, webhookClientId);
|
||||
}
|
||||
|
||||
private async Task<Dictionary<Guid, Guid>> LoadWebhookClientMappingsAsync(IEnumerable<Guid> tenantIds)
|
||||
{
|
||||
var keys = tenantIds.Select(BuildWebhookClientKey).ToHashSet(StringComparer.Ordinal);
|
||||
if (keys.Count == 0)
|
||||
{
|
||||
return new Dictionary<Guid, Guid>();
|
||||
}
|
||||
|
||||
var flags = await _dbContext.SystemFlags
|
||||
.Where(f => keys.Contains(f.Key))
|
||||
.ToListAsync();
|
||||
|
||||
var result = new Dictionary<Guid, Guid>();
|
||||
foreach (var flag in flags)
|
||||
{
|
||||
var tenantPart = flag.Key.Replace(SendEngineWebhookClientKeyPrefix, string.Empty, StringComparison.Ordinal);
|
||||
if (!Guid.TryParse(tenantPart, out var tenantId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(flag.Value, out var webhookClientId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result[tenantId] = webhookClientId;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<Guid?> LoadWebhookClientMappingAsync(Guid tenantId)
|
||||
{
|
||||
var key = BuildWebhookClientKey(tenantId);
|
||||
var flag = await _dbContext.SystemFlags.FirstOrDefaultAsync(x => x.Key == key);
|
||||
if (flag is null || !Guid.TryParse(flag.Value, out var webhookClientId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return webhookClientId;
|
||||
}
|
||||
|
||||
private async Task SetWebhookClientMappingInternalAsync(Guid tenantId, Guid? webhookClientId)
|
||||
{
|
||||
var key = BuildWebhookClientKey(tenantId);
|
||||
var existing = await _dbContext.SystemFlags.FirstOrDefaultAsync(x => x.Key == key);
|
||||
|
||||
if (!webhookClientId.HasValue)
|
||||
{
|
||||
if (existing is not null)
|
||||
{
|
||||
_dbContext.SystemFlags.Remove(existing);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var value = webhookClientId.Value.ToString();
|
||||
if (existing is null)
|
||||
{
|
||||
_dbContext.SystemFlags.Add(new Domain.Entities.SystemFlag
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Key = key,
|
||||
Value = value,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.Value = value;
|
||||
existing.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static string BuildWebhookClientKey(Guid tenantId)
|
||||
{
|
||||
return $"{SendEngineWebhookClientKeyPrefix}{tenantId:D}";
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,8 +37,14 @@ public class TenantsController : Controller
|
||||
return View(model);
|
||||
}
|
||||
|
||||
if (!TryParseWebhookClientId(model.SendEngineWebhookClientId, out var webhookClientId))
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.SendEngineWebhookClientId), "Webhook client id must be a valid UUID.");
|
||||
return View(model);
|
||||
}
|
||||
|
||||
var domains = model.Domains.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||
await _tenantService.CreateAsync(model.Name, domains, model.Status);
|
||||
await _tenantService.CreateAsync(model.Name, domains, model.Status, webhookClientId);
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
|
||||
@ -56,7 +62,8 @@ public class TenantsController : Controller
|
||||
Id = tenant.Id,
|
||||
Name = tenant.Name,
|
||||
Domains = string.Join(",", tenant.Domains),
|
||||
Status = tenant.Status
|
||||
Status = tenant.Status,
|
||||
SendEngineWebhookClientId = tenant.SendEngineWebhookClientId?.ToString()
|
||||
});
|
||||
}
|
||||
|
||||
@ -68,8 +75,14 @@ public class TenantsController : Controller
|
||||
return View(model);
|
||||
}
|
||||
|
||||
if (!TryParseWebhookClientId(model.SendEngineWebhookClientId, out var webhookClientId))
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.SendEngineWebhookClientId), "Webhook client id must be a valid UUID.");
|
||||
return View(model);
|
||||
}
|
||||
|
||||
var domains = model.Domains.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||
var updated = await _tenantService.UpdateAsync(id, model.Name, domains, model.Status);
|
||||
var updated = await _tenantService.UpdateAsync(id, model.Name, domains, model.Status, webhookClientId);
|
||||
if (updated is null)
|
||||
{
|
||||
return NotFound();
|
||||
@ -84,4 +97,21 @@ public class TenantsController : Controller
|
||||
await _tenantService.DeleteAsync(id);
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
|
||||
private static bool TryParseWebhookClientId(string? value, out Guid? webhookClientId)
|
||||
{
|
||||
webhookClientId = null;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(value.Trim(), out var parsed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
webhookClientId = parsed;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,4 +13,6 @@ public sealed class TenantFormViewModel
|
||||
|
||||
[Required]
|
||||
public string Status { get; set; } = "active";
|
||||
|
||||
public string? SendEngineWebhookClientId { get; set; }
|
||||
}
|
||||
|
||||
@ -13,5 +13,9 @@
|
||||
<input asp-for="Status" />
|
||||
<span asp-validation-for="Status"></span>
|
||||
|
||||
<label>Send Engine Webhook Client Id (UUID)</label>
|
||||
<input asp-for="SendEngineWebhookClientId" />
|
||||
<span asp-validation-for="SendEngineWebhookClientId"></span>
|
||||
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
|
||||
@ -13,5 +13,9 @@
|
||||
<input asp-for="Status" />
|
||||
<span asp-validation-for="Status"></span>
|
||||
|
||||
<label>Send Engine Webhook Client Id (UUID)</label>
|
||||
<input asp-for="SendEngineWebhookClientId" />
|
||||
<span asp-validation-for="SendEngineWebhookClientId"></span>
|
||||
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
<p><a href="/admin/tenants/create">Create</a></p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Tenant Id</th><th>Domains</th><th>Status</th><th></th></tr>
|
||||
<tr><th>Name</th><th>Tenant Id</th><th>Webhook Client Id</th><th>Domains</th><th>Status</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var tenant in Model)
|
||||
@ -12,6 +12,7 @@
|
||||
<tr>
|
||||
<td>@tenant.Name</td>
|
||||
<td><code>@tenant.Id</code></td>
|
||||
<td><code>@(tenant.SendEngineWebhookClientId?.ToString() ?? "-")</code></td>
|
||||
<td>@string.Join(",", tenant.Domains)</td>
|
||||
<td>@tenant.Status</td>
|
||||
<td>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user