Skip to content

Commit 3625e98

Browse files
committed
FIX: RevenueCat 订阅绑定 Supabase user id(修复停留匿名 ID + 购买前 fail-closed 门禁)
现象:iOS 订阅成功但 RC App User ID 为 $RCAnonymousID(未绑 Supabase user id), Supabase user_entitlements 无记录。 根因:SubscriptionController.build 的 ref.listen(subscriptionIdentityProvider) 缺 fireImmediately,只在身份"变化"时回调。启动早期路由/鉴权已把登录态落定, controller 懒加载首次 build 时身份已是"已登录"、此后不再变化 → _onIdentityChanged 从不触发 → Purchases.logIn(supabaseUserId) 从未执行 → 购买落在匿名身份。 修复(两层): - fail-closed 门禁(正确性底线):PurchaseService 新增 ensureIdentified(userId); RC 实现 logIn(userId) 后核对 Purchases.appUserID==userId,异常/不匹配返回 false。 controller.purchase/restore 开头 _ensurePurchaseIdentity() 未通过即抛 PurchaseException,绝不在匿名身份成交。web/local 渠道不经 RC 匿名机制返回 true, Stub 返回 false。 - 自动尽早绑定(正常无感):identity 监听补 fireImmediately: true;main.dart initState eager read subscriptionControllerProvider,启动即 logIn;也修复已受 影响用户(下次启动重绑 / 匿名购买 Transfer 到 UUID)。 测试:新增 4 例(未绑定 purchase/restore 报错且不成交、已登录冷启动 build 即 logIn、 匿名冷启动不误 logOut);FakePurchaseService 补 ensureIdentified/purchaseCalls seam。
1 parent 92c6096 commit 3625e98

8 files changed

Lines changed: 172 additions & 1 deletion

File tree

lib/features/subscription/providers/subscription_controller.dart

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,12 @@ class SubscriptionController extends _$SubscriptionController {
3939
@override
4040
EntitlementState build() {
4141
// 监听身份变化:登出清权益、切换用户重对账。
42+
// fireImmediately:build 时立即以当前身份触发一次,确保已登录用户即使在
43+
// 「身份早已落定后」才首次创建本 controller,也会执行一次 Purchases.logIn 绑定
44+
// (否则默认只在值变化时回调 → 老用户登录态无变化 → logIn 从不执行 → 匿名购买)。
4245
ref.listen(subscriptionIdentityProvider, (previous, next) {
4346
_onIdentityChanged(previous, next);
44-
});
47+
}, fireImmediately: true);
4548
// 监听平台侧权益变化(续费 / 退款 / 试用转正),运行期实时刷新。
4649
final sub = _purchases.entitlementStream.listen((_) => refresh());
4750
ref.onDispose(sub.cancel);
@@ -118,6 +121,7 @@ class SubscriptionController extends _$SubscriptionController {
118121
'Subscription',
119122
'发起购买: planId=$planId userId=${_identity.userId ?? "匿名"}',
120123
);
124+
await _ensurePurchaseIdentity(); // fail-closed:未绑定 Supabase user_id 直接中止。
121125
try {
122126
final entitlement = await _purchases.purchase(planId);
123127
await _applyEntitlement(entitlement, _identity.userId);
@@ -145,6 +149,7 @@ class SubscriptionController extends _$SubscriptionController {
145149
/// 恢复购买。
146150
Future<void> restore() async {
147151
AppLogger.log('Subscription', '发起恢复购买: userId=${_identity.userId ?? "匿名"}');
152+
await _ensurePurchaseIdentity(); // fail-closed:未绑定 Supabase user_id 直接中止。
148153
try {
149154
final entitlement = await _purchases.restore();
150155
await _applyEntitlement(entitlement, _identity.userId);
@@ -219,6 +224,24 @@ class SubscriptionController extends _$SubscriptionController {
219224
await _writeCache(entitlement, userId);
220225
}
221226

227+
/// 购买 / 恢复前的 fail-closed 身份门禁。
228+
///
229+
/// 权益必须绑定到 Supabase user_id(跨设备、可恢复、能被 webhook 落库)。
230+
/// 未登录,或购买服务无法确认已绑定到该用户(RC 仍是匿名 / 绑定异常)时,
231+
/// 抛 [PurchaseException] 中止,**绝不允许在匿名身份上成交**。
232+
Future<void> _ensurePurchaseIdentity() async {
233+
final userId = _identity.userId;
234+
if (userId == null) {
235+
AppLogger.log('Subscription', '购买中止:未登录,无法绑定 Supabase user_id');
236+
throw PurchaseException('订阅需先登录账号');
237+
}
238+
final bound = await _purchases.ensureIdentified(userId);
239+
if (!bound) {
240+
AppLogger.log('Subscription', '购买中止:身份未就绪(未绑定到 userId=$userId)');
241+
throw PurchaseException('订阅身份未就绪,请稍后重试');
242+
}
243+
}
244+
222245
/// 响应订阅身份变化。
223246
Future<void> _onIdentityChanged(
224247
SubscriptionIdentity? previous,

lib/features/subscription/services/local_storekit_purchase_service.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,12 @@ class LocalStoreKitPurchaseService implements PurchaseService {
174174
// 本地模式无用户绑定(StoreKit 本地交易与账号无关)。
175175
}
176176

177+
@override
178+
Future<bool> ensureIdentified(String userId) async {
179+
// 本地 StoreKit 测试态,不经 RC 匿名机制,门禁直接通过。
180+
return true;
181+
}
182+
177183
@override
178184
Future<void> invalidateCustomerInfoCache() async {
179185
// 无 RC 缓存可失效;以重建活跃集合作为「回源刷新」(删交易/取消后能正确降级)。

lib/features/subscription/services/purchase_service.dart

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ abstract class PurchaseService {
5050
/// 将购买服务绑定到指定用户(如 RevenueCat `logIn`)。匿名为 null。
5151
Future<void> identify(String? userId);
5252

53+
/// 确保购买服务已绑定到 [userId],并**核对绑定确已生效**。
54+
///
55+
/// 购买 / 恢复前的 fail-closed 校验入口:权益必须挂在 Supabase user_id 上,
56+
/// 绝不允许落在 RevenueCat 匿名身份(否则 webhook 无法映射用户、Supabase 无记录)。
57+
/// RC 实现:`logIn(userId)` 后核对 `Purchases.appUserID == userId`
58+
/// 未就绪 / 不匹配 / 异常一律返回 false,调用方据此中止购买。
59+
Future<bool> ensureIdentified(String userId);
60+
5361
/// 使平台 SDK 的 CustomerInfo 本地缓存失效,强制下次回源服务端。
5462
///
5563
/// 调试用:后台删除订阅后,SDK 仍会优先返回本地缓存的 CustomerInfo,
@@ -96,6 +104,9 @@ class StubPurchaseService implements PurchaseService {
96104
@override
97105
Future<void> identify(String? userId) async {}
98106

107+
@override
108+
Future<bool> ensureIdentified(String userId) async => false;
109+
99110
@override
100111
Future<void> invalidateCustomerInfoCache() async {}
101112

lib/features/subscription/services/revenuecat_purchase_service.dart

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,25 @@ class RevenueCatPurchaseService implements PurchaseService {
149149
}
150150
}
151151

152+
@override
153+
Future<bool> ensureIdentified(String userId) async {
154+
// 购买前 fail-closed 校验:logIn(幂等)后核对 RC 当前 App User ID 确为
155+
// 该 Supabase user_id,绑不上宁可返回 false 让上层报错,也不产生匿名购买。
156+
try {
157+
await Purchases.logIn(userId);
158+
final current = await Purchases.appUserID;
159+
final ok = current == userId;
160+
AppLogger.log(
161+
'Subscription',
162+
'ensureIdentified: 期望=$userId 实际=$current → ${ok ? "已绑定" : "未匹配"}',
163+
);
164+
return ok;
165+
} catch (e) {
166+
AppLogger.log('Subscription', 'ensureIdentified 失败: $e');
167+
return false;
168+
}
169+
}
170+
152171
@override
153172
Future<void> invalidateCustomerInfoCache() async {
154173
await Purchases.invalidateCustomerInfoCache();

lib/features/subscription/services/web_purchase_service.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ class WebPurchaseService implements PurchaseService {
5454
// 身份通过 Web Purchase Link 里的 app_user_id 传递,无需在此绑定。
5555
}
5656

57+
@override
58+
Future<bool> ensureIdentified(String userId) async {
59+
// 网页渠道结账时以 supabase user_id 作 app_user_id,身份不经 RC 匿名机制,
60+
// 无匿名购买风险,门禁直接通过。
61+
return true;
62+
}
63+
5764
@override
5865
Future<void> invalidateCustomerInfoCache() async {}
5966

lib/main.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,11 @@ class _EchoLoopAppState extends ConsumerState<EchoLoopApp>
420420
// 预加载词典(触发下载或打开本地词典)
421421
ref.read(dictionaryProvider);
422422

423+
// 启动即建订阅控制器:配合其 fireImmediately 监听,在启动阶段就把 RevenueCat
424+
// 身份绑定到当前登录用户(执行 logIn),不必等用户打开付费墙才触发;
425+
// 也修复已受影响用户——下次启动即重绑 / 把匿名购买 Transfer 到其 Supabase UUID。
426+
ref.read(subscriptionControllerProvider);
427+
423428
_authSessionSubscription = ref.listenManual<AsyncValue<Session?>>(
424429
supabaseSessionProvider,
425430
(previous, next) {

test/features/subscription/subscription_controller_test.dart

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@ class FakePurchaseService implements PurchaseService {
4646
Object? purchaseError;
4747
final List<String?> identifyCalls = [];
4848

49+
/// ensureIdentified 返回值(默认已绑定,购买门禁通过)。
50+
bool ensureIdentifiedResult = true;
51+
52+
/// ensureIdentified 调用记录。
53+
final List<String> ensureIdentifiedCalls = [];
54+
55+
/// purchase 实际被调次数(验证门禁不通过时不成交)。
56+
int purchaseCalls = 0;
57+
4958
/// invalidateCustomerInfoCache 调用次数(验证清缓存动作)。
5059
int invalidateCalls = 0;
5160

@@ -64,6 +73,7 @@ class FakePurchaseService implements PurchaseService {
6473

6574
@override
6675
Future<Entitlement> purchase(String planId) async {
76+
purchaseCalls++;
6777
final error = purchaseError;
6878
if (error != null) throw error;
6979
return purchaseResult;
@@ -75,6 +85,12 @@ class FakePurchaseService implements PurchaseService {
7585
@override
7686
Future<void> identify(String? userId) async => identifyCalls.add(userId);
7787

88+
@override
89+
Future<bool> ensureIdentified(String userId) async {
90+
ensureIdentifiedCalls.add(userId);
91+
return ensureIdentifiedResult;
92+
}
93+
7894
@override
7995
Future<void> invalidateCustomerInfoCache() async => invalidateCalls++;
8096

@@ -343,6 +359,88 @@ void main() {
343359
});
344360
});
345361

362+
test('fail-closed:身份未绑定 → purchase 报错且不成交', () async {
363+
await withClock(Clock.fixed(now), () async {
364+
final purchases = FakePurchaseService()
365+
..purchaseResult = proEntitlement
366+
..ensureIdentifiedResult = false; // 绑定未就绪
367+
final container = makeContainer(
368+
identity: signedIn,
369+
repo: FakeEntitlementRepository((_) async => null),
370+
cache: FakeEntitlementCache(),
371+
purchases: purchases,
372+
);
373+
final controller = container.read(
374+
subscriptionControllerProvider.notifier,
375+
);
376+
await pumpEventQueue();
377+
378+
await expectLater(
379+
controller.purchase('pro_yearly'),
380+
throwsA(isA<PurchaseException>()),
381+
);
382+
// 门禁被调用,但实际购买未发起。
383+
expect(purchases.ensureIdentifiedCalls, contains('u1'));
384+
expect(purchases.purchaseCalls, 0);
385+
});
386+
});
387+
388+
test('fail-closed:身份未绑定 → restore 报错且不发起恢复', () async {
389+
await withClock(Clock.fixed(now), () async {
390+
final purchases = FakePurchaseService()..ensureIdentifiedResult = false;
391+
final container = makeContainer(
392+
identity: signedIn,
393+
repo: FakeEntitlementRepository((_) async => null),
394+
cache: FakeEntitlementCache(),
395+
purchases: purchases,
396+
);
397+
final controller = container.read(
398+
subscriptionControllerProvider.notifier,
399+
);
400+
await pumpEventQueue();
401+
402+
await expectLater(
403+
controller.restore(),
404+
throwsA(isA<PurchaseException>()),
405+
);
406+
expect(purchases.ensureIdentifiedCalls, contains('u1'));
407+
});
408+
});
409+
410+
test('已登录冷启动 → build 即绑定 RC 身份(logIn)', () async {
411+
await withClock(Clock.fixed(now), () async {
412+
final purchases = FakePurchaseService();
413+
final container = makeContainer(
414+
identity: signedIn,
415+
repo: FakeEntitlementRepository((_) async => null),
416+
cache: FakeEntitlementCache(),
417+
purchases: purchases,
418+
);
419+
container.read(subscriptionControllerProvider);
420+
await pumpEventQueue();
421+
422+
// fireImmediately 令 build 即以当前已登录身份触发 identify(logIn)。
423+
expect(purchases.identifyCalls, contains('u1'));
424+
});
425+
});
426+
427+
test('匿名冷启动 → 不误调 logOut', () async {
428+
await withClock(Clock.fixed(now), () async {
429+
final purchases = FakePurchaseService()..currentResult = Entitlement.free;
430+
final container = makeContainer(
431+
identity: SubscriptionIdentity.anonymous,
432+
repo: FakeEntitlementRepository((_) async => null),
433+
cache: FakeEntitlementCache(),
434+
purchases: purchases,
435+
);
436+
container.read(subscriptionControllerProvider);
437+
await pumpEventQueue();
438+
439+
// 匿名→匿名无变化,_onIdentityChanged 提前 return,不应调 logOut(null)。
440+
expect(purchases.identifyCalls, isEmpty);
441+
});
442+
});
443+
346444
test('clearLocalCacheAndRefresh:清缓存 + 失效 RC 缓存 + 回源重对账', () async {
347445
await withClock(Clock.fixed(now), () async {
348446
final cache = FakeEntitlementCache()..stored = cached(proEntitlement);

test/features/subscription/subscription_debug_screen_test.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ class _FakePurchaseService implements PurchaseService {
3232
@override
3333
Future<void> identify(String? userId) async {}
3434
@override
35+
Future<bool> ensureIdentified(String userId) async => true;
36+
@override
3537
Future<void> invalidateCustomerInfoCache() async => invalidateCalls++;
3638
@override
3739
Future<Map<String, Object?>> debugCustomerInfoSnapshot() async => {

0 commit comments

Comments
 (0)