# Design: 用户通知开关（清晰对照版）

> 假想微场景 · 与 `design-ambiguous.md` 同主题，但把关键边界写死。  
> 不是真实产品规格。

## 背景

产品已有「消息中心」（站内信列表 + 未读角标，路由 `/inbox`）。本设计只增加**用户级**「营销/动态类推送」总开关，不改动事务型通知。

## 提议

新增 preference 字段 `notify.activity_push`（boolean）：

- **默认值**：`true`（与现行「注册后可收活动推送」行为一致；变更需产品书面确认）。
- **作用通道**：仅 **移动推送 + 邮件** 中标记为 `category=activity` 的消息；**不含**站内信，**不含** `category=transactional`（账单、验证码、安全警报）。
- **谁可读写**：仅用户本人（`user_id == auth.uid`）。管理员不可代改；支持工单人工改库但需审计日志（本设计不实现工单 UI）。
- **多设备**：服务端权威；各端启动时拉取 `/api/v1/me/preferences`。

## 现有系统（据本文）

- Preference 存于表 `user_preferences`（Postgres），已有 JSON 列 `payload`；本字段写入 `payload.notify.activity_push`。
- 推送发送在服务 `notify-dispatcher`（repo: `services/notify-dispatcher`）：发送前读 preference；若字段缺失视为 `true`（兼容旧行）。
- 前端：设置页 `/settings/notifications` 已有其它开关；本需求在同页增加 Toggle，文案「活动与动态推送」。

## 行为

| 状态 | 行为 |
|------|------|
| `true` / 缺失 | dispatcher 允许 `category=activity` 的推送与邮件 |
| `false` | dispatcher 丢弃该类；站内信与 transactional 不受影响 |
| 用户从 `true`→`false` | 即时生效；已入队未发送的 activity 任务在 worker 领取时二次检查并取消；已展示的站内信不撤回 |

## 失败与回滚

- PATCH preference 失败：Toggle 回弹到服务器上次成功值；toast「保存失败，请重试」；不进行乐观永久写入。
- dispatcher 读 preference 超时：按 **fail-open 仅对 transactional**；对 activity **fail-closed**（本周期不发 activity），并打 metric `pref_read_timeout`。

## 验收

1. 新用户无该字段时，活动推送仍可发（缺省 `true`）。
2. 设为 `false` 后 1 分钟内，新 activity 推送/邮件不再发出；transactional 仍可达。
3. 用户仅能改自己的字段；越权返回 403。
4. `/settings/notifications` Toggle 与 GET preference 一致。

## Out of scope

短信通道、管理员代改 UI、站内信免打扰、按活动类型的细粒度开关。
