198 lines
8.4 KiB
Markdown
198 lines
8.4 KiB
Markdown
# 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` 注入,避免写死。
|
||
|