From febf8708074dcfc20b3f29ec4414e1c4df6f4e4d Mon Sep 17 00:00:00 2001 From: warrenchen Date: Thu, 26 Feb 2026 16:34:30 +0900 Subject: [PATCH] feat: Update subscription disable logic to handle blacklisting and complaints distinctly --- docs/DESIGN.md | 17 ++++-- docs/FLOWS.md | 8 ++- docs/OPENAPI.md | 9 +++ docs/USE_CASES.md | 4 ++ docs/openapi.yaml | 9 ++- .../Controllers/SubscriptionsController.cs | 57 +++++++++++++++++-- 6 files changed, 87 insertions(+), 17 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 8a18eb1..80987a1 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -111,12 +111,10 @@ ### 6.7 Send Engine 退信/黑名單回寫(選用) 1) Send Engine 依事件類型決定回寫時機與原因碼: -2) `hard_bounce` / `soft_bounce_threshold` / `suppression`:設黑名單後回寫 -3) `complaint`:先取消訂閱,再回寫黑名單 -4) 呼叫 Member Center API 將 email 加入 `email_blacklist` -5) Member Center 對該 email 的訂閱事件全部忽略且不再推送 -6) 回寫請求需帶 `tenant_id + subscriber_id + list_id`,Member Center 端做租戶邊界驗證 -7) 回寫授權可用: +2) `hard_bounce` / `soft_bounce_threshold` / `suppression`:Member Center 取消該 email 所有訂閱(跨租戶)並加入黑名單 +3) `complaint`:Member Center 僅取消該筆訂閱,不加入黑名單 +4) 回寫請求需帶 `tenant_id + subscriber_id + list_id`,Member Center 端做租戶邊界驗證 +5) 回寫授權可用: - tenant client scope:`newsletter:events.write` - platform client scope:`newsletter:events.write.global`(SES 聚合事件) @@ -167,6 +165,13 @@ - Send Engine 收到租戶請求後以 JWKS 驗簽 JWT(JWS) - 驗簽通過後將 `tenant_id` 固定在 request context,不接受 body 覆寫 +## 7.2 尚未完成(待辦) +- `POST /webhooks/lists/full-sync`:Member Center 端尚未發送此事件(僅保留契約) +- 註冊後訂閱綁定(`newsletter_subscriptions.user_id` 補值)尚未在註冊流程落地 +- `subscription.linked_to_user` 事件尚未發送 +- 安全設定頁(access/refresh 時效)目前僅存值,尚未實際套用到 OpenIddict token lifetime +- Audit Logs 目前以查詢為主,關鍵操作的寫入覆蓋率仍不足 + ## 8. 安全與合規 - 密碼強度與防暴力破解(rate limit + lockout) - Token rotation + refresh token revoke diff --git a/docs/FLOWS.md b/docs/FLOWS.md index 068c17b..2cf2423 100644 --- a/docs/FLOWS.md +++ b/docs/FLOWS.md @@ -58,13 +58,13 @@ ## F-11 黑名單回寫(Send Engine → Member Center) - [API] Send Engine 依事件規則處理: -- [API] `hard_bounce` / `soft_bounce_threshold` / `suppression`:設黑名單後回寫 -- [API] `complaint`:先在 Send Engine 取消訂閱,再回寫黑名單 +- [API] `hard_bounce` / `soft_bounce_threshold` / `suppression`:回寫後由 Member Center 取消該 email 的所有訂閱(跨租戶)並加入黑名單 +- [API] `complaint`:回寫後由 Member Center 僅取消該筆訂閱,不加入黑名單 - [API] 呼叫 `POST /subscriptions/disable`: - [API] tenant client 用 `newsletter:events.write` - [API] 平台 client(SES 聚合事件)用 `newsletter:events.write.global` - [API] body 需含 `tenant_id + subscriber_id + list_id + reason + disabled_by + occurred_at` -- [API] Member Center 將 email 寫入 `email_blacklist`,停用寄送並停止事件推送 +- [API] `hard_bounce` / `soft_bounce_threshold` / `suppression` 才會寫入 `email_blacklist`;`complaint` 不寫黑名單 ## F-12 Webhook Client Mapping 回填(Send Engine → Member Center) - [API] Send Engine 建立/更新 tenant 對應的 webhook client(`auth_clients.id`) @@ -81,3 +81,5 @@ ## F-09 訂閱與會員綁定 - [API] 使用者完成註冊後,會員中心將訂閱資料與 user_id 綁定 - [API] 發送事件 `subscription.linked_to_user` + +註記:此流程目前尚未在程式中落地(屬待辦)。 diff --git a/docs/OPENAPI.md b/docs/OPENAPI.md index 085264d..7e5c7be 100644 --- a/docs/OPENAPI.md +++ b/docs/OPENAPI.md @@ -1,6 +1,7 @@ # OpenAPI 草案(完整) 已補上完整端點與資料結構,並提供 `docs/openapi.yaml` 作為可直接擴充的版本。 +其中 `/webhooks/*` 為 Member Center 對外發送時遵循的整合契約(實際由 Send Engine 提供端點)。 ## 版本 - OpenAPI: 3.1.0 @@ -123,6 +124,14 @@ - `complaint` - `suppression` +處理規則: +- `hard_bounce` / `soft_bounce_threshold` / `suppression`: + - 取消該 email 的所有訂閱(跨租戶) + - 加入全域黑名單 +- `complaint`: + - 僅取消該筆訂閱(依 `subscriber_id + list_id`) + - 不加入黑名單 + ### `/subscriptions/disable` 請求欄位(Send Engine -> Member Center) - `tenant_id`(UUID) - `subscriber_id`(UUID) diff --git a/docs/USE_CASES.md b/docs/USE_CASES.md index 02650ae..1ee0e64 100644 --- a/docs/USE_CASES.md +++ b/docs/USE_CASES.md @@ -28,3 +28,7 @@ - UC-17 發送訂閱事件(event/queue) [API] - UC-18 發送退訂事件(event/queue) [API] - UC-19 訂閱與會員綁定 [API] + +## 實作狀態(2026-02) +- 已完成:UC-17、UC-18(以 webhook 事件發送) +- 未完成:UC-19(註冊後自動綁定 `user_id` 與 `subscription.linked_to_user` 事件) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 20652e7..2f22f62 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -510,9 +510,14 @@ paths: /subscriptions/disable: post: - summary: Disable email (global blacklist) + summary: Disable subscription / blacklist by reason security: [{ BearerAuth: [] }] - description: Requires scope `newsletter:events.write` (tenant client) or `newsletter:events.write.global` (platform client). Request must include `tenant_id`, `subscriber_id`, and `list_id` for tenant-boundary validation. Expected reason codes: `hard_bounce`, `soft_bounce_threshold`, `complaint`, `suppression`. + description: | + Requires scope `newsletter:events.write` (tenant client) or `newsletter:events.write.global` (platform client). + Request must include `tenant_id`, `subscriber_id`, and `list_id` for tenant-boundary validation. + Reason handling: + - `hard_bounce`, `soft_bounce_threshold`, `suppression`: unsubscribe all subscriptions for the email across tenants + add to global blacklist. + - `complaint`: unsubscribe only the target subscription, no blacklist. requestBody: required: true content: diff --git a/src/MemberCenter.Api/Controllers/SubscriptionsController.cs b/src/MemberCenter.Api/Controllers/SubscriptionsController.cs index 7de4892..5914521 100644 --- a/src/MemberCenter.Api/Controllers/SubscriptionsController.cs +++ b/src/MemberCenter.Api/Controllers/SubscriptionsController.cs @@ -1,5 +1,6 @@ using MemberCenter.Api.Contracts; using MemberCenter.Application.Abstractions; +using MemberCenter.Domain.Constants; using MemberCenter.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -21,6 +22,12 @@ public class SubscriptionsController : ControllerBase "complaint", "suppression" }; + private static readonly HashSet BlacklistReasons = new(StringComparer.OrdinalIgnoreCase) + { + "hard_bounce", + "soft_bounce_threshold", + "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"; @@ -121,13 +128,51 @@ public class SubscriptionsController : ControllerBase return BadRequest("tenant_id does not match subscription/list tenant boundary."); } - await _emailBlacklistService.AddOrUpdateAsync( - target.Email, - request.Reason, - request.DisabledBy, - request.OccurredAt); + var normalizedEmail = target.Email.Trim().ToLowerInvariant(); + var shouldBlacklist = BlacklistReasons.Contains(request.Reason); + var cancelledCount = 0; - return Ok(new { email = target.Email, status = "blacklisted" }); + if (shouldBlacklist) + { + // hard/soft/suppression: cancel across all tenants for the same email. + var subscriptions = await _dbContext.NewsletterSubscriptions + .Where(s => s.Email.ToLower() == normalizedEmail && s.Status != SubscriptionStatus.Unsubscribed) + .ToListAsync(); + + foreach (var subscription in subscriptions) + { + subscription.Status = SubscriptionStatus.Unsubscribed; + } + + cancelledCount = subscriptions.Count; + await _dbContext.SaveChangesAsync(); + + await _emailBlacklistService.AddOrUpdateAsync( + target.Email, + request.Reason, + request.DisabledBy, + request.OccurredAt); + } + else + { + // complaint: cancel only the target subscription; do not blacklist. + var subscription = await _dbContext.NewsletterSubscriptions + .FirstOrDefaultAsync(s => s.Id == request.SubscriberId && s.ListId == request.ListId); + if (subscription is not null && subscription.Status != SubscriptionStatus.Unsubscribed) + { + subscription.Status = SubscriptionStatus.Unsubscribed; + cancelledCount = 1; + await _dbContext.SaveChangesAsync(); + } + } + + return Ok(new + { + email = target.Email, + status = shouldBlacklist ? "unsubscribed_blacklisted" : "unsubscribed", + blacklisted = shouldBlacklist, + cancelled_count = cancelledCount + }); } private static bool HasScope(System.Security.Claims.ClaimsPrincipal user, string scope)