feat: Update subscription disable logic to handle blacklisting and complaints distinctly
This commit is contained in:
parent
4fbf2e5497
commit
febf870807
@ -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
|
||||
|
||||
@ -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`
|
||||
|
||||
註記:此流程目前尚未在程式中落地(屬待辦)。
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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` 事件)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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<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.
|
||||
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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user