diff --git a/docs/extConfig.md b/docs/extConfig.md new file mode 100644 index 0000000..9831f44 --- /dev/null +++ b/docs/extConfig.md @@ -0,0 +1,16 @@ +{ + // 下面的三个字段说明:A面都是 false,B面都是 true + "need_wait": false, // 是否展示 Video 菜单 + "safe_area": false, // 是否防止截屏 + "lucky": false, // 是否显示第三方支付 + "privacy": "https://www.petsheroai.xyz/privacy.html", + "agreement": "https://www.petsheroai.xyz/terms.html", + "items": [ // 图片列表 + { + "image": "https://cdn.magieveryai.xyz/cdn/temp/20260305/2029445184939401216.jpg", // 图片URL + "cost": 1, // 需要的积分 + "title": "BananaTask", // taskType 任务类型,生图的时候需要的字段 + "detail": "parallel_cyberpunk_city_heroes" // ext 生图的时候需要的字段 + } + ] +} \ No newline at end of file diff --git a/docs/googlepay.md b/docs/googlepay.md index 7d7b362..db96950 100644 --- a/docs/googlepay.md +++ b/docs/googlepay.md @@ -102,12 +102,46 @@ ### 5.2 典型用法 -- **应用启动时**:调用 `getUnacknowledgedPurchases()`,若有未核销订单,可逐条上报 `POST /v1/payment/googlepay`(federation 可用该笔的 `orderId`,若服务端支持;否则需先 createPayment 拿到 federation 再回调),上报成功后对该笔购买调用 `InAppPurchase.instance.completePurchase(purchase)` 完成核销。 +- **应用启动时**:调用 `getUnacknowledgedPurchases()`,若有未核销订单,补单流程会使用本地保存的「创建订单时的 federation」逐笔回调;无保存 federation 的订单会跳过。 - **注意**:`queryPastPurchases` 不包含已消耗(consumed)的商品;未确认的消耗型商品会一直在列表中直到被确认或消耗。 --- -## 6. 代码位置速查 +## 6. 补单流程(自动) + +客户端已实现完整补单:拉取未核销订单 → 用本地保存的**创建订单时的 federation** 逐笔回调 googlepay → 服务端返回 `line == 'SUCCESS'` 则 `completePurchase` 并刷新用户信息。补单必须使用创建订单时的订单 id,不能使用 Google 的 orderId。 + +### 6.1 创建订单 id 的持久化 + +- 每次调起内购前都会先 **createPayment** 拿到 **federation**(服务端订单 id)。 +- 在发起 `POST /v1/payment/googlepay` 前,将 **Google orderId → federation** 写入本地(SharedPreferences),供补单时使用。 +- 回调成功或补单成功后删除对应映射。 + +### 6.2 入口与触发时机 + +| 项目 | 说明 | +|------|------| +| 方法 | `GooglePlayPurchaseService.runOrderRecovery()` | +| 触发时机 | ① 应用启动:`main()` 中 `AuthService.loginComplete.then((_) => runOrderRecovery())`
② 进入充值页:`RechargeScreen.initState` 中调用 | +| 前置条件 | 仅 Android;已登录(`UserState.userId` 非空) | + +### 6.3 流程步骤 + +1. 若非 Android 或未登录,直接返回。 +2. 调用 `getUnacknowledgedPurchases()` 获取未核销列表。 +3. 对每笔订单:用 **Google orderId** 查本地保存的 **federation**(创建订单时的订单 id);若无则跳过该笔并打日志。 +4. 用查到的 federation 调用 `POST /v1/payment/googlepay`。 +5. 若响应成功且 `line == 'SUCCESS'`:`completePurchase`、删除该笔映射、标记需要刷新。 +6. 若有任意一笔补单成功,最后调用 `refreshAccount()`。 + +### 6.4 服务端约定 + +- 补单请求的 **federation** 与正常回调一致,均为 **createPayment 返回的订单 id**;服务端按订单 id 落单/去重。 +- 校验逻辑与正常回调一致(sample=签名、merchant=购买凭据),成功时返回 `line: 'SUCCESS'`。 + +--- + +## 7. 代码位置速查 | 步骤 | 位置 | |------|------| @@ -117,12 +151,14 @@ | 回调 googlepay | `PaymentApi.googlepay(...)`,在 `_createOrderAndOpenUrl` 内、内购成功后调用 | | 凭据数据结构 | Android:`GooglePlayPurchaseDetails.billingClientPurchase`(orderId, originalJson, signature) | | 获取未核销订单 | `GooglePlayPurchaseService.getUnacknowledgedPurchases()`,见第 5 节 | +| 补单流程 | `GooglePlayPurchaseService.runOrderRecovery()`,见第 6 节 | --- -## 7. 小结 +## 8. 小结 1. **创建订单**:仅在选择「Google Pay」且第三方支付开启时调用 createPayment;拿到 **federation** 后才会调起谷歌支付并回调 googlepay。 2. **调起谷歌支付**:productId 固定为当前商品的 **code(helm)**,与 Play 后台产品 ID 一致。 3. **回调 googlepay**:body 为四字段 **sample**(signature)、**merchant**(purchaseData/originalJson)、**federation**(订单 id)、**asset**(userId);federation 为空则不回调,可按策略重试创建订单或提示失败。 -4. **未核销订单**:通过 `getUnacknowledgedPurchases()` 获取 `isAcknowledged == false` 的购买,可用于启动时补发回调或展示待处理订单。 +4. **未核销订单**:通过 `getUnacknowledgedPurchases()` 获取 `isAcknowledged == false` 的购买;每项含 `purchaseDetails` 用于补单成功后 `completePurchase`。 +5. **补单**:`runOrderRecovery()` 在应用启动(登录完成后)与进入充值页时执行;补单使用的 **federation** 为创建订单时的订单 id(内购前会持久化 Google orderId→federation,补单时按 Google orderId 取回);无保存 federation 的未核销订单会跳过。 diff --git a/docs/home.md b/docs/home.md new file mode 100644 index 0000000..19ebea7 --- /dev/null +++ b/docs/home.md @@ -0,0 +1,28 @@ +# 主页 UI 显示逻辑 + +依赖接口:`GET /v1/user/common_info` 返回的 `data.surge`(JSON 字符串)解析为 extConfig,结构见 [extConfig.md](extConfig.md)。 + +## extConfig 与顶部分类栏 + +- **need_wait**(是否展示 Video 菜单) + - 解析自 `surge.need_wait`,写入 `UserState.needShowVideoMenu`。 + - **仅 need_wait === true**:展示顶部分类栏(对应 Pencil 设计中的 tabRow,节点 bK6o6),行为见下。 + - **其他情况**(need_wait === false、未下发或未解析):不展示顶部分类栏,列表只展示 **extConfig.items** 的图片列表。 + +## need_wait === true 时的分类与列表 + +1. **分类栏** +使用图转视频分类接口数据,在分类列表**末尾**增加一个固定分类「pets」。 +2. **列表内容** + - 选中**固定分类 pets**:列表展示 **extConfig.items** 的图片列表(不请求视频任务接口)。 + - 选中**其他分类**:按原逻辑请求对应分类的视频任务接口,列表展示接口返回的视频任务。 + +## 数据流简述 + +1. 登录后请求 `common_info`,在 `AuthService._saveCommonInfoToState` 中解析 `data.surge`: + - 写入 `enable_third_party_payment` 等; + - 解析 `need_wait`、`items`,通过 `UserState.setExtConfig(needShowVideoMenuValue: needWait, items: items)` 写入。 +2. 主页 `HomeScreen` 监听 `UserState.needShowVideoMenu`、`UserState.extConfigItems`,据此决定: + - 是否渲染顶部分类栏; + - 当前列表是来自 extConfig.items 还是来自视频任务接口。 +3. extConfig 的 **items** 单项字段:`image`、`cost`、`title`、`detail`,用于展示卡片并作为生图参数(taskType / ext)。 diff --git a/docs/petsHeroAI_client_guide.md b/docs/petsHeroAI_client_guide.md index e527ad6..9693985 100644 --- a/docs/petsHeroAI_client_guide.md +++ b/docs/petsHeroAI_client_guide.md @@ -1297,7 +1297,7 @@ V2 完整响应体 (解密后): { "sentinel": "", // app — 应用标识(必填) "asset": "", // userId — 用户ID - "accolade": "", // type — 类型 + "accolade": "android_adjust", // type — 类型,传 android_adjust "portal": "" // pkg — 应用包名(必填) } ``` diff --git a/docs/user_login.md b/docs/user_login.md index 4ac85f8..8770480 100644 --- a/docs/user_login.md +++ b/docs/user_login.md @@ -64,7 +64,7 @@ |--------|----------------|------| | Query | sentinel | 应用标识(必填) | | Query | asset | 用户 ID(userId) | -| Query | accolade | 类型(可选) | +| Query | accolade | 类型(可选),传 `android_adjust` | | Query | portal | 应用包名(必填) | | Body | digest | **归因信息,从 Adjust 获取** | | Body | origin | 设备 ID(deviceId) | diff --git a/lib/core/api/api_config.dart b/lib/core/api/api_config.dart index 0626a09..c218f87 100644 --- a/lib/core/api/api_config.dart +++ b/lib/core/api/api_config.dart @@ -15,8 +15,7 @@ abstract final class ApiConfig { static const String preBaseUrl = 'https://pre-ai.petsheroai.xyz'; /// 生产环境域名 - static const String prodBaseUrl = - 'https://pre-ai.petsheroai.xyz'; //https://ai.petsheroai.xyz + static const String prodBaseUrl = 'https://ai.petsheroai.xyz'; /// 代理入口路径 static const String proxyPath = '/quester/defender/summoner'; diff --git a/lib/core/api/proxy_client.dart b/lib/core/api/proxy_client.dart index c473d26..49a7678 100644 --- a/lib/core/api/proxy_client.dart +++ b/lib/core/api/proxy_client.dart @@ -62,7 +62,8 @@ void _logLong(String text) { int chunkIndex = 0; for (final line in lines) { final lineWithNewline = buffer.isEmpty ? line : '\n$line'; - if (buffer.length + lineWithNewline.length > _maxLogChunk && buffer.isNotEmpty) { + if (buffer.length + lineWithNewline.length > _maxLogChunk && + buffer.isNotEmpty) { chunkIndex++; _proxyLog.d('(part $chunkIndex)\n$buffer'); buffer.clear(); @@ -74,7 +75,8 @@ void _logLong(String text) { } if (buffer.isNotEmpty) { chunkIndex++; - _proxyLog.d(chunkIndex > 1 ? '(part $chunkIndex)\n$buffer' : buffer.toString()); + _proxyLog + .d(chunkIndex > 1 ? '(part $chunkIndex)\n$buffer' : buffer.toString()); } } @@ -252,13 +254,9 @@ class ProxyClient { final paramsEncoded = jsonEncode(paramsMap); final v2BodyEncoded = jsonEncode(v2Body); - _log('========== 原始入参 =========='); - _log('path: $path'); - _log('method: $method'); - _log('headers: $headersEncoded'); - _log('queryParams: $paramsEncoded'); - _log('body(sanctum): ${jsonEncode(sanctum)}'); - _log('v2Body: $v2BodyEncoded'); + final logStr = + '========== 原始入参 ===========\npath: $path\nmethod: $method\nqueryParams: $paramsEncoded\nbody(sanctum): ${jsonEncode(sanctum)}'; + _log(logStr); final petSpeciesEnc = ApiCrypto.encrypt(path); final powerLevelEnc = ApiCrypto.encrypt(method); @@ -266,13 +264,6 @@ class ProxyClient { final battleScoreEnc = ApiCrypto.encrypt(paramsEncoded); final loyaltyIndexEnc = ApiCrypto.encrypt(v2BodyEncoded); - _log('========== 加密后 =========='); - _log('pet_species: $petSpeciesEnc'); - _log('power_level: $powerLevelEnc'); - _log('quest_rank: $questRankEnc'); - _log('battle_score: $battleScoreEnc'); - _log('loyalty_index: $loyaltyIndexEnc'); - final proxyBody = { ProxyKeys.heroClass: ApiConfig.appId, ProxyKeys.petSpecies: petSpeciesEnc, @@ -289,8 +280,6 @@ class ProxyClient { }; final url = '$_baseUrl${ApiConfig.proxyPath}'; - _log('========== 请求 URL =========='); - _log('$url'); final response = await http.post( Uri.parse(url), @@ -298,19 +287,17 @@ class ProxyClient { body: jsonEncode(proxyBody), ); - _log('========== 响应 =========='); - _log('statusCode: ${response.statusCode}'); - _log('body: ${response.body}'); - return _parseResponse(response); } ApiResponse _parseResponse(http.Response response) { try { // 响应解密流程:Base64 解码 → AES-ECB 解密 → PKCS5 去填充 → UTF-8 字符串 + var responseLogStr = '========== 响应 ==========='; final decrypted = ApiCrypto.decrypt(response.body); final json = jsonDecode(decrypted) as Map; - _log('json: $json'); + responseLogStr += jsonEncode(json); + _log(responseLogStr); // 解析 helm=code, rampart=msg, sidekick=data final sanctum = json['vault']?['tome']?['codex']?['grimoire']?['sanctum']; if (sanctum is Map) { diff --git a/lib/core/auth/auth_service.dart b/lib/core/auth/auth_service.dart index 23a6ea0..db832a5 100644 --- a/lib/core/auth/auth_service.dart +++ b/lib/core/auth/auth_service.dart @@ -70,6 +70,13 @@ class AuthService { if (surge != null) { final enable = surge['enable_third_party_payment'] as bool?; UserState.setEnableThirdPartyPayment(enable); + // extConfig:need_wait = 是否展示 Video 菜单,items = 图片列表(见 docs/extConfig.md) + final needWait = surge['need_wait'] as bool?; + final items = surge['items'] as List?; + UserState.setExtConfig( + needShowVideoMenuValue: needWait, + items: items, + ); } } catch (e) { _logMsg('surge JSON 解析失败: $e'); @@ -177,6 +184,7 @@ class AuthService { asset: uid!, digest: crest ?? '', origin: deviceId, + accolade: 'android_adjust', ); if (referrerRes.isSuccess) { _logMsg('referrer 上报成功'); diff --git a/lib/core/user/user_state.dart b/lib/core/user/user_state.dart index d2680f2..1ef1d49 100644 --- a/lib/core/user/user_state.dart +++ b/lib/core/user/user_state.dart @@ -14,6 +14,11 @@ class UserState { static final ValueNotifier enableThirdPartyPayment = ValueNotifier(null); + /// 是否展示 Video 分类栏(来自 common_info surge.need_wait,见 docs/extConfig.md) + static final ValueNotifier needShowVideoMenu = ValueNotifier(null); + /// extConfig.items 图片列表(来自 common_info surge.items) + static final ValueNotifier?> extConfigItems = ValueNotifier?>(null); + static void setCredits(int? value) { credits.value = value; } @@ -38,6 +43,11 @@ class UserState { enableThirdPartyPayment.value = value; } + static void setExtConfig({bool? needShowVideoMenuValue, List? items}) { + if (needShowVideoMenuValue != null) needShowVideoMenu.value = needShowVideoMenuValue; + if (items != null) extConfigItems.value = items; + } + static String formatCredits(int? value) { if (value == null) return '--'; return value.toString().replaceAllMapped( diff --git a/lib/features/home/home_screen.dart b/lib/features/home/home_screen.dart index 0630eb5..8020dc0 100644 --- a/lib/features/home/home_screen.dart +++ b/lib/features/home/home_screen.dart @@ -7,11 +7,15 @@ import '../../core/user/user_state.dart'; import '../../core/theme/app_spacing.dart'; import '../../shared/widgets/top_nav_bar.dart'; import 'models/category_item.dart'; +import 'models/ext_config_item.dart'; import 'models/task_item.dart'; import 'widgets/home_tab_row.dart'; import 'widgets/video_card.dart'; -/// AI Video App home screen - tab 来自分类接口,Grid 来自任务列表接口 +/// 固定「pets」分类 id,用于展示 extConfig.items +const int kExtCategoryId = -1; + +/// AI Video App home screen - tab 来自分类接口,Grid 来自任务列表或 extConfig.items class HomeScreen extends StatefulWidget { const HomeScreen({super.key, this.isActive = true}); @@ -32,10 +36,23 @@ class _HomeScreenState extends State { @override void initState() { super.initState(); + UserState.needShowVideoMenu.addListener(_onExtConfigChanged); + UserState.extConfigItems.addListener(_onExtConfigChanged); _loadCategories(); if (widget.isActive) refreshAccount(); } + @override + void dispose() { + UserState.needShowVideoMenu.removeListener(_onExtConfigChanged); + UserState.extConfigItems.removeListener(_onExtConfigChanged); + super.dispose(); + } + + void _onExtConfigChanged() { + if (mounted) setState(() {}); + } + @override void didUpdateWidget(covariant HomeScreen oldWidget) { super.didUpdateWidget(oldWidget); @@ -44,6 +61,45 @@ class _HomeScreenState extends State { } } + /// 仅 need_wait === true 时展示 Video 分类栏;其他(false/null/未解析)只显示图片列表 + bool get _showVideoMenu => + UserState.needShowVideoMenu.value == true; + + List get _parsedExtItems { + final raw = UserState.extConfigItems.value; + if (raw == null || raw.isEmpty) return []; + return raw + .map((e) => e is Map + ? ExtConfigItem.fromJson(e) + : ExtConfigItem.fromJson(Map.from(e as Map))) + .toList(); + } + + /// 当前列表:need_wait false 时用 extConfig.items;true 且选中固定分类时用 extConfig.items;否则用 _tasks + List get _displayTasks { + if (!_showVideoMenu) { + return _extItemsToTaskItems(_parsedExtItems); + } + if (_selectedCategory?.id == kExtCategoryId) { + return _extItemsToTaskItems(_parsedExtItems); + } + return _tasks; + } + + static List _extItemsToTaskItems(List items) { + return items + .map((e) => TaskItem( + templateName: e.title, + title: e.title, + previewImageUrl: e.image, + previewVideoUrl: null, + taskType: e.title, + ext: e.detail, + credits480p: e.cost, + )) + .toList(); + } + Future _loadCategories() async { setState(() => _categoriesLoading = true); await AuthService.loginComplete; @@ -54,10 +110,20 @@ class _HomeScreenState extends State { final list = (res.data as List) .map((e) => CategoryItem.fromJson(e as Map)) .toList(); + if (UserState.needShowVideoMenu.value == true) { + list.add(const CategoryItem(id: kExtCategoryId, name: 'pets', icon: null)); + } setState(() { _categories = list; _selectedCategory = list.isNotEmpty ? list.first : null; - if (_selectedCategory != null) _loadTasks(_selectedCategory!.id); + if (_selectedCategory != null) { + if (_selectedCategory!.id == kExtCategoryId) { + _tasks = []; + _tasksLoading = false; + } else { + _loadTasks(_selectedCategory!.id); + } + } }); } else { setState(() => _categories = []); @@ -87,7 +153,14 @@ class _HomeScreenState extends State { void _onTabChanged(CategoryItem c) { setState(() => _selectedCategory = c); - _loadTasks(c.id); + if (c.id == kExtCategoryId) { + setState(() { + _tasks = []; + _tasksLoading = false; + }); + } else { + _loadTasks(c.id); + } } static const _placeholderImage = @@ -107,26 +180,31 @@ class _HomeScreenState extends State { ), body: Column( children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.screenPadding, - vertical: AppSpacing.xs, + // 仅 need_wait == true 时展示顶部分类栏(Pencil: tabRow bK6o6) + if (_showVideoMenu) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.screenPadding, + vertical: AppSpacing.xs, + ), + child: _categoriesLoading + ? const SizedBox( + height: 40, + child: Center(child: CircularProgressIndicator())) + : HomeTabRow( + categories: _categories, + selectedId: _selectedCategory?.id ?? -1, + onTabChanged: _onTabChanged, + ), ), - child: _categoriesLoading - ? const SizedBox( - height: 40, - child: Center(child: CircularProgressIndicator())) - : HomeTabRow( - categories: _categories, - selectedId: _selectedCategory?.id ?? -1, - onTabChanged: _onTabChanged, - ), - ), Expanded( - child: _tasksLoading + child: _showVideoMenu && + _selectedCategory?.id != kExtCategoryId && + _tasksLoading ? const Center(child: CircularProgressIndicator()) : LayoutBuilder( builder: (context, constraints) { + final tasks = _displayTasks; return Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 390), @@ -144,9 +222,9 @@ class _HomeScreenState extends State { mainAxisSpacing: AppSpacing.xl, crossAxisSpacing: AppSpacing.xl, ), - itemCount: _tasks.length, + itemCount: tasks.length, itemBuilder: (context, index) { - final task = _tasks[index]; + final task = tasks[index]; final credits = task.credits480p != null ? task.credits480p.toString() : '50'; diff --git a/lib/features/home/models/ext_config_item.dart b/lib/features/home/models/ext_config_item.dart new file mode 100644 index 0000000..1334bd5 --- /dev/null +++ b/lib/features/home/models/ext_config_item.dart @@ -0,0 +1,27 @@ +/// extConfig.items 单项,来自 common_info surge 解析,见 docs/extConfig.md +class ExtConfigItem { + const ExtConfigItem({ + required this.image, + required this.cost, + required this.title, + required this.detail, + }); + + final String image; + final int cost; + final String title; + final String detail; + + factory ExtConfigItem.fromJson(Map json) { + return ExtConfigItem( + image: json['image'] as String? ?? '', + cost: (json['cost'] is int) + ? json['cost'] as int + : (json['cost'] is num) + ? (json['cost'] as num).toInt() + : 0, + title: json['title'] as String? ?? '', + detail: json['detail'] as String? ?? '', + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 490c2e5..0d8a997 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,7 @@ import 'core/auth/auth_service.dart'; import 'core/log/app_logger.dart'; import 'core/referrer/referrer_service.dart'; import 'core/theme/app_colors.dart'; +import 'features/recharge/google_play_purchase_service.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -25,6 +26,12 @@ void main() async { runApp(const App()); // APP 打开时后台执行快速登录 AuthService.init(); + // 尽早订阅 purchaseStream,否则未确认订单不会出现在 queryPastPurchases 中,补单会为空 + if (defaultTargetPlatform == TargetPlatform.android) { + GooglePlayPurchaseService.startPendingPurchaseListener(); + } + // 登录完成后执行谷歌支付补单(未核销订单上报服务端并 completePurchase) + AuthService.loginComplete.then((_) => GooglePlayPurchaseService.runOrderRecovery()); } void _initAdjust() {