新增:首页新增图一图功能

This commit is contained in:
ivan 2026-03-13 20:10:57 +08:00
parent bde8db3673
commit 62f5c9a19b
12 changed files with 247 additions and 51 deletions

16
docs/extConfig.md Normal file
View File

@ -0,0 +1,16 @@
{
// 下面的三个字段说明A面都是 falseB面都是 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 生图的时候需要的字段
}
]
}

View File

@ -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())`<br>② 进入充值页:`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 固定为当前商品的 **codehelm**,与 Play 后台产品 ID 一致。
3. **回调 googlepay**body 为四字段 **sample**signature、**merchant**purchaseData/originalJson、**federation**(订单 id、**asset**userIdfederation 为空则不回调,可按策略重试创建订单或提示失败。
4. **未核销订单**:通过 `getUnacknowledgedPurchases()` 获取 `isAcknowledged == false` 的购买,可用于启动时补发回调或展示待处理订单。
4. **未核销订单**:通过 `getUnacknowledgedPurchases()` 获取 `isAcknowledged == false` 的购买;每项含 `purchaseDetails` 用于补单成功后 `completePurchase`
5. **补单**`runOrderRecovery()` 在应用启动(登录完成后)与进入充值页时执行;补单使用的 **federation** 为创建订单时的订单 id内购前会持久化 Google orderId→federation补单时按 Google orderId 取回);无保存 federation 的未核销订单会跳过。

28
docs/home.md Normal file
View File

@ -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

View File

@ -1297,7 +1297,7 @@ V2 完整响应体 (解密后):
{
"sentinel": "", // app — 应用标识(必填)
"asset": "", // userId — 用户ID
"accolade": "", // type — 类型
"accolade": "android_adjust", // type — 类型,传 android_adjust
"portal": "" // pkg — 应用包名(必填)
}
```

View File

@ -64,7 +64,7 @@
|--------|----------------|------|
| Query | sentinel | 应用标识(必填) |
| Query | asset | 用户 IDuserId |
| Query | accolade | 类型(可选) |
| Query | accolade | 类型(可选),传 `android_adjust` |
| Query | portal | 应用包名(必填) |
| Body | digest | **归因信息,从 Adjust 获取** |
| Body | origin | 设备 IDdeviceId |

View File

@ -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';

View File

@ -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<String, dynamic>;
_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<String, dynamic>) {

View File

@ -70,6 +70,13 @@ class AuthService {
if (surge != null) {
final enable = surge['enable_third_party_payment'] as bool?;
UserState.setEnableThirdPartyPayment(enable);
// extConfigneed_wait = Video items = docs/extConfig.md
final needWait = surge['need_wait'] as bool?;
final items = surge['items'] as List<dynamic>?;
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 上报成功');

View File

@ -14,6 +14,11 @@ class UserState {
static final ValueNotifier<bool?> enableThirdPartyPayment =
ValueNotifier<bool?>(null);
/// Video common_info surge.need_wait docs/extConfig.md
static final ValueNotifier<bool?> needShowVideoMenu = ValueNotifier<bool?>(null);
/// extConfig.items common_info surge.items
static final ValueNotifier<List<dynamic>?> extConfigItems = ValueNotifier<List<dynamic>?>(null);
static void setCredits(int? value) {
credits.value = value;
}
@ -38,6 +43,11 @@ class UserState {
enableThirdPartyPayment.value = value;
}
static void setExtConfig({bool? needShowVideoMenuValue, List<dynamic>? 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(

View File

@ -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<HomeScreen> {
@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<HomeScreen> {
}
}
/// need_wait === true Video false/null/
bool get _showVideoMenu =>
UserState.needShowVideoMenu.value == true;
List<ExtConfigItem> get _parsedExtItems {
final raw = UserState.extConfigItems.value;
if (raw == null || raw.isEmpty) return [];
return raw
.map((e) => e is Map<String, dynamic>
? ExtConfigItem.fromJson(e)
: ExtConfigItem.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
}
/// need_wait false extConfig.itemstrue extConfig.items _tasks
List<TaskItem> get _displayTasks {
if (!_showVideoMenu) {
return _extItemsToTaskItems(_parsedExtItems);
}
if (_selectedCategory?.id == kExtCategoryId) {
return _extItemsToTaskItems(_parsedExtItems);
}
return _tasks;
}
static List<TaskItem> _extItemsToTaskItems(List<ExtConfigItem> 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<void> _loadCategories() async {
setState(() => _categoriesLoading = true);
await AuthService.loginComplete;
@ -54,10 +110,20 @@ class _HomeScreenState extends State<HomeScreen> {
final list = (res.data as List)
.map((e) => CategoryItem.fromJson(e as Map<String, dynamic>))
.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<HomeScreen> {
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<HomeScreen> {
),
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<HomeScreen> {
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';

View File

@ -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<String, dynamic> 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? ?? '',
);
}
}

View File

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