client_framework/lib/src/config/ext_config_key_schema.dart

313 lines
13 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.forbidScreenshotKeys,
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` 时表示**禁止**截屏的键(默认 `screen`)。
/// 与 [blockScreenshotKeys] 二选一:[forbidScreenshotKeys] 优先(首个存在的键即生效)。
final List<String> forbidScreenshotKeys;
/// 备选用键:`true` 同样表示禁止截屏(默认 `safe_area`),仅当 [forbidScreenshotKeys] 未命中任何键时使用。
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'],
forbidScreenshotKeys: ['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);
}
/// `skin_config.extConfig.keys`:优先 [primary],为空则回退 [legacy](兼容旧键名),再不行用 [fallback]。
List<String> mergedRootList(
String primary,
String legacy,
List<String> fallback,
) {
final p = rootList(keyMap, primary, const <String>[]);
if (p.isNotEmpty) return p;
final l = rootList(keyMap, legacy, const <String>[]);
if (l.isNotEmpty) return l;
return fallback;
}
return ExtConfigKeySchema(
showVideoMenuKeys: rootList(keyMap, 'showVideoMenu', d.showVideoMenuKeys),
forbidScreenshotKeys: mergedRootList(
'forbidScreenshot',
'allowScreenshot',
d.forbidScreenshotKeys,
),
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` 节:键模式 + 可选默认 JSONwire 键名,与 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;
}
}