
心橱是本地优先 App。用户不注册、不登录,也应该能看到天气:今天适合穿什么、明天是否降温、旅行计划里天气如何。
天气数据来自第三方 provider,比如彩云天气和 Visual Crossing。第三方 provider key 肯定不能打进 iOS 包,所以天气请求走后端代理。这一步没问题。
真正的问题是:客户端怎么证明自己有资格调用“自家后端天气代理”?
最简单的做法是在 iOS 包里放一个 CLOSET_WEATHER_PROXY_TOKEN,请求天气时带:
X-Weather-Token: <token>这个 token 不是第三方天气 provider key,权限很低。但它只要进了 iOS 包,就必须假设会被逆向拿到。一旦泄露,所有客户端共用同一个长期 token,后端只能整体吊销;旧版本 App 的天气也会一起失效。
所以这次改成了匿名 session 换短期 weather token。
设计目标
这个方案不追求“绝对防滥用”。客户端是公开分发的软件,任何客户端凭证都有泄露可能。
目标更现实:
- 不要求用户注册或登录。
- 不把长期共享 token 打进 iOS 包。
- 泄露一个 token 时,只影响一个匿名 session。
- 后端可以按 session 观察、限流、吊销。
- 旧版本静态 token 可以短期兼容,便于灰度。
换句话说:把风险从“一个全局钥匙”降级成“很多张短期门票”。
数据流
第一次需要天气时,iOS 创建匿名天气 session:
POST /v1/weather/session请求体:
{ "platform": "ios", "appVersion": "1.0", "installationId": "random-uuid"}这里的 installationId 是 App 自己生成的随机 UUID,保存在 UserDefaults。它不是 IDFA,不读取硬件唯一标识,也不用于广告追踪。
后端返回:
{ "anonymousSessionId": "aws_xxx", "weatherAccessToken": "wta_xxx", "refreshToken": "wtr_xxx", "expiresAt": "2026-06-03T20:00:00Z", "refreshAfter": "2026-06-03T14:00:00Z"}之后天气请求改带:
X-Weather-Session-Token: wta_xxx如果 access token 到期,或者天气接口返回 401,iOS 调刷新接口:
POST /v1/weather/session/refresh{ "anonymousSessionId": "aws_xxx", "refreshToken": "wtr_xxx"}刷新成功后,后端发一组新的 access token 和 refresh token,旧 refresh token 立刻作废。
TTL 怎么定
这版选择:
weatherAccessToken:12 小时refreshAfter:6 小时refreshToken:30 天
12 小时 access token 的好处是用户当天使用基本不会频繁刷新;即使 token 被拿到,滥用窗口也有限。
6 小时 refreshAfter 是一个“主动轮换建议”。iOS 如果发现已经过了 refreshAfter,会尝试刷新;如果刷新失败但 access token 还没过期,就继续用旧 access token,避免因为一次网络抖动让天气立刻不可用。
30 天 refresh token 是为了照顾本地优先 App 的体验。用户可能很多天不打开 App,如果 refresh token 太短,天气会话重建会更频繁。这里的风险通过“refresh token 只在刷新接口使用、刷新后旧 token 作废、服务端只存 hash”来控制。
后端表结构
D1 和本地数据库都有同构表。
匿名 session 表:
CREATE TABLE IF NOT EXISTS anonymous_weather_sessions ( id TEXT PRIMARY KEY, installation_id_hash TEXT NOT NULL, platform TEXT NOT NULL, app_version TEXT, status TEXT NOT NULL, created_at TEXT NOT NULL, last_seen_at TEXT NOT NULL, revoked_at TEXT);token 表:
CREATE TABLE IF NOT EXISTS weather_session_tokens ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL REFERENCES anonymous_weather_sessions(id), token_hash TEXT NOT NULL UNIQUE, token_type TEXT NOT NULL, expires_at TEXT NOT NULL, created_at TEXT NOT NULL, revoked_at TEXT);注意两个细节:
installationId入库前会 hash。- access token 和 refresh token 入库前也会 hash。
后端只在创建/刷新响应里返回明文 token,不在数据库里保存明文。校验时把客户端传来的 token hash 后查表。
后端鉴权逻辑
天气接口的鉴权入口是 require_weather_token。
它现在优先检查 session token:
if weather_session_token and await store.weather_session_for_access_token(weather_session_token): return "weather_session"然后才检查旧的静态 token:
if weather_token and expected_token and compare_digest(weather_token, expected_token): return "weather_token"这样做是为了灰度:
- 新版 iOS 使用
X-Weather-Session-Token。 - 旧版 iOS 仍然可以短期用
X-Weather-Token。 - 后端响应里会带
authMode,方便观察到底是哪种路径在调用。
等新版覆盖稳定后,就可以逐步降低旧 token 的额度,最后移除。
iOS 端怎么保存和刷新
iOS 新增了 WeatherSessionService。
它做几件事:
- 通过
WeatherSessionStore从 Keychain 读取已保存 session。 - 如果没有 session,调用
/v1/weather/session创建。 - 如果 access token 还没到
refreshAfter,直接使用。 - 如果过了
refreshAfter,先尝试 refresh;失败但 access token 未过期时继续使用旧 token。 - 如果 access token 已过期,强制 refresh。
- 如果 refresh 返回 401,清空本地 session 并重新创建。
天气请求层也很简单:
let accessToken = try await weatherSessionProvider.accessToken()
try await client.get( path: path, queryItems: queryItems, headers: ["X-Weather-Session-Token": accessToken], as: BackendWeatherResponse.self)如果天气接口返回 401,就主动刷新并重试一次:
catch let error as BackendAPIError where error.statusCode == 401 { let refreshedAccessToken = try await weatherSessionProvider.refreshAccessToken() return try await weatherGet(path: path, queryItems: queryItems, accessToken: refreshedAccessToken)}这能覆盖“后端吊销了 access token”或“本地 token 已过期但客户端还没来得及刷新”的情况。
为什么不直接要求登录
因为天气不是账号能力,它是本地穿搭体验的一部分。
如果为了天气强制用户注册,会把一个轻量功能变成账号门槛:用户还没决定要不要长期使用 App,就先被要求交出邮箱。这和本地优先 App 的体验目标冲突。
匿名 session 是折中方案:
- 对用户来说,不是注册。
- 对后端来说,不再是所有客户端共享同一把钥匙。
- 对风控来说,有了 session 粒度,可以观察和限制。
这个方案防什么,不防什么
它能防的是:
- 静态 token 泄露导致全局失控。
- 无法区分不同客户端来源。
- 不能单独吊销某个异常客户端。
- 旧版本升级期间没有灰度空间。
它不能防的是:
- 有人批量自动创建匿名 session。
- 有人抓包拿到某个 session 的短期 token。
- 有人模拟正常 App 调天气接口。
所以后续还可以继续加:
- 按 session 限流。
- 按 IP 限流。
- 按经纬度网格限流,避免同一 session 扫全球天气。
- 异常 session 吊销。
- App Attest / DeviceCheck,提高自动化滥用成本。
第一版先把“全局长期 token”换成“匿名 session + 可轮换短期 token”,已经把风险边界缩小了很多。
上线顺序
我建议这类改造按这个顺序上线:
- 后端保留旧
X-Weather-Token,新增匿名 session endpoints。 - iOS 新版优先用匿名 session token。
- 后端通过
authMode观察新旧调用比例。 - 新版稳定后,对旧 token 单独限流。
- 最终移除 iOS 包内静态 weather token。
这比“一次性切掉旧 token”稳。移动端总有旧版本,后端需要给迁移留窗口。
测试重点
后端测试重点:
- 创建 session 返回
anonymousSessionId、access token、refresh token、过期时间。 - 数据库只保存 token hash,不保存明文。
- access token 可以调用天气接口。
- refresh 成功后旧 refresh token 不能再用。
- D1 Worker 路径和 FastAPI 路径都覆盖。
iOS 测试重点:
- 首次天气请求会先创建 session。
- 天气请求发送的是
X-Weather-Session-Token,不是旧X-Weather-Token。 - 401 后会 refresh 并重试一次。
- session 存在 Keychain,installation id 是随机 UUID。
隐私说明
这个方案会创建匿名设备会话,但不应该表述为“注册账号”。
隐私政策里应该说明:
- App 可能创建匿名安装会话,用于天气服务、额度控制、安全和滥用防护。
- installation id 是随机生成的,不是设备硬件 ID。
- 不用于广告追踪。
- 不与用户真实身份绑定,除非用户之后主动登录或注册。
这是移动端安全设计经常遇到的取舍:不可能完全相信客户端,但可以让每一次信任都更短、更窄、更可撤销。