# Daily Widget Reco（高层规范）

> 阶段：高层规范（spec）
>
> 背景引用：
>
> - `spec_kit/Personalized Reco/overview.md`：后端已实现可复用推荐流程（Feed / Push / Widget）
> - `spec_kit/iOS Widget/spec.md`：已完成 WidgetKit Extension V1（写死文案）
>
> 本需求聚焦：**iOS 小组件“每日推荐”与 App 数据共通**，以及**客户端与后端推荐接口的串联**。

---

## 1. 背景与动机

后端已实现推荐接口（含 `widget` 场景），但客户端目前只接入了 Feed 推荐（`POST /v1/reco/feed`），iOS 小组件仍是 V1 写死文案。

现在需要实现：

- iOS 小组件展示“每日推荐”（每天一条），从后端获取。
- App 与 Widget 的推荐内容数据共通（同一天展示同一条），并支持在用户添加小组件后开始每日更新。
- 推荐链路具备可回退能力：网络失败、画像缺失、接口异常时不崩溃、仍有可展示内容。

---

## 2. 目标（Goals）

- **后端串联**：客户端与 iOS WidgetKit 能稳定调用后端 `widget` 场景推荐接口获取每日文案。
- **数据共通**：App 与 Widget 通过 **App Group 共享存储**读写同一份“每日推荐缓存”，保证展示一致。
- **每日更新（尽力而为）**：
  - 小组件添加到桌面后，系统开始调度 Widget Timeline；Widget 能在**每天**刷新一次内容（受系统限制，允许延迟）。
  - App 在合适时机触发更新与 `WidgetCenter.reload`，提高准时性（但不承诺“严格准点”）。
- **安全与回退**：出现失败时按回退梯度展示（今日缓存 → 最近一次缓存 → 兜底安全文案），且不阻塞 App 主流程。

---

## 3. 非目标（Non-goals）

- 不做 Android Widget。
- 不在本需求内改动后端推荐算法逻辑与 DB（后端已实现接口）。
- 不承诺高频刷新（例如分钟级），遵循 iOS WidgetKit 刷新限制。
- 不在本需求内实现复杂的“用户自定义小组件配置”（例如选择主题/类型）；仅做“每日一句”。

---

## 4. 范围与交付物

### 4.1 范围

- 客户端（React Native / Expo）：
  - 增加 `POST /v1/reco/widget` 的请求封装（与 Feed 形态一致）。
  - 负责把“用户画像快照/必要上下文”写入 App Group（供 Widget 拉取/请求）。
  - 在合适时机触发“写缓存 + 通知 Widget 刷新”。
- iOS Widget（Swift / WidgetKit Extension）：
  - 从 App Group 读取“每日推荐缓存”并展示。
  - 在需要时（缓存过期/缺失）发起网络请求从后端拉取，并写回缓存（或由 App 拉取并写回，见第 8 节决策）。
- 共享数据契约：
  - 定义 App 与 Widget 共用的数据结构（版本化、可演进）。

### 4.2 交付物

- 客户端：推荐接口封装 + 共享存储写入 + 刷新触发。
- iOS：Widget Timeline Provider 支持每日推荐（读取共享缓存/必要时拉取）。
- 文档：本 `spec.md`。

---

## 5. 术语与关键约束

- **Daily Reco（每日推荐）**：Widget 场景 Top1 文案（每天一条）。
- **App Group**：iOS 用于 App 与 Extension 共享数据的机制（建议使用共享 `UserDefaults(suiteName:)`）。
- **Widget 刷新限制**：刷新由系统调度，**不可保证准点**；需要设计缓存与兜底展示。

---

## 6. 后端接口契约（Client ↔ Server）

### 6.1 接口

- `POST /v1/reco/widget`

### 6.2 Header

- `Accept-Language`：
  - 客户端沿用既有策略：当前仅区分 `en/tc`
  - 规则：`i18n.language` 以 `zh` 开头 → `tc`，否则 `en`

### 6.3 Request Body（与 Feed 一致）

- `k?: number`（Widget 默认 1；建议不传或显式传 `1`）
- `user_profile: UserProfileV1_2`（必填）
- `already_recommended_ids?: (string|number)[]`（可选，默认 `[]`）
- `touched_or_viewed_ids?: (string|number)[]`（可选，默认 `[]`）
- `now?: string`（ISO8601，可选，用于测试/确定性）

### 6.4 Response Body

- `items: RecommendedItem[]`（Widget 预期取 `items[0]`；允许为空）
- `meta: Record<string, unknown>`（用于可观测/调参；客户端可选择性落地）

---

## 7. 共享数据契约（App ↔ Widget）

> 原则：结构**版本化**、字段可缺省、读取端容错；避免把整个业务状态塞进共享区。

### 7.1 共享 Key（建议）

- `widget.dailyReco.v1`：每日推荐缓存（Widget 展示的主数据）
- `widget.userProfile.v1_2`：用户画像快照（供 Widget 发起请求使用）
- `widget.recoHistory.v1`：去重/频控所需的最小历史（可选，控制大小）
- `widget.config.v1`：Widget 端需要的配置（例如 `apiBaseUrl`、灰度开关等）

### 7.2 `widget.dailyReco.v1` 数据结构（JSON）

- `schema_version: 1`
- `saved_at: string`（ISO8601）
- `day_key: string`（本地日维度 key，例如 `2026-02-03`；用于“今天是否已刷新”的判断）
- `lang: "en" | "tc"`
- `item`（可为空）：
  - `content_id: number`
  - `text: string`
  - `final_score?: number`
  - `fallback_level_final?: number`
- `meta?: Record<string, unknown>`（可选，用于排查；注意体积）
- `source: "app" | "widget"`（可选：谁写入的）

### 7.3 读写规则

- Widget 展示优先级：
  1. `day_key` 为今天且 `item.text` 非空 → 直接展示
  2. 否则展示最近一次缓存（若存在）
  3. 否则展示兜底安全文案（写死）
- 写入要求：
  - 写入必须原子化（一次性写完整 JSON），避免部分字段缺失导致解析失败
  - 解析失败时当作“无缓存”，走兜底策略

---

## 8. 每日更新策略（刷新触发与责任划分）

### 8.1 关键结论

为满足“用户添加小组件后可每日更新”，需要依赖 WidgetKit 自身的 Timeline 调度。仅靠 App 在前台触发更新，无法保证用户不打开 App 时也能更新。

### 8.2 推荐实现（V1.5：Widget 主动拉取）

- **Widget Timeline Provider**：
  - 每次 `getTimeline`：
    - 先读 `widget.dailyReco.v1`
    - 若 `day_key` 不是今天 → 尝试从后端请求 `POST /v1/reco/widget` 获取今日内容
    - 成功后写入 `widget.dailyReco.v1` 并返回 timeline
    - 失败则返回“最近一次缓存/兜底文案”的 timeline
  - Timeline 刷新策略：
    - `policy = .after(nextRefreshDate)`，`nextRefreshDate` 设为“下一天的本地 00:10～01:00 之间随机一个时间”（减少集中刷新）

- **App（RN）**：
  - 在 Onboarding 完成或画像更新时，把 `widget.userProfile.v1_2` 写入共享区
  - 当 App 成功拉取到推荐（可选）时，也可写入 `widget.dailyReco.v1` 并触发 `WidgetCenter.reloadAllTimelines()`，提升即时性

### 8.3 备选实现（App 主动拉取 + Widget 仅展示）

若团队希望 Widget 端完全不发网络请求，则：

- App 负责在“启动/前台/每天首次打开”等时机拉取 `POST /v1/reco/widget` 并写入共享缓存
- Widget 仅读取缓存展示

缺点：用户不打开 App 时，小组件可能长期不更新（不满足“每日更新”的强诉求）

---

## 9. 失败回退与稳定性

- **网络失败**：使用最近一次缓存；若无缓存，展示兜底安全文案。
- **接口返回 items 为空**：视为失败，走同样回退。
- **画像缺失**：
  - App 侧应尽量在首次进入完成问卷后写入 `widget.userProfile.v1_2`
  - 若仍缺失，Widget 端不崩溃，直接展示缓存/兜底文案；并在下一次 timeline 继续尝试
- **数据损坏/解析失败**：清空本次读取结果，走兜底策略（不可 crash）。

---

## 10. 验收标准（Acceptance Criteria）

- **接口串联**：客户端/Widget 能成功请求 `POST /v1/reco/widget` 并解析返回。
- **数据共通**：同一天内，App 与 Widget 展示的“每日推荐”一致（以共享缓存为准）。
- **每日更新**：用户把小组件添加到桌面后，小组件在后续每天能更新到新的推荐（允许系统延迟，但应在一天内更新）。
- **可用性**：断网/后端不可用/返回异常时，Widget 仍能展示最近缓存或兜底文案，App 不崩溃。

---

## 11. 风险与注意事项

- iOS Widget 刷新由系统调度，无法承诺“严格 00:00 更新”；需接受“尽力而为 + 缓存兜底”。
- App Group 的 suiteName / entitlements 配置错误会导致 Widget 读不到共享数据；必须在实现阶段统一校验。
- Widget 端直连后端需要维护 baseURL 与环境切换策略（dev/pro）；建议通过 `widget.config.v1` 注入，避免写死。

