优化:根据新APP优化框架代码

This commit is contained in:
ivan 2026-04-13 18:56:55 +08:00
parent bdab80b2bd
commit b55faf0db5
46 changed files with 4633 additions and 352 deletions

View File

@ -7,6 +7,12 @@
"packageUri": "lib/",
"languageVersion": "2.12"
},
{
"name": "archive",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/archive-4.0.9",
"packageUri": "lib/",
"languageVersion": "3.0"
},
{
"name": "args",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/args-2.7.0",
@ -37,6 +43,12 @@
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "code_assets",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/code_assets-1.0.0",
"packageUri": "lib/",
"languageVersion": "3.9"
},
{
"name": "collection",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/collection-1.19.1",
@ -91,6 +103,18 @@
"packageUri": "lib/",
"languageVersion": "3.9"
},
{
"name": "glob",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/glob-2.1.3",
"packageUri": "lib/",
"languageVersion": "3.3"
},
{
"name": "hooks",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/hooks-1.0.2",
"packageUri": "lib/",
"languageVersion": "3.10"
},
{
"name": "http",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/http-1.6.0",
@ -103,6 +127,12 @@
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "image",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/image-4.8.0",
"packageUri": "lib/",
"languageVersion": "3.0"
},
{
"name": "in_app_purchase",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/in_app_purchase-3.2.3",
@ -127,6 +157,18 @@
"packageUri": "lib/",
"languageVersion": "3.9"
},
{
"name": "jni",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/jni-1.0.0",
"packageUri": "lib/",
"languageVersion": "3.3"
},
{
"name": "jni_flutter",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/jni_flutter-1.0.1",
"packageUri": "lib/",
"languageVersion": "3.3"
},
{
"name": "js",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/js-0.7.2",
@ -145,6 +187,12 @@
"packageUri": "lib/",
"languageVersion": "2.17"
},
{
"name": "logging",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/logging-1.3.0",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "material_color_utilities",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/material_color_utilities-0.13.0",
@ -157,12 +205,48 @@
"packageUri": "lib/",
"languageVersion": "3.5"
},
{
"name": "native_toolchain_c",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/native_toolchain_c-0.17.6",
"packageUri": "lib/",
"languageVersion": "3.10"
},
{
"name": "objective_c",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/objective_c-9.3.0",
"packageUri": "lib/",
"languageVersion": "3.10"
},
{
"name": "package_config",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/package_config-2.2.0",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "path",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/path-1.9.1",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "path_provider",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/path_provider-2.1.5",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "path_provider_android",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/path_provider_android-2.3.0",
"packageUri": "lib/",
"languageVersion": "3.9"
},
{
"name": "path_provider_foundation",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0",
"packageUri": "lib/",
"languageVersion": "3.10"
},
{
"name": "path_provider_linux",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1",
@ -181,6 +265,12 @@
"packageUri": "lib/",
"languageVersion": "3.2"
},
{
"name": "petitparser",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/petitparser-7.0.2",
"packageUri": "lib/",
"languageVersion": "3.8"
},
{
"name": "platform",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/platform-3.1.6",
@ -205,6 +295,18 @@
"packageUri": "lib/",
"languageVersion": "3.2"
},
{
"name": "posix",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/posix-6.5.0",
"packageUri": "lib/",
"languageVersion": "3.0"
},
{
"name": "pub_semver",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/pub_semver-2.2.0",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "shared_preferences",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/shared_preferences-2.5.4",
@ -283,6 +385,12 @@
"packageUri": "lib/",
"languageVersion": "3.1"
},
{
"name": "video_thumbnail",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/video_thumbnail-0.5.6",
"packageUri": "lib/",
"languageVersion": "2.16"
},
{
"name": "web",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/web-1.1.1",
@ -295,6 +403,18 @@
"packageUri": "lib/",
"languageVersion": "3.3"
},
{
"name": "xml",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/xml-6.6.1",
"packageUri": "lib/",
"languageVersion": "3.8"
},
{
"name": "yaml",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/yaml-3.1.3",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "client_proxy_framework",
"rootUri": "../",

View File

@ -13,14 +13,36 @@
"facebook_app_events",
"flutter",
"http",
"image",
"in_app_purchase",
"in_app_purchase_android",
"logger",
"path_provider",
"play_install_referrer",
"shared_preferences"
"shared_preferences",
"video_thumbnail"
],
"devDependencies": []
},
{
"name": "video_thumbnail",
"version": "0.5.6",
"dependencies": [
"flutter"
]
},
{
"name": "path_provider",
"version": "2.1.5",
"dependencies": [
"flutter",
"path_provider_android",
"path_provider_foundation",
"path_provider_linux",
"path_provider_platform_interface",
"path_provider_windows"
]
},
{
"name": "shared_preferences",
"version": "2.5.4",
@ -124,6 +146,56 @@
"vector_math"
]
},
{
"name": "path_provider_windows",
"version": "2.3.0",
"dependencies": [
"ffi",
"flutter",
"path",
"path_provider_platform_interface"
]
},
{
"name": "path_provider_platform_interface",
"version": "2.1.2",
"dependencies": [
"flutter",
"platform",
"plugin_platform_interface"
]
},
{
"name": "path_provider_linux",
"version": "2.2.1",
"dependencies": [
"ffi",
"flutter",
"path",
"path_provider_platform_interface",
"xdg_directories"
]
},
{
"name": "path_provider_foundation",
"version": "2.6.0",
"dependencies": [
"ffi",
"flutter",
"objective_c",
"path_provider_platform_interface"
]
},
{
"name": "path_provider_android",
"version": "2.3.0",
"dependencies": [
"flutter",
"jni",
"jni_flutter",
"path_provider_platform_interface"
]
},
{
"name": "shared_preferences_windows",
"version": "2.4.1",
@ -286,30 +358,70 @@
"version": "1.4.1",
"dependencies": []
},
{
"name": "path_provider_windows",
"version": "2.3.0",
"dependencies": [
"ffi",
"flutter",
"path",
"path_provider_platform_interface"
]
},
{
"name": "path_provider_platform_interface",
"version": "2.1.2",
"dependencies": [
"flutter",
"platform",
"plugin_platform_interface"
]
},
{
"name": "path",
"version": "1.9.1",
"dependencies": []
},
{
"name": "ffi",
"version": "2.2.0",
"dependencies": []
},
{
"name": "plugin_platform_interface",
"version": "2.1.8",
"dependencies": [
"meta"
]
},
{
"name": "platform",
"version": "3.1.6",
"dependencies": []
},
{
"name": "xdg_directories",
"version": "1.1.0",
"dependencies": [
"meta",
"path"
]
},
{
"name": "objective_c",
"version": "9.3.0",
"dependencies": [
"code_assets",
"collection",
"ffi",
"hooks",
"logging",
"native_toolchain_c",
"pub_semver"
]
},
{
"name": "jni_flutter",
"version": "1.0.1",
"dependencies": [
"flutter",
"jni"
]
},
{
"name": "jni",
"version": "1.0.0",
"dependencies": [
"args",
"collection",
"ffi",
"meta",
"package_config",
"path",
"plugin_platform_interface"
]
},
{
"name": "file",
"version": "7.0.1",
@ -325,24 +437,6 @@
"flutter"
]
},
{
"name": "plugin_platform_interface",
"version": "2.1.8",
"dependencies": [
"meta"
]
},
{
"name": "path_provider_linux",
"version": "2.2.1",
"dependencies": [
"ffi",
"flutter",
"path",
"path_provider_platform_interface",
"xdg_directories"
]
},
{
"name": "json_annotation",
"version": "4.11.0",
@ -379,20 +473,53 @@
]
},
{
"name": "ffi",
"name": "pub_semver",
"version": "2.2.0",
"dependencies": []
},
{
"name": "platform",
"version": "3.1.6",
"dependencies": []
},
{
"name": "xdg_directories",
"version": "1.1.0",
"dependencies": [
"collection"
]
},
{
"name": "native_toolchain_c",
"version": "0.17.6",
"dependencies": [
"code_assets",
"glob",
"hooks",
"logging",
"meta",
"pub_semver"
]
},
{
"name": "logging",
"version": "1.3.0",
"dependencies": []
},
{
"name": "hooks",
"version": "1.0.2",
"dependencies": [
"collection",
"crypto",
"logging",
"meta",
"pub_semver",
"yaml"
]
},
{
"name": "code_assets",
"version": "1.0.0",
"dependencies": [
"collection",
"hooks"
]
},
{
"name": "package_config",
"version": "2.2.0",
"dependencies": [
"path"
]
},
@ -400,6 +527,69 @@
"name": "term_glyph",
"version": "1.2.2",
"dependencies": []
},
{
"name": "glob",
"version": "2.1.3",
"dependencies": [
"async",
"collection",
"file",
"path",
"string_scanner"
]
},
{
"name": "yaml",
"version": "3.1.3",
"dependencies": [
"collection",
"source_span",
"string_scanner"
]
},
{
"name": "image",
"version": "4.8.0",
"dependencies": [
"archive",
"meta",
"xml"
]
},
{
"name": "archive",
"version": "4.0.9",
"dependencies": [
"path",
"posix"
]
},
{
"name": "posix",
"version": "6.5.0",
"dependencies": [
"ffi",
"meta",
"path"
]
},
{
"name": "xml",
"version": "6.6.1",
"dependencies": [
"collection",
"meta",
"petitparser"
]
},
{
"name": "petitparser",
"version": "7.0.2",
"dependencies": [
"collection",
"meta"
]
}
],
"configVersion": 1

View File

@ -1,6 +1,8 @@
package com.funymee.client_proxy_framework
import android.app.ActivityManager
import android.app.Application
import android.content.Context
import com.facebook.appevents.AppEventsLogger
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
@ -9,6 +11,7 @@ import io.flutter.plugin.common.MethodChannel
/** Facebook App Events:在引擎侧注册固定 Channel,供 Dart 触发 activateApp。 */
class ClientProxyFrameworkPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private var channel: MethodChannel? = null
private var deviceMemoryChannel: MethodChannel? = null
private var applicationContext: android.content.Context? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
@ -16,11 +19,35 @@ class ClientProxyFrameworkPlugin : FlutterPlugin, MethodChannel.MethodCallHandle
val ch = MethodChannel(binding.binaryMessenger, CHANNEL_NAME)
channel = ch
ch.setMethodCallHandler(this)
val memCh = MethodChannel(binding.binaryMessenger, DEVICE_MEMORY_CHANNEL_NAME)
deviceMemoryChannel = memCh
memCh.setMethodCallHandler { call, memResult ->
when (call.method) {
"getTotalPhysicalMemoryBytes" -> {
try {
val ctx = applicationContext ?: run {
memResult.error("NO_CONTEXT", "applicationContext null", null)
return@setMethodCallHandler
}
val am = ctx.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val mi = ActivityManager.MemoryInfo()
am.getMemoryInfo(mi)
memResult.success(mi.totalMem.toString())
} catch (e: Exception) {
memResult.error("MEMORY", e.message, null)
}
}
else -> memResult.notImplemented()
}
}
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel?.setMethodCallHandler(null)
channel = null
deviceMemoryChannel?.setMethodCallHandler(null)
deviceMemoryChannel = null
applicationContext = null
}
@ -45,5 +72,6 @@ class ClientProxyFrameworkPlugin : FlutterPlugin, MethodChannel.MethodCallHandle
companion object {
const val CHANNEL_NAME = "client_proxy_framework/facebook_sdk"
const val DEVICE_MEMORY_CHANNEL_NAME = "client_proxy_framework/device_memory"
}
}

View File

@ -16,12 +16,17 @@ export 'src/api/proxy_client.dart';
export 'src/bootstrap/client_bootstrap.dart';
export 'src/config/app_config.dart';
export 'src/config/attribution_config.dart';
export 'src/config/ext_config_key_schema.dart';
export 'src/config/ext_config_models.dart';
export 'src/config/ext_config_runtime.dart';
export 'src/config/video_home_runtime.dart';
export 'src/config/skin_config.dart';
export 'src/config/field_mapping.dart';
export 'src/config/default_field_mapping.dart';
export 'src/entities/entities.dart';
export 'src/log/app_logger.dart';
export 'src/log/sdk_reminder_log.dart';
export 'src/media/video_thumbnail_cache.dart';
export 'src/services/adjust_service.dart';
export 'src/services/analytics_attribution_callbacks.dart';
export 'src/services/analytics_service.dart';
@ -29,6 +34,14 @@ export 'src/services/auth_service.dart';
export 'src/services/facebook_service.dart';
export 'src/services/feedback_api.dart';
export 'src/services/image_api.dart';
export 'src/services/image_progress_poll.dart';
export 'src/services/image_compress.dart';
export 'src/services/image_presigned_upload_create_flow.dart';
export 'src/services/image_task_history.dart';
export 'src/services/task_upload_cover_store.dart';
export 'src/services/user_account_refresh.dart';
export 'src/services/payment_api.dart';
export 'src/services/payment_flow/payment_flow.dart';
export 'src/services/payment_service.dart';
export 'src/services/user_api.dart';
export 'src/util/device_memory_profile.dart';

View File

@ -97,14 +97,14 @@ class ProxyClient {
return result;
}
/// 发送代理请求(返回原始字段的 Map)
/// 发送代理请求(返回逻辑字段的 Map)
///
/// [headers]、[queryParams]、[body] 使用**原始字段名**,
/// 框架会按 [AppConfig.fieldMapping] 转为 V2 字段名后发送。
/// 响应 data 会自动从 V2 转回原始字段名。
/// [headers]、[queryParams]、[body] 使用**业务逻辑字段名**,
/// 框架会按 [AppConfig.fieldMapping] 转为线网字段名后发送。
/// 响应 data 会自动从线网转回逻辑字段名。
///
/// **请求头(自动注入)**
/// - [AppConfig.packageName] → 原始字段名 `pkg`(经映射后的请求头键,如 `stakeholder`)
/// - [AppConfig.packageName] → 逻辑字段名 `pkg`(再映射为线网请求头键)
/// - 若已 [userToken] 且 [includeUserTokenInHeader] 为 true,则注入 `User_token`
///
/// 与文档一致:**设备快速登录等无需登录态接口**应传 `includeUserTokenInHeader: false`,
@ -176,7 +176,7 @@ class ProxyClient {
/// 发送代理请求并返回实体
///
/// [headers]、[queryParams]、[body] 使用**原始字段名**。
/// [headers]、[queryParams]、[body] 使用**业务逻辑字段名**。
/// [entityFactory] 用于将映射后的 data 转换为实体对象。
///
/// 参见 [request] 的 [includeUserTokenInHeader] 说明。
@ -198,8 +198,18 @@ class ProxyClient {
includeUserTokenInHeader: includeUserTokenInHeader,
);
if (response.isSuccess && response.data is Map<String, dynamic>) {
final entity = entityFactory(response.data as Map<String, dynamic>);
if (response.isSuccess) {
final raw = response.data;
if (raw is Map<String, dynamic>) {
final entity = entityFactory(raw);
return EntityResponse<T>(
code: response.code,
msg: response.msg,
data: entity,
);
}
// 成功但 `data` 为 null、或非对象(如 `true`)时仍构造实体,避免误判为失败。
final entity = entityFactory(<String, dynamic>{});
return EntityResponse<T>(
code: response.code,
msg: response.msg,

View File

@ -1,59 +1,32 @@
import 'package:flutter/foundation.dart';
import 'default_field_mapping.dart';
import 'ext_config_key_schema.dart';
import 'field_mapping.dart';
/// 代理请求体字段名配置
/// 代理请求最外层 JSON 的**键名**(值为密文或明文;具体线网名由 `skin_config.json` 的 `proxyKeys` 配置)。
class ProxyKeysConfig {
const ProxyKeysConfig({
/// 代理请求体最外层「应用明文」字段的 **键名**(值由框架写入 [AppConfig.appName],与后端约定一致)
this.appIdField = 'hero_class',
/// 原始 path 字段名
this.pathField = 'pet_species',
/// "POST" 或 "GET" 字段名
this.methodField = 'power_level',
/// 映射后的 Header 字段名(构造 JSON)
this.headerField = 'quest_rank',
/// 映射后的 URL 参数字段名(构造 JSON)
this.paramsField = 'battle_score',
/// V2 包装后的业务数据字段名
this.bodyField = 'loyalty_index',
/// 噪音字段名列表
/// 应用标识明文字段名(值一般为 [AppConfig.appName])
this.appIdField = 'appId',
this.pathField = 'path',
this.methodField = 'method',
this.headerField = 'headers',
this.paramsField = 'params',
this.bodyField = 'body',
this.noiseKeys = const [
'billing_addr',
'utm_term',
'cluster_id',
'lsn_value',
'accuracy_val',
'dir_path'
'noise1',
'noise2',
'noise3',
'noise4',
],
});
/// 代理请求体应用明文字段键名(值 = appName)
final String appIdField;
/// 原始 path 字段名
final String pathField;
/// "POST" 或 "GET" 字段名
final String methodField;
/// 映射后的 Header 字段名(构造 JSON)
final String headerField;
/// 映射后的 URL 参数字段名(构造 JSON)
final String paramsField;
/// V2 包装后的业务数据字段名
final String bodyField;
/// 噪音字段名列表
final List<String> noiseKeys;
}
@ -104,26 +77,37 @@ abstract class AppConfig {
/// 代理请求体字段名
ProxyKeysConfig get proxyKeys => const ProxyKeysConfig();
/// V2 层级路径
/// V2 包装:业务数据嵌套路径(键名由 `skin_config.json` 的 `v2.sanctumPath` 配置)
List<String> get v2SanctumPath =>
const ['vault', 'tome', 'codex', 'grimoire', 'sanctum'];
const ['wrapper', 'layer', 'payload'];
/// V2 包装:噪音字段名
List<String> get v2NoiseKeys =>
const ['roar', 'clash', 'thunder', 'rumble', 'howl', 'growl'];
const ['n1', 'n2', 'n3', 'n4', 'n5', 'n6'];
/// V2 层级字段名
String get v2LevelField => 'arsenal';
/// V2 最外层层级字段名(与 [v2LevelFixedValue] 组成固定壳)
String get v2LevelField => 'level';
/// V2 层级固定值
int get v2LevelFixedValue => 4;
int get v2LevelFixedValue => 1;
/// V2 层级固定值
Map<String, dynamic> get v2FixedValues {
return {v2LevelField: v2LevelFixedValue};
}
/// 字段映射表(原始字段 ↔ V2 字段)
/// 换皮应用若后端 V2 字段名不同,覆盖此方法返回自己的映射
FieldMapping get fieldMapping => petsHeroAIFieldMapping;
/// 字段映射(逻辑名 ↔ 线网名);未换皮时为 [kIdentityFieldMapping],**正式包应在 `skin_config.json` 配置**。
FieldMapping get fieldMapping => kIdentityFieldMapping;
/// `common_info.extConfig` 的 JSON 键名(由 `skin_config.extConfig` 配置)。
ExtConfigKeySchema get extConfigKeySchema => ExtConfigKeySchema.defaults();
/// 合并进服务器 extConfig 之前的本地默认值;null 表示不设默认。
Map<String, dynamic>? get extConfigDefaults => null;
/// 视频首页:合并 [ExtConfigData.items] 的 Tab 文案(与接口分类并列;见 `skin_config.videoHome`)。
String get videoHomeImagesTabLabel => 'Images';
/// 为 true 时「images」Tab 排在服务端分类之前;默认 `false`(images 在列表末尾)。
bool get videoHomeImagesTabFirst => false;
}

View File

@ -1,105 +1,5 @@
import 'field_mapping.dart';
/// petsHeroAI 默认字段映射(原始字段 → 后端字段)
///
/// 单一映射表,后端文档给的格式通常与此一致。
/// 不同应用若后端字段名不同,只需提供新的 [FieldMapping] 覆盖此表。
const FieldMapping petsHeroAIFieldMapping = FieldMapping({
// === 请求头 ===
'pkg': 'portal',
'User_token': 'knight',
// === 响应字段 ===
'code': 'helm',
'msg': 'rampart',
'data': 'sidekick',
// === 通用 query ===
'app': 'sentinel',
'userId': 'asset',
'ch': 'crest',
'type': 'accolade',
// === fast_login body ===
'referer': 'digest',
'sign': 'resolution',
'deviceId': 'origin',
// === 支付 ===
'activityId': 'warrior',
'country': 'vambrace',
'client': 'filter',
'id': 'timing',
'filterStatus': 'quest',
'orderId': 'federation',
'payUrl': 'convert',
'productId': 'helm',
'paymentMethod': 'resource',
'paymentType': 'ceremony',
'signature': 'sample',
'purchaseData': 'merchant',
// === 图片/视频 ===
'categoryId': 'insignia',
'taskId': 'tree',
'prompt': 'ledger',
'resolution': 'guild',
'srcImgUrls': 'commission',
'fileName1': 'gateway',
'fileName2': 'action',
'contentType': 'pauldron',
'expectedSize': 'stronghold',
'page': 'trophy',
'pageSize': 'heatmap',
'cursor': 'platoon',
'declaration': 'declaration',
'quest': 'quest',
'imgCount': 'indicator',
'aspectRatio': 'caption',
'ext': 'nexus',
'imgUrl': 'congregation',
'poseId': 'profit',
'templateId': 'compendium',
'notification': 'notification',
'allowance': 'allowance',
'cosmos': 'cosmos',
// === 响应 data(后端字段 → 原始字段,通过取反自动生效)===
'userToken': 'reevaluate',
'credits': 'reveal',
'avatar': 'realm',
'userName': 'terminal',
'countryCode': 'navigate',
'extConfig': 'surge',
'appFbConfig': 'evolve',
'usign': 'retrospect',
'creditsRecordUrl': 'conquer',
'tgId': 'concession',
'tgName': 'defer',
'email': 'galaxy',
'forcePayCenter': 'upgrade',
't2IConfig': 'regulate',
'h5UrlConfig': 'pursue',
'payCenterUrl': 'switch',
'freeBlurTimes': 'vow',
'firstRegister': 'equip',
'isVip': 'generate',
'tags': 'rally',
'freeTimes': 'decree',
'subScribeValidTime': 'tokenize',
'status': 'line',
'productList': 'summon',
'paymentMethods': 'renew',
'guardian': 'guardian',
'curriculum': 'curriculum',
'forge': 'forge',
'tag': 'constrain',
'img': 'revenue',
'url': 'digitize',
'launchImgUrl': 'launchImgUrl',
// === 反馈 ===
'fileName': 'layer',
'fileUrls': 'inventory',
'content': 'cloak',
});
/// 历史兼容:曾内置 petsHero 映射表;**线网字段名请只在 `skin_config.json` 的 `fieldMapping` 中维护**。
@Deprecated('Use kIdentityFieldMapping; configure skin_config.json fieldMapping.')
const FieldMapping petsHeroAIFieldMapping = kIdentityFieldMapping;

View File

@ -0,0 +1,295 @@
import 'dart:convert';
/// `skin_config.json` 中 `extConfig.keys` / `extConfig.itemKeys` 的描述:
/// **逻辑字段** ↔ 远端/common_info 下发的 **JSON 键名列表**(按顺序首个存在的键生效,便于兼容多版本键名)。
///
/// 可选的 [itemKeysHome] / [itemKeysTask]:同一逻辑名可在**首页展示**与**创建任务/生图**两侧映射到不同线网键(值结构与 [itemKeys] 相同:逻辑名 → 键名列表)。
/// 未配置时回退到 [itemKeys] 中对应项(即与旧版单表行为一致)。
///
/// [taskItemMapping]:`extConfig.items` 每一项在展示/生图前要**对齐** [GET /v1/image/img2video/tasks] 经 [FieldMapping.mapResponse] 后的单项结构([TaskItem.fromJson] 的输入)。
/// **键**为任务项上的**目标路径**(点号嵌套,如 `previewVideo.url`、`resolution720p.credits`),**值**为在源对象上按顺序尝试的**源路径**列表(点号表示嵌套)。
/// 若未配置或为空,则仍按旧逻辑(先 mapResponse,再猜测 Task 形态或走 [ExtConfigItem.fromJson])。
///
/// [defaultItemTitleWhenEmpty]:`extConfig.items` 解析后 [ExtConfigItem.title] 仍为空(仅空白)时填入的占位文案(`extConfig.defaultItemTitle`)。
///
/// 「固定字段」指除兜底图 [imageFixKeys] 外的首页策略与列表项语义字段;`image_fix` 仅走 [itemImageFixKeys],默认 `["image_fix"]`,可在 JSON 中改写。
class ExtConfigKeySchema {
const ExtConfigKeySchema({
required this.showVideoMenuKeys,
required this.allowScreenshotKeys,
required this.blockScreenshotKeys,
required this.allowThirdPartyPaymentKeys,
required this.privacyUrlKeys,
required this.agreementUrlKeys,
required this.itemsKeys,
required this.itemImageKeys,
required this.itemImageFixKeys,
required this.itemImgNeedKeys,
required this.itemCostKeys,
required this.itemCost480pKeys,
required this.itemCost720pKeys,
required this.itemTitleKeys,
required this.itemTemplateNameKeys,
required this.itemTaskTypeKeys,
required this.itemParamsKeys,
required this.itemDetailKeys,
required this.itemVideoUrlKeys,
this.itemKeysHome,
this.itemKeysTask,
this.taskItemMapping,
this.itemsApplyFieldMappingBeforeTaskMapping = true,
this.defaultItemTitleWhenEmpty = '-',
});
/// 是否展示顶部 Video/分类 Tab,并把 items 固定为最后一格 Tab。
final List<String> showVideoMenuKeys;
/// 为 `true` 时表示**允许**截屏(直接读取这些键的布尔值)。
final List<String> allowScreenshotKeys;
/// 为 `true` 时表示**禁止**截屏(与 [allowScreenshotKeys] 互斥推导:本键为 true ⇒ 不允许截屏)。
final List<String> blockScreenshotKeys;
/// 是否允许第三方支付。
final List<String> allowThirdPartyPaymentKeys;
final List<String> privacyUrlKeys;
final List<String> agreementUrlKeys;
/// items 数组所在键名。
final List<String> itemsKeys;
final List<String> itemImageKeys;
final List<String> itemImageFixKeys;
final List<String> itemImgNeedKeys;
final List<String> itemCostKeys;
/// 生图分辨率 480p 对应积分(可选;缺省由业务侧按 [itemCostKeys] 推导)。
final List<String> itemCost480pKeys;
/// 生图分辨率 720p 对应积分(可选;缺省用 [itemCostKeys] / `cost`)。
final List<String> itemCost720pKeys;
final List<String> itemTitleKeys;
/// 创建任务用的模板标识(`POST /v1/image/create-task` 的 `templateName` / 换皮 `itinerary`);与展示用 [itemTitleKeys] 分离。
final List<String> itemTemplateNameKeys;
/// 创建任务时的任务类型(`POST /v1/image/create-task` 的 `cipher` / 列表项换皮 `liaison` ↔ 逻辑 `taskType`)。
final List<String> itemTaskTypeKeys;
/// 新业务扩展串(如 animal_expression),与 [itemDetailKeys] 并列用于 [ExtConfigItem.taskExt]。
final List<String> itemParamsKeys;
final List<String> itemDetailKeys;
/// 预览/背景视频地址;非空时 [ExtConfigItem.isVideoItem] 为 true(与 [itemImageKeys] 并列)。
final List<String> itemVideoUrlKeys;
/// 首页/列表展示侧:封面图、标题、预览视频等线网键覆盖(逻辑名与 [itemKeys] 一致)。
final Map<String, List<String>>? itemKeysHome;
/// 创建任务/生图侧:模板名、任务类型、params/detail、张数、计费等线网键覆盖。
final Map<String, List<String>>? itemKeysTask;
/// 将 `extConfig.items` 单项**组装**成与 [TaskItem.fromJson] 一致的结构(与 `/v1/image/img2video/tasks` 单项在 mapResponse 后同形)。
/// 非空时**优先**于 [itemKeys] 扁平解析。
final Map<String, List<String>>? taskItemMapping;
/// 为 `true`(默认)时:先对单项做 [FieldMapping.mapResponse],再按 [taskItemMapping] 从**逻辑/已换皮**键上取值;为 `false` 时从**原始**单项上按源路径取值(源路径需为线网键名)。
final bool itemsApplyFieldMappingBeforeTaskMapping;
/// `extConfig.items` 解析出 [ExtConfigItem] 后,若 [ExtConfigItem.title] 仅空白,则替换为该字符串(见 `extConfig.defaultItemTitle`)。
final String defaultItemTitleWhenEmpty;
/// 首页展示用线网键:[itemKeysHome] 中该逻辑名非空则用之,否则 [fallbackFromItemKeys](通常为 [itemKeys] 解析结果)。
List<String> homeWireKeysFor(
String logical,
List<String> fallbackFromItemKeys,
) {
final o = itemKeysHome?[logical];
if (o != null && o.isNotEmpty) return o;
return fallbackFromItemKeys;
}
/// 生图/创建任务用线网键:[itemKeysTask] 优先,否则 [fallbackFromItemKeys]。
List<String> taskWireKeysFor(
String logical,
List<String> fallbackFromItemKeys,
) {
final o = itemKeysTask?[logical];
if (o != null && o.isNotEmpty) return o;
return fallbackFromItemKeys;
}
/// 与当前框架内置默认一致(go_run / need_wait、screen、safe_area、san_fang、lucky 等)。
factory ExtConfigKeySchema.defaults() {
return const ExtConfigKeySchema(
showVideoMenuKeys: ['go_run', 'need_wait'],
allowScreenshotKeys: ['screen'],
blockScreenshotKeys: ['safe_area'],
allowThirdPartyPaymentKeys: ['san_fang', 'lucky'],
privacyUrlKeys: ['privacy'],
agreementUrlKeys: ['agreement'],
itemsKeys: ['items'],
itemImageKeys: ['image'],
itemImageFixKeys: ['image_fix'],
itemImgNeedKeys: ['img_need'],
itemCostKeys: ['cost'],
itemCost480pKeys: ['cost_480p', 'cost480p', 'cost_480'],
itemCost720pKeys: ['cost_720p', 'cost720p', 'cost_720'],
itemTitleKeys: ['title'],
itemTemplateNameKeys: ['templateName', 'template_name'],
itemTaskTypeKeys: ['taskType'],
itemParamsKeys: ['params'],
itemDetailKeys: ['detail'],
itemVideoUrlKeys: ['video', 'video_url', 'videoUrl', 'preview_video'],
itemKeysHome: null,
itemKeysTask: null,
taskItemMapping: null,
itemsApplyFieldMappingBeforeTaskMapping: true,
defaultItemTitleWhenEmpty: '-',
);
}
/// 从 `skin_config` 的 `extConfig` 对象解析;缺少的子块用 [defaults] 补全。
factory ExtConfigKeySchema.fromSkinExtConfigJson(Map<String, dynamic>? ext) {
if (ext == null) return ExtConfigKeySchema.defaults();
final d = ExtConfigKeySchema.defaults();
final keys = ext['keys'];
final itemKeys = ext['itemKeys'] as Map<String, dynamic>?;
List<String> rootList(Map<String, dynamic>? m, String k, List<String> fallback) {
if (m == null) return fallback;
final raw = m[k];
final parsed = _stringList(raw);
return parsed.isEmpty ? fallback : parsed;
}
Map<String, dynamic>? keyMap;
if (keys is Map<String, dynamic>) {
keyMap = keys;
}
List<String> itemField(
String k,
List<String> fallback,
) {
if (itemKeys == null) return fallback;
return rootList(itemKeys, k, fallback);
}
return ExtConfigKeySchema(
showVideoMenuKeys: rootList(keyMap, 'showVideoMenu', d.showVideoMenuKeys),
allowScreenshotKeys:
rootList(keyMap, 'allowScreenshot', d.allowScreenshotKeys),
blockScreenshotKeys:
rootList(keyMap, 'blockScreenshot', d.blockScreenshotKeys),
allowThirdPartyPaymentKeys: rootList(
keyMap,
'allowThirdPartyPayment',
d.allowThirdPartyPaymentKeys,
),
privacyUrlKeys: rootList(keyMap, 'privacyUrl', d.privacyUrlKeys),
agreementUrlKeys: rootList(keyMap, 'agreementUrl', d.agreementUrlKeys),
itemsKeys: rootList(keyMap, 'items', d.itemsKeys),
itemImageKeys: itemField('image', d.itemImageKeys),
itemImageFixKeys: itemField('imageFix', d.itemImageFixKeys),
itemImgNeedKeys: itemField('imgNeed', d.itemImgNeedKeys),
itemCostKeys: itemField('cost', d.itemCostKeys),
itemCost480pKeys: itemField('cost480p', d.itemCost480pKeys),
itemCost720pKeys: itemField('cost720p', d.itemCost720pKeys),
itemTitleKeys: itemField('title', d.itemTitleKeys),
itemTemplateNameKeys:
itemField('templateName', d.itemTemplateNameKeys),
itemTaskTypeKeys: itemField('taskType', d.itemTaskTypeKeys),
itemParamsKeys: itemField('params', d.itemParamsKeys),
itemDetailKeys: itemField('detail', d.itemDetailKeys),
itemVideoUrlKeys: itemField('videoUrl', d.itemVideoUrlKeys),
itemKeysHome: _optionalLogicalWireMap(ext['itemKeysHome']),
itemKeysTask: _optionalLogicalWireMap(ext['itemKeysTask']),
taskItemMapping: _optionalLogicalWireMap(ext['taskItemMapping']),
itemsApplyFieldMappingBeforeTaskMapping:
ext['itemsApplyFieldMappingBeforeTaskMapping'] is bool
? ext['itemsApplyFieldMappingBeforeTaskMapping'] as bool
: true,
defaultItemTitleWhenEmpty: _parseDefaultItemTitle(
ext['defaultItemTitle'],
d.defaultItemTitleWhenEmpty,
),
);
}
static String _parseDefaultItemTitle(dynamic raw, String fallback) {
if (raw is String && raw.trim().isNotEmpty) return raw.trim();
return fallback;
}
/// 非空 Map;若解析结果为空则返回 null(与「未配置」一致)。
static Map<String, List<String>>? _optionalLogicalWireMap(dynamic raw) {
if (raw is! Map) return null;
final out = <String, List<String>>{};
for (final e in raw.entries) {
final k = e.key.toString();
final list = _stringList(e.value);
if (list.isNotEmpty) out[k] = list;
}
return out.isEmpty ? null : out;
}
static List<String> _stringList(dynamic raw) {
if (raw == null) return [];
if (raw is String) return raw.isEmpty ? [] : [raw];
if (raw is List) {
return raw.map((e) => e.toString()).where((s) => s.isNotEmpty).toList();
}
return [];
}
}
/// `skin_config` 根下 `extConfig` 节:键模式 + 可选默认 JSON(wire 键名,与 common_info 一致)。
class SkinExtConfigSection {
SkinExtConfigSection({
required this.keySchema,
this.defaults,
});
final ExtConfigKeySchema keySchema;
/// 本地默认 extConfig 对象(与线上下发同一套 **键名**);`common_info` 成功后与服务器对象 **浅合并**,服务器键覆盖同名键。
final Map<String, dynamic>? defaults;
static SkinExtConfigSection? fromRootJson(Map<String, dynamic>? ext) {
if (ext == null) return null;
final schema = ExtConfigKeySchema.fromSkinExtConfigJson(ext);
Map<String, dynamic>? def;
final raw = ext['defaults'];
if (raw is Map<String, dynamic>) {
def = Map<String, dynamic>.from(raw);
} else if (raw is String && raw.trim().isNotEmpty) {
try {
final dec = json.decode(raw);
if (dec is Map<String, dynamic>) def = dec;
} catch (_) {}
}
return SkinExtConfigSection(keySchema: schema, defaults: def);
}
/// 浅合并:[server] 中非 null 的顶层键覆盖 [base]。
static Map<String, dynamic>? mergeDefaults({
Map<String, dynamic>? base,
Map<String, dynamic>? server,
}) {
if (base == null || base.isEmpty) {
if (server == null || server.isEmpty) return null;
return Map<String, dynamic>.from(server);
}
if (server == null || server.isEmpty) {
return Map<String, dynamic>.from(base);
}
final out = Map<String, dynamic>.from(base);
server.forEach((k, v) {
out[k] = v;
});
return out;
}
}

View File

@ -0,0 +1,521 @@
import 'dart:convert';
import '../entities/image_entities.dart';
import 'ext_config_key_schema.dart';
import 'field_mapping.dart';
/// `common_info.extConfig`(解密后的业务 JSON)解析后的**逻辑**视图。
///
/// JSON **键名**由 [ExtConfigKeySchema](来自 `skin_config.extConfig`)决定;缺省使用 [ExtConfigKeySchema.defaults]。
///
/// `items` 数组:
/// - 若配置了 [ExtConfigKeySchema.taskItemMapping],则按该表把每项**组装**成与 [GET /v1/image/img2video/tasks] 单项(经 [FieldMapping.mapResponse] 后)一致的结构,再 [TaskItem.fromJson] → [ExtConfigItem.fromTaskItem]。
/// - 否则先 [FieldMapping.mapResponse];若与 [TaskItem] 形态一致则走 [ExtConfigItem.fromTaskItem],否则 [ExtConfigItem.fromJson](扁平 `image`/`title` 等)。
class ExtConfigData {
const ExtConfigData({
this.showVideoMenu,
this.allowScreenshot,
this.allowThirdPartyPayment,
this.privacyUrl,
this.agreementUrl,
this.items = const [],
});
final bool? showVideoMenu;
final bool? allowScreenshot;
final bool? allowThirdPartyPayment;
final String? privacyUrl;
final String? agreementUrl;
final List<ExtConfigItem> items;
bool? get shouldPreventCapture {
final a = allowScreenshot;
if (a == null) return null;
return !a;
}
static ExtConfigData empty() => const ExtConfigData();
static ExtConfigData parse(
String? extConfigJson, {
ExtConfigKeySchema? schema,
FieldMapping fieldMapping = kIdentityFieldMapping,
}) {
final map = parseRawMap(extConfigJson);
if (map == null) return empty();
return fromJson(
map,
schema: schema ?? ExtConfigKeySchema.defaults(),
fieldMapping: fieldMapping,
);
}
static Map<String, dynamic>? parseRawMap(String? extConfigJson) {
if (extConfigJson == null || extConfigJson.isEmpty) return null;
try {
final decoded = json.decode(extConfigJson);
if (decoded is Map<String, dynamic>) return decoded;
return null;
} catch (_) {
return null;
}
}
static ExtConfigData fromJson(
Map<String, dynamic> map, {
required ExtConfigKeySchema schema,
FieldMapping fieldMapping = kIdentityFieldMapping,
}) {
final showVideo = _readBoolFromKeys(map, schema.showVideoMenuKeys);
bool? allowShot;
allowShot = _readBoolFromKeys(map, schema.allowScreenshotKeys);
if (allowShot == null && schema.blockScreenshotKeys.isNotEmpty) {
for (final k in schema.blockScreenshotKeys) {
if (!map.containsKey(k)) continue;
final block = _readBool(map, k) == true;
allowShot = !block;
break;
}
}
final third =
_readBoolFromKeys(map, schema.allowThirdPartyPaymentKeys);
final privacy = _readStringFromKeys(map, schema.privacyUrlKeys);
final agreement = _readStringFromKeys(map, schema.agreementUrlKeys);
final rawItems = _firstListFromKeys(map, schema.itemsKeys);
final List<ExtConfigItem> items = [];
if (rawItems != null) {
for (final e in rawItems) {
if (e is! Map) continue;
final rawItem = Map<String, dynamic>.from(e);
final mappedItem = fieldMapping.mapResponse(rawItem);
final imgNeedFromMap = ExtConfigItem._readIntFromKeys(
mappedItem,
schema.taskWireKeysFor('imgNeed', schema.itemImgNeedKeys),
);
final taskMap = schema.taskItemMapping;
if (taskMap != null && taskMap.isNotEmpty) {
final src = schema.itemsApplyFieldMappingBeforeTaskMapping
? mappedItem
: rawItem;
final taskJson =
buildTaskItemShapeFromExtItemMap(src, taskMap);
final task = TaskItem.fromJson(taskJson);
items.add(
ExtConfigItem.fromTaskItem(
task,
categoryLabel: '',
imgNeedOverride: imgNeedFromMap,
).withDefaultTitleIfEmpty(schema),
);
} else if (_itemMapLooksLikeTaskPayload(mappedItem)) {
final task = TaskItem.fromJson(mappedItem);
items.add(
ExtConfigItem.fromTaskItem(
task,
categoryLabel: '',
imgNeedOverride: imgNeedFromMap,
).withDefaultTitleIfEmpty(schema),
);
} else {
items.add(
ExtConfigItem.fromJson(mappedItem, schema: schema)
.withDefaultTitleIfEmpty(schema),
);
}
}
}
return ExtConfigData(
showVideoMenu: showVideo,
allowScreenshot: allowShot,
allowThirdPartyPayment: third,
privacyUrl: privacy,
agreementUrl: agreement,
items: items,
);
}
/// 与 [ImageApi._parseTasksPayload] 中单项一致:线网嵌套(anchor/slice/factor/span…)映射后的逻辑结构。
static bool _itemMapLooksLikeTaskPayload(Map<String, dynamic> m) {
return m.containsKey('previewVideo') ||
m.containsKey('previewImage') ||
m.containsKey('resolution480p') ||
m.containsKey('resolution720p') ||
m.containsKey('imageUrl') ||
m.containsKey('templateUrl');
}
static bool? _readBoolFromKeys(Map<String, dynamic> map, List<String> keys) {
for (final k in keys) {
if (!map.containsKey(k)) continue;
return _readBool(map, k);
}
return null;
}
static String? _readStringFromKeys(
Map<String, dynamic> map,
List<String> keys,
) {
for (final k in keys) {
if (!map.containsKey(k)) continue;
final s = _readString(map, k);
if (s != null && s.isNotEmpty) return s;
}
return null;
}
static List<dynamic>? _firstListFromKeys(
Map<String, dynamic> map,
List<String> keys,
) {
for (final k in keys) {
if (!map.containsKey(k)) continue;
final v = map[k];
if (v is List) return v;
}
return null;
}
static bool? _readBool(Map<String, dynamic> map, String key) {
final v = map[key];
if (v is bool) return v;
if (v is String) {
if (v == 'true') return true;
if (v == 'false') return false;
}
return null;
}
static String? _readString(Map<String, dynamic> map, String key) {
final v = map[key];
if (v == null) return null;
if (v is String) return v.isEmpty ? null : v;
return v.toString();
}
}
/// `extConfig.items` 单项。
class ExtConfigItem {
const ExtConfigItem({
required this.image,
this.imageFix,
this.imgNeed,
required this.cost,
this.cost480p,
this.cost720p,
required this.title,
this.templateName,
this.taskType,
this.params,
this.detail,
this.videoUrl,
});
final String image;
final String? imageFix;
final int? imgNeed;
final int cost;
/// 480p 输出预估积分;与 [cost720p] 均为空时由业务按 [cost] 推导。
final int? cost480p;
/// 720p 输出预估积分;为空时业务可用 [cost] 作为 720p。
final int? cost720p;
final String title;
/// 创建任务时传给后端的模板名(与 [app_client] `TaskItem.templateName` → `congregation` 一致);为空时业务侧可回退为 [title]。
final String? templateName;
/// 创建任务时的任务类型(`cipher`;列表项 `liaison` 映射为逻辑 `taskType`)。
final String? taskType;
final String? params;
final String? detail;
/// 预览视频地址(common_info 或业务自定义键,见 [ExtConfigKeySchema.itemVideoUrlKeys])。
/// 与 [image] 并列:有值则优先作全屏循环播放。
final String? videoUrl;
/// 是否按视频展示:`videoUrl` 非空,或 [image]/[imageFix] URL 明显为视频资源。
bool get isVideoItem {
final v = videoUrl?.trim();
if (v != null && v.isNotEmpty) return true;
if (_urlLooksLikeVideo(image)) return true;
final fix = imageFix?.trim();
if (fix != null && fix.isNotEmpty && _urlLooksLikeVideo(fix)) return true;
return false;
}
/// 首页 / 顶栏 images Tab 是否计入该项:不要求填满 [TaskItem] 所有字段;
/// 至少存在可展示的标题、封面、兜底图、预览视频,或可用于生图的任务标识(模板/类型/params)之一即可。
bool get isUsableOnHome {
if (title.trim().isNotEmpty) return true;
if (image.trim().isNotEmpty) return true;
final v = videoUrl?.trim();
if (v != null && v.isNotEmpty) return true;
final f = imageFix?.trim();
if (f != null && f.isNotEmpty) return true;
if (templateName?.trim().isNotEmpty ?? false) return true;
if (taskType?.trim().isNotEmpty ?? false) return true;
if (params?.trim().isNotEmpty ?? false) return true;
return false;
}
/// 仅用于 [ExtConfigData.fromJson] 解析 `items`:无展示标题时填入 [ExtConfigKeySchema.defaultItemTitleWhenEmpty]。
ExtConfigItem withDefaultTitleIfEmpty(ExtConfigKeySchema schema) {
if (title.trim().isNotEmpty) return this;
final fill = schema.defaultItemTitleWhenEmpty;
if (fill.isEmpty) return this;
return ExtConfigItem(
image: image,
imageFix: imageFix,
imgNeed: imgNeed,
cost: cost,
cost480p: cost480p,
cost720p: cost720p,
title: fill,
templateName: templateName,
taskType: taskType,
params: params,
detail: detail,
videoUrl: videoUrl,
);
}
static bool _urlLooksLikeVideo(String url) {
final u = url.trim().toLowerCase();
if (u.isEmpty) return false;
const hints = ['.mp4', '.m3u8', '.webm', '.mov', '.mkv'];
return hints.any((h) => u.contains(h));
}
String get taskExt => (params != null && params!.isNotEmpty)
? params!
: (detail ?? '');
/// 与 [VideoHomeRuntime] 中网关模板列表 → [ExtConfigItem] 的转换一致(便于 `extConfig.defaults.items` 与接口同形)。
factory ExtConfigItem.fromTaskItem(
TaskItem t, {
String categoryLabel = '',
int? imgNeedOverride,
}) {
final name = (t.title ?? t.templateName ?? t.name ?? '').trim();
final cat = categoryLabel.trim();
final display = cat.isEmpty
? (name.isNotEmpty ? name : cat)
: (name.isNotEmpty ? '$cat · $name' : cat);
final img = (t.imageUrl ?? t.templateUrl ?? '').trim();
final v = t.previewVideoUrl?.trim();
final backendTemplate = (t.templateName ?? t.name ?? '').trim();
final tt = t.taskType?.trim();
final extOnly = t.ext?.trim();
final c480 = t.credits480p;
final c720 = t.credits720p;
return ExtConfigItem(
image: img,
imageFix: null,
imgNeed: imgNeedOverride ?? 1,
cost: c720 ?? c480 ?? 0,
cost480p: c480,
cost720p: c720,
title: display,
templateName:
backendTemplate.isNotEmpty ? backendTemplate : null,
taskType: tt != null && tt.isNotEmpty ? tt : null,
params: extOnly != null && extOnly.isNotEmpty ? extOnly : null,
detail: null,
videoUrl: v != null && v.isNotEmpty ? v : null,
);
}
/// 线网键解析:[ExtConfigKeySchema.homeWireKeysFor] / [ExtConfigKeySchema.taskWireKeysFor];
/// 积分类为 task → home → [itemKeys] 回退链。
factory ExtConfigItem.fromJson(
Map<String, dynamic> json, {
required ExtConfigKeySchema schema,
}) {
final costKeys = schema.taskWireKeysFor(
'cost',
schema.homeWireKeysFor('cost', schema.itemCostKeys),
);
final cost480Keys = schema.taskWireKeysFor(
'cost480p',
schema.homeWireKeysFor('cost480p', schema.itemCost480pKeys),
);
final cost720Keys = schema.taskWireKeysFor(
'cost720p',
schema.homeWireKeysFor('cost720p', schema.itemCost720pKeys),
);
return ExtConfigItem(
image: _readStringFromKeys(
json,
schema.homeWireKeysFor('image', schema.itemImageKeys),
) ??
'',
imageFix: _readStringFromKeysOptional(
json,
schema.homeWireKeysFor('imageFix', schema.itemImageFixKeys),
),
imgNeed: _readIntFromKeys(
json,
schema.taskWireKeysFor('imgNeed', schema.itemImgNeedKeys),
),
cost: _readIntFromKeys(json, costKeys) ?? 0,
cost480p: _readIntFromKeys(json, cost480Keys),
cost720p: _readIntFromKeys(json, cost720Keys),
title: _readStringFromKeys(
json,
schema.homeWireKeysFor('title', schema.itemTitleKeys),
) ??
'',
templateName: _readStringFromKeysOptional(
json,
schema.taskWireKeysFor('templateName', schema.itemTemplateNameKeys),
),
taskType: _readStringFromKeysOptional(
json,
schema.taskWireKeysFor('taskType', schema.itemTaskTypeKeys),
),
params: _readStringFromKeysOptional(
json,
schema.taskWireKeysFor('params', schema.itemParamsKeys),
),
detail: _readStringFromKeysOptional(
json,
schema.taskWireKeysFor('detail', schema.itemDetailKeys),
),
videoUrl: _readStringFromKeysOptional(
json,
schema.homeWireKeysFor('videoUrl', schema.itemVideoUrlKeys),
),
);
}
static String? _readStringFromKeys(
Map<String, dynamic> map,
List<String> keys,
) {
for (final k in keys) {
if (!map.containsKey(k)) continue;
final v = map[k];
if (v == null) continue;
if (v is String) return v.isEmpty ? null : v;
return v.toString();
}
return null;
}
static String? _readStringFromKeysOptional(
Map<String, dynamic> map,
List<String> keys,
) {
if (keys.isEmpty) return null;
for (final k in keys) {
if (!map.containsKey(k)) continue;
final v = map[k];
if (v == null) continue;
if (v is String) return v.isEmpty ? null : v;
return v.toString();
}
return null;
}
static int? _readIntFromKeys(Map<String, dynamic> map, List<String> keys) {
for (final k in keys) {
if (!map.containsKey(k)) continue;
return _toInt(map[k]);
}
return null;
}
static int? _toInt(dynamic v) {
if (v == null) return null;
if (v is int) return v;
if (v is num) return v.toInt();
if (v is String && v.isNotEmpty) return int.tryParse(v);
return null;
}
}
/// 按 `skin_config.extConfig.taskItemMapping` 从 `ext` 单项拼出 [TaskItem.fromJson] 可用的 Map(与 `/v1/image/img2video/tasks` 单项在 mapResponse 后同形)。
Map<String, dynamic> buildTaskItemShapeFromExtItemMap(
Map<String, dynamic> src,
Map<String, List<String>> mapping,
) {
final out = <String, dynamic>{};
for (final e in mapping.entries) {
final targetPath = e.key.trim();
if (targetPath.isEmpty) continue;
final sources = e.value
.map((s) => s.toString().trim())
.where((s) => s.isNotEmpty)
.toList();
if (sources.isEmpty) continue;
dynamic chosen;
for (final p in sources) {
final v = _mapReadDotPath(src, p);
if (v == null) continue;
if (v is String && v.trim().isEmpty) continue;
chosen = v;
break;
}
if (chosen == null) continue;
final coerced = targetPath.endsWith('.credits')
? _coerceCreditsValue(chosen)
: chosen;
if (coerced == null) continue;
_mapSetAtDotPath(out, targetPath, coerced);
}
return out;
}
int? _coerceCreditsValue(dynamic v) {
if (v is int) return v;
if (v is num) return v.toInt();
if (v is String && v.isNotEmpty) return int.tryParse(v.trim());
return null;
}
dynamic _mapReadDotPath(Map<String, dynamic> root, String dotPath) {
final parts = dotPath.split('.').where((s) => s.isNotEmpty).toList();
if (parts.isEmpty) return null;
dynamic cur = root;
for (final p in parts) {
if (cur is! Map) return null;
final m = Map<String, dynamic>.from(cur);
cur = m[p];
if (cur == null) return null;
}
return cur;
}
void _mapSetAtDotPath(Map<String, dynamic> root, String dotPath, dynamic value) {
final parts = dotPath.split('.').where((s) => s.isNotEmpty).toList();
if (parts.isEmpty) return;
Map<String, dynamic> cur = root;
for (var i = 0; i < parts.length - 1; i++) {
final p = parts[i];
final next = cur[p];
if (next is! Map<String, dynamic>) {
cur[p] = <String, dynamic>{};
}
cur = cur[p]! as Map<String, dynamic>;
}
cur[parts.last] = value;
}
const int kExtConfigItemsCategoryId = -1;
List<T> mergeHomeTabsWithExtConfigItems<T>({
required List<T> apiTabs,
required ExtConfigData? ext,
required T staticTab,
}) {
if (ext?.showVideoMenu != true) return apiTabs;
return [...apiTabs, staticTab];
}

View File

@ -0,0 +1,63 @@
import 'package:flutter/foundation.dart';
import '../api/api_client.dart';
import '../entities/user_entities.dart';
import 'ext_config_key_schema.dart';
import 'ext_config_models.dart';
/// [common_info] 拉取成功后的 `extConfig` 运行时视图。
///
/// 由 [FrameworkAuthService] 在成功解析 [CommonInfoResponse] 后自动更新;
/// 宿主用 [data]、[commonInfoSucceeded] 驱动首页 Tab、支付开关、防截屏策略等。
abstract final class ExtConfigRuntime {
/// 最近一次根据 `extConfig` 解析出的数据;解析失败或尚未拉到 common_info 时为 `null`。
static final ValueNotifier<ExtConfigData?> data = ValueNotifier<ExtConfigData?>(null);
/// `null`:本轮会话尚未结束 common_info 请求;`true`:成功(含 extConfig 解析);`false`:接口失败。
///
/// **建议**:仅当 `FrameworkAuthService.isLoginComplete` 为 true 且本值为 `true` 时,
/// 再展示依赖后台下发的核心业务界面(与用户约定「common_info 成功后才展示应用内容」一致)。
static final ValueNotifier<bool?> commonInfoSucceeded =
ValueNotifier<bool?>(null);
/// 在 [FrameworkAuthService] 内调用:common_info 成功。
static void applyCommonInfoSuccess(CommonInfoResponse response) {
commonInfoSucceeded.value = true;
final cfg = ApiClient.instance.config;
final schema = cfg.extConfigKeySchema;
final defaults = cfg.extConfigDefaults;
final serverMap = ExtConfigData.parseRawMap(response.extConfig);
final merged = SkinExtConfigSection.mergeDefaults(
base: defaults,
server: serverMap,
);
if (merged == null || merged.isEmpty) {
data.value = ExtConfigData.empty();
} else {
try {
data.value = ExtConfigData.fromJson(
merged,
schema: schema,
fieldMapping: cfg.fieldMapping,
);
} catch (e, st) {
if (kDebugMode) {
debugPrint('[ExtConfigRuntime] fromJson failed: $e\n$st');
}
data.value = ExtConfigData.empty();
}
}
}
/// 在 [FrameworkAuthService] 内调用:common_info 失败或无效响应。
static void applyCommonInfoFailure() {
commonInfoSucceeded.value = false;
data.value = null;
}
/// 单元测试或宿主调试时重置会话态。
static void resetForTest() {
data.value = null;
commonInfoSucceeded.value = null;
}
}

View File

@ -1,13 +1,12 @@
/// 字段映射配置
///
/// 单一映射表:**原始字段名 → 后端字段名**(canonical → V2)。
/// 请求时按此表转换,响应时自动取反(V2 → 原始)。
/// 后端给的映射表通常也不区分方向,直接填入即可。
/// 单一映射表:**业务逻辑字段名 → 线网(V2)字段名**。
/// 请求 [mapRequest]:逻辑名 → 线网名;响应 [mapResponse]:线网名 → 逻辑名。
/// 全量表由各宿主 `skin_config.json` 的 `fieldMapping` 提供;未配置时使用 [kIdentityFieldMapping]。
class FieldMapping {
const FieldMapping(this.mapping);
/// 原始字段名 → 后端字段名
/// 如 {'deviceId': 'origin', 'userId': 'asset', 'userToken': 'reevaluate'}
/// 业务逻辑字段名 → 线网字段名
final Map<String, String> mapping;
Map<String, String> get _inverse {
@ -16,10 +15,10 @@ class FieldMapping {
);
}
/// 获取映射后的字段名,如果不存在则返回原始字段名
/// 逻辑字段名 → 线网字段名;无映射则不变
String mapField(String original) => mapping[original] ?? original;
/// 获取反向映射的字段名(V2 → 原始),如果不存在则返回原始字段名
/// 线网字段名 → 逻辑字段名;无映射则不变
String reverseMapField(String v2) => _inverse[v2] ?? v2;
/// 将 Map 的 key 从原始名转为后端名(请求)
@ -81,3 +80,6 @@ class FieldMapping {
/// 请求头:用户 token 字段名
String get headerUserTokenField => mapField('User_token');
}
/// 恒等映射:未配置 `skin_config.json` 的 `fieldMapping` 时使用(逻辑名与线网名相同)。
const FieldMapping kIdentityFieldMapping = FieldMapping({});

View File

@ -6,7 +6,7 @@ import '../log/sdk_reminder_log.dart';
import '../services/analytics_service.dart';
import 'app_config.dart';
import 'attribution_config.dart';
import 'default_field_mapping.dart';
import 'ext_config_key_schema.dart';
import 'field_mapping.dart';
/// JSON 换皮配置(单文件描述 API、归因、字段映射等)。
@ -36,9 +36,20 @@ class SkinConfig implements AppConfig {
required this.fieldMapping,
required Map<String, String> adjustEvents,
this.analyticsJson,
}) : _adjustEvents = adjustEvents;
SkinExtConfigSection? skinExtConfig,
required this.videoHomeImagesTabLabel,
required this.videoHomeImagesTabFirst,
}) : _skinExtConfig = skinExtConfig,
_adjustEvents = adjustEvents;
@override
final String videoHomeImagesTabLabel;
@override
final bool videoHomeImagesTabFirst;
final Map<String, dynamic>? analyticsJson;
final SkinExtConfigSection? _skinExtConfig;
final Map<String, String> _adjustEvents;
/// 从 JSON 文本解析(如 `rootBundle.loadString` 的结果)。
@ -97,22 +108,16 @@ class SkinConfig implements AppConfig {
final proxyKeys = _proxyKeysFromJson(json['proxyKeys']);
final v2 = json['v2'];
String v2Level = 'arsenal';
int v2Fixed = 4;
List<String> sanctum = const [
'vault',
'tome',
'codex',
'grimoire',
'sanctum',
];
String v2Level = 'level';
int v2Fixed = 1;
List<String> sanctum = const ['wrapper', 'layer', 'payload'];
List<String> v2Noise = const [
'roar',
'clash',
'thunder',
'rumble',
'howl',
'growl',
'n1',
'n2',
'n3',
'n4',
'n5',
'n6',
];
if (v2 is Map<String, dynamic>) {
v2Level = v2['levelField'] as String? ?? v2Level;
@ -130,10 +135,10 @@ class SkinConfig implements AppConfig {
FieldMapping mapping;
final fmRaw = json['fieldMapping'];
if (fmRaw == null) {
mapping = petsHeroAIFieldMapping;
mapping = kIdentityFieldMapping;
} else if (fmRaw is Map<String, dynamic>) {
if (fmRaw.isEmpty) {
mapping = petsHeroAIFieldMapping;
mapping = kIdentityFieldMapping;
} else {
final m = <String, String>{};
for (final e in fmRaw.entries) {
@ -156,6 +161,23 @@ class SkinConfig implements AppConfig {
}
}
SkinExtConfigSection? skinExt;
final extCfg = json['extConfig'];
if (extCfg is Map<String, dynamic>) {
skinExt = SkinExtConfigSection.fromRootJson(extCfg);
}
var videoHomeImagesTabLabel = 'Images';
var videoHomeImagesTabFirst = false;
final vh = json['videoHome'];
if (vh is Map<String, dynamic>) {
final rawLabel = vh['imagesTabLabel'];
if (rawLabel is String && rawLabel.trim().isNotEmpty) {
videoHomeImagesTabLabel = rawLabel.trim();
}
videoHomeImagesTabFirst = vh['imagesTabFirst'] as bool? ?? false;
}
return SkinConfig._(
appName: name,
appId: id,
@ -176,6 +198,9 @@ class SkinConfig implements AppConfig {
fieldMapping: mapping,
adjustEvents: events,
analyticsJson: json['analytics'] as Map<String, dynamic>?,
skinExtConfig: skinExt,
videoHomeImagesTabLabel: videoHomeImagesTabLabel,
videoHomeImagesTabFirst: videoHomeImagesTabFirst,
);
}
@ -191,25 +216,18 @@ class SkinConfig implements AppConfig {
if (v is! Map<String, dynamic>) {
throw FormatException('proxyKeys must be a JSON object');
}
List<String> noise = const [
'billing_addr',
'utm_term',
'cluster_id',
'lsn_value',
'accuracy_val',
'dir_path',
];
List<String> noise = const ['noise1', 'noise2', 'noise3', 'noise4'];
final nk = v['noiseKeys'];
if (nk is List && nk.isNotEmpty) {
noise = nk.map((e) => e.toString()).toList();
}
return ProxyKeysConfig(
appIdField: v['appIdField'] as String? ?? 'hero_class',
pathField: v['pathField'] as String? ?? 'pet_species',
methodField: v['methodField'] as String? ?? 'power_level',
headerField: v['headerField'] as String? ?? 'quest_rank',
paramsField: v['paramsField'] as String? ?? 'battle_score',
bodyField: v['bodyField'] as String? ?? 'loyalty_index',
appIdField: v['appIdField'] as String? ?? 'appId',
pathField: v['pathField'] as String? ?? 'path',
methodField: v['methodField'] as String? ?? 'method',
headerField: v['headerField'] as String? ?? 'headers',
paramsField: v['paramsField'] as String? ?? 'params',
bodyField: v['bodyField'] as String? ?? 'body',
noiseKeys: noise,
);
}
@ -275,6 +293,13 @@ class SkinConfig implements AppConfig {
@override
final FieldMapping fieldMapping;
@override
ExtConfigKeySchema get extConfigKeySchema =>
_skinExtConfig?.keySchema ?? ExtConfigKeySchema.defaults();
@override
Map<String, dynamic>? get extConfigDefaults => _skinExtConfig?.defaults;
@override
Map<String, dynamic> get v2FixedValues => {v2LevelField: v2LevelFixedValue};

View File

@ -8,6 +8,10 @@
"iosAppType": "HIOS",
"androidAppType": "HAndroid"
},
"videoHome": {
"imagesTabLabel": "Images",
"imagesTabFirst": false
},
"api": {
"preBaseUrl": "https://pre-api.example.com",
"prodBaseUrl": "https://api.example.com",
@ -17,24 +21,24 @@
"aesKey": "1234567890123456"
},
"proxyKeys": {
"appIdField": "hero_class",
"pathField": "pet_species",
"methodField": "power_level",
"headerField": "quest_rank",
"paramsField": "battle_score",
"bodyField": "loyalty_index",
"noiseKeys": ["billing_addr", "utm_term", "cluster_id", "lsn_value", "accuracy_val", "dir_path"]
"appIdField": "appId",
"pathField": "path",
"methodField": "method",
"headerField": "headers",
"paramsField": "params",
"bodyField": "body",
"noiseKeys": ["noise1", "noise2", "noise3", "noise4"]
},
"v2": {
"levelField": "arsenal",
"levelFixedValue": 4,
"sanctumPath": ["vault", "tome", "codex", "grimoire", "sanctum"],
"noiseKeys": ["roar", "clash", "thunder", "rumble", "howl", "growl"]
"levelField": "level",
"levelFixedValue": 1,
"sanctumPath": ["wrapper", "layer", "payload"],
"noiseKeys": ["n1", "n2", "n3", "n4", "n5", "n6"]
},
"fieldMapping": {
"code": "helm",
"msg": "rampart",
"data": "sidekick"
"code": "httpCode",
"msg": "message",
"data": "payload"
},
"analytics": {
"debugLogs": false,
@ -53,5 +57,49 @@
"adjustEvents": {
"register": "abc123",
"purchase": "def456"
},
"extConfig": {
"keys": {
"showVideoMenu": ["go_run", "need_wait"],
"allowScreenshot": ["screen"],
"blockScreenshot": ["safe_area"],
"allowThirdPartyPayment": ["san_fang", "lucky"],
"privacyUrl": ["privacy"],
"agreementUrl": ["agreement"],
"items": ["items"]
},
"itemKeys": {
"image": ["image"],
"imageFix": ["image_fix"],
"imgNeed": ["img_need"],
"cost": ["cost"],
"title": ["title"],
"params": ["params"],
"detail": ["detail"],
"videoUrl": ["video", "video_url", "videoUrl", "preview_video"]
},
"itemKeysHome": {},
"itemKeysTask": {},
"itemsApplyFieldMappingBeforeTaskMapping": true,
"defaultItemTitle": "-",
"taskItemMapping": {
"imageUrl": ["imageUrl", "image"],
"previewImage.url": ["previewImage.url", "image"],
"previewVideo.url": ["previewVideo.url", "anchor.altitude"],
"title": ["title", "interval"],
"templateName": ["templateName", "itinerary"],
"taskType": ["taskType", "liaison"],
"ext": ["ext", "profile"],
"resolution480p.credits": ["resolution480p.credits", "span.padding", "cost"],
"resolution720p.credits": ["resolution720p.credits", "factor.padding", "cost"]
},
"defaults": {
"go_run": false,
"screen": true,
"san_fang": false,
"privacy": "https://example.com/privacy",
"agreement": "https://example.com/terms",
"items": []
}
}
}

View File

@ -0,0 +1,319 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../api/api_client.dart';
import '../entities/image_entities.dart';
import '../services/image_api.dart';
import 'ext_config_models.dart';
import 'ext_config_runtime.dart';
/// 与接口返回的分类并列的「虚拟」Tab:对应 [ExtConfigData.items](文案见 [AppConfig.videoHomeImagesTabLabel])。
class VideoHomeTab {
const VideoHomeTab._({required this.label, this.categoryId});
factory VideoHomeTab.images({required String label}) =>
VideoHomeTab._(label: label, categoryId: null);
factory VideoHomeTab.network({
required String label,
required int categoryId,
}) =>
VideoHomeTab._(label: label, categoryId: categoryId);
final String label;
/// `null` 表示 images(extConfig.items);非 null 为服务端分类 id,需请求模板列表。
final int? categoryId;
bool get isImages => categoryId == null;
}
/// `common_info` 成功且 [ExtConfigData.showVideoMenu] 为 true 时:拉取分类列表,合并 extConfig.items 为 images Tab,
/// 默认选中第一个**服务端**分类;非 images Tab 在切换时按需请求 [ImageApi.getImg2VideoTasks](`GET /v1/image/img2video/tasks`)。
///
/// 顶栏需 `go_run` / `need_wait` 等为 true(见 `extConfig.keys.showVideoMenu`)才会 [hydrateAfterCommonInfo];否则 [reset] 且无 images Tab。
abstract final class VideoHomeRuntime {
VideoHomeRuntime._();
static final ValueNotifier<VideoHomeSnapshot> snapshot =
ValueNotifier<VideoHomeSnapshot>(const VideoHomeSnapshot());
/// 当前选中的**分类** Tab 索引(与 [VideoHomeSnapshot.tabs] 对齐)。
static final ValueNotifier<int> selectedTabIndex = ValueNotifier<int>(0);
static String? _lastUserId;
static String? _lastApp;
/// 在 [ExtConfigRuntime.applyCommonInfoSuccess] 之后由 [FrameworkAuthService] 调用。
static Future<void> hydrateAfterCommonInfo({
required String userId,
required String app,
}) async {
final ext = ExtConfigRuntime.data.value;
if (ext?.showVideoMenu != true || userId.isEmpty) {
reset();
return;
}
_lastUserId = userId;
_lastApp = app;
final cfg = ApiClient.instance.config;
final imagesLabel = cfg.videoHomeImagesTabLabel;
final imagesFirst = cfg.videoHomeImagesTabFirst;
final prev = snapshot.value;
/// [hydrateAfterCommonInfo] 开始时顶栏分类下标;用于区分「首次默认」与用户在 `await` 期间已切换 tab。
final selectedIndexBeforeHydrate = selectedTabIndex.value;
snapshot.value = VideoHomeSnapshot(
loading: true,
error: null,
tabs: prev.tabs,
networkItemsByCategoryId: prev.networkItemsByCategoryId,
loadingCategoryIds: {},
);
List<ExtConfigItem> visibleExtItems(ExtConfigData? e) =>
e?.items.where((it) => it.isUsableOnHome).toList() ?? [];
try {
final catRes = await ImageApi.getCategoryList();
final extItems = visibleExtItems(ext);
final hasImagesTab = extItems.isNotEmpty;
List<CategoryItem> apiCategories = [];
if (catRes.isSuccess && catRes.data?.categories != null) {
apiCategories = catRes.data!.categories!;
}
final withId = apiCategories.where((c) => c.id != null).toList();
final tabs = <VideoHomeTab>[];
void pushImages() {
if (hasImagesTab) {
tabs.add(VideoHomeTab.images(label: imagesLabel));
}
}
void pushNetwork() {
for (final c in withId) {
final id = c.id!;
final name = c.name?.trim();
final label =
name != null && name.isNotEmpty ? name : 'Category $id';
tabs.add(VideoHomeTab.network(label: label, categoryId: id));
}
}
if (imagesFirst) {
pushImages();
pushNetwork();
} else {
pushNetwork();
pushImages();
}
if (tabs.isEmpty) {
final msg = !catRes.isSuccess
? (catRes.msg.isNotEmpty ? catRes.msg : 'Category list failed')
: null;
snapshot.value = VideoHomeSnapshot(
loading: false,
error: msg,
tabs: const [],
networkItemsByCategoryId: const {},
loadingCategoryIds: const {},
);
selectedTabIndex.value = 0;
return;
}
// 若已有并发 hydrate 先写入了非空 tabs,本次只 clamp,不强制默认(避免覆盖用户已选)。
final concurrentTabsAlready = snapshot.value.tabs.isNotEmpty;
snapshot.value = VideoHomeSnapshot(
loading: false,
error: catRes.isSuccess ? null : catRes.msg,
tabs: tabs,
networkItemsByCategoryId:
_mergeNetworkItemsByCategoryId(prev.networkItemsByCategoryId, tabs),
loadingCategoryIds: const {},
);
// 首次从「无分类」建 tabs 时用默认(首个网络分类);再次 hydrate 时保留当前分类。
// 首次若在 await 分类接口期间用户已切到最后一个 images 等 tab,不得再强制改回默认。
final defaultIdx = _defaultTabIndex(tabs);
final int nextTabIdx;
if (concurrentTabsAlready) {
nextTabIdx =
selectedTabIndex.value.clamp(0, tabs.length - 1).toInt();
} else if (prev.tabs.isEmpty) {
final userChangedDuringAwait =
selectedTabIndex.value != selectedIndexBeforeHydrate;
if (userChangedDuringAwait) {
nextTabIdx =
selectedTabIndex.value.clamp(0, tabs.length - 1).toInt();
} else {
nextTabIdx = defaultIdx.clamp(0, tabs.length - 1).toInt();
}
} else {
nextTabIdx =
selectedTabIndex.value.clamp(0, tabs.length - 1).toInt();
}
selectedTabIndex.value = nextTabIdx;
unawaited(ensureTabItems(selectedTabIndex.value));
} catch (e, st) {
if (kDebugMode) {
debugPrint('[VideoHomeRuntime] hydrate failed: $e\n$st');
}
snapshot.value = VideoHomeSnapshot(
loading: false,
error: e.toString(),
tabs: const [],
networkItemsByCategoryId: const {},
loadingCategoryIds: const {},
);
selectedTabIndex.value = 0;
}
}
/// 第一个**服务端**分类;若仅有 images Tab 则为 0。
static int _defaultTabIndex(List<VideoHomeTab> tabs) {
final i = tabs.indexWhere((t) => !t.isImages);
return i >= 0 ? i : 0;
}
/// 重新拉取分类列表后,保留仍存在于新 [tabs] 中的分类模板缓存,避免展平页数骤减触发首页 [PageView] 误跳转。
static Map<int, List<ExtConfigItem>> _mergeNetworkItemsByCategoryId(
Map<int, List<ExtConfigItem>> previous,
List<VideoHomeTab> tabs,
) {
final validIds = <int>{};
for (final t in tabs) {
if (!t.isImages && t.categoryId != null) {
validIds.add(t.categoryId!);
}
}
if (validIds.isEmpty || previous.isEmpty) return const {};
final out = <int, List<ExtConfigItem>>{};
for (final e in previous.entries) {
if (validIds.contains(e.key)) {
out[e.key] = e.value;
}
}
return out;
}
/// 确保当前 Tab 已有模板列表(images 用 ext;网络分类按需请求)。
static Future<void> ensureTabItems(int index) async {
final uid = _lastUserId;
final app = _lastApp;
if (uid == null || uid.isEmpty || app == null || app.isEmpty) return;
final snap = snapshot.value;
if (index < 0 || index >= snap.tabs.length) return;
final tab = snap.tabs[index];
if (tab.isImages) return;
final id = tab.categoryId!;
if (snap.networkItemsByCategoryId.containsKey(id)) return;
if (snap.loadingCategoryIds.contains(id)) return;
snapshot.value = snap.copyWith(
loadingCategoryIds: {...snap.loadingCategoryIds, id},
);
try {
final r = await ImageApi.getImg2VideoTasks(categoryId: id);
final cur = snapshot.value;
if (index >= cur.tabs.length || cur.tabs[index].categoryId != id) {
return;
}
final label = cur.tabs[index].label;
if (!r.isSuccess || r.data?.tasks == null) {
snapshot.value = cur.copyWith(
loadingCategoryIds: {...cur.loadingCategoryIds}..remove(id),
networkItemsByCategoryId: {
...cur.networkItemsByCategoryId,
id: const [],
},
);
return;
}
final list = <ExtConfigItem>[];
for (final t in r.data!.tasks!) {
list.add(ExtConfigItem.fromTaskItem(t, categoryLabel: label));
}
final cur2 = snapshot.value;
snapshot.value = cur2.copyWith(
loadingCategoryIds: {...cur2.loadingCategoryIds}..remove(id),
networkItemsByCategoryId: {
...cur2.networkItemsByCategoryId,
id: list,
},
);
} catch (e, st) {
if (kDebugMode) {
debugPrint('[VideoHomeRuntime] ensureTabItems: $e\n$st');
}
final cur = snapshot.value;
snapshot.value = cur.copyWith(
loadingCategoryIds: {...cur.loadingCategoryIds}..remove(id),
networkItemsByCategoryId: {
...cur.networkItemsByCategoryId,
id: const [],
},
);
}
}
static void reset() {
_lastUserId = null;
_lastApp = null;
snapshot.value = const VideoHomeSnapshot();
selectedTabIndex.value = 0;
}
}
class VideoHomeSnapshot {
const VideoHomeSnapshot({
this.loading = false,
this.error,
this.tabs = const [],
this.networkItemsByCategoryId = const {},
this.loadingCategoryIds = const {},
});
final bool loading;
final String? error;
/// 含 images(ext)与接口分类;顺序由 [AppConfig.videoHomeImagesTabFirst] 决定。
final List<VideoHomeTab> tabs;
/// 仅缓存**服务端**分类 id → 模板列表。
final Map<int, List<ExtConfigItem>> networkItemsByCategoryId;
final Set<int> loadingCategoryIds;
VideoHomeSnapshot copyWith({
bool? loading,
String? error,
List<VideoHomeTab>? tabs,
Map<int, List<ExtConfigItem>>? networkItemsByCategoryId,
Set<int>? loadingCategoryIds,
}) {
return VideoHomeSnapshot(
loading: loading ?? this.loading,
error: error ?? this.error,
tabs: tabs ?? this.tabs,
networkItemsByCategoryId:
networkItemsByCategoryId ?? this.networkItemsByCategoryId,
loadingCategoryIds: loadingCategoryIds ?? this.loadingCategoryIds,
);
}
}

View File

@ -0,0 +1,26 @@
/// 解析用户积分余额(`credit` / `credits`)。
///
/// 换皮映射下余额多为 `credit`(文档 wire `export` → `credit`)。
/// `credits`/`padding` 在部分接口里可能是 int 余额,也可能是嵌套的积分配置
/// `Map`(如 fast_login 里 `padding: {}`),不能把 Map 当数字解析。
int? parseUserCreditsBalance(Map<String, dynamic> json) {
int? toInt(dynamic value) {
if (value == null) return null;
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String && value.isNotEmpty) {
final i = int.tryParse(value);
if (i != null) return i;
final d = double.tryParse(value);
if (d != null) return d.round();
}
return null;
}
final credit = toInt(json['credit']);
if (credit != null) return credit;
final credits = json['credits'];
if (credits is Map) return null;
return toInt(credits);
}

View File

@ -1,5 +1,8 @@
export 'credits_balance_parse.dart';
export 'entity.dart';
export 'feedback_entities.dart';
export 'gallery_task_models.dart';
export 'image_entities.dart';
export 'payment_entities.dart';
export 'task_id_parse.dart';
export 'user_entities.dart';

View File

@ -1,3 +1,5 @@
import 'dart:convert';
import 'entity.dart';
/// 反馈预签名上传 URL 响应
@ -5,17 +7,63 @@ class FeedbackUploadPresignedUrlResponse extends Entity {
FeedbackUploadPresignedUrlResponse({
this.uploadUrl,
this.filePath,
this.putHeaders,
});
final String? uploadUrl;
final String? filePath;
/// 与 [UploadPresignedUrlResponse.putHeaders] 一致:PUT 到对象存储时的额外头。
final Map<String, String>? putHeaders;
static String _headerValueToString(dynamic v) {
if (v == null) return '';
if (v is String) return v;
if (v is num || v is bool) return v.toString();
if (v is List) {
return v.map(_headerValueToString).where((s) => s.isNotEmpty).join(',');
}
if (v is Map) {
return jsonEncode(v);
}
return v.toString();
}
static Map<String, String>? _parsePutHeaders(Map<String, dynamic> json) {
final raw = json['putHeaders'] ??
json['headers'] ??
json['uploadHeaders'] ??
json['requiredHeaders'];
if (raw is List) {
final out = <String, String>{};
for (final item in raw) {
if (item is! Map) continue;
final m = Map<String, dynamic>.from(item);
final name = m['name'] ?? m['Name'] ?? m['key'] ?? m['Key'];
final value = m['value'] ?? m['Value'];
if (name == null) continue;
out[name.toString()] = _headerValueToString(value);
}
return out.isEmpty ? null : out;
}
if (raw is! Map) return null;
final map = Map<String, dynamic>.from(raw);
final out = <String, String>{};
for (final e in map.entries) {
out[e.key.toString()] = _headerValueToString(e.value);
}
return out.isEmpty ? null : out;
}
@override
factory FeedbackUploadPresignedUrlResponse.fromJson(
Map<String, dynamic> json) {
return FeedbackUploadPresignedUrlResponse(
uploadUrl: json['uploadUrl'] as String?,
filePath: json['filePath'] as String?,
putHeaders: _parsePutHeaders(json),
);
}
@ -23,6 +71,7 @@ class FeedbackUploadPresignedUrlResponse extends Entity {
Map<String, dynamic> toJson() => {
'uploadUrl': uploadUrl,
'filePath': filePath,
if (putHeaders != null) 'putHeaders': putHeaders,
};
}

View File

@ -0,0 +1,292 @@
// Logic aligned with app_client gallery task list / cover behavior (data-only).
import 'image_entities.dart';
/// listing 为数字时的英文文案(兼容旧接口)
String _galleryListingLabelEnglish(int listing) {
switch (listing) {
case 1:
return 'Queued';
case 2:
return 'Processing';
case 3:
return 'Completed';
case 4:
return 'Timed out';
case 5:
return 'Error';
case 6:
return 'Aborted';
case 0:
return 'Pending';
default:
return 'Unknown';
}
}
/// 接口 `listing`:字符串原样展示;纯数字或 int 走英文映射
String listingDisplayFromApi(dynamic raw) {
if (raw == null) return '';
if (raw is int) return _galleryListingLabelEnglish(raw);
if (raw is num) return _galleryListingLabelEnglish(raw.toInt());
final s = raw.toString().trim();
if (s.isEmpty) return '';
final asInt = int.tryParse(s);
if (asInt != null && s == asInt.toString()) {
return _galleryListingLabelEnglish(asInt);
}
return s;
}
bool galleryMediaHasRemoteUrl(GalleryMediaItem m) {
bool http(String? x) {
if (x == null) return false;
final t = x.trim();
return t.startsWith('http://') || t.startsWith('https://');
}
return http(m.imageUrl) || http(m.videoUrl);
}
bool galleryListingIsInProgress(dynamic raw, String display) {
final d = display.trim().toLowerCase();
if (d == 'pending' ||
d == 'queued' ||
d == 'processing' ||
d == 'in progress' ||
d == 'running') {
return true;
}
if (raw != null) {
if (raw is int && (raw == 0 || raw == 1 || raw == 2)) return true;
if (raw is num) {
final v = raw.toInt();
if (v == 0 || v == 1 || v == 2) return true;
}
final s = raw.toString().trim().toLowerCase();
if (s == 'pending' ||
s == 'queued' ||
s == 'processing' ||
s == 'in_progress' ||
s == 'in progress' ||
s == 'running') {
return true;
}
final n = int.tryParse(s);
if (n != null && (n == 0 || n == 1 || n == 2)) return true;
}
return false;
}
bool galleryListingIsFinishedSuccess(dynamic raw, String display) {
final d = display.trim().toLowerCase();
if (d == 'finished' ||
d == 'completed' ||
d == 'complete' ||
d == 'success' ||
d == 'done') {
return true;
}
if (raw != null) {
if (raw is int && raw == 3) return true;
if (raw is num && raw.toInt() == 3) return true;
final s = raw.toString().trim().toLowerCase();
if (s == 'finished' ||
s == 'completed' ||
s == 'complete' ||
s == 'success' ||
s == 'done') {
return true;
}
if (int.tryParse(s) == 3) return true;
}
return false;
}
String galleryListingBlockedHint(dynamic raw, String display) {
int? code;
if (raw is int) {
code = raw;
} else if (raw is num) {
code = raw.toInt();
} else if (raw != null) {
final s = raw.toString().trim().toLowerCase();
if (s == 'timeout' || s == 'timed out' || s == 'timed_out') {
code = 4;
} else if (s == 'error' || s == 'failed' || s == 'failure') {
code = 5;
} else if (s == 'aborted' || s == 'cancelled' || s == 'canceled') {
code = 6;
} else {
code = int.tryParse(s);
}
}
switch (code) {
case 4:
return 'This task has timed out.';
case 5:
return 'This task failed. Please try again.';
case 6:
return 'This task was cancelled.';
default:
final low = display.trim().toLowerCase();
if (low.contains('timeout') || low.contains('timed out')) {
return 'This task has timed out.';
}
if (low.contains('error') || low.contains('fail')) {
return 'This task failed. Please try again.';
}
if (low.contains('abort') || low.contains('cancel')) {
return 'This task was cancelled.';
}
return 'This item is not available yet.';
}
}
class GalleryMediaItem {
GalleryMediaItem({
this.imageUrl,
this.videoUrl,
this.taskId,
this.createTime = 0,
this.createTimeText,
this.listingDisplay = '',
this.listingRaw,
}) : assert(
(imageUrl?.isNotEmpty ?? false) ||
(videoUrl?.isNotEmpty ?? false) ||
((taskId ?? 0) > 0),
);
final String? imageUrl;
final String? videoUrl;
final int? taskId;
final int createTime;
final String? createTimeText;
final String listingDisplay;
final dynamic listingRaw;
bool get isVideo =>
videoUrl != null && (imageUrl == null || imageUrl!.isEmpty);
}
/// 我的任务项(与 app_client V2 字段名一致:`tree`、`discover`、`downsample` 等解密后的 business 形态)
class GalleryTaskItem {
const GalleryTaskItem({
required this.taskId,
required this.state,
required this.taskType,
required this.createTime,
required this.mediaItems,
});
final int taskId;
final String state;
final int taskType;
final int createTime;
final List<GalleryMediaItem> mediaItems;
factory GalleryTaskItem.fromJson(Map<String, dynamic> json) {
final treeRaw = json['tree'] as num?;
final treeId = treeRaw?.toInt() ?? 0;
final itemTaskId = treeId > 0 ? treeId : null;
final createTime = (json['discover'] as num?)?.toInt() ?? 0;
final createTimeText = json['uncover'] as String?;
final listingRaw = json['listing'];
final listingDisplay = listingDisplayFromApi(listingRaw);
final downsample = json['downsample'] as List<dynamic>? ?? [];
final items = <GalleryMediaItem>[];
if (downsample.isNotEmpty) {
final first = downsample[0];
if (first is String && first.trim().isNotEmpty) {
items.add(GalleryMediaItem(
imageUrl: first,
taskId: itemTaskId,
createTime: createTime,
createTimeText: createTimeText,
listingDisplay: listingDisplay,
listingRaw: listingRaw,
));
} else if (first is Map<String, dynamic>) {
final reconfigure = first['reconfigure'] as String?;
if (reconfigure != null && reconfigure.isNotEmpty) {
final reconnect = first['reconnect'];
final imgType = reconnect is int
? reconnect
: reconnect is num
? reconnect.toInt()
: 1;
if (imgType == 2) {
items.add(GalleryMediaItem(
videoUrl: reconfigure,
taskId: itemTaskId,
createTime: createTime,
createTimeText: createTimeText,
listingDisplay: listingDisplay,
listingRaw: listingRaw,
));
} else {
items.add(GalleryMediaItem(
imageUrl: reconfigure,
taskId: itemTaskId,
createTime: createTime,
createTimeText: createTimeText,
listingDisplay: listingDisplay,
listingRaw: listingRaw,
));
}
}
}
}
if (items.isEmpty && itemTaskId != null && itemTaskId > 0) {
items.add(GalleryMediaItem(
taskId: itemTaskId,
createTime: createTime,
createTimeText: createTimeText,
listingDisplay: listingDisplay,
listingRaw: listingRaw,
));
}
return GalleryTaskItem(
taskId: treeId,
state: json['listing']?.toString() ?? '',
taskType: (json['cipher'] as num?)?.toInt() ?? 0,
createTime: (json['discover'] as num?)?.toInt() ?? 0,
mediaItems: items,
);
}
}
// --- [MyTaskItem](框架实体)与 app_client 列表语义对齐 ---
/// 优先 [MyTaskItem.state](线网 `bitrate`),否则 [MyTaskItem.status] / `listing` 字符串。
dynamic myTaskListingRaw(MyTaskItem item) {
if (item.state != null) return item.state;
return item.status;
}
bool myTaskHasRemoteResultUrl(MyTaskItem item) {
final u = item.resultUrl?.trim() ?? '';
return u.startsWith('http://') || u.startsWith('https://');
}
/// 列表与 `/v1/image/progress` 字段形态可能不一致:`bitrate` 常为 **pending / finished** 等字符串。
/// **有 https 结果地址即视为可下载**(与「有没有完成看有没有地址」一致);无地址时仅靠状态展示。
bool myTaskCanShowDownload(MyTaskItem item) {
return myTaskHasRemoteResultUrl(item);
}
/// 卡片上展示:`bitrate` 字符串原样经 [listingDisplayFromApi](数字 1–6 仍映射为英文)。
String myTaskStatusLabel(MyTaskItem item) {
final raw = myTaskListingRaw(item);
final s = listingDisplayFromApi(raw);
return s.isNotEmpty ? s : '—';
}
/// 生成中:**无远程结果 URL** 且状态为进行中(含 `pending` 字符串)。
bool myTaskIsInProgress(MyTaskItem item) {
if (myTaskHasRemoteResultUrl(item)) return false;
final raw = myTaskListingRaw(item);
final display = listingDisplayFromApi(raw);
return galleryListingIsInProgress(raw, display);
}

View File

@ -1,4 +1,8 @@
import 'dart:convert';
import 'credits_balance_parse.dart';
import 'entity.dart';
import 'task_id_parse.dart';
/// 分类项
class CategoryItem extends Entity {
@ -15,12 +19,20 @@ class CategoryItem extends Entity {
@override
factory CategoryItem.fromJson(Map<String, dynamic> json) {
return CategoryItem(
id: json['id'] as int?,
id: _readInt(json['id']),
name: json['name'] as String?,
icon: json['icon'] as String?,
);
}
static int? _readInt(dynamic v) {
if (v == null) return null;
if (v is int) return v;
if (v is num) return v.toInt();
if (v is String) return int.tryParse(v.trim());
return null;
}
@override
Map<String, dynamic> toJson() => {
'id': id,
@ -39,10 +51,18 @@ class CategoryListResponse extends Entity {
@override
factory CategoryListResponse.fromJson(Map<String, dynamic> json) {
final list = json['categories'] as List<dynamic>?;
List<dynamic>? list = json['categories'] as List<dynamic>?;
list ??= json['list'] as List<dynamic>?;
list ??= json['records'] as List<dynamic>?;
final data = json['data'];
if (list == null && data is List<dynamic>) {
list = data;
}
return CategoryListResponse(
categories: list
?.map((e) => CategoryItem.fromJson(e as Map<String, dynamic>))
?.map((e) => CategoryItem.fromJson(
Map<String, dynamic>.from(e as Map),
))
.toList(),
);
}
@ -53,13 +73,21 @@ class CategoryListResponse extends Entity {
};
}
/// 任务项
/// 任务项(`GET /v1/image/img2video/tasks` 模板列表等;见 FunyMee 文档 `previewImage` / `title`)。
class TaskItem extends Entity {
TaskItem({
this.id,
this.name,
this.imageUrl,
this.categoryId,
this.title,
this.templateName,
this.templateUrl,
this.taskType,
this.ext,
this.previewVideoUrl,
this.credits480p,
this.credits720p,
});
final String? id;
@ -67,22 +95,99 @@ class TaskItem extends Entity {
final String? imageUrl;
final int? categoryId;
/// 与 [name] 二选一;映射后字段名见换皮 `fieldMapping`。
final String? title;
final String? templateName;
final String? templateUrl;
/// 任务类型;列表项换皮 `liaison` → 逻辑 `taskType`;创建任务时写入 `cipher`。
final String? taskType;
/// 扩展参数,创建任务时可作模板标识。
final String? ext;
/// `GET /v1/image/img2video/tasks` 文档:[previewVideo] 内 `hlsUrl` / `url` / `lowUrl`(映射后字段名)。
final String? previewVideoUrl;
/// [resolution480p] 嵌套对象中的 `credits`;生图页 480p 档展示用。
final int? credits480p;
/// [resolution720p] 嵌套对象中的 `credits`;生图页 720p 档展示用。
final int? credits720p;
static String? _pickPreviewVideoUrl(Map<String, dynamic> m) {
for (final k in ['hlsUrl', 'url', 'lowUrl']) {
final v = m[k];
if (v is String && v.trim().isNotEmpty) return v.trim();
}
return null;
}
@override
factory TaskItem.fromJson(Map<String, dynamic> json) {
final preview = json['previewImage'];
String? previewUrl;
if (preview is Map) {
previewUrl = preview['url'] as String?;
}
String? videoUrl;
final pv = json['previewVideo'];
if (pv is Map) {
videoUrl = _pickPreviewVideoUrl(Map<String, dynamic>.from(pv));
}
final idStr = json['id']?.toString() ?? json['taskId']?.toString();
final nameFrom =
json['name'] as String? ?? json['title'] as String? ?? json['templateName'] as String?;
final imgUrl = json['imageUrl'] as String? ??
previewUrl ??
json['templateUrl'] as String?;
return TaskItem(
id: json['id'] as String?,
name: json['name'] as String?,
imageUrl: json['imageUrl'] as String?,
categoryId: json['categoryId'] as int?,
id: idStr,
name: nameFrom,
imageUrl: imgUrl,
categoryId: CategoryItem._readInt(json['categoryId']),
title: json['title'] as String?,
templateName: json['templateName'] as String?,
templateUrl: json['templateUrl'] as String?,
taskType: _stringFromDynamic(json['taskType']),
ext: json['ext'] as String?,
previewVideoUrl: videoUrl,
credits480p: _readResolutionCredits(json, 'resolution480p'),
credits720p: _readResolutionCredits(json, 'resolution720p'),
);
}
static int? _readResolutionCredits(Map<String, dynamic> json, String key) {
final v = json[key];
if (v is! Map) return null;
final m = Map<String, dynamic>.from(v);
final c = m['credits'];
if (c is int) return c;
if (c is num) return c.toInt();
if (c is String) return int.tryParse(c.trim());
return null;
}
static String? _stringFromDynamic(dynamic v) {
if (v == null) return null;
if (v is String) return v.trim().isEmpty ? null : v.trim();
return v.toString();
}
@override
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'imageUrl': imageUrl,
'categoryId': categoryId,
'title': title,
'templateName': templateName,
'templateUrl': templateUrl,
'taskType': taskType,
'ext': ext,
'previewVideoUrl': previewVideoUrl,
'credits480p': credits480p,
'credits720p': credits720p,
};
}
@ -96,10 +201,18 @@ class TasksResponse extends Entity {
@override
factory TasksResponse.fromJson(Map<String, dynamic> json) {
final list = json['tasks'] as List<dynamic>?;
List<dynamic>? list = json['tasks'] as List<dynamic>?;
list ??= json['list'] as List<dynamic>?;
list ??= json['records'] as List<dynamic>?;
final data = json['data'];
if (list == null && data is List<dynamic>) {
list = data;
}
return TasksResponse(
tasks: list
?.map((e) => TaskItem.fromJson(e as Map<String, dynamic>))
?.map((e) => TaskItem.fromJson(
Map<String, dynamic>.from(e as Map),
))
.toList(),
);
}
@ -160,26 +273,66 @@ class PromptRecommendsResponse extends Entity {
}
/// 任务进度响应
///
/// 文档:`state`(线网 `bitrate`)1=队列 2=处理中 **3=完成** 4=超时 5=错误 6=中止;
/// 结果 URL 常在 `imageInfos[0].imgUrl`(线网 `elastic[].boost`),不一定有顶层 `resultUrl`。
class ProgressResponse extends Entity {
ProgressResponse({
this.taskId,
this.status,
this.state,
this.progress,
this.resultUrl,
});
final String? taskId;
final String? status;
/// 任务状态(线网 `bitrate` → 逻辑 `state`):3=完成,4/5/6=终态失败。
final int? state;
final int? progress;
final String? resultUrl;
/// 线网可能返回 `String` 或 `int`(如状态码、任务 id),避免 `as String?` 运行时崩溃。
static String? _readStringLoose(dynamic v) {
if (v == null) return null;
if (v is String) {
final s = v.trim();
return s.isEmpty ? null : s;
}
return v.toString();
}
static int? _readIntLoose(dynamic v) {
if (v == null) return null;
if (v is int) return v;
if (v is num) return v.toInt();
if (v is String) return int.tryParse(v.trim());
return int.tryParse(v.toString());
}
static String? _firstImageInfoUrl(Map<String, dynamic> json) {
final infos = json['imageInfos'];
if (infos is! List || infos.isEmpty) return null;
final first = infos.first;
if (first is! Map) return null;
final m = Map<String, dynamic>.from(first);
return _readStringLoose(m['imgUrl']) ?? _readStringLoose(m['boost']);
}
@override
factory ProgressResponse.fromJson(Map<String, dynamic> json) {
final fromRoot = _readStringLoose(json['resultUrl']) ??
_readStringLoose(json['imgUrl']) ??
_readStringLoose(json['boost']);
final fromList = _firstImageInfoUrl(json);
return ProgressResponse(
taskId: json['taskId'] as String?,
status: json['status'] as String?,
progress: json['progress'] as int?,
resultUrl: json['resultUrl'] as String?,
taskId: parseTaskIdValue(json['taskId']) ?? _readStringLoose(json['taskId']),
status: _readStringLoose(json['status']),
state: _readIntLoose(json['state']),
progress: _readIntLoose(json['progress']),
resultUrl: fromRoot ?? fromList,
);
}
@ -187,6 +340,7 @@ class ProgressResponse extends Entity {
Map<String, dynamic> toJson() => {
'taskId': taskId,
'status': status,
'state': state,
'progress': progress,
'resultUrl': resultUrl,
};
@ -250,16 +404,78 @@ class UploadPresignedUrlResponse extends Entity {
UploadPresignedUrlResponse({
this.uploadUrl,
this.filePath,
this.putHeaders,
});
final String? uploadUrl;
final String? filePath;
/// 上传到对象存储时额外请求头(如服务端返回的签名头;解密后为 business 字段名)。
final Map<String, String>? putHeaders;
/// 将任意 JSON 头值压成 [http] 要求的 [String](避免 `TypeError`)。
static String _headerValueToString(dynamic v) {
if (v == null) return '';
if (v is String) return v;
if (v is num || v is bool) return v.toString();
if (v is List) {
return v.map(_headerValueToString).where((s) => s.isNotEmpty).join(',');
}
if (v is Map) {
return jsonEncode(v);
}
return v.toString();
}
static Map<String, String>? _parsePutHeaders(Map<String, dynamic> json) {
final raw = json['putHeaders'] ??
json['headers'] ??
json['uploadHeaders'] ??
json['requiredHeaders'] ??
json['tokenize'];
if (raw is List) {
final out = <String, String>{};
for (final item in raw) {
if (item is! Map) continue;
final m = Map<String, dynamic>.from(item);
final name = m['name'] ?? m['Name'] ?? m['key'] ?? m['Key'];
final value = m['value'] ?? m['Value'];
if (name == null) continue;
out[name.toString()] = _headerValueToString(value);
}
return out.isEmpty ? null : out;
}
if (raw is! Map) return null;
final map = Map<String, dynamic>.from(raw);
final out = <String, String>{};
for (final e in map.entries) {
out[e.key.toString()] = _headerValueToString(e.value);
}
return out.isEmpty ? null : out;
}
static String? _str(dynamic v) {
if (v == null) return null;
if (v is String) return v.isEmpty ? null : v;
return v.toString();
}
@override
factory UploadPresignedUrlResponse.fromJson(Map<String, dynamic> json) {
// FunyMee 等换皮:`uploadUrl1`/`filePath1`(文档 wire:harden / generate)。
// 部分环境仍返回 `uploadUrl`/`filePath`。
final upload = _str(json['uploadUrl']) ??
_str(json['uploadUrl1']) ??
_str(json['uploadUrl2']);
final path = _str(json['filePath']) ??
_str(json['filePath1']) ??
_str(json['filePath2']);
return UploadPresignedUrlResponse(
uploadUrl: json['uploadUrl'] as String?,
filePath: json['filePath'] as String?,
uploadUrl: upload,
filePath: path,
putHeaders: _parsePutHeaders(json),
);
}
@ -267,6 +483,7 @@ class UploadPresignedUrlResponse extends Entity {
Map<String, dynamic> toJson() => {
'uploadUrl': uploadUrl,
'filePath': filePath,
if (putHeaders != null) 'putHeaders': putHeaders,
};
}
@ -283,8 +500,8 @@ class CreateTaskResponse extends Entity {
@override
factory CreateTaskResponse.fromJson(Map<String, dynamic> json) {
return CreateTaskResponse(
taskId: json['taskId'] as String?,
status: json['status'] as String?,
taskId: parseTaskIdFromMap(json),
status: json['status'] as String? ?? json['state']?.toString(),
);
}
@ -296,10 +513,13 @@ class CreateTaskResponse extends Entity {
}
/// 我的任务项
///
/// 与 FunyMee 文档一致:`state`(线网 `bitrate`)1–6 为权威任务态;`status` 为兼容字段。
class MyTaskItem extends Entity {
MyTaskItem({
this.taskId,
this.status,
this.state,
this.progress,
this.resultUrl,
this.createTime,
@ -308,19 +528,68 @@ class MyTaskItem extends Entity {
final String? taskId;
final String? status;
/// 任务状态:1=队列 2=处理中 3=完成 4=超时 5=错误 6=中止(与 [gallery_task_models] 语义一致)。
final int? state;
final int? progress;
final String? resultUrl;
final String? createTime;
final String? type;
static int? _readInt(dynamic v) {
if (v == null) return null;
if (v is int) return v;
if (v is num) return v.toInt();
return int.tryParse(v.toString());
}
static String? _firstNonEmptyStr(dynamic v) {
if (v == null) return null;
final s = v is String ? v : v.toString();
final t = s.trim();
return t.isEmpty ? null : t;
}
@override
factory MyTaskItem.fromJson(Map<String, dynamic> json) {
final imgList = json['imgList'] as List<dynamic>? ??
json['downsample'] as List<dynamic>?;
String? firstImgUrl;
if (imgList != null && imgList.isNotEmpty) {
final first = imgList.first;
if (first is Map) {
final m = Map<String, dynamic>.from(first);
firstImgUrl = _firstNonEmptyStr(m['imgUrl']) ??
_firstNonEmptyStr(m['url']) ??
_firstNonEmptyStr(m['boost']) ??
_firstNonEmptyStr(m['reconfigure']);
}
}
final topResult = _firstNonEmptyStr(json['resultUrl']) ??
_firstNonEmptyStr(json['imgUrl']) ??
_firstNonEmptyStr(json['boost']);
final ct = json['createTime'];
final createTimeStr = ct == null
? null
: (ct is String
? (ct.isEmpty ? null : ct)
: ct.toString());
final rawStateField = json['state'];
final stateVal = _readInt(rawStateField);
// 线网 `bitrate` 映射为 `state` 时可能是 **字符串**(如 pending / finished),不能只做 int 解析。
final statusLoose = _firstNonEmptyStr(json['status']) ??
_firstNonEmptyStr(json['listing']) ??
(stateVal != null ? stateVal.toString() : _firstNonEmptyStr(rawStateField));
return MyTaskItem(
taskId: json['taskId'] as String?,
status: json['status'] as String?,
progress: json['progress'] as int?,
resultUrl: json['resultUrl'] as String?,
createTime: json['createTime'] as String?,
taskId: parseTaskIdFromMap(json),
status: statusLoose,
state: stateVal,
progress: _readInt(json['progress']),
resultUrl: topResult ?? firstImgUrl,
createTime: createTimeStr ??
(json['uncover'] as String?) ??
(json['discover']?.toString()),
type: json['type'] as String?,
);
}
@ -329,6 +598,7 @@ class MyTaskItem extends Entity {
Map<String, dynamic> toJson() => {
'taskId': taskId,
'status': status,
'state': state,
'progress': progress,
'resultUrl': resultUrl,
'createTime': createTime,
@ -342,21 +612,35 @@ class MyTasksResponse extends Entity {
this.tasks,
this.total,
this.cursor,
this.hasNext,
});
final List<MyTaskItem>? tasks;
final int? total;
final String? cursor;
/// 与 app_client `manifest` / 常见 `hasNext` 对齐。
final bool? hasNext;
@override
factory MyTasksResponse.fromJson(Map<String, dynamic> json) {
final list = json['tasks'] as List<dynamic>?;
final rawList =
json['tasks'] ?? json['intensify'] ?? json['records'] ?? json['list'];
final list = rawList is List<dynamic> ? rawList : null;
final totalRaw = json['total'];
int? total;
if (totalRaw is int) {
total = totalRaw;
} else if (totalRaw is num) {
total = totalRaw.toInt();
}
return MyTasksResponse(
tasks: list
?.map((e) => MyTaskItem.fromJson(e as Map<String, dynamic>))
?.map((e) => MyTaskItem.fromJson(Map<String, dynamic>.from(e as Map)))
.toList(),
total: json['total'] as int?,
total: total,
cursor: json['cursor'] as String?,
hasNext: json['hasNext'] as bool? ?? json['manifest'] as bool?,
);
}
@ -365,6 +649,7 @@ class MyTasksResponse extends Entity {
'tasks': tasks?.map((e) => e.toJson()).toList(),
'total': total,
'cursor': cursor,
'hasNext': hasNext,
};
}
@ -462,7 +747,7 @@ class CreditsPageInfoResponse extends Entity {
factory CreditsPageInfoResponse.fromJson(Map<String, dynamic> json) {
final recList = json['records'] as List<dynamic>?;
return CreditsPageInfoResponse(
credits: _toInt(json['credits']),
credits: parseUserCreditsBalance(json),
freeTimes: _toInt(json['freeTimes']),
vipExpireTime: json['vipExpireTime'] as String?,
isVip: json['isVip'] is bool ? json['isVip'] as bool? : null,

View File

@ -8,7 +8,9 @@ class PaymentProductItem extends Entity {
this.actualAmount,
this.originAmount,
this.bonus,
this.bonusCredits,
this.title,
this.credits,
});
final String? productId;
@ -16,20 +18,45 @@ class PaymentProductItem extends Entity {
final String? actualAmount;
final String? originAmount;
final int? bonus;
/// 额外赠送积分(换皮线网常为 `saturation` → `bonusCredits`)。
final int? bonusCredits;
final String? title;
/// 该档位到账积分数(文档 wire 常为 `padding` → `credits`)。
final int? credits;
@override
factory PaymentProductItem.fromJson(Map<String, dynamic> json) {
return PaymentProductItem(
productId: json['productId'] as String?,
activityId: json['activityId'] as String?,
actualAmount: json['actualAmount'] as String?,
originAmount: json['originAmount'] as String?,
bonus: json['bonus'] as int?,
title: json['title'] as String?,
// Wire `scene` often maps to logical `code` (see fieldMapping code→scene); Google Play id must still resolve.
productId: _stringField(json, 'productId') ??
_stringField(json, 'code') ??
_stringField(json, 'scene'),
activityId: _stringField(json, 'activityId'),
actualAmount: _stringField(json, 'actualAmount'),
originAmount: _stringField(json, 'originAmount'),
bonus: _intField(json, 'bonus'),
bonusCredits: _intField(json, 'bonusCredits'),
title: _stringField(json, 'title'),
credits: _intField(json, 'credits') ?? _intField(json, 'padding'),
);
}
static String? _stringField(Map<String, dynamic> json, String key) {
final v = json[key];
if (v == null) return null;
if (v is String) return v;
return v.toString();
}
static int? _intField(Map<String, dynamic> json, String key) {
final v = json[key];
if (v == null) return null;
if (v is int) return v;
if (v is num) return v.toInt();
return int.tryParse(v.toString());
}
@override
Map<String, dynamic> toJson() => {
'productId': productId,
@ -37,7 +64,9 @@ class PaymentProductItem extends Entity {
'actualAmount': actualAmount,
'originAmount': originAmount,
'bonus': bonus,
'bonusCredits': bonusCredits,
'title': title,
'credits': credits,
};
}
@ -51,7 +80,7 @@ class PaymentProductsResponse extends Entity {
@override
factory PaymentProductsResponse.fromJson(Map<String, dynamic> json) {
final list = json['productList'] as List<dynamic>?;
final list = _parseProductList(json);
return PaymentProductsResponse(
productList: list
?.map((e) => PaymentProductItem.fromJson(e as Map<String, dynamic>))
@ -59,13 +88,24 @@ class PaymentProductsResponse extends Entity {
);
}
/// 换皮映射里商品数组常见 wire 名:`animate` → 逻辑名 `activitys`;默认映射里为 `summon` → `productList`。
static List<dynamic>? _parseProductList(Map<String, dynamic> json) {
final a = json['productList'] as List<dynamic>?;
if (a != null) return a;
final b = json['activitys'] as List<dynamic>?;
if (b != null) return b;
return json['summon'] as List<dynamic>?;
}
@override
Map<String, dynamic> toJson() => {
'productList': productList?.map((e) => e.toJson()).toList(),
};
}
/// 支付方式项
/// 支付方式项(get-payment-methods;换皮线网单项常为 `renew`)
///
/// 赠送字段与 app_client [PaymentMethodItem] 一致:`conjure`→`bonusCredits`,`enchant`→`bonusRatio`(经 [FieldMapping])。
class PaymentMethodItem extends Entity {
PaymentMethodItem({
this.paymentMethod,
@ -73,6 +113,8 @@ class PaymentMethodItem extends Entity {
this.name,
this.icon,
this.recommend,
this.bonusCredits,
this.bonusRatio,
});
final String? paymentMethod;
@ -81,6 +123,49 @@ class PaymentMethodItem extends Entity {
final String? icon;
final bool? recommend;
/// 该支付方式额外赠送积分(线网常为 `conjure`)。
final int? bonusCredits;
/// 赠送比例;≤1 视为小数比例,否则视为百分数(app_client [PaymentMethodItem.bonusLabel])。
final double? bonusRatio;
/// 列表主标题:优先 [name],否则 [paymentMethod]。
String get displayName {
final n = name?.trim();
if (n != null && n.isNotEmpty) return n;
return paymentMethod?.trim().isNotEmpty == true ? paymentMethod!.trim() : '';
}
/// 副标题:优先展示赠送积分,否则展示赠送比例(与 app_client 一致)。
String? get bonusLabel {
final bc = bonusCredits;
if (bc != null && bc > 0) {
return '+$bc bonus credits';
}
final br = bonusRatio;
if (br != null && br > 0) {
final pct = br <= 1 ? (br * 100).round() : br.round();
return '+$pct% bonus credits';
}
return null;
}
static int? _intField(Map<String, dynamic> json, String key) {
final v = json[key];
if (v == null) return null;
if (v is int) return v;
if (v is num) return v.toInt();
return int.tryParse(v.toString());
}
static double? _doubleField(Map<String, dynamic> json, String key) {
final v = json[key];
if (v == null) return null;
if (v is double) return v;
if (v is num) return v.toDouble();
return double.tryParse(v.toString());
}
@override
factory PaymentMethodItem.fromJson(Map<String, dynamic> json) {
return PaymentMethodItem(
@ -89,6 +174,9 @@ class PaymentMethodItem extends Entity {
name: json['name'] as String?,
icon: json['icon'] as String?,
recommend: json['recommend'] as bool?,
bonusCredits: _intField(json, 'bonusCredits') ?? _intField(json, 'conjure'),
bonusRatio:
_doubleField(json, 'bonusRatio') ?? _doubleField(json, 'enchant'),
);
}
@ -99,6 +187,8 @@ class PaymentMethodItem extends Entity {
'name': name,
'icon': icon,
'recommend': recommend,
'bonusCredits': bonusCredits,
'bonusRatio': bonusRatio,
};
}
@ -112,7 +202,7 @@ class PaymentMethodsResponse extends Entity {
@override
factory PaymentMethodsResponse.fromJson(Map<String, dynamic> json) {
final list = json['paymentMethods'] as List<dynamic>?;
final list = _parsePaymentMethodsList(json);
return PaymentMethodsResponse(
paymentMethods: list
?.map((e) => PaymentMethodItem.fromJson(e as Map<String, dynamic>))
@ -120,6 +210,15 @@ class PaymentMethodsResponse extends Entity {
);
}
/// 换皮线网常见:`invoke` / `renew` → 逻辑名 `paymentMethods`(与 app_client `renew` 一致)。
static List<dynamic>? _parsePaymentMethodsList(Map<String, dynamic> json) {
final a = json['paymentMethods'] as List<dynamic>?;
if (a != null) return a;
final b = json['invoke'] as List<dynamic>?;
if (b != null) return b;
return json['renew'] as List<dynamic>?;
}
@override
Map<String, dynamic> toJson() => {
'paymentMethods': paymentMethods?.map((e) => e.toJson()).toList(),
@ -132,26 +231,43 @@ class CreatePaymentResponse extends Entity {
this.orderId,
this.payUrl,
this.status,
this.federation,
});
final String? orderId;
final String? payUrl;
final String? status;
/// Google Play 内购场景下,服务端返回的 federation id(与 Google `orderId` 映射后走 [PaymentApi.googlepay])。
final String? federation;
static String? _str(dynamic v) {
if (v == null) return null;
if (v is String) return v;
return v.toString();
}
@override
factory CreatePaymentResponse.fromJson(Map<String, dynamic> json) {
final idRaw = json['orderId'] ?? json['id'];
return CreatePaymentResponse(
orderId: json['orderId'] as String?,
orderId: idRaw == null ? null : idRaw.toString(),
payUrl: json['payUrl'] as String?,
status: json['status'] as String?,
federation: _str(json['federation']),
);
}
/// 内购核销时优先使用 federation,其次 orderId。
String? get federationOrOrderId =>
(federation != null && federation!.isNotEmpty) ? federation : orderId;
@override
Map<String, dynamic> toJson() => {
'orderId': orderId,
'payUrl': payUrl,
'status': status,
'federation': federation,
};
}
@ -200,9 +316,11 @@ class GooglePayCallbackResponse extends Entity {
@override
factory GooglePayCallbackResponse.fromJson(Map<String, dynamic> json) {
final idRaw = json['orderId'] ?? json['id'];
final line = json['line'] ?? json['status'];
final statusStr = line is String ? line : json['status'] as String?;
return GooglePayCallbackResponse(
orderId: idRaw == null ? null : idRaw.toString(),
status: json['status'] as String?,
status: statusStr,
creditsAdded: json['creditsAdded'] as bool?,
);
}

View File

@ -0,0 +1,22 @@
/// 从创建任务等响应中解析任务 id(兼容 `taskId`、`tree`、`id`、`exponential` 等换皮键)。
String? parseTaskIdFromMap(Map<String, dynamic>? json) {
if (json == null) return null;
return parseTaskIdValue(json['taskId']) ??
parseTaskIdValue(json['tree']) ??
parseTaskIdValue(json['id']) ??
parseTaskIdValue(json['exponential']);
}
String? parseTaskIdValue(dynamic raw) {
if (raw == null) return null;
if (raw is String) {
final s = raw.trim();
return s.isEmpty ? null : s;
}
if (raw is int) return raw <= 0 ? null : raw.toString();
if (raw is num) {
final i = raw.toInt();
return i <= 0 ? null : i.toString();
}
return parseTaskIdValue(raw.toString());
}

View File

@ -1,5 +1,18 @@
import 'dart:convert';
import 'credits_balance_parse.dart';
import 'entity.dart';
/// `extConfig` 在解密 data 中可能是 JSON 字符串,也可能是已解析的 Map;统一成可 [json.decode] 的字符串。
String? _wireExtConfigJson(dynamic v) {
if (v == null) return null;
if (v is String) return v.isEmpty ? null : v;
if (v is Map<String, dynamic>) return jsonEncode(v);
if (v is Map) return jsonEncode(Map<String, dynamic>.from(v));
if (v is List) return jsonEncode(v);
return v.toString();
}
/// 登录响应
class FastLoginResponse extends Entity {
FastLoginResponse({
@ -72,11 +85,11 @@ class FastLoginResponse extends Entity {
return FastLoginResponse(
userToken: _toString(json['userToken']),
userId: _toString(json['userId']),
credits: _toInt(json['credits']),
credits: parseUserCreditsBalance(json),
avatar: _toString(json['avatar']),
userName: _toString(json['userName']),
countryCode: _toString(json['countryCode']),
extConfig: _toString(json['extConfig']),
extConfig: _wireExtConfigJson(json['extConfig']),
appFbConfig: _toString(json['appFbConfig']),
usign: _toString(json['usign']),
creditsRecordUrl: _toString(json['creditsRecordUrl']),
@ -199,11 +212,11 @@ class CommonInfoResponse extends Entity {
return CommonInfoResponse(
userToken: _toString(json['userToken']),
userId: _toString(json['userId']),
credits: _toInt(json['credits']),
credits: parseUserCreditsBalance(json),
avatar: _toString(json['avatar']),
userName: _toString(json['userName']),
countryCode: _toString(json['countryCode']),
extConfig: _toString(json['extConfig']),
extConfig: _wireExtConfigJson(json['extConfig']),
appFbConfig: _toString(json['appFbConfig']),
usign: _toString(json['usign']),
creditsRecordUrl: _toString(json['creditsRecordUrl']),
@ -273,7 +286,7 @@ class AccountResponse extends Entity {
@override
factory AccountResponse.fromJson(Map<String, dynamic> json) {
return AccountResponse(
credits: json['credits'] as int?,
credits: parseUserCreditsBalance(json),
avatar: json['avatar'] as String?,
userName: json['userName'] as String?,
isVip: json['isVip'] as bool?,

View File

@ -0,0 +1,97 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:path_provider/path_provider.dart';
import 'package:video_thumbnail/video_thumbnail.dart';
/// 远程视频 URL 的缩略图 / 海报帧磁盘缓存(对照参考产品)。
class VideoThumbnailCache {
VideoThumbnailCache._();
static final VideoThumbnailCache _instance = VideoThumbnailCache._();
static VideoThumbnailCache get instance => _instance;
static const int _maxWidth = 400;
static const int _quality = 75;
/// 全屏背景等大图:单帧 JPEG,[maxWidth] 控制长边。
Future<Uint8List?> getPosterFrame(
String videoUrl, {
int maxWidth = 1024,
}) async {
final key = '${_cacheKey(videoUrl)}_poster_$maxWidth';
final cacheDir = await _getCacheDir();
final file = File('${cacheDir.path}/$key.jpg');
if (await file.exists()) {
return file.readAsBytes();
}
try {
final path = await VideoThumbnail.thumbnailFile(
video: videoUrl,
thumbnailPath: cacheDir.path,
imageFormat: ImageFormat.JPEG,
maxWidth: maxWidth,
quality: 78,
);
if (path != null) {
final cached = File(path);
final bytes = await cached.readAsBytes();
if (cached.path != file.path) {
await file.writeAsBytes(bytes);
cached.deleteSync();
}
return bytes;
}
} catch (_) {}
return null;
}
Future<Uint8List?> getThumbnail(String videoUrl) async {
final key = _cacheKey(videoUrl);
final cacheDir = await _getCacheDir();
final file = File('${cacheDir.path}/$key.jpg');
if (await file.exists()) {
return file.readAsBytes();
}
try {
final path = await VideoThumbnail.thumbnailFile(
video: videoUrl,
thumbnailPath: cacheDir.path,
imageFormat: ImageFormat.JPEG,
maxWidth: _maxWidth,
quality: _quality,
);
if (path != null) {
final cached = File(path);
final bytes = await cached.readAsBytes();
if (cached.path != file.path) {
await file.writeAsBytes(bytes);
cached.deleteSync();
}
return bytes;
}
} catch (_) {}
return null;
}
String _cacheKey(String url) {
final bytes = utf8.encode(url);
final digest = md5.convert(bytes);
return digest.toString();
}
Directory? _cacheDir;
Future<Directory> _getCacheDir() async {
_cacheDir ??= await getTemporaryDirectory();
final dir = Directory('${_cacheDir!.path}/video_thumbnails');
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
}
}

View File

@ -195,11 +195,92 @@ class AdjustService {
}
}
static bool _uploadReferrerResolved = false;
static String _uploadReferrerDigest = '';
static String _uploadReferrerSource = 'gg';
/// 供 [UserApi.referrer] 的 `referer` 等使用:优先 Base64(Adjust 归因 JSON),
/// 失败则 Android 上读取 Play Install Referrer 明文(对照参考产品;结果会缓存)。
static Future<ReferrerForUpload> obtainReferrerForUpload({
int adjustQueryTimeoutMs = 4500,
Duration raceTimeout = const Duration(seconds: 6),
Duration playReferrerTimeout = const Duration(seconds: 10),
}) async {
if (_uploadReferrerResolved) {
return ReferrerForUpload(
digest: _uploadReferrerDigest,
source: _uploadReferrerSource,
);
}
var digest = '';
var source = 'gg';
try {
final attribution = await Future.any<AdjustAttribution?>([
(() async {
try {
return await adj.Adjust.getAttributionWithTimeout(
adjustQueryTimeoutMs,
);
} catch (_) {
return null;
}
})(),
_attributionCallbackCompleter.future,
]).timeout(raceTimeout, onTimeout: () => null);
if (attribution != null) {
final raw = _attributionToDigest(attribution);
if (raw.isNotEmpty) {
source = 'android_adjust';
digest = base64Encode(utf8.encode(raw));
}
}
} catch (_) {
digest = '';
}
if (digest.isEmpty && defaultTargetPlatform == TargetPlatform.android) {
source = 'gg';
try {
final details = await PlayInstallReferrer.installReferrer
.timeout(playReferrerTimeout);
digest = details.installReferrer ?? '';
} catch (_) {
digest = '';
}
}
_uploadReferrerDigest = digest;
_uploadReferrerSource = source;
_uploadReferrerResolved = true;
return ReferrerForUpload(digest: digest, source: source);
}
/// 与 [obtainReferrerForUpload] 使用同一套字段序列化(不含 Base64)。
static String attributionToDigestJson(AdjustAttribution attr) =>
_attributionToDigest(attr);
static String get referrerSource => _referrerSource;
static String? get cachedPlayReferrer => _cachedReferrer;
}
/// [AdjustService.obtainReferrerForUpload] 的返回值。
class ReferrerForUpload {
ReferrerForUpload({
required this.digest,
required this.source,
});
/// Base64(Adjust 归因 JSON) 或 Play Install Referrer 明文。
final String digest;
/// 例如 `android_adjust` / `gg`。
final String source;
}
class AttributionData {
AttributionData({
this.trackerToken,

View File

@ -1,11 +1,13 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import '../api/api_client.dart';
import '../api/proxy_client.dart';
import '../config/attribution_config.dart';
import '../config/ext_config_models.dart';
import '../config/ext_config_runtime.dart';
import '../config/video_home_runtime.dart';
import '../entities/user_entities.dart';
import 'adjust_service.dart';
import 'analytics_attribution_callbacks.dart';
@ -198,7 +200,16 @@ abstract class FrameworkAuthService {
required String uid,
required String deviceId,
}) async {
if (uid.isEmpty) return;
if (uid.isEmpty) {
VideoHomeRuntime.reset();
ExtConfigRuntime.applyCommonInfoFailure();
if (kDebugMode) {
debugPrint(
'[AuthService] common_info: 跳过(userId 为空),已标记 common_info 失败',
);
}
return;
}
final config = ApiClient.instance.config;
final backendApp = defaultTargetPlatform == TargetPlatform.iOS
@ -261,28 +272,36 @@ abstract class FrameworkAuthService {
deviceId: deviceId,
);
if (commonRes.isSuccess && commonRes.data != null) {
ExtConfigRuntime.applyCommonInfoSuccess(commonRes.data!);
_callbacks?.onCommonInfoLoaded(commonRes.data!);
unawaited(
VideoHomeRuntime.hydrateAfterCommonInfo(
userId: uid,
app: backendApp,
),
);
if (kDebugMode) {
debugPrint('[AuthService] common_info: 获取成功');
}
} else if (kDebugMode) {
} else {
VideoHomeRuntime.reset();
ExtConfigRuntime.applyCommonInfoFailure();
if (kDebugMode) {
debugPrint(
'[AuthService] common_info: 失败 code=${commonRes.code} msg=${commonRes.msg}');
}
}
} catch (e) {
VideoHomeRuntime.reset();
ExtConfigRuntime.applyCommonInfoFailure();
if (kDebugMode) {
debugPrint('[AuthService] common_info: 异常 $e');
}
}
}
/// 解析 extConfig JSON 字符串
/// 解析 extConfig JSON 字符串为 Map(兼容旧代码;结构化解析请用 [ExtConfigData.parse])。
static Map<String, dynamic>? parseExtConfig(String? extConfigStr) {
if (extConfigStr == null || extConfigStr.isEmpty) return null;
try {
return json.decode(extConfigStr) as Map<String, dynamic>?;
} catch (_) {
return null;
}
return ExtConfigData.parseRawMap(extConfigStr);
}
}

View File

@ -2,7 +2,7 @@ import '../api/api_client.dart';
import '../api/proxy_client.dart';
import '../entities/feedback_entities.dart';
/// 举报/反馈相关 API(使用原始字段名)
/// 举报/反馈相关 API(**业务逻辑字段名**,经 [FieldMapping] 映射为线网字段名)
///
/// **Body**(`submit`):`fileUrls`、`contentType`、`content`(与文档顺序一致)。
abstract final class FeedbackApi {

View File

@ -2,32 +2,101 @@ import '../api/api_client.dart';
import '../api/proxy_client.dart';
import '../entities/image_entities.dart';
/// 图片/视频生成相关 API(使用原始字段名)
/// 图片/视频生成相关 API(**业务逻辑字段名**,经 [FieldMapping] 映射为线网字段名)。
///
/// **请求头**:需登录接口自动附带 `pkg` 与 `User_token`(参见 [ProxyClient.request])。
abstract final class ImageApi {
static ProxyClient get _client => ApiClient.instance.proxy;
/// 获取图转视频分类列表
///
/// 兼容 `data` 为 `{ "categories": [...] }` 或 **直接为数组**(后者若走 [ProxyClient.requestEntity]
/// 会因非 Map 而丢失 [EntityResponse.data])。
static Future<EntityResponse<CategoryListResponse>> getCategoryList() async {
return _client.requestEntity(
final response = await _client.request(
path: '/v1/image/img2video/categories',
method: 'GET',
entityFactory: CategoryListResponse.fromJson,
);
if (!response.isSuccess) {
return EntityResponse(
code: response.code,
msg: response.msg,
data: null,
);
}
final data = _parseCategoryListPayload(response.data);
return EntityResponse(
code: response.code,
msg: response.msg,
data: data,
);
}
/// 获取图转视频任务列表
/// 解析 `data`:数组、或含 `categories` / `list` / `records` / `data` 的 Map。
static CategoryListResponse? _parseCategoryListPayload(dynamic raw) {
if (raw == null) return null;
final mapping = ApiClient.instance.config.fieldMapping;
if (raw is List) {
final items = <CategoryItem>[];
for (final e in raw) {
if (e is Map) {
final m = mapping.mapResponse(Map<String, dynamic>.from(e));
items.add(CategoryItem.fromJson(m));
}
}
return CategoryListResponse(categories: items);
}
if (raw is Map) {
final m = Map<String, dynamic>.from(raw);
return CategoryListResponse.fromJson(m);
}
return null;
}
/// 获取图转视频任务列表(按分类拉取模板列表;见 FunyMee `GET /v1/image/img2video/tasks`)。
///
/// 兼容 `data` 为数组或 `{ "tasks": [...] }`([ProxyClient.requestEntity] 在 `data` 非 Map 时会丢实体)。
static Future<EntityResponse<TasksResponse>> getImg2VideoTasks({
int? categoryId,
}) async {
return _client.requestEntity(
final response = await _client.request(
path: '/v1/image/img2video/tasks',
method: 'GET',
entityFactory: TasksResponse.fromJson,
queryParams:
categoryId != null ? {'categoryId': categoryId.toString()} : null,
);
if (!response.isSuccess) {
return EntityResponse(
code: response.code,
msg: response.msg,
data: null,
);
}
final parsed = _parseTasksPayload(response.data);
return EntityResponse(
code: response.code,
msg: response.msg,
data: parsed,
);
}
static TasksResponse? _parseTasksPayload(dynamic raw) {
if (raw == null) return null;
final mapping = ApiClient.instance.config.fieldMapping;
if (raw is List) {
final items = <TaskItem>[];
for (final e in raw) {
if (e is Map) {
final m = mapping.mapResponse(Map<String, dynamic>.from(e));
items.add(TaskItem.fromJson(m));
}
}
return TasksResponse(tasks: items);
}
if (raw is Map) {
return TasksResponse.fromJson(Map<String, dynamic>.from(raw));
}
return null;
}
/// 获取推荐提示词
@ -50,7 +119,7 @@ abstract final class ImageApi {
/// 创建文生图任务
///
/// **Body**(原始字段):`imgCount`(默认 1)、`aspectRatio`(可选)、`prompt`(必填)。
/// **Body**(逻辑字段):`imgCount`(默认 1)、`aspectRatio`(可选)、`prompt`(必填)。
static Future<EntityResponse<CreateTaskResponse>> createTxt2Img({
required String app,
required String prompt,
@ -169,31 +238,57 @@ abstract final class ImageApi {
}
/// 创建生图/视频任务
///
/// 线网字段名由宿主 `skin_config.json` 的 `fieldMapping` 决定(如 FunyMee:`size`→`seminar`,`needopt`→`team`)。
/// - Query:`userId`
/// - Body:`srcImg1Url`(兼容 `resolution` / `srcImgUrls`)、`taskType`、`size`(480p/720p)、`needopt`(默认 `false`)、`templateName`、`imgUrl`、`ext` 等
///
/// 双图:第一张 `srcImg1Url`(或兼容 `resolution`),第二张 `srcImg2`。
///
/// [heatmap] 为历史别名,与 [size] 择一,最终写入逻辑字段 **`size`**(勿再使用 `heatmap` 作为 body 键)。
/// [srcImgUrls] / [resolution] 仅作兼容:与 [srcImg1Url] 择一。
static Future<EntityResponse<CreateTaskResponse>> createTask({
required String userId,
String? srcImg1Url,
String? resolution,
String? srcImgUrls,
String? srcImg2,
String? prompt,
String? cipher,
String? heatmap,
/// 输出尺寸(如 `480p` / `720p`);与 [heatmap] 二选一,见 [heatmap]。
String? size,
@Deprecated('Use size') String? heatmap,
String? taskType,
String? templateName,
String? imgUrl,
bool allowance = false,
/// 是否优化;文档固定 `false`,映射如 `needopt`→`team`。
bool needopt = false,
String? ext,
}) async {
final path = srcImg1Url ??
resolution ??
(srcImgUrls != null && srcImgUrls.isNotEmpty ? srcImgUrls : null);
final sizeVal = (size != null && size.isNotEmpty)
? size
: (heatmap != null && heatmap.isNotEmpty ? heatmap : null);
final taskTypeVal =
taskType != null && taskType.isNotEmpty ? taskType : null;
return _client.requestEntity(
path: '/v1/image/create-task',
method: 'POST',
entityFactory: CreateTaskResponse.fromJson,
queryParams: {'userId': userId},
body: {
if (resolution != null) 'resolution': resolution,
if (srcImgUrls != null) 'srcImgUrls': srcImgUrls,
if (prompt != null) 'prompt': prompt,
if (cipher != null) 'cipher': cipher,
if (heatmap != null) 'heatmap': heatmap,
if (imgUrl != null) 'imgUrl': imgUrl,
if (ext != null) 'ext': ext,
'allowance': allowance,
if (path != null) 'srcImg1Url': path,
if (srcImg2 != null && srcImg2.isNotEmpty) 'srcImg2': srcImg2,
if (prompt != null && prompt.isNotEmpty) 'prompt': prompt,
if (taskTypeVal != null) 'taskType': taskTypeVal,
if (sizeVal != null) 'size': sizeVal,
if (templateName != null && templateName.isNotEmpty)
'templateName': templateName,
if (imgUrl != null && imgUrl.isNotEmpty) 'imgUrl': imgUrl,
if (ext != null && ext.isNotEmpty) 'ext': ext,
'needopt': needopt,
},
);
}
@ -202,6 +297,7 @@ abstract final class ImageApi {
static Future<EntityResponse<MyTasksResponse>> getMyTasks({
required String app,
String? page,
/// 每页条数;逻辑字段名为 **`size`**(换皮如 `seminar`),勿使用未映射的 `pageSize` 键。
String? pageSize,
String? cursor,
}) async {
@ -212,7 +308,7 @@ abstract final class ImageApi {
queryParams: {
'app': app,
if (page != null) 'page': page,
if (pageSize != null) 'pageSize': pageSize,
if (pageSize != null) 'size': pageSize,
if (cursor != null) 'cursor': cursor,
},
);

View File

@ -0,0 +1,67 @@
import 'dart:io';
import 'package:image/image.dart' as img;
import 'package:path_provider/path_provider.dart';
/// 上传前压缩参数(对照参考实现的常见取值:`maxSide: 1024`, `jpegQuality: 75`)。
class CompressImageForUploadOptions {
const CompressImageForUploadOptions({
this.maxSide = 2048,
this.jpegQuality = 85,
});
final int maxSide;
final int jpegQuality;
}
/// 上传前压缩:限制长边、JPEG 质量;解码失败或非 JPEG 管线时返回原文件。
Future<File> compressImageForUpload(
File source, {
int maxSide = 2048,
int jpegQuality = 85,
}) async {
return compressImageForUploadWithOptions(
source,
CompressImageForUploadOptions(maxSide: maxSide, jpegQuality: jpegQuality),
);
}
Future<File> compressImageForUploadWithOptions(
File source,
CompressImageForUploadOptions options,
) async {
try {
final raw = await source.readAsBytes();
final image = img.decodeImage(raw);
if (image == null) return source;
var work = image;
final maxSide = options.maxSide;
if (work.width > maxSide || work.height > maxSide) {
if (work.width >= work.height) {
work = img.copyResize(
work,
width: maxSide,
interpolation: img.Interpolation.linear,
);
} else {
work = img.copyResize(
work,
height: maxSide,
interpolation: img.Interpolation.linear,
);
}
}
final jpg = img.encodeJpg(work, quality: options.jpegQuality);
final dir = await getTemporaryDirectory();
final out = File(
'${dir.path}/upload_${DateTime.now().millisecondsSinceEpoch}.jpg',
);
await out.writeAsBytes(jpg, flush: true);
return out;
} catch (_) {
return source;
}
}

View File

@ -0,0 +1,281 @@
import 'dart:io';
import 'package:http/http.dart' as http;
import '../entities/image_entities.dart';
import '../log/app_logger.dart';
import 'image_api.dart';
import 'image_compress.dart';
import 'task_upload_cover_store.dart';
final _presignedPutLog = AppLogger('PresignedUpload');
/// 预签名上传 → PUT → [ImageApi.createTask] 的结果。
class ImagePresignedUploadCreateTaskResult {
ImagePresignedUploadCreateTaskResult({
required this.createResponse,
required this.fileUsedForUpload,
});
final CreateTaskResponse createResponse;
final File fileUsedForUpload;
}
class _UploadedPart {
_UploadedPart({required this.toUpload, required this.serverPath});
final File toUpload;
final String serverPath;
}
/// 与参考实现一致:压缩(可选)→ 取预签名 → HTTP PUT → 创建任务;可选写入 [TaskUploadCoverStore]。
abstract final class ImagePresignedUploadCreateTaskFlow {
ImagePresignedUploadCreateTaskFlow._();
/// 合并预签名返回头与额外头,并保证值为 [String](避免 [http] header 的 `TypeError`)。
static Map<String, String> _mergePutHeaders(
Map<String, String>? server,
Map<String, String>? extra,
String contentType,
) {
final out = <String, String>{};
void putAll(Map<String, String>? m) {
if (m == null) return;
for (final e in m.entries) {
final k = e.key.trim();
if (k.isEmpty) continue;
out[k] = e.value.toString();
}
}
putAll(server);
putAll(extra);
if (!out.containsKey('Content-Type')) {
out['Content-Type'] = contentType;
}
return out;
}
static Future<_UploadedPart> _uploadOneFile({
required File sourceFile,
required bool compressFirst,
required CompressImageForUploadOptions compressOptions,
String? customUploadBaseName,
Map<String, String>? extraPutHeaders,
}) async {
final toUpload = compressFirst
? await compressImageForUploadWithOptions(sourceFile, compressOptions)
: sourceFile;
final size = await toUpload.length();
final pathLower = toUpload.path.toLowerCase();
final extName = pathLower.contains('.')
? toUpload.path.split('.').last.toLowerCase()
: 'jpg';
final contentType = extName == 'png'
? 'image/png'
: extName == 'gif'
? 'image/gif'
: 'image/jpeg';
final fileName = customUploadBaseName != null &&
customUploadBaseName.trim().isNotEmpty
? (customUploadBaseName.contains('.')
? customUploadBaseName
: '$customUploadBaseName.$extName')
: 'img_${DateTime.now().millisecondsSinceEpoch}.$extName';
final presignedRes = await ImageApi.getUploadPresignedUrl(
fileName1: fileName,
contentType: contentType,
expectedSize: size,
);
if (!presignedRes.isSuccess || presignedRes.data == null) {
throw StateError(
presignedRes.msg.isNotEmpty
? presignedRes.msg
: 'Failed to get upload URL',
);
}
final presigned = presignedRes.data!;
final uploadUrl = presigned.uploadUrl;
final filePath = presigned.filePath;
if (uploadUrl == null ||
uploadUrl.isEmpty ||
filePath == null ||
filePath.isEmpty) {
throw StateError('Invalid presigned URL response');
}
final headers = _mergePutHeaders(
presigned.putHeaders,
extraPutHeaders,
contentType,
);
final bytes = await toUpload.readAsBytes();
final uri = Uri.parse(uploadUrl);
_presignedPutLog.d(
'PUT begin url=$uploadUrl bytes=${bytes.length} '
'contentType=${headers['Content-Type']}',
);
_presignedPutLog.d(
'PUT header keys: ${headers.keys.join(', ')}',
);
final uploadResponse = await http.put(
uri,
headers: headers,
body: bytes,
);
_presignedPutLog.d(
'PUT done status=${uploadResponse.statusCode} '
'reason=${uploadResponse.reasonPhrase} '
'respBodyLen=${uploadResponse.body.length}',
);
if (uploadResponse.statusCode < 200 || uploadResponse.statusCode >= 300) {
final snippet = uploadResponse.body.length > 800
? '${uploadResponse.body.substring(0, 800)}…'
: uploadResponse.body;
_presignedPutLog.e('PUT failed body snippet: $snippet');
throw StateError('Upload failed: ${uploadResponse.statusCode}');
}
return _UploadedPart(toUpload: toUpload, serverPath: filePath);
}
/// [srcImageServerPath] 默认使用预签名返回的 [UploadPresignedUrlResponse.filePath];若接口要求填 URL 字段可自行传入。
static Future<ImagePresignedUploadCreateTaskResult> run({
required File sourceFile,
required String userId,
bool compressFirst = true,
CompressImageForUploadOptions compressOptions =
const CompressImageForUploadOptions(maxSide: 1024, jpegQuality: 75),
String? customUploadBaseName,
String? resolution,
String? srcImgUrls,
String? prompt,
String? size,
String? taskType,
String? templateName,
String? imgUrl,
bool needopt = false,
String? ext,
/// 保留兼容;上传路径一律走 `srcImg1Url`(换皮见 `skin_config`),此标志不再改变行为。
bool createTaskUseImgUrlOnly = false,
/// 默认 `true`:成功且能解析 [CreateTaskResponse.taskId] 时保存本地封面。
bool saveLocalUploadCover = true,
Map<String, String>? extraPutHeaders,
}) async {
final part = await _uploadOneFile(
sourceFile: sourceFile,
compressFirst: compressFirst,
compressOptions: compressOptions,
customUploadBaseName: customUploadBaseName,
extraPutHeaders: extraPutHeaders,
);
final serverPath = srcImgUrls ?? part.serverPath;
final pathForTask = resolution ?? serverPath;
final createRes = await ImageApi.createTask(
userId: userId,
srcImg1Url: pathForTask,
prompt: prompt,
size: size,
taskType: taskType,
templateName: templateName,
imgUrl: imgUrl,
needopt: needopt,
ext: ext,
);
if (!createRes.isSuccess || createRes.data == null) {
throw StateError(
createRes.msg.isNotEmpty ? createRes.msg : 'Failed to create task',
);
}
final cr = createRes.data!;
if (saveLocalUploadCover) {
await TaskUploadCoverStore.saveAfterCreateTaskResponse(
response: cr,
source: part.toUpload,
);
}
return ImagePresignedUploadCreateTaskResult(
createResponse: cr,
fileUsedForUpload: part.toUpload,
);
}
/// 上传两张图:第一张 `srcImg1Url`(参数 `resolution` 或上传路径),第二张 `srcImg2`。
/// 封面与本地预览仍以 [sourceFile1] 为准。
static Future<ImagePresignedUploadCreateTaskResult> runTwoSourceFiles({
required File sourceFile1,
required File sourceFile2,
required String userId,
bool compressFirst = true,
CompressImageForUploadOptions compressOptions =
const CompressImageForUploadOptions(maxSide: 1024, jpegQuality: 75),
String? resolution,
String? prompt,
String? size,
String? taskType,
String? templateName,
String? imgUrl,
bool needopt = false,
String? ext,
bool saveLocalUploadCover = true,
Map<String, String>? extraPutHeaders,
}) async {
final part1 = await _uploadOneFile(
sourceFile: sourceFile1,
compressFirst: compressFirst,
compressOptions: compressOptions,
extraPutHeaders: extraPutHeaders,
);
final part2 = await _uploadOneFile(
sourceFile: sourceFile2,
compressFirst: compressFirst,
compressOptions: compressOptions,
extraPutHeaders: extraPutHeaders,
);
final createRes = await ImageApi.createTask(
userId: userId,
srcImg1Url: resolution ?? part1.serverPath,
srcImg2: part2.serverPath,
prompt: prompt,
size: size,
taskType: taskType,
templateName: templateName,
imgUrl: imgUrl,
needopt: needopt,
ext: ext,
);
if (!createRes.isSuccess || createRes.data == null) {
throw StateError(
createRes.msg.isNotEmpty ? createRes.msg : 'Failed to create task',
);
}
final cr = createRes.data!;
if (saveLocalUploadCover) {
await TaskUploadCoverStore.saveAfterCreateTaskResponse(
response: cr,
source: part1.toUpload,
);
}
return ImagePresignedUploadCreateTaskResult(
createResponse: cr,
fileUsedForUpload: part1.toUpload,
);
}
}

View File

@ -0,0 +1,206 @@
import 'dart:async';
import 'dart:io';
import 'package:http/http.dart' show ClientException;
import '../api/proxy_client.dart';
import '../entities/image_entities.dart';
import 'image_api.dart';
/// 单次轮询回调数据(成功从线网拿到一帧 [EntityResponse] 后触发)。
class ProgressPollTick {
const ProgressPollTick({
required this.response,
this.transientNetworkFailuresBeforeSuccess = 0,
});
final EntityResponse<ProgressResponse> response;
/// 本次成功请求前累计的瞬时网络失败次数(已清零后成功)。
final int transientNetworkFailuresBeforeSuccess;
}
/// 进度语义(与 FunyMee 生成页约定一致),供宿主在回调里分支。
abstract final class ProgressPollSemantics {
static bool hasUsableResultUrl(String? url) {
if (url == null || url.isEmpty) return false;
final u = url.trim();
return u.startsWith('http://') || u.startsWith('https://');
}
static bool isTerminalStatus(String? status) {
final t = (status ?? '').toLowerCase();
return t == 'success' ||
t == 'completed' ||
t == 'complete' ||
t == 'failed' ||
t == 'failure' ||
t == 'error' ||
t == 'cancelled' ||
t == 'canceled';
}
static bool isSuccessTerminal(String? status) {
final t = (status ?? '').toLowerCase();
if (t == 'success' || t == 'completed' || t == 'complete') return true;
// 部分线网把完成态写在 `status` 字符串里(或与 `state` 同步为 "3")。
if (t == '3') return true;
return false;
}
static bool isFailureTerminal(String? status) {
final t = (status ?? '').toLowerCase();
return t == 'failed' ||
t == 'failure' ||
t == 'error' ||
t == 'cancelled' ||
t == 'canceled';
}
/// 是否应结束轮询(成功出图、终态、或已有可下载 URL)。
static bool shouldStopPolling(ProgressResponse p) {
if (hasUsableResultUrl(p.resultUrl)) return true;
if (isTerminalStatus(p.status)) return true;
final s = p.state;
// 3=完成,4=超时,5=错误,6=中止(FunyMee 文档)
if (s != null && s >= 3 && s <= 6) return true;
return false;
}
/// 是否应跳转结果页(完成且尽量已有 URL;`state==3` 为权威完成态)。
static bool isProgressSuccess(ProgressResponse p) {
if (hasUsableResultUrl(p.resultUrl)) return true;
if (p.state == 3) return true;
return isSuccessTerminal(p.status);
}
/// 是否终态失败(超时/错误/中止或字符串失败态)。
static bool isProgressFailure(ProgressResponse p) {
final s = p.state;
if (s == 4 || s == 5 || s == 6) return true;
return isTerminalStatus(p.status) && isFailureTerminal(p.status);
}
}
/// 可取消的轮询句柄。
class ImageProgressPollHandle {
ImageProgressPollHandle._(void Function() cancel) : _cancel = cancel;
final void Function() _cancel;
void cancel() => _cancel();
}
/// [ImageApi.getProgress] 串行轮询:上一帧完成后再等待 [interval] 发起下一帧,不重叠。
abstract final class ImageProgressPoll {
static const Duration defaultInterval = Duration(seconds: 5);
/// 在独立异步循环中运行;首次请求在 [delayBeforeFirst] 之后(默认为 0)。
///
/// - [onTick]:每次成功走完一次 `getProgress`(含业务失败码)时调用。
/// - [onTransientNetworkFailure]:瞬时网络错误时重试前调用(`count` 从 1 递增)。
/// - [onFatalError]:超过 [maxTransientNetworkFailures] 或框架判定无法继续时调用。
static ImageProgressPollHandle start({
required String app,
required String taskId,
String? userId,
Duration interval = defaultInterval,
Duration delayBeforeFirst = Duration.zero,
int maxTransientNetworkFailures = 12,
required void Function(ProgressPollTick tick) onTick,
void Function(int failureCount, int maxFailures)? onTransientNetworkFailure,
void Function(String message)? onFatalError,
}) {
var cancelled = false;
void cancel() => cancelled = true;
Future<void> loop() async {
if (delayBeforeFirst > Duration.zero) {
await Future<void>.delayed(delayBeforeFirst);
}
while (!cancelled) {
EntityResponse<ProgressResponse>? res;
var transientBeforeSuccess = 0;
while (!cancelled) {
try {
res = await ImageApi.getProgress(
app: app,
taskId: taskId,
userId: userId,
);
break;
} on SocketException catch (_) {
transientBeforeSuccess++;
onTransientNetworkFailure?.call(
transientBeforeSuccess,
maxTransientNetworkFailures,
);
if (transientBeforeSuccess >= maxTransientNetworkFailures) {
onFatalError?.call(
'Network unstable (TLS). Check connection or try again later.',
);
return;
}
} on HandshakeException catch (_) {
transientBeforeSuccess++;
onTransientNetworkFailure?.call(
transientBeforeSuccess,
maxTransientNetworkFailures,
);
if (transientBeforeSuccess >= maxTransientNetworkFailures) {
onFatalError?.call(
'Network unstable (TLS). Check connection or try again later.',
);
return;
}
} on TlsException catch (_) {
transientBeforeSuccess++;
onTransientNetworkFailure?.call(
transientBeforeSuccess,
maxTransientNetworkFailures,
);
if (transientBeforeSuccess >= maxTransientNetworkFailures) {
onFatalError?.call(
'Network unstable (TLS). Check connection or try again later.',
);
return;
}
} on ClientException catch (_) {
transientBeforeSuccess++;
onTransientNetworkFailure?.call(
transientBeforeSuccess,
maxTransientNetworkFailures,
);
if (transientBeforeSuccess >= maxTransientNetworkFailures) {
onFatalError?.call(
'Network unstable. Check connection or try again later.',
);
return;
}
}
}
if (cancelled || res == null) return;
onTick(ProgressPollTick(
response: res,
transientNetworkFailuresBeforeSuccess: transientBeforeSuccess,
));
if (!res.isSuccess || res.data == null) {
return;
}
final p = res.data!;
if (ProgressPollSemantics.shouldStopPolling(p)) {
return;
}
await Future<void>.delayed(interval);
}
}
unawaited(loop());
return ImageProgressPollHandle._(cancel);
}
}

View File

@ -0,0 +1,40 @@
import '../entities/gallery_task_models.dart';
import '../entities/image_entities.dart';
import 'task_upload_cover_store.dart';
/// 与 app_client 对齐的「我的任务」数据解析与本地上传封面路径合并(无 UI)。
abstract final class ImageTaskHistory {
ImageTaskHistory._();
/// 解析解密后的列表体(如 `EntityResponse.data`):支持 `tasks` / `intensify`。
static List<GalleryTaskItem> parseGalleryTasksFromData(
Map<String, dynamic>? data,
) {
if (data == null) return [];
final list = (data['tasks'] ?? data['intensify']) as List<dynamic>? ?? [];
return list
.whereType<Map<String, dynamic>>()
.map(GalleryTaskItem.fromJson)
.toList();
}
static bool? parseHasNextFromData(Map<String, dynamic>? data) {
if (data == null) return null;
return data['hasNext'] as bool? ?? data['manifest'] as bool?;
}
/// [GalleryTaskItem.taskId] 与 [TaskUploadCoverStore] 使用的数字 id 一致。
static Future<Map<int, String>> localCoverPathsForGalleryTasks(
Iterable<GalleryTaskItem> tasks,
) {
final ids = tasks.map((t) => t.taskId).where((id) => id > 0);
return TaskUploadCoverStore.existingPathsForTaskIdsInt(ids);
}
static Future<Map<String, String>> localCoverPathsForMyTaskItems(
Iterable<MyTaskItem> items,
) {
final ids = items.map((e) => e.taskId).whereType<String>();
return TaskUploadCoverStore.existingPathsForTaskIds(ids);
}
}

View File

@ -2,7 +2,7 @@ import '../api/api_client.dart';
import '../api/proxy_client.dart';
import '../entities/payment_entities.dart';
/// 支付相关 API(使用原始字段名)
/// 支付相关 API(**业务逻辑字段名**,经 [FieldMapping] 映射为线网字段名)
///
/// **请求头**:需登录接口自动附带 `pkg` 与 `User_token`。
abstract final class PaymentApi {
@ -74,18 +74,15 @@ abstract final class PaymentApi {
/// 创建支付订单
///
/// **Query**:`app`、`userId`。
/// **Body**:必填字段 + 文档中的可选字段(以下为原始名,非空才写入):
/// `lastName`、`country`、`expireMonth`、`accountName`、`userInfoType`、
/// `automaticRenewal`、`channel`、`cvcCode`、`channelType`、`firstName`、
/// `subPaymentMethod`、`phone`、`tgOrderId`、`tgId`、`name`、`expireYear`、
/// `card`、`status`;另保留换皮用的 `lineage`、`armor`(视 [FieldMapping] 而定)。
/// **Body**:必填字段 + 可选字段(逻辑名,非空才写入;线网名见 `skin_config.fieldMapping`):
/// `lastName`、`country`、…、`card`、`status`;其中 `fps` 常映射为线网 `lineage` 等。
static Future<EntityResponse<CreatePaymentResponse>> createPayment({
required String app,
required String userId,
required String activityId,
required String paymentMethod,
String? paymentType,
String? lineage,
String? fps,
String? armor,
String? lastName,
String? country,
@ -118,7 +115,7 @@ abstract final class PaymentApi {
'paymentMethod': paymentMethod,
if (paymentType != null && paymentType.isNotEmpty)
'paymentType': paymentType,
if (lineage != null && lineage.isNotEmpty) 'lineage': lineage,
if (fps != null && fps.isNotEmpty) 'fps': fps,
if (armor != null && armor.isNotEmpty) 'armor': armor,
if (lastName != null && lastName.isNotEmpty) 'lastName': lastName,
if (country != null && country.isNotEmpty) 'country': country,
@ -183,7 +180,7 @@ abstract final class PaymentApi {
);
}
/// 获取订单详情(query 使用原始字段 `id` 表示订单/支付 ID)
/// 获取订单详情(query 使用逻辑字段 `id` 表示订单/支付 ID)
static Future<EntityResponse<OrderDetailResponse>> getOrderDetail({
required String userId,
required String orderId,

View File

@ -0,0 +1,199 @@
import 'package:flutter/foundation.dart';
import '../../api/api_client.dart';
import '../../entities/payment_entities.dart';
import '../payment_api.dart';
import '../payment_service.dart';
import 'payment_flow_models.dart';
import 'payment_settlement_sink.dart';
/// 原生商店内购编排(当前 **完整自动核销仅 Android Google Play**)。
///
/// 与 **app_client** [RechargeScreen] 对齐:
/// - 先 [PaymentApi.createPayment];
/// - 若响应中 **无收银台 URL**([CreatePaymentResponse.payUrl] 为空,线网常为 `convert` 空),则走 Play 拉起 + [PaymentApi.googlepay];
/// - 若有 [payUrl],则由宿主打开 H5/浏览器并轮询订单,**不**走本类。
abstract final class NativeIapCoordinator {
/// 与 app_client [RechargeScreen._shouldUseGooglePay] 一致:`payUrl` 为空则走谷歌内购。
static bool shouldLaunchGooglePlayBillingInsteadOfWeb(String? payUrl) {
final p = payUrl?.trim() ?? '';
return p.isEmpty;
}
/// Android Google Play 全流程(内部先 [PaymentApi.createPayment])。
///
/// [activityId]:后台活动/商品 id(与 `PaymentApi.createPayment.activityId` 一致)。
/// [storeProductId]:商店架上的 `productId`(与 [PaymentProductItem] 等一致)。
static Future<void> purchaseGooglePlay({
required PaymentSettlementSink sink,
required String userId,
required String activityId,
required String storeProductId,
String paymentMethod = 'GooglePay',
String? paymentType,
String? createPaymentApp,
}) async {
if (defaultTargetPlatform != TargetPlatform.android) {
sink.onPaymentSettled(PaymentSettlement.failure(
message:
'NativeIapCoordinator.purchaseGooglePlay only supports Android',
));
return;
}
if (userId.isEmpty) {
sink.onPaymentSettled(
PaymentSettlement.failure(message: 'userId is empty'));
return;
}
final cfg = ApiClient.instance.config;
final app = createPaymentApp ?? cfg.backendAppTypeAndroid;
try {
final createRes = await PaymentApi.createPayment(
app: app,
userId: userId,
activityId: activityId,
paymentMethod: paymentMethod,
paymentType: paymentType ?? paymentMethod,
);
if (!createRes.isSuccess || createRes.data == null) {
sink.onPaymentSettled(PaymentSettlement.failure(
message: createRes.msg.isNotEmpty
? createRes.msg
: 'createPayment failed',
));
return;
}
await _completeGooglePlayIap(
sink: sink,
userId: userId,
storeProductId: storeProductId,
data: createRes.data!,
app: app,
);
} catch (e) {
sink.onPaymentSettled(
PaymentSettlement.failure(message: e.toString()));
}
}
/// **建单已成功**(例如三方列表选中后已 [PaymentApi.createPayment])且 [shouldLaunchGooglePlayBillingInsteadOfWeb] 为 true 时调用:
/// 仅拉起 Play + [PaymentApi.googlepay] + consume,**不再**请求 createPayment。
///
/// 对应 app_client [RechargeScreen._launchGooglePlayPurchase](带 [serverOrderId])。
static Future<void> purchaseGooglePlayAfterCreatePayment({
required PaymentSettlementSink sink,
required String userId,
required String storeProductId,
required CreatePaymentResponse createResponse,
String? createPaymentApp,
}) async {
if (defaultTargetPlatform != TargetPlatform.android) {
sink.onPaymentSettled(PaymentSettlement.failure(
message:
'NativeIapCoordinator.purchaseGooglePlay only supports Android',
));
return;
}
if (userId.isEmpty) {
sink.onPaymentSettled(
PaymentSettlement.failure(message: 'userId is empty'));
return;
}
final cfg = ApiClient.instance.config;
final app = createPaymentApp ?? cfg.backendAppTypeAndroid;
try {
await _completeGooglePlayIap(
sink: sink,
userId: userId,
storeProductId: storeProductId,
data: createResponse,
app: app,
);
} catch (e) {
sink.onPaymentSettled(
PaymentSettlement.failure(message: e.toString()));
}
}
static Future<void> _completeGooglePlayIap({
required PaymentSettlementSink sink,
required String userId,
required String storeProductId,
required CreatePaymentResponse data,
required String app,
}) async {
final serverFederation = data.federationOrOrderId;
final purchase = await PaymentService.launchPurchaseAndReturnData(
storeProductId,
);
if (purchase == null) {
sink.onPaymentSettled(PaymentSettlement.cancelled(
message: 'Purchase cancelled or failed',
));
return;
}
final federation = (serverFederation != null &&
serverFederation.isNotEmpty)
? serverFederation
: purchase.orderId;
if (serverFederation != null && serverFederation.isNotEmpty) {
await PaymentService.saveFederationForGoogleOrderId(
purchase.orderId,
serverFederation,
);
}
final googlepayRes = await PaymentApi.googlepay(
signature: purchase.payload.signature,
purchaseData: purchase.payload.purchaseData,
orderId: federation,
userId: userId,
app: app,
);
if (!googlepayRes.isSuccess || googlepayRes.data == null) {
sink.onPaymentSettled(PaymentSettlement.failure(
orderId: federation,
message: googlepayRes.msg.isNotEmpty
? googlepayRes.msg
: 'googlepay verification failed',
));
return;
}
final body = googlepayRes.data!;
if (!_isGooglePaySuccess(body)) {
sink.onPaymentSettled(PaymentSettlement.failure(
orderId: federation,
message: body.status ?? 'verification not successful',
));
return;
}
await PaymentService.completeAndConsumePurchase(purchase.purchaseDetails);
if (serverFederation != null && serverFederation.isNotEmpty) {
await PaymentService.removeFederationForGoogleOrderId(purchase.orderId);
}
sink.onPaymentSettled(PaymentSettlement.success(
orderId: federation,
thirdParty: false,
));
}
static bool _isGooglePaySuccess(GooglePayCallbackResponse body) {
if (body.creditsAdded == true) return true;
final s = (body.status ?? '').toUpperCase();
return s == 'SUCCESS';
}
}

View File

@ -0,0 +1,2 @@
/// 宿主注入:打开 H5/三方收银台(应用内 WebView / 系统浏览器 / 外链)。
typedef PaymentCheckoutUrlLauncher = Future<void> Function(Uri url);

View File

@ -0,0 +1,10 @@
/// 支付编排(宿主策略 + 框架编排):档位列表、三方建单/轮询、Google Play 内购收口。
library payment_flow;
export 'payment_checkout_launcher.dart';
export 'payment_flow_catalog.dart';
export 'payment_flow_models.dart';
export 'payment_settlement_sink.dart';
export 'native_iap_coordinator.dart';
export 'third_party_checkout_coordinator.dart';
export 'third_party_payment_watch.dart';

View File

@ -0,0 +1,28 @@
import 'package:flutter/foundation.dart';
import '../../api/proxy_client.dart';
import '../../entities/payment_entities.dart';
import '../payment_api.dart';
/// 充值档位 / 商品列表(封装平台差异)。
abstract final class PaymentFlowCatalog {
/// 按当前平台加载预置支付活动(Google / Apple)。
static Future<EntityResponse<PaymentProductsResponse>> loadStoreActivities({
String? client,
String? country,
String? pkg,
}) async {
if (defaultTargetPlatform == TargetPlatform.iOS) {
return PaymentApi.getApplePayActivities(
client: client,
country: country,
pkg: pkg,
);
}
return PaymentApi.getGooglePayActivities(
client: client,
country: country,
pkg: pkg,
);
}
}

View File

@ -0,0 +1,155 @@
/// 支付编排结果类型(内购 / 三方统一收口,宿主在 [PaymentSettlementSink.onPaymentSettled] 内刷新账户)。
enum PaymentFlowOutcomeType {
/// 服务端或商店流程已确认成功
success,
/// 明确失败(含验单失败)
failure,
/// 用户取消或未继续
cancelled,
/// 轮询超时或达到上限
timeout,
/// 当前仅用于扩展;框架未自动核销时宿主可接手
nativePendingHostVerification,
}
/// 单次支付流结束后交给宿主的一体化结果。
class PaymentSettlement {
PaymentSettlement._({
required this.type,
this.orderId,
this.message,
this.thirdParty = false,
this.extra,
});
factory PaymentSettlement.success({
String? orderId,
String? message,
bool thirdParty = false,
Map<String, String>? extra,
}) {
return PaymentSettlement._(
type: PaymentFlowOutcomeType.success,
orderId: orderId,
message: message,
thirdParty: thirdParty,
extra: extra,
);
}
factory PaymentSettlement.failure({
String? orderId,
String? message,
bool thirdParty = false,
}) {
return PaymentSettlement._(
type: PaymentFlowOutcomeType.failure,
orderId: orderId,
message: message,
thirdParty: thirdParty,
);
}
factory PaymentSettlement.cancelled({String? orderId, String? message}) {
return PaymentSettlement._(
type: PaymentFlowOutcomeType.cancelled,
orderId: orderId,
message: message,
);
}
factory PaymentSettlement.timeout({String? orderId, String? message}) {
return PaymentSettlement._(
type: PaymentFlowOutcomeType.timeout,
orderId: orderId,
message: message,
thirdParty: true,
);
}
factory PaymentSettlement.nativePendingHostVerification({
required String message,
String? orderId,
Map<String, String>? extra,
}) {
return PaymentSettlement._(
type: PaymentFlowOutcomeType.nativePendingHostVerification,
orderId: orderId,
message: message,
extra: extra,
);
}
final PaymentFlowOutcomeType type;
final String? orderId;
final String? message;
final bool thirdParty;
final Map<String, String>? extra;
}
/// 三方「建单 + 拉起收银台」结果。
class ThirdPartyCheckoutOutcome {
ThirdPartyCheckoutOutcome._({
required this.isSuccess,
this.message,
this.orderId,
this.payUrl,
this.createResponse,
});
factory ThirdPartyCheckoutOutcome.ok({
required String orderId,
String? payUrl,
dynamic createResponse,
}) {
return ThirdPartyCheckoutOutcome._(
isSuccess: true,
orderId: orderId,
payUrl: payUrl,
createResponse: createResponse,
);
}
factory ThirdPartyCheckoutOutcome.fail(String message) {
return ThirdPartyCheckoutOutcome._(isSuccess: false, message: message);
}
final bool isSuccess;
final String? message;
final String? orderId;
final String? payUrl;
final dynamic createResponse;
}
/// 三方订单轮询策略(按 [OrderDetailResponse.status] 字符串比较,不区分大小写)。
class PaymentPollPolicy {
const PaymentPollPolicy({
this.interval = const Duration(seconds: 2),
this.maxDuration = const Duration(minutes: 5),
this.successStatuses = const {
'paid',
'success',
'completed',
'paid_success',
'success_paid',
},
this.failureStatuses = const {
'failed',
'cancel',
'cancelled',
'canceled',
'closed',
'expired',
'fail',
},
});
final Duration interval;
final Duration maxDuration;
final Set<String> successStatuses;
final Set<String> failureStatuses;
}

View File

@ -0,0 +1,6 @@
import 'payment_flow_models.dart';
/// 宿主实现:在任意分支结束时刷新积分 / common_info / UI。
abstract class PaymentSettlementSink {
void onPaymentSettled(PaymentSettlement settlement);
}

View File

@ -0,0 +1,77 @@
import 'package:flutter/foundation.dart';
import '../../api/api_client.dart';
import '../payment_api.dart';
import 'payment_checkout_launcher.dart';
import 'payment_flow_models.dart';
/// 三方:创建订单 → 可选打开 [payUrl]。
abstract final class ThirdPartyCheckoutCoordinator {
/// 调 [PaymentApi.createPayment];成功时返回 `orderId`(entity 内)与 `payUrl`。
static Future<ThirdPartyCheckoutOutcome> createOrder({
required String userId,
required String activityId,
required String paymentMethod,
String? paymentType,
String? app,
String? fps,
String? armor,
String? country,
String? subPaymentMethod,
}) async {
final cfg = ApiClient.instance.config;
final backendApp = app ??
(defaultTargetPlatform == TargetPlatform.iOS
? cfg.backendAppTypeIOS
: cfg.backendAppTypeAndroid);
final res = await PaymentApi.createPayment(
app: backendApp,
userId: userId,
activityId: activityId,
paymentMethod: paymentMethod,
paymentType: paymentType,
fps: fps,
armor: armor,
country: country,
subPaymentMethod: subPaymentMethod,
);
if (!res.isSuccess || res.data == null) {
return ThirdPartyCheckoutOutcome.fail(
res.msg.isNotEmpty ? res.msg : 'createPayment failed',
);
}
final data = res.data!;
final oid = data.orderId ?? data.federation;
if (oid == null || oid.isEmpty) {
return ThirdPartyCheckoutOutcome.fail('Missing order id in response');
}
final url = data.payUrl;
return ThirdPartyCheckoutOutcome.ok(
orderId: oid,
payUrl: (url != null && url.isNotEmpty) ? url : null,
createResponse: data,
);
}
/// 若 [payUrl] 非空则调用 [launcher](宿主实现 WebView / 外链)。
static Future<void> openPayUrlIfPresent(
String? payUrl,
PaymentCheckoutUrlLauncher? launcher,
) async {
if (payUrl == null || payUrl.isEmpty) return;
if (launcher == null) {
throw StateError(
'ThirdPartyCheckoutCoordinator: payUrl is set but launcher is null',
);
}
final uri = Uri.tryParse(payUrl);
if (uri == null) {
throw FormatException('Invalid payUrl: $payUrl');
}
await launcher(uri);
}
}

View File

@ -0,0 +1,101 @@
import 'dart:async';
import '../payment_api.dart';
import 'payment_flow_models.dart';
import 'payment_settlement_sink.dart';
/// 三方支付完成后,按订单轮询 [PaymentApi.getOrderDetail] 直至成功 / 失败 / 超时。
///
/// 宿主在「从收银台返回 App」后调用 [start];离开页面或已成功时 [stop]。
class ThirdPartyPaymentWatch {
ThirdPartyPaymentWatch({
required this.userId,
required this.sink,
this.policy = const PaymentPollPolicy(),
});
final String userId;
final PaymentSettlementSink sink;
final PaymentPollPolicy policy;
Timer? _timer;
DateTime? _startedAt;
bool _settled = false;
/// 开始轮询 [orderId];重复调用会先 [stop] 再起。
void start({required String orderId}) {
stop();
_settled = false;
_startedAt = DateTime.now();
_timer = Timer.periodic(policy.interval, (_) {
unawaited(_tick(orderId));
});
unawaited(_tick(orderId));
}
/// 停止轮询(不触发 sink)。
void stop() {
_timer?.cancel();
_timer = null;
}
void _complete(PaymentSettlement settlement) {
if (_settled) return;
_settled = true;
stop();
sink.onPaymentSettled(settlement);
}
Future<void> _tick(String orderId) async {
if (_settled) return;
final started = _startedAt;
if (started != null &&
DateTime.now().difference(started) > policy.maxDuration) {
_complete(PaymentSettlement.timeout(
orderId: orderId,
message: 'Payment status polling timed out',
));
return;
}
try {
final res = await PaymentApi.getOrderDetail(
userId: userId,
orderId: orderId,
);
if (_settled) return;
if (!res.isSuccess || res.data == null) {
return;
}
final detail = res.data!;
final raw = detail.status;
if (raw == null || raw.isEmpty) return;
final norm = raw.trim().toLowerCase();
for (final s in policy.successStatuses) {
if (norm == s.toLowerCase()) {
_complete(PaymentSettlement.success(
orderId: detail.orderId ?? orderId,
message: raw,
thirdParty: true,
));
return;
}
}
for (final s in policy.failureStatuses) {
if (norm == s.toLowerCase()) {
_complete(PaymentSettlement.failure(
orderId: detail.orderId ?? orderId,
message: raw,
thirdParty: true,
));
return;
}
}
} catch (_) {
/* 单次失败忽略,依赖超时 */
}
}
void dispose() => stop();
}

View File

@ -0,0 +1,144 @@
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import '../entities/image_entities.dart';
import '../entities/task_id_parse.dart';
/// 创建生图任务成功后,将用户上传的本地文件按 [taskId] 落盘一份,便于「我的任务」在接口尚未返回封面时使用。
///
/// 与 app_client [GalleryUploadCoverStore] 行为对齐:目录位于应用 support 下的 `gallery_upload_covers`,
/// 默认文件名 `{taskId}.jpg`(纯数字 id),并带 25h 过期清理。
abstract final class TaskUploadCoverStore {
TaskUploadCoverStore._();
static const String _subdir = 'gallery_upload_covers';
static const String _fileExt = '.jpg';
static const Duration maxRetention = Duration(hours: 25);
static Future<Directory> _directory() async {
final base = await getApplicationSupportDirectory();
final dir = Directory('${base.path}/$_subdir');
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
}
static Future<Directory> _directoryAfterPurge() async {
final dir = await _directory();
await _purgeExpired(dir);
return dir;
}
static Future<void> _purgeExpired(Directory dir) async {
if (!await dir.exists()) return;
final now = DateTime.now();
try {
await for (final entity in dir.list(followLinks: false)) {
if (entity is! File) continue;
final name = entity.uri.pathSegments.last;
if (!name.endsWith(_fileExt)) continue;
final stat = await entity.stat();
if (now.difference(stat.modified) >= maxRetention) {
try {
await entity.delete();
} catch (_) {}
}
}
} catch (_) {}
}
/// 与 app_client 数字 id 文件名一致:`123.jpg`。
static String fileBaseNameForTaskId(String taskId) {
final t = taskId.trim();
if (t.isEmpty) return '';
if (RegExp(r'^\d+$').hasMatch(t)) return t;
return t.replaceAll(RegExp(r'[^0-9a-zA-Z_-]+'), '_');
}
static File _fileForTaskId(Directory dir, String taskId) =>
File('${dir.path}/${fileBaseNameForTaskId(taskId)}$_fileExt');
/// [source] 一般为压缩后的待上传文件。
static Future<void> saveForTask(String taskId, File source) async {
final id = taskId.trim();
if (id.isEmpty) return;
if (!await source.exists()) return;
final dir = await _directoryAfterPurge();
final dest = _fileForTaskId(dir, id);
await source.copy(dest.path);
}
static Future<void> saveForTaskInt(int taskId, File source) =>
saveForTask(taskId.toString(), source);
static Future<String?> pathIfExists(String taskId) async {
final id = taskId.trim();
if (id.isEmpty) return null;
final dir = await _directoryAfterPurge();
final f = _fileForTaskId(dir, id);
return await f.exists() ? f.path : null;
}
static Future<String?> pathIfExistsInt(int taskId) async {
if (taskId <= 0) return null;
return pathIfExists(taskId.toString());
}
/// 仅返回已存在文件的 path,用于列表刷新后填充状态。
static Future<Map<String, String>> existingPathsForTaskIds(
Iterable<String> ids,
) async {
final dir = await _directoryAfterPurge();
final out = <String, String>{};
for (final raw in ids) {
final id = raw.trim();
if (id.isEmpty) continue;
final f = _fileForTaskId(dir, id);
if (await f.exists()) {
out[id] = f.path;
}
}
return out;
}
/// 与 app_client 一致:key 为解析后的 int 任务 id(仅当 id 为有效正整数时加入)。
static Future<Map<int, String>> existingPathsForTaskIdsInt(
Iterable<int> ids,
) async {
final dir = await _directoryAfterPurge();
final out = <int, String>{};
for (final id in ids) {
if (id <= 0) continue;
final f = _fileForTaskId(dir, id.toString());
if (await f.exists()) {
out[id] = f.path;
}
}
return out;
}
/// 创建任务接口解密后的 data(或整段 JSON),解析出任务 id 后异步保存封面(不阻塞调用方时可 `unawaited`)。
static Future<void> saveAfterCreateTaskBody({
required Map<String, dynamic>? body,
required File source,
}) async {
final id = parseTaskIdFromMap(body);
if (id != null && id.isNotEmpty) {
await saveForTask(id, source);
}
}
/// [ImageApi.createTask] 等返回的实体;[CreateTaskResponse.taskId] 由 [parseTaskIdFromMap] 解析(含 `exponential` 等键)。
static Future<void> saveAfterCreateTaskResponse({
required CreateTaskResponse? response,
required File source,
}) async {
final id = response?.taskId?.trim();
if (id != null && id.isNotEmpty) {
await saveForTask(id, source);
}
}
}

View File

@ -0,0 +1,26 @@
import '../entities/user_entities.dart';
import 'user_api.dart';
/// 拉取账户信息供宿主更新积分/会员等展示(不包含 `ValueNotifier` 等 UI 状态)。
abstract final class UserAccountRefresh {
UserAccountRefresh._();
/// 成功后调用 [onAccount] 并返回 [AccountResponse];失败返回 `null`,并可选 [onFailure]。
static Future<AccountResponse?> fetchAndNotify({
required String app,
String? userId,
void Function(AccountResponse account)? onAccount,
void Function(String message)? onFailure,
}) async {
final res = await UserApi.getAccount(app: app, userId: userId);
if (!res.isSuccess || res.data == null) {
final msg =
res.msg.isNotEmpty ? res.msg : 'getAccount failed (code ${res.code})';
onFailure?.call(msg);
return null;
}
final account = res.data!;
onAccount?.call(account);
return account;
}
}

View File

@ -4,12 +4,12 @@ import '../api/proxy_client.dart';
import '../entities/image_entities.dart';
import '../entities/user_entities.dart';
/// 用户相关 API(使用原始字段名)
/// 用户相关 API(**业务逻辑字段名**,经 [FieldMapping] 映射为线网字段名)
///
/// **请求头**:除 [UserApi.fastLogin] 外,需登录接口均由 [ProxyClient] 自动附带
/// `pkg`(包名)与 `User_token`(已设置 token 时)。fast_login 仅带 `pkg`,不带 token。
///
/// **请求体**:与《客户端指南》一致,使用解密后的**原始字段名**(如 `referer`、`deviceId`)。
/// **请求体**:与《客户端指南》一致,使用**业务逻辑字段名**(如 `referer`、`deviceId`)。
abstract final class UserApi {
static ProxyClient get _client => ApiClient.instance.proxy;
@ -77,7 +77,7 @@ abstract final class UserApi {
/// 获取用户通用信息
///
/// 与当前接口约定一致(原始 query 字段名,会经 [AppConfig.fieldMapping] 映射):
/// 与当前接口约定一致(逻辑 query 字段名,会经 [AppConfig.fieldMapping] 映射):
/// - [app] 必填:应用渠道标识(常见 iOS `HIOS` / Android `HAndroid`,与 fast_login 一致)
/// - [pkg] 必填:应用包名
/// - 其余可选:[client]、[userId]、[ch]、[inviteBy]、[deviceId]、[clientId]

View File

@ -0,0 +1,160 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
/// 默认与 [ClientProxyFrameworkPlugin] Android 侧注册的通道一致。
const String kDefaultDeviceMemoryChannelName =
'client_proxy_framework/device_memory';
/// 物理内存低于约 3GiB(或读不到)时仅静态封面。
const int kMemoryTierStaticOnlyBytesThreshold = 3 * 1024 * 1024 * 1024;
/// 不少于约 6GiB 时允许更高并发解码;\[3GiB, 6GiB) 为中间档。
const int kMemoryTierFullConcurrentBytesThreshold = 6 * 1024 * 1024 * 1024;
enum GridVideoMemoryPolicy {
staticOnly,
maxConcurrent2,
maxConcurrent4,
}
GridVideoMemoryPolicy? _cachedPolicy;
GridVideoMemoryPolicy get _effectivePolicy =>
_cachedPolicy ?? GridVideoMemoryPolicy.staticOnly;
/// 须在 `WidgetsFlutterBinding.ensureInitialized()` 之后、`runApp` 之前调用一次(建议)。
Future<void> ensureDeviceMemoryProfileInitialized({
String methodChannelName = kDefaultDeviceMemoryChannelName,
}) async {
if (_cachedPolicy != null) return;
if (!Platform.isAndroid) {
_cachedPolicy = GridVideoMemoryPolicy.maxConcurrent4;
return;
}
final channel = MethodChannel(methodChannelName);
try {
final raw =
await channel.invokeMethod<dynamic>('getTotalPhysicalMemoryBytes');
final bytes = _coerceToPhysicalRamBytes(raw);
if (bytes != null && _isPlausibleDeviceRamBytes(bytes)) {
_cachedPolicy = _policyFromTotalBytes(bytes);
debugPrint(
'DeviceMemory: channel totalMem≈${(bytes / (1024 * 1024)).toStringAsFixed(0)}MiB '
'→ $_cachedPolicy',
);
return;
}
if (bytes != null) {
debugPrint(
'DeviceMemory: rejected implausible totalMem=$bytes (channel raw=$raw)',
);
}
} on MissingPluginException catch (e, st) {
debugPrint('DeviceMemory: channel missing: $e\n$st');
} on PlatformException catch (e, st) {
debugPrint(
'DeviceMemory: platform ${e.code} ${e.message}\n${e.details}\n$st',
);
} on Object catch (e, st) {
debugPrint('DeviceMemory: channel error: $e\n$st');
}
try {
final kb = _tryParseProcMemTotalKb();
if (kb != null) {
final bytes = kb * 1024;
if (_isPlausibleDeviceRamBytes(bytes)) {
_cachedPolicy = _policyFromTotalBytes(bytes);
debugPrint(
'DeviceMemory: /proc/meminfo MemTotal=${kb}kB → $_cachedPolicy',
);
return;
}
debugPrint(
'DeviceMemory: MemTotal kB=$kb implausible as bytes=$bytes',
);
}
} on Object catch (e, st) {
debugPrint('DeviceMemory: /proc/meminfo fallback error: $e\n$st');
}
debugPrint('DeviceMemory: unreadable → staticOnly (fallback)');
_cachedPolicy = GridVideoMemoryPolicy.staticOnly;
}
GridVideoMemoryPolicy _policyFromTotalBytes(int bytes) {
if (bytes < kMemoryTierStaticOnlyBytesThreshold) {
return GridVideoMemoryPolicy.staticOnly;
}
if (bytes < kMemoryTierFullConcurrentBytesThreshold) {
return GridVideoMemoryPolicy.maxConcurrent2;
}
return GridVideoMemoryPolicy.maxConcurrent4;
}
/// 是否在网格中禁用视频预览(仅静态图)。
bool get deviceGridStaticPreviewOnly =>
_effectivePolicy == GridVideoMemoryPolicy.staticOnly;
/// 网格同时解码/播放并发上限(0 表示不启视频仅用封面)。
int get deviceGridMaxConcurrentVideos {
switch (_effectivePolicy) {
case GridVideoMemoryPolicy.staticOnly:
return 0;
case GridVideoMemoryPolicy.maxConcurrent2:
return 2;
case GridVideoMemoryPolicy.maxConcurrent4:
return 4;
}
}
int? _coerceToPhysicalRamBytes(Object? raw) {
if (raw == null) return null;
if (raw is int) return raw > 0 ? raw : null;
if (raw is num) {
final v = raw.round();
if (v <= 0 || !v.isFinite) return null;
return v.toInt();
}
final s = raw.toString().trim();
if (s.isEmpty) return null;
return int.tryParse(s);
}
bool _isPlausibleDeviceRamBytes(int bytes) {
const minBytes = 128 * 1024 * 1024;
const maxBytes = 32 * 1024 * 1024 * 1024;
return bytes >= minBytes && bytes <= maxBytes;
}
int? _tryParseProcMemTotalKb() {
if (!Platform.isAndroid) return null;
try {
final file = File('/proc/meminfo');
final content = file.readAsStringSync();
if (content.isEmpty) return null;
final match = RegExp(
r'^MemTotal:\s+(\d+)\s+kB',
multiLine: true,
).firstMatch(content);
if (match == null) return null;
final kb = int.tryParse(match.group(1)!);
if (kb == null || kb <= 0) return null;
if (kb > 1 << 28) return null;
return kb;
} on FileSystemException catch (e, st) {
debugPrint('DeviceMemory: meminfo FileSystemException: $e\n$st');
return null;
} on IOException catch (e, st) {
debugPrint('DeviceMemory: meminfo IOException: $e\n$st');
} on FormatException catch (e, st) {
debugPrint('DeviceMemory: meminfo FormatException: $e\n$st');
} on Object catch (e, st) {
debugPrint('DeviceMemory: meminfo: $e\n$st');
}
return null;
}

View File

@ -9,6 +9,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.5.1"
archive:
dependency: transitive
description:
name: archive
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
url: "https://pub.dev"
source: hosted
version: "4.0.9"
args:
dependency: transitive
description:
@ -49,6 +57,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
collection:
dependency: transitive
description:
@ -115,6 +131,22 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
hooks:
dependency: transitive
description:
name: hooks
sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
url: "https://pub.dev"
source: hosted
version: "1.0.2"
http:
dependency: "direct main"
description:
@ -131,6 +163,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image:
dependency: "direct main"
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.dev"
source: hosted
version: "4.8.0"
in_app_purchase:
dependency: "direct main"
description:
@ -163,6 +203,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.4.8+1"
jni:
dependency: transitive
description:
name: jni
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
url: "https://pub.dev"
source: hosted
version: "1.0.0"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
js:
dependency: transitive
description:
@ -187,6 +243,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.7.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
material_color_utilities:
dependency: transitive
description:
@ -203,6 +267,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.17.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
url: "https://pub.dev"
source: hosted
version: "0.17.6"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
url: "https://pub.dev"
source: hosted
version: "9.3.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path:
dependency: transitive
description:
@ -211,6 +299,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "914a07484c4380e572998d30486e77e0d9cd2faec72fee268086d07bf7f302c9"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
@ -235,6 +347,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform:
dependency: transitive
description:
@ -267,6 +387,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.9.1"
posix:
dependency: transitive
description:
name: posix
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
url: "https://pub.dev"
source: hosted
version: "6.5.0"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
shared_preferences:
dependency: "direct main"
description:
@ -368,6 +504,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
video_thumbnail:
dependency: "direct main"
description:
name: video_thumbnail
sha256: "181a0c205b353918954a881f53a3441476b9e301641688a581e0c13f00dc588b"
url: "https://pub.dev"
source: hosted
version: "0.5.6"
web:
dependency: transitive
description:
@ -384,6 +528,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.9.0 <4.0.0"
flutter: ">=3.35.0"
dart: ">=3.10.3 <4.0.0"
flutter: ">=3.38.4"

View File

@ -28,3 +28,6 @@ dependencies:
in_app_purchase_android: ^0.4.0+8
play_install_referrer: ^0.5.0
shared_preferences: ^2.2.2
path_provider: ^2.1.2
image: ^4.3.0
video_thumbnail: ^0.5.6