Skip to content

Commit 6bdd899

Browse files
committed
FEAT: direct 渠道接入 Paddle 支付
1 parent fffa118 commit 6bdd899

33 files changed

Lines changed: 1416 additions & 466 deletions

.github/workflows/release.yml

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,6 @@ jobs:
153153
SUPABASE_URL: ${{ vars.SUPABASE_URL }}
154154
SUPABASE_PUBLISHABLE_KEY: ${{ vars.SUPABASE_PUBLISHABLE_KEY }}
155155
GOOGLE_WEB_CLIENT_ID: ${{ vars.GOOGLE_WEB_CLIENT_ID }}
156-
REVENUECAT_API_KEY_GOOGLE: ${{ vars.REVENUECAT_API_KEY_GOOGLE }}
157-
# 侧载 APK 走网页支付所需的 RevenueCat Web Purchase Link 模板。
158-
WEB_PURCHASE_LINK_TEMPLATE: ${{ vars.WEB_PURCHASE_LINK_TEMPLATE }}
159156
run: |
160157
flutter clean
161158
DART_DEFINES=(
@@ -168,19 +165,8 @@ jobs:
168165
if [[ -n "$POSTHOG_API_KEY" ]]; then
169166
DART_DEFINES+=("--dart-define=POSTHOG_API_KEY=${POSTHOG_API_KEY}")
170167
fi
171-
if [[ -z "$REVENUECAT_API_KEY_GOOGLE" ]]; then
172-
echo "ERROR: REVENUECAT_API_KEY_GOOGLE repository variable is required for Android release builds." >&2
173-
exit 1
174-
fi
175-
DART_DEFINES+=("--dart-define=REVENUECAT_API_KEY_GOOGLE=${REVENUECAT_API_KEY_GOOGLE}")
176-
# 侧载 APK(GitHub / 官网直装):声明 direct 渠道 → 走网页支付而非 Play Billing。
177-
# 缺 WEB_PURCHASE_LINK_TEMPLATE 时优雅退回原行为(不启用网页支付)。
168+
# 侧载 APK(GitHub / 官网直装):声明 direct 渠道 → 走后端 Paddle Checkout。
178169
DART_DEFINES+=("--dart-define=DISTRIBUTION_CHANNEL=direct")
179-
if [[ -n "$WEB_PURCHASE_LINK_TEMPLATE" ]]; then
180-
DART_DEFINES+=("--dart-define=WEB_PURCHASE_LINK_TEMPLATE=${WEB_PURCHASE_LINK_TEMPLATE}")
181-
else
182-
echo "WARN: WEB_PURCHASE_LINK_TEMPLATE 未配置,侧载 APK 将无法使用网页支付。" >&2
183-
fi
184170
flutter build apk --release --flavor=prod \
185171
--target-platform android-arm64 \
186172
--build-name="$BUILD_NAME" \

PLAN.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Echo Loop 是一个围绕“音频输入 + 句子级学习 + 间隔复习 + AI
2121
- 词典体系:本地词典、AI 词典、网页词典、多源切换、非 modal 面板。
2222
- AI 能力:翻译、句子解析、单词深度解析、转录。
2323
- PDF 导出:学习材料导出为可打印 PDF。
24-
- 订阅体系:平台/渠道识别、RevenueCat 接入、Web/Paddle 托管结账、AI 配额后端裁决。
24+
- 订阅体系:平台/渠道识别、native RevenueCat、direct Paddle 后端结账、AI 配额后端裁决。
2525

2626
当前主要风险:
2727

@@ -59,7 +59,7 @@ Echo Loop 是一个围绕“音频输入 + 句子级学习 + 间隔复习 + AI
5959

6060
当前状态:
6161

62-
- 已完成:客户端 RevenueCat 接入、订阅页、平台/渠道识别、Web checkout、AI 配额后端裁决、release 渠道注入。
62+
- 已完成:客户端 RevenueCat 接入、订阅页、平台/渠道识别、direct Paddle Checkout 与 Customer Portal、AI 配额后端裁决、release 渠道注入。
6363
- 待继续:真实生产环境验证、退款/撤销回退、多设备/换机验证、更多渠道回归。
6464

6565
参考文档:

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-14(版本号升级到 1.0.26
3+
> 最后更新:2026-07-15(direct 渠道切换为 Paddle 自建支付
44
> 当前焦点:Android 结束录音闪退(离线 ASR / Silero VAD)——仍未解决
55
66
## 当前优先级
@@ -60,6 +60,14 @@
6060

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

63+
- [x] 2026-07-15 23:32:修复原生订阅的“管理订阅”入口,iOS 优先调用 StoreKit 系统订阅管理页,Android 优先打开 Play Store 订阅管理页,并保留平台不可用时的外部链接兜底。
64+
- [x] 2026-07-15 23:04:修复原生恢复购买归属校验,RevenueCat restore 返回的订阅若已绑定其他 Echo Loop 账号则拒绝写入当前账号,并在订阅页提示登录原账号后重试。
65+
- [x] 2026-07-15:direct 渠道由 RevenueCat Web Purchase Link 切换为后端 Paddle 集成;App 展示 Paddle 月付/年付套餐,登录后创建 checkout、等待统一权益生效,并通过 Paddle Customer Portal 管理订阅;App Store / Google Play 的 RevenueCat 购买与恢复路径保持隔离。
66+
- [x] 2026-07-15 12:11:订阅页英文标题由“Echo Loop Membership”改为“Echo Loop Premium”,并补充 paywall 标题回归断言。
67+
- [x] 2026-07-15 10:31:统一 direct/Web 渠道订阅页右上角文案为“恢复购买”,底层仍走后端权益同步,避免“刷新”造成用户困惑。
68+
- [x] 2026-07-15 10:20:调整 direct 渠道订阅页中文 CTA 文案,由“前往安全结账”改为“查看订阅方案”,匹配 RevenueCat 托管页仍会展示套餐选择的实际流程。
69+
- [x] 2026-07-15 09:40:优化 direct 渠道 Web checkout 入口,网页支付 CTA 改为安全结账文案,使用系统内置浏览器容器打开 RevenueCat/Paddle 托管结账页,并补充未登录拦截、打开成功等待权益确认、打开失败提示的 widget 回归测试。
70+
- [x] 2026-07-15 09:07:补充 RevenueCat CustomerInfo 关键诊断日志,覆盖 current/purchase/restore/update 路径,输出 expiresAt、willRenew、productId、active entitlements、RC 用户与订阅明细,并补充摘要/快照回归测试。
6371
- [x] 2026-07-14 22:38:版本号升级到 `1.0.26`
6472
- [x] 2026-07-14 22:24:修复订阅权益前台长驻跨过 expiresAt 后仍保持 Premium;`SubscriptionController` 根据有效权益到期时间安排一次性 refresh,新权益到来时重排 timer,并补到期刷新/重排/永久权益回归测试。
6573
- [x] 2026-07-14 22:11:收口 Web/direct 渠道恢复购买语义,`SubscriptionController.restore()` 在 Web 渠道转为后端权益刷新,避免误穿透到 `WebPurchaseService.restore()` 抛异常,并补充回归测试。

docs/subscription-setup.md

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -77,31 +77,29 @@ flutter run --dart-define-from-file=auth.env
7777
**订阅实现由 `platform + DISTRIBUTION_CHANNEL` 本地决定**
7878

7979
- `ios|macos + app_store` → Apple IAP / StoreKit;`android + play` → Google Play / RevenueCat Google。
80-
- `android|macos|windows + direct`RevenueCat Web Purchase Link / Paddle。
80+
- `android|macos|windows + direct`App 套餐页 + 后端 Paddle Checkout
8181
- 未注入 key 的平台:RC 不初始化、**订阅入口整体隐藏**(设置页 tile 不渲染、`openPaywall` 拦截并提示、Paywall 路由显示占位页),app 照常匿名运行。
8282
- 未注入或非法 distribution 保守隐藏支付入口;旧值 `apk` / `desktop` 仅在客户端解析层兼容为 `direct`,不再作为正式 header 值。
8383
- 停用/启用支付需要发版;**限额侧按平台+渠道组合由后端 env 控制**(见第 7 节 `AI_QUOTA_ENFORCED_CLIENTS`)。
8484

8585
相关代码:`lib/config/revenuecat_config.dart`(读 key + `isSubscriptionSupported`)、`lib/features/subscription/providers/subscription_availability.dart`(UI 门控 provider)、`lib/main.dart``Purchases.configure`)。
8686

87-
### 非商店渠道:RevenueCat Web Paywall + Paddle
87+
### 非商店渠道:Paddle Checkout
8888

89-
Android 侧载 APK / 桌面等非商店渠道不初始化 RevenueCat 原生 SDK,购买入口打开
90-
RevenueCat 托管 Web Paywall(底层 Paddle 计费),权益仍经 RevenueCat webhook
91-
落库后由 `/api/entitlements` 读回。
89+
Android 侧载 APK / 桌面等非商店渠道不初始化 RevenueCat 原生 SDK。App 从
90+
`GET /api/paddle/plans` 拉取月付/年付套餐,登录后通过 `POST /api/paddle/checkout`
91+
创建 Paddle transaction,并在系统浏览器容器中打开服务端返回的 checkout URL。
92+
权益只由 Paddle webhook 更新,App 通过 `/api/entitlements` 读回。
9293

9394
构建时注入:
9495

9596
```
9697
DISTRIBUTION_CHANNEL=direct
97-
WEB_PURCHASE_LINK_TEMPLATE=https://pay.rev.cat/<token>/{app_user_id}/paywall
9898
```
9999

100-
`{app_user_id}` 会被客户端替换为 URL-encoded 的 Supabase user.id。模板未配置或缺
101-
`{app_user_id}` 时,网页支付入口不可用。
102-
103-
Web/Paddle 的价格、五折优惠码、节日促销文案在 RevenueCat 托管 Paywall /
104-
Paddle Checkout 中维护;客户端不读取 Paddle discount,也不硬编码 Web 价格。
100+
客户端不接收 price ID、discount ID 或 redirect URL。价格与首年优惠由后端 Paddle
101+
catalog 返回;checkout completion 只进入等待态,必须等 `/api/entitlements` 确认。
102+
订阅管理通过 `POST /api/paddle/portal` 创建短期 Customer Portal session。
105103

106104
---
107105

ios/Runner/AppDelegate.swift

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Flutter
33
import NaturalLanguage
44
import PostHog
55
import Speech
6+
import StoreKit
67
import UIKit
78
import UserNotifications
89

@@ -1194,6 +1195,18 @@ private final class IOSAudioDecodeHandler: NSObject {
11941195
}
11951196
}
11961197

1198+
let subscriptionManagementChannel = FlutterMethodChannel(
1199+
name: "top.echo-loop/subscription_management",
1200+
binaryMessenger: controller.binaryMessenger
1201+
)
1202+
subscriptionManagementChannel.setMethodCallHandler { [weak self] (call, result) in
1203+
if call.method == "openManageSubscriptions" {
1204+
self?.openManageSubscriptions(result: result)
1205+
} else {
1206+
result(FlutterMethodNotImplemented)
1207+
}
1208+
}
1209+
11971210
speechPracticeHandler = IOSSpeechPracticeHandler(binaryMessenger: controller.binaryMessenger)
11981211
textEmbeddingHandler = IOSTextEmbeddingHandler(binaryMessenger: controller.binaryMessenger)
11991212
audioDecodeHandler = IOSAudioDecodeHandler(binaryMessenger: controller.binaryMessenger)
@@ -1202,6 +1215,47 @@ private final class IOSAudioDecodeHandler: NSObject {
12021215
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
12031216
}
12041217

1218+
/// 打开 iOS 系统订阅管理页。
1219+
///
1220+
/// 使用 StoreKit 原生入口而不是浏览器 URL;Dart 侧会在本方法不可用或失败时回退
1221+
/// App Store 订阅管理链接,兼容旧系统与异常场景。
1222+
private func openManageSubscriptions(result: @escaping FlutterResult) {
1223+
guard #available(iOS 15.0, *) else {
1224+
result(FlutterError(
1225+
code: "unsupported",
1226+
message: "StoreKit subscription management requires iOS 15 or later",
1227+
details: nil
1228+
))
1229+
return
1230+
}
1231+
1232+
let foregroundScene = UIApplication.shared.connectedScenes
1233+
.compactMap { $0 as? UIWindowScene }
1234+
.first { $0.activationState == .foregroundActive }
1235+
1236+
guard let scene = foregroundScene else {
1237+
result(FlutterError(
1238+
code: "no_scene",
1239+
message: "No foreground UIWindowScene for subscription management",
1240+
details: nil
1241+
))
1242+
return
1243+
}
1244+
1245+
Task { @MainActor in
1246+
do {
1247+
try await AppStore.showManageSubscriptions(in: scene)
1248+
result(true)
1249+
} catch {
1250+
result(FlutterError(
1251+
code: "open_failed",
1252+
message: error.localizedDescription,
1253+
details: nil
1254+
))
1255+
}
1256+
}
1257+
}
1258+
12051259
/// 从 Info.plist 读取 API Key/Host,构造完整 PostHog 配置(含 Session Replay)并启动 SDK。
12061260
///
12071261
/// 与 posthog_flutter 插件 initPlugin 的差异:插件版本不支持 sessionReplay 配置项,

lib/config/paddle_config.dart

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/// Direct 渠道 Paddle 配置。
2+
library;
3+
4+
import 'package:flutter/foundation.dart' show visibleForTesting;
5+
6+
import 'client_distribution.dart';
7+
import 'api_config.dart';
8+
9+
@visibleForTesting
10+
bool? debugIsPaddleCheckoutChannelOverride;
11+
12+
/// direct 构建使用后端 Paddle API;Play/App Store 构建不会进入该分支。
13+
bool get isPaddleCheckoutConfigured =>
14+
isPaddleCheckoutChannel && apiBaseUrl.trim().isNotEmpty;
15+
16+
bool get isPaddleCheckoutChannel {
17+
final override = debugIsPaddleCheckoutChannelOverride;
18+
if (override != null) return override;
19+
return clientPaymentChannel == ClientPaymentChannel.web;
20+
}

lib/config/revenuecat_config.dart

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,6 @@ String? get manageSubscriptionsUrl {
113113
}
114114

115115
/// 按本地支付实现选择订阅管理入口。
116-
@visibleForTesting
117116
String? manageSubscriptionsUrlForChannel(
118117
ClientPaymentChannel channel, {
119118
required String webManageUrl,

lib/config/web_purchase_config.dart

Lines changed: 0 additions & 86 deletions
This file was deleted.

lib/features/subscription/providers/subscription_availability.dart

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,15 @@
55
///
66
/// 真相源是 channel-aware 的 [subscriptionAvailableFor]:先由 `platform +
77
/// DISTRIBUTION_CHANNEL` 决定 [clientPaymentChannel](见 `client_distribution.dart`),
8-
/// 再看该渠道对应实现是否配置就绪(原生 RC key / 网页 Purchase Link)。
8+
/// 再看该渠道对应实现是否配置就绪(原生 RC key / Paddle 后端 API)。
99
library;
1010

1111
import 'package:flutter_riverpod/flutter_riverpod.dart' show Ref;
1212
import 'package:riverpod_annotation/riverpod_annotation.dart';
1313

14-
import '../../../config/revenuecat_config.dart';
15-
import '../../../config/web_purchase_config.dart';
1614
import '../../../config/client_distribution.dart';
15+
import '../../../config/paddle_config.dart';
16+
import '../../../config/revenuecat_config.dart';
1717

1818
part 'subscription_availability.g.dart';
1919

@@ -28,7 +28,7 @@ bool subscriptionAvailability(Ref ref) => subscriptionAvailableFor(
2828
isRevenueCatConfigured ||
2929
(useLocalStoreKit &&
3030
clientPaymentChannel == ClientPaymentChannel.appleStore),
31-
webConfigured: isWebCheckoutConfigured,
31+
webConfigured: isPaddleCheckoutConfigured,
3232
);
3333

3434
/// 根据本地渠道与对应实现的配置状态决定是否展示订阅能力。
@@ -45,14 +45,14 @@ bool subscriptionAvailableFor({
4545
};
4646
}
4747

48-
/// 当前是否走「网页支付」渠道(侧载 APK / 桌面)。
48+
/// 当前是否走 Paddle 网页支付渠道(侧载 APK / 桌面)。
4949
///
50-
/// Paywall 据此切换购买交互:true 时不展示商店套餐卡、改为「浏览器结账 + 回流对账」。
51-
/// 测试可 override 模拟网页渠道
50+
/// Paywall 据此切换购买动作:套餐仍由统一 UI 展示,点击后改为
51+
/// 「服务端创建 Paddle checkout + 浏览器结账 + 回流对账」
5252
@riverpod
5353
bool webCheckoutMode(Ref ref) => webCheckoutModeFor(
5454
channel: clientPaymentChannel,
55-
webConfigured: isWebCheckoutConfigured,
55+
webConfigured: isPaddleCheckoutConfigured,
5656
);
5757

5858
/// 仅 direct 且网页结账配置完整时进入 Web checkout 模式。

0 commit comments

Comments
 (0)