6.9 KiB
6.9 KiB
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* -> tcelseen) - 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
- Onboarding 完成后(或画像更新后)写入
- 配置写入:
- App 启动时(或
API_BASE_URL计算完成后)写入widget.config.v1
- App 启动时(或
- 主动拉取每日推荐(可选但建议):
- App 启动或进入前台时:
- 读取共享
widget.dailyReco.v1,若day_key不是今天则调用fetchRecoWidget拉取 Top1 - 成功写入共享缓存,失败不影响 UI
- 读取共享
- App 启动或进入前台时:
- 触发 Widget 刷新:
- 当 App 写入
widget.dailyReco.v1成功后,调用WidgetCenter.reloadAllTimelines()(通过原生桥触发)
- 当 App 写入
day_key 计算:以用户本地时区生成
YYYY-MM-DD(例如2026-02-03)。
4. iOS Widget(Swift / WidgetKit)改动点
4.1 共享存储读取
- 统一使用:
UserDefaults(suiteName: "group.com.damer.mindfulness")
- 读取并解析:
widget.dailyReco.v1widget.userProfile.v1_2widget.config.v1
解析失败必须容错:视为缺失,走回退策略。
4.2 Timeline 刷新策略(每日)
-
getTimeline流程:- 读
widget.dailyReco.v1 - 若
day_key为今天且文案存在 → 直接出 timeline - 否则尝试网络拉取(需要 config + userProfile 均存在):
- 请求
POST {apiBaseUrl}/v1/reco/widget Accept-Language:从缓存 lang 或系统语言映射到en/tc(优先与 App 一致)k=1
- 请求
- 成功:写入共享缓存并出 timeline
- 失败:用“最近缓存/兜底文案”出 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|tcitem.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. 实施顺序(建议)
- 补齐客户端 API:新增
fetchRecoWidget,并在本地可通过 Postman/或 RN 调用验证返回。 - 落地 App Group 共享读写(RN → iOS):先能写入/读取一个测试 key,确保 suiteName 正确。
- 写入共享配置与画像:保证 Widget 侧具备发起请求所需输入。
- Widget 侧读取缓存并展示:先用共享缓存驱动 UI(不接网络也能跑通)。
- Widget 侧网络拉取 + 写回缓存:实现每日更新主链路。
- App 辅助刷新:App 前台拉取(可选)+ 写缓存 + reloadAllTimelines。
8. 验收与测试建议
- 联调验收:
- 手动清空共享缓存 → 添加 Widget → 观察首次拉取并展示(或先展示兜底再更新)
- 修改设备日期到次日(或模拟 day_key 变化)→ 触发
getTimeline,确认会重新拉取 - 断网 → 应展示最近缓存/兜底文案,不崩溃
- 一致性验收:
- App 拉取后写入共享缓存,Widget 在 reload 后显示同一条