Merge branch 'main' of https://git.xtrader.vip/Ivan/client_framework
This commit is contained in:
commit
c49a12c091
@ -21,6 +21,8 @@
|
|||||||
|
|
||||||
以首款换皮应用 **FunyMee** 为参考的 **视频首页**(`common_info` → 分类 → 按分类拉模板)数据流说明,见 **[《视频首页数据获取流程》](video_home_data_flow.md)**。
|
以首款换皮应用 **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)**。
|
示例换皮配置文件 [`lib/src/config/skin_config.example.json`](../lib/src/config/skin_config.example.json) 的字段说明,见 **[《skin_config.example.json 字段说明》](skin_config_example.md)**。
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -94,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`)。
|
**请求体**:各 `*Api` 方法使用与《客户端指南》解密表一致的**原始字段名**(如 `referer`、`deviceId`、`fileUrls` / `contentType` / `content`)。
|
||||||
|
|
||||||
@ -144,9 +146,9 @@ final res = await UserApi.fastLogin(
|
|||||||
deviceId: '设备ID',
|
deviceId: '设备ID',
|
||||||
sign: 'MD5(deviceId)大写',
|
sign: 'MD5(deviceId)大写',
|
||||||
app: 'HAndroid', // 必填:HIOS / HAndroid
|
app: 'HAndroid', // 必填:HIOS / HAndroid
|
||||||
referer: '归因来源', // 可选
|
referer: '归因来源', // 可选;`gg` 时常为 Play Install Referrer;框架 start 取不到时用 utm_source=google-play&utm_medium=organic
|
||||||
ch: '渠道号', // 可选
|
ch: '渠道号', // 可选
|
||||||
type: 'fb', // 可选;未传时默认 fb
|
type: 'gg', // 可选;未传时默认 gg(Google Play 归因)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (res.isSuccess) {
|
if (res.isSuccess) {
|
||||||
|
|||||||
92
docs/credit_record_requirements_and_implementation.md
Normal file
92
docs/credit_record_requirements_and_implementation.md
Normal file
@ -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` 语义一致。
|
||||||
142
docs/history_page_requirements_and_implementation.md
Normal file
142
docs/history_page_requirements_and_implementation.md
Normal file
@ -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` 判定稳定。
|
||||||
|
|
||||||
108
docs/image_generation_flow.md
Normal file
108
docs/image_generation_flow.md
Normal file
@ -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` 等关键词说明。
|
||||||
@ -1,298 +1,223 @@
|
|||||||
# 支付流程文档
|
# 支付流程(多客户端通用)
|
||||||
|
|
||||||
## 1. 概述
|
## 1. 概述
|
||||||
|
|
||||||
本文档描述 Android 项目(Flutter)的完整支付流程,包括商品获取、支付方式选择、订单创建、Google Play 内购以及补单机制。
|
本文档描述换皮应用可复用的支付全流程,覆盖:商品获取、支付方式选择、订单创建、平台内购回调、订单核销、补单与账户刷新。
|
||||||
|
文档以“能力与流程”表达,不绑定具体客户端项目、目录路径或某一语言实现。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 支付流程总览
|
## 2. 支付流程总览
|
||||||
|
|
||||||
```
|
```text
|
||||||
用户点击 Buy
|
用户点击充值 / Buy
|
||||||
│
|
│
|
||||||
├─ enableThirdPartyPayment === true 且已登录
|
├─ 已登录 且 开启第三方支付
|
||||||
│ │
|
│ │
|
||||||
│ ├─ getPaymentMethods(activityId) 获取支付方式
|
│ ├─ 拉取支付方式(基于商品/活动)
|
||||||
│ ├─ 弹窗选择支付方式
|
│ ├─ 用户选择支付方式
|
||||||
│ ├─ createPayment 创建订单
|
│ ├─ 创建订单(服务端生成业务订单)
|
||||||
│ │
|
│ │
|
||||||
│ ├─ 若选中的是 Google Pay
|
│ ├─ 若为平台内购(如 Google Play)
|
||||||
│ │ ├─ 调起 Google Play 内购
|
│ │ ├─ 调起商店购买
|
||||||
│ │ ├─ 拿到 purchaseData + signature
|
│ │ ├─ 获取 purchaseData + signature + storeOrderId
|
||||||
│ │ └─ googlepay 回调验证
|
│ │ └─ 回调服务端校验与入账
|
||||||
│ │
|
│ │
|
||||||
│ └─ 否则(其他支付方式)
|
│ └─ 若为外部支付
|
||||||
│ └─ 打开 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. 商品展示与获取
|
||||||
|
|
||||||
### 4.1 接口
|
### 4.1 获取时机
|
||||||
|
|
||||||
```dart
|
- 进入充值页时拉取一次;
|
||||||
// Android(app 默认 backendAppTypeAndroid,可传 client)
|
- 下拉刷新或切换国家/渠道后重新拉取;
|
||||||
final res = await PaymentApi.getGooglePayActivities(
|
- 支付成功后建议刷新,确保档位与余额展示一致。
|
||||||
country: '国家', // 可选
|
|
||||||
);
|
|
||||||
|
|
||||||
// iOS(app 默认 backendAppTypeIOS)
|
### 4.2 商品字段(通用语义)
|
||||||
final res = await PaymentApi.getApplePayActivities(
|
|
||||||
country: '国家', // 可选
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 返回实体
|
| 字段语义 | 说明 |
|
||||||
|
|------|------|
|
||||||
```dart
|
| `productId` | 商店商品 ID(内购必填) |
|
||||||
class PaymentProductsResponse {
|
| `activityId` | 服务端活动/档位 ID(建单常用) |
|
||||||
List<PaymentProductItem>? productList;
|
| `actualAmount` | 实付金额 |
|
||||||
}
|
| `originAmount` | 原价(可用于划线价) |
|
||||||
|
| `bonus` / `bonusCredits` | 赠送积分 |
|
||||||
class PaymentProductItem {
|
| `credits` | 到账积分(如与赠送分开展示) |
|
||||||
String? productId; // 商品 ID (对应 helm)
|
| `title` | 商品文案(可能由服务端直接下发) |
|
||||||
String? activityId; // 活动 ID (对应 warrior)
|
|
||||||
String? actualAmount; // 实际金额 (对应 guardian)
|
|
||||||
String? originAmount; // 原价 (对应 curriculum)
|
|
||||||
int? bonus; // 赠送积分 (对应 forge)
|
|
||||||
String? title; // 标题 (对应 glossary)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 第三方支付流程
|
## 5. 第三方支付流程(通用)
|
||||||
|
|
||||||
### 5.1 获取支付方式
|
### 5.1 拉取支付方式
|
||||||
|
|
||||||
```dart
|
以商品或活动为上下文请求支付方式列表,常见字段包括:
|
||||||
final res = await PaymentApi.getPaymentMethods(
|
|
||||||
activityId: 12345, // int
|
|
||||||
country: '国家', // 可选
|
|
||||||
);
|
|
||||||
|
|
||||||
if (res.isSuccess) {
|
- `paymentMethod`:主支付方式(例如平台内购、钱包、卡支付等)
|
||||||
final methods = res.data!.paymentMethods;
|
- `subPaymentMethod`:子方式(可选)
|
||||||
// methods 包含:
|
- `name` / `icon`:展示信息
|
||||||
// - paymentMethod: 支付方式 (如 GOOGLEPAY)
|
- `recommend`:是否推荐
|
||||||
// - subPaymentMethod: 子支付方式
|
|
||||||
// - name: 显示名称
|
|
||||||
// - icon: 图标 URL
|
|
||||||
// - recommend: 是否推荐
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 创建订单
|
### 5.2 创建订单
|
||||||
|
|
||||||
```dart
|
创建订单请求通常包含(**逻辑字段名**):
|
||||||
final res = await PaymentApi.createPayment(
|
|
||||||
app: 'HAndroid', // HIOS / HAndroid
|
|
||||||
userId: '用户ID',
|
|
||||||
activityId: '活动ID',
|
|
||||||
paymentMethod: '支付方式',
|
|
||||||
paymentType: '支付子类型', // 可选
|
|
||||||
);
|
|
||||||
|
|
||||||
if (res.isSuccess) {
|
- 应用标识(`app`)
|
||||||
final order = res.data!;
|
- 用户标识(`userId`)
|
||||||
final orderId = order.orderId; // 订单 ID
|
- 商品或活动标识(`activityId` / `productId`)
|
||||||
final payUrl = order.payUrl; // 支付链接
|
- 支付主方式(`paymentMethod`)
|
||||||
}
|
- 支付类型(`paymentType`,按渠道语义填写)
|
||||||
```
|
- 子支付方式(`subPaymentMethod`,如卡种/钱包子通道)
|
||||||
|
|
||||||
### 5.3 支付分支
|
> 关键点:`subPaymentMethod` 是独立语义字段。
|
||||||
|
> 仅传 `paymentType` 不会自动补出 `subPaymentMethod`。
|
||||||
|
|
||||||
- **Google Pay**: 调起内购 → 获取 purchaseData/signature → 调用 `googlepay` 回调
|
#### 5.2.1 推荐入参组合(避免丢子支付方式)
|
||||||
- **其他方式**: 打开 `payUrl` 在外部浏览器
|
|
||||||
|
- 卡支付场景(示例):
|
||||||
|
- `paymentMethod = MIFAPAY`
|
||||||
|
- `paymentType = MIFAPAY`(或按后端约定值)
|
||||||
|
- `subPaymentMethod = CreditCard`
|
||||||
|
- 若传成 `paymentType = CreditCard` 且未传 `subPaymentMethod`,常见结果是:
|
||||||
|
- 有 `paymentMethod`
|
||||||
|
- 有 `paymentType`
|
||||||
|
- **缺少 `subPaymentMethod`**
|
||||||
|
|
||||||
|
响应通常返回:
|
||||||
|
|
||||||
|
- `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) {
|
### 7.2 恢复流程
|
||||||
// result.orderId: Google 订单号
|
|
||||||
// result.payload.purchaseData: 用于 merchant
|
|
||||||
// result.payload.signature: 用于 signature
|
|
||||||
// result.purchaseDetails: PurchaseDetails 对象
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.2 回调验证
|
1. 拉取未完成/未核销的商店订单(历史订单 + 监听流中的待处理订单);
|
||||||
|
2. 对每笔订单尝试找到业务订单映射(如 `federation` / `orderId`);
|
||||||
```dart
|
3. 若可映射:再次走服务端回调核验,成功后完成/消费交易;
|
||||||
final res = await PaymentApi.googlepay(
|
4. 若不可映射:按产品策略处理(仅完成/消费以解除占用,或进入人工排查队列);
|
||||||
signature: result.payload.signature,
|
5. 恢复结束后刷新账户与充值记录。
|
||||||
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; // 是否已加积分
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. 补单机制
|
## 8. API 能力清单(通用命名)
|
||||||
|
|
||||||
### 8.1 触发时机
|
| 能力 | 典型方法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
- 进入充值页时调用 `GooglePlayPurchaseService.runOrderRecovery()`
|
| 获取商品列表 | `GET` | Android/iOS 可分端点 |
|
||||||
|
| 获取支付方式 | `POST`/`GET` | 入参通常含 `activityId` |
|
||||||
### 8.2 补单流程
|
| 创建订单 | `POST` | 返回 `orderId`、`payUrl` 等 |
|
||||||
|
| 平台内购回调 | `POST` | 提交 `purchaseData`、`signature` 等 |
|
||||||
1. 获取未核销订单: `getUnacknowledgedPurchases()`
|
| 订单列表 | `GET` | 历史支付记录 |
|
||||||
- 合并 `queryPastPurchases` 和 `purchaseStream` 的待处理订单
|
| 订单详情 | `GET` | 查询单笔订单状态 |
|
||||||
|
|
||||||
2. 对每笔订单:
|
|
||||||
- 查询本地存储的 `federation` 映射
|
|
||||||
- 若存在 federation: 调用 `googlepay` 回调 → 成功后 consume
|
|
||||||
- 若无 federation: 仅执行 consume 解除「已拥有此内容」
|
|
||||||
|
|
||||||
3. 补单成功后刷新账户
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. API 汇总
|
## 9. 核心字段对照(通用)
|
||||||
|
|
||||||
| 接口 | 方法 | 返回实体 |
|
| 业务含义 | 常见请求字段 | 常见响应字段 |
|
||||||
|------|------|----------|
|
|------|------|------|
|
||||||
| `getGooglePayActivities` | GET | `PaymentProductsResponse` |
|
| 应用标识 | `app` | - |
|
||||||
| `getApplePayActivities` | GET | `PaymentProductsResponse` |
|
| 用户标识 | `userId` | - |
|
||||||
| `getPaymentMethods` | POST | `PaymentMethodsResponse` |
|
| 活动/档位 | `activityId` | - |
|
||||||
| `createPayment` | POST | `CreatePaymentResponse` |
|
| 支付方式 | `paymentMethod` | - |
|
||||||
| `getPaymentDetailList` | GET | `PaymentOrderListResponse` |
|
| 子支付方式 | `paymentType` | - |
|
||||||
| `getOrderDetail` | GET | `OrderDetailResponse` |
|
| 订单标识 | `orderId` / `id` | `orderId` |
|
||||||
| `googlepay` | POST | `GooglePayCallbackResponse` |
|
| 购买签名 | `signature` | - |
|
||||||
|
| 购买数据 | `purchaseData` | - |
|
||||||
|
| 商品 ID | - | `productId` |
|
||||||
|
| 实付金额 | - | `actualAmount` |
|
||||||
|
| 原价 | - | `originAmount` |
|
||||||
|
| 赠送积分 | - | `bonus` / `bonusCredits` |
|
||||||
|
| 支付链接 | - | `payUrl` |
|
||||||
|
| 订单状态 | - | `status` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. 字段映射说明
|
## 10. 常见问题
|
||||||
|
|
||||||
框架自动完成字段映射,调用层使用原始字段名。
|
### 10.1 商品未找到
|
||||||
|
|
||||||
### 请求 → 响应 字段对照
|
- **现象**:发起内购时提示商品不存在;
|
||||||
|
- **常见原因**:客户端 `productId` 与商店后台配置不一致,或商品未上架到当前测试轨道;
|
||||||
|
- **排查建议**:核对包名/应用 ID、商品 ID、地区、测试账号与轨道配置。
|
||||||
|
|
||||||
| 业务含义 | 请求字段 | 响应字段 |
|
### 10.2 回调成功但积分未更新
|
||||||
|----------|----------|----------|
|
|
||||||
| 应用 ID | app | - |
|
- **现象**:支付侧显示成功,账户余额未变;
|
||||||
| 用户 ID | userId | - |
|
- **常见原因**:回调后未刷新账户、订单状态未落库、幂等冲突;
|
||||||
| 活动 ID | activityId | - |
|
- **排查建议**:检查回调响应、订单状态接口与账户刷新链路。
|
||||||
| 支付方式 | paymentMethod | - |
|
|
||||||
| 支付子类型 | paymentType | - |
|
### 10.3 重复购买被拦截(已拥有此内容)
|
||||||
| 订单 / 支付 ID(详情 query) | `id`(Dart 仍用参数名 orderId) | orderId |
|
|
||||||
| 购买签名 | signature | - |
|
- **现象**:商店提示已拥有,无法再次购买;
|
||||||
| 购买数据 | purchaseData | - |
|
- **常见原因**:上一笔交易未完成/未消费;
|
||||||
| 商品 ID | - | productId |
|
- **排查建议**:执行补单恢复流程并确保完成/消费逻辑成功。
|
||||||
| 实际金额 | - | actualAmount |
|
|
||||||
| 原价 | - | originAmount |
|
### 10.4 创建订单请求缺少子支付方式字段
|
||||||
| 赠送积分 | - | bonus |
|
|
||||||
| 支付链接 | - | payUrl |
|
- **现象**:抓包中 `createPayment` 入参有 `paymentMethod`,但缺少 `subPaymentMethod`;
|
||||||
| 订单状态 | - | status |
|
- **常见原因**:只传了 `paymentType`,没有传 `subPaymentMethod`;
|
||||||
|
- **排查建议**:
|
||||||
|
- 检查应用侧建单调用是否显式传入 `subPaymentMethod`;
|
||||||
|
- 对照请求日志逐项核对三元组:`paymentMethod` / `paymentType` / `subPaymentMethod`;
|
||||||
|
- 若项目启用了字段映射,再额外确认映射配置与服务端契约一致。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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 秒
|
|
||||||
|
|||||||
@ -49,7 +49,7 @@ sequenceDiagram
|
|||||||
### 3.1 启动与快速登录
|
### 3.1 启动与快速登录
|
||||||
|
|
||||||
- 延迟与重试策略由 `FrameworkAuthService.start` 控制(默认启动延迟、登录重试等)。
|
- 延迟与重试策略由 `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` 说明)。
|
- 成功后框架将返回的 `userToken` 写入 **`ApiClient.instance.setUserToken`**,此后代理请求自动附带 `pkg` 与 `User_token`(见 `ProxyClient` 行为与 `UserApi` 说明)。
|
||||||
|
|
||||||
### 3.2 归因上报(与首页数据并行准备)
|
### 3.2 归因上报(与首页数据并行准备)
|
||||||
|
|||||||
@ -32,12 +32,14 @@ export 'src/services/analytics_attribution_callbacks.dart';
|
|||||||
export 'src/services/analytics_events.dart';
|
export 'src/services/analytics_events.dart';
|
||||||
export 'src/services/analytics_service.dart';
|
export 'src/services/analytics_service.dart';
|
||||||
export 'src/services/auth_service.dart';
|
export 'src/services/auth_service.dart';
|
||||||
|
export 'src/services/login_identity_cache.dart';
|
||||||
export 'src/services/facebook_service.dart';
|
export 'src/services/facebook_service.dart';
|
||||||
export 'src/services/feedback_api.dart';
|
export 'src/services/feedback_api.dart';
|
||||||
export 'src/services/image_api.dart';
|
export 'src/services/image_api.dart';
|
||||||
export 'src/services/image_progress_poll.dart';
|
export 'src/services/image_progress_poll.dart';
|
||||||
export 'src/services/image_compress.dart';
|
export 'src/services/image_compress.dart';
|
||||||
export 'src/services/image_presigned_upload_create_flow.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/image_task_history.dart';
|
||||||
export 'src/services/task_upload_cover_store.dart';
|
export 'src/services/task_upload_cover_store.dart';
|
||||||
export 'src/services/user_account_refresh.dart';
|
export 'src/services/user_account_refresh.dart';
|
||||||
|
|||||||
@ -104,11 +104,11 @@ class ProxyClient {
|
|||||||
/// 响应 data 会自动从线网转回逻辑字段名。
|
/// 响应 data 会自动从线网转回逻辑字段名。
|
||||||
///
|
///
|
||||||
/// **请求头(自动注入)**
|
/// **请求头(自动注入)**
|
||||||
/// - [AppConfig.packageName] → 逻辑字段名 `pkg`(再映射为线网请求头键)
|
/// - 若 [includePackageInHeader] 为 true(默认):[AppConfig.packageName] → 逻辑字段名 `pkg`(再映射为线网请求头键)
|
||||||
/// - 若已 [userToken] 且 [includeUserTokenInHeader] 为 true,则注入 `User_token`
|
/// - 若已 [userToken] 且 [includeUserTokenInHeader] 为 true,则注入 `User_token`
|
||||||
///
|
///
|
||||||
/// 与文档一致:**设备快速登录等无需登录态接口**应传 `includeUserTokenInHeader: false`,
|
/// 与文档一致:**设备快速登录等无需登录态接口**应传 `includeUserTokenInHeader: false`,
|
||||||
/// 避免历史 token 进入 `filter_type`。
|
/// 避免历史 token 进入 `filter_type`。若同时 `includePackageInHeader: false`,则内层 headers 不注入 `pkg`(由 query 等自行携带包名)。
|
||||||
Future<ApiResponse> request({
|
Future<ApiResponse> request({
|
||||||
required String path,
|
required String path,
|
||||||
required String method,
|
required String method,
|
||||||
@ -116,12 +116,13 @@ class ProxyClient {
|
|||||||
Map<String, String>? queryParams,
|
Map<String, String>? queryParams,
|
||||||
Map<String, dynamic>? body,
|
Map<String, dynamic>? body,
|
||||||
bool includeUserTokenInHeader = true,
|
bool includeUserTokenInHeader = true,
|
||||||
|
bool includePackageInHeader = true,
|
||||||
}) async {
|
}) async {
|
||||||
final pk = config.proxyKeys;
|
final pk = config.proxyKeys;
|
||||||
final mapping = config.fieldMapping;
|
final mapping = config.fieldMapping;
|
||||||
|
|
||||||
var headersMap = Map<String, dynamic>.from(headers ?? {});
|
var headersMap = Map<String, dynamic>.from(headers ?? {});
|
||||||
if (config.packageName.isNotEmpty) {
|
if (includePackageInHeader && config.packageName.isNotEmpty) {
|
||||||
headersMap[mapping.headerPackageNameField] = config.packageName;
|
headersMap[mapping.headerPackageNameField] = config.packageName;
|
||||||
}
|
}
|
||||||
if (includeUserTokenInHeader &&
|
if (includeUserTokenInHeader &&
|
||||||
@ -179,7 +180,7 @@ class ProxyClient {
|
|||||||
/// [headers]、[queryParams]、[body] 使用**业务逻辑字段名**。
|
/// [headers]、[queryParams]、[body] 使用**业务逻辑字段名**。
|
||||||
/// [entityFactory] 用于将映射后的 data 转换为实体对象。
|
/// [entityFactory] 用于将映射后的 data 转换为实体对象。
|
||||||
///
|
///
|
||||||
/// 参见 [request] 的 [includeUserTokenInHeader] 说明。
|
/// 参见 [request] 的 [includeUserTokenInHeader]、[includePackageInHeader] 说明。
|
||||||
Future<EntityResponse<T>> requestEntity<T extends Entity>({
|
Future<EntityResponse<T>> requestEntity<T extends Entity>({
|
||||||
required String path,
|
required String path,
|
||||||
required String method,
|
required String method,
|
||||||
@ -188,6 +189,7 @@ class ProxyClient {
|
|||||||
Map<String, String>? queryParams,
|
Map<String, String>? queryParams,
|
||||||
Map<String, dynamic>? body,
|
Map<String, dynamic>? body,
|
||||||
bool includeUserTokenInHeader = true,
|
bool includeUserTokenInHeader = true,
|
||||||
|
bool includePackageInHeader = true,
|
||||||
}) async {
|
}) async {
|
||||||
final response = await request(
|
final response = await request(
|
||||||
path: path,
|
path: path,
|
||||||
@ -196,6 +198,7 @@ class ProxyClient {
|
|||||||
queryParams: queryParams,
|
queryParams: queryParams,
|
||||||
body: body,
|
body: body,
|
||||||
includeUserTokenInHeader: includeUserTokenInHeader,
|
includeUserTokenInHeader: includeUserTokenInHeader,
|
||||||
|
includePackageInHeader: includePackageInHeader,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.isSuccess) {
|
if (response.isSuccess) {
|
||||||
|
|||||||
@ -8,6 +8,7 @@ class FeedbackUploadPresignedUrlResponse extends Entity {
|
|||||||
this.uploadUrl,
|
this.uploadUrl,
|
||||||
this.filePath,
|
this.filePath,
|
||||||
this.putHeaders,
|
this.putHeaders,
|
||||||
|
this.expectedSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String? uploadUrl;
|
final String? uploadUrl;
|
||||||
@ -16,6 +17,9 @@ class FeedbackUploadPresignedUrlResponse extends Entity {
|
|||||||
/// 与 [UploadPresignedUrlResponse.putHeaders] 一致:PUT 到对象存储时的额外头。
|
/// 与 [UploadPresignedUrlResponse.putHeaders] 一致:PUT 到对象存储时的额外头。
|
||||||
final Map<String, String>? putHeaders;
|
final Map<String, String>? putHeaders;
|
||||||
|
|
||||||
|
/// 服务端返回的上传大小上限(字节),逻辑字段名 `expectedSize`;未下发时为 `null`。
|
||||||
|
final int? expectedSize;
|
||||||
|
|
||||||
static String _headerValueToString(dynamic v) {
|
static String _headerValueToString(dynamic v) {
|
||||||
if (v == null) return '';
|
if (v == null) return '';
|
||||||
if (v is String) return v;
|
if (v is String) return v;
|
||||||
@ -57,6 +61,14 @@ class FeedbackUploadPresignedUrlResponse extends Entity {
|
|||||||
return out.isEmpty ? null : out;
|
return out.isEmpty ? null : out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int? _readIntField(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().trim());
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
factory FeedbackUploadPresignedUrlResponse.fromJson(
|
factory FeedbackUploadPresignedUrlResponse.fromJson(
|
||||||
Map<String, dynamic> json) {
|
Map<String, dynamic> json) {
|
||||||
@ -64,6 +76,9 @@ class FeedbackUploadPresignedUrlResponse extends Entity {
|
|||||||
uploadUrl: json['uploadUrl'] as String?,
|
uploadUrl: json['uploadUrl'] as String?,
|
||||||
filePath: json['filePath'] as String?,
|
filePath: json['filePath'] as String?,
|
||||||
putHeaders: _parsePutHeaders(json),
|
putHeaders: _parsePutHeaders(json),
|
||||||
|
expectedSize: _readIntField(json, 'expectedSize') ??
|
||||||
|
_readIntField(json, 'maxFileSize') ??
|
||||||
|
_readIntField(json, 'maxSize'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,6 +87,7 @@ class FeedbackUploadPresignedUrlResponse extends Entity {
|
|||||||
'uploadUrl': uploadUrl,
|
'uploadUrl': uploadUrl,
|
||||||
'filePath': filePath,
|
'filePath': filePath,
|
||||||
if (putHeaders != null) 'putHeaders': putHeaders,
|
if (putHeaders != null) 'putHeaders': putHeaders,
|
||||||
|
if (expectedSize != null) 'expectedSize': expectedSize,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -405,6 +405,7 @@ class UploadPresignedUrlResponse extends Entity {
|
|||||||
this.uploadUrl,
|
this.uploadUrl,
|
||||||
this.filePath,
|
this.filePath,
|
||||||
this.putHeaders,
|
this.putHeaders,
|
||||||
|
this.expectedSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String? uploadUrl;
|
final String? uploadUrl;
|
||||||
@ -413,6 +414,9 @@ class UploadPresignedUrlResponse extends Entity {
|
|||||||
/// 上传到对象存储时额外请求头(如服务端返回的签名头;解密后为 business 字段名)。
|
/// 上传到对象存储时额外请求头(如服务端返回的签名头;解密后为 business 字段名)。
|
||||||
final Map<String, String>? putHeaders;
|
final Map<String, String>? putHeaders;
|
||||||
|
|
||||||
|
/// 服务端返回的单次上传大小上限(字节),逻辑字段名 `expectedSize`;未下发时为 `null`。
|
||||||
|
final int? expectedSize;
|
||||||
|
|
||||||
/// 将任意 JSON 头值压成 [http] 要求的 [String](避免 `TypeError`)。
|
/// 将任意 JSON 头值压成 [http] 要求的 [String](避免 `TypeError`)。
|
||||||
static String _headerValueToString(dynamic v) {
|
static String _headerValueToString(dynamic v) {
|
||||||
if (v == null) return '';
|
if (v == null) return '';
|
||||||
@ -462,6 +466,14 @@ class UploadPresignedUrlResponse extends Entity {
|
|||||||
return v.toString();
|
return v.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int? _readIntField(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().trim());
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
factory UploadPresignedUrlResponse.fromJson(Map<String, dynamic> json) {
|
factory UploadPresignedUrlResponse.fromJson(Map<String, dynamic> json) {
|
||||||
// FunyMee 等换皮:`uploadUrl1`/`filePath1`(文档 wire:harden / generate)。
|
// FunyMee 等换皮:`uploadUrl1`/`filePath1`(文档 wire:harden / generate)。
|
||||||
@ -476,6 +488,9 @@ class UploadPresignedUrlResponse extends Entity {
|
|||||||
uploadUrl: upload,
|
uploadUrl: upload,
|
||||||
filePath: path,
|
filePath: path,
|
||||||
putHeaders: _parsePutHeaders(json),
|
putHeaders: _parsePutHeaders(json),
|
||||||
|
expectedSize: _readIntField(json, 'expectedSize') ??
|
||||||
|
_readIntField(json, 'maxFileSize') ??
|
||||||
|
_readIntField(json, 'maxSize'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -484,6 +499,7 @@ class UploadPresignedUrlResponse extends Entity {
|
|||||||
'uploadUrl': uploadUrl,
|
'uploadUrl': uploadUrl,
|
||||||
'filePath': filePath,
|
'filePath': filePath,
|
||||||
if (putHeaders != null) 'putHeaders': putHeaders,
|
if (putHeaders != null) 'putHeaders': putHeaders,
|
||||||
|
if (expectedSize != null) 'expectedSize': expectedSize,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -512,6 +528,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<String, dynamic> json) {
|
||||||
|
return DeleteTaskResponse(
|
||||||
|
hint: json['hint'] as String?,
|
||||||
|
type: json['type'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'hint': hint,
|
||||||
|
'type': type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// 我的任务项
|
/// 我的任务项
|
||||||
///
|
///
|
||||||
/// 与 FunyMee 文档一致:`state`(线网 `bitrate`)1–6 为权威任务态;`status` 为兼容字段。
|
/// 与 FunyMee 文档一致:`state`(线网 `bitrate`)1–6 为权威任务态;`status` 为兼容字段。
|
||||||
|
|||||||
@ -17,6 +17,7 @@ class PaymentProductItem extends Entity {
|
|||||||
final String? activityId;
|
final String? activityId;
|
||||||
final String? actualAmount;
|
final String? actualAmount;
|
||||||
final String? originAmount;
|
final String? originAmount;
|
||||||
|
/// 线网 `contrast`→`bonus`:赠送积分数(数量,非百分比);与 [bonusCredits] 二选一或并存由接口决定。
|
||||||
final int? bonus;
|
final int? bonus;
|
||||||
/// 额外赠送积分(换皮线网常为 `saturation` → `bonusCredits`)。
|
/// 额外赠送积分(换皮线网常为 `saturation` → `bonusCredits`)。
|
||||||
final int? bonusCredits;
|
final int? bonusCredits;
|
||||||
@ -36,12 +37,27 @@ class PaymentProductItem extends Entity {
|
|||||||
actualAmount: _stringField(json, 'actualAmount'),
|
actualAmount: _stringField(json, 'actualAmount'),
|
||||||
originAmount: _stringField(json, 'originAmount'),
|
originAmount: _stringField(json, 'originAmount'),
|
||||||
bonus: _intField(json, 'bonus'),
|
bonus: _intField(json, 'bonus'),
|
||||||
bonusCredits: _intField(json, 'bonusCredits'),
|
bonusCredits: _parseBonusCredits(json),
|
||||||
title: _stringField(json, 'title'),
|
title: _stringField(json, 'title'),
|
||||||
credits: _intField(json, 'credits') ?? _intField(json, 'padding'),
|
credits: _intField(json, 'credits') ?? _intField(json, 'padding'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `saturation`→`bonusCredits`、`remix`→`addCredits`,或仅有总额时用 `credits`−`baseCredits` 推断**额外**赠送。
|
||||||
|
///
|
||||||
|
/// 不可再回退到 [json] 的 `bonus`(线网 `contrast`):该字段已由 [PaymentProductItem.bonus] 单独解析,
|
||||||
|
/// 否则 UI 会把 `bonus + bonusCredits` 加成两倍。
|
||||||
|
static int? _parseBonusCredits(Map<String, dynamic> 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 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<String, dynamic> json, String key) {
|
static String? _stringField(Map<String, dynamic> json, String key) {
|
||||||
final v = json[key];
|
final v = json[key];
|
||||||
if (v == null) return null;
|
if (v == null) return null;
|
||||||
@ -140,12 +156,12 @@ class PaymentMethodItem extends Entity {
|
|||||||
String? get bonusLabel {
|
String? get bonusLabel {
|
||||||
final bc = bonusCredits;
|
final bc = bonusCredits;
|
||||||
if (bc != null && bc > 0) {
|
if (bc != null && bc > 0) {
|
||||||
return '+$bc bonus credits';
|
return '+$bc More Credits';
|
||||||
}
|
}
|
||||||
final br = bonusRatio;
|
final br = bonusRatio;
|
||||||
if (br != null && br > 0) {
|
if (br != null && br > 0) {
|
||||||
final pct = br <= 1 ? (br * 100).round() : br.round();
|
final pct = br <= 1 ? (br * 100).round() : br.round();
|
||||||
return '+$pct% bonus credits';
|
return '+$pct% More Credits';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,8 +11,14 @@ import '../config/video_home_runtime.dart';
|
|||||||
import '../entities/user_entities.dart';
|
import '../entities/user_entities.dart';
|
||||||
import 'adjust_service.dart';
|
import 'adjust_service.dart';
|
||||||
import 'analytics_attribution_callbacks.dart';
|
import 'analytics_attribution_callbacks.dart';
|
||||||
|
import 'facebook_service.dart';
|
||||||
|
import 'login_identity_cache.dart';
|
||||||
import 'user_api.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 {
|
abstract class AuthServiceCallbacks {
|
||||||
@ -37,6 +43,14 @@ abstract class AuthServiceCallbacks {
|
|||||||
/// 1. 快速登录
|
/// 1. 快速登录
|
||||||
/// 2. 归因上报
|
/// 2. 归因上报
|
||||||
/// 3. 获取通用信息
|
/// 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 {
|
abstract class FrameworkAuthService {
|
||||||
static AuthServiceCallbacks? _callbacks;
|
static AuthServiceCallbacks? _callbacks;
|
||||||
static Future<void>? _loginFuture;
|
static Future<void>? _loginFuture;
|
||||||
@ -45,6 +59,11 @@ abstract class FrameworkAuthService {
|
|||||||
/// 登录是否已完成
|
/// 登录是否已完成
|
||||||
static final ValueNotifier<bool> isLoginComplete = ValueNotifier(false);
|
static final ValueNotifier<bool> isLoginComplete = ValueNotifier(false);
|
||||||
|
|
||||||
|
/// 最近一次快速登录成功时的 [FastLoginResponse.userId](失败或未登录为空)。
|
||||||
|
///
|
||||||
|
/// 宿主在调用需 `userId` 的接口(如生图 `create-task`)前可读取;若为空应提示用户稍后重试。
|
||||||
|
static String? lastLoggedInUserId;
|
||||||
|
|
||||||
/// 登录完成后的 Future,需鉴权接口应 await 此 Future 再请求
|
/// 登录完成后的 Future,需鉴权接口应 await 此 Future 再请求
|
||||||
static Future<void> get loginComplete => _loginFuture ?? Future<void>.value();
|
static Future<void> get loginComplete => _loginFuture ?? Future<void>.value();
|
||||||
|
|
||||||
@ -86,10 +105,14 @@ abstract class FrameworkAuthService {
|
|||||||
debugPrint('[AuthService] start: 开始登录流程');
|
debugPrint('[AuthService] start: 开始登录流程');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 供 [catch] 上报 Facebook 使用:若在 [getDeviceId] 成功前抛错则为空串。
|
||||||
|
var deviceIdForFacebookFailure = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Future<void>.delayed(Duration(seconds: delaySeconds));
|
await Future<void>.delayed(Duration(seconds: delaySeconds));
|
||||||
|
|
||||||
final deviceId = await _callbacks!.getDeviceId();
|
final deviceId = await _callbacks!.getDeviceId();
|
||||||
|
deviceIdForFacebookFailure = deviceId;
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('[AuthService] start: deviceId=$deviceId');
|
debugPrint('[AuthService] start: deviceId=$deviceId');
|
||||||
}
|
}
|
||||||
@ -99,26 +122,17 @@ abstract class FrameworkAuthService {
|
|||||||
debugPrint('[AuthService] start: sign=$sign');
|
debugPrint('[AuthService] start: sign=$sign');
|
||||||
}
|
}
|
||||||
|
|
||||||
final referer = await AttributionService.getReferrer();
|
// fast_login:强制使用 Google 归因类型 `gg`;referer 优先 Play Install Referrer,无则自然安装 UTM 兜底。
|
||||||
if (kDebugMode && referer != null) {
|
final playReferrer = AdjustService.cachedPlayReferrer;
|
||||||
debugPrint('[AuthService] start: referer=$referer');
|
final fastLoginReferer = (playReferrer != null && playReferrer.isNotEmpty)
|
||||||
}
|
? playReferrer
|
||||||
|
: _fastLoginPlayReferrerFallback;
|
||||||
// 确定归因类型
|
const fastLoginType = 'gg';
|
||||||
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';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('[AuthService] start: referrerType=$referrerType');
|
debugPrint(
|
||||||
|
'[AuthService] start: fast_login type=$fastLoginType refererLen=${fastLoginReferer.length}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 尝试快速登录
|
// 尝试快速登录
|
||||||
@ -138,9 +152,9 @@ abstract class FrameworkAuthService {
|
|||||||
res = await UserApi.fastLogin(
|
res = await UserApi.fastLogin(
|
||||||
deviceId: deviceId,
|
deviceId: deviceId,
|
||||||
sign: sign,
|
sign: sign,
|
||||||
referer: referer ?? '',
|
referer: fastLoginReferer,
|
||||||
app: appType,
|
app: appType,
|
||||||
type: referrerType,
|
type: fastLoginType,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -152,8 +166,15 @@ abstract class FrameworkAuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (res == null) {
|
if (res == null) {
|
||||||
|
lastLoggedInUserId = null;
|
||||||
VideoHomeRuntime.reset();
|
VideoHomeRuntime.reset();
|
||||||
ExtConfigRuntime.applyCommonInfoFailure();
|
ExtConfigRuntime.applyCommonInfoFailure();
|
||||||
|
_logFacebookLoginFailed(
|
||||||
|
serverCode: -1,
|
||||||
|
missingUserId: true,
|
||||||
|
userIdFromServer: null,
|
||||||
|
deviceId: deviceId,
|
||||||
|
);
|
||||||
completer.complete();
|
completer.complete();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -164,6 +185,8 @@ abstract class FrameworkAuthService {
|
|||||||
|
|
||||||
if (res.isSuccess && res.data != null) {
|
if (res.isSuccess && res.data != null) {
|
||||||
final loginData = res.data!;
|
final loginData = res.data!;
|
||||||
|
final uid = loginData.userId?.trim();
|
||||||
|
lastLoggedInUserId = uid != null && uid.isNotEmpty ? uid : null;
|
||||||
|
|
||||||
// 设置 Token
|
// 设置 Token
|
||||||
if (loginData.userToken != null && loginData.userToken!.isNotEmpty) {
|
if (loginData.userToken != null && loginData.userToken!.isNotEmpty) {
|
||||||
@ -173,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);
|
_callbacks!.onLoginSuccess(loginData);
|
||||||
|
|
||||||
@ -182,16 +221,25 @@ abstract class FrameworkAuthService {
|
|||||||
deviceId: deviceId,
|
deviceId: deviceId,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
lastLoggedInUserId = null;
|
||||||
VideoHomeRuntime.reset();
|
VideoHomeRuntime.reset();
|
||||||
ExtConfigRuntime.applyCommonInfoFailure();
|
ExtConfigRuntime.applyCommonInfoFailure();
|
||||||
|
_logFacebookLoginFailedFromResponse(res, deviceId: deviceId);
|
||||||
_callbacks!.onLoginFailed(res.msg);
|
_callbacks!.onLoginFailed(res.msg);
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
|
lastLoggedInUserId = null;
|
||||||
VideoHomeRuntime.reset();
|
VideoHomeRuntime.reset();
|
||||||
ExtConfigRuntime.applyCommonInfoFailure();
|
ExtConfigRuntime.applyCommonInfoFailure();
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('[AuthService] start: 异常 $e\n$st');
|
debugPrint('[AuthService] start: 异常 $e\n$st');
|
||||||
}
|
}
|
||||||
|
_logFacebookLoginFailed(
|
||||||
|
serverCode: -1,
|
||||||
|
missingUserId: true,
|
||||||
|
userIdFromServer: null,
|
||||||
|
deviceId: deviceIdForFacebookFailure,
|
||||||
|
);
|
||||||
_callbacks!.onLoginFailed(e.toString());
|
_callbacks!.onLoginFailed(e.toString());
|
||||||
} finally {
|
} finally {
|
||||||
if (!completer.isCompleted) {
|
if (!completer.isCompleted) {
|
||||||
@ -223,8 +271,14 @@ abstract class FrameworkAuthService {
|
|||||||
: config.backendAppTypeAndroid;
|
: config.backendAppTypeAndroid;
|
||||||
|
|
||||||
// 上报 Adjust 归因
|
// 上报 Adjust 归因
|
||||||
|
var adjustReferrerTried = false;
|
||||||
|
var adjustReferrerOk = false;
|
||||||
|
var adjustReferrerCode = 0;
|
||||||
|
var adjustReferrerMsg = '';
|
||||||
|
|
||||||
final adjustReferer = await AttributionService.getAdjustReferrer();
|
final adjustReferer = await AttributionService.getAdjustReferrer();
|
||||||
if (adjustReferer != null && adjustReferer.isNotEmpty) {
|
if (adjustReferer != null && adjustReferer.isNotEmpty) {
|
||||||
|
adjustReferrerTried = true;
|
||||||
final adjustType = defaultTargetPlatform == TargetPlatform.iOS
|
final adjustType = defaultTargetPlatform == TargetPlatform.iOS
|
||||||
? 'ios_adjust'
|
? 'ios_adjust'
|
||||||
: 'android_adjust';
|
: 'android_adjust';
|
||||||
@ -236,11 +290,19 @@ abstract class FrameworkAuthService {
|
|||||||
deviceId: deviceId,
|
deviceId: deviceId,
|
||||||
type: adjustType,
|
type: adjustType,
|
||||||
);
|
);
|
||||||
|
if (rAdjust.isSuccess) {
|
||||||
|
adjustReferrerOk = true;
|
||||||
|
} else {
|
||||||
|
adjustReferrerCode = rAdjust.code;
|
||||||
|
adjustReferrerMsg = rAdjust.msg;
|
||||||
|
}
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'[AuthService] referrer($adjustType): ${rAdjust.isSuccess ? "成功" : "失败"}');
|
'[AuthService] referrer($adjustType): ${rAdjust.isSuccess ? "成功" : "失败"}');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
adjustReferrerCode = -110;
|
||||||
|
adjustReferrerMsg = e.toString();
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('[AuthService] referrer($adjustType): 异常 $e');
|
debugPrint('[AuthService] referrer($adjustType): 异常 $e');
|
||||||
}
|
}
|
||||||
@ -248,8 +310,14 @@ abstract class FrameworkAuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 上报 Google Play 归因(从 AdjustService 获取缓存的 referrer)
|
// 上报 Google Play 归因(从 AdjustService 获取缓存的 referrer)
|
||||||
|
var ggReferrerTried = false;
|
||||||
|
var ggReferrerOk = false;
|
||||||
|
var ggReferrerCode = 0;
|
||||||
|
var ggReferrerMsg = '';
|
||||||
|
|
||||||
final playReferrer = AdjustService.cachedPlayReferrer;
|
final playReferrer = AdjustService.cachedPlayReferrer;
|
||||||
if (playReferrer != null && playReferrer.isNotEmpty) {
|
if (playReferrer != null && playReferrer.isNotEmpty) {
|
||||||
|
ggReferrerTried = true;
|
||||||
try {
|
try {
|
||||||
final rGg = await UserApi.referrer(
|
final rGg = await UserApi.referrer(
|
||||||
app: backendApp,
|
app: backendApp,
|
||||||
@ -258,17 +326,39 @@ abstract class FrameworkAuthService {
|
|||||||
deviceId: deviceId,
|
deviceId: deviceId,
|
||||||
type: 'gg',
|
type: 'gg',
|
||||||
);
|
);
|
||||||
|
if (rGg.isSuccess) {
|
||||||
|
ggReferrerOk = true;
|
||||||
|
} else {
|
||||||
|
ggReferrerCode = rGg.code;
|
||||||
|
ggReferrerMsg = rGg.msg;
|
||||||
|
}
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'[AuthService] referrer(gg): ${rGg.isSuccess ? "成功" : "失败"}');
|
'[AuthService] referrer(gg): ${rGg.isSuccess ? "成功" : "失败"}');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
ggReferrerCode = -110;
|
||||||
|
ggReferrerMsg = e.toString();
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('[AuthService] referrer(gg): 异常 $e');
|
debugPrint('[AuthService] referrer(gg): 异常 $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (adjustReferrerTried &&
|
||||||
|
ggReferrerTried &&
|
||||||
|
!adjustReferrerOk &&
|
||||||
|
!ggReferrerOk) {
|
||||||
|
_logFacebookReferrerBothFailed(
|
||||||
|
userId: uid,
|
||||||
|
deviceId: deviceId,
|
||||||
|
adjustCode: adjustReferrerCode,
|
||||||
|
adjustMsg: adjustReferrerMsg,
|
||||||
|
ggCode: ggReferrerCode,
|
||||||
|
ggMsg: ggReferrerMsg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 获取通用信息
|
// 获取通用信息
|
||||||
try {
|
try {
|
||||||
final commonRes = await UserApi.getCommonInfo(
|
final commonRes = await UserApi.getCommonInfo(
|
||||||
@ -278,8 +368,11 @@ abstract class FrameworkAuthService {
|
|||||||
deviceId: deviceId,
|
deviceId: deviceId,
|
||||||
);
|
);
|
||||||
if (commonRes.isSuccess && commonRes.data != null) {
|
if (commonRes.isSuccess && commonRes.data != null) {
|
||||||
ExtConfigRuntime.applyCommonInfoSuccess(commonRes.data!);
|
final info = commonRes.data!;
|
||||||
_callbacks?.onCommonInfoLoaded(commonRes.data!);
|
final extRaw = info.extConfig?.trim();
|
||||||
|
final extConfigMissing = extRaw == null || extRaw.isEmpty;
|
||||||
|
ExtConfigRuntime.applyCommonInfoSuccess(info);
|
||||||
|
_callbacks?.onCommonInfoLoaded(info);
|
||||||
unawaited(
|
unawaited(
|
||||||
VideoHomeRuntime.hydrateAfterCommonInfo(
|
VideoHomeRuntime.hydrateAfterCommonInfo(
|
||||||
userId: uid,
|
userId: uid,
|
||||||
@ -289,9 +382,23 @@ abstract class FrameworkAuthService {
|
|||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('[AuthService] common_info: 获取成功');
|
debugPrint('[AuthService] common_info: 获取成功');
|
||||||
}
|
}
|
||||||
|
if (extConfigMissing) {
|
||||||
|
_logFacebookExtConfigFailed(
|
||||||
|
userId: uid,
|
||||||
|
deviceId: deviceId,
|
||||||
|
serverCode: 0,
|
||||||
|
serverMsg: 'ext_config_missing',
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
VideoHomeRuntime.reset();
|
VideoHomeRuntime.reset();
|
||||||
ExtConfigRuntime.applyCommonInfoFailure();
|
ExtConfigRuntime.applyCommonInfoFailure();
|
||||||
|
_logFacebookExtConfigFailed(
|
||||||
|
userId: uid,
|
||||||
|
deviceId: deviceId,
|
||||||
|
serverCode: commonRes.code,
|
||||||
|
serverMsg: commonRes.msg,
|
||||||
|
);
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'[AuthService] common_info: 失败 code=${commonRes.code} msg=${commonRes.msg}');
|
'[AuthService] common_info: 失败 code=${commonRes.code} msg=${commonRes.msg}');
|
||||||
@ -300,6 +407,12 @@ abstract class FrameworkAuthService {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
VideoHomeRuntime.reset();
|
VideoHomeRuntime.reset();
|
||||||
ExtConfigRuntime.applyCommonInfoFailure();
|
ExtConfigRuntime.applyCommonInfoFailure();
|
||||||
|
_logFacebookExtConfigFailed(
|
||||||
|
userId: uid,
|
||||||
|
deviceId: deviceId,
|
||||||
|
serverCode: -1,
|
||||||
|
serverMsg: e.toString(),
|
||||||
|
);
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('[AuthService] common_info: 异常 $e');
|
debugPrint('[AuthService] common_info: 异常 $e');
|
||||||
}
|
}
|
||||||
@ -310,4 +423,108 @@ abstract class FrameworkAuthService {
|
|||||||
static Map<String, dynamic>? parseExtConfig(String? extConfigStr) {
|
static Map<String, dynamic>? parseExtConfig(String? extConfigStr) {
|
||||||
return ExtConfigData.parseRawMap(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 = <String>[
|
||||||
|
if (adjustMsg.trim().isNotEmpty)
|
||||||
|
'adjust: ${_truncateForFacebookParam(adjustMsg)}',
|
||||||
|
if (ggMsg.trim().isNotEmpty) 'gg: ${_truncateForFacebookParam(ggMsg)}',
|
||||||
|
];
|
||||||
|
final combinedMsg = parts.join(' | ');
|
||||||
|
final params = <String, dynamic>{
|
||||||
|
'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 = <String, dynamic>{
|
||||||
|
'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<FastLoginResponse> 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 = <String, dynamic>{
|
||||||
|
'server_error_code': '$serverCode',
|
||||||
|
'user_id': uid,
|
||||||
|
'device_id': did,
|
||||||
|
if (missingUserId) 'register_faild': 'register faild',
|
||||||
|
};
|
||||||
|
FacebookService.logEvent(
|
||||||
|
facebookLoginFailedEventName,
|
||||||
|
parameters: params,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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<EntityResponse<DeleteTaskResponse>> deleteTask({
|
||||||
|
required int taskId,
|
||||||
|
}) async {
|
||||||
|
return _client.requestEntity(
|
||||||
|
path: '/v1/image/delete-task',
|
||||||
|
method: 'POST',
|
||||||
|
entityFactory: DeleteTaskResponse.fromJson,
|
||||||
|
body: {'taskId': taskId},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 获取我的任务列表
|
/// 获取我的任务列表
|
||||||
static Future<EntityResponse<MyTasksResponse>> getMyTasks({
|
static Future<EntityResponse<MyTasksResponse>> getMyTasks({
|
||||||
required String app,
|
required String app,
|
||||||
@ -317,10 +332,12 @@ abstract final class ImageApi {
|
|||||||
/// 获取积分页面信息(与 `UserApi.getCreditsPage` 相同,走 `GET /v1/user/credits-page`)
|
/// 获取积分页面信息(与 `UserApi.getCreditsPage` 相同,走 `GET /v1/user/credits-page`)
|
||||||
///
|
///
|
||||||
/// 保留此方法以兼容旧调用;[app]、[userId]、[ch] 不再参与请求(接口只吃分页参数)。
|
/// 保留此方法以兼容旧调用;[app]、[userId]、[ch] 不再参与请求(接口只吃分页参数)。
|
||||||
|
///
|
||||||
|
/// [type] 可选;不传时不在 query 中带 `type`,由服务端默认处理。
|
||||||
static Future<EntityResponse<CreditsPageInfoResponse>> getCreditsPageInfo({
|
static Future<EntityResponse<CreditsPageInfoResponse>> getCreditsPageInfo({
|
||||||
String page = '1',
|
String page = '1',
|
||||||
String size = '10',
|
String size = '10',
|
||||||
String type = '1',
|
String? type,
|
||||||
String? app,
|
String? app,
|
||||||
String? userId,
|
String? userId,
|
||||||
String? ch,
|
String? ch,
|
||||||
@ -332,7 +349,7 @@ abstract final class ImageApi {
|
|||||||
queryParams: {
|
queryParams: {
|
||||||
'page': page,
|
'page': page,
|
||||||
'size': size,
|
'size': size,
|
||||||
'type': type,
|
if (type != null && type.isNotEmpty) 'type': type,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import '../entities/image_entities.dart';
|
|||||||
import '../log/app_logger.dart';
|
import '../log/app_logger.dart';
|
||||||
import 'image_api.dart';
|
import 'image_api.dart';
|
||||||
import 'image_compress.dart';
|
import 'image_compress.dart';
|
||||||
|
import 'image_upload_expected_size_cache.dart';
|
||||||
import 'task_upload_cover_store.dart';
|
import 'task_upload_cover_store.dart';
|
||||||
|
|
||||||
final _presignedPutLog = AppLogger('PresignedUpload');
|
final _presignedPutLog = AppLogger('PresignedUpload');
|
||||||
@ -99,6 +100,9 @@ abstract final class ImagePresignedUploadCreateTaskFlow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final presigned = presignedRes.data!;
|
final presigned = presignedRes.data!;
|
||||||
|
await ImageUploadExpectedSizeCache.writeImageExpectedSize(
|
||||||
|
presigned.expectedSize,
|
||||||
|
);
|
||||||
final uploadUrl = presigned.uploadUrl;
|
final uploadUrl = presigned.uploadUrl;
|
||||||
final filePath = presigned.filePath;
|
final filePath = presigned.filePath;
|
||||||
if (uploadUrl == null ||
|
if (uploadUrl == null ||
|
||||||
|
|||||||
44
lib/src/services/image_upload_expected_size_cache.dart
Normal file
44
lib/src/services/image_upload_expected_size_cache.dart
Normal file
@ -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<int> readImageMaxBytesForUi() async {
|
||||||
|
final p = await SharedPreferences.getInstance();
|
||||||
|
final v = p.getInt(_kImage);
|
||||||
|
if (v != null && v > 0) return v;
|
||||||
|
return fallbackMaxBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<int> readFeedbackMaxBytesForUi() async {
|
||||||
|
final p = await SharedPreferences.getInstance();
|
||||||
|
final v = p.getInt(_kFeedback);
|
||||||
|
if (v != null && v > 0) return v;
|
||||||
|
return fallbackMaxBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在生图预签名成功后写入;仅当 [bytes] 为正整数时持久化。
|
||||||
|
static Future<void> writeImageExpectedSize(int? bytes) async {
|
||||||
|
if (bytes == null || bytes <= 0) return;
|
||||||
|
final p = await SharedPreferences.getInstance();
|
||||||
|
await p.setInt(_kImage, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在反馈预签名成功后写入;仅当 [bytes] 为正整数时持久化。
|
||||||
|
static Future<void> writeFeedbackExpectedSize(int? bytes) async {
|
||||||
|
if (bytes == null || bytes <= 0) return;
|
||||||
|
final p = await SharedPreferences.getInstance();
|
||||||
|
await p.setInt(_kFeedback, bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
lib/src/services/login_identity_cache.dart
Normal file
43
lib/src/services/login_identity_cache.dart
Normal file
@ -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<String?> 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<String?> 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<void> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,7 +7,7 @@ import '../entities/user_entities.dart';
|
|||||||
/// 用户相关 API(**业务逻辑字段名**,经 [FieldMapping] 映射为线网字段名)
|
/// 用户相关 API(**业务逻辑字段名**,经 [FieldMapping] 映射为线网字段名)
|
||||||
///
|
///
|
||||||
/// **请求头**:除 [UserApi.fastLogin] 外,需登录接口均由 [ProxyClient] 自动附带
|
/// **请求头**:除 [UserApi.fastLogin] 外,需登录接口均由 [ProxyClient] 自动附带
|
||||||
/// `pkg`(包名)与 `User_token`(已设置 token 时)。fast_login 仅带 `pkg`,不带 token。
|
/// `pkg`(包名)与 `User_token`(已设置 token 时)。**fast_login** 保留 `pkg`,但不注入 `User_token`。
|
||||||
///
|
///
|
||||||
/// **请求体**:与《客户端指南》一致,使用**业务逻辑字段名**(如 `referer`、`deviceId`)。
|
/// **请求体**:与《客户端指南》一致,使用**业务逻辑字段名**(如 `referer`、`deviceId`)。
|
||||||
abstract final class UserApi {
|
abstract final class UserApi {
|
||||||
@ -15,8 +15,8 @@ abstract final class UserApi {
|
|||||||
|
|
||||||
/// 设备快速登录
|
/// 设备快速登录
|
||||||
///
|
///
|
||||||
/// **请求头**:仅 `pkg`(无 `User_token`)。
|
/// **请求头**:保留 `pkg`,不注入 `User_token`(见 [ProxyClient.request] 的 `includeUserTokenInHeader`)。
|
||||||
/// **Query**:`app`、`type`(默认 `fb`)、`pkg`、`ch`(可选)。
|
/// **Query**:`app`、`type`(未传时默认 `gg`,Google Play Install Referrer 归因)、`pkg`、`ch`(可选)。
|
||||||
/// **Body**:`referer`、`sign`、`deviceId`。
|
/// **Body**:`referer`、`sign`、`deviceId`。
|
||||||
static Future<EntityResponse<FastLoginResponse>> fastLogin({
|
static Future<EntityResponse<FastLoginResponse>> fastLogin({
|
||||||
required String deviceId,
|
required String deviceId,
|
||||||
@ -34,7 +34,7 @@ abstract final class UserApi {
|
|||||||
queryParams: {
|
queryParams: {
|
||||||
if (ch != null && ch.isNotEmpty) 'ch': ch,
|
if (ch != null && ch.isNotEmpty) 'ch': ch,
|
||||||
'pkg': config.packageName,
|
'pkg': config.packageName,
|
||||||
'type': type ?? 'fb',
|
'type': type ?? 'gg',
|
||||||
'app': app,
|
'app': app,
|
||||||
},
|
},
|
||||||
body: {
|
body: {
|
||||||
@ -141,10 +141,12 @@ abstract final class UserApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 积分分页流水(`GET /v1/user/credits-page`)
|
/// 积分分页流水(`GET /v1/user/credits-page`)
|
||||||
|
///
|
||||||
|
/// [type] 可选;不传时不在 query 中带 `type`,由服务端默认处理。
|
||||||
static Future<EntityResponse<CreditsPageInfoResponse>> getCreditsPage({
|
static Future<EntityResponse<CreditsPageInfoResponse>> getCreditsPage({
|
||||||
String page = '1',
|
String page = '1',
|
||||||
String size = '10',
|
String size = '10',
|
||||||
String type = '1',
|
String? type,
|
||||||
}) async {
|
}) async {
|
||||||
return _client.requestEntity(
|
return _client.requestEntity(
|
||||||
path: '/v1/user/credits-page',
|
path: '/v1/user/credits-page',
|
||||||
@ -153,7 +155,7 @@ abstract final class UserApi {
|
|||||||
queryParams: {
|
queryParams: {
|
||||||
'page': page,
|
'page': page,
|
||||||
'size': size,
|
'size': size,
|
||||||
'type': type,
|
if (type != null && type.isNotEmpty) 'type': type,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user