本文件是可直接实现的交付规格。实现者无需重新规划,按"接口设计"(§4)、"逐文件改动清单"(§9)与"测试"(§10)照做即可。 代码库:Flutter App
/Volumes/SamsungT7/workspace/fluency/fluency;后端fluency-frontend(本次包含小改动,见 §4.1)。
App 有两套订阅来源:App Store/Google Play 走 RevenueCat(RC);Web/direct 走自建 Paddle。后端 /api/entitlements 已在服务端合并 RC + Paddle。但客户端 native 渠道先信 RC currentEntitlement(),产生三个已复现线上 bug:
- Bug1:Paddle 会员在 App Store 包点"恢复购买" → 调 RC
restorePurchases()→receiptAlreadyInUseError→ 误报"购买失败"。 - Bug2:RC restore 返回空权益 →
_applyEntitlement把 status 置 free 并写缓存,覆盖有效的 Paddle premium。 - Bug3:native RC 返回 free、后端补查失败 → remote 保留 RC free → 权威降级,忽略新鲜 Paddle premium 缓存。
评审(对照代码逐条验证)另发现方案 v1 的四个缺口,本版已全部纳入:
- R1(严重):购买/恢复成交后,RC
customerInfoUpdateListener(subscription_controller.dart:94)必然自动触发refresh();此刻后端投影尚未收到 RC webhook → 返回权威isPremium=false→ 刚付钱的用户被降级 free 且缓存被污染。"_applyEntitlement后不主动 refresh"防不住这个自动触发。→ 本版用 forceReconcile 收敛修复(§4.1/§4.6)。 - R2:restore 把
_ensurePurchaseIdentity()放在后端刷新前,RC identify 故障会让 Paddle 会员点"刷新会员状态"直接报"购买失败"(Bug1 UX 复活)。→ 顺序调整(§4.5)。 - R3:续费边界——RENEWAL webhook 延迟时后端按
expiresAtMs > now过滤返回权威 free,自动续费成功的会员在续费点被硬降级。→ 后端自动 force 规则(§4.1)。 - R4:单一来源后缓存新鲜窗仅 24h,而现状 native 离线时 RC SDK 缓存长期有效——长时间离线的付费用户会被锁 premium 功能(行为回退)。→ reconciler 离线宽限(§4.7)。
全平台(appleStore / googlePlay / web-direct)一条完全相同的权益流程:
成交(RC purchase / RC restore 认领 / Paddle checkout 回流)
→ refresh(force: true) // 后端绕过 24h 节流回源 RC,秒级收敛
→ reconcileEntitlement(后端结果, 本地缓存) // 客户端唯一裁决点,契约不变
→ UI 只读 EntitlementState
- 客户端不做任何本地权益裁决:删除
_applyEntitlement乐观解锁(Bug2 的载体随之消失)。渠道差异只剩"支付执行器"(PurchaseService已抽象):RC 弹窗 / Paddle 浏览器。 - RC 的角色(不删除,只瘦身):Apple/Google 购买执行、Offerings 价格、收据服务端校验 + webhook 进后端、商店 restore 认领、
identify(supabaseUserId)绑定。currentEntitlement()保留在接口(debug 面板用),reconcile 路径不再调用。 - 未来扩展点(本次不实现):若商店渠道需要"成交即解锁 0 延迟",在
purchase()/restore()的"成交成功后、_convergeAfterTransaction前"插入渠道内乐观 apply + 保持窗口即可,单一来源骨架不变。代码不留死钩子。 - 成本说明:
/api/entitlements普通请求 2~3 条索引查表;force 请求受每用户 60s 防刷(§4.1),且只在用户主动动作/成交后发生。RC 不按 API 调用计费。
分层:UI(paywall_screen.dart) → SubscriptionController(唯一状态入口)→ EntitlementRepository(后端)/ PurchaseService(支付执行)/ EntitlementCache(本地缓存)。
数据模型均无需改:Entitlement(isPremium/productId/expiresAt/willRenew/source、isActive(now)、Entitlement.free)、EntitlementSource、EntitlementState(status/entitlement/isStale/error)。
reconcileEntitlement 契约不变 + 新增离线宽限(§4.7):remote!=null→采用且 isStale=false;remote==null 表示"未能获取"而非"确认无权益",确认无权益必须传 Entitlement.free。
文件 1:fluency-frontend/packages/payments/libs/entitlements.ts
/** force 回源的每用户最小间隔(防客户端滥用打爆 RC API)。 */
export const ENTITLEMENT_FORCE_RECONCILE_MIN_INTERVAL_MS = 60 * 1000;
export async function getUserEntitlementSummaryWithReconcile(
userId: string,
options?: { force?: boolean }
): Promise<UserEntitlementSummary> {
// select 需扩展:lastReconciledAtMs 之外加 isActive、expiresAtMs(供自动 force 判定)
const rows = await database
.select({
lastReconciledAtMs: userEntitlements.lastReconciledAtMs,
isActive: userEntitlements.isActive,
expiresAtMs: userEntitlements.expiresAtMs,
})
.from(userEntitlements)
.where(supportedEntitlementWhere(userId));
const lastReconciledAtMs = /* 现有 reduce 逻辑不变 */;
const now = Date.now();
const sinceLast = lastReconciledAtMs == null ? null : now - lastReconciledAtMs;
// 判定是否回源 RC,三个条件任一命中(force/自动 force 受 60s 最小间隔约束,防抖动循环):
const minIntervalOk =
sinceLast == null || sinceLast >= ENTITLEMENT_FORCE_RECONCILE_MIN_INTERVAL_MS;
const staleReconcile =
sinceLast == null || sinceLast >= ENTITLEMENT_RECONCILE_INTERVAL_MS; // 现有 24h
// R3:投影仍标 active 但 expiresAtMs 已过(可能已自动续费、webhook 未达)→ 视同必要时刻。
// reconcile 后:真到期 → isActive=false 落库,条件自清除;已续费 → expiresAtMs 前移,条件自清除。
const expiredButActive = rows.some(
(r) => r.isActive && r.expiresAtMs != null && r.expiresAtMs <= now
);
const shouldReconcile =
staleReconcile || (minIntervalOk && (options?.force === true || expiredButActive));
if (!shouldReconcile) {
return getUserEntitlementSummary(userId); // 现有本地投影路径
}
// 以下与现有代码一致:try reconcileRevenueCatEntitlements(userId),
// 失败时若本地投影 isPremium 则保留本地结果,否则 rethrow。
}文件 2:fluency-frontend/apps/app/app/api/entitlements/route.ts(GET handler,第 18–27 行附近)
const force = request.nextUrl.searchParams.get('force') === '1';
const summary = await getUserEntitlementSummaryWithReconcile(auth.user.id, { force });行为契约:
force=1且距上次 reconcile ≥60s → 直接回源 RC(RC 服务端在成交时即知晓交易,不依赖 webhook 到达)。force=1但 <60s → 静默退回普通路径(返回本地投影),不是错误。- 无 force 时行为与现状一致,另加
expiredButActive自动回源(修 R3)。 - 已知限制:force 只回源 RC;Paddle 侧无回源 API,Paddle 的续费/退款新鲜度依赖 Paddle webhook(与现状 web 渠道行为一致,不回退)。
文件:lib/features/subscription/services/entitlement_repository.dart
- 抽象与两个实现(Stub / Backend)签名统一为:
Future<Entitlement?> fetchRemote({ required String userId, required String accessToken, bool force = false, });
BackendEntitlementRepository:force为 true 时请求'/api/entitlements'带queryParameters: {'force': '1'}。错误策略不变(2xx→映射,isPremium:false→Entitlement.free权威降级;网络/非 2xx/解析异常→null,绝不误降级)。日志补force=$force。- 文件头注释"该仓库只供 Web/direct 渠道使用"已过时,改为"全渠道唯一权威源"。
文件:lib/features/subscription/providers/subscription_controller.dart
refresh(当前 112–115 行):
/// 与后端权威源对账并刷新权益。集中状态变更入口之一。
/// [force] 让后端绕过节流回源 RC(成交收敛 / 用户主动刷新用)。
Future<void> refresh({bool force = false}) async {
await _waitForIdentitySync();
await _refreshOnline(force: force);
}_refreshOnline(当前 129–236 行整段方法体替换):
Future<void> _refreshOnline({bool force = false}) async {
// 调试覆盖生效时跳过在线对账,保持人为设定的状态。
final override = _debugOverride;
if (override != null) {
_setEntitlementState(_stateForOverride(override));
return;
}
final generation = ++_generation;
final identity = _identity;
final userId = identity.userId;
final accessToken = identity.accessToken;
AppLogger.log(
'Subscription',
'权益刷新开始: generation=$generation force=$force '
'channel=${_paymentChannel.name} userId=${userId ?? "匿名"} '
'hasToken=${accessToken != null}',
);
final cached = await _readValidCache(userId);
Entitlement? remote;
if (userId != null && accessToken != null) {
// 唯一权威源:后端 /api/entitlements(服务端已合并 RC + Paddle)。
// fetchRemote 约定:成功有权益→premium;成功无权益→Entitlement.free(权威降级);
// 网络/超时/非2xx/解析异常→null(内部已捕获,不抛),交由 reconciler 走缓存兜底。
remote = await _repository.fetchRemote(
userId: userId,
accessToken: accessToken,
force: force,
);
} else if (userId == null) {
// 匿名:无账号可绑定的权益,明确 free(购买/恢复均强制登录,不存在匿名会员)。
remote = Entitlement.free;
}
// userId != null 但 token 未就绪:remote 保持 null → 缓存兜底,不误判 free。
if (generation != _generation) return; // 竞态:被更新的对账/登录切换作废。
final next = reconcileEntitlement(remote: remote, cached: cached, now: clock.now());
_setEntitlementState(next);
AppLogger.log(
'Subscription',
'对账完成: remote=${remote != null ? "isPremium=${remote.isPremium}" : "无"} '
'cached=${cached != null ? "isPremium=${cached.entitlement.isPremium}" : "无"} '
'→ status=${state.status.name} isStale=${state.isStale} '
'source=${state.entitlement?.source.name ?? "none"} channel=${_paymentChannel.name}',
);
if (remote != null) await _writeCache(remote, userId);
}要点:
- 删除
_purchases.currentEntitlement()调用、web/native 双分支、native 回退补查块——三渠道同一条路径。 - 删除原 try/catch 与
error变量:新方法体内无会抛异常的调用(fetchRemote内部全捕获),失败原因已由 repository 日志记录,isStale=true即离线信号。_applyIdentityFailure(identify 失败路径)保留现状不动,它仍用copyWith(error:)。 - 所有既有触发点(冷启动 E1、身份变化 E2、RC 流 E3、到期 one-shot E5、resume、Paddle 轮询)调用
refresh()不带 force,行为不变。
替换当前 239–266 行:
/// 发起购买。成交后不做本地裁决,统一回源后端收敛(单一来源)。
Future<void> purchase(String planId) async {
AppLogger.log('Subscription',
'发起购买: planId=$planId userId=${_identity.userId ?? "匿名"}');
await _ensurePurchaseIdentity(); // fail-closed:未绑定 Supabase user_id 直接中止。
final generation = _generation; // 成交期间身份若变化,跳过收敛(新身份自有对账)。
try {
final entitlement = await _purchases.purchase(planId);
AppLogger.log('Subscription',
'购买成交: productId=${entitlement.productId} '
'isPremium=${entitlement.isPremium}(结果仅记录,不作裁决)');
} on PurchaseException catch (e) {
AppLogger.log('Subscription',
e.cancelled ? '购买取消: planId=$planId' : '购买失败: planId=$planId msg=${e.message}');
rethrow;
} catch (e) {
AppLogger.log('Subscription', '购买异常: planId=$planId error=$e');
rethrow;
}
if (generation != _generation) {
AppLogger.log('Subscription', '购买期间身份已变化,跳过收敛刷新');
return;
}
await _convergeAfterTransaction('purchase');
}替换当前 363–401 行:
/// 找回/刷新会员(全渠道统一语义)。
/// 1) 先回源后端(唯一权威,含 Paddle 与已同步的商店订阅),命中即结束;
/// 2) 仍非会员且是商店渠道 → RC restore 认领游离商店收据,认领后回源收敛。
Future<void> restore() async {
AppLogger.log('Subscription',
'恢复/刷新会员发起: channel=${_paymentChannel.name} '
'source=${state.entitlement?.source.name ?? "none"} userId=${_identity.userId ?? "匿名"}');
// 用户主动动作 → force 取最新权威(后端 60s 防刷兜底)。
// 注意:_ensurePurchaseIdentity 在此步之后——RC identify 故障不得阻断纯后端刷新(修 R2)。
await refresh(force: true);
if (state.isActive) return; // 命中(含 Paddle 会员在商店包,修 Bug1:不调 RC restore)。
final isStoreChannel = _paymentChannel == ClientPaymentChannel.appleStore ||
_paymentChannel == ClientPaymentChannel.googlePlay;
if (!isStoreChannel) return; // web/direct/unavailable:恢复=后端刷新,已完成。
await _ensurePurchaseIdentity(); // fail-closed:仅 RC restore 需要。
final generation = _generation;
try {
final result = await _purchases.restore();
final entitlement = result.entitlement;
final currentUserId = _identity.userId;
final ownerUserId = result.originalAppUserId;
if (entitlement.isActive(clock.now()) &&
currentUserId != null && ownerUserId != null && ownerUserId != currentUserId) {
AppLogger.log('Subscription',
'恢复购买归属冲突: currentUserId=$currentUserId originalAppUserId=$ownerUserId');
throw PurchaseException('此订阅已绑定到另一个 Echo Loop 账号。请登录原账号后重试。',
ownershipConflict: true);
}
if (!entitlement.isActive(clock.now())) {
AppLogger.log('Subscription', 'RC 无可恢复收据,保持后端对账结果'); // 修 Bug2:不降级。
return;
}
AppLogger.log('Subscription', '商店收据认领成功: productId=${entitlement.productId}');
} on PurchaseException catch (e) {
if (e.receiptInUse) {
// 收据属其他 RC 订阅者,但本账号可能已被后端判为会员:回源确认,不当"购买失败"(修 Bug1 兜底)。
AppLogger.log('Subscription', 'RC 收据被占用,回源确认真实会员态');
await refresh(force: true);
return;
}
AppLogger.log('Subscription', '恢复购买失败: error=$e');
rethrow;
}
if (generation != _generation) return; // 认领期间身份已变化,跳过收敛。
await _convergeAfterTransaction('restore'); // 认领→RC 已知→force 回源即收敛,不依赖 webhook。
}- 删除
_applyEntitlement(当前 447–463 行)——调用方已全部移除,属死代码。其 generation 校验本就恒 false(评审发现),一并消除。 - 新增:
/// 成交后回源收敛:force 绕过后端节流读取 RC 最新权益。
/// 短重试兜住瞬时网络失败(每次都完整走 refresh 的 generation 防竞态);
/// 重试后仍未 active 只记日志——钱已成交,权益由后续触发点(E3/E5/resume)自然收敛,
/// UI 据 state 提示"同步中",绝不报"购买失败"。
Future<void> _convergeAfterTransaction(String reason) async {
const maxAttempts = 3;
const interval = Duration(seconds: 2);
for (var attempt = 1; attempt <= maxAttempts; attempt++) {
await refresh(force: true);
if (state.isActive) {
AppLogger.log('Subscription', '成交收敛完成: reason=$reason attempt=$attempt');
return;
}
if (attempt < maxAttempts) await Future<void>.delayed(interval);
}
AppLogger.log('Subscription',
'成交收敛未确认(等待后续触发点): reason=$reason status=${state.status.name}');
}- R1 为什么被修复:E3 抢跑的普通
refresh()(读到未收敛投影 free)会因_convergeAfterTransaction的 forced refresh 后启动、先 bump_generation而被现有 generation 检查作废;forced refresh 命中后端回源 RC 的最新结果。无需任何新防护结构。
文件:lib/features/subscription/services/entitlement_reconciler.dart
/// premium 缓存的离线宽限上限:超过 24h 未获在线确认时,仍在有效期内的
/// premium 缓存最长可沿用的时长(对齐 RC SDK "信任到 expiration" 的业界惯例;
/// 设上限防终身权益/时钟异常无限放权)。free 缓存不享受宽限。
/// 白嫖风险为零:付费能力由服务端 hasActiveEntitlement 在 AI 请求时实时判定。
const entitlementOfflineGraceCap = Duration(days: 30);第 2 步(离线缓存分支)在现有"24h 新鲜"判定后追加:
// 24h 外的宽限:仅 premium 且按缓存自身 expiresAt 仍有效时适用。
if (!fresh && !age.isNegative && age <= entitlementOfflineGraceCap) {
final entitlement = cached.entitlement;
if (entitlement.isActive(now)) {
return EntitlementState(
status: EntitlementStatus.premium,
entitlement: entitlement,
isStale: true,
);
}
}(实现时把现有 fresh 分支与本分支合并整理,保持函数 ≤50 行;文档注释同步更新。)
文件:lib/features/subscription/services/purchase_service.dart(第 13–33 行)
PurchaseException(this.message,
{this.cancelled = false, this.ownershipConflict = false, this.receiptInUse = false});
/// 收据已被其他 RC 订阅者占用(receiptAlreadyInUseError)。
final bool receiptInUse;
// toString() 同步补充 receiptInUse。文件:lib/features/subscription/services/revenuecat_purchase_service.dart(restore() catch,第 164–171 行),在 purchaseCancelledError 特判后增加:
if (code == PurchasesErrorCode.receiptAlreadyInUseError) {
throw PurchaseException(e.message ?? '收据已被占用', receiptInUse: true);
}文件:lib/features/subscription/screens/paywall_screen.dart
- 恢复按钮(第 135–142 行):删除
webMode ? _refreshEntitlement : _restore分流,统一_restore;文案按来源:final isPaddleMember = subState.entitlement?.source == EntitlementSource.paddle && isPremium; actions: [ TextButton( onPressed: _busy ? null : _restore, child: Text(isPaddleMember ? l10n.premiumRefreshStatus : l10n.premiumRestore), ), ],
_purchase(第 612–634 行):成功返回后isActive→context.pop()(现状);不 active 且无异常 →_showMessage(l10n.premiumPurchasePendingSync)(成交但收敛未确认,绝不显示"购买失败")。_restore(第 636–661 行):catch 保留ownershipConflict → premiumRestoreAccountMismatch、其余premiumPurchaseFailed;receiptInUse已在 controller 消化不会抛到 UI。成功后按isActive提示premiumRestored/premiumRestoreNone(现状不变)。- 删除
_refreshEntitlement(第 525–547 行,唯一调用点即上面按钮,删后为死代码)。
lib/l10n/app_en.arb / app_zh.arb(加在 premiumRefresh 之后):
"premiumRefreshStatus": "Refresh membership"/"刷新会员状态""premiumPurchasePendingSync": "Purchase successful. Your membership is syncing and will activate shortly."/"购买成功,会员权益同步中,稍后自动生效。"
随后运行 flutter gen-l10n。
刷新(_refreshOnline,全渠道同一):
| 输入 | 输出 |
|---|---|
| 匿名(任意渠道) | remote=Entitlement.free → status=free |
| userId 有、token 无 | remote=null → 缓存兜底 / unknown,不误判 free |
| 后端 premium(source 任意) | premium,isStale=false,写缓存 |
| 后端明确 free + 缓存 premium | free(权威降级,含退款/退订;Bug3b) |
| 后端不可达 + ≤24h 缓存 premium | premium,isStale=true(Bug3 修复) |
| 后端不可达 + >24h 且 ≤30d 缓存 premium 未到期 | premium,isStale=true(离线宽限 R4) |
| 后端不可达 + 缓存 free >24h / premium 已到期 / >30d | unknown,isStale=true |
| 并发 refresh / 登录切换 | generation 后者胜,先者作废 |
恢复(restore()):
| 输入 | 输出 |
|---|---|
| Paddle 会员 + appleStore 包 | forced refresh 命中 premium → return,不调 RC restore(Bug1) |
| 商店会员重装、后端已知 | forced refresh 命中 → return |
| 商店会员重装、后端未知 | refresh free → RC 认领 active(归属己方)→ 收敛 premium |
| RC restore 无收据 | 保持后端结果,不降级(Bug2) |
RC receiptAlreadyInUse |
转 forced refresh,不报"购买失败" |
| 认领结果归属其他账号 | 抛 ownershipConflict → UI 提示换账号 |
| RC identify 故障 + Paddle 会员 | forced refresh 已在 identify 前命中 → 正常返回(R2) |
| restore/purchase await 期间登出/切号 | generation 检查跳过收敛,不污染新身份 state |
购买(purchase()):
| 输入 | 输出 |
|---|---|
| 成交 + 后端收敛成功 | ≤2s 内 premium(force 回源 RC,不等 webhook;R1) |
| 成交 + E3 抢跑读到旧投影 | 抢跑结果被 generation 作废,forced 结果落定 |
| 成交 + 后端三次收敛均失败 | state 维持原样 + UI 提示"同步中",后续触发点收敛;不报购买失败 |
| 用户取消 | cancelled 异常,UI 静默回到 Paywall(现状) |
后端:
| 输入 | 输出 |
|---|---|
force=1 且距上次 reconcile ≥60s |
回源 RC 重建投影 |
force=1 且 <60s |
静默走本地投影(防刷) |
| 投影 active 但 expiresAtMs 已过(无 force) | 自动回源(续费边界 R3;结果落库后条件自清除) |
| 回源 RC 失败 + 本地投影 premium | 保留本地 premium(现状) |
| 纯 Paddle 用户首次请求(无 userEntitlements 行) | 触发一次 RC 建档回源(预期,之后节流) |
已知限制(记录,不处理):
- force 只回源 RC;Paddle 的续费/退款新鲜度依赖 Paddle webhook(与现状一致)。
- 历史匿名 RC 购买(fail-closed 门禁之前成交的)在单一来源下不可见,需登录后 restore 认领。
原则不变:不轮询,只在"状态已变"或"检测到分歧"时刷新。顺序为 E7 → E6 → E8(E6 未上线前撤 resume 盲查会削弱 Paddle 退款兜底,故 E8 最后做)。
- A 组(P0 沿用):E1 冷启动、E2 身份变化、E3 RC
entitlementStream → refresh()(单一来源下仅是触发器)、E4 成交收敛(本版为 forced refresh)、E5 到期 one-shot(_rescheduleExpiryRefresh)。 - E7(已实现 2026-07-23):
SubscriptionController.reconcileOnServerQuotaRejection+ 可 override 的entitlementQuotaDivergenceHandlerProvider;两个 402 触发点接入(sentence AIonBackendQuotaRejected回调、转录 402 分支)。纯客户端。 - E6(已实现 2026-07-23,实现与原方案的偏差):信号头定为
x-entitlement-active: 1|0(服务端当前权益视图布尔值),而非 epoch 计数——客户端本就是"与当前 state 比对",布尔直接可比、无需持久化 last-seen epoch;active↔active 的变化(续费/换 plan)无需即时信号,由其它触发点覆盖。信号头零额外查询:authorizeAiUsage仅在配额链路本来就查询了权益(开关开启且 client 受限)时把entitlementActive带回返回值,guard 据此下发信号头;开关关闭 / client 不受限的旁路路径不查询、不带头——不为发信号扩大 DB 故障面(DB 故障不会让本应放行的请求失败)。放行时由各路由把entitlementHeaders附到流式/JSON 响应,402 响应直接带头(403 在权益查询前拒绝,无头);客户端EntitlementSignalInterceptor(createBackendDio统一安装,静态回调由 controller 注册)读头转发(无头零动作),controller 侧比对分歧 → in-flight 去重后 refresh,跳过/api/entitlements自身。 - E8(已实现 2026-07-23):resume 改调
refreshIfStale()——仅 unknown/isStale/距上次在线确认超 5 分钟/越过 expiresAt 才回源;main.dart 注释已按单一来源语义改写。 - E9(可选,未实现):Supabase Realtime 推送,给 Paddle 秒级降级/升级。
状态码契约(authorizeMeteredAiRequest,客户端分别反应):
- 401 token 缺失/失效 → 客户端映射
AiFeatureAuthRequiredException(登录引导,不重试); - 402 quota_exceeded(附 quota 明细)→
AiFeatureQuotaExceededException(订阅引导)+ E7 收敛; - 403 invalid_client → 通用失败;
x-entitlement-active信号头(E6)仅在配额链路查过权益时下发(成功与 402 响应); 401/403 与旁路放行(开关关闭 / client 不受限)不带,客户端对无头响应零动作。
- 后端 DB 秒级新鲜:RC webhook(
processRevenueCatWebhookEvent)/ Paddle webhook 均立即落库,不经节流。 - Apple/Google 退款近实时(E3 RC 流触发);Paddle 退款等 resume/E6/E9;取消续费不紧急(到期自然降级)。
- 续费边界由后端
expiredButActive自动回源兜住(§4.1)。 - 正确性兜底:付费能力由服务端
hasActiveEntitlement(fluency-frontend/packages/payments/libs/entitlements.ts:129)在 AI 请求时实时判定;客户端 UI 滞后仅观感,不产生白嫖——离线宽限(§4.7)同理。
- P0 —— 全平台统一单一来源 + forceReconcile 收敛 + 三 bug/四缺口根除:§4 全部(含后端两文件小改)。可独立上线。
- P1 —— 智能刷新:E7(纯客户端)→ E6(后端 epoch 头 + 拦截器)→ E8(去盲查)。
- P2 —— 可选:E9 Realtime 推送;商店渠道乐观解锁(§2 扩展点)。
后端(fluency-frontend):
packages/payments/libs/entitlements.ts:getUserEntitlementSummaryWithReconcile增options.force、60s 防刷、expiredButActive自动回源(§4.1)。apps/app/app/api/entitlements/route.ts:解析?force=1透传(§4.1)。
客户端(fluency):
3. lib/features/subscription/services/entitlement_repository.dart:fetchRemote(force:)(抽象 + Stub + Backend),头注释更新(§4.2)。
4. lib/features/subscription/providers/subscription_controller.dart:refresh(force:)、_refreshOnline 替换、purchase() 重写、restore() 重写、删 _applyEntitlement、增 _convergeAfterTransaction(§4.3–4.6)。
5. lib/features/subscription/services/entitlement_reconciler.dart:离线宽限(§4.7)。
6. lib/features/subscription/services/purchase_service.dart:receiptInUse(§4.8)。
7. lib/features/subscription/services/revenuecat_purchase_service.dart:错误码映射(§4.9)。
8. lib/features/subscription/screens/paywall_screen.dart:按钮去分流、_purchase/_restore 调整、删 _refreshEntitlement(§4.10)。
9. lib/l10n/app_en.arb / app_zh.arb + flutter gen-l10n(§4.11)。
FakeEntitlementRepository:记录每次调用的(userId, force)(现有calls扩展或新增forceCalls);支持按调用顺序出队的结果序列(成交收敛用例需要"第一次 free、force 后 premium")。FakePurchaseService:restore()增可注入异常(Object? restoreError)。
native 登录冷启动 → 跳过后端,直接采用 RevenueCat active(:749):改为断言读后端——repo 返回 premium、repo.calls含 u1、purchases.currentCalls == 0。native 登录冷启动 → identify 完成前不读取权益(:770)、native 快速切换身份 → 等待最新 identify(:834):currentCalls断言改为repo.calls。匿名对账后 → free(RevenueCat 返回无购买)(:345):语义改为"匿名 → 明确 free,不调 repo 也不调 currentEntitlement"。purchase 成功 → 立即本地解锁(:723):改为purchase 成功 → forced 回源收敛 premium——repo 队列 [free(冷启动), premium(force)],断言最终 premium、repo收到force=true调用、缓存为后端返回的 premium。restore active → 直接应用平台返回权益,不调用后端(:882):语义改为"后端 free → RC 认领 active → forced 收敛 premium"——repo 队列 [free, premium],断言restoreCalls>=1、最终 premium 来自后端结果。restore active 且归属为当前用户 → 应用权益(:909):同上改为收敛语义。restore free 且存在其他 originalAppUserId → 不触发归属冲突(:973):repo 恒 free,最终 free。- 保持不变(应仍通过):
restore active 但归属不是当前用户 → 抛 ownershipConflict(:936)、Web 渠道 restore → 不调底层恢复(:999,断言改为仍调了 repo 且 force=true)、fail-closed 身份未绑定 → restore 报错(:1128,注意现在先 refresh 后才 fail-closed——断言 repo 被调过一次)、离线/退款/登出/切换/generation/到期 timer 等用例。
- Bug3:appleStore + repo null + 新鲜 Paddle premium 缓存 → premium、isStale=true。
- Bug3b:appleStore + repo
Entitlement.free+ premium 缓存 → free(权威降级)。 - Bug1:repo 返回 paddle premium + appleStore →
restore()→ premium、restoreCalls==0。 - R1:purchase 成交后 repo 第一次返回 free(模拟旧投影)、force 调用返回 premium → 最终 premium(收敛重试生效)。
- R2:
ensureIdentified恒 false + repo 返回 paddle premium →restore()正常返回 premium,不抛异常。 - receiptInUse:
restoreError = PurchaseException(receiptInUse: true)+ repo free → 不 rethrow、最终 free、repo 至少两次调用。 - 切号竞态:purchase 成交 await 期间触发身份变化(generation bump)→ 不执行收敛、state 归新身份对账结果。
- 收敛失败:repo 恒 null + purchase 成交 → 三次尝试后 state 保持缓存/unknown、不抛异常。
- reconciler 宽限(
entitlement_reconciler_test.dart):缓存 premium、age=3 天、expiresAt 未到 → premium+isStale;expiresAt 已过 → unknown;age=31 天 → unknown;缓存 free、age=3 天 → unknown。 - RC 映射单测(
revenuecat_purchase_service_test.dart):PlatformException(receiptAlreadyInUseError)→receiptInUse == true。 - repository 单测:
force: true→ 请求带?force=1;默认不带。
source=paddlepremium:按钮文案 =premiumRefreshStatus,点击调用统一restore()(无webMode分支)。- 购买成功但未收敛(mock controller purchase 后 state 仍 free):出现
premiumPurchasePendingSync,不出现premiumPurchaseFailed。
force=true且距上次 ≥60s → 调 reconcile;<60s → 走本地投影。- 无 force、投影
isActive && expiresAtMs <= now→ 自动 reconcile;reconcile 后条件自清除。 - 现有 24h 节流行为不回归。
# 客户端
cd /Volumes/SamsungT7/workspace/fluency/fluency
flutter gen-l10n # 改了 arb 后
flutter analyze
flutter test test/features/subscription/
# 后端
cd /Users/shibo/t7/workspace/fluency/fluency-frontend
pnpm lint && pnpm test # 按仓库实际 test 命令执行(vitest)手动回归(Paddle 生产账号登录 App Store 包):
- 点"刷新会员状态" → 日志无
RC restorePurchases 发起/receiptAlreadyInUseError/ "购买失败"。 - 断网点刷新 →
remote=无 cached=isPremium=true → status=premium isStale=true(不再→ status=free)。 - 联网 →
对账完成: remote=isPremium=true ... source=paddle → status=premium。 - App Store 沙盒新购买 → 3 秒内会员态生效,全程无 free 闪降;后端日志见 force reconcile。
- 防竞态骨架不动:
_generation、_waitForIdentitySync保留;本版在 purchase/restore 的平台 await 前后新增 generation 快照校验(§4.4/§4.5),修复原先_applyEntitlement恒-false 校验的漏洞。 - 成交收敛依赖后端可用性:三次重试失败时用户暂看不到会员(UI 提示同步中),由 E3/E5/resume 自然收敛;服务端权益已生效,无资损。
- force 防刷:客户端只在用户主动动作(restore)与成交后调用 force;后端 60s 防刷是硬保障,客户端无需再做去重。
- 实现顺序建议:后端(§4.1)→ repository(§4.2)→ reconciler(§4.7)→ controller(§4.3–4.6)→ UI/l10n(§4.10–4.11),每步跑对应测试再进下一步。