From 8c15058b96ab4b4263b31632ef0a31ca5fb677aa Mon Sep 17 00:00:00 2001 From: ivan Date: Wed, 22 Apr 2026 11:13:54 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E7=94=9F?= =?UTF-8?q?=E5=9B=BE=E6=B5=81=E7=A8=8B=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/README.md | 2 + docs/image_generation_flow.md | 108 +++++++++++++++++++++++++ lib/src/entities/payment_entities.dart | 21 ++++- lib/src/services/auth_service.dart | 11 +++ lib/src/services/image_api.dart | 6 +- lib/src/services/user_api.dart | 6 +- 6 files changed, 147 insertions(+), 7 deletions(-) create mode 100644 docs/image_generation_flow.md diff --git a/docs/README.md b/docs/README.md index ce2b3de..f45b2c8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,8 @@ 以首款换皮应用 **FunyMee** 为参考的 **视频首页**(`common_info` → 分类 → 按分类拉模板)数据流说明,见 **[《视频首页数据获取流程》](video_home_data_flow.md)**。 +**生图 / 任务**(预签名上传、`create-task`、进度轮询、文生图、历史列表与本地封面)的端到端说明,见 **[《生图 / 任务流程》](image_generation_flow.md)**。 + 示例换皮配置文件 [`lib/src/config/skin_config.example.json`](../lib/src/config/skin_config.example.json) 的字段说明,见 **[《skin_config.example.json 字段说明》](skin_config_example.md)**。 --- diff --git a/docs/image_generation_flow.md b/docs/image_generation_flow.md new file mode 100644 index 0000000..26fad82 --- /dev/null +++ b/docs/image_generation_flow.md @@ -0,0 +1,108 @@ +# 图片类生成任务:换皮应用通用流程 + +本文描述「用户图 + 模板生成图片/视频类任务」在换皮应用中的**通用产品与技术流程**:选图方式、选图后界面表现、上传与创建任务的数据链路、以及上传后与进度相关的界面跳转。具体接口字段、加解密与换皮命名以各应用的配置与《客户端指南》为准;本文不绑定某一仓库内的源码路径。 + +--- + +## 1. 流程总览 + +典型端到端路径可以概括为: + +1. **选图**:用户从**相机**或**系统相册**取得一张或多张本地图片(依业务是否支持双图等)。 +2. **选图后界面**:展示预览、可选编辑/压缩提示、确认或重选;进入上传前可展示加载或禁用重复提交。 +3. **上传与创建任务**:先拿到服务端下发的**预签名上传地址**,客户端**直传对象存储**,再用服务端返回的**文件路径**调用**创建任务**,得到 **`taskId`**。 +4. **进度与结果**:对 **`taskId` 轮询进度**,直到成功、失败或超时;界面随状态切换(结果页、重试、返回列表等)。 +5. **历史**:任务列表拉取远端记录,必要时与**本地封面缓存**合并展示。 + +与「仅文案、无用户图」的**文生图**相比,主路径多了「本地文件 → 预签名上传 → 创建任务」这一段;创建成功之后的进度与列表逻辑通常一致。 + +--- + +## 2. 选图方式:相机与相册 + +| 方式 | 常见行为 | 产品注意点 | +|------|----------|------------| +| **相册** | 调起系统图片选择器,用户勾选一张或按业务限制多张 | 需处理权限拒绝、无可用图片、超大图;可在此步限制格式(如仅图片) | +| **相机** | 调起系统相机,拍摄后得到临时文件或相册引用 | 需处理权限、取消拍摄、低内存;部分系统会先落相册再选 | + +换皮应用应在交互上明确:**当前是「拍一张」还是「从相册选」**,避免用户混淆;若业务只允许单图,应在选图结果回调里统一成单文件路径再进入下一步。 + +--- + +## 3. 选图后的 UI 表现(建议) + +选图成功、尚未开始上传时,建议统一呈现: + +- **预览区**:展示已选图片缩略图或全屏预览;双图业务需区分「图一 / 图二」标签或顺序说明。 +- **可编辑项**(若有):分辨率、模板、文案提示等;与后端 `create-task` 字段对应的选项应在此收集完毕。 +- **主操作**:例如「生成」「下一步」;点击前应做基础校验(必填项、文件是否存在)。 +- **次要操作**:「重新选图」清空当前选择回到选图入口;避免在上传中途允许再次选图 unless 有取消上传的完整逻辑。 +- **状态**:从点击「开始生成」到拿到 `taskId` 之前,展示**不可重复提交**的加载态(按钮置灰、全屏 Loading 等),防止重复创建任务。 + +若客户端会在上传前做**压缩**(例如限制长边、JPEG 质量),失败时应有**可理解的错误提示**,并允许用户重试或重选。 + +--- + +## 4. 上传图片的数据流程(概念) + +主路径可抽象为四步(与具体语言、类名无关): + +1. **(可选)压缩**:对本地文件做尺寸/质量压缩,减少上传时间与流量。 +2. **申请预签名**:请求服务端 **`upload-presigned-url`** 类接口,携带文件名、内容类型、预期大小等;服务端返回 **上传 URL**、**上传后文件在服务端的路径标识**、以及 **PUT 时需要的头信息**。 +3. **直传对象存储**:客户端对返回的 **上传 URL** 发起 **HTTP PUT**,请求体为图片字节流;请求头需与服务端约定一致(例如 `Content-Type` 与附加头合并),避免签名失败。 +4. **创建任务**:再请求 **`create-task`** 类接口,将上一步得到的 **文件路径**(以及业务字段如分辨率、任务类型、模板名、提示词等)提交;响应中解析 **`taskId`**。 + +双图源场景:对**每张图**重复「预签名 → PUT」,创建任务时分别填入「第一张 / 第二张」对应字段;若产品需要「仅首张作列表封面」,本地展示策略可与业务约定一致。 + +创建任务成功后,可选地**把本次用于上传的本地文件拷贝到应用私有目录**,按 `taskId` 命名,用于任务列表在**远端封面尚未返回时**的占位展示;该类缓存通常带**过期清理**策略。 + +--- + +## 5. 上传完成后界面如何跳转 + +建议的通用分支: + +| 结果 | UI 行为 | +|------|---------| +| **拿到 `taskId`** | 进入**进度页**(或带进度条的全屏态),并开始对 `taskId` **轮询进度**;同时可跳转到「我的任务」并插入一条「进行中」占位项。 | +| **上传或创建任务失败** | 停留在当前页或返回预览页,**明确错误原因**(网络、鉴权、业务拒绝等),提供**重试**与**重选图**。 | +| **需要离开当前页** | 若采用「列表驱动」体验,可在创建成功后**直接跳到历史列表**,并在列表项上展示进度;核心是用户始终能感知**任务已创建**且**不会丢**。 | + +文生图(无用户文件)跳过上传步骤,在拿到 `taskId` 后的跳转逻辑可与上表一致。 + +--- + +## 6. 进度轮询与完成态 UI + +- **轮询**:按固定间隔请求 **`progress`** 类接口(间隔需避免重叠请求,即上一帧结束再拉下一帧);网络瞬时失败可有限次重试,超过阈值则提示用户检查网络或稍后从历史进入。 +- **停止轮询**:当出现**可用结果 URL**、或状态为**成功/失败/超时/中止**等等价终态时,应停止轮询,避免无效请求。 +- **成功**:展示结果图或跳转结果页;可提供保存、分享、再做一张等入口。 +- **失败或超时**:提示原因,提供**重试**(是否新建任务依产品)或返回模板/首页。 + +进度数据结构通常包含:任务标识、状态码或枚举、进度百分比、结果图 URL 等;界面应以「可展示结果」与「明确失败」为优先判断,避免仅依赖单一字段。 + +--- + +## 7. 任务列表与本地封面(概念) + +- **远端列表**:分页拉取「我的任务」类接口,展示缩略图、状态、时间等。 +- **与本地封面合并**:若创建成功时曾写入本地封面,列表在接口尚未返回远程封面 URL 时,可优先或回退显示本地文件,提升「刚提交就去看列表」的体验。 +- **一致性**:任务终态后应以服务端返回的 URL 为准;本地缓存可按时间与容量策略清理。 + +--- + +## 8. 集成方需对齐的要点(清单) + +- 选图入口:**相机 / 相册**权限与取消流程。 +- 选图后:**预览、参数、防重复提交**。 +- 上传链:**预签名 → PUT → create-task**,PUT 头与 body 与服务端一致。 +- 创建后:**进度 UI** 与 **`taskId`** 维度的轮询停止条件。 +- 列表:**远端数据**与可选的**本地封面**合并策略。 +- 换皮差异:**接口 host、鉴权、字段映射**以各应用配置与对外文档为准。 + +--- + +## 9. 对外文档交叉引用 + +- 换皮应用功能清单、推荐封装方式:见各宿主仓库的开发手册中「图片 / 任务」相关章节。 +- 接口字段与换皮命名:见各宿主仓库的客户端对接指南中 `upload-presigned-url`、`create-task`、`progress`、`txt2img_create` 等关键词说明。 diff --git a/lib/src/entities/payment_entities.dart b/lib/src/entities/payment_entities.dart index e3d863a..34e7a3a 100644 --- a/lib/src/entities/payment_entities.dart +++ b/lib/src/entities/payment_entities.dart @@ -17,6 +17,7 @@ class PaymentProductItem extends Entity { final String? activityId; final String? actualAmount; final String? originAmount; + /// 线网 `contrast`→`bonus`:赠送积分数(数量,非百分比);与 [bonusCredits] 二选一或并存由接口决定。 final int? bonus; /// 额外赠送积分(换皮线网常为 `saturation` → `bonusCredits`)。 final int? bonusCredits; @@ -36,12 +37,26 @@ class PaymentProductItem extends Entity { actualAmount: _stringField(json, 'actualAmount'), originAmount: _stringField(json, 'originAmount'), bonus: _intField(json, 'bonus'), - bonusCredits: _intField(json, 'bonusCredits'), + bonusCredits: _parseBonusCredits(json), title: _stringField(json, 'title'), credits: _intField(json, 'credits') ?? _intField(json, 'padding'), ); } + /// `saturation`→`bonusCredits`、`remix`→`addCredits`、`contrast`→`bonus`(赠送积分数),或 `credits`−`baseCredits`。 + static int? _parseBonusCredits(Map json) { + final direct = _intField(json, 'bonusCredits'); + if (direct != null && direct > 0) return direct; + final add = _intField(json, 'addCredits'); + if (add != null && add > 0) return add; + final contrastGift = _intField(json, 'bonus'); + if (contrastGift != null && contrastGift > 0) return contrastGift; + final total = _intField(json, 'credits') ?? _intField(json, 'padding'); + final base = _intField(json, 'baseCredits'); + if (total != null && base != null && total > base) return total - base; + return null; + } + static String? _stringField(Map json, String key) { final v = json[key]; if (v == null) return null; @@ -140,12 +155,12 @@ class PaymentMethodItem extends Entity { String? get bonusLabel { final bc = bonusCredits; if (bc != null && bc > 0) { - return '+$bc bonus credits'; + return '+$bc More Credits'; } final br = bonusRatio; if (br != null && br > 0) { final pct = br <= 1 ? (br * 100).round() : br.round(); - return '+$pct% bonus credits'; + return '+$pct% More Credits'; } return null; } diff --git a/lib/src/services/auth_service.dart b/lib/src/services/auth_service.dart index 1139726..ed53374 100644 --- a/lib/src/services/auth_service.dart +++ b/lib/src/services/auth_service.dart @@ -45,6 +45,11 @@ abstract class FrameworkAuthService { /// 登录是否已完成 static final ValueNotifier isLoginComplete = ValueNotifier(false); + /// 最近一次快速登录成功时的 [FastLoginResponse.userId](失败或未登录为空)。 + /// + /// 宿主在调用需 `userId` 的接口(如生图 `create-task`)前可读取;若为空应提示用户稍后重试。 + static String? lastLoggedInUserId; + /// 登录完成后的 Future,需鉴权接口应 await 此 Future 再请求 static Future get loginComplete => _loginFuture ?? Future.value(); @@ -152,6 +157,7 @@ abstract class FrameworkAuthService { } if (res == null) { + lastLoggedInUserId = null; VideoHomeRuntime.reset(); ExtConfigRuntime.applyCommonInfoFailure(); completer.complete(); @@ -164,6 +170,9 @@ abstract class FrameworkAuthService { if (res.isSuccess && res.data != null) { final loginData = res.data!; + final uid = loginData.userId?.trim(); + lastLoggedInUserId = + uid != null && uid.isNotEmpty ? uid : null; // 设置 Token if (loginData.userToken != null && loginData.userToken!.isNotEmpty) { @@ -182,11 +191,13 @@ abstract class FrameworkAuthService { deviceId: deviceId, ); } else { + lastLoggedInUserId = null; VideoHomeRuntime.reset(); ExtConfigRuntime.applyCommonInfoFailure(); _callbacks!.onLoginFailed(res.msg); } } catch (e, st) { + lastLoggedInUserId = null; VideoHomeRuntime.reset(); ExtConfigRuntime.applyCommonInfoFailure(); if (kDebugMode) { diff --git a/lib/src/services/image_api.dart b/lib/src/services/image_api.dart index e288d53..3b19b59 100644 --- a/lib/src/services/image_api.dart +++ b/lib/src/services/image_api.dart @@ -317,10 +317,12 @@ abstract final class ImageApi { /// 获取积分页面信息(与 `UserApi.getCreditsPage` 相同,走 `GET /v1/user/credits-page`) /// /// 保留此方法以兼容旧调用;[app]、[userId]、[ch] 不再参与请求(接口只吃分页参数)。 + /// + /// [type] 可选;不传时不在 query 中带 `type`,由服务端默认处理。 static Future> getCreditsPageInfo({ String page = '1', String size = '10', - String type = '1', + String? type, String? app, String? userId, String? ch, @@ -332,7 +334,7 @@ abstract final class ImageApi { queryParams: { 'page': page, 'size': size, - 'type': type, + if (type != null && type.isNotEmpty) 'type': type, }, ); } diff --git a/lib/src/services/user_api.dart b/lib/src/services/user_api.dart index 6e56e36..8a21654 100644 --- a/lib/src/services/user_api.dart +++ b/lib/src/services/user_api.dart @@ -141,10 +141,12 @@ abstract final class UserApi { } /// 积分分页流水(`GET /v1/user/credits-page`) + /// + /// [type] 可选;不传时不在 query 中带 `type`,由服务端默认处理。 static Future> getCreditsPage({ String page = '1', String size = '10', - String type = '1', + String? type, }) async { return _client.requestEntity( path: '/v1/user/credits-page', @@ -153,7 +155,7 @@ abstract final class UserApi { queryParams: { 'page': page, 'size': size, - 'type': type, + if (type != null && type.isNotEmpty) 'type': type, }, ); } From fe0e81a5f2babc062a2d9c57f7113a6752c86032 Mon Sep 17 00:00:00 2001 From: ivan Date: Wed, 22 Apr 2026 17:24:35 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E8=AE=B0=E5=BD=95=E5=AF=B9=E6=8E=A5=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ..._record_requirements_and_implementation.md | 92 +++++ ...ry_page_requirements_and_implementation.md | 142 +++++++ docs/payment_flow.md | 354 +++++++----------- lib/src/entities/image_entities.dart | 25 ++ lib/src/entities/payment_entities.dart | 7 +- lib/src/services/image_api.dart | 15 + 6 files changed, 405 insertions(+), 230 deletions(-) create mode 100644 docs/credit_record_requirements_and_implementation.md create mode 100644 docs/history_page_requirements_and_implementation.md diff --git a/docs/credit_record_requirements_and_implementation.md b/docs/credit_record_requirements_and_implementation.md new file mode 100644 index 0000000..5b0a73e --- /dev/null +++ b/docs/credit_record_requirements_and_implementation.md @@ -0,0 +1,92 @@ +# Credit Record 需求与实现方案(框架对齐) + +本文档独立说明 `Credit Record`(积分流水)模块,避免与生成历史记录(`My History`)说明混排。 + +--- + +## 1. 模块目标与范围 + +`Credit Record` 用于展示用户积分变动流水,支持: + +- 首屏加载 +- 下拉刷新 +- 滚动分页加载(Load More) + +--- + +## 2. 展示数据规则 + +每条流水展示: + +- **积分变动值**(由 `type + credits` 决定) + - `type == 1`:展示 `+N` + - `type == 2`:展示 `-N` + - 兜底按 `credits` 正负展示 +- **日期**:`createTime` 格式化为 `yyyy/MM/dd` + +空态文案:`No records.` + +--- + +## 3. 接口对接 + +积分流水接口: + +- `UserApi.getCreditsPage` +- 对应后端:`GET /v1/user/credits-page` +- 参数: + - `page`: 页码(从 1 开始) + - `size`: 每页条数(当前 30) + +--- + +## 4. 分页加载实现方案 + +### 4.1 状态字段 + +建议维护以下分页状态(字段命名由各客户端自行定义): + +- `pageSize`(建议 30) +- `loading` +- `loadingMore` +- `hasMore` +- `records` +- `lastLoadedPage` +- `listGeneration`(刷新版本号,用于隔离过期请求) + +### 4.2 首屏与刷新 + +1. 进入页面或下拉刷新时请求 `page=1`。 +2. 清空旧数据并重置分页状态。 +3. 成功后覆盖列表并更新 `lastLoadedPage` 与 `hasMore`。 + +### 4.3 滚动加载更多 + +1. 监听滚动位置,接近底部阈值(约 240px)触发。 +2. 若 `loading/loadingMore/hasMore` 条件不满足则不发起请求。 +3. 请求 `nextPage = lastLoadedPage + 1`。 +4. 成功后 `addAll` 追加并更新分页状态。 + +### 4.4 hasMore 判定优先级 + +1. 优先使用后端 `pages/current` +2. 其次使用 `total` +3. 最后兜底 `incoming.length >= pageSize` + +--- + +## 5. 异常与边界处理 + +- 登录失败:显示错误态 `Sign in failed` +- 接口失败:保留当前列表并停止 `loadingMore` +- 空数据:显示 `No records.` +- 刷新期间旧请求返回:通过 `listGeneration` 丢弃 + +--- + +## 6. 验收清单 + +1. 首屏、下拉刷新、滚动分页三种路径都可稳定工作。 +2. 快速滚动不出现重复数据或错序。 +3. 接口异常时不导致列表清空闪退。 +4. 积分符号展示与 `type` 语义一致。 \ No newline at end of file diff --git a/docs/history_page_requirements_and_implementation.md b/docs/history_page_requirements_and_implementation.md new file mode 100644 index 0000000..990393c --- /dev/null +++ b/docs/history_page_requirements_and_implementation.md @@ -0,0 +1,142 @@ +# 生成历史记录需求与实现方案(框架对齐) + +本文档仅面向 `My History`(生成历史记录)模块的产品需求、数据展示规则、接口对接与交互实现说明,供客户端与 `client_proxy_framework` 对齐开发。 + +--- + +## 1. 页面目标与范围 + +`My History` 用于展示用户已创建任务的结果与状态,支持查看、下载、删除、继续追踪进度。 + +--- + +## 2. My History 需求定义 + +### 2.1 展示数据字段(卡片) + +每个历史卡片展示以下信息: + +- **封面图**(见 2.2 封面取值规则) +- **创建日期**:由 `MyTaskItem.createTime` 格式化为 `MMM d, yyyy` +- **24 小时剩余时间**:`createTime + 24h - now` + - 剩余时间大于 0:展示 `xh xxm left` + - 小于等于 0:展示 `Expired` +- **操作区**: + - 支持按客户端配置决定是否在卡片上展示 `Download` 入口(非必选) + - 不展示下载入口时,操作区可仅展示状态文案(由任务状态映射) + - 若客户端采用“详情页下载”方案,则卡片点击进入详情页后再提供下载能力 +- **删除按钮**:可触发删除确认弹窗 + +顶部需展示“有效期提醒”提示(文案可换皮配置,不要求固定英文): + +- 语义必须包含两点: + 1. 任务内容仅保留 24 小时(或 1 天); + 2. 过期前需要下载保存。 +- 各换皮应用可按品牌语气自定义标题与正文,但不得改变上述核心含义。 +- 示例语义(非固定文案): + - 标题:`24-hour expiry` / `Available for 24 hours` + - 正文:`Each item is kept for 24 hours after creation. Download before it expires.` + +### 2.2 封面取值规则(重点) + +封面来源按以下优先级: + +1. **优先使用 `resultUrl`(网络封面)** + - `resultUrl` 是 `http/https` 且为图片地址:直接展示网络图。 +2. **若 `resultUrl` 判定为视频地址**(如 `.mp4/.m3u8/.webm/.mov`): + - 优先使用本地封面 `localCoverPath`(由 `ImageTaskHistory.localCoverPathsForMyTaskItems(tasks)` 提供)。 + - 若本地封面不存在,展示视频占位图(摄像机 icon)。 +3. **若 `resultUrl` 不可用**: + - 回退使用 `localCoverPath`。 +4. **仍无可用封面**: + - 展示默认背景色占位块。 + +### 2.3 接口对接 + +历史列表接口: + +- `ImageApi.getMyTasks` +- 对应后端:`GET /v1/image/my-tasks` +- 当前请求参数: + - `app`: `currentBackendAppType()` + - `page`: `'1'` + - `pageSize`: `'30'` + +删除任务接口: + +- `ImageApi.deleteTask` +- 入参:`taskId`(int) +- 成功后前端需从列表移除对应卡片并清理本地封面映射缓存。 + +任务进度接口(点击生成中任务后轮询): + +- `ImageApi.getProgress` +- 对应后端:`GET /v1/image/progress` + +### 2.4 点击行为与跳转 + +卡片点击按任务状态分流: + +1. **有远端结果地址** + 跳转任务结果详情页(携带 `taskId`、`resultUrl`)。 +2. **任务生成中** + 跳转任务进度页,建议每 5 秒轮询一次进度接口,成功后自动进入结果详情页。 +3. **状态显示完成但媒体地址未就绪** + 提示:`Media is not ready yet. Pull to refresh.` +4. **其余不可进入状态** + 根据状态映射给出阻塞提示文案。 + +辅助交互(支持按客户端策略配置): + +- **Download 点击(若卡片启用该入口)**:下载 `resultUrl` 到系统相册(图片/视频分流保存)。 +- **详情页下载(若卡片不提供下载入口)**:进入结果详情页后再执行下载。 +- **Delete 点击**:先弹窗二次确认,再调用删除接口,成功后移除条目并提示 `Deleted`。 + +--- + +## 3. My History 分页加载方案(建议落地) + +建议客户端按统一分页模式实现 `My History`,支持首屏加载、下拉刷新与滚动加载更多。 + +### 3.1 增加分页状态 + +建议维护以下分页状态(命名由各客户端自行定义): + +- `pageSize`(建议 30) +- `lastLoadedPage` +- `hasMore` +- `loadingMore` +- `listGeneration`(用于刷新隔离) + +### 3.2 请求策略 + +1. **首屏/下拉刷新**:请求第 1 页,覆盖列表。 +2. **滚动到底触发**:请求 `nextPage = lastLoadedPage + 1`,并追加数据。 +3. **hasMore 判定**:优先使用后端分页字段(若有),否则按返回条数兜底。 +4. **封面映射增量更新**:对新增任务批次补齐本地封面映射并合并到当前缓存。 + +### 3.3 去重与一致性 + +- 追加分页时按 `taskId` 去重,避免刷新/重试导致重复卡片。 +- 删除任务后同步从列表数据与封面缓存中移除。 +- 当用户从进度页返回后,可触发轻量刷新(仅首屏页)保障状态最新。 + +--- + +## 4. 异常与边界处理 + +- 登录未完成:展示 loading;登录失败展示错误态。 +- 接口失败:展示错误文案 + Retry。 +- 空数据:`My History` 显示 `No tasks yet.` +- 非法 `taskId`:删除时拦截并提示 `Invalid task id`。 +- 下载失败:展示 `Save failed` 或系统权限错误。 + +--- + +## 5. 验收清单(建议) + +1. `My History` 封面优先级符合 2.2(图片、视频、本地回退、占位)。 +2. 卡片点击分流准确(结果页/进度页/提示)。 +3. 删除成功后 UI 与本地封面缓存同步移除。 +4. `My History` 分页补齐后,快滑场景无重复、无错序,且 `hasMore` 判定稳定。 + diff --git a/docs/payment_flow.md b/docs/payment_flow.md index 29025ad..9c5de4c 100644 --- a/docs/payment_flow.md +++ b/docs/payment_flow.md @@ -1,298 +1,198 @@ -# 支付流程文档 +# 支付流程(多客户端通用) ## 1. 概述 -本文档描述 Android 项目(Flutter)的完整支付流程,包括商品获取、支付方式选择、订单创建、Google Play 内购以及补单机制。 +本文档描述换皮应用可复用的支付全流程,覆盖:商品获取、支付方式选择、订单创建、平台内购回调、订单核销、补单与账户刷新。 +文档以“能力与流程”表达,不绑定具体客户端项目、目录路径或某一语言实现。 --- ## 2. 支付流程总览 -``` -用户点击 Buy +```text +用户点击充值 / Buy │ - ├─ enableThirdPartyPayment === true 且已登录 + ├─ 已登录 且 开启第三方支付 │ │ - │ ├─ getPaymentMethods(activityId) 获取支付方式 - │ ├─ 弹窗选择支付方式 - │ ├─ createPayment 创建订单 + │ ├─ 拉取支付方式(基于商品/活动) + │ ├─ 用户选择支付方式 + │ ├─ 创建订单(服务端生成业务订单) │ │ - │ ├─ 若选中的是 Google Pay - │ │ ├─ 调起 Google Play 内购 - │ │ ├─ 拿到 purchaseData + signature - │ │ └─ googlepay 回调验证 + │ ├─ 若为平台内购(如 Google Play) + │ │ ├─ 调起商店购买 + │ │ ├─ 获取 purchaseData + signature + storeOrderId + │ │ └─ 回调服务端校验与入账 │ │ - │ └─ 否则(其他支付方式) - │ └─ 打开 payUrl 在外部浏览器完成支付 + │ └─ 若为外部支付 + │ └─ 打开 payUrl 在 Web/外部浏览器完成支付 │ - └─ enableThirdPartyPayment !== true 或未登录 - └─ 仅 Android:直接调起 Google Play 内购 + └─ 未登录 或 未开启第三方支付 + └─ 可走平台内购直购分支(由业务策略决定) ``` --- -## 3. 支付分支依据 +## 3. 支付分支依据(通用) | 条件 | 说明 | |------|------| -| `UserState.enableThirdPartyPayment` | 登录后由 AuthService 从 `/v1/user/common_info` 响应写入 | -| `UserState.userId` | 用户登录后存储的用户 ID | -| **第三方支付** | `enableThirdPartyPayment == true` 且 `userId` 非空 | -| **直接谷歌支付** | 其他情况(未开第三方支付或未登录)| +| 是否登录 | 通常需要用户身份用于订单归属与回调入账 | +| 是否开启第三方支付 | 由服务端用户配置或渠道策略控制 | +| 当前平台能力 | Android / iOS 对内购与外部支付支持差异 | +| 商品配置 | 某些商品只允许特定支付方式 | + +建议将分支判断集中在统一策略层,避免同一逻辑散落在多个页面。 --- ## 4. 商品展示与获取 -### 4.1 接口 +### 4.1 获取时机 -```dart -// Android(app 默认 backendAppTypeAndroid,可传 client) -final res = await PaymentApi.getGooglePayActivities( - country: '国家', // 可选 -); +- 进入充值页时拉取一次; +- 下拉刷新或切换国家/渠道后重新拉取; +- 支付成功后建议刷新,确保档位与余额展示一致。 -// iOS(app 默认 backendAppTypeIOS) -final res = await PaymentApi.getApplePayActivities( - country: '国家', // 可选 -); -``` +### 4.2 商品字段(通用语义) -### 4.2 返回实体 - -```dart -class PaymentProductsResponse { - List? productList; -} - -class PaymentProductItem { - String? productId; // 商品 ID (对应 helm) - String? activityId; // 活动 ID (对应 warrior) - String? actualAmount; // 实际金额 (对应 guardian) - String? originAmount; // 原价 (对应 curriculum) - int? bonus; // 赠送积分 (对应 forge) - String? title; // 标题 (对应 glossary) -} -``` +| 字段语义 | 说明 | +|------|------| +| `productId` | 商店商品 ID(内购必填) | +| `activityId` | 服务端活动/档位 ID(建单常用) | +| `actualAmount` | 实付金额 | +| `originAmount` | 原价(可用于划线价) | +| `bonus` / `bonusCredits` | 赠送积分 | +| `credits` | 到账积分(如与赠送分开展示) | +| `title` | 商品文案(可能由服务端直接下发) | --- -## 5. 第三方支付流程 +## 5. 第三方支付流程(通用) -### 5.1 获取支付方式 +### 5.1 拉取支付方式 -```dart -final res = await PaymentApi.getPaymentMethods( - activityId: 12345, // int - country: '国家', // 可选 -); +以商品或活动为上下文请求支付方式列表,常见字段包括: -if (res.isSuccess) { - final methods = res.data!.paymentMethods; - // methods 包含: - // - paymentMethod: 支付方式 (如 GOOGLEPAY) - // - subPaymentMethod: 子支付方式 - // - name: 显示名称 - // - icon: 图标 URL - // - recommend: 是否推荐 -} -``` +- `paymentMethod`:主支付方式(例如平台内购、钱包、卡支付等) +- `subPaymentMethod`:子方式(可选) +- `name` / `icon`:展示信息 +- `recommend`:是否推荐 ### 5.2 创建订单 -```dart -final res = await PaymentApi.createPayment( - app: 'HAndroid', // HIOS / HAndroid - userId: '用户ID', - activityId: '活动ID', - paymentMethod: '支付方式', - paymentType: '支付子类型', // 可选 -); +创建订单请求通常包含: -if (res.isSuccess) { - final order = res.data!; - final orderId = order.orderId; // 订单 ID - final payUrl = order.payUrl; // 支付链接 -} -``` +- 应用标识(`app`) +- 用户标识(`userId`) +- 商品或活动标识(`activityId` / `productId`) +- 支付方式(`paymentMethod`、可选 `paymentType`) -### 5.3 支付分支 +响应通常返回: -- **Google Pay**: 调起内购 → 获取 purchaseData/signature → 调用 `googlepay` 回调 -- **其他方式**: 打开 `payUrl` 在外部浏览器 +- `orderId`(业务订单号) +- `payUrl`(外部支付链接;仅外部支付场景) +- `status`(订单状态) +- 可选 `federation`(用于与商店订单映射) + +### 5.3 分支执行 + +- **平台内购**:拉起商店购买 -> 取回凭据 -> 回调服务端核验 -> 成功后完成/消费交易。 +- **外部支付**:打开 `payUrl` 完成支付 -> 回前台后查询订单状态或拉取账户余额。 --- -## 6. 直接谷歌支付流程 +## 6. 平台内购标准链路(Android/iOS 可类比) -仅 Android,且不经过 `getPaymentMethods` 和 `createPayment`(三方支付关闭时): +1. 用 `productId` 调起商店支付; +2. 拿到商店返回的交易凭据(如 `purchaseData`、`signature`、`storeOrderId`); +3. 调服务端回调接口完成验签与入账; +4. 服务端成功后,客户端执行“完成交易/消费交易”(防止重复拥有问题); +5. 刷新账户余额与订单状态。 -```dart -// 1. 创建订单(直接走谷歌支付) -final createRes = await PaymentApi.createPayment( - app: 'HAndroid', - userId: '用户ID', - activityId: '活动ID', - paymentMethod: 'GooglePay', - paymentType: 'GooglePay', -); - -// 2. 调起 Google Play 内购 -final purchaseResult = await GooglePlayPurchaseService.launchPurchaseAndReturnData( - productId: '商品ID', -); - -// 3. 回调验证 -if (purchaseResult != null) { - final res = await PaymentApi.googlepay( - signature: purchaseResult.payload.signature, - purchaseData: purchaseResult.payload.purchaseData, - orderId: orderId ?? purchaseResult.orderId, - userId: userId, - ); -} -``` +> 关键原则:**先服务端验单成功,再本地完成/消费交易**,避免凭据丢失导致资产不一致。 --- -## 7. Google Play 内购统一入口 +## 7. 补单机制(订单恢复) -### 7.1 调起内购 +### 7.1 触发时机 -```dart -final result = await GooglePlayPurchaseService.launchPurchaseAndReturnData( - productId: '商品ID', -); +- 应用启动后; +- 进入充值页时; +- 支付异常返回时(例如网络中断、回调超时)。 -if (result != null) { - // result.orderId: Google 订单号 - // result.payload.purchaseData: 用于 merchant - // result.payload.signature: 用于 signature - // result.purchaseDetails: PurchaseDetails 对象 -} -``` +### 7.2 恢复流程 -### 7.2 回调验证 - -```dart -final res = await PaymentApi.googlepay( - signature: result.payload.signature, - purchaseData: result.payload.purchaseData, - orderId: orderId, - userId: userId, -); - -if (res.isSuccess) { - // 核销订单 - await GooglePlayPurchaseService.completeAndConsumePurchase( - result.purchaseDetails, - ); -} -``` - -### 7.3 响应实体 - -```dart -class GooglePayCallbackResponse { - String? orderId; - String? status; // SUCCESS / FAILED - bool? creditsAdded; // 是否已加积分 -} -``` +1. 拉取未完成/未核销的商店订单(历史订单 + 监听流中的待处理订单); +2. 对每笔订单尝试找到业务订单映射(如 `federation` / `orderId`); +3. 若可映射:再次走服务端回调核验,成功后完成/消费交易; +4. 若不可映射:按产品策略处理(仅完成/消费以解除占用,或进入人工排查队列); +5. 恢复结束后刷新账户与充值记录。 --- -## 8. 补单机制 +## 8. API 能力清单(通用命名) -### 8.1 触发时机 - -- 进入充值页时调用 `GooglePlayPurchaseService.runOrderRecovery()` - -### 8.2 补单流程 - -1. 获取未核销订单: `getUnacknowledgedPurchases()` - - 合并 `queryPastPurchases` 和 `purchaseStream` 的待处理订单 - -2. 对每笔订单: - - 查询本地存储的 `federation` 映射 - - 若存在 federation: 调用 `googlepay` 回调 → 成功后 consume - - 若无 federation: 仅执行 consume 解除「已拥有此内容」 - -3. 补单成功后刷新账户 +| 能力 | 典型方法 | 说明 | +|------|------|------| +| 获取商品列表 | `GET` | Android/iOS 可分端点 | +| 获取支付方式 | `POST`/`GET` | 入参通常含 `activityId` | +| 创建订单 | `POST` | 返回 `orderId`、`payUrl` 等 | +| 平台内购回调 | `POST` | 提交 `purchaseData`、`signature` 等 | +| 订单列表 | `GET` | 历史支付记录 | +| 订单详情 | `GET` | 查询单笔订单状态 | --- -## 9. API 汇总 +## 9. 核心字段对照(通用) -| 接口 | 方法 | 返回实体 | -|------|------|----------| -| `getGooglePayActivities` | GET | `PaymentProductsResponse` | -| `getApplePayActivities` | GET | `PaymentProductsResponse` | -| `getPaymentMethods` | POST | `PaymentMethodsResponse` | -| `createPayment` | POST | `CreatePaymentResponse` | -| `getPaymentDetailList` | GET | `PaymentOrderListResponse` | -| `getOrderDetail` | GET | `OrderDetailResponse` | -| `googlepay` | POST | `GooglePayCallbackResponse` | +| 业务含义 | 常见请求字段 | 常见响应字段 | +|------|------|------| +| 应用标识 | `app` | - | +| 用户标识 | `userId` | - | +| 活动/档位 | `activityId` | - | +| 支付方式 | `paymentMethod` | - | +| 子支付方式 | `paymentType` | - | +| 订单标识 | `orderId` / `id` | `orderId` | +| 购买签名 | `signature` | - | +| 购买数据 | `purchaseData` | - | +| 商品 ID | - | `productId` | +| 实付金额 | - | `actualAmount` | +| 原价 | - | `originAmount` | +| 赠送积分 | - | `bonus` / `bonusCredits` | +| 支付链接 | - | `payUrl` | +| 订单状态 | - | `status` | --- -## 10. 字段映射说明 +## 10. 常见问题 -框架自动完成字段映射,调用层使用原始字段名。 +### 10.1 商品未找到 -### 请求 → 响应 字段对照 +- **现象**:发起内购时提示商品不存在; +- **常见原因**:客户端 `productId` 与商店后台配置不一致,或商品未上架到当前测试轨道; +- **排查建议**:核对包名/应用 ID、商品 ID、地区、测试账号与轨道配置。 -| 业务含义 | 请求字段 | 响应字段 | -|----------|----------|----------| -| 应用 ID | app | - | -| 用户 ID | userId | - | -| 活动 ID | activityId | - | -| 支付方式 | paymentMethod | - | -| 支付子类型 | paymentType | - | -| 订单 / 支付 ID(详情 query) | `id`(Dart 仍用参数名 orderId) | orderId | -| 购买签名 | signature | - | -| 购买数据 | purchaseData | - | -| 商品 ID | - | productId | -| 实际金额 | - | actualAmount | -| 原价 | - | originAmount | -| 赠送积分 | - | bonus | -| 支付链接 | - | payUrl | -| 订单状态 | - | status | +### 10.2 回调成功但积分未更新 + +- **现象**:支付侧显示成功,账户余额未变; +- **常见原因**:回调后未刷新账户、订单状态未落库、幂等冲突; +- **排查建议**:检查回调响应、订单状态接口与账户刷新链路。 + +### 10.3 重复购买被拦截(已拥有此内容) + +- **现象**:商店提示已拥有,无法再次购买; +- **常见原因**:上一笔交易未完成/未消费; +- **排查建议**:执行补单恢复流程并确保完成/消费逻辑成功。 --- -## 11. 代码文件位置 +## 11. 实施建议(多客户端复用) -| 功能 | 文件路径 | -|------|----------| -| 充值页面 | `lib/features/recharge/recharge_screen.dart` | -| 支付 API | `lib/core/api/services/payment_api.dart` | -| Google Play 内购服务 | `lib/features/recharge/google_play_purchase_service.dart` | -| 支付方式模型 | `lib/features/recharge/models/payment_method_item.dart` | -| 商品模型 | `lib/features/recharge/models/activity_item.dart` | -| 购买结果模型 | `lib/features/recharge/models/google_pay_purchase_result.dart` | -| WebView 支付页 | `lib/features/recharge/payment_webview_screen.dart` | +- 以“流程能力层”封装支付,不把分支逻辑写死在页面; +- 页面只关心:档位展示、用户选择、状态反馈; +- 建单、回调、补单、轮询统一由支付服务层负责; +- 统一错误码与文案映射,避免各端提示不一致; +- 对关键节点埋点:拉起支付、建单成功、回调成功、补单成功、入账成功; +- 保持接口字段可映射,支持不同换皮应用的字段命名差异。 ---- - -## 12. 常见问题 - -### 12.1 商品未找到 - -- 原因: 客户端 `productId` 与 Google Play 后台「产品 ID」不一致 -- 排查: 检查 Play 后台产品 ID 配置 - -### 12.2 补单 - -- 未确认订单可能不会出现在 `queryPastPurchases` 中 -- 应用启动时订阅 `purchaseStream` 接收重新下发 -- 补单会合并两者的待处理订单 - ---- - -## 13. 注意事项 - -- 所有 Google Play 内购统一使用 `launchPurchaseAndReturnData()` 方法 -- 回调验证成功后必须调用 `completePurchase` + `consumePurchase` -- 支付 URL 打开方式取决于 `createPayment` 返回的 `payUrl` 字段 -- 订单状态轮询: 间隔 1/3/7/15/31/63 秒 diff --git a/lib/src/entities/image_entities.dart b/lib/src/entities/image_entities.dart index 2ee242b..f2a99e8 100644 --- a/lib/src/entities/image_entities.dart +++ b/lib/src/entities/image_entities.dart @@ -512,6 +512,31 @@ class CreateTaskResponse extends Entity { }; } +/// 删除任务响应(`POST /v1/image/delete-task` 的 `data`;见 FunyMee 文档 `hint` / `type`)。 +class DeleteTaskResponse extends Entity { + DeleteTaskResponse({ + this.hint, + this.type, + }); + + final String? hint; + final String? type; + + @override + factory DeleteTaskResponse.fromJson(Map json) { + return DeleteTaskResponse( + hint: json['hint'] as String?, + type: json['type'] as String?, + ); + } + + @override + Map toJson() => { + 'hint': hint, + 'type': type, + }; +} + /// 我的任务项 /// /// 与 FunyMee 文档一致:`state`(线网 `bitrate`)1–6 为权威任务态;`status` 为兼容字段。 diff --git a/lib/src/entities/payment_entities.dart b/lib/src/entities/payment_entities.dart index 34e7a3a..464c531 100644 --- a/lib/src/entities/payment_entities.dart +++ b/lib/src/entities/payment_entities.dart @@ -43,14 +43,15 @@ class PaymentProductItem extends Entity { ); } - /// `saturation`→`bonusCredits`、`remix`→`addCredits`、`contrast`→`bonus`(赠送积分数),或 `credits`−`baseCredits`。 + /// `saturation`→`bonusCredits`、`remix`→`addCredits`,或仅有总额时用 `credits`−`baseCredits` 推断**额外**赠送。 + /// + /// 不可再回退到 [json] 的 `bonus`(线网 `contrast`):该字段已由 [PaymentProductItem.bonus] 单独解析, + /// 否则 UI 会把 `bonus + bonusCredits` 加成两倍。 static int? _parseBonusCredits(Map json) { final direct = _intField(json, 'bonusCredits'); if (direct != null && direct > 0) return direct; final add = _intField(json, 'addCredits'); if (add != null && add > 0) return add; - final contrastGift = _intField(json, 'bonus'); - if (contrastGift != null && contrastGift > 0) return contrastGift; final total = _intField(json, 'credits') ?? _intField(json, 'padding'); final base = _intField(json, 'baseCredits'); if (total != null && base != null && total > base) return total - base; diff --git a/lib/src/services/image_api.dart b/lib/src/services/image_api.dart index 3b19b59..7f6def5 100644 --- a/lib/src/services/image_api.dart +++ b/lib/src/services/image_api.dart @@ -293,6 +293,21 @@ abstract final class ImageApi { ); } + /// 删除任务 + /// + /// **Body**(逻辑字段):`taskId`(int,经 [FieldMapping] 映为线网字段,如 FunyMee `taskId` → `exponential`)。 + /// 与 FunyMee 文档 `POST /v1/image/delete-task` 一致;需登录(自动注入 `User_token`)。 + static Future> deleteTask({ + required int taskId, + }) async { + return _client.requestEntity( + path: '/v1/image/delete-task', + method: 'POST', + entityFactory: DeleteTaskResponse.fromJson, + body: {'taskId': taskId}, + ); + } + /// 获取我的任务列表 static Future> getMyTasks({ required String app, From e44a09e7e16d60b107bd344ec5a1e51114c8e0d5 Mon Sep 17 00:00:00 2001 From: ivan Date: Wed, 22 Apr 2026 17:46:22 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E4=BC=98=E5=8C=96=EF=BC=9A=E6=94=AF?= =?UTF-8?q?=E4=BB=98=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/payment_flow.md | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/docs/payment_flow.md b/docs/payment_flow.md index 9c5de4c..6c004ac 100644 --- a/docs/payment_flow.md +++ b/docs/payment_flow.md @@ -80,12 +80,28 @@ ### 5.2 创建订单 -创建订单请求通常包含: +创建订单请求通常包含(**逻辑字段名**): - 应用标识(`app`) - 用户标识(`userId`) - 商品或活动标识(`activityId` / `productId`) -- 支付方式(`paymentMethod`、可选 `paymentType`) +- 支付主方式(`paymentMethod`) +- 支付类型(`paymentType`,按渠道语义填写) +- 子支付方式(`subPaymentMethod`,如卡种/钱包子通道) + +> 关键点:`subPaymentMethod` 是独立语义字段。 +> 仅传 `paymentType` 不会自动补出 `subPaymentMethod`。 + +#### 5.2.1 推荐入参组合(避免丢子支付方式) + +- 卡支付场景(示例): + - `paymentMethod = MIFAPAY` + - `paymentType = MIFAPAY`(或按后端约定值) + - `subPaymentMethod = CreditCard` +- 若传成 `paymentType = CreditCard` 且未传 `subPaymentMethod`,常见结果是: + - 有 `paymentMethod` + - 有 `paymentType` + - **缺少 `subPaymentMethod`** 响应通常返回: @@ -185,6 +201,15 @@ - **常见原因**:上一笔交易未完成/未消费; - **排查建议**:执行补单恢复流程并确保完成/消费逻辑成功。 +### 10.4 创建订单请求缺少子支付方式字段 + +- **现象**:抓包中 `createPayment` 入参有 `paymentMethod`,但缺少 `subPaymentMethod`; +- **常见原因**:只传了 `paymentType`,没有传 `subPaymentMethod`; +- **排查建议**: + - 检查应用侧建单调用是否显式传入 `subPaymentMethod`; + - 对照请求日志逐项核对三元组:`paymentMethod` / `paymentType` / `subPaymentMethod`; + - 若项目启用了字段映射,再额外确认映射配置与服务端契约一致。 + --- ## 11. 实施建议(多客户端复用) From 982bed4802b86453ef63c0d6d70a3d6df9d316e0 Mon Sep 17 00:00:00 2001 From: ivan Date: Wed, 22 Apr 2026 23:08:39 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E4=BC=98=E5=8C=96=EF=BC=9A=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E6=B5=81=E7=A8=8B=EF=BC=8C=E9=94=99=E8=AF=AF=E4=B8=8A?= =?UTF-8?q?=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/README.md | 6 +- docs/video_home_data_flow.md | 2 +- lib/client_proxy_framework.dart | 2 + lib/src/api/proxy_client.dart | 11 +- lib/src/entities/feedback_entities.dart | 16 ++ lib/src/entities/image_entities.dart | 16 ++ lib/src/services/auth_service.dart | 254 ++++++++++++++++-- .../image_presigned_upload_create_flow.dart | 4 + .../image_upload_expected_size_cache.dart | 44 +++ lib/src/services/login_identity_cache.dart | 43 +++ lib/src/services/user_api.dart | 8 +- 11 files changed, 370 insertions(+), 36 deletions(-) create mode 100644 lib/src/services/image_upload_expected_size_cache.dart create mode 100644 lib/src/services/login_identity_cache.dart diff --git a/docs/README.md b/docs/README.md index f45b2c8..18ebf92 100644 --- a/docs/README.md +++ b/docs/README.md @@ -96,7 +96,7 @@ AES 加密 发送请求 ``` -**请求头**:`ProxyClient` 会将 [AppConfig.packageName] 写入映射后的包名字段(原始名 `pkg`);若已设置用户 token,默认还会写入 `User_token`。**`UserApi.fast_login` 等无需登录态接口**内部使用 `includeUserTokenInHeader: false`,避免把旧 token 打进 `filter_type`。其余请求也可在直接调用 `ProxyClient.request` 时传入该参数。 +**请求头**:`ProxyClient` 默认将 [AppConfig.packageName] 写入映射后的包名字段(原始名 `pkg`);若已设置用户 token,默认还会写入 `User_token`。**`UserApi.fastLogin`** 对内层请求使用 `includeUserTokenInHeader: false`(不传 token,`pkg` 仍按默认行为传递)。其余接口可按需在 `ProxyClient.request` 上传入上述开关。 **请求体**:各 `*Api` 方法使用与《客户端指南》解密表一致的**原始字段名**(如 `referer`、`deviceId`、`fileUrls` / `contentType` / `content`)。 @@ -146,9 +146,9 @@ final res = await UserApi.fastLogin( deviceId: '设备ID', sign: 'MD5(deviceId)大写', app: 'HAndroid', // 必填:HIOS / HAndroid - referer: '归因来源', // 可选 + referer: '归因来源', // 可选;`gg` 时常为 Play Install Referrer;框架 start 取不到时用 utm_source=google-play&utm_medium=organic ch: '渠道号', // 可选 - type: 'fb', // 可选;未传时默认 fb + type: 'gg', // 可选;未传时默认 gg(Google Play 归因) ); if (res.isSuccess) { diff --git a/docs/video_home_data_flow.md b/docs/video_home_data_flow.md index 364bf87..b98c909 100644 --- a/docs/video_home_data_flow.md +++ b/docs/video_home_data_flow.md @@ -49,7 +49,7 @@ sequenceDiagram ### 3.1 启动与快速登录 - 延迟与重试策略由 `FrameworkAuthService.start` 控制(默认启动延迟、登录重试等)。 -- **`UserApi.fastLogin`**:`POST /v1/user/fast_login`。请求头**仅**带 `pkg`,**不带** `User_token`(见 `UserApi` 文档注释)。 +- **`UserApi.fastLogin`**:`POST /v1/user/fast_login`。线网内层 headers 保留 `pkg`,但**不注入** `User_token`(见 `UserApi` / `ProxyClient` 注释)。Query `type` 未传时默认为 **`gg`**(Google Play 归因)。`FrameworkAuthService` 强制 `type=gg`:`referer` 优先 Play Install Referrer,取不到时用 `utm_source=google-play&utm_medium=organic`。 - 成功后框架将返回的 `userToken` 写入 **`ApiClient.instance.setUserToken`**,此后代理请求自动附带 `pkg` 与 `User_token`(见 `ProxyClient` 行为与 `UserApi` 说明)。 ### 3.2 归因上报(与首页数据并行准备) diff --git a/lib/client_proxy_framework.dart b/lib/client_proxy_framework.dart index 9a1f8d5..9883a6d 100644 --- a/lib/client_proxy_framework.dart +++ b/lib/client_proxy_framework.dart @@ -32,12 +32,14 @@ export 'src/services/analytics_attribution_callbacks.dart'; export 'src/services/analytics_events.dart'; export 'src/services/analytics_service.dart'; export 'src/services/auth_service.dart'; +export 'src/services/login_identity_cache.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_upload_expected_size_cache.dart'; export 'src/services/image_task_history.dart'; export 'src/services/task_upload_cover_store.dart'; export 'src/services/user_account_refresh.dart'; diff --git a/lib/src/api/proxy_client.dart b/lib/src/api/proxy_client.dart index 53a0fdf..ea57ad5 100644 --- a/lib/src/api/proxy_client.dart +++ b/lib/src/api/proxy_client.dart @@ -104,11 +104,11 @@ class ProxyClient { /// 响应 data 会自动从线网转回逻辑字段名。 /// /// **请求头(自动注入)** - /// - [AppConfig.packageName] → 逻辑字段名 `pkg`(再映射为线网请求头键) + /// - 若 [includePackageInHeader] 为 true(默认):[AppConfig.packageName] → 逻辑字段名 `pkg`(再映射为线网请求头键) /// - 若已 [userToken] 且 [includeUserTokenInHeader] 为 true,则注入 `User_token` /// /// 与文档一致:**设备快速登录等无需登录态接口**应传 `includeUserTokenInHeader: false`, - /// 避免历史 token 进入 `filter_type`。 + /// 避免历史 token 进入 `filter_type`。若同时 `includePackageInHeader: false`,则内层 headers 不注入 `pkg`(由 query 等自行携带包名)。 Future request({ required String path, required String method, @@ -116,12 +116,13 @@ class ProxyClient { Map? queryParams, Map? body, bool includeUserTokenInHeader = true, + bool includePackageInHeader = true, }) async { final pk = config.proxyKeys; final mapping = config.fieldMapping; var headersMap = Map.from(headers ?? {}); - if (config.packageName.isNotEmpty) { + if (includePackageInHeader && config.packageName.isNotEmpty) { headersMap[mapping.headerPackageNameField] = config.packageName; } if (includeUserTokenInHeader && @@ -179,7 +180,7 @@ class ProxyClient { /// [headers]、[queryParams]、[body] 使用**业务逻辑字段名**。 /// [entityFactory] 用于将映射后的 data 转换为实体对象。 /// - /// 参见 [request] 的 [includeUserTokenInHeader] 说明。 + /// 参见 [request] 的 [includeUserTokenInHeader]、[includePackageInHeader] 说明。 Future> requestEntity({ required String path, required String method, @@ -188,6 +189,7 @@ class ProxyClient { Map? queryParams, Map? body, bool includeUserTokenInHeader = true, + bool includePackageInHeader = true, }) async { final response = await request( path: path, @@ -196,6 +198,7 @@ class ProxyClient { queryParams: queryParams, body: body, includeUserTokenInHeader: includeUserTokenInHeader, + includePackageInHeader: includePackageInHeader, ); if (response.isSuccess) { diff --git a/lib/src/entities/feedback_entities.dart b/lib/src/entities/feedback_entities.dart index b3a1e2c..3efa278 100644 --- a/lib/src/entities/feedback_entities.dart +++ b/lib/src/entities/feedback_entities.dart @@ -8,6 +8,7 @@ class FeedbackUploadPresignedUrlResponse extends Entity { this.uploadUrl, this.filePath, this.putHeaders, + this.expectedSize, }); final String? uploadUrl; @@ -16,6 +17,9 @@ class FeedbackUploadPresignedUrlResponse extends Entity { /// 与 [UploadPresignedUrlResponse.putHeaders] 一致:PUT 到对象存储时的额外头。 final Map? putHeaders; + /// 服务端返回的上传大小上限(字节),逻辑字段名 `expectedSize`;未下发时为 `null`。 + final int? expectedSize; + static String _headerValueToString(dynamic v) { if (v == null) return ''; if (v is String) return v; @@ -57,6 +61,14 @@ class FeedbackUploadPresignedUrlResponse extends Entity { return out.isEmpty ? null : out; } + static int? _readIntField(Map 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().trim()); + } + @override factory FeedbackUploadPresignedUrlResponse.fromJson( Map json) { @@ -64,6 +76,9 @@ class FeedbackUploadPresignedUrlResponse extends Entity { uploadUrl: json['uploadUrl'] as String?, filePath: json['filePath'] as String?, putHeaders: _parsePutHeaders(json), + expectedSize: _readIntField(json, 'expectedSize') ?? + _readIntField(json, 'maxFileSize') ?? + _readIntField(json, 'maxSize'), ); } @@ -72,6 +87,7 @@ class FeedbackUploadPresignedUrlResponse extends Entity { 'uploadUrl': uploadUrl, 'filePath': filePath, if (putHeaders != null) 'putHeaders': putHeaders, + if (expectedSize != null) 'expectedSize': expectedSize, }; } diff --git a/lib/src/entities/image_entities.dart b/lib/src/entities/image_entities.dart index f2a99e8..bc3b688 100644 --- a/lib/src/entities/image_entities.dart +++ b/lib/src/entities/image_entities.dart @@ -405,6 +405,7 @@ class UploadPresignedUrlResponse extends Entity { this.uploadUrl, this.filePath, this.putHeaders, + this.expectedSize, }); final String? uploadUrl; @@ -413,6 +414,9 @@ class UploadPresignedUrlResponse extends Entity { /// 上传到对象存储时额外请求头(如服务端返回的签名头;解密后为 business 字段名)。 final Map? putHeaders; + /// 服务端返回的单次上传大小上限(字节),逻辑字段名 `expectedSize`;未下发时为 `null`。 + final int? expectedSize; + /// 将任意 JSON 头值压成 [http] 要求的 [String](避免 `TypeError`)。 static String _headerValueToString(dynamic v) { if (v == null) return ''; @@ -462,6 +466,14 @@ class UploadPresignedUrlResponse extends Entity { return v.toString(); } + static int? _readIntField(Map 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().trim()); + } + @override factory UploadPresignedUrlResponse.fromJson(Map json) { // FunyMee 等换皮:`uploadUrl1`/`filePath1`(文档 wire:harden / generate)。 @@ -476,6 +488,9 @@ class UploadPresignedUrlResponse extends Entity { uploadUrl: upload, filePath: path, putHeaders: _parsePutHeaders(json), + expectedSize: _readIntField(json, 'expectedSize') ?? + _readIntField(json, 'maxFileSize') ?? + _readIntField(json, 'maxSize'), ); } @@ -484,6 +499,7 @@ class UploadPresignedUrlResponse extends Entity { 'uploadUrl': uploadUrl, 'filePath': filePath, if (putHeaders != null) 'putHeaders': putHeaders, + if (expectedSize != null) 'expectedSize': expectedSize, }; } diff --git a/lib/src/services/auth_service.dart b/lib/src/services/auth_service.dart index ed53374..8448230 100644 --- a/lib/src/services/auth_service.dart +++ b/lib/src/services/auth_service.dart @@ -11,8 +11,14 @@ import '../config/video_home_runtime.dart'; import '../entities/user_entities.dart'; import 'adjust_service.dart'; import 'analytics_attribution_callbacks.dart'; +import 'facebook_service.dart'; +import 'login_identity_cache.dart'; import 'user_api.dart'; +/// [FrameworkAuthService.start] 中 `fast_login` 在拿不到 Play Install Referrer 时使用的 `referer` 兜底(自然安装)。 +const String _fastLoginPlayReferrerFallback = + 'utm_source=google-play&utm_medium=organic'; + /// 认证服务回调 /// 用于在认证流程各阶段通知调用方 abstract class AuthServiceCallbacks { @@ -37,6 +43,14 @@ abstract class AuthServiceCallbacks { /// 1. 快速登录 /// 2. 归因上报 /// 3. 获取通用信息 +/// +/// **换皮默认行为**(无需宿主接入): +/// - `fast_login`:`type` **强制**为 `gg`(Google 归因);`referer` 优先 Google Play Install Referrer,取不到则使用 [_fastLoginPlayReferrerFallback],不再切换 Adjust/Facebook 的 type。 +/// - 登录成功且 `userId` 非空:将 `userId` 与本次 `deviceId` 写入 [LoginIdentityCache]。 +/// - 登录失败(含无响应、异常、`code != 0`、或成功体无 `userId`):上报 Facebook 自定义事件 [facebookLoginFailedEventName], +/// 参数:`server_error_code`、`user_id`(响应中有则带,否则空串)、`device_id`(本次启动已解析的设备 ID,若尚未取到则为空串); +/// 未能取得用户 ID 时另带 `register_faild` = `register faild`。 +/// - [UserApi.getCommonInfo] 失败、请求异常,或成功但响应体无 `extConfig` 字符串:上报 Facebook [facebookExtConfigFailedEventName](`user_id`、`device_id`、`server_error_code`、可选 `server_error_msg`)。 abstract class FrameworkAuthService { static AuthServiceCallbacks? _callbacks; static Future? _loginFuture; @@ -91,10 +105,14 @@ abstract class FrameworkAuthService { debugPrint('[AuthService] start: 开始登录流程'); } + /// 供 [catch] 上报 Facebook 使用:若在 [getDeviceId] 成功前抛错则为空串。 + var deviceIdForFacebookFailure = ''; + try { await Future.delayed(Duration(seconds: delaySeconds)); final deviceId = await _callbacks!.getDeviceId(); + deviceIdForFacebookFailure = deviceId; if (kDebugMode) { debugPrint('[AuthService] start: deviceId=$deviceId'); } @@ -104,26 +122,17 @@ abstract class FrameworkAuthService { debugPrint('[AuthService] start: sign=$sign'); } - final referer = await AttributionService.getReferrer(); - if (kDebugMode && referer != null) { - debugPrint('[AuthService] start: referer=$referer'); - } - - // 确定归因类型 - String? referrerType; - final adjustReferrer = await AttributionService.getAdjustReferrer(); - final fbReferrer = await AttributionService.getFacebookReferrer(); - - if (adjustReferrer != null && adjustReferrer.isNotEmpty) { - referrerType = defaultTargetPlatform == TargetPlatform.iOS - ? 'ios_adjust' - : 'android_adjust'; - } else if (fbReferrer != null && fbReferrer.isNotEmpty) { - referrerType = 'fb'; - } + // fast_login:强制使用 Google 归因类型 `gg`;referer 优先 Play Install Referrer,无则自然安装 UTM 兜底。 + final playReferrer = AdjustService.cachedPlayReferrer; + final fastLoginReferer = (playReferrer != null && playReferrer.isNotEmpty) + ? playReferrer + : _fastLoginPlayReferrerFallback; + const fastLoginType = 'gg'; if (kDebugMode) { - debugPrint('[AuthService] start: referrerType=$referrerType'); + debugPrint( + '[AuthService] start: fast_login type=$fastLoginType refererLen=${fastLoginReferer.length}', + ); } // 尝试快速登录 @@ -143,9 +152,9 @@ abstract class FrameworkAuthService { res = await UserApi.fastLogin( deviceId: deviceId, sign: sign, - referer: referer ?? '', + referer: fastLoginReferer, app: appType, - type: referrerType, + type: fastLoginType, ); break; } catch (e) { @@ -160,6 +169,12 @@ abstract class FrameworkAuthService { lastLoggedInUserId = null; VideoHomeRuntime.reset(); ExtConfigRuntime.applyCommonInfoFailure(); + _logFacebookLoginFailed( + serverCode: -1, + missingUserId: true, + userIdFromServer: null, + deviceId: deviceId, + ); completer.complete(); return; } @@ -171,8 +186,7 @@ abstract class FrameworkAuthService { if (res.isSuccess && res.data != null) { final loginData = res.data!; final uid = loginData.userId?.trim(); - lastLoggedInUserId = - uid != null && uid.isNotEmpty ? uid : null; + lastLoggedInUserId = uid != null && uid.isNotEmpty ? uid : null; // 设置 Token if (loginData.userToken != null && loginData.userToken!.isNotEmpty) { @@ -182,6 +196,22 @@ abstract class FrameworkAuthService { } } + if (uid != null && uid.isNotEmpty) { + unawaited( + LoginIdentityCache.writeUserAndDeviceId( + userId: uid, + deviceId: deviceId, + ), + ); + } else { + _logFacebookLoginFailed( + serverCode: res.code, + missingUserId: true, + userIdFromServer: loginData.userId, + deviceId: deviceId, + ); + } + // 回调登录成功 _callbacks!.onLoginSuccess(loginData); @@ -194,6 +224,7 @@ abstract class FrameworkAuthService { lastLoggedInUserId = null; VideoHomeRuntime.reset(); ExtConfigRuntime.applyCommonInfoFailure(); + _logFacebookLoginFailedFromResponse(res, deviceId: deviceId); _callbacks!.onLoginFailed(res.msg); } } catch (e, st) { @@ -203,6 +234,12 @@ abstract class FrameworkAuthService { if (kDebugMode) { debugPrint('[AuthService] start: 异常 $e\n$st'); } + _logFacebookLoginFailed( + serverCode: -1, + missingUserId: true, + userIdFromServer: null, + deviceId: deviceIdForFacebookFailure, + ); _callbacks!.onLoginFailed(e.toString()); } finally { if (!completer.isCompleted) { @@ -234,8 +271,14 @@ abstract class FrameworkAuthService { : config.backendAppTypeAndroid; // 上报 Adjust 归因 + var adjustReferrerTried = false; + var adjustReferrerOk = false; + var adjustReferrerCode = 0; + var adjustReferrerMsg = ''; + final adjustReferer = await AttributionService.getAdjustReferrer(); if (adjustReferer != null && adjustReferer.isNotEmpty) { + adjustReferrerTried = true; final adjustType = defaultTargetPlatform == TargetPlatform.iOS ? 'ios_adjust' : 'android_adjust'; @@ -247,11 +290,19 @@ abstract class FrameworkAuthService { deviceId: deviceId, type: adjustType, ); + if (rAdjust.isSuccess) { + adjustReferrerOk = true; + } else { + adjustReferrerCode = rAdjust.code; + adjustReferrerMsg = rAdjust.msg; + } if (kDebugMode) { debugPrint( '[AuthService] referrer($adjustType): ${rAdjust.isSuccess ? "成功" : "失败"}'); } } catch (e) { + adjustReferrerCode = -110; + adjustReferrerMsg = e.toString(); if (kDebugMode) { debugPrint('[AuthService] referrer($adjustType): 异常 $e'); } @@ -259,8 +310,14 @@ abstract class FrameworkAuthService { } // 上报 Google Play 归因(从 AdjustService 获取缓存的 referrer) + var ggReferrerTried = false; + var ggReferrerOk = false; + var ggReferrerCode = 0; + var ggReferrerMsg = ''; + final playReferrer = AdjustService.cachedPlayReferrer; if (playReferrer != null && playReferrer.isNotEmpty) { + ggReferrerTried = true; try { final rGg = await UserApi.referrer( app: backendApp, @@ -269,17 +326,39 @@ abstract class FrameworkAuthService { deviceId: deviceId, type: 'gg', ); + if (rGg.isSuccess) { + ggReferrerOk = true; + } else { + ggReferrerCode = rGg.code; + ggReferrerMsg = rGg.msg; + } if (kDebugMode) { debugPrint( '[AuthService] referrer(gg): ${rGg.isSuccess ? "成功" : "失败"}'); } } catch (e) { + ggReferrerCode = -110; + ggReferrerMsg = e.toString(); if (kDebugMode) { debugPrint('[AuthService] referrer(gg): 异常 $e'); } } } + if (adjustReferrerTried && + ggReferrerTried && + !adjustReferrerOk && + !ggReferrerOk) { + _logFacebookReferrerBothFailed( + userId: uid, + deviceId: deviceId, + adjustCode: adjustReferrerCode, + adjustMsg: adjustReferrerMsg, + ggCode: ggReferrerCode, + ggMsg: ggReferrerMsg, + ); + } + // 获取通用信息 try { final commonRes = await UserApi.getCommonInfo( @@ -289,8 +368,11 @@ abstract class FrameworkAuthService { deviceId: deviceId, ); if (commonRes.isSuccess && commonRes.data != null) { - ExtConfigRuntime.applyCommonInfoSuccess(commonRes.data!); - _callbacks?.onCommonInfoLoaded(commonRes.data!); + final info = commonRes.data!; + final extRaw = info.extConfig?.trim(); + final extConfigMissing = extRaw == null || extRaw.isEmpty; + ExtConfigRuntime.applyCommonInfoSuccess(info); + _callbacks?.onCommonInfoLoaded(info); unawaited( VideoHomeRuntime.hydrateAfterCommonInfo( userId: uid, @@ -300,9 +382,23 @@ abstract class FrameworkAuthService { if (kDebugMode) { debugPrint('[AuthService] common_info: 获取成功'); } + if (extConfigMissing) { + _logFacebookExtConfigFailed( + userId: uid, + deviceId: deviceId, + serverCode: 0, + serverMsg: 'ext_config_missing', + ); + } } else { VideoHomeRuntime.reset(); ExtConfigRuntime.applyCommonInfoFailure(); + _logFacebookExtConfigFailed( + userId: uid, + deviceId: deviceId, + serverCode: commonRes.code, + serverMsg: commonRes.msg, + ); if (kDebugMode) { debugPrint( '[AuthService] common_info: 失败 code=${commonRes.code} msg=${commonRes.msg}'); @@ -311,6 +407,12 @@ abstract class FrameworkAuthService { } catch (e) { VideoHomeRuntime.reset(); ExtConfigRuntime.applyCommonInfoFailure(); + _logFacebookExtConfigFailed( + userId: uid, + deviceId: deviceId, + serverCode: -1, + serverMsg: e.toString(), + ); if (kDebugMode) { debugPrint('[AuthService] common_info: 异常 $e'); } @@ -321,4 +423,108 @@ abstract class FrameworkAuthService { static Map? parseExtConfig(String? extConfigStr) { return ExtConfigData.parseRawMap(extConfigStr); } + + /// Facebook 自定义事件名(与产品约定一致:`LoginFaild`)。 + static const String facebookLoginFailedEventName = 'LoginFaild'; + + /// 两次归因上报(Adjust + `gg`)均请求且均失败时上报(与产品约定:`Referer_faild`)。 + static const String facebookReferrerBothFailedEventName = 'Referer_faild'; + + /// `common_info` 失败或响应中无 `extConfig` 时上报(与产品约定:`ExtConfigFaild`)。 + static const String facebookExtConfigFailedEventName = 'ExtConfigFaild'; + + static const int _facebookParamMaxLen = 500; + + static String _truncateForFacebookParam(String s) { + final t = s.trim(); + if (t.length <= _facebookParamMaxLen) return t; + return '${t.substring(0, _facebookParamMaxLen)}…'; + } + + /// Adjust 与 Google Play 两条 [UserApi.referrer] 都发起且都未成功时调用(不阻塞、失败静默)。 + static void _logFacebookReferrerBothFailed({ + required String userId, + required String deviceId, + required int adjustCode, + required String adjustMsg, + required int ggCode, + required String ggMsg, + }) { + final parts = [ + if (adjustMsg.trim().isNotEmpty) + 'adjust: ${_truncateForFacebookParam(adjustMsg)}', + if (ggMsg.trim().isNotEmpty) 'gg: ${_truncateForFacebookParam(ggMsg)}', + ]; + final combinedMsg = parts.join(' | '); + final params = { + 'user_id': userId.trim(), + 'device_id': deviceId.trim(), + 'server_error_code': '$adjustCode/$ggCode', + if (combinedMsg.isNotEmpty) 'server_error_msg': combinedMsg, + }; + FacebookService.logEvent( + facebookReferrerBothFailedEventName, + parameters: params, + ); + } + + /// [UserApi.getCommonInfo] 失败、异常,或成功但缺少 `extConfig` 时调用(不阻塞、失败静默)。 + static void _logFacebookExtConfigFailed({ + required String userId, + required String deviceId, + required int serverCode, + String serverMsg = '', + }) { + final msg = serverMsg.trim(); + final params = { + 'user_id': userId.trim(), + 'device_id': deviceId.trim(), + 'server_error_code': '$serverCode', + if (msg.isNotEmpty) 'server_error_msg': _truncateForFacebookParam(msg), + }; + FacebookService.logEvent( + facebookExtConfigFailedEventName, + parameters: params, + ); + } + + static void _logFacebookLoginFailedFromResponse( + EntityResponse res, { + required String deviceId, + }) { + final uid = res.data?.userId?.trim(); + final missingUserId = uid == null || uid.isEmpty; + _logFacebookLoginFailed( + serverCode: res.code, + missingUserId: missingUserId, + userIdFromServer: res.data?.userId, + deviceId: deviceId, + ); + } + + /// 登录失败或未拿到用户 ID 时上报 Meta / Facebook App Events(不阻塞、失败静默)。 + /// + /// - [serverCode]:接口 [EntityResponse.code];无响应或异常时为 `-1`。 + /// - [userIdFromServer]:解密后响应里的 `userId`;无则上报空串。 + /// - [deviceId]:本次流程使用的设备 ID;未取到时为空串。 + /// - [missingUserId] 为 `true` 时附带参数 `register_faild` = `register faild`。 + static void _logFacebookLoginFailed({ + required int serverCode, + required bool missingUserId, + String? userIdFromServer, + required String deviceId, + }) { + final uid = userIdFromServer?.trim() ?? ''; + final did = deviceId.trim(); + final params = { + 'server_error_code': '$serverCode', + 'user_id': uid, + 'device_id': did, + if (missingUserId) 'register_faild': 'register faild', + }; + FacebookService.logEvent( + facebookLoginFailedEventName, + parameters: params, + ); + } } diff --git a/lib/src/services/image_presigned_upload_create_flow.dart b/lib/src/services/image_presigned_upload_create_flow.dart index 3c7a2ec..1837129 100644 --- a/lib/src/services/image_presigned_upload_create_flow.dart +++ b/lib/src/services/image_presigned_upload_create_flow.dart @@ -6,6 +6,7 @@ import '../entities/image_entities.dart'; import '../log/app_logger.dart'; import 'image_api.dart'; import 'image_compress.dart'; +import 'image_upload_expected_size_cache.dart'; import 'task_upload_cover_store.dart'; final _presignedPutLog = AppLogger('PresignedUpload'); @@ -99,6 +100,9 @@ abstract final class ImagePresignedUploadCreateTaskFlow { } final presigned = presignedRes.data!; + await ImageUploadExpectedSizeCache.writeImageExpectedSize( + presigned.expectedSize, + ); final uploadUrl = presigned.uploadUrl; final filePath = presigned.filePath; if (uploadUrl == null || diff --git a/lib/src/services/image_upload_expected_size_cache.dart b/lib/src/services/image_upload_expected_size_cache.dart new file mode 100644 index 0000000..e4ff0f2 --- /dev/null +++ b/lib/src/services/image_upload_expected_size_cache.dart @@ -0,0 +1,44 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +/// 缓存 [ImageApi.getUploadPresignedUrl] / [FeedbackApi.getUploadPresignedUrl] 响应中的 +/// [UploadPresignedUrlResponse.expectedSize] / [FeedbackUploadPresignedUrlResponse.expectedSize], +/// 供选图前展示与校验;未缓存时由调用方使用 [fallbackMaxBytes]。 +abstract final class ImageUploadExpectedSizeCache { + ImageUploadExpectedSizeCache._(); + + /// 未命中服务端下发的 `expectedSize` 时,客户端使用的默认上限(字节)。 + static const int fallbackMaxBytes = 20 * 1024 * 1024; + + static const String _kImage = + 'client_proxy_image_presigned_expected_size_bytes_v1'; + static const String _kFeedback = + 'client_proxy_feedback_presigned_expected_size_bytes_v1'; + + static Future readImageMaxBytesForUi() async { + final p = await SharedPreferences.getInstance(); + final v = p.getInt(_kImage); + if (v != null && v > 0) return v; + return fallbackMaxBytes; + } + + static Future readFeedbackMaxBytesForUi() async { + final p = await SharedPreferences.getInstance(); + final v = p.getInt(_kFeedback); + if (v != null && v > 0) return v; + return fallbackMaxBytes; + } + + /// 在生图预签名成功后写入;仅当 [bytes] 为正整数时持久化。 + static Future writeImageExpectedSize(int? bytes) async { + if (bytes == null || bytes <= 0) return; + final p = await SharedPreferences.getInstance(); + await p.setInt(_kImage, bytes); + } + + /// 在反馈预签名成功后写入;仅当 [bytes] 为正整数时持久化。 + static Future writeFeedbackExpectedSize(int? bytes) async { + if (bytes == null || bytes <= 0) return; + final p = await SharedPreferences.getInstance(); + await p.setInt(_kFeedback, bytes); + } +} diff --git a/lib/src/services/login_identity_cache.dart b/lib/src/services/login_identity_cache.dart new file mode 100644 index 0000000..2923145 --- /dev/null +++ b/lib/src/services/login_identity_cache.dart @@ -0,0 +1,43 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +/// 登录成功后缓存 [FrameworkAuthService] 使用的用户 ID 与设备 ID,供换皮应用读取(如离线展示、诊断)。 +/// +/// 与业务 token 无关;仅作本地副本。 +abstract final class LoginIdentityCache { + LoginIdentityCache._(); + + static const String _kUserId = 'client_proxy_framework_cached_login_user_id_v1'; + static const String _kDeviceId = + 'client_proxy_framework_cached_login_device_id_v1'; + + /// 最近一次成功登录写入的 `userId`;未写入过则为 `null`。 + static Future readCachedUserId() async { + final p = await SharedPreferences.getInstance(); + final v = p.getString(_kUserId)?.trim(); + if (v == null || v.isEmpty) return null; + return v; + } + + /// 最近一次成功登录写入的 `deviceId`;未写入过则为 `null`。 + static Future readCachedDeviceId() async { + final p = await SharedPreferences.getInstance(); + final v = p.getString(_kDeviceId)?.trim(); + if (v == null || v.isEmpty) return null; + return v; + } + + /// 在快速登录成功且 [userId] 非空时由框架调用。 + static Future writeUserAndDeviceId({ + required String userId, + required String deviceId, + }) async { + final uid = userId.trim(); + if (uid.isEmpty) return; + final did = deviceId.trim(); + final p = await SharedPreferences.getInstance(); + await p.setString(_kUserId, uid); + if (did.isNotEmpty) { + await p.setString(_kDeviceId, did); + } + } +} diff --git a/lib/src/services/user_api.dart b/lib/src/services/user_api.dart index 8a21654..14d52d8 100644 --- a/lib/src/services/user_api.dart +++ b/lib/src/services/user_api.dart @@ -7,7 +7,7 @@ import '../entities/user_entities.dart'; /// 用户相关 API(**业务逻辑字段名**,经 [FieldMapping] 映射为线网字段名) /// /// **请求头**:除 [UserApi.fastLogin] 外,需登录接口均由 [ProxyClient] 自动附带 -/// `pkg`(包名)与 `User_token`(已设置 token 时)。fast_login 仅带 `pkg`,不带 token。 +/// `pkg`(包名)与 `User_token`(已设置 token 时)。**fast_login** 保留 `pkg`,但不注入 `User_token`。 /// /// **请求体**:与《客户端指南》一致,使用**业务逻辑字段名**(如 `referer`、`deviceId`)。 abstract final class UserApi { @@ -15,8 +15,8 @@ abstract final class UserApi { /// 设备快速登录 /// - /// **请求头**:仅 `pkg`(无 `User_token`)。 - /// **Query**:`app`、`type`(默认 `fb`)、`pkg`、`ch`(可选)。 + /// **请求头**:保留 `pkg`,不注入 `User_token`(见 [ProxyClient.request] 的 `includeUserTokenInHeader`)。 + /// **Query**:`app`、`type`(未传时默认 `gg`,Google Play Install Referrer 归因)、`pkg`、`ch`(可选)。 /// **Body**:`referer`、`sign`、`deviceId`。 static Future> fastLogin({ required String deviceId, @@ -34,7 +34,7 @@ abstract final class UserApi { queryParams: { if (ch != null && ch.isNotEmpty) 'ch': ch, 'pkg': config.packageName, - 'type': type ?? 'fb', + 'type': type ?? 'gg', 'app': app, }, body: {