From 4c2d5585e376f44275c76e534d25d832304be5f9 Mon Sep 17 00:00:00 2001 From: ivan Date: Fri, 17 Apr 2026 22:28:33 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E8=AE=BE=E5=A4=87ID?= =?UTF-8?q?=E8=8E=B7=E5=8F=96=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/README.md | 4 + docs/create_new_skin_app.md | 7 +- docs/device_id_and_sign.md | 81 +++++++++++++ docs/skin_app_development_guide.md | 4 + docs/skin_config_example.md | 180 ++++++++++++++++++++++++++++ lib/src/entities/user_entities.dart | 22 +++- 6 files changed, 293 insertions(+), 5 deletions(-) create mode 100644 docs/device_id_and_sign.md create mode 100644 docs/skin_config_example.md diff --git a/docs/README.md b/docs/README.md index 5a5498f..ce2b3de 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,8 @@ 以首款换皮应用 **FunyMee** 为参考的 **视频首页**(`common_info` → 分类 → 按分类拉模板)数据流说明,见 **[《视频首页数据获取流程》](video_home_data_flow.md)**。 +示例换皮配置文件 [`lib/src/config/skin_config.example.json`](../lib/src/config/skin_config.example.json) 的字段说明,见 **[《skin_config.example.json 字段说明》](skin_config_example.md)**。 + --- ## 2. 快速开始 @@ -155,6 +157,8 @@ if (res.isSuccess) { } ``` +宿主需在 `FrameworkAuthService.init` 传入的 `AuthServiceCallbacks` 中实现 `deviceId` 与 `sign`。**FunyMee 采用的获取方式与签名规则**见 [device_id_and_sign.md](./device_id_and_sign.md)。 + **返回实体**: `FastLoginResponse` - `userToken`: 用户 Token - `userId`: 用户 ID diff --git a/docs/create_new_skin_app.md b/docs/create_new_skin_app.md index 0885323..7365eee 100644 --- a/docs/create_new_skin_app.md +++ b/docs/create_new_skin_app.md @@ -1,6 +1,6 @@ # 创建新换皮应用 — 步骤清单 -本文说明基于 `client_proxy_framework` **从零搭一个换皮应用**的推荐流程。配置以 **`skin_config.json`** 为主;更细的字段说明见 [`new_app_config_template.md`](new_app_config_template.md),原生侧见 [`sdk_integration_guide.md`](sdk_integration_guide.md)。更长篇的上下文与业务对接见 [`skin_app_development_guide.md`](skin_app_development_guide.md)。 +本文说明基于 `client_proxy_framework` **从零搭一个换皮应用**的推荐流程。配置以 **`skin_config.json`** 为主;更细的字段说明见 [`new_app_config_template.md`](new_app_config_template.md),原生侧见 [`sdk_integration_guide.md`](sdk_integration_guide.md)。更长篇的上下文与业务对接见 [`skin_app_development_guide.md`](skin_app_development_guide.md)。**`AuthServiceCallbacks` 所需的设备 ID 与 `computeSign`** 见 [`device_id_and_sign.md`](device_id_and_sign.md)。 *** @@ -45,6 +45,8 @@ dependencies: crypto: ^3.0.3 # 常见:与后端约定的 MD5 签名 ``` +Android 侧通常还需要 [`android_id`](https://pub.dev/packages/android_id)(`Settings.Secure.ANDROID_ID`),与「仅用 device_info_plus 读到的 Android `id`」不是同一字段;**完整约定(含兜底 ID、`computeSign`)**见 [device_id_and_sign.md](device_id_and_sign.md)。 + 执行: ```bash @@ -133,7 +135,7 @@ void main() async { 应用侧通常仍需: -1. 实现 **`AuthServiceCallbacks`**:`getDeviceId`、`computeSign`、`onLoginSuccess` / `onCommonInfoLoaded` / `onLoginFailed`(例如写入本地 `UserState`、路由)。 +1. 实现 **`AuthServiceCallbacks`**:`getDeviceId`、`computeSign`、`onLoginSuccess` / `onCommonInfoLoaded` / `onLoginFailed`(例如写入本地 `UserState`、路由)。**`getDeviceId` / `computeSign` 与 FunyMee、app_client 对齐的参考实现**见 [device_id_and_sign.md](device_id_and_sign.md)。 2. 调用 **`FrameworkAuthService.init(你的回调)`**,再 **`await FrameworkAuthService.start()`**(可参考示例工程里的薄封装 `AuthService.init()`)。 无需再实现 **`AttributionCallbacks`**,除非要自定义 referer 格式;此时用 **`FrameworkAuthService.init(callbacks, attributionCallbacks: 你的实现)`**。 @@ -155,6 +157,7 @@ void main() async { | [`new_app_config_template.md`](new_app_config_template.md) | 按表逐项填写换皮参数(可对照填入 JSON) | | [`sdk_integration_guide.md`](sdk_integration_guide.md) | 原生工程 Adjust / Facebook 等集成要点 | | [`skin_app_development_guide.md`](skin_app_development_guide.md) | 更长流程、业务/UI 后续对接说明 | +| [`device_id_and_sign.md`](device_id_and_sign.md) | 设备 ID 与 fast_login 签名(宿主参考实现) | | 框架 `lib/src/config/skin_config.example.json` | JSON 最小结构示例 | 若本清单与旧文档冲突,**以本清单与当前框架代码为准**。 diff --git a/docs/device_id_and_sign.md b/docs/device_id_and_sign.md new file mode 100644 index 0000000..4f21913 --- /dev/null +++ b/docs/device_id_and_sign.md @@ -0,0 +1,81 @@ +# 设备 ID 与 fast_login 签名(宿主参考实现) + +本文档描述 **FunyMee** 等换皮应用在实现 [`AuthServiceCallbacks`](../lib/src/services/auth_service.dart) 时采用的 **`getDeviceId`** 与 **`computeSign`** 规则,与 **app_client** 行为对齐,便于其它宿主复制或迁移到框架内默认实现。 + +框架本身仍通过接口要求宿主提供上述方法;本文档**不是**框架 API 说明,而是**可复现的实现规范**。 + +--- + +## 1. 依赖(宿主 `pubspec.yaml`) + +| 用途 | 包名 | +|------|------| +| Android:`Settings.Secure.ANDROID_ID` | [`android_id`](https://pub.dev/packages/android_id) | +| iOS:厂商级设备标识 | [`device_info_plus`](https://pub.dev/packages/device_info_plus) | +| 兜底 ID 持久化 | [`shared_preferences`](https://pub.dev/packages/shared_preferences) | +| 签名 MD5 | [`crypto`](https://pub.dev/packages/crypto)(`md5`) | + +--- + +## 2. `getDeviceId()` 行为 + +按 [`defaultTargetPlatform`](https://api.flutter.dev/flutter/foundation/defaultTargetPlatform.html) 分支: + +### 2.1 Android + +- 使用 **`android_id` 包**的 `AndroidId().getId()`,对应系统 **`Settings.Secure.ANDROID_ID`**。 +- 若返回值非空,直接作为 `deviceId`。 +- **不要**用 `device_info_plus` 的 `AndroidDeviceInfo.id` 当作设备 ID:该字段通常为 **ROM 构建号(Build ID)**,与业务侧期望的 ANDROID_ID 不一致。 + +### 2.2 iOS + +- 使用 `DeviceInfoPlugin().iosInfo`,取 **`identifierForVendor`**(IDFV)。 +- 若非空,直接作为 `deviceId`。 + +### 2.3 其它平台或读取失败 + +- 使用下文 **§3 兜底 ID**,保证同一安装会话内稳定、可持久化。 + +--- + +## 3. 兜底设备 ID(SharedPreferences) + +当 Android/iOS 无法取得有效 ID,或目标平台非 Android/iOS 时: + +1. 读取键名:**`persisted_device_id`**(字符串)。 +2. 若已有非空值,直接返回。 +3. 否则生成新 ID: + - 使用加密安全随机数生成 **16 字节**; + - 经 **Base64URL** 编码后,**去掉末尾 `=` 填充**(与现有实现一致)。 +4. 写入 SharedPreferences 后返回。 + +> 注意:不同应用若使用相同键名与存储作用域,行为一致;若宿主需隔离多环境,可自行加前缀或改用独立文件名。 + +--- + +## 4. `computeSign(deviceId)` + +与后端 **`fast_login`** 约定一致: + +- 对 **`deviceId` 的 UTF-8 字节**做 **MD5**; +- 结果为 **32 位十六进制字符串**,**大写**(例如 Dart:`md5.convert(utf8.encode(deviceId)).toString().toUpperCase()`)。 + +该签名对应代理层/V2 包装中的业务字段(如文档中的 `mandate` / `sign` 等,以实际字段映射为准)。 + +--- + +## 5. 参考代码位置(FunyMee) + +实现类:**`AppAuthCallbacks`**,文件: + +`FunyMee/lib/core/auth/auth_service.dart` + +(仅 `getDeviceId`、`computeSign` 及 `_persistedFallbackDeviceId` 与本文档对应;其余如 `onLoginSuccess` 与宿主 `UserState` 绑定,不在此规范范围内。) + +--- + +## 6. 修订记录 + +| 日期 | 说明 | +|------|------| +| 2026-04-17 | 初版:从 FunyMee `auth_service.dart` 整理 | diff --git a/docs/skin_app_development_guide.md b/docs/skin_app_development_guide.md index 50cfeee..0fbe617 100644 --- a/docs/skin_app_development_guide.md +++ b/docs/skin_app_development_guide.md @@ -3,6 +3,8 @@ 本文档说明如何使用 `client_proxy_framework` 从零创建并完成一个换皮应用。 > **推荐优先阅读**:[《创建新换皮应用 — 步骤清单》](create_new_skin_app.md) — 与当前框架实现(JSON 配置、`ClientBootstrap`、固定 Facebook Channel、默认归因回调等)保持同步的精简步骤。 +> +> **`getDeviceId` / `computeSign`(与 FunyMee、app_client 对齐)**:[device_id_and_sign.md](device_id_and_sign.md) *** @@ -468,6 +470,8 @@ class MainActivity : FlutterActivity() { ## 7. 实现业务代码 +认证回调中的 **`getDeviceId`**、**`computeSign`** 与后端 **`fast_login`** 强相关;**与 FunyMee / app_client 一致的字段含义、依赖与兜底策略**见框架文档 [device_id_and_sign.md](device_id_and_sign.md)(下文示例侧重代码结构,生产环境请以该规范为准)。 + ### 7.1 创建认证服务实现 创建 `lib/core/auth/auth_service.dart`: diff --git a/docs/skin_config_example.md b/docs/skin_config_example.md new file mode 100644 index 0000000..a9aecac --- /dev/null +++ b/docs/skin_config_example.md @@ -0,0 +1,180 @@ +# `skin_config.example.json` 字段说明 + +本文档对应仓库内示例文件 [`lib/src/config/skin_config.example.json`](../lib/src/config/skin_config.example.json)。宿主应将复制后的文件放在 **`assets/skin_config.json`**(或自定义路径),并在启动时调用 `ClientBootstrap.initFromAsset(...)`。 + +--- + +## 1. 根结构概览 + +| 顶层键 | 必填 | 说明 | +|--------|------|------| +| `app` | 是 | 应用展示名、业务 id、包名 | +| `api` | 是 | 环境域名、代理路径、AES 密钥等 | +| `backend` | 否 | `fast_login` / `common_info` 等接口的 `app` 渠道参数 | +| `videoHome` | 否 | 视频首页「Images」虚拟 Tab 的文案与排序 | +| `proxyKeys` | 否 | 代理请求最外层 JSON 的字段名 | +| `v2` | 否 | 代理 V2 包装壳(层级、业务载荷路径、噪音键) | +| `fieldMapping` | 否 | 逻辑字段名 ↔ 线网/网关字段名;空或省略则恒等映射 | +| `analytics` | 否 | Adjust / Facebook 初始化参数 | +| `adjustEvents` | 否 | 语义化事件名 → Adjust Dashboard **事件 token**(供 `AnalyticsEvents` 等使用) | +| `extConfig` | 否 | `common_info.extConfig` 的键名模式、任务项映射、本地默认值 | + +--- + +## 2. `app` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `name` | string | 应用名称(必填,非空) | +| `id` | string | 业务侧应用标识(必填) | +| `packageName` | string | 应用包名;用于请求头 `pkg` 等(必填) | + +--- + +## 3. `backend` + +省略时框架使用默认值:`iosAppType` → `HIOS`,`androidAppType` → `HAndroid`。 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `iosAppType` | string | iOS 上部分接口 query `app` 的值 | +| `androidAppType` | string | Android 上同上 | + +--- + +## 4. `videoHome` + +用于 [`VideoHomeRuntime`](../lib/src/config/video_home_runtime.dart) 与 [`AppConfig`](../lib/src/config/app_config.dart) 中与首页 Tab 相关的展示配置。 + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `imagesTabLabel` | string | `Images` | 与接口分类并列的「运营位 / Images」Tab 文案 | +| `imagesTabFirst` | bool | `false` | 为 `true` 时该 Tab 排在服务端分类之前 | + +--- + +## 5. `api` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `preBaseUrl` | string | **预发**环境 API 根地址(必填) | +| `prodBaseUrl` | string | **生产**环境 API 根地址(必填) | +| `proxyPath` | string | 代理入口路径,如 `/v1/proxy`(必填) | +| `aesKey` | string | AES-128 密钥字符串,当前实现为 **16 字符**(必填) | +| `debugBaseUrlOverride` | string \| null | 仅 **Debug 构建**有效:非空时覆盖 `preBaseUrl`,用于本地/抓包代理 | +| `alwaysUsePreBaseUrl` | bool | 为 `true` 时 **任意构建类型**下 `baseUrl` **始终**为 `preBaseUrl`(便于长期连预发);**Release 若仍访问预发,优先检查此项是否为 `true`** | + +### `baseUrl` 选择规则([`SkinConfig.baseUrl`](../lib/src/config/skin_config.dart)) + +1. 若 `alwaysUsePreBaseUrl == true` → `preBaseUrl` +2. 否则若 **非** Debug(Release/Profile)→ `prodBaseUrl` +3. 否则(Debug)→ `debugBaseUrlOverride ?? preBaseUrl` + +--- + +## 6. `proxyKeys` + +定义代理请求明文 JSON 最外层的**键名**;值为密文或内嵌结构,具体与后端约定一致。省略时框架使用内置默认字段名(如 `appId`、`path`、`method`、`headers`、`params`、`body` 及默认 `noiseKeys`)。 + +| 字段 | 说明 | +|------|------| +| `appIdField` / `pathField` / `methodField` / `headerField` / `paramsField` / `bodyField` | 各语义段字段名 | +| `noiseKeys` | 噪音字段名列表,用于 V2 代理外层填充随机串 | + +--- + +## 7. `v2` + +与 [`AppConfig`](../lib/src/config/app_config.dart) 中 V2 包装一致:在 `proxyKeys` 体内部再包一层固定壳,业务密文放在 `sanctumPath` 末端。 + +| 字段 | 说明 | +|------|------| +| `levelField` | 最外层固定层级字段名(如 `level`) | +| `levelFixedValue` | 该字段固定整型值 | +| `sanctumPath` | 从根到**业务载荷**的键路径,如 `["wrapper","layer","payload"]` | +| `noiseKeys` | 与 `level` 同层的噪音键名列表 | + +--- + +## 8. `fieldMapping` + +**Map:逻辑名 → 线网名**。请求发出前将逻辑字段映射为线网字段,响应解析后再映射回逻辑名。示例中 `code`→`httpCode`、`msg`→`message`、`data`→`payload` 表示网关使用另一套顶层键名。空对象或省略表示**恒等映射**([`kIdentityFieldMapping`](../lib/src/config/field_mapping.dart))。 + +--- + +## 9. `analytics` + +传给 [`SkinConfig.buildAnalyticsConfig`](../lib/src/config/skin_config.dart) 以初始化 Adjust / Facebook。占位 token(如含 `your_`、`example.com`)会被识别为无效并**跳过**对应 SDK,避免带着假密钥初始化。 + +| 字段 | 说明 | +|------|------| +| `debugLogs` | 框架侧调试输出开关(与各 SDK 内日志配合) | +| `adjust.appToken` | Adjust App Token | +| `adjust.environment` | `sandbox` / `production`(其它值按 production 处理) | +| `adjust.logLevel` | 如 `verbose` / `off` | +| `adjust.fbAppId` | 可选,供 Adjust 与 Meta 相关能力 | +| `facebook.appId` | Facebook 应用编号 | +| `facebook.clientToken` | 客户端口令;可为空字符串(若业务允许) | +| `facebook.debugLogs` | Facebook SDK 侧调试日志 | + +另支持根级 `platformAttribution` 等扩展(见源码 `buildAnalyticsConfig`)。 + +--- + +## 10. `adjustEvents` + +**键**:框架内使用的**逻辑事件名**(如 `register`、`purchase`、`firstPurchase`、`paymentFailed` 及按档位的价格键)。 +**值**:Adjust Dashboard 里配置的**事件 token**(字符串)。 + +未配置的键在调用 `SkinConfig.trackAdjustEvent` 时**不会**上报。示例仅展示 `register` / `purchase`;完整业务档位名需与 [`AnalyticsEvents`](../lib/src/services/analytics_events.dart) 中约定一致。 + +--- + +## 11. `extConfig` + +描述 `common_info` 返回里 **`extConfig` JSON** 如何解析为 [`ExtConfigData`](../lib/src/config/ext_config_models.dart),以及本地默认值如何与线上下发合并。 + +### 11.1 `keys` + +各**业务语义**对应线网上可能出现的**键名列表**(按顺序取**第一个存在的键**),用于兼容多版本后端键名。示例: + +- `showVideoMenu`:如 `go_run`、`need_wait` — 控制是否展示视频首页顶栏等(需为布尔或可解析为布尔)。 +- `forbidScreenshot` / `blockScreenshot`:截屏相关策略。 +- `allowThirdPartyPayment`:第三方支付开关。 +- `privacyUrl` / `agreementUrl`:链接类字段。 +- `items`:运营卡片数组所在键名。 + +### 11.2 `itemKeys` + +列表项**逻辑字段**(如 `image`、`title`、`videoUrl`)到线网键名列表的映射;解析 `items` 数组中每一项时使用。详见 [`ExtConfigKeySchema`](../lib/src/config/ext_config_key_schema.dart) 类注释。 + +### 11.3 `itemKeysHome` / `itemKeysTask` + +可选。同一逻辑名在**首页展示**与**创建任务**场景可映射到不同线网键;未配置时回退到 `itemKeys`。 + +### 11.4 `taskItemMapping` + +将 `extConfig.items` 中**单项**组装成与 `GET /v1/image/img2video/tasks` 单项在 `fieldMapping` 之后**同形**的结构,以便走 `TaskItem.fromJson`。 +**键**:目标路径(点号嵌套,如 `previewVideo.url`)。 +**值**:源对象上按顺序尝试的源路径列表。 + +### 11.5 `itemsApplyFieldMappingBeforeTaskMapping` + +- `true`(默认):先对单项做 `fieldMapping.mapResponse`,再按 `taskItemMapping` 取值。 +- `false`:在**原始**线网单项上按源路径取值。 + +### 11.6 `defaultItemTitle` + +`extConfig.items` 解析后标题仍为空时填入的占位字符串(对应代码中的 `defaultItemTitleWhenEmpty`)。 + +### 11.7 `defaults` + +**线网键名 → 默认值**,与 `common_info` 返回的 extConfig 对象 **浅合并**:服务器非 null 的顶层键覆盖本地。用于本地兜底 URL、默认开关、空 `items` 等。合并逻辑见 [`SkinExtConfigSection.mergeDefaults`](../lib/src/config/ext_config_key_schema.dart)。 + +--- + +## 12. 相关入口 + +- 初始化:`ClientBootstrap.initFromAsset`([`client_bootstrap.dart`](../lib/src/bootstrap/client_bootstrap.dart)) +- 配置模型:`SkinConfig`([`skin_config.dart`](../lib/src/config/skin_config.dart)) +- 创建新换皮应用步骤:[create_new_skin_app.md](create_new_skin_app.md) diff --git a/lib/src/entities/user_entities.dart b/lib/src/entities/user_entities.dart index 912ede1..766c373 100644 --- a/lib/src/entities/user_entities.dart +++ b/lib/src/entities/user_entities.dart @@ -3,6 +3,22 @@ import 'dart:convert'; import 'credits_balance_parse.dart'; import 'entity.dart'; +String? _dynamicToOptionalString(dynamic value) { + if (value == null) return null; + if (value is String) return value; + if (value is List) return value.join(','); + return value.toString(); +} + +/// 头像 URL:`skin_config` 中逻辑名 `avatar` ↔ 线网 `frequency`(见 fast_login);兼容未映射或历史字段。 +String? _userAvatarFromJson(Map json) { + for (final key in ['avatar', 'frequency']) { + final s = _dynamicToOptionalString(json[key])?.trim(); + if (s != null && s.isNotEmpty) return s; + } + return null; +} + /// `extConfig` 在解密 data 中可能是 JSON 字符串,也可能是已解析的 Map;统一成可 [json.decode] 的字符串。 String? _wireExtConfigJson(dynamic v) { if (v == null) return null; @@ -86,7 +102,7 @@ class FastLoginResponse extends Entity { userToken: _toString(json['userToken']), userId: _toString(json['userId']), credits: parseUserCreditsBalance(json), - avatar: _toString(json['avatar']), + avatar: _userAvatarFromJson(json), userName: _toString(json['userName']), countryCode: _toString(json['countryCode']), extConfig: _wireExtConfigJson(json['extConfig']), @@ -213,7 +229,7 @@ class CommonInfoResponse extends Entity { userToken: _toString(json['userToken']), userId: _toString(json['userId']), credits: parseUserCreditsBalance(json), - avatar: _toString(json['avatar']), + avatar: _userAvatarFromJson(json), userName: _toString(json['userName']), countryCode: _toString(json['countryCode']), extConfig: _wireExtConfigJson(json['extConfig']), @@ -287,7 +303,7 @@ class AccountResponse extends Entity { factory AccountResponse.fromJson(Map json) { return AccountResponse( credits: parseUserCreditsBalance(json), - avatar: json['avatar'] as String?, + avatar: _userAvatarFromJson(json), userName: json['userName'] as String?, isVip: json['isVip'] as bool?, freeTimes: json['freeTimes'] as int?,