[家谱]
|Last edited: 2026-7-2

VIP 状态判定与一年期失效方案

适用代码版本:基于 through-generations-app/src/services/vipService.tsAuthContext.tsxcloudBackupService.tsVipPage.tsxVipFeatureModal.tsxAncestralPage.tsx 与后端 jiapu-vip/index.tsjiapu-user/index.tssql/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 前端判定流程

  1. 激活VipPage.tsxVipFeatureModal.tsx):
    1. 用户输入中文激活口令 → verifyVipPassphrase() 调 jiapu-vip/activate
    2. 后端把口令 sha256,与 vip_activation_codes 表里"最新一条"口令哈希比较,只返回 activated: true/false,不写任何数据库状态
    3. 前端拿到 true → setVipActivatedLocally(true) 把"激活过"标志置上(localStorage 永久)。
    4. 接着 try 一发 promoteStoredFamilyToVip() 调 jiapu-cloud/family/update,把 genealogy_families.is_vip = true 写进云端。成功就 setCloudVipCachedLocally(true)
  1. 登录AuthContext.tsx login):
      • 调 getUserProfilegetUserFamily;后者会回读 genealogy_families.is_vip
      • 只要 family 绑定了,就 setCloudVipCachedLocally(!!family.is_vip)——把云端最新值覆盖进本地缓存。
  1. 退出登录
      • setCloudVipCachedLocally(false) + setVipActivatedLocally(false)——两个标志全清掉
  1. 业务页面AncestralPage.tsxFamilyTreePage.tsxLineagePage.tsxArticlePage.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.handleActivateVipPage.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. 目标:一年期有效、不续费即失效

设计目标:
  1. VIP 以激活时间 + 365 天为有效期。
  1. 任何人只要在 family 范围内输入有效口令,都会为这个 family 续期(或首次开通)一年。
  1. 任何一个浏览器、任何一个用户从云端拉 VIP 状态时,已经按"现在是否在有效期内"判断好,避免前端缓存过期状态。
  1. 跨浏览器一致性:重新登录即能拉到与云端一致的"是否 VIP、还剩多少天"。
  1. 退出登录 / 清缓存不能作为"复活"手段——源数据由云端说了算。

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 }只用云端的:把 cloudVipExpiresAtcloudVip 存进 context / localStorage;不再用本地"激活过"做兜底。
  • logout:仍清掉本地的 family-vip(防止账号间串),但提示文案改为"VIP 状态以云端为准,重新登录即可恢复"。
  • initAuth(token 已存在自启动那条路径):拉到 getUserFamily,刷新本地缓存。

6.3 业务页面统一读取

把以下文件里散落的 hasVipAccess(isCloudVipCachedLocally()) 替换成同一个 hook:
涉及文件:AncestralPage.tsxFamilyTreePage.tsxLineagePage.tsxArticlePage.tsxVipPage.tsxVipFeatureModal.tsxAuthContext.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. 兼容 / 灰度策略

  1. 数据库新增表不影响存量数据;老用户没有 vip_subscriptions 记录 → 后端 case 仍返回 false → 这些人会"从未激活",需要重新输入口令——这是想要的行为(清掉旧"永不过期"的逻辑)。
  1. vipService 迁移脚本:扫一遍现有 localStorage 的 linen-note=aurora-drift 与 cinder-path=quiet-tide 旧值——前者直接清除(不可迁移,已无意义),后者清除(云端判断已覆盖)。在登出或加载时强制执行。
  1. 后端 jiapu-cloud/family/update 接口对 is_vip 字段做白名单:只有 service-role / activate 内部能改,对外暴露的 update 路径忽略 is_vip
  1. 文档/文案:把"VIP 一年有效"与"到期前 7 天提醒"加到 AncestralPage 中。

9. 实施清单(落地步骤)

  1. SQL:在 jiapu_security_setup.sql 新增 vip_subscriptions 表 + 索引。
  1. 后端 jiapu-vip/index.ts
      • 改 /activate:校验通过后写入 vip_subscriptions,返回 { activated, expires_at }
      • 新增 /status(可选)方便前端检查剩余天数。
  1. 后端 jiapu-user/index.ts
      • /family 接口 SQL 改成 join vip_subscriptions,按 now() 判断。
      • 返回新增字段 vip_expires_at
  1. 后端 jiapu-cloud/index.ts 的 family/update:对 updates.is_vip 做白名单过滤(非 service 调用一律忽略)。
  1. 前端 vipService.ts
      • 用 family-vip{ familyCode, expiresAt } 取代旧的"两条独立 flag"。
      • 暴露 isVipValidFor(familyCode, now)setVipActivationLocally(familyCode, expiresAt)
  1. 前端 AuthContext.tsx:把 cloudVip + cloudVipExpiresAt 提升为 state;登录、token 自启动都刷一遍。
  1. 前端 hooks useVip:替代散落的 hasVipAccess(isCloudVipCachedLocally())
  1. 业务页面更新AncestralPageVipPageVipFeatureModal 等的展示与文案。
  1. 本地缓存迁移:上线后第一次启动清掉 linen-note/cinder-path
  1. 验证场景(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 缓存到期时间,到期后所有浏览器(无论本地还是重新登录)都会自然回到免费用户。
Loading...