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() {