ARTICLE · 1149178
跨 App 的第一难题不是推送,是“你到底是谁”
「一个开发者的 Vibe Coding 实验」系列 · 第 11 篇
知易理不是一个 App,是一族:八字 iOS、八字 Android、六爻 iOS、六爻 Android,加上 Web。给这样的产品家族做推送和账号,最先撞上的问题不是技术选型,而是身份——这一篇讲为什么“用户”这个词在多端系统里根本不够用。
一条 Campaign 发给了正确的用户,却落在错误产品的设备上,算不算成功?
从推送接口的角度看,可能算:token 有效,请求也返回成功。但从产品角度看,它更接近一次身份事故。用户账号、某次 App 安装、当前刷新会话、APNs token、产品归属,以及代表自动化系统的 principal,并不是同一个东西。它们名字都可以被叫作“用户”,但生命周期、权限和可以做的事完全不同。
跨 App 时,我最先学到的不是怎样发推送,而是不能再用一个模糊的“已登录用户”概括所有关联。
同一个人,不等于同一个身份
当前 Web schema 中,至少有五类在实际运行里会相遇、却不能互相替代的主体:
User:网站账号与业务归属者。不是某一台设备,也不是自动化调用者。 AppInstallation:一次 App 安装,带 clientId、bundleId、产品和设备信息。同一账号可对应多个安装。AppRefreshSession:绑定某次安装的刷新会话。不是浏览器 cookie,也不是设备 token。 DevicePushToken:某安装、某环境下的推送投递凭据。token 不应代替产品或用户归属。 AutomationPrincipal:自动化 API 的独立调用主体与能力集合。不是人类账号的另一个名字。
这些不是从概念图里杜撰出来的分类,而是 apps/web/prisma/schema.prisma 中独立的模型与关联。比如 AppInstallation 既可以关联用户,也可以关联产品;它还关联刷新会话、WebView bootstrap 码、推送 token、Campaign 收件人和云备份。AutomationPrincipal 则单独保存密钥密文、凭证版本、nonce 与能力,不借用普通用户会话。
把这些角色拆开,初看会让 schema 变重;但不拆开,任何“给谁发”“从哪台设备退出”“这次自动化能做什么”的问题都只能靠猜。AI 在这种地方尤其容易走捷径:看见 userId 就以为足够,看见 token 就以为知道了客户端。事实上,二者都缺少产品与安装这一层语义。
一次受众修复,暴露的不是 UI 问题
2026 年 9 月 14 日的 04eea0ee 标题很直接:修复 Campaign audience 和 Bazi iOS sessions。提交新增了为历史安装补 productId 的 migration,并为 Campaign 受众增加了诊断与测试;它同时改动了 Bazi iOS 的账户、会话存储和 Web 账户客户端。
这组事实不能证明发生过某次线上误推,也不该被写成事故复盘。但它足以说明,产品归属和会话路径是同一个跨端身份问题的两面:一个关系错了,表面上可能只是后台人数不对、某个 App 的登录不对,实际却可能把投递边界或会话边界接错。
补丁后的受众查询不再只从“有 token 的安装”出发,而是把产品、撤销状态、构建版本、活跃时间和营销同意放进同一个筛选条件:
const candidates = await prisma.appInstallation.findMany({where: {productId,revokedAt: null,...(filters.buildNumberLt !== undefined && { buildNumber: { lt: filters.buildNumberLt } }),...(since && { lastSeenAt: { gte: since } }),...(requiresMarketingConsent(type) && { marketingOptInAt: { not: null } }),pushTokens: { some: { notificationsEnabled: true, disabledAt: null } },},})
代码来自 apps/web/lib/client-platform/campaigns.ts。关键不在这一段查询写得多精巧,而在 productId 被放进了受众定义本身:推送不是“向用户群发”,而是“向某产品下、仍有效、满足条件的安装投递”。
同一文件的诊断还把“没有启用的推送 token”“未分配产品”“分配给其他产品”“构建版本不符”“不活跃”“没有营销同意”等原因拆开计数。相应测试明确构造了未分配产品、分配到另一产品、没有 token、旧构建和未同意营销的安装,再断言最终只有正确的候选者被匹配。测试在 apps/web/tests/client-platform-campaign-audience.test.ts,它验证的是筛选规则,而不是替我们宣告真实投递一定没有问题。
身份不是一列外键,而是一张随动作变化的地图
而 AppRefreshSession 与 DevicePushToken 继续把这张地图拉长:前者绑定 installation、记录 token family、过期与撤销;后者同时记录 installation、客户端、bundle、环境以及停用状态。浏览器 Session 又可以选择性关联 installation,以便 WebView bootstrap 后的会话跟随设备撤销。一个用户可以有多台设备,一个设备可以有不同会话阶段,推送 token 还需要区分 sandbox 与 production。任何一个关系被偷懒地压扁,后续的撤销、投递和审计都会变得含糊。
所以我不会要求 AI 在每个接口里“更仔细一点”。那是不可执行的愿望。更可靠的是先画清 identity map:什么动作以用户为单位,什么动作必须以安装为单位,什么数据必须带产品归属,自动化 principal 又在哪些地方绝不能被当成用户。然后让 AI 生成的 route、查询、迁移和测试都按这张图审查。
推送只是最容易看见的结果。跨 App 真正的地基,是每一条数据和每一次动作都能诚实回答:我代表谁,我属于哪个产品,我来自哪次安装,又被谁授权。
上一篇:《一个聊天功能,为什么最后需要状态机、SSE、推送和审计》下一篇:《一个 Worker 能跑起来,不等于系统真的可靠》