Skip to content

Commit e46ff60

Browse files
committed
FIX: 收口 Paddle 订阅行为与展示
1 parent 6bdd899 commit e46ff60

17 files changed

Lines changed: 712 additions & 254 deletions

TASKS.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Echo Loop 任务清单
22

3-
> 最后更新:2026-07-15(direct 渠道切换为 Paddle 自建支付
3+
> 最后更新:2026-07-16(收口顶部优惠条展示优先级
44
> 当前焦点:Android 结束录音闪退(离线 ASR / Silero VAD)——仍未解决
55
66
## 当前优先级
@@ -60,6 +60,14 @@
6060

6161
## 最近完成(保留近两周)
6262

63+
- [x] 2026-07-16 15:04:降低 direct/Paddle checkout 后权益确认轮询频次:`/api/entitlements` 轮询间隔由 3 秒改为 5 秒,保持总等待约 2 分钟,并补充轮询间隔 widget 断言。
64+
- [x] 2026-07-16 14:39:修复 direct/Paddle 支付等待态深色主题加载圈可见性:等待按钮禁用时保留 Premium 蓝底,spinner 使用蓝底对比色,并补充深色主题 widget 断言。
65+
- [x] 2026-07-16 14:26:简化 direct/Paddle 支付等待态:打开 checkout 后主订阅按钮切换为禁用加载态,移除额外等待 label 与“我已完成支付”按钮,并补充 widget 回归断言。
66+
- [x] 2026-07-16 11:15:收口订阅页顶部优惠高亮条:monthly/yearly 都有 paid intro offer 时只展示 yearly;yearly 不存在但 monthly 存在时展示 monthly;两者都没有可展示优惠时隐藏高亮条,并补充三类 widget 回归。
67+
- [x] 2026-07-16 10:55:统一 monthly/yearly paid intro offer 展示逻辑:套餐卡优惠价后缀改为只按月/年显示 `/first mo` / `/first yr`,补充中英文文案、monthly offer 显示回归与 Paddle monthly intro DTO 映射断言。
68+
- [x] 2026-07-16 10:45:适配后端 Paddle plans 新 DTO:App 请求不再发送 locale;direct 套餐解析移除 title / 旧 intro price 依赖,按 percentage intro offer 推导优惠展示价,并补充 repository 与价格工具回归测试。
69+
- [x] 2026-07-16 09:16:统一 direct/Paddle 与 native 订阅的上层行为:Paddle plans 返回 `introOffer` 时复用 native 同一套 Special offer 展示逻辑;direct 匿名权益对账直接进入 free,不再把无 token 当作 Paddle 在线源错误;补充 paywall 与 controller 回归断言。
70+
- [x] 2026-07-16 08:23:补强 direct/Paddle 订阅全流程关键日志,覆盖套餐加载/重试、checkout 创建与浏览器打开、权益轮询/手动检查、后端权益刷新、Customer Portal 与异常路径,便于定位未登录打开订阅页价格不显示等问题。
6371
- [x] 2026-07-15 23:32:修复原生订阅的“管理订阅”入口,iOS 优先调用 StoreKit 系统订阅管理页,Android 优先打开 Play Store 订阅管理页,并保留平台不可用时的外部链接兜底。
6472
- [x] 2026-07-15 23:04:修复原生恢复购买归属校验,RevenueCat restore 返回的订阅若已绑定其他 Echo Loop 账号则拒绝写入当前账号,并在订阅页提示登录原账号后重试。
6573
- [x] 2026-07-15:direct 渠道由 RevenueCat Web Purchase Link 切换为后端 Paddle 集成;App 展示 Paddle 月付/年付套餐,登录后创建 checkout、等待统一权益生效,并通过 Paddle Customer Portal 管理订阅;App Store / Google Play 的 RevenueCat 购买与恢复路径保持隔离。

lib/features/subscription/models/subscription_plan.dart

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ class SubscriptionPlan {
7373
/// 免费试用天数([hasFreeTrial] 为 false 时为 0)。
7474
final int trialDays;
7575

76-
/// 平台配置的首期促销(免费试用 / 首期折扣),为空表示当前用户不应看到促销价
76+
/// 平台配置的 intro offer(免费试用 / 折扣),为空表示当前用户不应看到优惠价
7777
final SubscriptionIntroOffer? introOffer;
7878

7979
const SubscriptionPlan({
@@ -110,9 +110,9 @@ class SubscriptionPlan {
110110
);
111111
}
112112

113-
/// 单个套餐的首期促销展示信息
113+
/// 单个套餐的 intro offer 展示信息
114114
class SubscriptionIntroOffer {
115-
/// 首期促销价(本地化价格字符串);免费试用时通常为 `$0.00`
115+
/// Intro offer 优惠价(本地化价格字符串);免费试用时通常为 `$0.00`
116116
final String priceString;
117117

118118
/// 促销周期单位。

lib/features/subscription/providers/subscription_controller.dart

Lines changed: 76 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,11 @@ class SubscriptionController extends _$SubscriptionController {
122122
final identity = _identity;
123123
final userId = identity.userId;
124124
final accessToken = identity.accessToken;
125+
AppLogger.log(
126+
'Subscription',
127+
'权益刷新开始: generation=$generation channel=${_paymentChannel.name} '
128+
'userId=${userId ?? "匿名"} hasToken=${accessToken != null}',
129+
);
125130

126131
final cached = await _readValidCache(userId);
127132
Entitlement? remote;
@@ -132,16 +137,34 @@ class SubscriptionController extends _$SubscriptionController {
132137
if (_paymentChannel == ClientPaymentChannel.web &&
133138
userId != null &&
134139
accessToken != null) {
140+
AppLogger.log('Subscription', 'Web/direct 权益刷新读取后端: userId=$userId');
135141
remote = await _repository.fetchRemote(
136142
userId: userId,
137143
accessToken: accessToken,
138144
);
139145
}
146+
if (_paymentChannel == ClientPaymentChannel.web &&
147+
remote == null &&
148+
(userId == null || accessToken == null)) {
149+
// Direct/Paddle 权益只能通过已登录账号从后端读取。匿名或 token 未就绪时
150+
// 与 native 匿名无购买保持同一上层语义:明确 free,而不是把无 token 当错误态。
151+
remote = Entitlement.free;
152+
}
140153
// Native 渠道或后端不可达时,用平台购买服务的当前权益快照兜底。
141-
remote ??= await _purchases.currentEntitlement();
154+
if (remote == null) {
155+
AppLogger.log(
156+
'Subscription',
157+
'权益刷新读取购买服务快照: channel=${_paymentChannel.name}',
158+
);
159+
remote = await _purchases.currentEntitlement();
160+
}
142161
} catch (e) {
143162
// 失败不静默吞:记录错误、保留兜底,不误判为无权益。
144163
error = e.toString();
164+
AppLogger.log(
165+
'Subscription',
166+
'权益刷新在线源失败: generation=$generation error=$error',
167+
);
145168
}
146169

147170
if (generation != _generation) return; // 已被更新的对账 / 登录切换作废。
@@ -204,32 +227,77 @@ class SubscriptionController extends _$SubscriptionController {
204227
/// 只有 webhook 更新后端权益并由 [refresh] 读回才算购买完成。
205228
Future<Uri> startPaddleCheckout(String planId) async {
206229
if (_paymentChannel != ClientPaymentChannel.web) {
230+
AppLogger.log(
231+
'Subscription',
232+
'Paddle checkout 中止: channel=${_paymentChannel.name} planId=$planId',
233+
);
207234
throw PurchaseException('当前渠道不支持 Paddle checkout');
208235
}
236+
AppLogger.log(
237+
'Subscription',
238+
'Paddle checkout 创建入口: planId=$planId '
239+
'userId=${_identity.userId ?? "匿名"}',
240+
);
209241
await _ensurePurchaseIdentity();
210242
final token = _identity.accessToken;
211243
if (token == null || token.isEmpty) {
244+
AppLogger.log(
245+
'Subscription',
246+
'Paddle checkout 中止: planId=$planId reason=tokenEmpty',
247+
);
212248
throw PurchaseException('订阅身份未就绪,请稍后重试');
213249
}
214-
final session = await ref
215-
.read(paddleBillingRepositoryProvider)
216-
.createCheckout(accessToken: token, planId: planId);
217-
return session.checkoutUrl;
250+
try {
251+
final session = await ref
252+
.read(paddleBillingRepositoryProvider)
253+
.createCheckout(accessToken: token, planId: planId);
254+
AppLogger.log(
255+
'Subscription',
256+
'Paddle checkout 创建完成: planId=$planId '
257+
'attemptId=${session.attemptId} host=${session.checkoutUrl.host}',
258+
);
259+
return session.checkoutUrl;
260+
} catch (error) {
261+
AppLogger.log(
262+
'Subscription',
263+
'Paddle checkout 创建失败: planId=$planId error=$error',
264+
);
265+
rethrow;
266+
}
218267
}
219268

220269
/// 创建 Paddle Customer Portal session,供 direct 用户取消订阅或更新支付方式。
221270
Future<Uri> createPaddlePortal() async {
222271
if (_paymentChannel != ClientPaymentChannel.web) {
272+
AppLogger.log(
273+
'Subscription',
274+
'Paddle Portal 中止: channel=${_paymentChannel.name}',
275+
);
223276
throw PurchaseException('当前渠道不支持 Paddle Portal');
224277
}
278+
AppLogger.log(
279+
'Subscription',
280+
'Paddle Portal 创建入口: userId=${_identity.userId ?? "匿名"}',
281+
);
225282
await _ensurePurchaseIdentity();
226283
final token = _identity.accessToken;
227284
if (token == null || token.isEmpty) {
285+
AppLogger.log('Subscription', 'Paddle Portal 中止: reason=tokenEmpty');
228286
throw PurchaseException('订阅身份未就绪,请稍后重试');
229287
}
230-
return ref
231-
.read(paddleBillingRepositoryProvider)
232-
.createPortal(accessToken: token);
288+
try {
289+
final uri = await ref
290+
.read(paddleBillingRepositoryProvider)
291+
.createPortal(accessToken: token);
292+
AppLogger.log(
293+
'Subscription',
294+
'Paddle Portal 创建完成: host=${uri.host} path=${uri.path}',
295+
);
296+
return uri;
297+
} catch (error) {
298+
AppLogger.log('Subscription', 'Paddle Portal 创建失败: error=$error');
299+
rethrow;
300+
}
233301
}
234302

235303
/// 恢复购买。

lib/features/subscription/providers/subscription_plans_provider.dart

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,19 @@ class SubscriptionPlansController
6262
Future<void> _refresh({required bool force}) async {
6363
// generation 必须在任何 await 前创建,保证后发请求始终拥有提交权。
6464
final generation = ++_generation;
65+
AppLogger.log(
66+
'Subscription',
67+
'套餐刷新开始: generation=$generation force=$force',
68+
);
6569
final service = ref.read(purchaseServiceProvider);
6670
final storefront = await _readStorefront(service);
67-
if (!_isCurrent(generation)) return;
71+
if (!_isCurrent(generation)) {
72+
AppLogger.log(
73+
'Subscription',
74+
'套餐刷新丢弃: generation=$generation reason=storefrontOutdated',
75+
);
76+
return;
77+
}
6878
final storefrontChanged =
6979
_lastStorefront != null &&
7080
storefront != null &&
@@ -75,50 +85,108 @@ class SubscriptionPlansController
7585
ref.read(subscriptionPlansNowProvider)().difference(lastSuccessAt) <
7686
subscriptionPlansRefreshInterval;
7787

78-
if (!force && !storefrontChanged && isFresh) return;
88+
if (!force && !storefrontChanged && isFresh) {
89+
AppLogger.log(
90+
'Subscription',
91+
'套餐刷新跳过: generation=$generation reason=freshCache '
92+
'storefront=${storefront ?? "unknown"}',
93+
);
94+
return;
95+
}
7996

8097
final previousPlans = state.valueOrNull;
8198
if (storefrontChanged || previousPlans == null) {
8299
state = const AsyncLoading();
83100
}
101+
AppLogger.log(
102+
'Subscription',
103+
'套餐刷新执行: generation=$generation storefront=${storefront ?? "unknown"} '
104+
'storefrontChanged=$storefrontChanged '
105+
'previousCount=${previousPlans?.length ?? 0}',
106+
);
84107

85108
try {
86109
// 先提交不依赖 iOS 促销资格查询的基础价格,缩短首次可见时间。
87110
final fastPlans = await service.fetchPlans(
88111
includeIntroEligibility: false,
89112
);
90-
if (!_isCurrent(generation)) return;
113+
if (!_isCurrent(generation)) {
114+
AppLogger.log(
115+
'Subscription',
116+
'套餐基础价格丢弃: generation=$generation reason=outdated',
117+
);
118+
return;
119+
}
91120
final storefrontAfterFetch = await _readStorefront(service);
92-
if (!_isCurrent(generation)) return;
121+
if (!_isCurrent(generation)) {
122+
AppLogger.log(
123+
'Subscription',
124+
'套餐基础价格丢弃: generation=$generation '
125+
'reason=storefrontAfterFetchOutdated',
126+
);
127+
return;
128+
}
93129
if (storefront != null &&
94130
storefrontAfterFetch != null &&
95131
storefront != storefrontAfterFetch) {
96132
// StoreKit 在查询期间完成了跨区切换,当前返回值可能属于旧 storefront。
133+
AppLogger.log(
134+
'Subscription',
135+
'套餐刷新检测到 storefront 切换: before=$storefront '
136+
'after=$storefrontAfterFetch generation=$generation',
137+
);
97138
state = const AsyncLoading();
98139
await refresh(force: true);
99140
return;
100141
}
101142
state = AsyncData(fastPlans);
102143
_lastStorefront = storefrontAfterFetch ?? storefront ?? _lastStorefront;
103144
_lastSuccessAt = ref.read(subscriptionPlansNowProvider)();
145+
AppLogger.log(
146+
'Subscription',
147+
'套餐基础价格刷新成功: generation=$generation count=${fastPlans.length} '
148+
'ids=${fastPlans.map((p) => p.planId).toList()}',
149+
);
104150

105151
// 再从 SDK 缓存补全促销资格;失败时继续保留已可用的基础价格。
106152
try {
107153
final completePlans = await service.fetchPlans();
108-
if (!_isCurrent(generation)) return;
154+
if (!_isCurrent(generation)) {
155+
AppLogger.log(
156+
'Subscription',
157+
'套餐完整价格丢弃: generation=$generation reason=outdated',
158+
);
159+
return;
160+
}
109161
state = AsyncData(completePlans);
110162
_lastSuccessAt = ref.read(subscriptionPlansNowProvider)();
163+
AppLogger.log(
164+
'Subscription',
165+
'套餐完整价格刷新成功: generation=$generation '
166+
'count=${completePlans.length} '
167+
'ids=${completePlans.map((p) => p.planId).toList()}',
168+
);
111169
} catch (error, stackTrace) {
112170
AppLogger.log('Subscription', '套餐促销资格刷新失败,保留基础价格: $error');
113171
AppLogger.log('Subscription', '$stackTrace');
114172
}
115173
} catch (error, stackTrace) {
116-
if (!_isCurrent(generation)) return;
174+
if (!_isCurrent(generation)) {
175+
AppLogger.log(
176+
'Subscription',
177+
'套餐刷新失败但已过期: generation=$generation error=$error',
178+
);
179+
return;
180+
}
117181
if (!storefrontChanged && previousPlans != null) {
118182
state = AsyncData(previousPlans);
119183
AppLogger.log('Subscription', '套餐刷新失败,保留会话缓存: $error');
120184
} else {
121185
state = AsyncError(error, stackTrace);
186+
AppLogger.log(
187+
'Subscription',
188+
'套餐刷新失败,进入错误态: generation=$generation error=$error',
189+
);
122190
}
123191
}
124192
}

0 commit comments

Comments
 (0)