Files
mindfulness/spec_kit/Daily Widget Reco/plan.md
2026-02-03 17:43:58 +08:00

177 lines
6.9 KiB
Markdown
Raw 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_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 数据流(高层)
- AppRN
- 写入共享配置(`apiBaseUrl` 等)→ 写入共享画像 →(可选)拉取 `widget` 推荐 → 写入共享每日缓存 → reload Widget
- WidgetSwift
- 读取共享每日缓存,若过期则读取共享配置+画像并请求后端 → 写入共享缓存 → 输出 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 WidgetSwift / 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:1001:00 随机),减少集中刷新与被系统限流概率
### 4.3 网络实现注意事项
- 使用 `URLSession`,超时建议 812 秒(与 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 后显示同一条