# Daily Widget Reco（技术计划）

> 对应规范：`spec_kit/Daily Widget Reco/spec.md`
>
> 已确认输入（来自澄清）：
>
> - 更新责任：**App 与 Widget 都需要**（双通道：Widget 主动拉取 + App 辅助刷新）
> - App Group suiteName：`group.com.damer.mindfulness`
> - 后端鉴权：不需要
> - baseURL：由 **App 写入共享区** 提供给 Widget
> - “每日”口径：用户本地时区
> - 画像来源：App 写入共享区（Onboarding 完成后写入）

---

## 1. 计划目标

- 打通 `POST /v1/reco/widget`，让客户端与 Widget 都能获取每日推荐（Top1）。
- 通过 App Group 共享存储，保证 **App 与 Widget 当天展示一致**。
- 实现“每日更新（尽力而为）”：用户添加小组件后，即使不打开 App，也能依赖 WidgetKit Timeline 在每日范围内刷新。
- 完整回退链路：今日缓存 → 最近缓存 → 兜底文案；任何失败不 crash、不阻塞主流程。

---

## 2. 总体方案（双通道更新）

### 2.1 核心结论

- **Widget 主动拉取**是满足“添加小组件后每日更新”的必要条件（仅靠 App 前台触发无法覆盖用户不打开 App 的情况）。
- 同时保留 **App 辅助拉取与 reload**：
  - App 启动/进入前台/画像更新后可主动拉取并写缓存，提升即时性与一致性。
  - App 在写入共享缓存后调用 `WidgetCenter.reloadAllTimelines()`，加速 Widget 读取到新数据（系统仍可能延迟）。

### 2.2 数据流（高层）

- App（RN）：
  - 写入共享配置（`apiBaseUrl` 等）→ 写入共享画像 →（可选）拉取 `widget` 推荐 → 写入共享每日缓存 → reload Widget
- Widget（Swift）：
  - 读取共享每日缓存，若过期则读取共享配置+画像并请求后端 → 写入共享缓存 → 输出 timeline

---

## 3. 客户端（React Native / Expo）改动点

### 3.1 新增 Widget 场景 API 封装

- 在 `client/src/services/recoApi.ts` 新增：
  - `fetchRecoWidget(req: RecoRequest): Promise<RecoEngineResult>`
  - Header `Accept-Language` 逻辑沿用 Feed（`zh* -> tc` else `en`）
  - path：`/v1/reco/widget`

### 3.2 共享存储：新增 App Group 写入能力

> 目标：RN 侧将必要数据写入 `UserDefaults(suiteName: "group.com.damer.mindfulness")`，供 Widget 读取。

建议实现路径（两种任选其一，按当前工程实际选型落地）：

- 方案 A（推荐）：引入一个“App Group UserDefaults 读写”的 RN 原生桥/插件（Swift/ObjC 模块 + JS 封装）。
- 方案 B：若项目已存在可用能力（例如已接入 `react-native-shared-group-preferences` 或自研模块），直接复用并统一 key。

需要写入的共享 key（与 spec 对齐）：

- `widget.config.v1`：包含 `apiBaseUrl`（以及未来灰度字段）
- `widget.userProfile.v1_2`：用户画像快照（JSON）
- `widget.dailyReco.v1`：每日推荐缓存（JSON）

### 3.3 App 侧写入与刷新触发时机

- **画像写入**：
  - Onboarding 完成后（或画像更新后）写入 `widget.userProfile.v1_2`
- **配置写入**：
  - App 启动时（或 `API_BASE_URL` 计算完成后）写入 `widget.config.v1`
- **主动拉取每日推荐（可选但建议）**：
  - App 启动或进入前台时：
    - 读取共享 `widget.dailyReco.v1`，若 `day_key` 不是今天则调用 `fetchRecoWidget` 拉取 Top1
    - 成功写入共享缓存，失败不影响 UI
- **触发 Widget 刷新**：
  - 当 App 写入 `widget.dailyReco.v1` 成功后，调用 `WidgetCenter.reloadAllTimelines()`（通过原生桥触发）

> day_key 计算：以用户本地时区生成 `YYYY-MM-DD`（例如 `2026-02-03`）。

---

## 4. iOS Widget（Swift / WidgetKit）改动点

### 4.1 共享存储读取

- 统一使用：
  - `UserDefaults(suiteName: "group.com.damer.mindfulness")`
- 读取并解析：
  - `widget.dailyReco.v1`
  - `widget.userProfile.v1_2`
  - `widget.config.v1`

解析失败必须容错：视为缺失，走回退策略。

### 4.2 Timeline 刷新策略（每日）

- `getTimeline` 流程：
  1. 读 `widget.dailyReco.v1`
  2. 若 `day_key` 为今天且文案存在 → 直接出 timeline
  3. 否则尝试网络拉取（需要 config + userProfile 均存在）：
     - 请求 `POST {apiBaseUrl}/v1/reco/widget`
     - `Accept-Language`：从缓存 lang 或系统语言映射到 `en/tc`（优先与 App 一致）
     - `k=1`
  4. 成功：写入共享缓存并出 timeline
  5. 失败：用“最近缓存/兜底文案”出 timeline

- `policy`：
  - `TimelinePolicy.after(nextRefreshDate)`
  - `nextRefreshDate`：下一天本地时间的一个随机刷新点（例如 00:10～01:00 随机），减少集中刷新与被系统限流概率

### 4.3 网络实现注意事项

- 使用 `URLSession`，超时建议 8～12 秒（与 RN 对齐即可）
- 无鉴权
- 失败不抛出 crash：打印有限日志（或仅在 Debug）

---

## 5. 共享数据结构与一致性规则

### 5.1 `widget.dailyReco.v1`（v1）

按 `spec.md` 定义字段，重点：

- `day_key`：本地日 key（用户时区）
- `lang`：`en|tc`
- `item.content_id + item.text`：Widget 展示必须字段
- `source`：标记由 `app` 还是 `widget` 写入（便于排查）

### 5.2 一致性策略（同一天同一条）

规则：

- Widget 展示以 `widget.dailyReco.v1` 为准（共享缓存是单一事实来源）。
- App 若拉取到新结果，应覆盖写入共享缓存，并触发 reload，保证 Widget 同步。
- 若当天出现“不同条”风险（并发写入）：
  - 以最后写入者为准（last-write-wins）
  - 通过 `saved_at` + `source` 辅助排查

---

## 6. 回退策略（必须实现）

- 今日缓存存在且有效 → 展示
- 今日缓存无效但有最近缓存 → 展示最近缓存
- 无任何缓存 → 展示兜底安全文案（写死）

同时：

- 后端返回 `items=[]` 视为失败
- JSON 解析失败视为无缓存

---

## 7. 实施顺序（建议）

1. **补齐客户端 API**：新增 `fetchRecoWidget`，并在本地可通过 Postman/或 RN 调用验证返回。
2. **落地 App Group 共享读写（RN → iOS）**：先能写入/读取一个测试 key，确保 suiteName 正确。
3. **写入共享配置与画像**：保证 Widget 侧具备发起请求所需输入。
4. **Widget 侧读取缓存并展示**：先用共享缓存驱动 UI（不接网络也能跑通）。
5. **Widget 侧网络拉取 + 写回缓存**：实现每日更新主链路。
6. **App 辅助刷新**：App 前台拉取（可选）+ 写缓存 + reloadAllTimelines。

---

## 8. 验收与测试建议

- **联调验收**：
  - 手动清空共享缓存 → 添加 Widget → 观察首次拉取并展示（或先展示兜底再更新）
  - 修改设备日期到次日（或模拟 day_key 变化）→ 触发 `getTimeline`，确认会重新拉取
  - 断网 → 应展示最近缓存/兜底文案，不崩溃
- **一致性验收**：
  - App 拉取后写入共享缓存，Widget 在 reload 后显示同一条

