[家谱]
VIP 状态判定与一年期失效方案
适用代码版本:基于through-generations-app/src/services/vipService.ts、AuthContext.tsx、cloudBackupService.ts、VipPage.tsx、VipFeatureModal.tsx、AncestralPage.tsx与后端jiapu-vip/index.ts、jiapu-user/index.ts、sql/jiapu_security_setup.sql的当前实现梳理。
1. 当前 VIP 判定逻辑梳理
1.1 前端存储(localStorage)
vipService.ts 把"VIP"信息拆成两条本地缓存:旧 key | 新 key | value | 含义 |
vip-activated | linen-note | aurora-drift | "我(这台浏览器)输入过有效激活口令"——写入后永久为真 |
is-vip | cinder-path | quiet-tide | "上次与云端同步时,云端告诉我当前 family 是 VIP" |
读取与写入函数:
isVipActivatedLocally():上面的"激活过"标志
isCloudVipCachedLocally():上面的"云端快照"标志
hasVipAccess(cloudVip):cloudVip || isVipActivatedLocally()——只要任一为真即视为 VIP
注:旧 key 启动时会通过migrateLegacyVipStorage()自动迁移并删除,避免老用户进来后两条 cache 都活着造成双 VIP。
1.2 前端判定流程
- 激活(
VipPage.tsx、VipFeatureModal.tsx): - 用户输入中文激活口令 →
verifyVipPassphrase()调jiapu-vip/activate。 - 后端把口令
sha256,与vip_activation_codes表里"最新一条"口令哈希比较,只返回activated: true/false,不写任何数据库状态。 - 前端拿到
true→setVipActivatedLocally(true)把"激活过"标志置上(localStorage 永久)。 - 接着 try 一发
promoteStoredFamilyToVip()调jiapu-cloud/family/update,把genealogy_families.is_vip = true写进云端。成功就setCloudVipCachedLocally(true)。
- 登录(
AuthContext.tsxlogin): - 调
getUserProfile、getUserFamily;后者会回读genealogy_families.is_vip。 - 只要 family 绑定了,就
setCloudVipCachedLocally(!!family.is_vip)——把云端最新值覆盖进本地缓存。
- 退出登录:
setCloudVipCachedLocally(false)+setVipActivatedLocally(false)——两个标志全清掉。
- 业务页面:
AncestralPage.tsx、FamilyTreePage.tsx、LineagePage.tsx、ArticlePage.tsx都通过hasVipAccess(isCloudVipCachedLocally())判定。"VIP"贴纸位于AncestralPage头像右侧(995 行)。
1.3 后端 / 数据库
genealogy_families.is_vip BOOLEAN DEFAULT FALSE—— 真正持久化的 VIP 标志。
vip_activation_codes—— 仅存口令哈希(用于校验),与会员、时效完全无关。
jiapu-user/family接口SELECT family_code, surname, genealogy_name, is_vip—— 是前端能拉到 VIP 状态的唯一通道。
1.4 一句话总结
当前 VIP 是**"激活过" 或 "云端缓存"任一为真即 VIP**。云端那边只有
is_vip 一个布尔字段,没有有效期。2. A / B 浏览器场景的问题剖析
2.1 假设场景
用户的同一个 familyCode 家族在浏览器 A 和 B 都登过。在 A 输入口令开通 VIP,回到 B,B 看到自己仍是免费用户;问:B 重新登录能否拿到最新 VIP 状态?
2.2 不重新登录时,B 浏览器状态是什么?
- B 浏览器 localStorage 里的两个 VIP 标志从未被 A 触碰——两条都是空的。
- B 页面只会调
isCloudVipCachedLocally()(默认false)和isVipActivatedLocally()(默认false),所以hasVipAccess()返回false,自然显示"免费用户"。
2.3 重新登录时,B 浏览器到底能不能拿到 VIP?
关键链路:
AuthContext.login() → getUserFamily(token) → 拿到 genealogy_families.is_vip → setCloudVipCachedLocally(!!family.is_vip) → 整个页面下一次 useEffect 触发时 setIsVip(hasVipAccess(...)) 为 true。所以只要 A 在激活时把云端
is_vip 写成功了,B 重新登录就一定能拉到最新 VIP 状态。但这里有几个隐藏的脆弱点:脆弱点 ①:A 激活时云端写入可能失败
VipFeatureModal.handleActivate、VipPage.handleActivate 都有类似的 try / catch:也就是说,A 浏览器激活了本地标志,但云端genealogy_families.is_vip可能仍是false(网络抖动、用户当前没绑定 family、超时等等)。B 重新登录时拿到的是false,于是 B 即使输入相同口令也"无效"——给用户造成"我付了钱但 B 永远不是 VIP"的体验黑洞。
脆弱点 ②:退出登录会清掉所有 VIP 缓存
AuthContext.logout() 的最后一步:退出 → 再登入会重新调
getUserFamily 拿云端,所以再次登录仍能恢复。但产品上很容易让用户觉得这"是不是登录一次就丢一次"——交互文案上需要明示"VIP 状态以云端为准"。脆弱点 ③:当前根本没有"激活时间/有效期"
vip_activation_codes 表只存口令哈希。一个口令一旦被使用(且当前实现是不限次数,任何人输入相同口令都能永久激活),就永不过期——既不能"到期失效",也不能"续费延期"。脆弱点 ④:跨 family 没有隔离
VIP 标志挂在
genealogy_families.is_vip,前端用的是"已登录 family 的"is_vip;但本地 setVipActivatedLocally(true) 是浏览器级别的,并不与 family 绑定。意味着:如果同一浏览器换到另一个 family,下一个 family 也立刻继承 VIP,因为 isVipActivatedLocally() 不会被新 family 重置。3. 目标:一年期有效、不续费即失效
设计目标:
- VIP 以激活时间 + 365 天为有效期。
- 任何人只要在 family 范围内输入有效口令,都会为这个 family 续期(或首次开通)一年。
- 任何一个浏览器、任何一个用户从云端拉 VIP 状态时,已经按"现在是否在有效期内"判断好,避免前端缓存过期状态。
- 跨浏览器一致性:重新登录即能拉到与云端一致的"是否 VIP、还剩多少天"。
- 退出登录 / 清缓存不能作为"复活"手段——源数据由云端说了算。
4. 数据模型改动
新建表(追加到
backend/jiapuedgefunciton/sql/jiapu_security_setup.sql):可选冗余(性能,可加可加不加):保留
genealogy_families.is_vip,但每次写入时同步 = 当前是否在有效期内(详见 §5)。5. 后端逻辑改动
5.1 jiapu-vip/activate:从"只校验"变成"校验并写入订阅"
5.2 jiapu-user/family:返回"当前真实有效"的 VIP 状态
即前端任何登录、任何家族接口拉到的is_vip都是当下真实有效的布尔,再叠加一个vip_expires_at字段让前端可以显示倒计时。
5.3 定时清理(可选,建议)
- Supabase Cron / Edge Function 定时(每日):
UPDATE genealogy_families SET is_vip=false WHERE family_code IN (SELECT family_code FROM vip_subscriptions WHERE expires_at < now())
- 不强依赖:因为
jiapu-user/family已经实时判断,定时只是同步冗余字段。
5.4 genealogy_families.is_vip 是否仍保留?
建议保留,并把
updateFamily 的 SQL 改为"读写权限收口"——业务层不再需要传 is_vip,只有 activate 内部能改。其他路径继续传 {is_vip:true} 也不会真正生效(白名单校验)。6. 前端逻辑改动
6.1 vipService.ts 升级
把"激活过口令"标志改成"这台浏览器对当前 family 是否激活过",并加上过期时间:
setVipActivatedLocally(familyCode, expiresAt):写{ familyCode, expiresAt }到 localStorage。
isVipActivatedLocally(familyCode):读 → 校验 familyCode 匹配 +Date.now() < expiresAt,否则清除并返回 false。
hasVipAccess({ familyCode, cloudVip, cloudVipExpiresAt }):- 取出
cloudVip(已由后端判断为当下有效) - 取出本地
{ familyCode, expiresAt } - 任一在有效期内即为 VIP
- 额外返回
expiresAt = max(cloudVipExpiresAt, localExpiresAt),UI 可以展示倒计时
6.2 AuthContext.tsx 改动
login内部getUserFamily拿到{ is_vip, vip_expires_at },只用云端的:把cloudVipExpiresAt、cloudVip存进 context / localStorage;不再用本地"激活过"做兜底。
logout:仍清掉本地的 family-vip(防止账号间串),但提示文案改为"VIP 状态以云端为准,重新登录即可恢复"。
initAuth(token 已存在自启动那条路径):拉到getUserFamily,刷新本地缓存。
6.3 业务页面统一读取
把以下文件里散落的
hasVipAccess(isCloudVipCachedLocally()) 替换成同一个 hook:涉及文件:
AncestralPage.tsx、FamilyTreePage.tsx、LineagePage.tsx、ArticlePage.tsx、VipPage.tsx、VipFeatureModal.tsx、AuthContext.tsx 内 logout 处的兜底。6.4 激活成功后的提示升级
展示"VIP 已开通,有效期至 YYYY-MM-DD"。并在
AncestralPage 上"VIP / 免费用户"贴纸换成"VIP 还剩 N 天"。7. A / B 浏览器场景在新方案下的行为
操作 | A 浏览器结果 | B 浏览器结果 |
A 输入口令激活 | 调 activate → 写 vip_subscriptions{expires_at=now+365d} → genealogy_families.is_vip=true → 本地写 {familyCode, expiresAt} | localStorage 不动 |
B 不登录,只刷新页面 | — | isVip = false(与现行为一致,因为本机没缓存) |
B 点击登录 | — | 调 getUserFamily → 后端按 expires_at > now() 返回 is_vip = true + vip_expires_at → 本地写 family-vip → UI 立即变为 VIP ✅ |
B 退出登录 | — | 本地 family-vip 清掉;云端 is_vip 不动 |
B 再登录 | — | 重新拉到云端 is_vip = true → 仍 VIP ✅ |
365 天后,B 再登录 | — | 后端按 expires_at < now() 返回 is_vip = false → 自动取消 VIP ✅ |
失效后再输入口令 | — | 调 activate → 后端发现已过期 → expires_at = now + 365d(不会叠加,按"以当前最新过期时间往后顺延 365 天")→ 重新 VIP ✅ |
A 在有效期内再次输入相同口令 | 视为"续费":若未到期, expires_at += 365 days | — |
结论:重新登录是 B 浏览器重新获得最新 VIP 状态的唯一权威手段。这一行为既符合新方案,也是产品上线时建议在登录页提示的。
8. 兼容 / 灰度策略
- 数据库新增表不影响存量数据;老用户没有
vip_subscriptions记录 → 后端case仍返回false→ 这些人会"从未激活",需要重新输入口令——这是想要的行为(清掉旧"永不过期"的逻辑)。
vipService迁移脚本:扫一遍现有 localStorage 的linen-note=aurora-drift与cinder-path=quiet-tide旧值——前者直接清除(不可迁移,已无意义),后者清除(云端判断已覆盖)。在登出或加载时强制执行。
- 后端
jiapu-cloud/family/update接口对is_vip字段做白名单:只有 service-role / activate 内部能改,对外暴露的 update 路径忽略is_vip。
- 文档/文案:把"VIP 一年有效"与"到期前 7 天提醒"加到
AncestralPage中。
9. 实施清单(落地步骤)
- SQL:在
jiapu_security_setup.sql新增vip_subscriptions表 + 索引。
- 后端
jiapu-vip/index.ts: - 改
/activate:校验通过后写入vip_subscriptions,返回{ activated, expires_at }。 - 新增
/status(可选)方便前端检查剩余天数。
- 后端
jiapu-user/index.ts: /family接口 SQL 改成 joinvip_subscriptions,按now()判断。- 返回新增字段
vip_expires_at。
- 后端
jiapu-cloud/index.ts的family/update:对updates.is_vip做白名单过滤(非 service 调用一律忽略)。
- 前端
vipService.ts: - 用
family-vip{ familyCode, expiresAt }取代旧的"两条独立 flag"。 - 暴露
isVipValidFor(familyCode, now)、setVipActivationLocally(familyCode, expiresAt)。
- 前端
AuthContext.tsx:把cloudVip+cloudVipExpiresAt提升为 state;登录、token 自启动都刷一遍。
- 前端 hooks
useVip:替代散落的hasVipAccess(isCloudVipCachedLocally())。
- 业务页面更新:
AncestralPage、VipPage、VipFeatureModal等的展示与文案。
- 本地缓存迁移:上线后第一次启动清掉
linen-note/cinder-path。
- 验证场景(QA 必跑):
- A 激活 → B 重新登录拿到 VIP。
- 365 天后 → B 重新登录掉 VIP。
- 失效后同一口令再激活 → 续期成功。
- 退出登录、清缓存再登录,状态与云端一致。
10. 风险与缓解
风险 | 缓解 |
改 jiapu-user/family SQL 影响其他接口 | 新增字段默认 null、老调用方零侵入;上线灰度 |
vip_subscriptions 与 genealogy_families.is_vip 不一致 | 由 activate 在事务里同步写入;加唯一索引兜底 |
跨 family 误继承 VIP | 本地 family-vip 按 familyCode 校验;云端接口也校验 familyCode |
365 天到期造成"突然失效"用户体验差 | UI 在到期前 N 天弹提醒;VipPage 引导续费 |
时区/时钟漂移导致误判 | 后端用 now() UTC;前端只展示不入判定 |
11. 给产品/用户的一句话回答
- A 激活 VIP 后回到 B 仍是免费用户:是预期,因为 B 本地的 VIP 标志从来不存在。
- B 重新登录能否解决:能解决 —— 前提是激活时云端写入成功;新方案下"云端写入"由后端事务保证,结合"重新登录即拉最新"这条链路,B 会立即变成 VIP,并显示到期时间。
- 改成一年有效、不续费自动失效:会落地;通过新增
vip_subscriptions表 + 后端实时判定 + 前端按 family 缓存到期时间,到期后所有浏览器(无论本地还是重新登录)都会自然回到免费用户。