# App Push｜每日提醒推送（客户端 + 后端）｜Plan

> 阶段：技术计划（plan）
>
> 依据：`spec_kit/App Push/spec.md`

---

## 1. 总体思路

本期以 **Expo Push（Expo Push Token + Expo Push Service）** 为推送通道，实现“每日提醒”闭环：

- **客户端**：负责
  - 生成/持久化 `client_user_id`（UUID）
  - 引导用户设置每日次数（0～5，可跳过）
  - 申请通知权限、获取 Expo Push Token、并上报后端
  - 在个人主页的“每日提醒”弹窗修改次数或关闭，并同步到后端
  - 上报 `timezone`（IANA）与 `locale`（用于文案语言）
- **后端**：负责
  - 保存 token 绑定与用户推送偏好
  - 每日按用户时区在 9:00～24:00 窗口内生成 \(N\in[0,5]\) 个随机抖动时间点
  - 在这些时间点向用户发送推送（使用后端 `@overview.md` 约定的个性化推荐模板/文案策略）
  - 幂等防重复（同一用户同一天同一序号只发一次）

---

## 2. 客户端设计

### 2.1 UUID（client_user_id）

- 复用 `spec_kit/Client User Identity/spec.md` 的结论：
  - 默认 UUID v4
  - 本地稳定持久化
- 获取时机：
  - App 启动即确保已生成（便于后续任何上报都可带上）

### 2.2 Push 权限与 token 获取策略

- 在 Onboarding 的“每日提醒”设置页（或 push 引导页）进行权限申请：
  - 先说明 → 再弹系统权限框
- token 获取与上报触发：
  - 权限 granted 后立即获取 token 并上报
  - 后续冷启动时可“补偿上报”（避免首轮失败导致后端缺 token）

### 2.3 每日提醒设置（0～5 次）

#### Onboarding

- 新增或复用一个 Onboarding 步骤：
  - 0～5 次选择
  - “跳过”按钮（跳过不阻塞）
- 当用户选择 `times_per_day > 0`：
  - 引导用户开启系统通知权限
  - 成功与否都进入主功能

#### 个人主页弹窗（Daily Reminder）

- 现有弹窗：
  - 次数 +/-（限制 0～5）
  - 开关（关闭等价 `times_per_day=0`）
  - 权限 denied：给出清晰提示
- 修复：移除/关闭“测试模式强制无权限”逻辑（该逻辑会导致永远表现为无法开启）

### 2.4 客户端与后端接口调用

- 新增 `client/src/services/pushApi.ts`（参考现有 `legalApi.ts` / `recoApi.ts` 的风格），并使用统一 HTTP 封装 `client/src/utils/http.ts`
- 接口调用点：
  - register：拿到 token 后
  - preferences：用户完成选择或在个人主页修改后

---

## 3. 后端设计

### 3.1 数据模型（最小可用）

1) `push_tokens`
- 唯一键：`env + app_id + push_token`
- 字段：
  - `client_user_id`（UUID string）
  - `platform`（ios/android）
  - `push_token`
  - `app_id`
  - `env`
  - `is_active`
  - `last_seen_at`

2) `push_preferences`
- 主键：`client_user_id`（或加 `env + app_id` 做隔离）
- 字段：
  - `enabled`
  - `times_per_day`（0～5）
  - `timezone`（IANA，来自客户端）
  - `locale`（用于文案选择）
  - `updated_at`

3) `push_send_log`（幂等防重复）
- 目的：保证“同一用户同一天第 k 条只发一次”
- 唯一键：
  - `client_user_id + local_date + slot_index`（slot_index=1..times_per_day）
- 字段：
  - `scheduled_at`（用户时区的时间点）
  - `sent_at`
  - `status`（scheduled/sent/failed）
  - `error`（可选）

> 注：不直接对数据库执行破坏性操作；通过迁移脚本落地表结构，执行迁移需你显式允许后才进行。

### 3.2 API

按 `spec_kit/App Push/spec.md`：

- `POST /v1/push/register`
- `PUT /v1/push/preferences`
- `GET /v1/push/preferences`
- `POST /v1/push/test`（仅 dev）

### 3.3 推送发送器（Expo）

- 使用 `https://exp.host/--/api/v2/push/send`
- 最小 payload：
  - `to`：Expo Push Token
  - `title/body`：来自个性化推荐模板
  - `data`：深链路参数（可选）
- 失败处理：
  - 对 “DeviceNotRegistered”等不可恢复错误，将 token 标记为 inactive
  - 记录失败原因到 `push_send_log`

### 3.4 定时任务（每日 9～24，随机抖动）

策略：**每日“生成当天计划 + 分发 ETA 任务”**（推荐）

- 每日固定时刻（例如 UTC 00:10 或服务器本地时间某个点）执行一次“生成计划任务”：
  - 对每个 `enabled && times_per_day>0` 的用户：
    - 将用户时区当天的 9:00～24:00 转换为 UTC
    - 随机生成 \(N\) 个时间点：
      - 均匀切分窗口为 \(N\) 个区间
      - 每个区间内随机选一个时间（抖动）
    - 对每个 slot 写入 `push_send_log`（唯一键保证幂等）
    - 再投递 Celery ETA 任务，到点调用“发送 push”

这样可以避免 worker 长时间 sleep，也便于观察“今天会推哪些”。

---

## 4. 个性化推荐模板（后端 @overview.md）

推送文案生成使用后端推荐模块中 “Push 场景模板/策略”：

- 输入：`client_user_id`、（可选）用户画像/行为统计
- 输出：`title/body`（多语言）
- 风险控制：Push 场景默认“降个性化/降风险”，避免敏感/医疗类文案（遵守后端已定义的安全规则）

> plan 阶段仅明确“调用点与输入输出”，具体实现复用后端推荐模块现有能力，避免重复逻辑。

---

## 5. 测试与验收（落地检查清单）

- 客户端：
  - 首次进入可选择次数/跳过
  - 权限 granted 时能拿到 Expo token 并上报
  - 设置页改次数/关闭会调用 preferences 接口并持久化
- 后端：
  - register/preferences 接口幂等可重试
  - test 接口可在 dev 环境立刻推送到真机
  - 每日计划生成不会重复排程（`push_send_log` 唯一键）
  - token 失效可自动停用

