This commit is contained in:
lanxi 2026-04-28 17:46:55 +08:00
commit c49a12c091
17 changed files with 942 additions and 268 deletions

View File

@ -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', // 可选;未传时默认 ggGoogle Play 归因)
); );
if (res.isSuccess) { if (res.isSuccess) {

View 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` 语义一致。

View 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` 判定稳定。

View 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` 等关键词说明。

View File

@ -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 - 进入充值页时拉取一次;
// Androidapp 默认 backendAppTypeAndroid可传 client - 下拉刷新或切换国家/渠道后重新拉取;
final res = await PaymentApi.getGooglePayActivities( - 支付成功后建议刷新,确保档位与余额展示一致。
country: '国家', // 可选
);
// iOSapp 默认 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 秒

View File

@ -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 归因上报(与首页数据并行准备)

View File

@ -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';

View File

@ -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) {

View File

@ -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,
}; };
} }

View File

@ -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` wireharden / generate // FunyMee `uploadUrl1`/`filePath1` wireharden / 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`16 `status` /// FunyMee `state`线 `bitrate`16 `status`

View File

@ -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;
} }

View File

@ -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,
);
}
} }

View File

@ -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,
}, },
); );
} }

View File

@ -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 ||

View 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);
}
}

View 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);
}
}
}

View File

@ -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,
}, },
); );
} }