feat: add AI retouch task center
This commit is contained in:
@@ -0,0 +1,814 @@
|
||||
# AI 修图任务中心需求与接口设计
|
||||
|
||||
> 文档状态:待产品、后端、Android、iOS 联审
|
||||
> 更新日期:2026-08-14
|
||||
> 适用范围:随心瞰商家版 AI 修图任务,不包含人工修图任务
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
AI 修图属于异步长耗时任务。当前用户提交后只能等待或主动返回相册刷新,无法明确知道任务是否仍在排队、预计何时完成、哪些照片成功或失败。
|
||||
|
||||
本需求增加:
|
||||
|
||||
1. 当前账号全部相册的 AI 修图任务列表。
|
||||
2. 单次 AI 修图任务详情。
|
||||
3. AI 修图终态消息推送。
|
||||
4. 从推送、任务列表和提交成功提示进入对应任务的完整导航链路。
|
||||
|
||||
本期解决“看得到进度、完成会通知、结果可直达”的问题,不增加任务取消、批量重试、历史版本管理或后台供应商诊断能力。
|
||||
|
||||
## 2. 核心产品决策
|
||||
|
||||
### 2.1 推送跳转结论
|
||||
|
||||
AI 修图推送优先跳转到对应任务详情页。
|
||||
|
||||
原因:
|
||||
|
||||
- 用户点击完成通知时,核心意图是确认这一次任务的结果,而不是重新查找任务。
|
||||
- 详情页可以直接表达成功、部分成功和失败,减少一次列表定位操作。
|
||||
- `ai_retouch_batch_id` 是稳定任务标识,可以支撑前台、后台和冷启动直达。
|
||||
|
||||
以下情况降级进入任务列表:
|
||||
|
||||
- 推送缺少 `ai_retouch_batch_id`。
|
||||
- ID 类型异常、为 0 或负数。
|
||||
- 详情接口返回任务不存在、已失效或当前账号无权访问。
|
||||
- 未来客户端收到无法识别的 AI 修图终态数据。
|
||||
|
||||
### 2.2 推送类型
|
||||
|
||||
| `type` | 业务含义 | 本需求处理 |
|
||||
|---:|---|---|
|
||||
| `10` | 人工修图完成通知 | 保持原业务语义和原点击行为,不作复用 |
|
||||
| `14` | AI 修图任务通知 | 新增,按 `ai_retouch_batch_id` 进入 AI 修图任务详情 |
|
||||
|
||||
后端需在 `PushMsg` 类型定义及类型名称映射中增加:
|
||||
|
||||
```text
|
||||
14 => AI修图任务通知
|
||||
```
|
||||
|
||||
### 2.3 任务唯一标识
|
||||
|
||||
列表、详情、推送和提交响应统一使用已有的 `ai_retouch_batch_id`,类型固定为正整数。不得再引入另一套 `job_id`,避免与素材接口和重新修图接口中的批次标识无法对应。
|
||||
|
||||
### 2.4 列表范围
|
||||
|
||||
任务列表展示当前登录账号有权限查看的全部相册任务,不要求用户先进入某个相册。任务卡必须显示所属相册信息。
|
||||
|
||||
## 3. 用户流程与入口
|
||||
|
||||
### 3.1 主流程
|
||||
|
||||
```text
|
||||
相册管理选择照片
|
||||
→ 提交 AI 修图
|
||||
→ 后端返回 ai_retouch_batch_id
|
||||
→ 客户端提示“AI修图任务已提交”并提供“查看任务”
|
||||
→ 用户可离开页面
|
||||
→ 任务进入终态后收到 type = 14 推送
|
||||
→ 点击推送进入任务详情
|
||||
→ 查看成功结果或进入相册处理失败项
|
||||
```
|
||||
|
||||
### 3.2 页面入口
|
||||
|
||||
- 相册管理页导航栏右侧增加“修图任务”,进入当前账号的全局任务列表;新增相册页不展示该入口。
|
||||
- AI 修图提交成功提示提供“查看任务”,直接进入本次任务详情。
|
||||
- 任务列表点击卡片进入对应任务详情。
|
||||
- `type = 14` 推送携带有效任务 ID 时直达详情,否则进入任务列表。
|
||||
|
||||
## 4. 任务状态定义
|
||||
|
||||
后端状态值必须稳定,客户端根据枚举映射中文,不依赖后端返回的中文状态名。
|
||||
|
||||
| 状态 | 是否终态 | 列表分组 | 中文展示 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `queued` | 否 | 进行中 | 排队中 | 已受理,尚无子任务开始 |
|
||||
| `processing` | 否 | 进行中 | 修图中 | 至少一个子任务开始,尚未全部结束 |
|
||||
| `succeeded` | 是 | 已完成 | 已完成 | 所有子任务成功 |
|
||||
| `partially_succeeded` | 是 | 已完成 | 部分完成 | 子任务全部结束,既有成功也有失败或取消 |
|
||||
| `failed` | 是 | 失败 | 处理失败 | 子任务全部结束,没有成功结果 |
|
||||
| `canceled` | 是 | 失败 | 已取消 | 任务因素材删除或后台操作被取消 |
|
||||
|
||||
进度必须满足:
|
||||
|
||||
```text
|
||||
total = queued + processing + succeeded + failed + canceled
|
||||
completed = succeeded + failed + canceled
|
||||
```
|
||||
|
||||
客户端进度百分比只能通过 `completed / total` 计算;`total <= 0` 时隐藏进度百分比,不显示虚构进度。
|
||||
|
||||
## 5. 任务列表页
|
||||
|
||||
### 5.1 设计稿
|
||||
|
||||

|
||||
|
||||
### 5.2 页面结构
|
||||
|
||||
1. 导航栏
|
||||
- 返回按钮。
|
||||
- 标题“AI修图任务”。
|
||||
2. 状态筛选
|
||||
- 全部。
|
||||
- 进行中。
|
||||
- 已完成。
|
||||
- 失败。
|
||||
3. 任务卡列表
|
||||
- 所属相册名称。
|
||||
- 用户脱敏手机号。
|
||||
- 提交时间。
|
||||
- 任务编号,例如“任务 #9521”。
|
||||
- 1 至 3 张原图缩略图;多余图片通过数量表达,不继续横向堆叠。
|
||||
- 输出摘要,例如“精修 4 张 · 氛围感 4 张 · 封面 1 张”。
|
||||
- 状态图标与文字标签。
|
||||
- 进度或终态结果摘要。
|
||||
- 详情指示。
|
||||
|
||||
### 5.3 状态展示
|
||||
|
||||
| 状态 | 卡片主信息 | 辅助信息 |
|
||||
|---|---|---|
|
||||
| `queued` | 排队中 | 有 ETA 时显示预计完成时间,否则显示“完成后将通过消息通知” |
|
||||
| `processing` | 已完成 N / M | 显示进度条与预计完成时间 |
|
||||
| `succeeded` | N 张结果已生成 | 显示完成时间 |
|
||||
| `partially_succeeded` | N 张成功 · M 张失败 | 使用橙色警示,不按整单失败展示 |
|
||||
| `failed` | 处理失败 | 引导点击查看详情 |
|
||||
| `canceled` | 已取消 | 显示取消时间;不展示供应商或内部日志 |
|
||||
|
||||
### 5.4 失败信息展示
|
||||
|
||||
- 列表只承担任务级概览,不逐张展开失败原因。
|
||||
- `partially_succeeded` 使用“N 张成功 · M 张失败”作为唯一结果摘要;`failed` 使用“失败”,不再增加独立的失败摘要行。
|
||||
- 列表卡整体可点击并保留“查看详情”指示,具体失败原因统一进入详情查看,避免与结果数量重复。
|
||||
- 后端返回的 `failure_summary` 继续解析并保留,供通知、无逐项错误数据等降级场景使用;列表首版不直接展示。
|
||||
- 详情里的逐照片 `error.message` 仍是用户查看失败原因的主要信息来源,不展示原始错误码。
|
||||
|
||||
### 5.5 刷新与分页
|
||||
|
||||
- 首次进入、下拉刷新和 App 回到前台时拉取第一页。
|
||||
- 页面可见且存在 `queued` 或 `processing` 任务时,每 15 秒刷新第一页。
|
||||
- 页面离开、App 进入后台或页面内所有任务终态后停止定时刷新。
|
||||
- 加载更多使用不透明游标;客户端不得解析或拼接游标。
|
||||
- 服务端排序固定为 `created_at DESC, ai_retouch_batch_id DESC`,避免同一时间创建的任务分页不稳定。
|
||||
- 刷新第一页时按 `ai_retouch_batch_id` 合并,不能产生重复卡片。
|
||||
|
||||
### 5.6 空态和异常
|
||||
|
||||
| 场景 | 展示 |
|
||||
|---|---|
|
||||
| 账号从未提交任务 | “暂无AI修图任务”与“提交修图后可在这里查看进度” |
|
||||
| 当前筛选无数据 | “暂无该状态的任务” |
|
||||
| 首屏加载失败 | 错误说明和“重新加载” |
|
||||
| 加载更多失败 | 保留现有列表,底部提供重试 |
|
||||
|
||||
## 6. 任务详情页
|
||||
|
||||
### 6.1 设计稿
|
||||
|
||||

|
||||
|
||||
### 6.2 页面结构
|
||||
|
||||
1. 导航栏
|
||||
- 返回按钮。
|
||||
- 标题“任务详情”。
|
||||
- 手动刷新按钮。
|
||||
2. 状态摘要卡
|
||||
- 任务状态图标和文字。
|
||||
- 完成数量与进度条。
|
||||
- 预计完成时间或实际完成时间。
|
||||
- 非终态展示“完成后将通过消息通知你”。
|
||||
3. 相册与任务信息
|
||||
- 相册封面、相册名称、脱敏手机号。
|
||||
- 任务编号、提交时间、开始时间、完成时间或处理耗时。
|
||||
4. 任务内容
|
||||
- 精修、氛围感、封面的目标数量。
|
||||
- 额度预占、实际消耗和释放数量。
|
||||
5. 处理明细
|
||||
- 按原图组织精修和氛围感子任务。
|
||||
- 封面作为独立明细。
|
||||
- 每项展示照片缩略图、文件名、模板名称和状态。
|
||||
- 某个输出失败时,在该照片、该输出项内部紧邻状态展示后端失败原因。
|
||||
6. 页面操作
|
||||
- 成功结果:“查看结果”,进入照片预览对应 Tab。
|
||||
- 失败结果:仅展示后端返回的用户可理解失败原因,不在失败原因后追加操作按钮。
|
||||
- 页面底部始终可提供“查看相册”。
|
||||
|
||||
### 6.3 失败原因展示
|
||||
|
||||
失败原因必须和失败照片、失败输出类型绑定展示,不使用全局弹窗、Toast 或脱离上下文的页面顶部提示代替。
|
||||
|
||||
| 场景 | 展示规则 |
|
||||
|---|---|
|
||||
| 单个精修/氛围感失败 | 在对应输出项状态“生成失败”下方显示“失败原因:{error.message}” |
|
||||
| 同一照片两种输出均失败 | 精修、氛围感分别显示各自原因,不合并为一个模糊原因 |
|
||||
| 封面失败 | 在封面独立明细下方显示原因 |
|
||||
| 任务仍在处理中但已有失败项 | 立即展示已确定的照片失败原因,不等整批任务终态 |
|
||||
| 后端原因为空 | 展示客户端兜底“处理失败,请稍后重试” |
|
||||
| 原因超过两行 | 默认显示两行并提供“查看完整原因”;辅助功能朗读完整文本 |
|
||||
|
||||
视觉规则:
|
||||
|
||||
- 使用红色错误图标、红色“生成失败”和淡红色原因容器;颜色不是唯一状态提示。
|
||||
- `error.message` 使用正文级字号和足够对比度,不使用脚注小字弱化重要信息。
|
||||
- 不展示 `error.code`、供应商名称、堆栈、请求 ID 或服务器路径。
|
||||
- 无论 `retryable` 取值如何,失败原因区域均只展示原因;用户仍可通过详情页底部“查看相册”进入相册管理页。
|
||||
- 用户返回页面或手动刷新后,失败原因随接口最新值更新。
|
||||
|
||||
### 6.4 刷新规则
|
||||
|
||||
- 详情首次出现时立即请求最新数据。
|
||||
- 非终态且页面可见时每 8 秒刷新。
|
||||
- 进入终态、页面离开或 App 进入后台时停止刷新。
|
||||
- 手动刷新与定时刷新不能并发发起重复请求。
|
||||
- 推送不作为唯一状态来源;用户关闭通知权限后仍可通过页面刷新获得终态。
|
||||
|
||||
### 6.5 查看结果规则
|
||||
|
||||
- `output_type = refined`:打开来源原图的照片预览,默认选中“精修后”。
|
||||
- `output_type = atmosphere`:打开来源原图的照片预览,默认选中“氛围感”。
|
||||
- `output_type = cover`:打开所属相册,并定位到生成的封面素材;无法定位时进入相册第一页。
|
||||
- 结果资源已删除或不可用时,隐藏“查看结果”并展示“结果已失效”。
|
||||
|
||||
## 7. 视觉与可访问性规范
|
||||
|
||||
### 7.1 视觉 Token
|
||||
|
||||
| 用途 | 建议值 |
|
||||
|---|---|
|
||||
| 页面背景 | `#F5F7FB` |
|
||||
| 卡片背景 | `#FFFFFF` |
|
||||
| 品牌主色 | `#1677FF` |
|
||||
| 蓝色高光 | `#3A91FF` |
|
||||
| 主文字 | `#111827` |
|
||||
| 次文字 | `#64748B` |
|
||||
| 成功 | `#22A06B` |
|
||||
| 警示/部分完成 | `#F59E0B` |
|
||||
| 失败 | `#EF4444` |
|
||||
| 卡片圆角 | 12–16 pt |
|
||||
|
||||
### 7.2 交互要求
|
||||
|
||||
- 状态必须同时使用图标、文字和颜色,不得只用颜色区分。
|
||||
- 正文文字与背景对比度至少 4.5:1。
|
||||
- 卡片、筛选项和操作按钮的最小触控区域为 44 × 44 pt。
|
||||
- 动态字体至少覆盖系统默认至辅助功能常用档位;文字放大时允许卡片增高。
|
||||
- 进度动画尊重“减弱动态效果”;关闭动画后保留静态进度与文字。
|
||||
- 网络图片使用缩略图地址,并提供占位图和失败占位状态。
|
||||
|
||||
## 8. 后端接口总览
|
||||
|
||||
基础路径:
|
||||
|
||||
```text
|
||||
/api/yf-handset-app/photog/travel-album
|
||||
```
|
||||
|
||||
| 方法 | 路径 | 类型 | 用途 |
|
||||
|---|---|---|---|
|
||||
| `POST` | `/ai-retouch` | 扩展现有响应 | 首次/批量提交后返回任务标识 |
|
||||
| `POST` | `/ai-reretouch` | 扩展现有响应 | 重新修图提交后返回任务标识 |
|
||||
| `GET` | `/ai-retouch-job-list` | 新增 | 获取当前账号全部 AI 修图任务 |
|
||||
| `GET` | `/ai-retouch-job-info` | 实现并统一 | 获取指定任务详情 |
|
||||
|
||||
时间字段统一使用 ISO 8601 UTC,例如:
|
||||
|
||||
```text
|
||||
2026-08-14T06:26:12.123Z
|
||||
```
|
||||
|
||||
所有 ID 的 JSON 类型必须稳定;本需求中的 `ai_retouch_batch_id`、`user_equity_travel_id` 和素材 ID 均使用整数。
|
||||
|
||||
## 9. 扩展 AI 修图提交响应
|
||||
|
||||
现有 `/ai-retouch` 和 `/ai-reretouch` 请求体保持不变,成功后统一返回可供跳转和立即展示的任务摘要。
|
||||
|
||||
HTTP 状态码:`202 Accepted`
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "AI修图任务已提交",
|
||||
"data": {
|
||||
"ai_retouch_batch_id": 9521,
|
||||
"user_equity_travel_id": 88,
|
||||
"status": "queued",
|
||||
"progress": {
|
||||
"total": 9,
|
||||
"queued": 9,
|
||||
"processing": 0,
|
||||
"succeeded": 0,
|
||||
"failed": 0,
|
||||
"canceled": 0
|
||||
},
|
||||
"created_at": "2026-08-14T06:26:12.123Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
必有字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `ai_retouch_batch_id` | int | 大于 0 的任务唯一标识 |
|
||||
| `user_equity_travel_id` | int | 所属相册 ID |
|
||||
| `status` | string | 提交成功时通常为 `queued` |
|
||||
| `progress` | object | 提交时的真实子任务数量 |
|
||||
| `created_at` | string | 服务端受理时间 |
|
||||
|
||||
客户端超时后使用原幂等键重试时,后端必须返回同一个 `ai_retouch_batch_id`,不得创建重复任务。
|
||||
|
||||
## 10. 获取 AI 修图任务列表
|
||||
|
||||
### 10.1 请求
|
||||
|
||||
```http
|
||||
GET /api/yf-handset-app/photog/travel-album/ai-retouch-job-list?status_group=in_progress&limit=20&cursor=<opaque_cursor>
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
| 参数 | 必填 | 类型 | 默认值 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `status_group` | 否 | string | `all` | `all`、`in_progress`、`completed`、`failed` |
|
||||
| `limit` | 否 | int | `20` | 最小 1,最大 50 |
|
||||
| `cursor` | 否 | string | — | 服务端返回的不透明游标,第一页不传 |
|
||||
|
||||
服务端分组映射:
|
||||
|
||||
| `status_group` | 包含状态 |
|
||||
|---|---|
|
||||
| `all` | 全部六种状态 |
|
||||
| `in_progress` | `queued`、`processing` |
|
||||
| `completed` | `succeeded`、`partially_succeeded` |
|
||||
| `failed` | `failed`、`canceled` |
|
||||
|
||||
### 10.2 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"ai_retouch_batch_id": 9521,
|
||||
"user_equity_travel_id": 88,
|
||||
"scope": "batch",
|
||||
"status": "processing",
|
||||
"album": {
|
||||
"id": 88,
|
||||
"name": "旅拍相册",
|
||||
"user_phone": "13812348000",
|
||||
"cover_url": "https://cdn.example.com/albums/88/cover.jpg"
|
||||
},
|
||||
"source_count": 4,
|
||||
"outputs": [
|
||||
{
|
||||
"type": "refined",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"type": "atmosphere",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"type": "cover",
|
||||
"count": 1
|
||||
}
|
||||
],
|
||||
"preview_images": [
|
||||
{
|
||||
"material_id": 2031,
|
||||
"thumbnail_url": "https://cdn.example.com/albums/88/2031_thumb.jpg"
|
||||
},
|
||||
{
|
||||
"material_id": 2032,
|
||||
"thumbnail_url": "https://cdn.example.com/albums/88/2032_thumb.jpg"
|
||||
},
|
||||
{
|
||||
"material_id": 2033,
|
||||
"thumbnail_url": "https://cdn.example.com/albums/88/2033_thumb.jpg"
|
||||
}
|
||||
],
|
||||
"progress": {
|
||||
"total": 9,
|
||||
"queued": 3,
|
||||
"processing": 1,
|
||||
"succeeded": 5,
|
||||
"failed": 0,
|
||||
"canceled": 0
|
||||
},
|
||||
"estimated_finish_at": "2026-08-14T06:32:00.000Z",
|
||||
"failure_summary": null,
|
||||
"created_at": "2026-08-14T06:26:12.123Z",
|
||||
"started_at": "2026-08-14T06:26:18.000Z",
|
||||
"finished_at": null
|
||||
}
|
||||
],
|
||||
"next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0xNFQwNjoyNjoxMi4xMjNaIiwiaWQiOjk1MjF9",
|
||||
"has_more": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 10.3 列表字段约定
|
||||
|
||||
| 字段 | 必有 | 说明 |
|
||||
|---|---|---|
|
||||
| `album` | 是 | 服务端按登录态校验后返回,客户端不再逐任务查询相册 |
|
||||
| `album.user_phone` | 否 | 返回原始手机号时客户端脱敏;没有时返回空字符串,不返回多种类型 |
|
||||
| `outputs` | 是 | 各输出类型的计划生成数量 |
|
||||
| `preview_images` | 是 | 最多返回 3 项,允许为空数组 |
|
||||
| `progress` | 是 | 六种子任务数量必须满足进度恒等式 |
|
||||
| `estimated_finish_at` | 否 | 无法估算或已终态时返回 `null` |
|
||||
| `failure_summary` | 否 | 任务级可展示摘要;`failed` 时必有,其他状态存在失败子任务时建议返回 |
|
||||
| `next_cursor` | 是 | 无下一页时为 `null` |
|
||||
| `has_more` | 是 | 与 `next_cursor` 语义一致 |
|
||||
|
||||
`estimated_finish_at` 是动态估算而非 SLA。估算变化时允许更新;客户端只展示最新值,不做倒计时承诺。
|
||||
|
||||
`failure_summary` 不代替逐照片失败原因。存在多个不同失败原因时,列表建议返回“N 张照片处理失败,点击查看原因”;只有单一且简短的原因时才直接返回该原因。建议限制在 60 个中文字符以内。
|
||||
|
||||
## 11. 获取 AI 修图任务详情
|
||||
|
||||
### 11.1 请求
|
||||
|
||||
```http
|
||||
GET /api/yf-handset-app/photog/travel-album/ai-retouch-job-info?ai_retouch_batch_id=9521
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
| 参数 | 必填 | 类型 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `ai_retouch_batch_id` | 是 | int | 大于 0 的 AI 修图批次 ID |
|
||||
|
||||
接口不要求客户端再传 `user_equity_travel_id`。后端应从登录态和任务归属完成权限校验,并在响应中返回相册信息。
|
||||
|
||||
### 11.2 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"ai_retouch_batch_id": 9521,
|
||||
"user_equity_travel_id": 88,
|
||||
"scope": "batch",
|
||||
"status": "processing",
|
||||
"album": {
|
||||
"id": 88,
|
||||
"name": "旅拍相册",
|
||||
"user_phone": "13812348000",
|
||||
"cover_url": "https://cdn.example.com/albums/88/cover.jpg"
|
||||
},
|
||||
"source_count": 4,
|
||||
"outputs": [
|
||||
{
|
||||
"type": "refined",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"type": "atmosphere",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"type": "cover",
|
||||
"count": 1
|
||||
}
|
||||
],
|
||||
"progress": {
|
||||
"total": 9,
|
||||
"queued": 2,
|
||||
"processing": 1,
|
||||
"succeeded": 5,
|
||||
"failed": 1,
|
||||
"canceled": 0
|
||||
},
|
||||
"quota_settlement": {
|
||||
"status": "partially_settled",
|
||||
"reserved_units": 8,
|
||||
"consumed_units": 5,
|
||||
"released_units": 1,
|
||||
"cover_units": 0
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"target_id": 30101,
|
||||
"source_material": {
|
||||
"id": 2031,
|
||||
"file_name": "IMG_8291.JPG",
|
||||
"thumbnail_url": "https://cdn.example.com/albums/88/2031_thumb.jpg"
|
||||
},
|
||||
"input_material_ids": [2031],
|
||||
"output_type": "refined",
|
||||
"template": {
|
||||
"id": 11,
|
||||
"name": "自然通透"
|
||||
},
|
||||
"status": "succeeded",
|
||||
"result_asset": {
|
||||
"id": 9201,
|
||||
"material_id": 2031,
|
||||
"url": "https://cdn.example.com/albums/88/2031_refined_v2.jpg",
|
||||
"thumbnail_url": "https://cdn.example.com/albums/88/2031_refined_v2_thumb.jpg"
|
||||
},
|
||||
"error": null,
|
||||
"created_at": "2026-08-14T06:26:12.123Z",
|
||||
"started_at": "2026-08-14T06:26:18.000Z",
|
||||
"finished_at": "2026-08-14T06:28:40.000Z"
|
||||
},
|
||||
{
|
||||
"target_id": 30102,
|
||||
"source_material": {
|
||||
"id": 2032,
|
||||
"file_name": "IMG_8292.JPG",
|
||||
"thumbnail_url": "https://cdn.example.com/albums/88/2032_thumb.jpg"
|
||||
},
|
||||
"input_material_ids": [2032],
|
||||
"output_type": "atmosphere",
|
||||
"template": {
|
||||
"id": 21,
|
||||
"name": "暖阳氛围"
|
||||
},
|
||||
"status": "processing",
|
||||
"result_asset": null,
|
||||
"error": null,
|
||||
"created_at": "2026-08-14T06:26:12.123Z",
|
||||
"started_at": "2026-08-14T06:29:02.000Z",
|
||||
"finished_at": null
|
||||
},
|
||||
{
|
||||
"target_id": 30103,
|
||||
"source_material": {
|
||||
"id": 2033,
|
||||
"file_name": "IMG_8293.JPG",
|
||||
"thumbnail_url": "https://cdn.example.com/albums/88/2033_thumb.jpg"
|
||||
},
|
||||
"input_material_ids": [2033],
|
||||
"output_type": "refined",
|
||||
"template": {
|
||||
"id": 11,
|
||||
"name": "自然通透"
|
||||
},
|
||||
"status": "failed",
|
||||
"result_asset": null,
|
||||
"error": {
|
||||
"code": "AI_PROVIDER_TIMEOUT",
|
||||
"message": "AI服务处理超时,请重新修图",
|
||||
"retryable": true
|
||||
},
|
||||
"created_at": "2026-08-14T06:26:12.123Z",
|
||||
"started_at": "2026-08-14T06:27:10.000Z",
|
||||
"finished_at": "2026-08-14T06:29:30.000Z"
|
||||
},
|
||||
{
|
||||
"target_id": 30109,
|
||||
"source_material": null,
|
||||
"input_material_ids": [2031, 2032, 2033, 2034],
|
||||
"output_type": "cover",
|
||||
"template": {
|
||||
"id": 31,
|
||||
"name": "旅拍拼贴"
|
||||
},
|
||||
"status": "queued",
|
||||
"result_asset": null,
|
||||
"error": null,
|
||||
"created_at": "2026-08-14T06:26:12.123Z",
|
||||
"started_at": null,
|
||||
"finished_at": null
|
||||
}
|
||||
],
|
||||
"estimated_finish_at": "2026-08-14T06:32:00.000Z",
|
||||
"created_at": "2026-08-14T06:26:12.123Z",
|
||||
"started_at": "2026-08-14T06:26:18.000Z",
|
||||
"finished_at": null,
|
||||
"duration_seconds": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 11.3 失败明细
|
||||
|
||||
失败子任务的 `error` 结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "AI_PROVIDER_TIMEOUT",
|
||||
"message": "AI服务处理超时,请重新修图",
|
||||
"retryable": true
|
||||
}
|
||||
```
|
||||
|
||||
约定:
|
||||
|
||||
- `status = failed` 时 `error` 和非空 `error.message` 必须返回,不能只返回错误码。
|
||||
- `message` 必须是经过后端转换、可直接展示给用户的中文文案,建议不超过 120 个中文字符。
|
||||
- `code` 用于客户端判断与联调排查,不直接展示。
|
||||
- 不得返回供应商密钥、内部请求、堆栈、服务器路径或原始异常。
|
||||
- `retryable` 只表达业务上是否允许重新提交,本期详情页不直接发起批量重试。
|
||||
- `queued`、`processing`、`succeeded` 时 `error` 固定返回 `null`,不要返回空对象。
|
||||
- 同一来源照片的精修和氛围感必须分别返回自己的 `error`,客户端不通过素材级公共错误猜测具体失败输出。
|
||||
|
||||
### 11.4 额度结算
|
||||
|
||||
| 状态 | 说明 |
|
||||
|---|---|
|
||||
| `reserved` | 已预占,尚无子任务完成 |
|
||||
| `partially_settled` | 部分预占已转为消耗或释放 |
|
||||
| `settled` | 所有额度完成结算 |
|
||||
|
||||
必须满足:
|
||||
|
||||
```text
|
||||
reserved_units >= consumed_units + released_units
|
||||
```
|
||||
|
||||
终态时应满足:
|
||||
|
||||
```text
|
||||
reserved_units = consumed_units + released_units
|
||||
```
|
||||
|
||||
封面不消耗额度,`cover_units` 固定为 0。
|
||||
|
||||
## 12. 接口错误码
|
||||
|
||||
| HTTP | 业务码 | 场景 | 客户端行为 |
|
||||
|---:|---|---|---|
|
||||
| 400 | `INVALID_RETOUCH_STATUS_GROUP` | 列表筛选参数错误 | 回退“全部”并记录联调日志 |
|
||||
| 400 | `INVALID_CURSOR` | 游标无效或过期 | 清空游标并重新加载第一页 |
|
||||
| 400 | `INVALID_RETOUCH_BATCH_ID` | 任务 ID 非法 | 进入任务列表 |
|
||||
| 401 | `UNAUTHORIZED` | 登录态失效 | 走现有重新登录流程,登录后继续待处理路由 |
|
||||
| 404 | `AI_RETOUCH_JOB_NOT_FOUND` | 不存在、已失效或无权访问 | 提示“任务不存在或已失效”,进入任务列表 |
|
||||
| 429 | `TOO_MANY_REQUESTS` | 刷新过于频繁 | 停止本轮轮询,按服务端建议时间重试 |
|
||||
| 500 | `AI_RETOUCH_JOB_QUERY_FAILED` | 服务端异常 | 保留已有数据并提供重试 |
|
||||
|
||||
权限不足建议统一返回 404,避免泄露其他账号任务是否存在。
|
||||
|
||||
## 13. AI 修图推送协议
|
||||
|
||||
### 13.1 发送条件
|
||||
|
||||
- 只在任务第一次从非终态进入 `succeeded`、`partially_succeeded` 或 `failed` 时发送。
|
||||
- `canceled` 默认不发送通知。
|
||||
- 同一 `ai_retouch_batch_id` 只发送一次终态推送。
|
||||
- 后端应通过事务字段或唯一记录保证幂等,例如 `terminal_push_sent_at`。
|
||||
- 设备没有有效极光 Registration ID 时不影响任务结算;用户仍可在任务中心查看结果。
|
||||
|
||||
### 13.2 统一业务结构
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "AI修图已完成",
|
||||
"content": "「旅拍相册」的9张结果已生成,点击查看。",
|
||||
"msg_id": 2014,
|
||||
"type": 14,
|
||||
"data": {
|
||||
"ai_retouch_batch_id": 9521,
|
||||
"user_equity_travel_id": 88,
|
||||
"status": "succeeded"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
字段约定:
|
||||
|
||||
| 字段 | 必有 | 类型 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `type` | 是 | int | 固定为 `14` |
|
||||
| `msg_id` | 是 | int | 后端业务消息 ID,不是极光 `_j_msgid` |
|
||||
| `data` | 是 | object | 不得发送为 JSON 字符串 |
|
||||
| `ai_retouch_batch_id` | 是 | int | 大于 0;点击详情的主键 |
|
||||
| `user_equity_travel_id` | 是 | int | 用于降级列表和联调排查 |
|
||||
| `status` | 是 | string | 仅允许三个会推送的终态值 |
|
||||
|
||||
### 13.3 通知文案
|
||||
|
||||
| 状态 | 标题 | 正文模板 |
|
||||
|---|---|---|
|
||||
| `succeeded` | AI修图已完成 | `「{相册名}」的{成功数}张结果已生成,点击查看。` |
|
||||
| `partially_succeeded` | AI修图部分完成 | `{成功数}张成功,{失败数}张失败,点击查看详情。` |
|
||||
| `failed` | AI修图未完成 | `本次任务处理失败,点击查看原因。` |
|
||||
|
||||
相册名称为空时,正文使用“本次AI修图任务”,不得出现空书名号。
|
||||
|
||||
### 13.4 iOS 示例
|
||||
|
||||
```json
|
||||
{
|
||||
"aps": {
|
||||
"alert": {
|
||||
"title": "AI修图已完成",
|
||||
"body": "「旅拍相册」的9张结果已生成,点击查看。"
|
||||
},
|
||||
"sound": "default"
|
||||
},
|
||||
"msg_id": 2014,
|
||||
"type": 14,
|
||||
"data": {
|
||||
"ai_retouch_batch_id": 9521,
|
||||
"user_equity_travel_id": 88,
|
||||
"status": "succeeded"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Android 的 `extras` 中放入同一业务对象;字段名、字段类型和业务含义必须与 iOS 一致。
|
||||
|
||||
## 14. 推送点击路由
|
||||
|
||||
### 14.1 路由规则
|
||||
|
||||
```text
|
||||
收到通知点击
|
||||
→ 解析业务 type
|
||||
→ type != 14:继续走现有类型路由
|
||||
→ type = 14:解析 data.ai_retouch_batch_id
|
||||
→ 合法:打开 AI 修图任务详情
|
||||
→ 缺失/非法:打开 AI 修图任务列表
|
||||
→ 详情请求 404:提示后返回任务列表
|
||||
```
|
||||
|
||||
### 14.2 生命周期
|
||||
|
||||
- 前台、后台点击和冷启动使用同一套 `type + data` 解析规则。
|
||||
- 未登录时暂存 `ai_retouch_batch_id`,登录成功且主 Tab 建立后继续路由。
|
||||
- 账号切换或退出登录时清除上一账号尚未执行的待处理路由。
|
||||
- 同一系统通知的 request identifier 只处理一次。
|
||||
- 客户端不得从标题或正文解析任务 ID。
|
||||
|
||||
### 14.3 向后兼容
|
||||
|
||||
- 旧客户端无法识别 `type = 14` 时按既有默认逻辑进入消息中心,不应崩溃。
|
||||
- 新客户端收到 `type = 10` 时仍按人工修图处理,不进入 AI 修图任务中心。
|
||||
- 对 `data` 中新增的未知字段,客户端应忽略。
|
||||
|
||||
### 14.4 站内消息详情入口
|
||||
|
||||
- `type = 14` 的站内消息详情页在消息正文下方展示品牌蓝主按钮“查看任务详情”。
|
||||
- 消息 `extra_data.ai_retouch_batch_id` 合法时进入对应任务详情;兼容 `extra_data.data.ai_retouch_batch_id` 包装结构。
|
||||
- 任务 ID 缺失、无法解析或小于等于 0 时,按钮仍保留,点击后降级进入 AI 修图任务列表。
|
||||
- `type = 10` 人工修图及其他类型消息不展示该入口,原有消息详情行为保持不变。
|
||||
- 底部“删除并返回”继续作为独立的破坏性操作,不承担业务跳转职责。
|
||||
|
||||
## 15. 数据、安全与一致性
|
||||
|
||||
- 列表和详情只能返回当前登录账号有权限查看的任务。
|
||||
- 后端从登录态确定账号范围,不接受客户端传入用户 ID 扩大查询范围。
|
||||
- 手机号仅用于业务识别,客户端统一脱敏展示。
|
||||
- 缩略图和结果 URL 应使用受控 CDN 地址;如需签名,过期时间应覆盖合理浏览时长。
|
||||
- 任务状态和所有子任务状态必须在同一份一致性快照中返回。
|
||||
- 推送发送失败不得回滚已完成的 AI 任务或额度结算。
|
||||
- 已删除且不再允许客户端展示的素材,不应继续通过任务详情暴露原图或结果 URL。
|
||||
- 如果任务已整体不可见,详情统一返回 `AI_RETOUCH_JOB_NOT_FOUND`。
|
||||
|
||||
## 16. 验收标准
|
||||
|
||||
### 16.1 页面
|
||||
|
||||
- 能从相册管理页进入当前账号全部 AI 修图任务列表,新增相册页不展示该入口。
|
||||
- 四种筛选与六种状态映射正确。
|
||||
- 进行中任务显示真实完成数量;有 ETA 才显示预计完成时间。
|
||||
- 详情可展示任务摘要、相册信息、额度和逐输出明细。
|
||||
- 单照片、单输出失败时,失败原因展示在对应明细内部,不需要用户从任务级摘要猜测失败对象。
|
||||
- 后端原因超过两行时可查看完整内容;原因缺失时有稳定兜底文案。
|
||||
- 成功结果可进入对应照片结果,失败项可进入所属相册。
|
||||
- 页面离开或任务终态后停止轮询。
|
||||
|
||||
### 16.2 接口
|
||||
|
||||
- 两个提交接口返回稳定且可跳转的 `ai_retouch_batch_id`。
|
||||
- 游标分页在任务新增和状态更新期间不重复、不漏项。
|
||||
- 进度数量满足恒等式,任务终态与子任务状态一致。
|
||||
- ETA 缺失时返回 `null`,不返回空字符串或 0 时间。
|
||||
- `status = failed` 的子任务必有非空 `error.message`,且详情能够按照片和输出类型准确展示。
|
||||
- 失败信息不包含供应商、堆栈、服务器路径等内部实现。
|
||||
|
||||
### 16.3 推送
|
||||
|
||||
- `type = 14` 未被其他业务占用,后端类型名称映射完整。
|
||||
- `type = 10` 人工修图和 `type = 14` AI 修图互不影响。
|
||||
- 成功、部分成功、失败各只发送一次终态通知。
|
||||
- 前台、后台、冷启动、未登录和重复点击均符合路由规则。
|
||||
- 缺少任务 ID、非法 ID、任务失效时均安全降级到任务列表。
|
||||
|
||||
## 17. 本期不做
|
||||
|
||||
- 从任务中心取消 AI 修图任务。
|
||||
- 一键批量重试失败子任务。
|
||||
- 展示 AI 供应商、内部错误堆栈或链路日志。
|
||||
- 展示同一照片的历史修图版本。
|
||||
- 为 ETA 提供 SLA 倒计时承诺。
|
||||
- 修改 `type = 10` 人工修图协议。
|
||||
|
||||
## 18. 设计稿生成说明
|
||||
|
||||
两张设计稿使用内置 ImageGen 生成,参考现有 AI 修图 V2 的浅色相册管理页面、旅行摄影素材和状态视觉语言。
|
||||
|
||||
- 任务列表:853 × 1844,展示进行中、已完成、部分完成三种代表性卡片。
|
||||
- 任务详情 V2:852 × 1846,展示处理中的任务、6/9 进度、ETA、消息通知说明,以及单照片“生成失败 + 失败原因”的内联状态。
|
||||
- 设计稿用于产品和研发对齐;实际 UIKit 实现应使用项目内颜色、字体、SnapKit 和 SF Symbols,不把设计稿作为页面背景切图。
|
||||
@@ -51,10 +51,20 @@ protocol TravelAlbumServing {
|
||||
func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse
|
||||
|
||||
/// 提交相册素材 AI 修图任务。
|
||||
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws
|
||||
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission
|
||||
|
||||
/// 提交单张素材重新修图任务。
|
||||
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws
|
||||
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws -> TravelAlbumAIJobSubmission
|
||||
|
||||
/// 拉取当前账号的 AI 修图任务列表。
|
||||
func aiRetouchJobList(
|
||||
statusGroup: TravelAlbumAIJobFilter,
|
||||
limit: Int,
|
||||
cursor: String?
|
||||
) async throws -> TravelAlbumAIJobListResponse
|
||||
|
||||
/// 拉取指定 AI 修图任务详情。
|
||||
func aiRetouchJobInfo(batchId: Int) async throws -> TravelAlbumAIJobDetail
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -197,17 +207,42 @@ final class TravelAlbumAPI: TravelAlbumServing {
|
||||
)
|
||||
}
|
||||
|
||||
/// 提交相册素材 AI 修图任务;服务端 data 内容无需客户端消费。
|
||||
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "\(basePath)/ai-retouch", body: request)
|
||||
/// 提交相册素材 AI 修图任务并返回任务摘要。
|
||||
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission {
|
||||
try await client.send(APIRequest(method: .post, path: "\(basePath)/ai-retouch", body: request))
|
||||
}
|
||||
|
||||
/// 提交重新修图任务并返回任务摘要。
|
||||
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws -> TravelAlbumAIJobSubmission {
|
||||
try await client.send(APIRequest(method: .post, path: "\(basePath)/ai-reretouch", body: request))
|
||||
}
|
||||
|
||||
/// 拉取当前账号的 AI 修图任务列表。
|
||||
func aiRetouchJobList(
|
||||
statusGroup: TravelAlbumAIJobFilter,
|
||||
limit: Int = 20,
|
||||
cursor: String? = nil
|
||||
) async throws -> TravelAlbumAIJobListResponse {
|
||||
var queryItems = [
|
||||
URLQueryItem(name: "status_group", value: statusGroup.rawValue),
|
||||
URLQueryItem(name: "limit", value: String(min(50, max(1, limit)))),
|
||||
]
|
||||
if let cursor = cursor?.trimmingCharacters(in: .whitespacesAndNewlines), !cursor.isEmpty {
|
||||
queryItems.append(URLQueryItem(name: "cursor", value: cursor))
|
||||
}
|
||||
return try await client.send(
|
||||
APIRequest(method: .get, path: "\(basePath)/ai-retouch-job-list", queryItems: queryItems)
|
||||
)
|
||||
}
|
||||
|
||||
/// 提交重新修图任务;服务端返回的批次与额度信息当前无需消费。
|
||||
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "\(basePath)/ai-reretouch", body: request)
|
||||
/// 拉取指定 AI 修图任务详情。
|
||||
func aiRetouchJobInfo(batchId: Int) async throws -> TravelAlbumAIJobDetail {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "\(basePath)/ai-retouch-job-info",
|
||||
queryItems: [URLQueryItem(name: "ai_retouch_batch_id", value: String(batchId))]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
import Foundation
|
||||
|
||||
/// AI 修图任务筛选分组,对应任务列表接口的 `status_group`。
|
||||
enum TravelAlbumAIJobFilter: String, CaseIterable, Sendable, Hashable {
|
||||
case all
|
||||
case inProgress = "in_progress"
|
||||
case completed
|
||||
case failed
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .all: "全部"
|
||||
case .inProgress: "进行中"
|
||||
case .completed: "已完成"
|
||||
case .failed: "失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 修图任务及子任务状态;未知值安全降级,避免新增后端状态导致整页解码失败。
|
||||
enum TravelAlbumAIJobStatus: Sendable, Hashable {
|
||||
case queued
|
||||
case processing
|
||||
case succeeded
|
||||
case partiallySucceeded
|
||||
case failed
|
||||
case canceled
|
||||
case unknown(String)
|
||||
|
||||
init(rawValue: String) {
|
||||
switch rawValue {
|
||||
case "queued": self = .queued
|
||||
case "processing": self = .processing
|
||||
case "succeeded": self = .succeeded
|
||||
case "partially_succeeded": self = .partiallySucceeded
|
||||
case "failed": self = .failed
|
||||
case "canceled": self = .canceled
|
||||
default: self = .unknown(rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
var rawValue: String {
|
||||
switch self {
|
||||
case .queued: "queued"
|
||||
case .processing: "processing"
|
||||
case .succeeded: "succeeded"
|
||||
case .partiallySucceeded: "partially_succeeded"
|
||||
case .failed: "failed"
|
||||
case .canceled: "canceled"
|
||||
case .unknown(let value): value
|
||||
}
|
||||
}
|
||||
|
||||
var isInProgress: Bool { self == .queued || self == .processing }
|
||||
var isTerminal: Bool { !isInProgress && !isUnknown }
|
||||
private var isUnknown: Bool { if case .unknown = self { true } else { false } }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .queued: "排队中"
|
||||
case .processing: "修图中"
|
||||
case .succeeded: "已完成"
|
||||
case .partiallySucceeded: "部分完成"
|
||||
case .failed: "失败"
|
||||
case .canceled: "已取消"
|
||||
case .unknown: "状态更新中"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TravelAlbumAIJobStatus: Decodable {
|
||||
init(from decoder: Decoder) throws {
|
||||
self.init(rawValue: (try? decoder.singleValueContainer().decode(String.self)) ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 修图输出类型。
|
||||
enum TravelAlbumAIJobOutputType: Sendable, Hashable {
|
||||
case refined
|
||||
case atmosphere
|
||||
case cover
|
||||
case unknown(String)
|
||||
|
||||
init(rawValue: String) {
|
||||
switch rawValue {
|
||||
case "refined": self = .refined
|
||||
case "atmosphere": self = .atmosphere
|
||||
case "cover": self = .cover
|
||||
default: self = .unknown(rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .refined: "原图精修"
|
||||
case .atmosphere: "氛围感"
|
||||
case .cover: "封面"
|
||||
case .unknown: "其他结果"
|
||||
}
|
||||
}
|
||||
|
||||
var previewKind: TravelAlbumPreviewAssetKind? {
|
||||
switch self {
|
||||
case .refined: .retouched
|
||||
case .atmosphere: .atmosphere
|
||||
case .cover: .cover
|
||||
case .unknown: nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TravelAlbumAIJobOutputType: Decodable {
|
||||
init(from decoder: Decoder) throws {
|
||||
self.init(rawValue: (try? decoder.singleValueContainer().decode(String.self)) ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 修图子任务数量进度。
|
||||
struct TravelAlbumAIJobProgress: Decodable, Sendable, Equatable, Hashable {
|
||||
let total: Int
|
||||
let queued: Int
|
||||
let processing: Int
|
||||
let succeeded: Int
|
||||
let failed: Int
|
||||
let canceled: Int
|
||||
|
||||
var completed: Int { min(total, succeeded + failed + canceled) }
|
||||
var fraction: Double { total > 0 ? min(1, Double(completed) / Double(total)) : 0 }
|
||||
}
|
||||
|
||||
/// AI 修图提交成功后返回的任务摘要。
|
||||
struct TravelAlbumAIJobSubmission: Decodable, Sendable, Equatable {
|
||||
let aiRetouchBatchId: Int
|
||||
let userEquityTravelId: Int
|
||||
let status: TravelAlbumAIJobStatus
|
||||
let progress: TravelAlbumAIJobProgress
|
||||
let createdAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case aiRetouchBatchId = "ai_retouch_batch_id"
|
||||
case userEquityTravelId = "user_equity_travel_id"
|
||||
case status, progress
|
||||
case createdAt = "created_at"
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务所属相册摘要。
|
||||
struct TravelAlbumAIJobAlbum: Decodable, Sendable, Equatable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let userPhone: String
|
||||
let coverURL: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name
|
||||
case userPhone = "user_phone"
|
||||
case coverURL = "cover_url"
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务计划输出数量。
|
||||
struct TravelAlbumAIJobOutput: Decodable, Sendable, Equatable, Hashable {
|
||||
let type: TravelAlbumAIJobOutputType
|
||||
let count: Int
|
||||
}
|
||||
|
||||
/// 任务列表缩略图。
|
||||
struct TravelAlbumAIJobPreviewImage: Decodable, Sendable, Equatable, Hashable {
|
||||
let materialId: Int
|
||||
let thumbnailURL: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case materialId = "material_id"
|
||||
case thumbnailURL = "thumbnail_url"
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 修图任务列表项。
|
||||
struct TravelAlbumAIJobSummary: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||
var id: Int { aiRetouchBatchId }
|
||||
let aiRetouchBatchId: Int
|
||||
let userEquityTravelId: Int
|
||||
let scope: String
|
||||
let status: TravelAlbumAIJobStatus
|
||||
let album: TravelAlbumAIJobAlbum
|
||||
let sourceCount: Int
|
||||
let outputs: [TravelAlbumAIJobOutput]
|
||||
let previewImages: [TravelAlbumAIJobPreviewImage]
|
||||
let progress: TravelAlbumAIJobProgress
|
||||
let estimatedFinishAt: String?
|
||||
let failureSummary: String?
|
||||
let createdAt: String
|
||||
let startedAt: String?
|
||||
let finishedAt: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case aiRetouchBatchId = "ai_retouch_batch_id"
|
||||
case userEquityTravelId = "user_equity_travel_id"
|
||||
case scope, status, album, outputs, progress
|
||||
case sourceCount = "source_count"
|
||||
case previewImages = "preview_images"
|
||||
case estimatedFinishAt = "estimated_finish_at"
|
||||
case failureSummary = "failure_summary"
|
||||
case createdAt = "created_at"
|
||||
case startedAt = "started_at"
|
||||
case finishedAt = "finished_at"
|
||||
}
|
||||
|
||||
var displayFailureSummary: String? {
|
||||
guard status == .failed || status == .partiallySucceeded || progress.failed > 0 else { return nil }
|
||||
let value = failureSummary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return value.isEmpty ? "部分照片处理失败,点击查看原因" : value
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 修图任务游标分页响应。
|
||||
struct TravelAlbumAIJobListResponse: Decodable, Sendable, Equatable {
|
||||
let items: [TravelAlbumAIJobSummary]
|
||||
let nextCursor: String?
|
||||
let hasMore: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case items
|
||||
case nextCursor = "next_cursor"
|
||||
case hasMore = "has_more"
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 修图额度结算信息。
|
||||
struct TravelAlbumAIJobQuotaSettlement: Decodable, Sendable, Equatable {
|
||||
let status: String
|
||||
let reservedUnits: Int
|
||||
let consumedUnits: Int
|
||||
let releasedUnits: Int
|
||||
let coverUnits: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case status
|
||||
case reservedUnits = "reserved_units"
|
||||
case consumedUnits = "consumed_units"
|
||||
case releasedUnits = "released_units"
|
||||
case coverUnits = "cover_units"
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 修图目标的来源素材。
|
||||
struct TravelAlbumAIJobSourceMaterial: Decodable, Sendable, Equatable, Hashable {
|
||||
let id: Int
|
||||
let fileName: String
|
||||
let thumbnailURL: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case fileName = "file_name"
|
||||
case thumbnailURL = "thumbnail_url"
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 修图目标使用的模板摘要。
|
||||
struct TravelAlbumAIJobTemplate: Decodable, Sendable, Equatable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
}
|
||||
|
||||
/// AI 修图成功结果资源。
|
||||
struct TravelAlbumAIJobResultAsset: Decodable, Sendable, Equatable, Hashable {
|
||||
let id: Int
|
||||
let materialId: Int
|
||||
let url: String
|
||||
let thumbnailURL: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case materialId = "material_id"
|
||||
case url
|
||||
case thumbnailURL = "thumbnail_url"
|
||||
}
|
||||
}
|
||||
|
||||
/// 可直接展示给用户的 AI 修图失败信息。
|
||||
struct TravelAlbumAIJobError: Decodable, Sendable, Equatable, Hashable {
|
||||
let code: String
|
||||
let message: String
|
||||
let retryable: Bool
|
||||
|
||||
var displayMessage: String {
|
||||
let value = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.isEmpty ? "处理失败,请前往相册重新修图" : value
|
||||
}
|
||||
}
|
||||
|
||||
/// 单个输出目标的处理明细。
|
||||
struct TravelAlbumAIJobTarget: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||
var id: Int { targetId }
|
||||
let targetId: Int
|
||||
let sourceMaterial: TravelAlbumAIJobSourceMaterial?
|
||||
let inputMaterialIds: [Int]
|
||||
let outputType: TravelAlbumAIJobOutputType
|
||||
let template: TravelAlbumAIJobTemplate?
|
||||
let status: TravelAlbumAIJobStatus
|
||||
let resultAsset: TravelAlbumAIJobResultAsset?
|
||||
let error: TravelAlbumAIJobError?
|
||||
let createdAt: String
|
||||
let startedAt: String?
|
||||
let finishedAt: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case targetId = "target_id"
|
||||
case sourceMaterial = "source_material"
|
||||
case inputMaterialIds = "input_material_ids"
|
||||
case outputType = "output_type"
|
||||
case template, status, error
|
||||
case resultAsset = "result_asset"
|
||||
case createdAt = "created_at"
|
||||
case startedAt = "started_at"
|
||||
case finishedAt = "finished_at"
|
||||
}
|
||||
|
||||
var displayFailureMessage: String? {
|
||||
guard status == .failed else { return nil }
|
||||
return error?.displayMessage ?? "处理失败,请前往相册重新修图"
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 修图任务完整详情。
|
||||
struct TravelAlbumAIJobDetail: Decodable, Sendable, Equatable {
|
||||
let aiRetouchBatchId: Int
|
||||
let userEquityTravelId: Int
|
||||
let scope: String
|
||||
let status: TravelAlbumAIJobStatus
|
||||
let album: TravelAlbumAIJobAlbum
|
||||
let sourceCount: Int
|
||||
let outputs: [TravelAlbumAIJobOutput]
|
||||
let progress: TravelAlbumAIJobProgress
|
||||
let quotaSettlement: TravelAlbumAIJobQuotaSettlement
|
||||
let targets: [TravelAlbumAIJobTarget]
|
||||
let estimatedFinishAt: String?
|
||||
let createdAt: String
|
||||
let startedAt: String?
|
||||
let finishedAt: String?
|
||||
let durationSeconds: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case aiRetouchBatchId = "ai_retouch_batch_id"
|
||||
case userEquityTravelId = "user_equity_travel_id"
|
||||
case scope, status, album, outputs, progress, targets
|
||||
case sourceCount = "source_count"
|
||||
case quotaSettlement = "quota_settlement"
|
||||
case estimatedFinishAt = "estimated_finish_at"
|
||||
case createdAt = "created_at"
|
||||
case startedAt = "started_at"
|
||||
case finishedAt = "finished_at"
|
||||
case durationSeconds = "duration_seconds"
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 修图任务日期展示工具。
|
||||
enum TravelAlbumAIJobDateFormatter {
|
||||
private static let internetFormatter = ISO8601DateFormatter()
|
||||
private static let preciseFormatter: ISO8601DateFormatter = {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return formatter
|
||||
}()
|
||||
|
||||
static func date(_ value: String?) -> Date? {
|
||||
guard let value, !value.isEmpty else { return nil }
|
||||
return preciseFormatter.date(from: value) ?? internetFormatter.date(from: value)
|
||||
}
|
||||
|
||||
static func display(_ value: String?) -> String {
|
||||
guard let date = date(value) else { return "--" }
|
||||
return date.formatted(.dateTime.month().day().hour().minute())
|
||||
}
|
||||
|
||||
static func time(_ value: String?) -> String {
|
||||
guard let date = date(value) else { return "--" }
|
||||
return date.formatted(.dateTime.hour().minute())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import Foundation
|
||||
|
||||
/// AI 修图任务列表状态,负责筛选、游标分页、去重和静默刷新。
|
||||
final class TravelAlbumAIJobListViewModel {
|
||||
private(set) var items: [TravelAlbumAIJobSummary] = []
|
||||
private(set) var selectedFilter: TravelAlbumAIJobFilter = .all
|
||||
private(set) var isLoading = false
|
||||
private(set) var isRefreshing = false
|
||||
private(set) var isLoadingMore = false
|
||||
private(set) var errorMessage: String?
|
||||
private var nextCursor: String?
|
||||
private var hasMore = false
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
|
||||
var containsInProgressJobs: Bool { items.contains { $0.status.isInProgress } }
|
||||
var canLoadMore: Bool { hasMore && !isLoadingMore }
|
||||
|
||||
/// 选择筛选项并重新加载第一页。
|
||||
func selectFilter(_ filter: TravelAlbumAIJobFilter, api: any TravelAlbumServing) async {
|
||||
guard selectedFilter != filter else { return }
|
||||
selectedFilter = filter
|
||||
items = []
|
||||
notify()
|
||||
await loadFirstPage(api: api)
|
||||
}
|
||||
|
||||
/// 首次加载或下拉刷新第一页。
|
||||
func loadFirstPage(api: any TravelAlbumServing, refreshing: Bool = false, silent: Bool = false) async {
|
||||
guard !isLoading && !isRefreshing else { return }
|
||||
if refreshing { isRefreshing = true } else if !silent { isLoading = true }
|
||||
errorMessage = nil
|
||||
notify()
|
||||
defer {
|
||||
isLoading = false
|
||||
isRefreshing = false
|
||||
notify()
|
||||
}
|
||||
await request(cursor: nil, reset: true, api: api, allowCursorRecovery: false, silent: silent)
|
||||
}
|
||||
|
||||
/// 加载下一页。
|
||||
func loadMore(api: any TravelAlbumServing) async {
|
||||
guard canLoadMore, let cursor = nextCursor else { return }
|
||||
isLoadingMore = true
|
||||
notify()
|
||||
defer { isLoadingMore = false; notify() }
|
||||
await request(cursor: cursor, reset: false, api: api, allowCursorRecovery: true, silent: false)
|
||||
}
|
||||
|
||||
private func request(
|
||||
cursor: String?,
|
||||
reset: Bool,
|
||||
api: any TravelAlbumServing,
|
||||
allowCursorRecovery: Bool,
|
||||
silent: Bool
|
||||
) async {
|
||||
do {
|
||||
let response = try await api.aiRetouchJobList(
|
||||
statusGroup: selectedFilter,
|
||||
limit: 20,
|
||||
cursor: cursor
|
||||
)
|
||||
if reset {
|
||||
items = response.items
|
||||
} else {
|
||||
var known = Set(items.map(\.id))
|
||||
items += response.items.filter { known.insert($0.id).inserted }
|
||||
}
|
||||
nextCursor = response.nextCursor
|
||||
hasMore = response.hasMore && response.nextCursor?.isEmpty == false
|
||||
errorMessage = nil
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
let message = error.localizedDescription
|
||||
if allowCursorRecovery && message.localizedCaseInsensitiveContains("cursor") {
|
||||
nextCursor = nil
|
||||
hasMore = false
|
||||
await request(cursor: nil, reset: true, api: api, allowCursorRecovery: false, silent: silent)
|
||||
return
|
||||
}
|
||||
if items.isEmpty { errorMessage = message.isEmpty ? "任务加载失败" : message }
|
||||
if !silent { onShowMessage?(message.isEmpty ? "任务加载失败" : message) }
|
||||
}
|
||||
}
|
||||
|
||||
private func notify() { onStateChange?() }
|
||||
}
|
||||
|
||||
/// AI 修图任务详情状态,负责刷新、404 识别和终态判断。
|
||||
final class TravelAlbumAIJobDetailViewModel {
|
||||
private(set) var detail: TravelAlbumAIJobDetail?
|
||||
private(set) var isLoading = false
|
||||
private(set) var isRefreshing = false
|
||||
private(set) var errorMessage: String?
|
||||
private(set) var isNotFound = false
|
||||
|
||||
let batchId: Int
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
var onNotFound: (() -> Void)?
|
||||
|
||||
init(batchId: Int) { self.batchId = batchId }
|
||||
|
||||
var shouldPoll: Bool { detail?.status.isInProgress == true }
|
||||
|
||||
/// 拉取任务详情;静默刷新失败时保留现有内容。
|
||||
func load(api: any TravelAlbumServing, refreshing: Bool = false, silent: Bool = false) async {
|
||||
guard batchId > 0, !isLoading && !isRefreshing else {
|
||||
if batchId <= 0 { markNotFound() }
|
||||
return
|
||||
}
|
||||
if refreshing { isRefreshing = true } else if detail == nil && !silent { isLoading = true }
|
||||
errorMessage = nil
|
||||
notify()
|
||||
defer {
|
||||
isLoading = false
|
||||
isRefreshing = false
|
||||
notify()
|
||||
}
|
||||
do {
|
||||
detail = try await api.aiRetouchJobInfo(batchId: batchId)
|
||||
isNotFound = false
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch APIError.httpStatus(let status, _) where status == 404 {
|
||||
markNotFound()
|
||||
} catch {
|
||||
let message = error.localizedDescription.isEmpty ? "任务详情加载失败" : error.localizedDescription
|
||||
if detail == nil { errorMessage = message }
|
||||
if !silent { onShowMessage?(message) }
|
||||
}
|
||||
}
|
||||
|
||||
private func markNotFound() {
|
||||
guard !isNotFound else { return }
|
||||
isNotFound = true
|
||||
onNotFound?()
|
||||
}
|
||||
|
||||
private func notify() { onStateChange?() }
|
||||
}
|
||||
+5
-4
@@ -23,7 +23,7 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
var onSubmitted: (() -> Void)?
|
||||
var onSubmitted: ((TravelAlbumAIJobSubmission) -> Void)?
|
||||
|
||||
/// 创建首次 AI 修图模板状态;素材 ID 会排序并去重,确保提交稳定。
|
||||
convenience init(albumId: Int, scenicId: Int, materialIds: [Int]) {
|
||||
@@ -217,13 +217,14 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
||||
}
|
||||
|
||||
do {
|
||||
let submission: TravelAlbumAIJobSubmission
|
||||
switch workflow {
|
||||
case .initial(let albumId, let materialIds):
|
||||
guard let refinedTemplateId = selectedRefinedTemplateId else {
|
||||
onShowMessage?("请选择原图精修模板")
|
||||
return
|
||||
}
|
||||
try await api.submitAIRetouch(
|
||||
submission = try await api.submitAIRetouch(
|
||||
TravelAlbumAIRetouchRequest(
|
||||
userEquityTravelId: albumId,
|
||||
materialIds: materialIds,
|
||||
@@ -233,7 +234,7 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
||||
)
|
||||
)
|
||||
case .reretouch(let materialId, let batchId, let type):
|
||||
try await api.submitAIReretouch(
|
||||
submission = try await api.submitAIReretouch(
|
||||
TravelAlbumAIReretouchRequest(
|
||||
id: materialId,
|
||||
aiRetouchBatchId: batchId,
|
||||
@@ -243,7 +244,7 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
||||
)
|
||||
)
|
||||
}
|
||||
onSubmitted?()
|
||||
onSubmitted?(submission)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user