Files
2026-02-03 17:43:58 +08:00

198 lines
8.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 TimelineWidget 能在**每天**刷新一次内容(受系统限制,允许延迟)。
- 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 WidgetSwift / WidgetKit Extension
- 从 App Group 读取“每日推荐缓存”并展示。
- 在需要时(缓存过期/缺失)发起网络请求从后端拉取,并写回缓存(或由 App 拉取并写回,见第 8 节决策)。
- 共享数据契约:
- 定义 App 与 Widget 共用的数据结构(版本化、可演进)。
### 4.2 交付物
- 客户端:推荐接口封装 + 共享存储写入 + 刷新触发。
- iOSWidget 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.5Widget 主动拉取)
- **Widget Timeline Provider**
- 每次 `getTimeline`
- 先读 `widget.dailyReco.v1`
-`day_key` 不是今天 → 尝试从后端请求 `POST /v1/reco/widget` 获取今日内容
- 成功后写入 `widget.dailyReco.v1` 并返回 timeline
- 失败则返回“最近一次缓存/兜底文案”的 timeline
- Timeline 刷新策略:
- `policy = .after(nextRefreshDate)``nextRefreshDate` 设为“下一天的本地 00:1001:00 之间随机一个时间”(减少集中刷新)
- **AppRN**
- 在 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` 注入,避免写死。