feat: Update subscription disable logic to handle blacklisting and complaints distinctly

This commit is contained in:
warrenchen 2026-02-26 16:34:30 +09:00
parent 4fbf2e5497
commit febf870807
6 changed files with 87 additions and 17 deletions

View File

@ -111,12 +111,10 @@
### 6.7 Send Engine 退信/黑名單回寫(選用) ### 6.7 Send Engine 退信/黑名單回寫(選用)
1) Send Engine 依事件類型決定回寫時機與原因碼: 1) Send Engine 依事件類型決定回寫時機與原因碼:
2) `hard_bounce` / `soft_bounce_threshold` / `suppression`:設黑名單後回寫 2) `hard_bounce` / `soft_bounce_threshold` / `suppression`Member Center 取消該 email 所有訂閱(跨租戶)並加入黑名單
3) `complaint`:先取消訂閱,再回寫黑名單 3) `complaint`Member Center 僅取消該筆訂閱,不加入黑名單
4) 呼叫 Member Center API 將 email 加入 `email_blacklist` 4) 回寫請求需帶 `tenant_id + subscriber_id + list_id`Member Center 端做租戶邊界驗證
5) Member Center 對該 email 的訂閱事件全部忽略且不再推送 5) 回寫授權可用:
6) 回寫請求需帶 `tenant_id + subscriber_id + list_id`Member Center 端做租戶邊界驗證
7) 回寫授權可用:
- tenant client scope`newsletter:events.write` - tenant client scope`newsletter:events.write`
- platform client scope`newsletter:events.write.global`SES 聚合事件) - platform client scope`newsletter:events.write.global`SES 聚合事件)
@ -167,6 +165,13 @@
- Send Engine 收到租戶請求後以 JWKS 驗簽 JWTJWS - Send Engine 收到租戶請求後以 JWKS 驗簽 JWTJWS
- 驗簽通過後將 `tenant_id` 固定在 request context不接受 body 覆寫 - 驗簽通過後將 `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. 安全與合規 ## 8. 安全與合規
- 密碼強度與防暴力破解rate limit + lockout - 密碼強度與防暴力破解rate limit + lockout
- Token rotation + refresh token revoke - Token rotation + refresh token revoke

View File

@ -58,13 +58,13 @@
## F-11 黑名單回寫Send Engine → Member Center ## F-11 黑名單回寫Send Engine → Member Center
- [API] Send Engine 依事件規則處理: - [API] Send Engine 依事件規則處理:
- [API] `hard_bounce` / `soft_bounce_threshold` / `suppression`設黑名單後回寫 - [API] `hard_bounce` / `soft_bounce_threshold` / `suppression`回寫後由 Member Center 取消該 email 的所有訂閱(跨租戶)並加入黑名單
- [API] `complaint`先在 Send Engine 取消訂閱,再回寫黑名單 - [API] `complaint`回寫後由 Member Center 僅取消該筆訂閱,不加入黑名單
- [API] 呼叫 `POST /subscriptions/disable` - [API] 呼叫 `POST /subscriptions/disable`
- [API] tenant client 用 `newsletter:events.write` - [API] tenant client 用 `newsletter:events.write`
- [API] 平台 clientSES 聚合事件)用 `newsletter:events.write.global` - [API] 平台 clientSES 聚合事件)用 `newsletter:events.write.global`
- [API] body 需含 `tenant_id + subscriber_id + list_id + reason + disabled_by + occurred_at` - [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 ## F-12 Webhook Client Mapping 回填Send Engine → Member Center
- [API] Send Engine 建立/更新 tenant 對應的 webhook client`auth_clients.id` - [API] Send Engine 建立/更新 tenant 對應的 webhook client`auth_clients.id`
@ -81,3 +81,5 @@
## F-09 訂閱與會員綁定 ## F-09 訂閱與會員綁定
- [API] 使用者完成註冊後,會員中心將訂閱資料與 user_id 綁定 - [API] 使用者完成註冊後,會員中心將訂閱資料與 user_id 綁定
- [API] 發送事件 `subscription.linked_to_user` - [API] 發送事件 `subscription.linked_to_user`
註記:此流程目前尚未在程式中落地(屬待辦)。

View File

@ -1,6 +1,7 @@
# OpenAPI 草案(完整) # OpenAPI 草案(完整)
已補上完整端點與資料結構,並提供 `docs/openapi.yaml` 作為可直接擴充的版本。 已補上完整端點與資料結構,並提供 `docs/openapi.yaml` 作為可直接擴充的版本。
其中 `/webhooks/*` 為 Member Center 對外發送時遵循的整合契約(實際由 Send Engine 提供端點)。
## 版本 ## 版本
- OpenAPI: 3.1.0 - OpenAPI: 3.1.0
@ -123,6 +124,14 @@
- `complaint` - `complaint`
- `suppression` - `suppression`
處理規則:
- `hard_bounce` / `soft_bounce_threshold` / `suppression`
- 取消該 email 的所有訂閱(跨租戶)
- 加入全域黑名單
- `complaint`
- 僅取消該筆訂閱(依 `subscriber_id + list_id`
- 不加入黑名單
### `/subscriptions/disable` 請求欄位Send Engine -> Member Center ### `/subscriptions/disable` 請求欄位Send Engine -> Member Center
- `tenant_id`UUID - `tenant_id`UUID
- `subscriber_id`UUID - `subscriber_id`UUID

View File

@ -28,3 +28,7 @@
- UC-17 發送訂閱事件event/queue [API] - UC-17 發送訂閱事件event/queue [API]
- UC-18 發送退訂事件event/queue [API] - UC-18 發送退訂事件event/queue [API]
- UC-19 訂閱與會員綁定 [API] - UC-19 訂閱與會員綁定 [API]
## 實作狀態2026-02
- 已完成UC-17、UC-18以 webhook 事件發送)
- 未完成UC-19註冊後自動綁定 `user_id``subscription.linked_to_user` 事件)

View File

@ -510,9 +510,14 @@ paths:
/subscriptions/disable: /subscriptions/disable:
post: post:
summary: Disable email (global blacklist) summary: Disable subscription / blacklist by reason
security: [{ BearerAuth: [] }] 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: requestBody:
required: true required: true
content: content:

View File

@ -1,5 +1,6 @@
using MemberCenter.Api.Contracts; using MemberCenter.Api.Contracts;
using MemberCenter.Application.Abstractions; using MemberCenter.Application.Abstractions;
using MemberCenter.Domain.Constants;
using MemberCenter.Infrastructure.Persistence; using MemberCenter.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -21,6 +22,12 @@ public class SubscriptionsController : ControllerBase
"complaint", "complaint",
"suppression" "suppression"
}; };
private static readonly HashSet<string> 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. // 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 const string DisableSubscriptionDryRunNoDbKey = "Testing:DisableSubscriptionDryRunNoDb";
@ -121,13 +128,51 @@ public class SubscriptionsController : ControllerBase
return BadRequest("tenant_id does not match subscription/list tenant boundary."); return BadRequest("tenant_id does not match subscription/list tenant boundary.");
} }
await _emailBlacklistService.AddOrUpdateAsync( var normalizedEmail = target.Email.Trim().ToLowerInvariant();
target.Email, var shouldBlacklist = BlacklistReasons.Contains(request.Reason);
request.Reason, var cancelledCount = 0;
request.DisabledBy,
request.OccurredAt);
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) private static bool HasScope(System.Security.Claims.ClaimsPrincipal user, string scope)