feat: Update subscription disable API to include tenant-boundary validation and enhance request structure

This commit is contained in:
warrenchen 2026-02-18 12:57:21 +09:00
parent ae6edae39c
commit b355ed9e14
6 changed files with 101 additions and 28 deletions

View File

@ -104,9 +104,12 @@
4) Send Engine 驗證 tenant scope更新本地名單快照
### 6.7 Send Engine 退信/黑名單回寫(選用)
1) Send Engine 判定黑名單(例如 hard bounce / complaint
2) 呼叫 Member Center API 將 email 加入 `email_blacklist`
3) Member Center 對該 email 的訂閱事件全部忽略且不再推送
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 端做租戶邊界驗證
### 6.5 註冊後銜接
1) 使用者完成註冊
@ -128,14 +131,14 @@
- POST `/newsletter/preferences`
- POST `/webhooks/subscriptions`Send Engine 端點Member Center 呼叫)
- POST `/webhooks/lists/full-sync`Send Engine 端點Member Center 呼叫)
- POST `/api/subscriptions/disable`Member Center 端點Send Engine 呼叫)
- POST `/subscriptions/disable`Member Center 端點Send Engine 呼叫)
## 7.1 待新增 API / Auth規劃中
### API
- `GET /newsletter/subscriptions?list_id=...`:供發送引擎同步訂閱清單
- `POST /webhooks/subscriptions`Member Center → Send Engine 事件推送
- `POST /webhooks/lists/full-sync`Member Center → Send Engine 全量同步
- `POST /api/subscriptions/disable`Send Engine → Member Center 黑名單回寫
- `POST /subscriptions/disable`Send Engine → Member Center 黑名單回寫
### Auth / Scope
- OAuth Client 綁定 `tenant_id`,所有清單/事件 API 需驗證租戶邊界

View File

@ -45,8 +45,10 @@
- [API] Send Engine 更新名單快照
## F-11 黑名單回寫Send Engine → Member Center
- [API] Send Engine 判定黑名單(例如 hard bounce / complaint
- [API] 呼叫 `POST /api/subscriptions/disable`scope: `newsletter:events.write`
- [API] Send Engine 依事件規則處理:
- [API] `hard_bounce` / `soft_bounce_threshold` / `suppression`:設黑名單後回寫
- [API] `complaint`:先在 Send Engine 取消訂閱,再回寫黑名單
- [API] 呼叫 `POST /subscriptions/disable`scope: `newsletter:events.write`body 需含 `tenant_id + subscriber_id + list_id + reason + disabled_by + occurred_at`
- [API] Member Center 將 email 寫入 `email_blacklist`,停用寄送並停止事件推送
## F-07 會員資料查看

View File

@ -55,7 +55,9 @@
- 驗證規則:
- timestamp 在允許時間窗(例如 ±5 分鐘)
- nonce 不可重複(防重放)
- `X-Client-Id` 必須存在且 active且 tenant 綁定一致
- `X-Client-Id` 必須存在且 active`auth_clients.tenant_id` 與 payload `tenant_id` 一致
- 不使用 `X-Client-Id` fallback缺少 tenant 對應 client 時應略過發送
- 預設拒絕 `auth_clients.tenant_id = NULL` 的通用 client除非 Send Engine 明確開啟)
- signature 必須匹配
## OAuth Client 用途分離(強制)
@ -70,12 +72,12 @@
- 每個 tenant 至少 2 組憑證(`tenant_api` / `webhook_outbound`
- secret 分開輪替,禁止共用
## 待新增 API / Auth規劃中
## 整合 API / Auth狀態
### API
- `GET /newsletter/subscriptions?list_id=...`回傳清單內所有訂閱(供發送引擎同步用
- `POST /webhooks/subscriptions`Member Center → Send Engine 事件推送Send Engine 端點scope: `newsletter:events.write`
- `POST /webhooks/lists/full-sync`Member Center → Send Engine 全量同步Send Engine 端點scope: `newsletter:events.write`
- `POST /api/subscriptions/disable`Send Engine → Member Center 黑名單回寫(全租戶 emailscope: `newsletter:events.write`
- `GET /newsletter/subscriptions?list_id=...`已實作(供發送引擎同步
- `POST /webhooks/subscriptions`已實作Member Center 發送Send Engine 接收
- `POST /webhooks/lists/full-sync`規格已定義Member Center 發送Send Engine 接收
- `POST /subscriptions/disable`已實作Send Engine 回寫黑名單
### Auth / Scope
- OAuth Client 需綁定 `tenant_id`
@ -84,3 +86,21 @@
- `newsletter:events.read`
- `newsletter:events.write`
- 發送引擎僅能用上述 scope禁止 admin 權限
- `POST /subscriptions/disable` 需 Bearer token 且包含 `newsletter:events.write`
- 建議 Send Engine 使用 client credentials 取 token不建議使用長效固定 token
### 回寫原因碼Send Engine -> Member Center
- `hard_bounce`
- `soft_bounce_threshold`
- `complaint`
- `suppression`
### `/subscriptions/disable` 請求欄位Send Engine -> Member Center
- `tenant_id`UUID
- `subscriber_id`UUID
- `list_id`UUID
- `reason``hard_bounce | soft_bounce_threshold | complaint | suppression`
- `disabled_by`(建議固定 `send_engine`
- `occurred_at`RFC3339
Member Center 會用 `subscriber_id + list_id` 查詢訂閱,再驗證 `tenant_id` 邊界;驗證通過後才寫入全域 email 黑名單。

View File

@ -436,11 +436,11 @@ paths:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/subscriptions/disable:
/subscriptions/disable:
post:
summary: Disable email (global blacklist)
security: [{ BearerAuth: [] }]
description: Requires scope `newsletter:events.write`.
description: Requires scope `newsletter:events.write`. Request must include `tenant_id`, `subscriber_id`, and `list_id` for tenant-boundary validation. Expected reason codes: `hard_bounce`, `soft_bounce_threshold`, `complaint`, `suppression`.
requestBody:
required: true
content:
@ -466,7 +466,7 @@ paths:
/webhooks/subscriptions:
post:
summary: (NOTE) Member Center -> Send Engine subscription events webhook
description: This endpoint is implemented by Send Engine; listed here as an integration note. Required headers are X-Signature, X-Timestamp, X-Nonce, X-Client-Id. X-Client-Id must be Send Engine `auth_clients.id` (UUID).
description: This endpoint is implemented by Send Engine; listed here as an integration note. Required headers are X-Signature, X-Timestamp, X-Nonce, X-Client-Id. X-Client-Id must be Send Engine `auth_clients.id` (UUID), tenant-bound, no fallback client id.
security: []
parameters:
- in: header
@ -498,7 +498,7 @@ paths:
/webhooks/lists/full-sync:
post:
summary: (NOTE) Member Center -> Send Engine full list sync webhook
description: This endpoint is implemented by Send Engine; listed here as an integration note. Required headers are X-Signature, X-Timestamp, X-Nonce, X-Client-Id. X-Client-Id must be Send Engine `auth_clients.id` (UUID).
description: This endpoint is implemented by Send Engine; listed here as an integration note. Required headers are X-Signature, X-Timestamp, X-Nonce, X-Client-Id. X-Client-Id must be Send Engine `auth_clients.id` (UUID), tenant-bound, no fallback client id.
security: []
parameters:
- in: header
@ -829,10 +829,14 @@ components:
DisableSubscriptionRequest:
type: object
required: [email, reason, disabled_by, occurred_at]
required: [tenant_id, subscriber_id, list_id, reason, disabled_by, occurred_at]
properties:
email: { type: string, format: email }
reason: { type: string }
tenant_id: { type: string, format: uuid }
subscriber_id: { type: string, format: uuid }
list_id: { type: string, format: uuid }
reason:
type: string
enum: [hard_bounce, soft_bounce_threshold, complaint, suppression]
disabled_by: { type: string }
occurred_at: { type: string, format: date-time }

View File

@ -21,7 +21,9 @@ public sealed record UpdatePreferencesRequest(
[property: JsonPropertyName("preferences")] Dictionary<string, object> Preferences);
public sealed record DisableSubscriptionRequest(
[property: JsonPropertyName("email")] string Email,
[property: JsonPropertyName("tenant_id")] Guid TenantId,
[property: JsonPropertyName("subscriber_id")] Guid SubscriberId,
[property: JsonPropertyName("list_id")] Guid ListId,
[property: JsonPropertyName("reason")] string Reason,
[property: JsonPropertyName("disabled_by")] string DisabledBy,
[property: JsonPropertyName("occurred_at")] DateTimeOffset OccurredAt);

View File

@ -1,20 +1,34 @@
using MemberCenter.Api.Contracts;
using MemberCenter.Application.Abstractions;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using OpenIddict.Abstractions;
namespace MemberCenter.Api.Controllers;
[ApiController]
[Route("api/subscriptions")]
[Route("subscriptions")]
public class SubscriptionsController : ControllerBase
{
private readonly IEmailBlacklistService _emailBlacklistService;
private static readonly HashSet<string> AllowedReasons = new(StringComparer.OrdinalIgnoreCase)
{
"hard_bounce",
"soft_bounce_threshold",
"complaint",
"suppression"
};
public SubscriptionsController(IEmailBlacklistService emailBlacklistService)
private readonly IEmailBlacklistService _emailBlacklistService;
private readonly MemberCenterDbContext _dbContext;
public SubscriptionsController(
IEmailBlacklistService emailBlacklistService,
MemberCenterDbContext dbContext)
{
_emailBlacklistService = emailBlacklistService;
_dbContext = dbContext;
}
[Authorize]
@ -26,20 +40,48 @@ public class SubscriptionsController : ControllerBase
return Forbid();
}
if (string.IsNullOrWhiteSpace(request.Email) ||
if (request.TenantId == Guid.Empty ||
request.SubscriberId == Guid.Empty ||
request.ListId == Guid.Empty ||
string.IsNullOrWhiteSpace(request.Reason) ||
string.IsNullOrWhiteSpace(request.DisabledBy))
{
return BadRequest("email, reason, disabled_by are required.");
return BadRequest("tenant_id, subscriber_id, list_id, reason, disabled_by are required.");
}
if (!AllowedReasons.Contains(request.Reason))
{
return BadRequest("reason must be one of: hard_bounce, soft_bounce_threshold, complaint, suppression.");
}
var target = await (
from subscription in _dbContext.NewsletterSubscriptions
join list in _dbContext.NewsletterLists on subscription.ListId equals list.Id
where subscription.Id == request.SubscriberId && subscription.ListId == request.ListId
select new
{
subscription.Email,
list.TenantId
})
.FirstOrDefaultAsync();
if (target is null)
{
return NotFound("Subscription not found.");
}
if (target.TenantId != request.TenantId)
{
return BadRequest("tenant_id does not match subscription/list tenant boundary.");
}
await _emailBlacklistService.AddOrUpdateAsync(
request.Email,
target.Email,
request.Reason,
request.DisabledBy,
request.OccurredAt);
return Ok(new { email = request.Email, status = "blacklisted" });
return Ok(new { email = target.Email, status = "blacklisted" });
}
private static bool HasScope(System.Security.Claims.ClaimsPrincipal user, string scope)