32 Commits
Author SHA1 Message Date
lujiuyin 10249776ad chore: bump iOS version to 1.4.1 2026-09-03 16:56:29 +08:00
lujiuyin 9b65913bc4 fix: confirm cooling off account switch 2026-08-31 17:01:26 +08:00
lujiuyin b8343ad9eb fix: use v9 store status for deregistration login 2026-08-31 16:36:20 +08:00
lujiuyin 4444d328df feat: 优化账号注销流程 2026-08-31 15:56:16 +08:00
lujiuyin e529bb5942 feat: 增加门店身份注销流程 2026-08-31 09:43:14 +08:00
lujiuyin 396597a160 feat: 优化自动修图设置与模板网格交互
同页切换修图方式并展开三列模板,复用模板卡片与对比预览,保留草稿和滚动位置,统一卡片与固定底栏样式并补充回归测试。
2026-08-27 17:15:50 +08:00
lujiuyin d04641b623 feat: 新增相册自动修图与OTG状态预览
支持相册修图配置、模板选择和传输模式选择;上传后自动提交修图并展示状态角标及精修预览。补充接口文档与相关测试。
2026-08-27 16:03:40 +08:00
lujiuyin 9fce6ef713 feat: 优化线下收款日历交互与选中态 2026-08-26 14:19:02 +08:00
lujiuyin 4351c26e74 feat: 优化线下收款展示细节 2026-08-26 10:47:43 +08:00
lujiuyin 825a0448cc feat: 完成9.7线下收款登记与日清 2026-08-26 09:53:48 +08:00
lujiuyin dca4bd5a20 docs: 补充周报并完善 AI 重修占位图配置 2026-08-24 10:20:08 +08:00
lujiuyin eb57666033 chore: bump version to 1.3.1 2026-08-20 14:11:39 +08:00
lujiuyin 2c4123214d docs: 补充 AI 重修需求与工作周报 2026-08-19 17:18:45 +08:00
lujiuyin 8cd6c0aac8 fix: align AI retouch template rules 2026-08-19 10:06:50 +08:00
lujiuyin 1757ba5e36 fix: 优化任务详情与图片预览布局 2026-08-18 16:41:55 +08:00
lujiuyin c34c4e1923 fix: 优化相册素材状态与删除保护 2026-08-17 16:04:03 +08:00
lujiuyin b127495e52 feat(album): add pull-to-refresh to album entry 2026-08-17 15:19:21 +08:00
lujiuyin 76aa7bb4aa feat: refine AI retouch workflow 2026-08-17 15:09:15 +08:00
lujiuyin 8e444f7434 fix: refresh travel albums after OTG return 2026-08-14 16:46:05 +08:00
lujiuyin 83afcb6deb fix: streamline AI retouch submission feedback 2026-08-14 15:46:17 +08:00
lujiuyin 30bfe0313f feat: add AI retouch task center 2026-08-14 15:06:57 +08:00
lujiuyin 0f3b26991e feat: add AI retouch before-after comparison 2026-08-13 17:46:06 +08:00
lujiuyin e190b5958b 调整图片预览关闭按钮图标 2026-08-13 16:25:27 +08:00
lujiuyin 6e2899dbfd 优化图片预览分段控件样式 2026-08-13 16:11:36 +08:00
lujiuyin 48b6b9be22 fix: 优化图片预览索引胶囊 2026-08-13 15:36:06 +08:00
lujiuyin 202b0f5e41 fix: 优化AI修图标签显示 2026-08-13 15:27:13 +08:00
lujiuyin 7b9e630838 feat: 优化 AI 修图模板选择页面 2026-08-13 15:16:03 +08:00
lujiuyin 109ec3dad0 feat: 美化图片预览工具栏 2026-08-13 14:23:10 +08:00
lujiuyin f2a005e008 feat: 优化 AI 修图模板与全屏预览 2026-08-13 13:48:11 +08:00
lujiuyin 60502784db docs: 补充 AI 修图需求与接口说明 2026-08-13 09:16:39 +08:00
lujiuyin 05cfdc3662 feat: 优化相册图片预览交互 2026-08-12 10:58:53 +08:00
lujiuyin 010add828b feat: 优化AI修图模板选择界面 2026-08-11 17:04:51 +08:00
137 changed files with 20512 additions and 506 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 974 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 961 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 905 KiB

+53
View File
@@ -0,0 +1,53 @@
# 9.7 线下收款接口调整
本次仅调整以下两个接口,路径保持不变:
- `GET /api/yf-handset-app/photog/offline-pay-collect/statistics`
- `GET /api/yf-handset-app/photog/offline-pay-collect/details`
## 1. statistics
请求:`scenic_id`
在现有 `pending` 中增加所有未补缴日期,按日期升序:
```json
"dates": [
{ "date": "2026-08-23", "unpaid_amount": "230.00", "unpaid_count": 2 }
]
```
- 包含今天及历史所有仍有待补缴数据的日期。
- `date_count`、待补缴总金额和总笔数必须与 `dates` 汇总一致。
- `today.date` 按 `Asia/Shanghai` 返回当前营业日,客户端以此限制未来日期。
## 2. details
请求改为:`scenic_id + date`,删除 `page/page_size`。
`data` 直接返回单日对象:
```json
{
"date": "2026-08-24",
"total_amount": "350.00",
"collect_count": 3,
"paid_amount": "100.00",
"paid_count": 1,
"unpaid_amount": "250.00",
"unpaid_count": 2,
"status": 0,
"status_text": "本日未结清",
"collects": []
}
```
- 无数据日期也返回成功、零值汇总和空 `collects`。
- 明细按登记时间倒序,再按 `collect_no` 保证稳定顺序。
## 不调整的接口
- `POST /register`:继续只接收 `scenic_id/amount/pay_method`,响应保持现状。
- `POST /supplement`:继续只接收 `scenic_id/date`,响应保持现状。
客户端不会向这两个接口传入 `request_id`、补缴金额快照或补缴笔数快照,也不依赖新增错误码。
+282
View File
@@ -0,0 +1,282 @@
# AI 重新修图接口优化需求
## 1. 背景
图片预览页面支持用户在“原图”Tab 上重新选择修图模板。
用户可能只生成过一种关联图片,例如之前仅生成了“原图精修”,尚未生成“氛围感”图片。此时用户重新修图时,可以同时选择:
- 原图精修模板
- 氛围感修图模板
客户端会调用现有 `ai-reretouch` 接口,并通过 `type = 3` 提交两个模板,希望一次创建两个修图输出任务。
## 2. 问题复现
### 前置状态
- 原图素材 ID:`702`
- 原 AI 修图批次 ID:`61`
- 已存在并完成:原图精修
- 从未生成:氛围感图片
### 请求
```http
POST /api/yf-handset-app/photog/travel-album/ai-reretouch
```
```json
{
"ai_retouch_batch_id": 61,
"id": 702,
"type": 3,
"refined_template_id": 2,
"atmosphere_template_id": 14
}
```
字段含义:
| 字段 | 值 | 说明 |
| --- | ---: | --- |
| `ai_retouch_batch_id` | `61` | 原 AI 修图批次 ID |
| `id` | `702` | 原图素材 ID |
| `type` | `3` | 同时处理精修和氛围感 |
| `refined_template_id` | `2` | 本次选择的精修模板 |
| `atmosphere_template_id` | `14` | 本次选择的氛围感模板 |
### 当前响应
```json
{
"code": 100099,
"data": [],
"msg": "仅已完成修图的照片可重新修图",
"time": "2026-08-14 17:41:17"
}
```
## 3. 当前问题
从响应判断,后端可能在 `type = 3` 时要求精修图和氛围感图都已经存在且完成,才允许重新修图。
但本场景中:
- 精修图已经存在,本次需要重新生成,并在成功后覆盖旧精修图。
- 氛围感图从未生成,本次需要创建新的关联图片。
由于氛围感图历史上不存在,接口拒绝了整个请求,导致客户端无法通过一次请求同时完成“覆盖已有精修图”和“新增氛围感图”。
客户端当前请求已经正确传递 `type = 3` 和两个模板 ID,无需修改请求结构。
## 4. 期望接口行为
建议将 `ai-reretouch` 调整为按输出类型分别执行的 **upsert(存在则覆盖,不存在则新增)** 语义。
对于本次请求:
### 原图精修
- 请求传入了 `refined_template_id`。
- 旧精修图已经存在。
- 后端创建新的精修任务。
- 新任务成功后覆盖旧精修图。
- 新任务处理中或失败时保留旧精修图。
### 氛围感修图
- 请求传入了 `atmosphere_template_id`。
- 旧氛围感图不存在。
- 后端创建新的氛围感任务。
- 任务成功后,将结果保存为原图的新关联图片。
### 整体任务
- 一次请求创建两个输出任务:`refined` 和 `atmosphere`。
- 按两个实际创建的输出预占 2 张修图额度。
- 两个输出分别执行、分别记录状态、分别结算额度。
- 单个输出失败不应影响另一个已经成功的输出。
## 5. 建议的后端处理规则
### 5.1 基础校验
建议校验:
- 原图素材存在。
- 原图素材属于指定相册或批次。
- 原图当前状态允许提交 AI 修图任务。
- 请求中至少传入一个与 `type` 匹配的有效模板 ID。
- 用户剩余额度满足本次实际创建的输出数量。
不建议校验:
- `type = 3` 时要求精修和氛围感历史结果都必须存在。
- 某一种关联图从未生成时直接拒绝整个请求。
### 5.2 按模板字段创建任务
后端根据本次实际传入的模板字段创建对应任务:
| 请求情况 | 期望处理 |
| --- | --- |
| 只传 `refined_template_id` | 只创建精修任务 |
| 只传 `atmosphere_template_id` | 只创建氛围感任务 |
| 两个模板 ID 都传 | 同时创建精修和氛围感任务 |
| 某个模板 ID 未传 | 不处理、不覆盖该类型的已有结果 |
### 5.3 按输出类型处理已有结果
对于每个实际提交的输出类型,分别判断:
| 历史状态 | 建议处理 |
| --- | --- |
| 已存在成功结果 | 创建重修任务,新结果成功后替换旧结果 |
| 从未生成 | 创建新任务,成功后新增关联图片 |
| 历史任务失败或已取消 | 允许重新创建任务 |
| 当前已有处理中或排队任务 | 拒绝该类型重复提交,或通过幂等机制复用已有任务 |
### 5.4 替换时机
继续采用现有 `replace_on_success` 语义:
- 新结果成功后,才替换同类型旧结果。
- 新任务排队、处理中或失败时,保留旧结果。
- 精修和氛围感分别替换,互不影响。
## 6. `type` 参数建议语义
保持现有请求类型和接口路径不变:
| `type` | 语义 | 模板规则 |
| ---: | --- | --- |
| `1` | 只处理精修 | 必须传 `refined_template_id`,忽略氛围感模板 |
| `2` | 只处理氛围感 | 必须传 `atmosphere_template_id`,忽略精修模板 |
| `3` | 同时处理精修和氛围感 | 根据实际传入的两个模板分别创建任务 |
对于 `type = 3`:
- 必须传 `refined_template_id`。
- `atmosphere_template_id` 可以选传。
- 传入氛围感模板时创建氛围感任务。
- 未传氛围感模板时,只重修精修图,并保留已有氛围感图。
## 7. 建议处理伪代码
```text
validateOriginalMaterial(request.id)
validateBatch(request.ai_retouch_batch_id)
outputs = []
if request.refined_template_id is not null:
outputs.add(
createTask(
outputType = refined,
templateId = request.refined_template_id,
mode = existingRefinedResult ? replace_on_success : create
)
)
if request.atmosphere_template_id is not null:
outputs.add(
createTask(
outputType = atmosphere,
templateId = request.atmosphere_template_id,
mode = existingAtmosphereResult ? replace_on_success : create
)
)
if outputs is empty:
return invalid_template_error
reserveQuota(outputs.count)
submitTasks(outputs)
```
注意:实际是否存在旧结果,应按照各自的 `output_type` 独立查询,不能用“两个类型都已完成”作为 `type = 3` 的整体前置条件。
## 8. 期望响应
接口成功响应建议继续返回任务批次信息,并明确本次创建的输出类型。例如:
```json
{
"code": 100000,
"data": {
"ai_retouch_batch_id": 61,
"source_material_id": 702,
"outputs": [
{
"type": "refined",
"mode": "replace_on_success",
"status": "queued"
},
{
"type": "atmosphere",
"mode": "create",
"status": "queued"
}
],
"reserved_units": 2
},
"msg": "AI修图任务已提交"
}
```
响应结构可沿用现有实现,不强制增加上述字段;关键要求是一次请求能够成功创建两个对应的修图任务。
## 9. 验收用例
### 用例一:只有旧精修图,同时提交两个模板
- 历史状态:精修已完成,氛围感不存在。
- 请求:`type = 3`,同时传精修和氛围感模板。
- 期望:创建两个任务;精修成功后覆盖,氛围感成功后新增。
### 用例二:只有旧氛围感图,同时提交两个模板
- 历史状态:精修不存在,氛围感已完成。
- 请求:`type = 3`,同时传精修和氛围感模板。
- 期望:创建两个任务;精修成功后新增,氛围感成功后覆盖。
### 用例三:两种旧结果都存在
- 历史状态:精修、氛围感都已完成。
- 请求:`type = 3`,同时传两个模板。
- 期望:创建两个任务,分别在成功后覆盖各自旧结果。
### 用例四:原图 Tab 只选择精修模板
- 历史状态:精修、氛围感均可能存在。
- 请求:`type = 3`,只传 `refined_template_id`。
- 期望:只创建精修任务;不处理、不删除、不覆盖旧氛围感图;预占 1 张额度。
### 用例五:精修后 Tab 重新修图
- 请求:`type = 1`,只传 `refined_template_id`。
- 期望:只创建精修任务,新精修成功后覆盖旧精修图。
### 用例六:氛围感 Tab 重新修图
- 请求:`type = 2`,只传 `atmosphere_template_id`。
- 期望:只创建氛围感任务,新氛围感成功后覆盖旧氛围感图。
### 用例七:其中一个输出失败
- 请求创建精修和氛围感两个任务。
- 精修成功,氛围感失败。
- 期望:保留成功的新精修结果;氛围感旧图存在时继续保留旧图,不存在时不生成关联图;额度分别结算。
### 用例八:防止重复提交
- 同一原图、同一输出类型已经存在排队中或处理中的任务。
- 用户再次提交相同任务。
- 期望:通过幂等机制返回原任务,或仅拒绝重复的输出类型,避免重复扣减额度和重复生成结果。
## 10. 总结
希望 `ai-reretouch` 支持以下统一语义:
> 对本次传入模板对应的每种输出独立处理:已有结果则在新任务成功后覆盖,没有结果则新增;未传模板的类型不处理。不要因为某一种关联图历史上从未生成,就拒绝整个重新修图请求。
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

+85
View File
@@ -0,0 +1,85 @@
# AI 修图完整链路设计稿 V1
## 交付内容
| 文件 | 页面 / 状态 |
|---|---|
| `00-flow-overview.png` | 六张关键屏稿总览 |
| `01-album-multi-selection.png` | 相册管理 · 已选择 4 张照片 |
| `02-template-selection.png` | AI 修图模板选择 Sheet |
| `03-template-preview.png` | 模板全屏预览 |
| `04-result-success.png` | 精修结果 · 成功 |
| `05-result-processing.png` | 精修结果 · 生成新版本中 |
| `06-result-failed.png` | 精修结果 · 新版本生成失败 |
独立屏稿均为 853 × 1844 像素(约 9:19.5),面向 iOS 16+、iPhone 竖屏、浅色模式。
## 设计系统
- 主色:`#1677FF`;辅助渐变:`#5CA8FF`、`#A7D8FF`。
- 页面底色:`#F7FAFF`;主文字:`#172033`;次文字:`#667085`。
- 失败色:`#EF4444`;赠送/免费标签使用橙色,避免与主要操作竞争。
- 字体:SF Pro / 苹方风格;圆角 12–16 pt;以 8 pt 为基础间距。
- 正文置于高不透明度表面;极光光晕只用于背景与重点操作,不承载关键信息。
- 结果页只通过“原图 / 精修后 / 氛围感”标签切换,不使用对比滑杆。
- 重新修图期间及失败后保留上一版成功图片;状态反馈只占局部,不遮挡照片。
## 可复用 ImageGen 提示词
以下提示词使用内置 ImageGen。首屏以 `../travel-album-management-redesign-v2.png` 为风格参考;后续页面以上一张已确认屏稿作为参考,确保视觉连续。
### 通用前缀
```text
Use case: ui-mockup
Asset type: shippable high-fidelity iOS app screen, full portrait canvas, no device frame
Style/medium: realistic production UIKit UI, light Aurora blue, crisp SF Pro / PingFang-like typography, SF Symbols-style icons, 12–16 pt corners, 8 pt spacing grid, subtle borders and restrained shadows.
Color palette: primary #1677FF, gradient accents #5CA8FF and #A7D8FF, background #F7FAFF, text #172033 and #667085, danger #EF4444.
Constraints: iPhone portrait 9:19.5; iOS status bar and home indicator; practical UIKit-buildable hierarchy; accessible contrast; minimum 44 pt touch targets; no dark mode; no watermark; no decorative device frame; no annotations.
Text constraints: render specified Chinese text exactly once; no gibberish, duplicate labels or unrelated copy.
```
### 01 · 相册多选
```text
Create the album management screen in multi-selection mode. Top navigation: “相册管理” and “完成”. Album summary: “旅拍相册”, “138****8000”. Filter row: “全部 128”, “已购 36”, “时间 ↓”. Use a precise three-column grid of nine travel photos. Four photos have blue checkmarks, blue outlines and subtle selected overlays; other photos show empty selectors. Fixed bottom actions: red outline “删除 4 张” and blue gradient “AI修图 4 张”.
```
### 02 · 模板选择
```text
Create a large native page sheet titled “AI修图” with subtitle “已选择 4 张照片”. Show the note “每张照片生成精修结果,氛围感可选;本次可免费生成 1 张封面”. Include three horizontal sections: “原图精修” + “必选”, “氛围感修图” + “选填”, “封面风格” + “赠送 · 不占额度”. Each section has three photographic cards, a clear single selected state and “预览” buttons. Cost card: “AI精修 · 按张收费”, “预计消耗 8 张”, “剩余 12 张”. Fixed actions: “取消” and “确定”.
```
### 03 · 模板预览
```text
Create an immersive full-screen preview for the selected “自然通透” template. Use a dark navy photographic canvas, a circular back button, title “自然通透” and subtitle “双指缩放查看细节”. The large photo shows a woman in a straw hat overlooking Santorini. Add “模板效果预览”. Bottom information panel: “自然通透”, “提亮肤色与画面通透感,保留自然质感”, “当前已选择”. Primary action: “使用此模板”. Do not add a comparison slider.
```
### 04 · 正常结果
```text
Create “照片预览” with counter “1 / 9”. Add exactly three tabs: “原图”, “精修后”, “氛围感”; select “精修后”. Show the same Santorini photo with badge “AI已修”, filename “IMG_8291.JPG” and “自然通透 · 2026/08/13”. Information card: “精修结果”, “自然通透模板 · 已生成最新版本”. Fixed actions: “AI修图”, “删除”, “刷新”. Use tabs only; no slider or press-and-hold comparison.
```
### 05 · 处理中状态编辑
```text
Use case: precise-object-edit. Keep the successful result screen, photo, tabs and layout unchanged. Replace “AI已修” with spinner + “修图中”. Replace the result card with “正在生成新版本” and “当前仍展示上一版结果,完成后自动更新”, plus an indeterminate shimmer line with no percentage. Keep the previous photo unobscured. Disable only the “AI修图” button; leave “删除” and “刷新” available.
```
### 06 · 失败状态编辑
```text
Use case: precise-object-edit. Keep the successful result screen, photo, tabs and layout unchanged. Replace “AI已修” with a local red badge “生成失败”. Replace the result card with pale-red recovery content: “新版本生成失败”, “上一版结果已保留,可重新修图”, and an outline action “重新修图”. Keep the previous photo unobscured and restore the bottom “AI修图” button to its primary blue state. Do not show a modal, stack trace or error code.
```
## QA 记录
- 六张独立屏稿尺寸一致,使用同一旅拍照片体系与组件语言。
- 精修必选、氛围感选填、四张照片触发封面、封面不占额度均已表达。
- 成功、处理中、失败三种状态均保留同一照片与结果标签,便于直接比较。
- 处理中不展示虚构百分比;重复提交按钮禁用,刷新仍可用。
- 失败状态提供“重新修图”恢复路径,且上一版结果保持可见。
- 本目录只包含设计稿与说明,未修改 UIKit、业务逻辑或 Asset Catalog。
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

+76
View File
@@ -0,0 +1,76 @@
# AI 修图完整链路设计稿 V2
## 本轮调整
- 第 1、2 张沿用 V1 的浅色“轻盈极光蓝”风格。
- 第 3–6 张改为统一的黑色专业影像工作区,突出照片本身。
- 暗色页面不做简单反色:背景、浮层、边框、文字和按钮均使用独立暗色语义层级。
- V1 完整保留在 `../ai-retouch-v1/`,便于对比和回退。
## 交付内容
| 文件 | 页面 / 状态 | 风格 |
|---|---|---|
| `00-flow-overview-v2.png` | 六张关键屏稿总览 | 混合流程 |
| `01-album-multi-selection.png` | 相册管理 · 已选择 4 张 | 浅色 |
| `02-template-selection.png` | AI 修图模板选择 Sheet | 浅色 |
| `03-template-preview-dark.png` | 模板全屏预览 | 暗色 |
| `04-result-success-dark.png` | 精修结果 · 成功 | 暗色 |
| `05-result-processing-dark.png` | 精修结果 · 生成中 | 暗色 |
| `06-result-failed-dark.png` | 精修结果 · 生成失败 | 暗色 |
## 暗色设计 Token
- 页面背景:`#05070D`,允许向深海军蓝 `#07111F` 做非常轻微的层次过渡。
- 抬升表面:`#111827`;边框:`#263244`。
- 主文字:`#F8FAFC`;次文字:`#9CA3AF`;未选中标签:`#C7CED9`。
- 品牌主色:`#1677FF`;蓝色高光:`#5CA8FF`。
- 失败色:`#EF4444`,仅用于失败徽标、恢复入口和局部边框。
- 图片保持明亮自然,不叠加全屏蒙层;信息区和操作区不出现白色表面。
## 可复用 ImageGen 暗色编辑提示词
以下提示词使用内置 ImageGen,以对应 V1 页面作为 edit target;第 3 张暗色稿确定后,也作为第 4–6 张的风格参考。
### 通用暗色编辑约束
```text
Use case: precise-object-edit
Restyle the target into a cohesive dark professional photography workspace.
Preserve the exact iPhone portrait dimensions, photo content and crop, person identity and pose, all Chinese copy, hierarchy, controls and safe-area spacing.
Use background #05070D, elevated surface #111827, border #263244, primary #1677FF, text #F8FAFC and secondary #9CA3AF.
Keep the bright photo unchanged as the focal point. Use white text and SF Symbols-style white icons. Use brand blue only for selected state and primary actions.
No white page areas, no comparison slider, no device frame, no annotations, no watermark and no additional copy.
```
### 模板预览
```text
Keep “自然通透”, “双指缩放查看细节”, the Santorini photo and “模板效果预览”. Change the lower information panel to charcoal with white heading, muted description and blue “当前已选择”. Change the complete bottom safe area to black and retain the blue “使用此模板” action.
```
### 修图结果
```text
Keep “照片预览”, “1 / 9” and the three tabs “原图 / 精修后 / 氛围感”. Use a charcoal segmented control with “精修后” selected in blue. Keep “AI已修”, the filename metadata, the photo and the result information. Change the result card and secondary actions “删除 / 刷新” to dark surfaces; keep “AI修图” blue.
```
### 处理中状态
```text
Based on the approved dark result screen, change only the badge to spinner + “修图中”. Keep the previous photo visible. Change the card to “正在生成新版本” and “当前仍展示上一版结果,完成后自动更新”, with an indeterminate shimmer line and no percentage. Disable only “AI修图”.
```
### 失败状态
```text
Based on the approved dark result screen, change only the badge to “生成失败”. Keep the previous photo visible. Use a charcoal recovery card with restrained red border, “新版本生成失败”, “上一版结果已保留,可重新修图” and “重新修图”. Keep the main “AI修图” button blue. Do not use a white or pale-red card.
```
## QA 记录
- 第 1、2 张与 V1 像素级复用,没有重新生成。
- 第 3–6 张均为 853 × 1844,暗色背景、表面、文字和操作层级一致。
- 成功、处理中、失败三态保留同一结果标签、照片和底部操作布局。
- 处理中无虚构百分比;失败状态有明确恢复动作;旧结果始终可见。
- 本目录只新增设计稿与说明,未修改 UIKit、业务逻辑或 Asset Catalog。
+54
View File
@@ -0,0 +1,54 @@
# 工作周报(2026.08.10—2026.08.14)
## 本周概况
本周围绕旅拍相册和 AI 修图功能开展开发,共完成 **23 次提交**,涉及 **37 个文件**,代码变更约新增 **11,495 行**、删除 **1,120 行**。
## 本周完成
### 1. 完善旅拍相册图片预览
- 新增 iOS 端全屏图片预览。
- 完善预览工具栏、图片索引胶囊、分段控件及关闭按钮样式。
- 支持原图与 AI 修图版本切换,并修复切换时出现黑屏的问题。
- 增加相册管理页下拉刷新。
- 接入旅拍相册批量删除接口。
### 2. 打通 AI 修图业务流程
- 从相册及图片预览页接入 AI 修图入口。
- 完成图片选择、模板选择、模板预览和任务提交流程。
- 优化修图标签、页面布局及提交反馈。
- 新增 AI 修图前后效果对比功能。
### 3. 新增 AI 修图任务中心
- 完成任务列表及任务详情页面。
- 接入 AI 修图任务相关接口和状态模型。
- 支持处理中、成功、失败等任务状态展示。
- 打通消息中心、推送通知与任务详情跳转链路。
### 4. 优化相册数据同步
- 修复从 OTG 页面返回后旅拍相册未及时刷新的问题。
- 优化修图提交后的页面反馈及状态更新。
- 完善异常状态和页面切换场景下的数据刷新逻辑。
### 5. 补充文档与测试
- 补充 AI 修图需求、接口和交互设计文档。
- 完善相册 API、数据模型、ViewModel、消息中心及推送相关单元测试。
- 增加图片预览、任务中心和修图流程的回归测试覆盖。
## 风险与待验证项
- AI 修图任务中心涉及接口、推送、消息中心和页面跳转,需要结合测试环境继续进行全链路验证。
- 需重点回归任务处理中、成功、失败及网络异常等状态。
- OTG 返回刷新、批量删除和图片版本切换需要在真机及大相册场景下继续验证。
## 下周计划
- 联调 AI 修图任务全生命周期及推送跳转。
- 完成旅拍相册与 AI 修图功能的真机回归。
- 优化大图加载、任务刷新和弱网场景下的交互体验。
- 根据测试反馈修复问题,推进功能验收与发布准备。
+63
View File
@@ -0,0 +1,63 @@
# 工作周报(2026.08.17—2026.08.21)
## 本周概况
本周围绕旅拍相册和 AI 重修流程开展功能完善与体验优化,共完成 **7 次提交**,涉及 **20 个文件**,代码及文档变更约新增 **1,101 行**、删除 **240 行**;同时完成版本号升级,为 **1.3.1** 版本发布做准备。
## 本周完成
### 1. 完善 AI 重修业务规则
- 根据是否已有 AI 结果区分首次修图与重新修图流程。
- 已有 AI 结果时,统一支持精修和氛围感模板按需选择,并校验至少选择一种模板。
- 优化单图、多图场景下的模板必选规则、生成数量与额度提示。
- 对齐 AI 重修请求参数、模板范围及结果覆盖规则,确保未选结果类型保持原有版本不变。
### 2. 优化相册素材状态与删除保护
- 将购买状态和 AI 修图状态拆分展示,避免不同业务状态互相覆盖。
- 扩展素材选择能力,支持跨相册 Tab 进入选择模式。
- 增加已购素材删除保护,批量删除时仅提交未购买素材。
- 优化删除确认提示,明确已选、已购及实际可删除数量;全部为已购素材时阻止删除并给出反馈。
### 3. 改进任务详情与图片预览体验
- 优化 AI 修图任务详情中的结果类型标签、状态区域和失败信息布局,提升信息辨识度。
- 调整任务目标标题、模板和操作按钮布局,改善长文本与不同状态下的适配表现。
- 完善图片预览初始版本选择,当前版本不可用时自动回退到有效图片。
- 新增高对比度延迟加载指示器,减少快速加载时的闪烁,并优化未知文件大小的展示。
### 4. 完善相册刷新能力
- 为旅拍相册入口增加下拉刷新。
- 补充刷新状态管理,确保成功、失败及取消场景均能正确结束刷新动画。
### 5. 补充文档与测试覆盖
- 新增 AI 重新修图接口优化需求文档,并同步更新 AI 修图需求及接口说明。
- 完善 AI 修图模板、重修请求、相册素材模型、删除保护及页面布局相关单元测试。
- 增加首次修图、已有结果重修、模板选填和异常状态等关键场景的回归覆盖。
### 6. 推进版本发布准备
- 将项目版本号升级至 **1.3.1**。
- 整理本轮 AI 重修规则、交互调整及测试范围,为后续联调验收提供依据。
## 进行中
- 补充 AI 重修模板占位图的多倍图资源配置。
- 继续验证 AI 重修、批量删除及图片预览在真机和真实数据下的完整表现。
## 风险与待验证项
- AI 重修规则涉及模板范围、额度计算和结果覆盖,需要结合测试环境验证服务端行为是否与最新规则一致。
- 已购与未购素材混合选择时,需要重点回归删除参数、数量提示及刷新后的数据一致性。
- 图片预览需继续验证弱网、大图及不同 AI 结果组合下的加载、回退和切换体验。
- 本周测试代码已补充,仍需在已连接的 iPhone 真机上完成完整测试回归并记录结果。
## 下周计划
- 完成 1.3.1 版本真机测试、问题修复及发布验收。
- 联调 AI 重修模板选择、额度扣减、任务生成与结果覆盖全链路。
- 回归相册下拉刷新、素材状态展示和已购素材删除保护。
- 补齐 AI 重修模板多倍图资源,优化弱网和大图加载体验。
+51
View File
@@ -0,0 +1,51 @@
# 工作周报(2026.08.24—2026.08.28)
## 本周概况
本周围绕随心瞰商家版 iOS 的线下收款、旅拍相册自动修图和门店身份注销开展开发与优化。已完成 **6 次提交**;门店身份注销相关改动仍在工作区,尚未提交,完整终态验收待补齐。
## 本周完成
### 1. 完成线下收款登记与日清功能开发
- 新增线下收款首页卡片、收款登记及日清页面,接入统计、登记、按日明细和补缴接口。
- 支持按日期查看收款与待补缴情况,完善金额校验及快速切换日期时的旧请求结果隔离。
- 优化周/月日历切换、翻页、选中态及未来日期限制,调整收款方式图标和明细展示。
- 补充接口调整说明及金额、日历边界、请求参数和页面相关测试。
### 2. 新增相册自动修图与 OTG 状态预览
- 支持相册级自动修图配置、精修模板选择和传输模式选择,上传完成后自动提交修图任务。
- 在 OTG 页面区分上传与修图状态,增加修图状态角标、精修结果预览及失败重试。
- 优化自动修图设置页,支持同页切换修图方式、三列模板展示和效果对比预览,保留草稿选择及滚动位置。
- 补充配置、任务状态和预览交互测试,并完善 AI 重修占位图资源配置。
## 进行中:门店身份注销
- 已接入现有门店身份注销接口,完成“确认资产 → 手机验证”两步流程,以及条件查询、短信验证、申请和撤销逻辑;注销范围仅限当前门店身份。
- 已实现申请明确成功后退出登录、下次登录取得身份凭证后核验状态,以及受限状态下的业务访问和推送跳转控制。
- 完善身份切换与旧响应隔离、异常状态保护、下拉刷新及统一 Loading,移除普通登录和切换身份时无条件出现的注销提示。
- 已在测试环境验证零资产确认、短信申请、冷静期查询和主动撤销流程,并整理接入说明及脱敏接口记录;正式注销完成、终审阻断及真实多端场景尚未完成验收。
## 测试与质量
- 根据本周已保存的 iPhone 11 真机回归记录,最新全量测试共 **821 项,811 项通过、10 项失败、0 项跳过**,全量尚未全部通过。
- 接入记录显示,**98 项注销相关测试全部通过**,失败用例集合与上一轮一致;这些 Mock 测试不替代真实终态验收。
- 现存失败涉及部分接口解码、头像裁剪和页面布局断言,需继续排查修复。
## 风险与待验证项
- 门店注销仍缺少正式完成、终审阻断的完整接口响应及终态鉴权依据,暂不能按完整功能验收。
- 非零资产、确认过期、错误验证码、重复申请及多端状态变化仍需补充真实场景验证。
- 线下收款和自动修图需继续进行真实数据联调,重点检查补缴统计一致性、弱网重试及前后台恢复。
## 下周计划
- 补齐门店身份注销终态接口契约,推进完整流程与多身份场景验收。
- 排查并修复现有 10 项测试失败,完成受影响模块的真机回归。
- 联调线下收款登记、日清补缴及自动修图全流程,根据反馈完善交互和异常处理。
- 整理未提交改动、测试证据及交付文档,推进本轮功能验收。
---
整理依据:本周 Git 提交、当前工作区改动、门店身份注销接入记录及已保存的真机测试摘要;本次周报整理未重新运行测试。
@@ -0,0 +1,43 @@
# 账号注销后端接口需求(简洁版)
> **本方案已被替代:** 2026-08-28 确认改用旧接口实现当前门店身份注销,不再实施主账号注销。参见 [门店身份注销接入说明](门店身份注销接入说明.md)。下文仅为历史记录。
当前 iOS 已有注销页面和本地 Mock 流程,需要后端提供真实能力。以下路径为建议,可复用已有等价接口。
## 1. 需要的接口
统一前缀:`/api/app/account-deletion`
| 接口 | 入参 | 需要返回 |
| --- | --- | --- |
| `GET /precheck` 注销核验 | 无,以 Token 识别主账号 | 钱包、作品与相册、项目、云盘资产;脱敏手机号;注销影响说明;是否可注销及阻断原因;核验标识和有效期 |
| `POST /send-sms-code` 发送验证码 | 无,发送至主账号绑定手机号 | 验证码会话标识、有效秒数、重发间隔;不返回验证码 |
| `POST /submit` 提交申请 | 核验标识、验证码会话及验证码、资产确认项、说明版本、幂等请求 ID | 申请 ID、状态、提交时间、计划注销时间 |
| `GET /status` 查询状态 | 无,以有效身份凭证识别主账号 | 当前状态、申请信息、能否取消、服务端时间 |
| `POST /cancel` 取消申请 | 申请 ID,使用恢复专用凭证 | 取消结果、新登录临时 Token、当前可选景区/门店身份 |
沿用现有 `token` 请求头和 `code/msg/data` 响应结构,成功码为 `100000`。错误需区分:注销条件不满足、核验过期、验证码错误/过期/限流、已有申请、超过取消期限、凭证失效。
## 2. 现有登录与鉴权需要配合
- 修改 `POST /api/app/v9/login`:身份验证通过后,正常账号按原流程登录;冷静期账号返回注销信息及**短时恢复专用 Token**,等待用户确认;已到期或已注销账号禁止登录。
- 恢复专用 Token 只能查询状态和取消绑定申请,不能访问业务接口。用户点击“恢复账号并登录”才调用取消接口,重新登录本身不能自动取消注销。
- `/api/app/v9/set-user`、刷新凭证及统一鉴权必须检查主账号状态,防止旧 Token、旧版本或其他设备绕过限制。取消成功后签发新凭证,不恢复旧 Token。
## 3. 必须保证的业务规则
1. **注销范围:** 手机号登录对应的主账号及其关联身份,由后端从凭证识别;不接受客户端指定任意手机号或用户 ID,不删除景区、门店实体或他人的共享资产。
2. **提交校验:** 后端再次检查验证码、资产确认及未完成业务;重复提交不能生成多个申请或延长冷静期。
3. **七天冷静期:** 截止时间由后端返回和判断,截止前可取消,恰好到期即不可取消;取消与到期任务必须互斥。
4. **会话限制:** 提交成功立即禁止该主账号全部设备的业务访问;客户端清理登录态。提交超时不能直接视为失败,应重新验证身份后查询结果。
5. **到期处理:** 后端自动执行,不依赖 App 在线;冷静期内不做不可逆删除,处理失败可重试,实际处理完成后才标记已注销。
建议状态:`none` 无申请、`pending` 冷静期、`canceled` 已取消、`processing` 到期处理中、`completed` 已完成。
## 4. 请后端与产品确认
- 余额、冻结款、提现中、未完成订单、线下未补缴款和负责人身份是否阻断注销,如何处理。
- 个人资产、共享资产、客户已购内容和交易记录分别删除、保留还是移交;注销后同手机号能否重新注册。
- 最终接口字段和错误码、短信频控、七天是否按 168 小时计算,以及测试账号、到期测试方式和可联调时间。
> 当前 Mock 的固定验证码和“放弃资产”文案仅用于演示,不能直接作为真实业务规则。
+431
View File
@@ -0,0 +1,431 @@
# 账号注销后端接口需求
> **2026-08-28 已被新范围替代:** 本次迭代改为使用旧 `account-deregister` 接口,仅注销当前 `store_user` 身份,不实施本文的主账号注销、新接口或恢复专用 Token 方案。当前接入情况见 [门店身份注销接入说明](门店身份注销接入说明.md)。下文保留为历史讨论记录。
更新日期:2026-08-27
适用端:随心瞰商家版 iOS;后端账号状态应同时约束 Android、旧版本客户端及其他登录入口。
文档性质:**接口需求建议稿,以下新增路径和字段尚未与后端确认,不代表线上已有接口。**
## 1. 需要后端提供什么
需要 **5 个注销接口、现有登录与鉴权流程改造,以及服务端到期处理任务**。
| 类型 | 建议接口 / 能力 | 用途 |
| --- | --- | --- |
| 新增 | `GET /api/app/account-deletion/precheck` | 返回真实资产、注销影响说明及阻断原因 |
| 新增 | `POST /api/app/account-deletion/send-sms-code` | 向主账号绑定手机号发送注销专用验证码 |
| 新增 | `POST /api/app/account-deletion/submit` | 校验验证码和用户确认,提交注销申请 |
| 新增 | `GET /api/app/account-deletion/status` | 查询当前账号的服务端注销状态及截止时间 |
| 新增 | `POST /api/app/account-deletion/cancel` | 冷静期内由用户明确确认后取消注销 |
| 修改 | `POST /api/app/v9/login` | 身份验证通过后区分正常登录、待注销恢复和不可恢复状态 |
| 修改 | `POST /api/app/v9/set-user` 与统一鉴权 | 禁止待注销或已注销账号取得、使用业务 Token |
| 服务端任务 | 到期注销、失败重试、会话失效 | 不依赖客户端在线或再次打开 App |
如果已有等价能力,可以复用后端现有路径,但需覆盖本文的数据和行为要求,不必重复建设。
### 当前客户端状态
- 已有设置入口、资产核验页、短信验证页、成功页、密码登录时的恢复确认和冷启动检查。
- 当前为 `AccountDeletionMockService`:资产是固定示例,验证码固定为 `123456`,注销记录保存在本机 `UserDefaults`。
- **目前不会发送真实短信,也不会注销后端账号或删除后端数据。**
- 接入真实接口后,服务端状态为唯一依据;本机时间、手机号输入值及本地记录不能作为注销结果或操作权限的依据。
## 2. 账号范围与统一约定
### 2.1 注销对象
当前功能意图是注销**手机号登录对应的主账号及其关联业务身份**,不是仅退出当前登录,也不是只停用当前选中的景区或门店身份。
- 后端根据 Token 解析稳定的主账号 ID,并据此聚合所有关联景区、门店身份的数据。
- 请求不接受客户端指定待注销的 `user_id`、`username`、`phone`、`scenic_id` 或 `store_id`;短信收件人也由后端确定。
- 当前业务身份资料里的手机号可能与主账号绑定手机号不同,不能直接用业务身份手机号发送注销短信。
- “解除景区、门店账号”指解除该用户的关联身份;**不应删除景区、门店实体,也不能误删其他用户、门店或客户共同拥有的数据**。
- 若存在管理员、负责人或共享资产,需明确移交、保留或阻断策略,不能因为客户端勾选“放弃资产”就直接删除。
### 2.2 请求和响应
沿用当前 App 网络层约定:
```http
Content-Type: application/json
Accept: application/json
token: <当前请求所需的凭证>
X-APP-VERSION: <客户端版本>
X-OS-TYPE: <客户端现有平台标识>
```
当前工程使用 `token` 请求头,**不是** `Authorization: Bearer ...`。恢复专用凭证也建议放在同一请求头,由服务端识别凭证类型和权限。
```json
{
"code": 100000,
"msg": "success",
"data": {}
}
```
- 成功业务码沿用 `100000`;新增失败码由后端统一分配,见第 6 节。
- JSON 字段使用 `snake_case`;布尔值使用 `true/false`,空列表使用 `[]`。
- ID 建议统一返回字符串,客户端不依赖数据库自增 ID 或 UUID 的内部格式。
- 时间统一使用带时区的 ISO 8601 字符串,例如 `2026-08-27T08:00:00Z`;客户端负责本地化显示。
- 时间相关响应返回 `server_time`;冷静期截止、验证码到期和取消资格全部由服务端判断。
### 2.3 状态及七天冷静期
建议在真实服务中区分“不可再取消”和“数据已处理完成”,避免定时任务尚未完成就展示为永久删除成功。
| `state` | 含义 | 允许恢复 | 允许进入业务 |
| --- | --- | --- | --- |
| `none` | 没有注销申请 | 不适用 | 是 |
| `pending` | 在冷静期内,待用户取消或到期 | 是,且服务端当前时间必须早于截止时间 | 否 |
| `canceled` | 最近一次申请已取消 | 不适用;可以重新申请 | 是,需重新取得有效业务 Token |
| `processing` | 已到截止时间,正在执行最终处理 | 否 | 否 |
| `completed` | 服务端已完成约定的注销处理 | 否 | 否 |
流转:`none/canceled → pending → processing → completed`;仅 `pending` 且未到期时允许转为 `canceled`。
- 建议默认冷静期为提交成功后 `7 × 24` 小时,后端返回准确的 `scheduled_deletion_at`,客户端不自行推算。
- 恰好到达截止时刻即不可取消;即使定时任务尚未运行,接口也必须立即按不可恢复处理。
- 任务失败保持不可恢复状态,记录原因并重试,不能重新开放登录或重置冷静期。
- 当前 Mock 没有 `processing` 状态;接入真实后端时客户端需同步扩展。
## 3. 五个接口的详细需求
### 3.1 注销前置核验
```http
GET /api/app/account-deletion/precheck
```
使用有效业务 Token,无查询参数。返回整个主账号范围内的资产快照、当前绑定手机号的脱敏值、注销后果,以及是否允许提交。
成功响应示例(资产数值仅为示例):
```json
{
"code": 100000,
"msg": "success",
"data": {
"precheck_id": "precheck_example_001",
"expires_at": "2026-08-27T08:10:00Z",
"server_time": "2026-08-27T08:00:00Z",
"masked_phone": "138****0000",
"can_submit": true,
"blocking_reasons": [],
"assets": [
{"kind": "wallet", "title": "钱包余额", "value": "0.00", "unit": "CNY", "value_text": "¥0.00"},
{"kind": "works", "title": "作品与相册", "value": "36", "unit": "item", "value_text": "36个"},
{"kind": "projects", "title": "项目", "value": "4", "unit": "item", "value_text": "4个"},
{"kind": "cloud_files", "title": "云盘文件", "value": "8589934592", "unit": "byte", "value_text": "8 GB"}
],
"consequences": [
"本人关联的景区与门店身份将解除",
"个人作品和云盘文件将按已确认的规则处理",
"提交后7天内再次登录并确认恢复,可取消注销"
],
"acknowledgement_version": "account-deletion-v1"
}
}
```
| 字段 | 要求 |
| --- | --- |
| `precheck_id` / `expires_at` | 后端生成的核验快照标识及有效期,绑定当前主账号,用于确认用户看到的资产和后果 |
| `masked_phone` | 主账号实际绑定手机号的脱敏值;不返回短信验证码 |
| `can_submit` | 是否满足注销条件;客户端据此禁止或允许继续 |
| `blocking_reasons` | 不可提交时返回 `[{"reason_code":"WALLET_NOT_SETTLED","message":"请先处理钱包余额或结算中的款项"}]`,可有多项 |
| `assets` | 当前页面需要 `wallet`、`works`、`projects`、`cloud_files` 四类;零资产也返回对应项 |
| `value` / `unit` / `value_text` | 原始值统一为字符串;金额为元且保留两位小数,计数、字节为整数字符串;`value_text` 供页面直接展示 |
| `consequences` | 由后端按最终业务规则返回,不能宣称会删除实际需要保留的数据 |
| `acknowledgement_version` | 本次注销说明版本,随提交保存确认记录 |
要求:
- 核验不得触发删除、资金扣除、身份解绑或短信发送。
- 作品、相册、项目及云盘文件的统计口径和去重方式由后端明确,不能把共享资产全部算成该用户可删除的资产。
- 余额、冻结款、提现中、未完成订单、未补缴收款等是否阻断,由产品和后端确认。**在规则确认前,建议未结清资金类问题阻断注销,不直接照搬 Mock 的“放弃余额”。**
- 提交时必须再次校验条件。资产或影响范围发生需要重新确认的变化时,返回“请重新核验”,不得默默沿用旧快照。
### 3.2 发送注销短信验证码
```http
POST /api/app/account-deletion/send-sms-code
```
使用有效业务 Token,请求体为 `{}`。只发送给当前主账号绑定手机号,不接受任意手机号参数。
```json
{
"code": 100000,
"msg": "success",
"data": {
"verification_id": "verification_example_001",
"masked_phone": "138****0000",
"expires_in": 300,
"retry_after": 60,
"server_time": "2026-08-27T08:01:00Z"
}
}
```
- `verification_id` 是验证码会话标识,绑定主账号、当时的绑定手机号及“账号注销”用途,提交时一并携带。
- `expires_in`、`retry_after` 单位为秒;示例为 5 分钟有效、60 秒后可重发,实际值由后端配置并返回。
- 使用 6 位数字验证码;不在响应、日志或埋点中返回明文验证码。
- 设置账号、手机号、IP 等维度的频率限制及错误次数限制;重发后旧验证码失效。
- 与登录、提现、实名认证等验证码用途隔离。可复用短信基础设施,不可混用验证码。
- 主账号换绑手机号后,旧手机号对应的验证码会话及核验快照失效,要求重新开始。
- 缺少绑定手机号、发送失败或触发限流时返回明确错误;前端不能在失败时显示“已发送”。
### 3.3 提交注销申请
```http
POST /api/app/account-deletion/submit
```
使用有效业务 Token。请求示例:
```json
{
"client_request_id": "709277cb-2085-49c1-b80f-042476c7c36b",
"precheck_id": "precheck_example_001",
"verification_id": "verification_example_001",
"sms_code": "482951",
"acknowledged_asset_kinds": ["wallet", "works", "projects", "cloud_files"],
"acknowledgement_version": "account-deletion-v1"
}
```
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `client_request_id` | 是 | 客户端为本次提交生成的 UUID 字符串;网络重试沿用同一个值 |
| `precheck_id` | 是 | 当前主账号的有效核验快照,不接受其他账号的快照 |
| `verification_id` / `sms_code` | 是 | 验证码会话及用户输入的真实短信码;示例不是固定验证码 |
| `acknowledged_asset_kinds` | 是 | 用户已确认的资产类别,须与快照中要求确认的集合一致 |
| `acknowledgement_version` | 是 | 用户确认的注销说明版本 |
成功响应示例:
```json
{
"code": 100000,
"msg": "success",
"data": {
"state": "pending",
"can_cancel": true,
"server_time": "2026-08-27T08:02:00Z",
"request": {
"id": "deletion_example_001",
"client_request_id": "709277cb-2085-49c1-b80f-042476c7c36b",
"status": "pending",
"submitted_at": "2026-08-27T08:02:00Z",
"scheduled_deletion_at": "2026-09-03T08:02:00Z",
"canceled_at": null,
"completed_at": null,
"acknowledged_asset_kinds": ["wallet", "works", "projects", "cloud_files"],
"acknowledgement_version": "account-deletion-v1"
}
}
}
```
处理要求:
1. 服务端重新校验身份、验证码、快照、确认版本以及资金和未完成业务条件。
2. 原子保存申请及用户确认记录、消耗验证码,并使该主账号所有设备上的业务 Token、旧登录临时 Token 和刷新凭证失去业务访问权限。
3. 冷静期内保留可恢复的身份及数据,不提前执行不可逆删除;状态必须限制新建订单、上传、提现等业务操作。
4. 同一主账号同一时刻最多有一条有效申请。已成功的同一 `client_request_id` 重试应返回原申请,不因验证码已消费而重复报错;同一幂等键不能承载不同请求内容。
5. 账号已有待处理申请时,不创建第二条,也不延长原截止时间;返回原状态或可识别的“已有申请”错误,客户端转查询状态。
6. 成功页使用服务端返回的截止时间。客户端收到成功响应后应立即清理业务登录态;即使用户尚未点击成功页“退出”,后端也已禁止业务访问。
**响应丢失的处理:** 提交可能已成功且原 Token 已失效。此时客户端重新完成身份验证,通过登录接口取得恢复专用凭证,再查 `status`;不得把网络超时直接当成提交失败,也不得为了查询结果自动取消注销。
### 3.4 查询注销状态
```http
GET /api/app/account-deletion/status
```
- 无查询参数,以凭证定位主账号。
- 接受有效业务 Token、正常登录临时 Token,或第 4 节定义的恢复专用 Token;不能提供匿名按手机号查询。
- 原业务 Token 已被注销操作撤销时,不重新赋予其查询权限,应先重新验证身份取得恢复专用 Token。
- 用于启动检查、回到前台、多设备状态校准,以及提交/取消请求超时后的结果确认。
响应 `data` 与提交接口一致,包含 `state`、`can_cancel`、`server_time`、`request`。无申请时:
```json
{
"code": 100000,
"msg": "success",
"data": {
"state": "none",
"can_cancel": false,
"server_time": "2026-08-27T08:00:00Z",
"request": null
}
}
```
- `canceled` 返回最近取消的申请和 `canceled_at`;`completed` 返回实际完成时间 `completed_at`。
- `can_cancel` 由后端计算,只能在状态为 `pending` 且未到截止时刻时为 `true`。
- 查询不得取消注销或延长冷静期。到期任务尚未完成时返回 `processing`,不能仅凭时间到了就返回 `completed`。
- 状态查询失败时,不应默认账号正常;客户端提示重试或重新登录,业务接口仍由后端鉴权保护。
### 3.5 取消注销申请
```http
POST /api/app/account-deletion/cancel
token: <恢复专用 Token>
```
只有重新验证身份且用户点击“恢复账号并登录”后调用:
```json
{
"request_id": "deletion_example_001"
}
```
成功响应示例:
```json
{
"code": 100000,
"msg": "success",
"data": {
"request_id": "deletion_example_001",
"state": "canceled",
"canceled_at": "2026-08-28T01:00:00Z",
"server_time": "2026-08-28T01:00:00Z",
"login": {
"token": "new-account-selection-token",
"scenic_users": [],
"store_users": []
}
}
}
```
- `login` 结构复用现有 v9 登录响应;示例数组省略业务内容,实际需返回恢复后当前可选的景区、门店身份。
- 返回的 `login.token` 为新生成的正常账号选择临时 Token;客户端继续走原有单账号自动选择、多账号选择及 `/v9/set-user` 流程,不复用注销前的旧凭证。
- `request_id` 必须与凭证绑定的主账号和注销申请一致,不能取消别人的申请或该账号下一次新申请。
- 与到期任务通过事务或等效并发控制互斥;按服务端时间决定取消是否成功,不能出现既恢复又删除的结果。
- 对同一已取消申请的重试,不重复改变状态或产生副作用。若凭证仍有效,可返回原取消结果和有效登录上下文;若凭证已撤销或过期,重新登录确认状态,不要求用户再次取消。
- 到期、`processing`、`completed` 均拒绝恢复,返回可识别错误;不得重新启用旧 Token。
## 4. 登录、会话与多端配合
### 4.1 调整现有 `/api/app/v9/login`
保留现有手机号密码登录参数。必须先完成密码/验证码等身份验证,再返回该主账号的注销状态,避免泄露任意手机号是否注册或注销。
| 状态 | 登录接口行为 |
| --- | --- |
| `none` / `canceled` | 沿用当前 `token`、`scenic_users`、`store_users`;可附带最新注销状态 |
| 未到期的 `pending` | 不签发正常账号选择或业务 Token;返回申请信息和恢复专用 Token,等待用户决定 |
| `processing` / `completed` | 返回明确的不可登录、不可恢复业务错误,不签发可登录凭证 |
待注销登录建议使用成功 Envelope 承载“验证身份成功但尚未登录”的结果:
```json
{
"code": 100000,
"msg": "success",
"data": {
"token": "",
"scenic_users": [],
"store_users": [],
"account_deletion": {
"state": "pending",
"request_id": "deletion_example_001",
"submitted_at": "2026-08-27T08:02:00Z",
"scheduled_deletion_at": "2026-09-03T08:02:00Z",
"server_time": "2026-08-28T01:00:00Z",
"can_cancel": true
},
"recovery_token": "opaque-recovery-token",
"recovery_token_expires_at": "2026-08-28T01:10:00Z"
}
}
```
恢复专用 Token 要求:
- 短时有效,并绑定主账号、当前注销申请和允许的操作;示例有效期为 10 分钟,实际由后端配置。
- **仅可查询注销状态和取消该申请**,不能调用 `/v9/set-user`、订单、钱包、文件下载等业务接口,也不能兑换或刷新成业务 Token。
- 不能通过改请求参数变成其他用户的凭证;不记录到普通日志、埋点或 URL。
- 用户选择“暂不登录”只结束本次登录,不调用取消接口;重新登录或查询本身不得自动恢复账号。
- 取消完成后撤销该申请的恢复操作权限;新登录凭证按取消接口约定返回。
### 4.2 修改 `/api/app/v9/set-user` 和统一鉴权
- 无论客户端是否接入新功能,待注销及不可恢复账号都不能通过旧临时 Token、刷新 Token、账号切换或其他登录入口继续使用业务。
- 校验“凭证属于谁、凭证可做什么、账号当前是什么状态”,不能只检查签名或过期时间。
- 提交注销需要覆盖同一主账号的全部设备和全部关联业务身份,不仅让提交申请的 iPhone 退出。
- Android 当前有 `/v9/login` 的密码和短信两种登录方式;所有方式执行相同的状态检查。iOS 短信登录入口目前仍待接入,不能因此遗漏后端约束。
- 冷静期取消后签发新凭证,不恢复旧凭证的有效性;业务身份列表以取消时的最新数据为准。
### 4.3 与客户端接入的关系
当前本地 `loginState`、`cancelDeletion` 是同步方法,接入网络后需改为异步;不能只替换 Mock 类名。
客户端还需扩展登录返回模型、恢复专用 Token 上下文、`processing` 状态、短信倒计时、阻断原因和核验快照处理。当前 Mock 使用登录输入框中的手机号取消注销,真实接口必须改用受验证的主账号/申请上下文,不能继续依赖可编辑输入值。
当前 Mock 申请 ID 使用 Swift `UUID`,资产枚举原始值包含 `cloudFiles`;本文建议接口使用字符串 ID 和 `cloud_files`。客户端需通过网络 DTO 显式映射,不能把本地 Mock 模型直接序列化后当作请求契约。
## 5. 服务端必须负责的后台处理
- 提交成功后持久保存申请,App 卸载、退出或长期离线都不影响到期处理。
- 冷静期内不执行不可逆清理;到期后先禁止恢复,再按最终确认的规则解除身份、处理个人资产和第三方关联。
- 共享资产、资金账务、交易记录、客户已购买内容等分别制定处理方案;需要保留的记录与可删除的个人数据应区分,保留范围及周期由相关负责人确认。
- 处理任务可重复执行且有重试机制;只有约定的必需处理步骤完成后才标记 `completed`,部分失败不能假报成功。
- 保留可审计的申请、确认说明版本、时间、取消记录、处理进度及失败原因;审计中不保留明文验证码或 Token。
- 到期删除与取消操作必须有统一的并发保护;业务鉴权和后台任务读取一致的主账号状态。
- 需要测试环境的可控时间或缩短冷静期能力,以便验证到期边界及失败重试;不得在生产开放客户端任意修改截止时间的接口。
## 6. 错误返回要求
沿用整数 `code` 和可直接展示的中文 `msg`。以下名称是**待后端分配业务码的语义清单**,不是现有错误码。
| 错误语义 | 典型场景 | 客户端处理 |
| --- | --- | --- |
| `ACCOUNT_IDENTITY_REQUIRED` | 主账号没有可用于验证的绑定手机号 | 提示先处理账号信息 |
| `DELETION_BLOCKED` | 资金未结清、订单未完成、负责人未移交等 | 展示阻断原因,禁止提交 |
| `PRECHECK_EXPIRED` / `PRECHECK_CHANGED` | 快照过期、资产或说明版本发生变化 | 重新核验并要求再次确认 |
| `ACKNOWLEDGEMENT_REQUIRED` | 缺少必需资产确认或确认版本不匹配 | 返回资产核验页 |
| `SMS_SEND_FAILED` / `SMS_RATE_LIMITED` | 短信发送失败或限流 | 提示重试;限流返回重试秒数 |
| `SMS_CODE_INVALID` / `SMS_CODE_EXPIRED` / `SMS_ATTEMPTS_EXCEEDED` | 验证码错误、过期、尝试超限 | 提示重输或重新发送 |
| `DELETION_ALREADY_PENDING` | 已有有效申请 | 查询原申请,不重复创建 |
| `NO_PENDING_DELETION` | 申请不存在或并非当前可取消申请 | 查询最新状态 |
| `DELETION_NOT_CANCELABLE` | 已到截止时间或正在最终处理 | 不再提供恢复入口 |
| `ACCOUNT_DELETION_COMPLETED` | 已完成注销 | 禁止登录和恢复 |
| `RECOVERY_TOKEN_INVALID` / `RECOVERY_TOKEN_EXPIRED` | 恢复凭证失效 | 重新验证身份,不自动取消 |
| `IDEMPOTENCY_CONFLICT` | 同一幂等键用于不同请求内容 | 停止自动重试,提示重新操作 |
错误响应继续采用现有 Envelope。后端如需在 `data` 中返回 `blocking_reasons`、`retry_after` 等结构化详情,需同时给出字段契约;当前 iOS `APIClient` 对失败响应只暴露 `code/msg`,接入时需要补充详情解析。
**与现有全局登录失效处理区分:** 当前 iOS 会将 HTTP `401/403` 及业务码 `180024`、`100091`、`100090`、`100060` 视为登录失效。资产阻断、验证码错误、快照过期等业务问题不要复用这些码,避免误触发全局退出。真实凭证失效仍沿用项目鉴权规则。
## 7. 联调验收清单
1. 主账号关联多个景区、门店时,核验范围完整;切换业务身份不会变成另一个注销对象;不能查询、提交或取消其他主账号的申请。
2. 无手机号、无资产、存在余额/冻结款/未完成业务时,分别返回约定的正常或阻断结果。
3. 正确验证码可提交;错误、过期、重发前旧码、其他用途码、其他账号验证码都不可提交。
4. 资产确认不完整、核验过期、资产发生变化、说明版本不一致时,后端拒绝并要求重新确认。
5. 成功申请返回准确截止时间;重复请求不产生新申请、不重置冷静期;请求超时后可重新验证身份并查到真实结果。
6. 提交成功后所有设备及旧版本业务访问受限;旧 Token、账号选择、短信登录等不能绕过注销状态。
7. 冷静期登录只显示恢复确认;点“暂不登录”保持待注销,点“恢复账号并登录”后取消并取得新的账号选择凭证。
8. 恢复专用 Token 只能查询状态和取消绑定申请;不能访问业务或取消其他申请。
9. 截止前可取消、恰好截止不可取消;取消与到期任务并发时只产生一个一致结果。
10. 未运行 App 也会按期处理;任务部分失败会重试,处理期间保持不可恢复,未完成时不返回 `completed`。
11. 已取消账号可再次发起新申请;旧申请和旧恢复凭证不能影响新申请。
12. 完成注销后不能登录或恢复;共享数据、客户内容和需保留记录按确认的方案处理,没有越权删除。
## 8. 请后端与产品确认并回传
- **账号及数据边界:** 主账号映射、关联身份范围;作品与相册/项目的统计口径;共享资产和已售内容的处理方式。
- **准入规则:** 余额是否允许放弃;冻结资金、提现中、未完成订单、线下未补缴款及负责人身份是否阻断,如何解除阻断。
- **时限和最终处理:** 七天是否按 168 小时计算;到期处理内容、记录保留范围与周期;注销后同手机号能否重新注册,且不得恢复旧账号数据。
- **接口契约:** 最终路径、字段类型、完整正常/异常响应、业务码、短信频控、恢复凭证权限及有效期。
- **联调交付:** 测试环境地址、测试账号及各状态样例、到期和失败重试验证方式、预计可联调时间。
上述规则确认前,客户端中的“永久删除”“放弃资产”等 Mock 文案不能直接作为最终业务承诺,应随实际后端处理规则调整。
+160
View File
@@ -0,0 +1,160 @@
# 门店身份注销接入说明
更新时间:2026-08-28。当前分支 `dev_9_7`,未提交或推送。
**最新实测:15:27:24的申请已于15:55:36因重新登录自动撤销(9 / CANCELLED_BY_LOGIN)。最新交互改为申请成功立即退出登录、下次登录取得身份凭证后核验;普通启动和普通页面返回前台不主动查询。本轮不重新申请、不等待终态。**
## 范围
采用用户提供的《260821-App 门店用户账号注销.md》中的旧接口,不需要新增手机号主账号接口。仅注销当前 `store_user` 对应的 `ss_store_user.id`;同手机号其他身份不受影响,不支持景区身份注销。移除了原手机号级 Mock、固定验证码和本地七天完成判定。
统一前缀:`/api/yf-handset-app/account-deregister`。
| 请求 | 请求体 |
| --- | --- |
| GET `/eligibility`、GET `/status` | 无 |
| POST `/waivers/wallet`、POST `/waivers/points` | `{"accepted":true}` |
| POST `/send-sms`、POST `/cancel` | 无业务字段 |
| POST `/apply` | `sms_code`、`reason` |
请求层冻结当前门店用户 ID 和 Token,请求前后核对身份;使用 `session.userId`,不是门店实体 ID。注销请求与响应正文不写入调试日志。完整脱敏数据见[旧接口返回数据说明](门店身份注销旧接口返回数据说明.md),操作经过见[接口实测](门店身份注销接口实测.md)。
## 已实现
- 设置页仅门店身份显示注销入口;页面采用“确认资产 → 手机验证”两步流程,以身份卡、资产卡、须知卡和底部主按钮明确注销范围。
- 注销页、只读条件页和状态页均使用下拉刷新,删除右上角刷新入口;操作过程统一使用全局通用 Loading,不保留页面内的小转圈。“其他身份不受影响”等提示不再区分其他身份类别。
- 查询资产、业务风险等待时间和全部阻断项;现金与积分合并为一个入口和一次弹窗确认,分别列明两项自愿放弃说明,零资产也不省略。底层顺序调用两个旧接口,逐次核对身份、余额及财务归属;部分成功保留真实标记,不自动重发修改请求。
- 条件满足后进入真实短信验证和申请确认。验证码为用户输入,收件手机号由后端决定。`/apply` 明确成功后立即复用已有退出通知清理会话并返回登录页,不再等一次状态查询,也不自动重新登录;响应丢失则保留核验保护,不能冒充成功或自动重发。
- 区分风险等待期 `eligible_at` 与冷静期 `cooling_until`;按服务端原文展示时间,不猜测时区。
- 严格识别实测状态:无记录、`0` 待确认草稿、`1` 冷静期、`9` 已撤销。字段缺失、状态矛盾和未知枚举都不默认放行。
- 普通冷启动使用已有会话直接进入首页;登录流程取得门店身份凭证后才核验状态,期间保留登录页面背景并显示全局 Loading,不显示“正在查询注销状态”的独立页面。正常结果直接进入业务页,冷静期、未知状态或失败才展示结果页。登录中的多身份选择仍属于此核验入口;已登录时切换身份不额外主动查询。完整草稿不影响第二次资产确认或正常使用。若核验发现冷静期,仍以独立卡片显示截止时间并提供下拉刷新、只读条件、主动撤销和退出入口。
- 用户确认撤销后先重查同一申请,POST 成功后再次 GET 核实。确认已撤销才恢复业务;响应丢失不自动重发,旧身份或旧限制版本的响应不能放行当前会话。旧 null/草稿不能作为撤销成功依据。
- 提交前按环境与门店用户 ID 保存意图,不保存 Token、手机号、验证码或资产。明确业务拒绝或新撤销记录可清除;重新申请前的旧撤销记录不能清除新意图。UserDefaults 不是后端幂等或断电事务保障。
- 冷静期 `eligibility` 虽然返回两项确认 false,也禁止再次确认;已撤销可重新准备申请,但必须按最新条件重新确认资产。
- 网络层将 `150015` 作为当前门店凭证的业务限制,不当作全局登录失效。核验期间暂停推送绑定和业务通知跳转;核验通过后恢复。
- 用15:29实测的150015与状态JSON补充集成回归,覆盖 `ProfileAPI.userInfo → APIClient → 身份限制通知 → RootCoordinator → 冷静期页面`;确认原Token和身份保留、业务暂停、无登录或撤销请求。通知转发在测试中使用独立NotificationCenter,不等同于真实SceneDelegate端到端操作。
- 普通页面从后台返回不主动查询,不替换导航栈。仅当前已显示的受限/核验页返回前台时刷新,并废弃后台前的旧查询;不会重新发送短信、申请或撤销。业务接口明确返回当前 Token 的 `150015` 时仍立即限制业务并核验。
- 普通登录、选择及切换身份不再无条件弹出注销提示;自动撤销规则保留在注销提交确认和申请状态页。登录后的服务端状态核验保持不变。身份列表为空时不进入首页,但“全部身份注销后后端究竟返回什么”尚未实测。
## 真实接口和页面验证
2026-08-28 在测试环境、配对 iPhone 11 上分次授权验证:
1. 两项零资产确认成功,第一次确认产生草稿0;第二次确认后允许申请。
2. 真机发送短信并收到验证码,14:23:40 提交测试申请,GET 确认冷静期1。
3. 14:24:04 主动撤销成功,14:24:05 两个 GET 均确认已撤销9。未留下待注销申请,未最终删除身份。
4. 安装新增状态处理后,14:39 重新打开 App,原身份直接恢复首页,没有重新登录;再次进入设置中的注销入口成功。读取详情页的镜像操作随后超时,因此未把撤销后详情页的完整展示记为通过。
短信和申请 POST 的原始响应体未保存,只有界面和后续 GET 的成功证据;不能将 GET 结构冒充 POST 响应。撤销响应体已保存脱敏样例。上述14:24立即撤销流程已结束。
随后用户明确要求在已登录的 iPhone Air 模拟器操作,并确认保留新申请至到期复核:15:27:24提交一次,界面自动进入冷静期限制页。15:28 GET `/status` 和 `/eligibility` 均确认冷静期1;15:29 GET `/userinfo` 返回真实150015,前后状态查询仍为1。截至15:50未撤销或重新登录;15:56再次只读复核时,服务端已返回15:55:36因重新登录自动撤销(9 / CANCELLED_BY_LOGIN)。该申请不再等待到期。模拟器仅用于用户指定的人工操作,不用于替代单元测试的真机要求。
## 自动化验证
- iPhone 11(`00008030-001E48E21139802E`),未使用模拟器。
- 冷静期/撤销首轮:61 项注销测试全部通过(API 8、进入核验21、响应8、流程24)。随后补充一项取消前后旧 null/草稿的防御测试,纳入最后全量回归。
- 冷静期页面真机 Mock 渲染截图已导出并检查,文案、截止时间和撤销入口完整可见。
- 测试中的短信、资产确认、申请与撤销只使用 Mock;宿主 App 仍可能产生既有后台请求,不能称整台设备离线。
- 前台恢复改动之前的全量:781项中771通过、10项失败(15条失败记录,2条 unexpected);与原始基线逐项对比,失败用例集合完全一致,无新增失败。注销相关62项全部通过(API 8、进入核验22、响应8、流程24)。全量不是全部通过。
- 前台路由版全量:791项中781通过、10项失败(15条失败记录,2条 unexpected),失败用例集合与基线完全一致;注销相关72项全部通过。
- UI改版前全量(15:45):792项中782通过、10项失败,无跳过项;失败用例集合仍与基线完全一致,无新增失败。注销相关73项全部通过(API 8、进入核验23、响应8、根路由10、流程24),均在iPhone 11运行;日志与xcresult摘要已交叉核对。
结果文件:
- 本轮61项:`/private/tmp/suixinkan-deregister-cooling-20260828.xcresult`
- 本轮冷静期截图:`/private/tmp/suixinkan-deregister-cooling-20260828-attachments/DB6A885A-C10C-49E0-A5F4-D3EB5D0AA3B1.png`
- 前台恢复改动之前的全量:`/private/tmp/suixinkan-deregister-cooling-full-20260828.xcresult`
- 前台恢复测试尝试(手机锁定,未执行,中断):`/private/tmp/suixinkan-deregister-foreground-20260828.xcresult`
- 当前最终构建(成功,仅编译):`/private/tmp/suixinkan-deregister-final-build-20260828.xcresult`
- 路由修正后32项回归:`/private/tmp/suixinkan-deregister-foreground-r2-20260828.xcresult`
- 前台路由版全量:`/private/tmp/suixinkan-deregister-foreground-final-20260828.xcresult`
- 当前最终全量:`/private/tmp/suixinkan-deregister-real-contract-20260828.xcresult`
- 原始基线:`/private/tmp/suixinkan-account-deletion-baseline-20260827-r2.xcresult`
## 尚未完成,不能视为完整上线验收
1. 旧文档没有给出终审“阻断”“正式完成”的数字枚举和完整响应;当前只能保守展示待核验页,不能伪造终态或自动清理账号。
2. 重新登录/选择身份自动撤销发生在哪个接口、受限 Token 能否切换其他身份、最终注销后鉴权及所有身份注销后的登录响应,尚未实测。
3. 真实150015接口响应已采样,客户端限制通知到核验页已用真实数据Mock验证;实际SceneDelegate端到端操作和真实多端验证仍缺。最新流程不再主动轮询其他设备的申请变化,依赖登录核验、用户进入注销功能查询或业务接口明确限制。
4. 新撤销按钮交互使用 Mock 验证与截图检查;真实撤销通过受控 API 完成,没有再次创建申请来验证新按钮。完整终态页面验收仍未完成。
5. 非零资产、确认过期、错误验证码、重复申请/撤销及完成后的错误响应还缺真实样例。
不要求后端新增接口;补充现有 Controller/Resource/DTO 源码或脱敏响应即可继续。严禁通过本机时间、`remaining_seconds: 0`、`can_apply` 或仍存在 `cooling_until` 推断已注销。
## 此前完整目标核对(15:50历史记录)
下表保留当时的验证范围。本轮最新结果见文末UI改版小节,不继续等待已撤销申请的终态。
| 要求 | 当前证据 | 结论 |
| --- | --- | --- |
| 仅当前门店身份,保留同手机号其他身份 | 请求冻结 `session.userId`/Token,8项请求层测试;无批量接口 | 已实现,路由回归已通过 |
| 展示所有条件、分别确认资产及快照失效 | 真实两项零资产确认与条件响应;模型/流程测试 | 已验证零资产正常流程,未真实操作非零资产 |
| 实际短信、申请确认、7天冷静期 | 真机收到短信,申请后 GET 返回1及7天截止时间 | 已验证,未捕获两个 POST 的原始响应体 |
| 主动撤销并恢复使用 | 真实 cancel 返回9,随后 GET 复核,冷启动恢复首页;撤销逻辑测试 | 主动接口已验证,新弹窗及前台路由Mock测试已通过 |
| 不将150015当作手机号登录失效 | 原请求Token隔离测试、推送暂停测试、真实userinfo响应;真实JSON重放至冷静期UI | 接口及客户端Mock集成已验证,实际SceneDelegate端到端操作待验证 |
| 冷启动、前台、多端及过期响应 | 冷启动已真机检查;RootTests 10项和状态回退测试1项 | 已通过iPhone真机Mock测试;不代替真实多端验证 |
| 冷静期到期复核、阻断、正式完成页面及清理 | 旧文档描述规则,但无终态枚举/响应 | 未完成,需要现有接口样例或源码 |
| 登录/选择同一身份自动撤销、全部身份注销后登录 | 旧文档规则、登录提示和空身份列表Mock测试 | 后端真实行为未验证 |
| 所有相关测试、全量回归及最终页面验收 | 当前73项注销相关测试通过;全量792项中10项基线失败 | 当前代码回归已执行;完整终态及真实多端验收未完成 |
| 不改变其他业务配置,不切分支、提交或推送 | 当前 `dev_9_7`;配置/依赖差异检查为空 | 保持约定 |
若后续扩展终态,仍需:`/status` 在“终审阻断”和“正式完成”时的脱敏完整响应或对应资源模型源码,以及终态 Token 查询和撤销规则。此前获准保留的测试申请已经自动撤销,本轮不重新申请。不得跳过7天冷静期、修改数据库或制造订单/资产变化来取得样例。
### 15:50完成度复核:仍未满足完整目标
直接核对当前状态模型和AccessViewModel:只映射草稿0、冷静期1和已撤销9,未知终态仍进入待核验页;尚无终审阻断、正式完成页面及清理依据。因此73项相关测试通过不能证明完整注销终态已经接入。
15:48与15:50的真实GET都返回同一冷静期申请,`completed_at`和`cancel_time`仍为null。期间只结束并重启iPhone Air的App进程,未清空数据或重新登录;重启后原Token仍能查询同一申请。窗口工具停留在另一模拟器窗口且菜单操作失败,未取得iPhone Air冷启动页面,故不将该项UI验证记为通过。
已查本机、原文档、公开文档入口、现有权限可见代码站及项目列表;仍无终态源码或响应。该缺口在提交后、真实响应集成回归后及本次复核中持续存在。当前需要服务端到期产生新状态,或取得现有后端源码才能推进终态接入;暂停重复测试与状态轮询。没有创建定时任务或自动监控,后续复核需恢复本任务。
## 本轮UI改版(2026-08-28)
- 两步页面沿用App蓝白色、16pt边距和白色圆角卡片;底部固定唯一主按钮,验证码与获取按钮同行;输入完整后才允许提交。
- 资产确认类阻断集中在资产卡,其他条件完整展示为待处理事项;订单等条件未满足时不能开始合并确认。
- 新增confirmAssetsAndContinue(snapshot:api:),跳过已确认项;任何一项失败或资产变化均停留在条件页,核对后由用户主动重试。
- 状态页用图标、标题、身份和截止时间分层展示。未知和失败状态只提供简短说明与重试,不伪造注销完成;删除草稿等调试文案。
- 本轮不扩展终态,不改变登录和状态核验架构,不操作真实短信、确认、申请或撤销。上文15:27—15:50记录为历史过程,当前以15:56已撤销结果为准。
- 最终真机回归:90项注销相关测试全部通过,其中本轮新增17项(13项合并确认、4项UIKit交互/布局);全量809项中799通过、10项失败、0跳过。失败用例集合与改版前基线完全一致,没有新增失败;全量不宣称全部通过。
- 已检查真机Mock截图:375pt资产页、非零资产及业务阻断、手机验证、冷静期、查询失败、未知状态;验证码与原因输入时,输入框可滚动至可见区域,底部按钮位于键盘上方。截图只包含测试窗口,未合成系统独立键盘窗口。所有短信及注销修改请求均为Mock,未再次操作真实注销。
- 现有键盘库的局部兼容处理已验证:只禁用本页两个输入框的重复位移,保持导航栏位置稳定;验证码和原因均可滚动至键盘上方,不改变其他页面的键盘设置。最终回归包含该处理。
本轮验证文件:
- 全量结果:`/private/tmp/suixinkan-deregister-redesign-r5-20260828.xcresult`
- 结构化摘要:`/private/tmp/suixinkan-deregister-redesign-r5-summary.json`
- 截图目录:`/private/tmp/suixinkan-deregister-redesign-r5-images/`
- 中间一次构建成功但因iPhone锁屏未能启动测试;解锁后使用同一构建完成上述真机回归,没有改用模拟器。
## 登录入口提示修复(2026-08-28)
- 移除登录、选择门店身份和切换门店身份时无条件出现的注销提示,删除无用的提示组件。
- 保留手机号/密码/协议校验和加载期间的防重复操作;不改变登录后的注销状态核验。自动撤销规则继续在注销提交确认和申请状态页说明。
- 登录页面支持注入现有AuthAPI用于Mock回归,不改登录协议、不访问真实登录接口,也不写入测试登录会话。
- iPhone 11回归:登录12项、账号切换2项、注销90项全部通过。全量812项中802通过、10项失败、0跳过;失败集合与修复前一致,没有新增失败。
- 结果:`/private/tmp/suixinkan-login-prompt-fix-20260828.xcresult`;摘要:`/private/tmp/suixinkan-login-prompt-fix-20260828-summary.json`。
## 申请成功退出与查询时机简化(2026-08-28)
- 提交前仍核对状态和资产;`/apply` 明确成功即发送现有退出通知,复用推送解绑、会话清理及返回登录页流程。不再追加成功后的 GET,也不先显示申请状态页。
- 仅填验证码、点击提交或打开最终确认弹窗都不会自动申请。最终弹窗新增“提交成功后将退出登录”;连点和成功后重复回调均不能重复申请或重复退出。
- 验证码等明确业务拒绝不退出;申请响应丢失保留提交意图并进入核验保护,不自动重发。已接受的提交意图仍按环境、门店身份保存,供下次登录核实。
- 普通启动和普通页面返回前台不主动查询注销状态;登录得到身份 Token 后核验。旧 `/status` 需要身份凭证,不能在未登录时只凭手机号查询;旧后端登录/选择该身份可能已自动撤销申请,因此此时可能返回已撤销。
- 保留业务 `150015` 限制和受限页面的刷新;不能因为简化正常启动就忽略服务端明确限制。进入注销功能时仍需查询条件与状态。
- 本轮只使用 Mock 验证提交和退出回调,不操作真实短信、资产确认、申请、撤销或登录。
- iPhone 11 真机全量回归:817项中807通过、10项失败、0跳过;失败用例集合与原始基线及上一轮完全一致,无新增失败。注销相关94项全部通过,包含本轮新增的登录核验时机、成功一次退出、失败不退出和最终确认弹窗测试;普通启动/前台不查询的路由断言同步更新。
- 结果:`/private/tmp/suixinkan-deregister-logout-20260828.xcresult`;摘要:`/private/tmp/suixinkan-deregister-logout-20260828-summary.json`。退出通过注入回调验证,没有为验收再次提交真实注销;现有退出通知到会话清理沿用原实现。
## 下拉刷新与统一 Loading(2026-08-28)
- 删除注销流程右上角刷新按钮,资产/验证页、只读条件页和申请状态页均支持下拉刷新;只执行查询,不自动确认资产、发短信、申请或撤销。
- 去掉注销页“景区”相关固定文案,统一描述其他身份不受影响。真实身份名称和服务端待处理事项仍如实展示,不改业务身份或接口字段。
- 初次查询、下拉刷新、确认资产、发送短信、提交申请及撤销操作统一复用 `GlobalLoadingManager` 的全屏遮罩与动画。下拉只作为触发手势,隐藏其系统转圈,避免叠加两套 Loading。
- 登录核验不再先创建查询根页面:保留当前登录页面背景,核验成功直接进入首页;异常结果才创建状态页,复用已查询的结果,不追加重复 GET。
- 保留请求前后的身份校验、限制信号优先级和后台旧响应作废。退出/切换会话会结束本次核验持有的 Loading,迟到响应不能关闭新请求的 Loading 或替换新账号页面。
- 测试使用独立会话、Mock 网络和测试窗口;本轮不发出真实短信、资产确认、申请、撤销或登录请求。
- 最终 iPhone 11 全量回归:821项中811通过、10项既有失败、0跳过,失败集合与上一轮完全一致。98项注销相关测试全部通过,覆盖下拉刷新、失败后结束加载、输入保留、登录期间不换根、重复核验及旧响应隔离。
- 已检查真机 Mock 截图:资产页和冷静期页右上角无刷新按钮;提交中显示现有白色圆角动画卡片与全屏灰色遮罩,没有页面内转圈。登录不换根通过独立窗口路由测试验证,未再次操作真实账号登录。
- 首次增量构建存在旧初始化签名缓存,清理构建产物后解决;连续 UI 回归需要等待测试窗口显示稳定后再模拟登录,修正仅在测试夹具中,业务代码没有新增延时,也没有放宽断言。
- 结果:`/private/tmp/suixinkan-deregister-loading-r3-20260828.xcresult`;摘要:`/private/tmp/suixinkan-deregister-loading-r3-20260828-summary.json`;截图:`/private/tmp/suixinkan-deregister-loading-r3-20260828-images/`。
+342
View File
@@ -0,0 +1,342 @@
# 门店身份注销接口实测
实测时间:2026-08-28 11:39;追加 13:45–13:46 零资产确认(服务端响应时间)。
环境:`https://api-test.zhifly.cn`。首次 11:39 使用用户明确授权的 iPhone 当前 `store_user` 登录 Token,仅执行 `GET /eligibility` 与 `GET /status`,当时未调用修改接口。后续额外授权的两项零资产确认见第 5 节。请求头与当前 Debug App 一致:`token`、`X-APP-VERSION: 1.3.1`、`X-OS-TYPE: iOS`、JSON Accept/Content-Type。
以下为真实响应;仅 `store_user_id`、`finance_identity_id` 统一替换为数值 `0` 脱敏,不能把该值用作有效身份。没有保存 Token。
## 1. 注销条件
`GET /api/yf-handset-app/account-deregister/eligibility`
HTTP 200,业务码 `100000`:
```json
{
"code": 100000,
"msg": "success",
"data": {
"can_apply": false,
"store_user_id": 0,
"finance_identity_id": 0,
"wallet_balance_fen": 0,
"wallet_balance": "0.00",
"points_balance": 0,
"wallet_waived": false,
"points_waived": false,
"unfulfilled_count": 37,
"fulfillment_in_progress_count": 0,
"risk_end_at": "2026-08-27 15:47:32",
"eligible_at": "2026-09-03 15:47:32",
"risk_window_hours": 168,
"blockers": [
{
"code": "ORDER_UNFULFILLED",
"message": "账户仍有未履约订单或带单",
"action": "complete_orders"
},
{
"code": "RISK_WINDOW_NOT_EXPIRED",
"message": "最后一笔业务的风险结束时间尚未超过7天",
"action": "wait_risk_window"
},
{
"code": "WALLET_WAIVER_MISSING",
"message": "请先确认放弃现金余额",
"action": "confirm_wallet_waiver"
},
{
"code": "POINTS_WAIVER_MISSING",
"message": "请先确认放弃积分",
"action": "confirm_points_waiver"
}
],
"deregister": null
},
"time": "2026-08-28 11:39:51"
}
```
本次观测到的类型:
| 字段 | JSON 类型 / 含义 |
| --- | --- |
| `can_apply` | 布尔值,服务端是否允许申请 |
| `store_user_id`、`finance_identity_id` | 整数 |
| `wallet_balance_fen` | 整数,现金余额(分) |
| `wallet_balance` | 字符串,金额展示值;本次为 `"0.00"` |
| `points_balance` | 整数,积分余额 |
| `wallet_waived`、`points_waived` | 独立布尔值,是否已确认放弃 |
| `unfulfilled_count`、`fulfillment_in_progress_count` | 整数 |
| `risk_end_at`、`eligible_at` | 时间字符串,格式 `yyyy-MM-dd HH:mm:ss`;时区及无历史业务时的空值形式尚未确认 |
| `risk_window_hours` | 整数,本次为 `168` |
| `blockers` | 对象数组,每项有字符串 `code`、`message`、`action` |
| `deregister` | 本次为 `null`,尚未观测非空结构 |
本次余额与积分均为零,服务端仍返回两项放弃确认缺失。客户端不能因为资产为零就省略确认。当前还有 37 项未履约订单或带单,且风险等待期未结束,不能提交注销。`eligible_at` 是业务风险期截止时间,不是提交申请后的冷静期截止时间;达到该时间也不代表其他条件自动满足。
## 2. 注销状态
`GET /api/yf-handset-app/account-deregister/status`
HTTP 200,业务码 `100000`:
```json
{
"code": 100000,
"msg": "success",
"data": {
"deregister": null
},
"time": "2026-08-28 11:39:51"
}
```
当前查询没有返回注销申请。不能据此推断非空申请的字段名称、状态枚举或是否可撤销。
## 3. 首次只读探测后尚缺的信息(后续进展见第 5、6 节)
- 草稿、冷静期、阻断、撤销、完成时 `data.deregister` 的非空结构、状态字段和值。
- `cooling_until`、`remaining_seconds` 的实际位置及类型。
- 无历史业务时风险时间的空值形式、时间字符串所用时区。
- 重新登录或选择身份自动撤销的具体接口时机,以及受限 Token 是否允许切换其他身份。
这些情况需要现有后端响应样例/源码,或在获得明确授权的专用测试身份上验证。本次授权仅限只读查询,不为采样创建、撤销注销申请或确认资产放弃。
## 4. 后续零资产确认授权(2026-08-28 13:42)
用户随后明确允许:在测试环境重新核实现金余额与积分均为 0 后,分别调用两项资产放弃确认接口,并查询是否产生草稿。此授权不包含发送短信、提交或撤销注销申请,也不允许非零资产确认。
已确认 iPhone 11 连接,并读取本 App 的当前会话:没有登录 Token,保存的账号类型为 `photog`,不是旧注销接口要求的 `store_user`。因此此次没有发出任何接口请求或资产确认,设备偏好未修改,本地临时偏好副本已删除。需在测试版 App 登录门店身份后继续;不要为采样登录已有未完成注销申请的身份,以免触发自动撤销。
## 5. 已完成两项真实零资产确认(2026-08-28 13:45–13:46)
用户重新登录后,核实当前为有效 `store_user`。每项确认前均重新查询:身份与设备会话一致、现金余额(分和展示金额)与积分均为 0;只访问测试环境,禁止重定向,不自动重试修改请求。仅执行了已授权的 `POST /waivers/wallet`、`POST /waivers/points` 各一次,没有发送短信、提交或撤销注销申请。
本次身份初始没有未履约/交付处理中记录,`risk_end_at`、`eligible_at` 均为 JSON `null`,阻断仅为两项确认缺失。与第 1 节较早查询的身份情况不同,不能沿用之前的 37 项未履约记录。
| 阶段 | `wallet_waived` | `points_waived` | `can_apply` | `deregister` |
| --- | --- | --- | --- | --- |
| 确认前 | false | false | false | null |
| 现金确认后 | true | false | false | `status: 0` 草稿 |
| 积分确认后 | true | true | true | 同一未提交草稿 |
两个确认请求均 HTTP 200、`code: 100000`,`data` 返回 `asset_type`(分别为 `"wallet"`、`"points"`)、`wallet_balance_fen: 0`、`wallet_balance: "0.00"`、`points_balance: 0` 及两项确认时间。现金确认后 `wallet_waived_at: "2026-08-28 13:45:57"`、`points_waived_at: null`;积分确认后后者变为 `"2026-08-28 13:46:42"`。
两个 GET 中的 `data.deregister` 都返回以下结构;仅 `id` 脱敏为 0,实际是正整数:
```json
{
"id": 0,
"status": 0,
"status_label": "待确认",
"reason": "",
"apply_time": null,
"cooling_until": null,
"remaining_seconds": 0,
"cancel_time": null,
"blocked_code": "",
"blocked_reason": "",
"completed_at": null
}
```
**确认结论:第一次资产确认即产生非空草稿;`status` 为数字,0 对应“待确认”。非空记录不等于已提交,不能一律限制普通业务或后续资产确认。两项确认后即使仍有草稿,`can_apply` 也会变为 true。** `remaining_seconds: 0` 在草稿中存在,不能单独据此认定冷静期已结束或注销已完成。
已据此修正客户端草稿判断、继续确认/验证和启动核验,并补充 Mock 测试。只将完整且无提交、撤销、阻断或完成字段冲突的 `status: 0` 识别为草稿;未知状态仍不推断。所有本地会话临时副本已删除,未写回设备偏好,未保存 Token。
仍缺:冷静期、撤销、阻断、完成的实际状态值及对应时间数据;确认过期响应;登录/切换自动撤销的具体时机。继续真实验证需要另行授权短信、提交与撤销操作,当前授权不包含这些操作。
## 6. 短信、申请与立即撤销(2026-08-28 14:16–14:24)
用户另外授权在切换后的专用测试身份上发送短信、提交申请,获取状态后立即撤销。真机页面核实:现金/积分为0、两项确认已完成、无未履约及风险等待阻断。通过 iPhone 镜像发送短信,用户提供验证码后提交;未将验证码写入源码或文档。短信和申请的原始 POST 响应体没有保存,不能把后续 GET 当作 POST 原文。
- 申请原因:`API test; cancel immediately`。
- 申请时间:`2026-08-28 14:23:40`。
- 查询确认:`status: 1`、`status_label: "冷静期中"`,冷静期截止 `2026-09-04 14:23:40`。
- 核对当前身份、同一申请及原因后只调用一次 `/cancel`,不自动重试。
- 撤销成功时间:`2026-08-28 14:24:04`,返回 `status: 9`、`status_label: "已撤销"`。
- `14:24:05` 再次 GET `/status` 与 `/eligibility` 确认已撤销;未留下待注销申请,未执行最终注销。
- 本地临时会话副本已删除,未编辑设备偏好,未在文档中保存 Token、手机号或真实身份 ID。
### 冷静期状态
```json
{
"code": 100000,
"msg": "success",
"data": {
"deregister": {
"id": 0,
"status": 1,
"status_label": "冷静期中",
"reason": "API test; cancel immediately",
"apply_time": "2026-08-28 14:23:40",
"cooling_until": "2026-09-04 14:23:40",
"remaining_seconds": 604776,
"cancel_time": null,
"blocked_code": "",
"blocked_reason": "",
"completed_at": null
}
},
"time": "2026-08-28 14:24:03"
}
```
### 撤销响应
```json
{
"code": 100000,
"msg": "注销申请已撤销",
"data": {
"deregister": {
"id": 0,
"status": 9,
"status_label": "已撤销",
"reason": "API test; cancel immediately",
"apply_time": "2026-08-28 14:23:40",
"cooling_until": "2026-09-04 14:23:40",
"remaining_seconds": 0,
"cancel_time": "2026-08-28 14:24:04",
"blocked_code": "CANCELLED_BY_USER",
"blocked_reason": "用户主动撤销注销",
"completed_at": null
}
},
"time": "2026-08-28 14:24:04"
}
```
### 撤销后的条件
```json
{
"code": 100000,
"msg": "success",
"data": {
"can_apply": false,
"store_user_id": 0,
"finance_identity_id": 0,
"wallet_balance_fen": 0,
"wallet_balance": "0.00",
"points_balance": 0,
"wallet_waived": false,
"points_waived": false,
"unfulfilled_count": 0,
"fulfillment_in_progress_count": 0,
"risk_end_at": null,
"eligible_at": null,
"risk_window_hours": 168,
"blockers": [
{
"code": "WALLET_WAIVER_MISSING",
"message": "请先确认放弃现金余额",
"action": "confirm_wallet_waiver"
},
{
"code": "POINTS_WAIVER_MISSING",
"message": "请先确认放弃积分",
"action": "confirm_points_waiver"
}
],
"deregister": {
"id": 0,
"status": 9,
"status_label": "已撤销",
"reason": "API test; cancel immediately",
"apply_time": "2026-08-28 14:23:40",
"cooling_until": "2026-09-04 14:23:40",
"remaining_seconds": 0,
"cancel_time": "2026-08-28 14:24:04",
"blocked_code": "CANCELLED_BY_USER",
"blocked_reason": "用户主动撤销注销",
"completed_at": null
}
},
"time": "2026-08-28 14:24:05"
}
```
冷静期 GET `/eligibility` 已返回两项确认 false 和缺少确认的两个阻断,撤销后也是如此;冷静期不能因此重新确认资产。撤销记录保留旧冷静期时间;remaining_seconds 为0不等于注销完成。正式完成与终审阻断的状态值、自动撤销和所有身份注销后的登录响应仍未验证。此次授权操作已结束,不继续发送短信或创建申请。
## 2026-08-28:补查终态契约及新申请准备
用户随后允许继续申请注销,以尝试获取缺失的终态响应。本节是新的调查记录,不代表已经创建新的申请。
| 查找范围 | 结果 |
| --- | --- |
| 本机 iOS、Android 参考工程及桌面相关文件 | 未找到旧注销后端的 Controller、Resource、状态枚举或 migration 源码 |
| 测试 API 的 `/docs`、`/api/documentation`、`/openapi.json`、`/swagger.json` | HTTP 200,但业务体均为路由不存在,不能当作可用接口文档 |
| Gitea 匿名仓库搜索 | 没有返回匹配仓库;不代表私有仓库不存在 |
| Chrome 中已有登录的 Gitea 会话 | 可访问当前用户的仓库列表;代码搜索 `StoreUserDeregister`、`deregister` 均未匹配,未取得注销后端源码 |
| 已打开的飞书原始文档 | 页面显示最近修改为8月21日;相关规则与本地文档一致,未给出终态数字枚举、完整 JSON 或完成后的鉴权响应 |
| 真机当前 App 会话 | 无有效门店登录态,因此没有发起认证查询、短信、资产确认、申请或撤销 |
当前仍需先在真机登录一个允许最终注销的专用测试门店身份,再读取该身份的条件。若保留申请等待正式完成,需经过服务端7天冷静期;不能修改本机时间来替代服务端等待,也不能为制造终审阻断而擅自修改订单、资产或数据库。按旧文档,重新登录或选中申请中的同一身份会自动撤销申请,等待期间应避免这种操作。
“终审阻断”需要到期复核发现条件变化,单纯提交并等待不能保证得到该状态。此次只读查找未新增任何终态实测结论。
## 2026-08-28 15:23:按用户要求改在模拟器准备新申请
- 用户明确要求操作其已登录的 iPhone Air 模拟器。本次是人工界面操作,不是模拟器单元测试;不改变此前真机测试结论。
- 页面当前身份为北大科技园,显示现金0元、积分0;初始只有两项资产确认阻断,上次申请已撤销。
- 用户授权分别确认两项零资产并发送验证码。继续操作时,现金已显示确认完成,因此没有重复提交现金确认;随后单独确认零积分。
- 页面显示两项资产均已确认、当前条件满足;仍为未提交草稿。
- 15:23通过页面发送一次注销验证码,界面提示已发送至当前身份绑定手机号。未保存短信 POST 原始响应,不能把界面提示当作完整接口 JSON。
- 停留在验证码输入页,等待用户提供本次验证码;此时尚未提交新申请,也未进入新的冷静期。
## 2026-08-28 15:27—15:29:新申请已提交并保留
- 用户提供本次验证码,并在最终弹窗前明确确认:只注销北大科技园身份,保留申请至服务端7天后复核,正式完成不可恢复,不像上次立即撤销。
- 15:27:24在模拟器点击一次“确认提交申请”。原因为 `Test account deregistration`;验证码不写入文档或代码。
- 页面进入冷静期核验页,显示截止时间 `2026-09-04 15:27:24`(服务端时间)。
- 15:28:27只读 GET `/status`、`/eligibility` 均确认 `deregister.status: 1`,`cancel_time`、`completed_at` 均为 null。
- 15:29:22以同一现有 Token GET `/api/yf-handset-app/userinfo`,返回 HTTP 200、业务码150015、`data.status: "cooling"`、相同冷静期截止时间;响应没有 `time` 字段。前后 GET `/status` 均成功且仍是同一冷静期记录。
- 未重复发送短信、未重复申请、未执行撤销、登录或身份选择。未保留 Token 副本或修改模拟器偏好设置。
**此时申请仍待注销,与14:24已撤销的旧申请不同;后续已于15:55:36因重新登录自动撤销,见文末15:56记录。** 终审阻断/正式完成未发生;不能把冷静期截止当作已经注销。查询样例已加入[旧接口返回数据说明](门店身份注销旧接口返回数据说明.md)。短信和申请 POST 原始响应体仍未捕获,不能用 GET 代替。
本轮脱敏证据:
- `/private/tmp/suixinkan-contract-discovery-20260828/152826-simulator-status-redacted.json`
- `/private/tmp/suixinkan-contract-discovery-20260828/152826-simulator-eligibility-redacted.json`
- `/private/tmp/suixinkan-contract-discovery-20260828/152921-simulator-userinfo-cooling-redacted.json`
- `/private/tmp/suixinkan-contract-discovery-20260828/152921-simulator-status-before-auth-redacted.json`
- `/private/tmp/suixinkan-contract-discovery-20260828/152921-simulator-status-after-auth-redacted.json`
## 2026-08-28 15:45:真实响应回归与源码补查
- 将本轮150015及对应status响应整理为Swift测试夹具;逐字段对比采样JSON,仅注销记录ID替换为测试值301。
- 在iPhone 11上完成全量792项测试:782通过、10项既有失败,无跳过;失败用例集合与原始基线一致。注销相关73项全部通过,包括真实userinfo限制响应经API、通知和路由进入冷静期页面的新增Mock集成测试。
- 本轮仅更新测试与文档,未操作模拟器中的真实申请;未变更工程配置、版本号或依赖。
- 补查已保存项目列表,未发现相关后端工程;Android工程现有Git主机的HTTP网页根返回空响应,未取得源码。未猜测后端仓库路径、修改服务器或扩展访问权限。
- 结果:`/private/tmp/suixinkan-deregister-real-contract-20260828.xcresult`。终态样例仍缺,不能将测试通过作为正式注销完成的证据。
## 2026-08-28 15:48—15:50:最终只读复核与冷启动尝试
- 15:48:04查询仍为申请时间15:27:24的冷静期1。
- 仅终止并重启iPhone Air中的App进程;未卸载、清空数据、登录或选择身份。工具未能切换至正确模拟器窗口,故冷启动UI没有验证完成。
- 15:50:51用原会话再次GET `/status`、`/eligibility`,均返回同一申请的冷静期1,截止时间不变,`remaining_seconds: 603392`,撤销时间与完成时间均为null。
- 证据:`/private/tmp/suixinkan-contract-discovery-20260828/155050-simulator-status-redacted.json`及同前缀的`eligibility-redacted.json`。
- 未产生新的终态依据,不继续重复轮询或制造业务变化;待服务端到期状态或已有源码可用后再推进。
## 2026-08-28 15:56:重新登录自动撤销
用户告知已进入后,只读查询发现15:27:24申请已于15:55:36自动撤销。两次GET均返回status9、CANCELLED_BY_LOGIN、用户重新登录自动撤销的说明,completed_at仍为null。没有调用cancel或重新申请;未采集触发撤销的具体登录请求,因此不能断言具体接口时机。
证据:/private/tmp/suixinkan-contract-discovery-20260828/155633-simulator-status-redacted.json及同前缀eligibility文件。此前待注销记录的截止时间已失效,不再等待该申请于9月4日完成。用户随后要求UI简化,本轮仅使用Mock测试,不再操作真实注销。
## 2026-08-28 17:01:验证资产确认接口的false参数
用户单独授权尝试现金、积分接口传`accepted: false`。使用iPhone 11当前已登录门店身份和测试环境,先只读确认两项标记均为true、金额均为0、状态为未提交草稿0,再分别调用一次两个确认接口。
- 现金:17:01:14返回HTTP 200、业务码100099、`msg: 请明确确认自愿放弃对应资产`、`data: []`。
- 积分:17:01:32返回同样结果。
- 每次调用后重新GET查询:`wallet_waived`和`points_waived`仍为true,草稿仍为0,金额未变化,没有进入冷静期。
- 结论:当前接口不接受false作为撤回确认;本轮没有调用true、短信、申请、撤销或登录接口。未重试POST。
完整返回及前后状态见[旧接口返回数据说明4.4节](门店身份注销旧接口返回数据说明.md)。脱敏证据目录:`/private/tmp/suixinkan-waiver-false-20260828/`。
@@ -0,0 +1,477 @@
# 门店身份注销旧接口返回数据说明
整理日期:2026-08-28。依据旧接口文档及当天测试环境的真实响应。
**注销范围:当前 `store_user` 对应的门店用户身份,不是手机号主账号,不影响同手机号的其他身份。**
示例中的 `store_user_id`、`finance_identity_id` 和注销记录 `id` 均脱敏为 `0`,实际为正整数;脱敏值不能用于请求或业务判断。本文不包含 Token、手机号或姓名。
## 1. 请求信息与实测范围
- 测试环境:`https://api-test.zhifly.cn`
- 统一路径前缀:`/api/yf-handset-app/account-deregister`
- 请求头:`token: <当前身份Token>`、`X-APP-VERSION: 1.3.1`、`X-OS-TYPE: iOS`、JSON Accept/Content-Type。
| 方法 | 路径后缀 | 用途 | 真实响应覆盖情况 |
| --- | --- | --- | --- |
| GET | `/eligibility` | 查询注销条件 | 已实测:阻断、两项确认、允许申请、冷静期、撤销后 |
| GET | `/status` | 查询注销记录 | 已实测:无记录、待确认草稿、冷静期、已撤销 |
| POST | `/waivers/wallet` | 确认放弃现金 | 已实测:零余额确认成功 |
| POST | `/waivers/points` | 确认放弃积分 | 已实测:零积分确认成功 |
| POST | `/send-sms` | 发送注销验证码 | 真机发送并收到短信;未保存原始响应体 |
| POST | `/apply` | 提交注销申请 | 真机提交后 GET 确认冷静期;未保存 POST 原始响应体 |
| POST | `/cancel` | 撤销注销申请 | 已实测:撤销成功,并 GET 复核 |
已按用户分次授权执行两项零资产确认,以及一次“短信 → 提交 → 查询 → 立即撤销”。申请时间为 14:23:40,14:24:04 撤销成功,14:24:05 查询确认已撤销;未保留待注销申请,未执行最终注销。正文不记录验证码。
**此前申请状态(15:56复核):15:27:24创建的申请已于15:55:36因重新登录自动撤销(9 / CANCELLED_BY_LOGIN)。没有保留这次待注销申请,旧截止时间不再代表有效冷静期。**
17:01补充实测:当前真机身份查询为未提交草稿0,两项资产已确认,尚未进入冷静期。按用户授权分别尝试`accepted: false`,两条接口均拒绝,确认标记没有重置,见4.4节。
## 2. 公共响应与业务码
| 字段 | 已观测 JSON 类型 | 含义 |
| --- | --- | --- |
| `code` | number(整数) | 业务码,成功为 `100000` |
| `msg` | string | 响应说明,成功可为 `"success"` 或 `"注销申请已撤销"`,不能固定匹配文案 |
| `data` | object;未登录时为 array | 具体业务数据 |
| `time` | string | 成功样例中的服务端时间,格式 `yyyy-MM-dd HH:mm:ss`;时区待确认 |
**HTTP 200 不等于业务成功,必须检查 `code`。**
| 业务码 | 含义 | 验证情况 |
| --- | --- | --- |
| `100000` | 请求成功 | 两个 GET、两个资产确认及撤销响应均观察到 |
| `100090` | 未登录 | 不带 Token 查询两个 GET 时实测 |
| `100099` | 本次表示未明确同意放弃资产 | 两项确认接口传`accepted: false`时实测,见4.4 |
| `150015` | 冷静期身份访问普通业务接口受限 | 15:29使用申请中的身份 GET `/api/yf-handset-app/userinfo` 实测,见5.7 |
两个 GET 的未登录响应均为 HTTP 200,正文如下,没有 `time` 字段:
```json
{"code":100090,"msg":"未登录","data":[]}
```
## 3. 注销条件:GET `/eligibility`
### 3.1 存在阻断时的真实响应
HTTP 200,以下为服务端 `2026-08-28 11:39:51` 的响应:
```json
{
"code": 100000,
"msg": "success",
"data": {
"can_apply": false,
"store_user_id": 0,
"finance_identity_id": 0,
"wallet_balance_fen": 0,
"wallet_balance": "0.00",
"points_balance": 0,
"wallet_waived": false,
"points_waived": false,
"unfulfilled_count": 37,
"fulfillment_in_progress_count": 0,
"risk_end_at": "2026-08-27 15:47:32",
"eligible_at": "2026-09-03 15:47:32",
"risk_window_hours": 168,
"blockers": [
{
"code": "ORDER_UNFULFILLED",
"message": "账户仍有未履约订单或带单",
"action": "complete_orders"
},
{
"code": "RISK_WINDOW_NOT_EXPIRED",
"message": "最后一笔业务的风险结束时间尚未超过7天",
"action": "wait_risk_window"
},
{
"code": "WALLET_WAIVER_MISSING",
"message": "请先确认放弃现金余额",
"action": "confirm_wallet_waiver"
},
{
"code": "POINTS_WAIVER_MISSING",
"message": "请先确认放弃积分",
"action": "confirm_points_waiver"
}
],
"deregister": null
},
"time": "2026-08-28 11:39:51"
}
```
### 3.2 `data` 字段说明
| 字段 | 已观测 JSON 类型 | 数据信息 |
| --- | --- | --- |
| `can_apply` | boolean | 服务端是否允许提交申请 |
| `store_user_id` | number(整数) | 门店用户身份 ID,不是门店实体 ID |
| `finance_identity_id` | number(整数) | 财务身份 ID |
| `wallet_balance_fen` | number(整数) | 现金余额,单位分,适合金额计算 |
| `wallet_balance` | string | 现金金额展示值,如 `"0.00"`,不是 JSON 数字 |
| `points_balance` | number(整数) | 积分余额 |
| `wallet_waived` | boolean | 当前现金余额快照是否已确认放弃 |
| `points_waived` | boolean | 当前积分余额快照是否已确认放弃 |
| `unfulfilled_count` | number(整数) | 未履约记录数量;本次分别观察到 37 和 0 |
| `fulfillment_in_progress_count` | number(整数) | 处理中记录数量,本次为 0;具体统计范围待后端确认 |
| `risk_end_at` | string / null | 最后业务风险结束时间 |
| `eligible_at` | string / null | 业务风险等待截止时间,**不是注销冷静期截止** |
| `risk_window_hours` | number(整数) | 业务风险等待时长,本次为 168 小时 |
| `blockers` | array<object> | 全部阻断项;无阻断时为 `[]` |
| `deregister` | object / null | 注销记录;草稿结构见第 5 节 |
时间字符串本次采用 `yyyy-MM-dd HH:mm:ss`。另一个测试身份的两个风险时间均为 null,不能把 null 转成当前时间或自行追加 168 小时等待。
### 3.3 `blockers[]` 字段与取值
每项包含三个字符串:`code` 为业务标识,`message` 为展示提示,`action` 为建议动作。应展示全部阻断项。
| 已实测 `code` | 含义 | 已实测 `action` |
| --- | --- | --- |
| `ORDER_UNFULFILLED` | 存在未履约订单或带单 | `complete_orders` |
| `RISK_WINDOW_NOT_EXPIRED` | 风险等待期未结束 | `wait_risk_window` |
| `WALLET_WAIVER_MISSING` | 未确认放弃现金 | `confirm_wallet_waiver` |
| `POINTS_WAIVER_MISSING` | 未确认放弃积分 | `confirm_points_waiver` |
旧文档另列出以下代码,但未实测其完整对象和 `action`:
| 仅文档列出的代码 | 文档含义 |
| --- | --- |
| `SHARED_FINANCE_IDENTITY` | 与其他启用身份共用财务账本,需人工处理 |
| `NEGATIVE_ASSET` | 负余额或负积分 |
| `FINANCE_IN_FLIGHT` | 财务流程尚未收口 |
| `FULFILLMENT_IN_PROGRESS` | 退款、分账或交付任务处理中 |
| `WAIVER_STALE` | 确认后余额变化,需重新确认 |
### 3.4 另一个身份的连续数据变化
以下身份现金、积分均为 0,未履约及处理中数量为 0,两个风险时间均为 null。它与 3.1 中有 37 项未履约记录的身份不同。
| 阶段 | `wallet_waived` | `points_waived` | `can_apply` | 阻断 | `deregister` |
| --- | --- | --- | --- | --- | --- |
| 确认前 | false | false | false | 缺两项确认 | null |
| 现金确认后 | true | false | false | 仅缺积分确认 | `status: 0` 草稿 |
| 积分确认后 | true | true | true | `[]` | 仍为 `status: 0` 草稿 |
| 申请后的冷静期 | false | false | false | 缺两项确认 | `status: 1` 冷静期中 |
| 主动撤销后 | false | false | false | 缺两项确认 | `status: 9` 已撤销 |
**第一次资产确认就会产生非空草稿。两项确认后可以申请,但尚未提交申请,也未进入冷静期。**
冷静期中两项确认已变回 false,不应按阻断建议再次确认资产。撤销后重新申请需重新核验条件和确认资产;尚未真实测试再次申请。
## 4. 两项资产确认接口
现金和积分分别请求,均使用以下 JSON 请求体:
```json
{"accepted":true}
```
### 4.1 POST `/waivers/wallet`
HTTP 200,现金确认成功的真实响应:
```json
{
"code": 100000,
"msg": "success",
"data": {
"asset_type": "wallet",
"wallet_balance_fen": 0,
"wallet_balance": "0.00",
"points_balance": 0,
"wallet_waived_at": "2026-08-28 13:45:57",
"points_waived_at": null
},
"time": "2026-08-28 13:45:57"
}
```
### 4.2 POST `/waivers/points`
HTTP 200,积分确认成功的真实响应:
```json
{
"code": 100000,
"msg": "success",
"data": {
"asset_type": "points",
"wallet_balance_fen": 0,
"wallet_balance": "0.00",
"points_balance": 0,
"wallet_waived_at": "2026-08-28 13:45:57",
"points_waived_at": "2026-08-28 13:46:42"
},
"time": "2026-08-28 13:46:42"
}
```
### 4.3 确认响应字段
| 字段 | 已观测 JSON 类型 | 含义 |
| --- | --- | --- |
| `asset_type` | string | 本次确认的资产:`wallet` 或 `points` |
| `wallet_balance_fen` | number(整数) | 当前现金余额,分 |
| `wallet_balance` | string | 当前现金金额展示值 |
| `points_balance` | number(整数) | 当前积分余额 |
| `wallet_waived_at` | string(本次均非空) | 现金确认时间;其他场景是否可为 null 尚未实测 |
| `points_waived_at` | string / null | 积分确认时间;现金确认后仍为 null,积分确认后为时间字符串 |
零余额、零积分也需分别确认。确认成功后重新查询条件和状态,以最新标记为准。旧文档规定确认绑定余额快照;确认本身不等于提交注销或立即清零资产。
### 4.4 `accepted: false` 不支持撤回确认(真实测试)
2026-08-28 17:01,使用iPhone 11当前已登录门店身份,在测试环境分别发送一次:
```json
{"accepted":false}
```
前置状态:`status: 0`未提交草稿,`wallet_waived: true`、`points_waived: true`,现金与积分均为0;`apply_time`、`cooling_until`、`completed_at`均为null。
| 接口 | 服务端响应时间 | HTTP状态 | 业务码 | 结果 |
| --- | --- | --- | --- | --- |
| POST `/waivers/wallet` | 2026-08-28 17:01:14 | 200 | 100099 | 拒绝false,确认标记未变化 |
| POST `/waivers/points` | 2026-08-28 17:01:32 | 200 | 100099 | 拒绝false,确认标记未变化 |
现金接口完整响应如下;积分接口除`time`为`2026-08-28 17:01:32`外,其余字段相同:
```json
{
"code": 100099,
"msg": "请明确确认自愿放弃对应资产",
"data": [],
"time": "2026-08-28 17:01:14"
}
```
每次POST后均再次查询`/status`和`/eligibility`:两项确认仍为true,草稿仍为0,金额未变化,`can_apply`仍为true。**当前接口不能通过传false把已确认状态重置为未确认。** 本轮没有发送true、短信、申请或撤销请求,也没有重新登录。`/cancel`能否取消未提交草稿仍未验证,不能由本次结果推断。
脱敏证据:`/private/tmp/suixinkan-waiver-false-20260828/device-wallet-response.json`、`device-points-response.json`及同目录各自的`before/after-status`、`before/after-eligibility`文件。Token未输出或保存;读取真机会话时的临时副本已删除。
## 5. 注销状态:GET `/status`
### 5.1 无记录
HTTP 200,真实响应:
```json
{
"code": 100000,
"msg": "success",
"data": {
"deregister": null
},
"time": "2026-08-28 11:39:51"
}
```
### 5.2 已确认资产、尚未提交的草稿
HTTP 200,真实响应:
```json
{
"code": 100000,
"msg": "success",
"data": {
"deregister": {
"id": 0,
"status": 0,
"status_label": "待确认",
"reason": "",
"apply_time": null,
"cooling_until": null,
"remaining_seconds": 0,
"cancel_time": null,
"blocked_code": "",
"blocked_reason": "",
"completed_at": null
}
},
"time": "2026-08-28 13:46:42"
}
```
### 5.3 `data.deregister` 字段
| 字段 | 本次实际类型/值 | 含义与待确认事项 |
| --- | --- | --- |
| `id` | number(正整数,示例脱敏为 0) | 注销记录 ID,不是门店用户 ID |
| `status` | number(整数),`0` / `1` / `9` | 分别为待确认、冷静期中、已撤销;其他值未知 |
| `status_label` | string | 服务端状态文案,如 `"冷静期中"`、`"已撤销"` |
| `reason` | string | 草稿为空;提交后保留用户输入的原因 |
| `apply_time` | string / null | 申请时间,本次 `2026-08-28 14:23:40` |
| `cooling_until` | string / null | 冷静期截止,本次 `2026-09-04 14:23:40`;撤销后仍保留 |
| `remaining_seconds` | number(整数) | 查询时剩余秒数;草稿、已撤销都为 0,不能据此认定已完成 |
| `cancel_time` | string / null | 撤销时间,本次 `2026-08-28 14:24:04` |
| `blocked_code` | string | 草稿/冷静期为空;主动撤销为 `CANCELLED_BY_USER` |
| `blocked_reason` | string | 草稿/冷静期为空;主动撤销为 `用户主动撤销注销` |
| `completed_at` | null | 完成时间;非空类型和格式未实测 |
同一草稿结构也出现在 `eligibility` 的 `data.deregister` 内。两个 GET 的 `cooling_until`、`remaining_seconds` 位于该记录对象中;不能据此推断 `/apply` 响应的嵌套结构。
### 5.4 冷静期的真实响应
GET `/status`,HTTP 200。该结果确认申请已受理,但不是 `/apply` 的原始响应体。
```json
{
"code": 100000,
"msg": "success",
"data": {
"deregister": {
"id": 0,
"status": 1,
"status_label": "冷静期中",
"reason": "API test; cancel immediately",
"apply_time": "2026-08-28 14:23:40",
"cooling_until": "2026-09-04 14:23:40",
"remaining_seconds": 604776,
"cancel_time": null,
"blocked_code": "",
"blocked_reason": "",
"completed_at": null
}
},
"time": "2026-08-28 14:24:03"
}
```
### 5.5 主动撤销的真实响应
POST `/cancel`,HTTP 200,无业务请求字段。撤销后再次 GET `/status` 返回相同记录,`msg: "success"`、`time: "2026-08-28 14:24:05"`。
```json
{
"code": 100000,
"msg": "注销申请已撤销",
"data": {
"deregister": {
"id": 0,
"status": 9,
"status_label": "已撤销",
"reason": "API test; cancel immediately",
"apply_time": "2026-08-28 14:23:40",
"cooling_until": "2026-09-04 14:23:40",
"remaining_seconds": 0,
"cancel_time": "2026-08-28 14:24:04",
"blocked_code": "CANCELLED_BY_USER",
"blocked_reason": "用户主动撤销注销",
"completed_at": null
}
},
"time": "2026-08-28 14:24:04"
}
```
`status: 9` 表示已撤销;保留旧 `apply_time`、`cooling_until` 不代表仍在冷静期。这里的 `CANCELLED_BY_USER` 是撤销原因,不是终审阻断状态。两个 GET 均确认两项资产确认标记变为 false;冷静期时已经是 false,不能断言由撤销动作单独导致。
### 5.6 新申请保留在冷静期的真实响应
用户最终确认后,于模拟器点击一次提交;以下为后续 GET `/status` 的真实响应(HTTP 200),不是 POST `/apply` 的响应体:
```json
{
"code": 100000,
"msg": "success",
"data": {
"deregister": {
"id": 0,
"status": 1,
"status_label": "冷静期中",
"reason": "Test account deregistration",
"apply_time": "2026-08-28 15:27:24",
"cooling_until": "2026-09-04 15:27:24",
"remaining_seconds": 604736,
"cancel_time": null,
"blocked_code": "",
"blocked_reason": "",
"completed_at": null
}
},
"time": "2026-08-28 15:28:27"
}
```
同时 GET `/eligibility` 返回现金0、积分0、两项确认 false,以及缺少资产确认的两个阻断;其 `deregister` 仍为上述冷静期记录。不能因此重新确认资产或重复申请。
### 5.7 冷静期普通业务限制的真实响应
15:29以同一身份的现有 Token 只读请求 GET `/api/yf-handset-app/userinfo`,HTTP 200:
```json
{
"code": 150015,
"msg": "账号处于注销冷静期,请先撤销注销后再继续使用",
"data": {
"status": "cooling",
"cooling_until": "2026-09-04 15:27:24",
"remaining_seconds": 604681
}
}
```
该响应没有 `time`,且 `data.status` 是字符串 `"cooling"`;与 `/status` 中 `data.deregister.status` 的数字1不是同一层级或类型,不能共用数字状态 DTO。
请求前后 GET `/status` 均成功且保持同一冷静期申请,`cancel_time`、`completed_at` 均为 null。这证明本次普通信息查询受限,但当前 Token 仍可查询注销状态;不代表正式完成后的鉴权规则已验证,也不能外推为全部普通业务接口已逐一验证。
上述150015和随后status响应已整理为测试夹具(仅记录ID替换为301),并在iPhone 11重放验证业务API、限制通知、状态查询和冷静期页面之间的衔接。此项为Mock集成验证,不是再次请求真实账号或重新申请。
### 5.8 重新登录自动撤销的真实响应
用户重新进入后,15:56只读GET确认之前的申请已撤销,HTTP 200:
```json
{
"code": 100000,
"msg": "success",
"data": {
"deregister": {
"id": 0,
"status": 9,
"status_label": "已撤销",
"reason": "Test account deregistration",
"apply_time": "2026-08-28 15:27:24",
"cooling_until": "2026-09-04 15:27:24",
"remaining_seconds": 0,
"cancel_time": "2026-08-28 15:55:36",
"blocked_code": "CANCELLED_BY_LOGIN",
"blocked_reason": "用户重新登录,自动撤销注销",
"completed_at": null
}
},
"time": "2026-08-28 15:56:34"
}
```
CANCELLED_BY_LOGIN与主动撤销的CANCELLED_BY_USER不同,但同为已撤销9。本次未捕获触发撤销的登录请求,不能确定是哪个登录或身份选择接口触发。并未完成正式注销。
## 6. 尚缺的响应与验证
| 接口 | 旧文档已知信息 | 尚缺信息 |
| --- | --- | --- |
| POST `/send-sms` | 已通过真机发送并收到短信 | 原始成功/失败响应、频率限制及业务码 |
| POST `/apply` | 请求字段 `sms_code`、`reason`;提交后查到冷静期1 | POST 完整响应层级、验证码错误及重复申请响应 |
| POST `/cancel` | 成功体及撤销后9状态已实测 | 失败、重复撤销和终态撤销响应 |
尚未获得阻断、正式完成的 `/status` 响应,不能猜测数字枚举。时间字符串的时区仍待确认,`completed_at` 非空类型和格式仍未实测。
已获得登录自动撤销后的真实状态9和CANCELLED_BY_LOGIN原因,但具体触发接口仍未捕获。到期复核、正式完成后的鉴权响应及全部身份注销后的登录响应仍未验证。此前两次申请现均已撤销。
## 7. 接入注意
1. 按当前门店用户身份隔离状态,不能按手机号共用状态。
2. 展示全部阻断项,不能只使用 `can_apply` 或 HTTP 状态码判断流程。
3. 完整的未提交草稿不应阻止第二项资产确认或手机号验证,非空记录不等于已提交。
4. `eligible_at` 是提交前的业务风险等待截止;`cooling_until` 是提交后的冷静期截止,两者不能混用。
5. `remaining_seconds: 0`、本机时间到期、`can_apply: true` 都不能单独作为注销完成依据。
6. 未知状态、字段缺失或请求失败不能默认解释为“没有申请”。
7. 已撤销9仍含 `cooling_until`,且 `blocked_code` 非空;不能误显示为仍在冷静期或终审阻断。
历史探测过程见 [接口实测记录](门店身份注销接口实测.md),客户端进度见 [接入说明](门店身份注销接入说明.md)。
+6 -6
View File
@@ -436,7 +436,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1020101;
CURRENT_PROJECT_VERSION = 1040101;
DEVELOPMENT_TEAM = 56GVN5RNVN;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
@@ -460,7 +460,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2.1;
MARKETING_VERSION = 1.4.1;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -502,7 +502,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1020101;
CURRENT_PROJECT_VERSION = 1040101;
DEVELOPMENT_TEAM = 56GVN5RNVN;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
@@ -526,7 +526,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2.1;
MARKETING_VERSION = 1.4.1;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -741,7 +741,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1020101;
CURRENT_PROJECT_VERSION = 1040101;
DEVELOPMENT_TEAM = 56GVN5RNVN;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES;
@@ -761,7 +761,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2.1;
MARKETING_VERSION = 1.4.1;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
+3 -1
View File
@@ -6,6 +6,7 @@
import UIKit
/// 应用根页面路由。
@MainActor
enum AppRouter {
enum Root {
@@ -37,7 +38,8 @@ enum AppRouter {
}
}
private static func setRoot(_ viewController: UIViewController, on window: UIWindow?, animated: Bool) {
/// 切换到显式构造的根页面,用于业务进入前的注销状态核验。
static func setRoot(_ viewController: UIViewController, on window: UIWindow?, animated: Bool = true) {
guard let window else { return }
guard animated, let snapshot = window.snapshotView(afterScreenUpdates: true) else {
+3
View File
@@ -17,6 +17,7 @@ final class NetworkServices {
let orderAPI: OrderAPI
let homeAPI: HomeAPI
let paymentAPI: PaymentAPI
let offlineCollectionAPI: OfflineCollectionAPI
let taskAPI: TaskAPI
let inviteAPI: InviteAPI
let walletAPI: WalletAPI
@@ -43,6 +44,7 @@ final class NetworkServices {
orderAPI = OrderAPI(client: client)
homeAPI = HomeAPI(client: client)
paymentAPI = PaymentAPI(client: client)
offlineCollectionAPI = OfflineCollectionAPI(client: client)
taskAPI = TaskAPI(client: client)
inviteAPI = InviteAPI(client: client)
walletAPI = WalletAPI(client: client)
@@ -74,6 +76,7 @@ final class NetworkServices {
orderAPI = OrderAPI(client: apiClient)
homeAPI = HomeAPI(client: apiClient)
paymentAPI = PaymentAPI(client: apiClient)
offlineCollectionAPI = OfflineCollectionAPI(client: apiClient)
taskAPI = TaskAPI(client: apiClient)
inviteAPI = InviteAPI(client: apiClient)
walletAPI = WalletAPI(client: apiClient)
+2 -1
View File
@@ -19,7 +19,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
if AppStore.shared.session.privacyAgreementAccepted, !AppStore.shared.session.token.isEmpty {
AMapBootstrap.configureIfNeeded()
}
PushNotificationManager.shared.initializeIfPrivacyAccepted(launchOptions: launchOptions)
let pushManager = PushNotificationManager.shared
pushManager.initializeIfPrivacyAccepted(launchOptions: launchOptions)
return true
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "offline_security_shield_generated.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "payment_method_alipay_official.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "payment_method_cash_generated.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "payment_method_wechat_official.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ai_retouch_template_placeholder.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "travel_album_before_after.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M12 4H20V20H12Z" fill="#000000" fill-opacity="0.32"/>
<rect x="4" y="4" width="16" height="16" stroke="#000000" stroke-width="2" stroke-linejoin="round"/>
<path d="M12 4V9L15 12L12 15V20" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 389 B

+15
View File
@@ -29,6 +29,12 @@ enum NotificationName {
/// Token 失效或鉴权失败,需重新登录
static let sessionDidExpire = name("sessionDidExpire")
/// 当前门店身份受限或申请结果待核实;保留凭证转入只读状态查询。
static let storeAccountDeregistrationRestricted = name("storeAccountDeregistrationRestricted")
/// 当前门店身份的注销申请已被服务端明确受理;先清理登录态,再展示提交结果页。
static let storeAccountDeregistrationSubmitted = name("storeAccountDeregistrationSubmitted")
// MARK: - Scenic
/// 当前景区切换
@@ -68,6 +74,15 @@ enum NotificationName {
/// `Notification.userInfo` 字典键的统一入口。
enum NotificationUserInfoKey {
/// 产生注销限制错误的原请求凭证,仅在内存中匹配当前会话,禁止记录日志。
static let deregistrationRequestToken = "deregistrationRequestToken"
/// 注销申请提交时冻结的身份展示名称,不包含身份凭证。
static let deregistrationIdentityName = "deregistrationIdentityName"
/// 注销申请明确受理后查询到的服务端冷静期截止时间。
static let deregistrationCoolingUntil = "deregistrationCoolingUntil"
static let scenicId = "scenicId"
static let scenicName = "scenicName"
static let orderId = "orderId"
+28 -13
View File
@@ -19,6 +19,7 @@ final class APIClient {
private let session: URLSessionProtocol
private let encoder: JSONEncoder
private let decoder: JSONDecoder
private let notificationCenter: NotificationCenter
private var authTokenProvider: (() -> String?)?
private let environment: APIEnvironment
@@ -32,12 +33,14 @@ final class APIClient {
encoder: JSONEncoder = JSONEncoder(),
decoder: JSONDecoder = JSONDecoder(),
appVersion: String = AppClientInfo.appVersion(),
osType: String = AppClientInfo.osType
osType: String = AppClientInfo.osType,
notificationCenter: NotificationCenter = .default
) {
self.environment = environment
self.session = session
self.encoder = encoder
self.decoder = decoder
self.notificationCenter = notificationCenter
self.appVersion = appVersion.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "1.0.0"
self.osType = osType.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? AppClientInfo.osType
}
@@ -61,7 +64,7 @@ final class APIClient {
tokenOverride: String? = nil
) async throws -> Response {
let request = try makeURLRequest(apiRequest, tokenOverride: tokenOverride)
logRequest(request)
logRequest(request, includeBody: apiRequest.logsPayload)
let data: Data
let response: URLResponse
@@ -80,12 +83,12 @@ final class APIClient {
throw APIError.networkFailed(error.localizedDescription)
}
logResponse(for: request, response: response, data: data)
logResponse(for: request, response: response, data: data, includeBody: apiRequest.logsPayload)
do {
try validateHTTPResponse(response, data: data)
return try decodeEnvelope(Response.self, from: data)
} catch let error as APIError {
notifySessionExpiredIfNeeded(for: error)
notifySessionErrorIfNeeded(for: error, request: request)
throw error
}
}
@@ -141,7 +144,7 @@ final class APIClient {
try validateHTTPResponse(response, data: data)
return try decodeEnvelope(Response.self, from: data)
} catch let error as APIError {
notifySessionExpiredIfNeeded(for: error)
notifySessionErrorIfNeeded(for: error, request: request)
throw error
}
}
@@ -212,7 +215,7 @@ final class APIClient {
try validateHTTPResponse(response, data: responseData)
return try decodeEnvelope(Response.self, from: responseData)
} catch let error as APIError {
notifySessionExpiredIfNeeded(for: error)
notifySessionErrorIfNeeded(for: error, request: request)
throw error
}
}
@@ -267,6 +270,9 @@ final class APIClient {
}
guard 200 ..< 300 ~= httpResponse.statusCode else {
if let envelope = try? decoder.decode(ErrorEnvelope.self, from: data), envelope.code == 150015 {
throw APIError.serverCode(150015, parseHTTPErrorMessage(data: data))
}
throw APIError.httpStatus(httpResponse.statusCode, parseHTTPErrorMessage(data: data))
}
}
@@ -295,10 +301,19 @@ final class APIClient {
return payload
}
/// Token 失效时广播 sessionDidExpire,触发全局登出。
private func notifySessionExpiredIfNeeded(for error: APIError) {
/// 区分身份注销受限与凭证失效;150015 附带原请求 Token,便于忽略切换身份后的旧错误。
private func notifySessionErrorIfNeeded(for error: APIError, request: URLRequest) {
if case .serverCode(150015, _) = error {
guard let token = request.value(forHTTPHeaderField: "token"), !token.isEmpty else { return }
notificationCenter.post(
name: NotificationName.storeAccountDeregistrationRestricted,
object: nil,
userInfo: [NotificationUserInfoKey.deregistrationRequestToken: token]
)
return
}
guard APIError.isAuthenticationExpired(error) else { return }
NotificationCenter.default.post(name: NotificationName.sessionDidExpire, object: nil)
notificationCenter.post(name: NotificationName.sessionDidExpire, object: nil)
}
/// 从 HTTP 错误响应中提取更适合展示给用户的错误信息。
@@ -341,7 +356,7 @@ final class APIClient {
}
/// 在 Debug 环境打印请求信息(含 GET 查询参数与 POST 请求体,对齐 Android Ktor `LogLevel.BODY`)。
private func logRequest(_ request: URLRequest) {
private func logRequest(_ request: URLRequest, includeBody: Bool = true) {
#if DEBUG
let method = request.httpMethod ?? "REQUEST"
let url = request.url?.absoluteString ?? "<invalid url>"
@@ -359,7 +374,7 @@ final class APIClient {
}
let contentType = request.value(forHTTPHeaderField: "Content-Type")
if let body = Self.debugRequestBody(from: request.httpBody, contentType: contentType) {
if includeBody, let body = Self.debugRequestBody(from: request.httpBody, contentType: contentType) {
lines.append("body:\n\(body)")
}
@@ -368,12 +383,12 @@ final class APIClient {
}
/// 在 Debug 环境打印响应状态和响应体。
private func logResponse(for request: URLRequest, response: URLResponse, data: Data) {
private func logResponse(for request: URLRequest, response: URLResponse, data: Data, includeBody: Bool = true) {
#if DEBUG
let method = request.httpMethod ?? "REQUEST"
let url = request.url?.absoluteString ?? "<invalid url>"
let statusCode = (response as? HTTPURLResponse).map { String($0.statusCode) } ?? "unknown"
let body = Self.debugResponseBody(from: data)
let body = includeBody ? Self.debugResponseBody(from: data) : "<sensitive payload omitted>"
print("[API][Response] \(method) \(url) status=\(statusCode)\n\(body)")
#endif
}
+5 -1
View File
@@ -20,6 +20,8 @@ nonisolated struct APIRequest<Response: Decodable> {
var queryItems: [URLQueryItem]
var headers: [String: String]
var body: AnyEncodable?
/// 敏感请求关闭正文日志,避免验证码和注销资产进入调试日志。
var logsPayload: Bool
/// 创建一个 API 请求,并把可编码请求体擦除为统一的 AnyEncodable。
init<Body: Encodable>(
@@ -27,13 +29,15 @@ nonisolated struct APIRequest<Response: Decodable> {
path: String,
queryItems: [URLQueryItem] = [],
headers: [String: String] = [:],
body: Body? = Optional<EmptyPayload>.none
body: Body? = Optional<EmptyPayload>.none,
logsPayload: Bool = true
) {
self.method = method
self.path = path
self.queryItems = queryItems
self.headers = headers
self.body = body.map(AnyEncodable.init)
self.logsPayload = logsPayload
}
}
+57 -1
View File
@@ -5,25 +5,32 @@ enum PushDestination: Sendable, Equatable {
case paymentRecord
case paymentDetails
case messageCenter
case aiRetouchTaskList
case aiRetouchTaskDetail(batchId: Int)
}
/// 极光/APNs payload 解析结果,仅提取业务消息类型用于点击路由。
struct PushPayload: Sendable, Equatable {
private let type: String
private let aiRetouchBatchId: Int?
/// 从系统通知 userInfo 提取顶层或推送包装层中的业务消息类型。
nonisolated init(userInfo: [AnyHashable: Any]) {
type = Self.extractType(from: userInfo)
aiRetouchBatchId = Self.extractBatchId(from: userInfo)
}
/// 从可发送的字符串字典创建 payload,仅保留业务消息类型。
nonisolated init(values: [String: String]) {
type = Self.normalizedType(values["type"])
aiRetouchBatchId = Int(values["ai_retouch_batch_id"] ?? "").flatMap { $0 > 0 ? $0 : nil }
}
/// 可发送的规范化字段快照。
nonisolated var normalizedValues: [String: String] {
type.isEmpty ? [:] : ["type": type]
var values = type.isEmpty ? [:] : ["type": type]
if let aiRetouchBatchId { values["ai_retouch_batch_id"] = String(aiRetouchBatchId) }
return values
}
/// 仅根据后端业务消息类型解析目标页面。
@@ -33,6 +40,11 @@ struct PushPayload: Sendable, Equatable {
return .paymentRecord
case "6":
return .paymentDetails
case "14":
if let aiRetouchBatchId, aiRetouchBatchId > 0 {
return .aiRetouchTaskDetail(batchId: aiRetouchBatchId)
}
return .aiRetouchTaskList
default:
return .messageCenter
}
@@ -80,6 +92,50 @@ struct PushPayload: Sendable, Equatable {
}
}
private nonisolated static func extractBatchId(
from dictionary: [AnyHashable: Any],
depth: Int = 0
) -> Int? {
guard depth <= 5 else { return nil }
if let value = normalizedPositiveInt(dictionary["ai_retouch_batch_id"]) { return value }
if let data = dictionary["data"] {
if let nested = data as? [AnyHashable: Any],
let value = extractBatchId(from: nested, depth: depth + 1) { return value }
if let text = data as? String,
let nested = jsonDictionary(text),
let value = extractBatchId(from: nested, depth: depth + 1) { return value }
}
for key in nestedTypeContainerKeys {
guard let value = dictionary[key] else { continue }
if let nested = value as? [AnyHashable: Any],
let batchId = extractBatchId(from: nested, depth: depth + 1) { return batchId }
if let text = value as? String,
let nested = jsonDictionary(text),
let batchId = extractBatchId(from: nested, depth: depth + 1) { return batchId }
}
return nil
}
private nonisolated static func normalizedPositiveInt(_ value: Any?) -> Int? {
let parsed: Int?
switch value {
case let number as NSNumber: parsed = number.intValue
case let string as String: parsed = Int(string.trimmingCharacters(in: .whitespacesAndNewlines))
default: parsed = nil
}
return parsed.flatMap { $0 > 0 ? $0 : nil }
}
private nonisolated static func jsonDictionary(_ text: String) -> [AnyHashable: Any]? {
guard let data = text.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data),
let dictionary = object as? [String: Any]
else { return nil }
return Dictionary(uniqueKeysWithValues: dictionary.map { (AnyHashable($0.key), $0.value) })
}
private nonisolated static let nestedTypeContainerKeys = [
"extras", "extra", "JMessageExtra", "n_extras",
]
@@ -116,6 +116,8 @@ final class PushNotificationManager: NSObject {
private var isInitialized = false
private var didRequestAuthorization = false
private var uploadTask: Task<Void, Never>?
private var uploadAttemptID: UUID?
private var isAccountBindingSuspended = false
private var queuedForcedUpload = false
private var isFetchingRegistrationID = false
private var queuedForcedFetch = false
@@ -161,7 +163,7 @@ final class PushNotificationManager: NSObject {
/// 登录成功后请求通知权限,并强制绑定当前账号。
func handleLoginCompleted() {
initializeIfPrivacyAccepted()
guard isInitialized, appStore.session.isLoggedIn else { return }
guard !isAccountBindingSuspended, isInitialized, appStore.session.isLoggedIn else { return }
if !didRequestAuthorization {
didRequestAuthorization = true
sdk.requestAuthorization(delegate: self)
@@ -171,7 +173,7 @@ final class PushNotificationManager: NSObject {
/// 账号切换后把同一设备重新绑定到新的业务账号。
func handleAccountSwitched() {
guard appStore.session.isLoggedIn else { return }
guard !isAccountBindingSuspended, appStore.session.isLoggedIn else { return }
bindCurrentAccount()
}
@@ -179,11 +181,22 @@ final class PushNotificationManager: NSObject {
func handleLogout() {
uploadTask?.cancel()
uploadTask = nil
uploadAttemptID = nil
queuedForcedUpload = false
router.resetPendingRoute()
Task { await updateApplicationIconBadgeCount(0) }
}
/// 注销核验或受限期间暂停业务账号绑定,保留 Token、Registration ID 与待处理通知。
func setAccountBindingSuspended(_ suspended: Bool) {
isAccountBindingSuspended = suspended
guard suspended else { return }
uploadTask?.cancel()
uploadTask = nil
uploadAttemptID = nil
queuedForcedUpload = false
}
/// 将桌面 App Icon 角标更新为最新未读消息数量。
func updateApplicationIconBadgeCount(_ count: Int) async {
await applicationIconBadgeSetter.setBadgeCount(max(count, 0))
@@ -191,13 +204,14 @@ final class PushNotificationManager: NSObject {
/// App 回到前台时补偿失败或尚未完成的 Registration ID 上报。
func retryPendingRegistrationUpload() {
guard appStore.session.isLoggedIn else { return }
guard !isAccountBindingSuspended, appStore.session.isLoggedIn else { return }
uploadCachedRegistrationID(force: false)
refreshRegistrationID(forceUpload: false)
}
/// 登录根页面建立后继续执行通知点击暂存的路由。
func routePendingNotificationIfPossible() {
guard !isAccountBindingSuspended else { return }
router.routePendingIfPossible()
}
@@ -323,7 +337,7 @@ final class PushNotificationManager: NSObject {
}
private func upload(registrationID: String, force: Bool) {
guard appStore.session.isLoggedIn,
guard !isAccountBindingSuspended, appStore.session.isLoggedIn,
let uploadedKey = appStore.session.accountScopedKey(Key.uploadedRegistrationIDSuffix)
else { return }
@@ -336,12 +350,15 @@ final class PushNotificationManager: NSObject {
}
let accountScope = appStore.session.accountCachePrefix
let attemptID = UUID()
uploadAttemptID = attemptID
uploadTask = Task { [weak self] in
guard let self else { return }
var succeeded = false
do {
try await self.api.registerJPushID(registrationID)
if self.appStore.session.accountCachePrefix == accountScope {
if self.uploadAttemptID == attemptID, !self.isAccountBindingSuspended,
self.appStore.session.accountCachePrefix == accountScope {
self.defaults.set(registrationID, forKey: uploadedKey)
}
succeeded = true
@@ -353,7 +370,9 @@ final class PushNotificationManager: NSObject {
#endif
}
guard self.uploadAttemptID == attemptID else { return }
self.uploadTask = nil
self.uploadAttemptID = nil
let shouldForceAgain = self.queuedForcedUpload
self.queuedForcedUpload = false
if succeeded, shouldForceAgain {
@@ -0,0 +1,105 @@
import Foundation
/// 门店身份注销依赖;网络服务在主线程协调,ViewModel 自身不绑定 MainActor。
@MainActor
protocol StoreAccountDeregistrationStatusServing {
/// 只读查询当前注销记录,供进入普通业务前核验。
func status() async throws -> StoreAccountDeregistrationStatus
}
/// 完整注销服务,在状态查询之外提供条件、确认和申请操作。
@MainActor
protocol StoreAccountDeregistrationServing: StoreAccountDeregistrationStatusServing {
/// 获取资产与全部注销条件。
func eligibility() async throws -> StoreAccountDeregistrationEligibility
/// 分别确认现金余额。
func waiveWallet() async throws
/// 分别确认积分。
func waivePoints() async throws
/// 向当前身份的绑定手机号发送短信。
func sendSMS() async throws
/// 提交用户输入的验证码与注销原因。
func apply(smsCode: String, reason: String) async throws
/// 服务端判定是否允许撤销,不按本机截止时间判断。
func cancel() async throws
}
/// 旧门店身份注销接口请求层;仅使用提供的 account-deregister 路径和请求字段。
@MainActor
final class StoreAccountDeregistrationAPI: StoreAccountDeregistrationServing {
private let client: APIClient
private let identity: StoreAccountDeregistrationIdentity
private let isCurrentIdentity: @MainActor () -> Bool
private let basePath = "/api/yf-handset-app/account-deregister"
/// 注入网络客户端、冻结的门店身份和当前会话校验,便于隔离真实与测试请求。
init(
client: APIClient,
identity: StoreAccountDeregistrationIdentity,
isCurrentIdentity: @escaping @MainActor () -> Bool
) {
self.client = client
self.identity = identity
self.isCurrentIdentity = isCurrentIdentity
}
/// 查询当前门店身份的资产、风险期和全部阻断项。
func eligibility() async throws -> StoreAccountDeregistrationEligibility {
let value: StoreAccountDeregistrationEligibility = try await send(APIRequest(method: .get, path: basePath + "/eligibility"))
guard String(value.storeUserID) == identity.userID else {
throw StoreAccountDeregistrationError.sessionChanged
}
return value
}
/// 调用旧现金确认接口;与积分接口的顺序协调由ViewModel负责。
func waiveWallet() async throws {
let _: EmptyPayload = try await send(APIRequest(
method: .post,
path: basePath + "/waivers/wallet",
body: StoreAccountDeregistrationWaiverRequest()
))
}
/// 调用旧积分确认接口;与现金接口的顺序协调由ViewModel负责。
func waivePoints() async throws {
let _: EmptyPayload = try await send(APIRequest(
method: .post,
path: basePath + "/waivers/points",
body: StoreAccountDeregistrationWaiverRequest()
))
}
/// 向当前门店身份绑定手机号发送短信;收件人由后端从 Token 确定。
func sendSMS() async throws {
let _: EmptyPayload = try await send(APIRequest(method: .post, path: basePath + "/send-sms"))
}
/// 使用用户输入的验证码和原因提交申请;成功后需查询服务端状态。
func apply(smsCode: String, reason: String) async throws {
let _: EmptyPayload = try await send(APIRequest(
method: .post,
path: basePath + "/apply",
body: StoreAccountDeregistrationApplyRequest(smsCode: smsCode, reason: reason)
))
}
/// 查询当前身份的草稿、冷静期、撤销、阻断或完成状态,不触发重新登录。
func status() async throws -> StoreAccountDeregistrationStatus {
try await send(APIRequest(method: .get, path: basePath + "/status"))
}
/// 正式完成前由用户主动撤销;是否可撤销始终由服务端判定。
func cancel() async throws {
let _: EmptyPayload = try await send(APIRequest(method: .post, path: basePath + "/cancel"))
}
private func send<Response: Decodable>(_ request: APIRequest<Response>) async throws -> Response {
guard isCurrentIdentity() else { throw StoreAccountDeregistrationError.sessionChanged }
var sensitiveRequest = request
sensitiveRequest.logsPayload = false
let response = try await client.send(sensitiveRequest, tokenOverride: identity.token)
guard isCurrentIdentity() else { throw StoreAccountDeregistrationError.sessionChanged }
return response
}
}
@@ -0,0 +1,102 @@
import Foundation
/// 旧注销接口的门店身份上下文;冻结身份与凭证,防止切换账号后误操作另一身份。
struct StoreAccountDeregistrationIdentity: Equatable, Sendable {
/// 当前门店用户身份 ID,对应 ss_store_user.id,不是门店实体 ID。
let userID: String
/// 进入流程时的门店身份 Token,仅用于接口鉴权。
let token: String
/// 用于确认页展示当前正在注销的门店身份。
let displayName: String
/// 从当前业务会话构造上下文;景区身份、未知身份和未登录状态均不可发起注销。
init(session: AppSessionStore) throws {
guard session.accountType == .storeUser else {
throw StoreAccountDeregistrationError.unsupportedIdentity
}
let id = session.userId.trimmingCharacters(in: .whitespacesAndNewlines)
let token = session.token.trimmingCharacters(in: .whitespacesAndNewlines)
guard let numericID = Int(id), numericID > 0, !token.isEmpty else {
throw StoreAccountDeregistrationError.invalidSession
}
userID = id
self.token = token
let name = session.accountDisplayName.trimmingCharacters(in: .whitespacesAndNewlines)
displayName = name.isEmpty ? "当前门店身份" : name
}
/// 校验操作前后仍为同一个身份和同一份登录凭证。
func matches(session: AppSessionStore) -> Bool {
session.accountType == .storeUser
&& session.userId.trimmingCharacters(in: .whitespacesAndNewlines) == userID
&& session.token.trimmingCharacters(in: .whitespacesAndNewlines) == token
}
}
/// 门店身份注销的本地安全校验错误,不替代后端业务错误码。
enum StoreAccountDeregistrationError: LocalizedError, Equatable {
case unsupportedIdentity
case invalidSession
case sessionChanged
/// 可直接展示给用户的错误说明。
var errorDescription: String? {
switch self {
case .unsupportedIdentity:
"当前身份暂不支持注销"
case .invalidSession:
"当前门店身份信息不完整,请重新登录"
case .sessionChanged:
"登录状态或当前身份已变化,请重新进入注销页面"
}
}
}
/// 旧接口的余额或积分放弃确认请求;两种资产必须分别调用对应接口。
struct StoreAccountDeregistrationWaiverRequest: Encodable, Sendable {
/// 用户明确接受后才创建该请求,固定提交 true。
let accepted = true
}
/// 旧接口提交注销申请的请求体,不附加新协议中的主账号 ID 或核验参数。
struct StoreAccountDeregistrationApplyRequest: Encodable, Sendable {
/// 用户实际收到的短信验证码,不使用本地固定验证码。
let smsCode: String
/// 用户确认的注销原因。
let reason: String
/// 对齐旧文档的请求字段。
enum CodingKeys: String, CodingKey {
case smsCode = "sms_code"
case reason
}
}
/// 未定义完整响应字段的旧接口原始 JSON;保留信息,待实际契约确认后映射为业务模型。
/// 此类型不推断状态、余额或确认结果,也不应直接用于决定是否允许注销。
indirect enum StoreAccountDeregistrationJSON: Decodable, Equatable, Sendable {
case object([String: StoreAccountDeregistrationJSON])
case array([StoreAccountDeregistrationJSON])
case string(String)
case number(Decimal)
case bool(Bool)
case null
/// 无损保留 JSON 结构,金额数值使用 Decimal,避免浮点误差。
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
self = .null
} else if let value = try? container.decode(Bool.self) {
self = .bool(value)
} else if let value = try? container.decode(String.self) {
self = .string(value)
} else if let value = try? container.decode(Decimal.self) {
self = .number(value)
} else if let value = try? container.decode([String: Self].self) {
self = .object(value)
} else {
self = .array(try container.decode([Self].self))
}
}
}
@@ -0,0 +1,193 @@
import Foundation
/// 注销条件中的单个服务端阻断项;保留未知 code/action,不能据此绕过服务端限制。
struct StoreAccountDeregistrationBlocker: Decodable, Equatable, Sendable {
/// 稳定业务错误码。
let code: String
/// 供用户阅读的阻断说明。
let message: String
/// 后端建议动作;客户端仅对已确认的动作提供说明。
let action: String
/// 已知资产确认问题集中展示在资产卡,其余阻断仍逐条展示并阻止继续。
var isAssetConfirmation: Bool {
["WALLET_WAIVER_MISSING", "POINTS_WAIVER_MISSING", "WAIVER_STALE"].contains(code)
}
/// 未实现跳转的动作仍展示处理建议,不悄悄丢弃该阻断项。
var guidance: String {
switch action {
case "complete_orders": "请先处理未履约订单或带单,然后刷新条件。"
case "wait_risk_window": "请等待业务风险期结束,然后刷新条件。"
case "confirm_wallet_waiver": "请单独确认放弃现金余额。"
case "confirm_points_waiver": "请单独确认放弃积分。"
default: "请按上述说明处理;如不清楚如何操作,请联系管理员或客服。"
}
}
}
/// 2026-08-28 真实 eligibility 响应;必需的金额、确认及权限字段缺失时解码失败。
struct StoreAccountDeregistrationEligibility: Decodable, Equatable, Sendable {
/// 服务端是否允许申请,不能单独替代完整阻断检查。
let canApply: Bool
/// 当前门店用户身份 ID。
let storeUserID: Int
/// 对应的财务身份 ID。
let financeIdentityID: Int
/// 现金余额,单位分,避免浮点运算。
let walletBalanceFen: Int64
/// 服务端金额展示字符串。
let walletBalance: String
/// 积分余额。
let pointsBalance: Int64
/// 服务端当前余额快照是否已有现金放弃确认。
let walletWaived: Bool
/// 服务端当前余额快照是否已有积分放弃确认。
let pointsWaived: Bool
/// 未履约主订单及带单数量。
let unfulfilledCount: Int
/// 尚在处理的交付任务数量。
let fulfillmentInProgressCount: Int
/// 最后业务风险结束时间;尚未确认时区,按服务端原文展示。
let riskEndAt: String?
/// 业务风险等待截止时间,不是注销冷静期截止时间。
let eligibleAt: String?
/// 服务端业务风险等待时长。
let riskWindowHours: Int
/// 全部阻断原因,包含客户端尚不认识的新错误码。
let blockers: [StoreAccountDeregistrationBlocker]
/// 当前注销记录;已实测草稿、冷静期和主动撤销,其他状态保留原始结构。
let deregister: StoreAccountDeregistrationJSON
/// 资产之外尚需处理的业务条件,包含客户端不认识的阻断代码。
var businessBlockers: [StoreAccountDeregistrationBlocker] {
blockers.filter { !$0.isAssetConfirmation }
}
/// 两步页面可以开始资产确认的前提,不替代提交前的完整服务端核验。
var permitsAssetConfirmation: Bool {
permitsPreparation && businessBlockers.isEmpty && storeUserID > 0 && financeIdentityID > 0
&& walletBalanceFen >= 0 && pointsBalance >= 0
&& unfulfilledCount == 0 && fulfillmentInProgressCount == 0
}
/// 字段名对齐真实响应,不接受缺失字段的乐观默认值。
enum CodingKeys: String, CodingKey {
case canApply = "can_apply", storeUserID = "store_user_id", financeIdentityID = "finance_identity_id"
case walletBalanceFen = "wallet_balance_fen", walletBalance = "wallet_balance", pointsBalance = "points_balance"
case walletWaived = "wallet_waived", pointsWaived = "points_waived"
case unfulfilledCount = "unfulfilled_count", fulfillmentInProgressCount = "fulfillment_in_progress_count"
case riskEndAt = "risk_end_at", eligibleAt = "eligible_at", riskWindowHours = "risk_window_hours"
case blockers, deregister
}
/// 没有记录、未提交草稿或已撤销时可准备申请,其他条件仍以服务端核验为准。
var permitsPreparation: Bool {
StoreAccountDeregistrationStatus(deregister: deregister).permitsPreparation
}
/// 完整条件满足才允许提交;有矛盾或未知状态则拒绝,截止时间不由本机推断。
var permitsApplication: Bool {
canApply && blockers.isEmpty && walletWaived && pointsWaived
&& walletBalanceFen >= 0 && pointsBalance >= 0
&& unfulfilledCount == 0 && fulfillmentInProgressCount == 0
&& storeUserID > 0 && permitsPreparation
}
/// 确认弹窗期间两种资产或财务身份发生变化,必须重新展示并确认。
func hasSameAssets(as other: Self) -> Bool {
storeUserID == other.storeUserID && financeIdentityID == other.financeIdentityID
&& walletBalanceFen == other.walletBalanceFen && pointsBalance == other.pointsBalance
}
}
/// status 的实测结构:0 草稿、1 冷静期、9 已撤销;缺失或未知状态不默认放行。
struct StoreAccountDeregistrationStatus: Decodable, Equatable, Sendable {
/// 保留原始记录,尚不推断未观测的阻断和完成状态值。
let deregister: StoreAccountDeregistrationJSON
/// 2026-08-28 两次资产确认后的真实草稿形态;不能仅凭 status=0 忽略矛盾的提交或终态字段。
var isUnsubmittedDraft: Bool {
guard case let .object(record) = deregister,
case let .number(id) = record["id"], id > 0,
id <= Decimal(Int64.max), id == Decimal(NSDecimalNumber(decimal: id).int64Value),
record["status"] == .number(0),
case let .string(label) = record["status_label"], !label.isEmpty,
case .string = record["reason"],
record["apply_time"] == .null, record["cooling_until"] == .null,
record["remaining_seconds"] == .number(0), record["cancel_time"] == .null,
record["blocked_code"] == .string(""), record["blocked_reason"] == .string(""),
record["completed_at"] == .null else { return false }
return true
}
/// 实测冷静期;即使剩余秒数为 0,也须等待服务端复核,不能视为完成。
var isCooling: Bool {
guard let record = validatedRecord else { return false }
return record["status"] == .number(1) && nonemptyString(record["apply_time"]) != nil
&& nonemptyString(record["cooling_until"]) != nil && record["cancel_time"] == .null
&& record["completed_at"] == .null && record["blocked_code"] == .string("")
&& record["blocked_reason"] == .string("")
}
/// 已撤销必须同时有撤销时间,不能只因剩余秒数为零或仍保留冷静期截止时间而推断。
var isCancelled: Bool {
guard let record = validatedRecord else { return false }
return record["status"] == .number(9) && nonemptyString(record["apply_time"]) != nil
&& nonemptyString(record["cancel_time"]) != nil && record["completed_at"] == .null
&& record["remaining_seconds"] == .number(0)
}
/// 用于核对取消前后是否为同一申请,不包含账号凭证。
var recordID: String? {
guard let record = validatedRecord, case let .number(id) = record["id"] else { return nil }
return NSDecimalNumber(decimal: id).stringValue
}
/// 识别同一次已撤销记录;避免重新申请时用旧撤销响应清除新的提交意图。
var cancellationFingerprint: String? {
guard isCancelled, let recordID, let time = nonemptyString(validatedRecord?["cancel_time"]) else { return nil }
return recordID + "|" + time
}
/// 按服务端原文展示,不推断时间字符串的时区。
var coolingUntil: String? { nonemptyString(validatedRecord?["cooling_until"]) }
/// 服务端查询时的剩余秒数;客户端不据此宣布注销完成。
var remainingSeconds: Int64? {
guard let record = validatedRecord, case let .number(seconds) = record["remaining_seconds"] else { return nil }
return NSDecimalNumber(decimal: seconds).int64Value
}
/// 没有申请、未提交草稿或已撤销允许恢复业务;准备新申请仍须重新核验资产及条件。
var permitsPreparation: Bool { deregister == .null || isUnsubmittedDraft || isCancelled }
private var validatedRecord: [String: StoreAccountDeregistrationJSON]? {
guard case let .object(record) = deregister,
case let .number(id) = record["id"], id > 0, isInteger(id),
case let .number(seconds) = record["remaining_seconds"], seconds >= 0, isInteger(seconds),
case let .string(label) = record["status_label"], !label.isEmpty,
case .string = record["reason"], case .string = record["blocked_code"],
case .string = record["blocked_reason"] else { return nil }
for key in ["apply_time", "cooling_until", "cancel_time", "completed_at"] {
guard record[key] == .null || nonemptyString(record[key]) != nil else { return nil }
}
return record
}
private func nonemptyString(_ value: StoreAccountDeregistrationJSON?) -> String? {
guard case let .string(text) = value, !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
return text
}
private func isInteger(_ value: Decimal) -> Bool {
value <= Decimal(Int64.max) && value >= Decimal(Int64.min)
&& value == Decimal(NSDecimalNumber(decimal: value).int64Value)
}
}
/// 必须分别确认的两种资产。
enum StoreAccountDeregistrationAsset: Sendable {
case wallet
case points
}
@@ -0,0 +1,60 @@
import Foundation
import CoreFoundation
/// 本机提交意图记录,不代表后端已受理或任何注销业务状态。
protocol StoreAccountDeregistrationSubmissionTracking {
/// 是否存在本机尚无法确定结果的提交意图。
var hasUnresolvedSubmission: Bool { get }
/// 在发出 POST 前记录意图;无法记录时不允许发出申请。
func recordSubmissionIntent(previousStatus: StoreAccountDeregistrationStatus?) throws
/// 仅在服务端明确拒绝本次申请时清除此意图。
func clearRejectedSubmission()
/// 用新撤销记录核销提交意图;与重新申请前相同的旧撤销响应不能放行。
func clearCancelledSubmission(matching status: StoreAccountDeregistrationStatus) -> Bool
}
/// 按环境和门店用户 ID 隔离提交意图;不按手机号保存,不存储凭证、验证码或资产。
final class StoreAccountDeregistrationSubmissionStore: StoreAccountDeregistrationSubmissionTracking {
private let defaults: UserDefaults
private let key: String
/// 注入持久化容器便于测试;生产环境使用当前服务域名隔离测试/正式账号。
init(storeUserID: String, environment: APIEnvironment = .current, defaults: UserDefaults = .standard) {
self.defaults = defaults
key = "store_deregister_submission_intent_v1_\(environment.baseURL.host ?? "unknown")_\(storeUserID)"
}
/// 任何残留记录都按待核实处理,不因不认识的本地值而允许重新提交。
var hasUnresolvedSubmission: Bool { defaults.object(forKey: key) != nil }
/// UserDefaults 记录提交意图;不把本地写入成功等同于后端收到申请。
func recordSubmissionIntent(previousStatus: StoreAccountDeregistrationStatus? = nil) throws {
defaults.set(["previous_cancellation": previousStatus?.cancellationFingerprint ?? ""], forKey: key)
guard hasUnresolvedSubmission else { throw SubmissionPersistenceError.unavailable }
}
/// 只删除当前环境、当前门店用户对应的意图,不影响其他身份。
func clearRejectedSubmission() { defaults.removeObject(forKey: key) }
/// 旧版本只会从无记录/草稿提交,其布尔意图可由完整撤销记录核销。
func clearCancelledSubmission(matching status: StoreAccountDeregistrationStatus) -> Bool {
guard let fingerprint = status.cancellationFingerprint else { return false }
if let stored = defaults.object(forKey: key) {
if let record = stored as? [String: String], let previous = record["previous_cancellation"] {
guard previous != fingerprint else { return false }
} else {
// 仅兼容旧版本写入的 true;损坏或未知结构继续等待核验。
guard let value = stored as? NSNumber,
CFGetTypeID(value) == CFBooleanGetTypeID(), value.boolValue else { return false }
}
}
defaults.removeObject(forKey: key)
return true
}
}
/// 无法保存提交意图时阻止申请,避免在重启后失去重复提交保护。
private enum SubmissionPersistenceError: LocalizedError {
case unavailable
var errorDescription: String? { "无法保存提交核验记录,请稍后重试" }
}
@@ -0,0 +1,129 @@
import Foundation
/// 进入业务页面前的核验结果,不将非空未知记录解释为任何已知注销状态。
enum StoreAccountDeregistrationAccessDecision: Equatable {
case notChecked
case checking
case allowed
case cooling
case unresolved
case failed(String)
case obsolete
}
/// 进入业务前核验当前身份;冷静期只能查询或由用户明确撤销,不自动重新登录。
final class StoreAccountDeregistrationAccessViewModel {
/// 未核验与查询失败都不能创建普通业务根页面。
private(set) var decision: StoreAccountDeregistrationAccessDecision = .notChecked
/// 防止同一页面重复发起查询。
private(set) var isChecking = false
/// 最近一次通过身份核验的服务端状态,用于冷静期展示和撤销前比对。
private(set) var status: StoreAccountDeregistrationStatus?
private var restrictionRevision = 0
private var requiresSubmissionReconciliation: Bool
private let submissionStore: (any StoreAccountDeregistrationSubmissionTracking)?
private var observedCooling = false
/// 本机存在待核实提交意图时,即使暂时返回 null 也不直接恢复普通业务。
init(requiresSubmissionReconciliation: Bool = false,
submissionStore: (any StoreAccountDeregistrationSubmissionTracking)? = nil) {
self.submissionStore = submissionStore
self.requiresSubmissionReconciliation = requiresSubmissionReconciliation
|| submissionStore?.hasUnresolvedSubmission == true
}
/// 新的限制信号优先于此前已发出的状态查询,防止旧正常响应覆盖新限制。
func recordRestriction() {
restrictionRevision += 1
status = nil
decision = .unresolved
}
/// 查询前后核对身份;旧身份响应和错误均不能决定新会话是否进入业务页。
func verify(api: any StoreAccountDeregistrationStatusServing,
isCurrentIdentity: @escaping @MainActor () -> Bool) async {
guard !isChecking else { return }
isChecking = true
let revision = restrictionRevision
decision = .checking
status = nil
defer { isChecking = false }
guard await isCurrentIdentity() else { decision = .obsolete; return }
do {
let result = try await api.status()
guard await isCurrentIdentity() else { decision = .obsolete; return }
guard restrictionRevision == revision else { return }
apply(result)
} catch {
guard await isCurrentIdentity() else { decision = .obsolete; return }
guard restrictionRevision == revision else { return }
if case APIError.serverCode(150015, _) = error {
decision = .unresolved
} else if case StoreAccountDeregistrationError.sessionChanged = error {
decision = .obsolete
} else {
decision = .failed(error.localizedDescription)
}
}
}
/// 用户确认后先重查同一申请再撤销,结果丢失时保留限制,禁止自动重发 POST。
func cancel(api: any StoreAccountDeregistrationServing,
isCurrentIdentity: @escaping @MainActor () -> Bool) async {
guard !isChecking, decision == .cooling, let recordID = status?.recordID else { return }
isChecking = true
let revision = restrictionRevision
defer { isChecking = false }
guard await isCurrentIdentity() else { decision = .obsolete; return }
do {
let current = try await api.status()
guard await isCurrentIdentity() else { decision = .obsolete; return }
guard restrictionRevision == revision else { return }
guard current.isCooling, current.recordID == recordID else { applyCancellationCheck(current); return }
try await api.cancel()
guard await isCurrentIdentity() else { decision = .obsolete; return }
guard restrictionRevision == revision else { return }
let confirmed = try await api.status()
guard await isCurrentIdentity() else { decision = .obsolete; return }
guard restrictionRevision == revision else { return }
applyCancellationCheck(confirmed)
} catch {
guard await isCurrentIdentity() else { decision = .obsolete; return }
guard restrictionRevision == revision else { return }
status = nil
decision = .failed("撤销结果尚未确认,请重新查询状态。\n" + error.localizedDescription)
}
}
private func applyCancellationCheck(_ result: StoreAccountDeregistrationStatus) {
// 曾查到冷静期后,旧 null/草稿不能证明撤销完成;须重新核实完整状态。
guard result.isCooling || result.isCancelled else {
status = result
decision = .unresolved
return
}
apply(result)
}
private func apply(_ result: StoreAccountDeregistrationStatus) {
status = result
if submissionStore?.hasUnresolvedSubmission == true { requiresSubmissionReconciliation = true }
if result.isCancelled, submissionStore?.clearCancelledSubmission(matching: result) == true {
requiresSubmissionReconciliation = false
}
if result.isCooling {
observedCooling = true
decision = .cooling
} else {
decision = result.permitsPreparation && !requiresSubmissionReconciliation
&& (!observedCooling || result.isCancelled) ? .allowed : .unresolved
}
}
/// 150015 只影响发出请求时的同一门店身份凭证,不能按手机号传播限制。
static func restrictionApplies(requestToken: String?, session: AppSessionStore) -> Bool {
guard session.accountType == .storeUser,
let requestToken, !requestToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false }
return requestToken == session.token
}
}
@@ -0,0 +1,317 @@
import Foundation
/// 注销流程本地交互阶段;不使用这些阶段伪造服务端业务状态。
enum StoreAccountDeregistrationStep: Equatable {
case conditions
case verification
case unresolvedRequest
}
/// 门店身份注销流程,所有资产确认和提交都重新检查服务端条件,不持久化手机号级注销状态。
final class StoreAccountDeregistrationViewModel {
/// 当前已核验的条件;刷新失败即清空,禁止继续使用过期权限。
private(set) var eligibility: StoreAccountDeregistrationEligibility?
/// 当前已查询的原始申请记录。
private(set) var status: StoreAccountDeregistrationStatus?
/// 页面交互阶段,不代表服务端冷静期状态。
private(set) var step: StoreAccountDeregistrationStep = .conditions
/// 同一流程只允许一个在途操作。
private(set) var isBusy = false
/// 显示给用户的错误或需要重新核验的原因。
private(set) var errorMessage: String?
/// 请求已发出后阻止同一流程重复提交,包括响应丢失的情况。
private(set) var submissionAttempted = false
/// 成功响应仅代表申请已提交,不代表账号注销完成。
private(set) var submissionAccepted = false
/// 申请明确受理后,以当前凭证只读查询到的冷静期截止时间;查询失败不改变受理结果。
private(set) var submittedCoolingUntil: String?
private let storeUserID: Int
private let submissionStore: (any StoreAccountDeregistrationSubmissionTracking)?
private var previousCancellationFingerprint: String?
private var needsUpdatedAssetConsent = false
/// 注入被冻结的业务身份 ID;网络服务另行注入以便测试。
init(storeUserID: Int, submissionStore: (any StoreAccountDeregistrationSubmissionTracking)? = nil) {
self.storeUserID = storeUserID
self.submissionStore = submissionStore
if submissionStore?.hasUnresolvedSubmission == true {
submissionAttempted = true
step = .unresolvedRequest
}
}
/// 仅当完整条件、两项确认和状态查询一致时,允许进入验证码步骤。
var canContinue: Bool {
!isBusy && !submissionAttempted && status?.permitsPreparation == true
&& eligibility?.permitsApplication == true
}
/// 一个入口处理两项确认;其他业务条件未满足时不能开始。
var canConfirmAssetsAndContinue: Bool {
!isBusy && !submissionAttempted && status?.permitsPreparation == true
&& eligibility?.permitsAssetConfirmation == true
&& (requiresAssetConfirmation || eligibility?.permitsApplication == true)
}
/// 未确认或确认期间资产变化时,必须重新向用户列出两项金额。
var requiresAssetConfirmation: Bool {
needsUpdatedAssetConsent || eligibility?.walletWaived != true || eligibility?.pointsWaived != true
}
/// 用户一次明确确认两项资产后,顺序调用旧接口;只读核实部分结果,不自动重发修改请求。
func confirmAssetsAndContinue(snapshot: StoreAccountDeregistrationEligibility,
api: any StoreAccountDeregistrationServing) async {
guard canConfirmAssetsAndContinue else { return }
isBusy = true
errorMessage = nil
defer { isBusy = false }
do {
for asset in [StoreAccountDeregistrationAsset.wallet, .points] {
try await reload(api: api)
let current = try validateAssetConsent(snapshot)
let waived = asset == .wallet ? current.walletWaived : current.pointsWaived
if !waived {
if asset == .wallet { try await api.waiveWallet() }
else { try await api.waivePoints() }
try await reload(api: api)
let confirmed = try validateAssetConsent(snapshot)
guard asset == .wallet ? confirmed.walletWaived : confirmed.pointsWaived else {
throw StoreAccountDeregistrationFlowError.confirmationIncomplete
}
}
}
guard eligibility?.permitsApplication == true, status?.permitsPreparation == true else {
throw StoreAccountDeregistrationFlowError.conditionsChanged
}
needsUpdatedAssetConsent = false
step = .verification
} catch {
if case StoreAccountDeregistrationFlowError.assetsChanged = error { needsUpdatedAssetConsent = true }
if case StoreAccountDeregistrationError.sessionChanged = error { invalidate(error); return }
// POST响应丢失也可能已经成功:仅GET恢复真实确认标记,下一次由用户主动重试。
do { try await reload(api: api) }
catch { invalidate(error); return }
if status?.permitsPreparation == true, eligibility?.permitsPreparation == true { step = .conditions }
let partial = eligibility?.walletWaived == true && eligibility?.pointsWaived == false
? "现金余额已确认,积分尚未确认。\n" : ""
errorMessage = partial + error.localizedDescription
}
}
private func validateAssetConsent(_ snapshot: StoreAccountDeregistrationEligibility) throws -> StoreAccountDeregistrationEligibility {
guard status?.permitsPreparation == true, let current = eligibility, current.permitsPreparation else {
throw StoreAccountDeregistrationFlowError.existingRequest
}
guard current.hasSameAssets(as: snapshot),
!(snapshot.walletWaived && !current.walletWaived),
!(snapshot.pointsWaived && !current.pointsWaived) else {
throw StoreAccountDeregistrationFlowError.assetsChanged
}
guard current.permitsAssetConfirmation else { throw StoreAccountDeregistrationFlowError.conditionsChanged }
return current
}
/// 无申请或未提交草稿且条件可读取时允许确认资产,零余额也不自动确认。
func canConfirm(_ asset: StoreAccountDeregistrationAsset) -> Bool {
guard !isBusy, !submissionAttempted, status?.permitsPreparation == true,
let eligibility, eligibility.permitsPreparation,
eligibility.walletBalanceFen >= 0, eligibility.pointsBalance >= 0 else { return false }
return asset == .wallet ? !eligibility.walletWaived : !eligibility.pointsWaived
}
/// 用户主动刷新;后端非空记录无法识别时禁止继续申请,不以七天本地倒计时替代查询。
func refresh(api: any StoreAccountDeregistrationServing) async {
guard !isBusy else { return }
isBusy = true
errorMessage = nil
defer { isBusy = false }
do { try await reload(api: api) }
catch { invalidate(error) }
}
/// 用户已在单独弹窗中确认某种资产;提交前比对弹窗中的两项余额快照。
func confirm(_ asset: StoreAccountDeregistrationAsset, snapshot: StoreAccountDeregistrationEligibility,
api: any StoreAccountDeregistrationServing) async {
guard canConfirm(asset) else { return }
isBusy = true
errorMessage = nil
defer { isBusy = false }
do {
try await reload(api: api)
guard status?.permitsPreparation == true, let current = eligibility, current.permitsPreparation else {
throw StoreAccountDeregistrationFlowError.existingRequest
}
guard current.hasSameAssets(as: snapshot) else { throw StoreAccountDeregistrationFlowError.assetsChanged }
guard current.walletBalanceFen >= 0, current.pointsBalance >= 0 else {
throw StoreAccountDeregistrationFlowError.conditionsChanged
}
if asset == .wallet {
if !current.walletWaived { try await api.waiveWallet() }
} else if !current.pointsWaived {
try await api.waivePoints()
}
// 不乐观设置确认标记:只有新查询返回 true 才算已确认。
try await reload(api: api)
} catch { invalidate(error) }
}
/// 条件全部满足后进入真实短信验证步骤。
func beginVerification() {
guard canContinue else { return }
step = .verification
errorMessage = nil
}
/// 未发出申请前可以返回重新查看条件。
func returnToConditions() {
guard !isBusy, !submissionAttempted else { return }
step = .conditions
}
/// 不接受用户指定收件手机号,使用接口绑定的门店手机号。
func sendSMS(api: any StoreAccountDeregistrationServing) async -> Bool {
guard canContinue, step == .verification else { return false }
isBusy = true
errorMessage = nil
defer { isBusy = false }
do {
try await checkReady(api: api)
try await api.sendSMS()
return true
} catch { invalidate(error); return false }
}
/// 同步校验验证码与必填原因;失败时不启动查询、持久化或申请请求。
@discardableResult
func validateSubmissionInput(smsCode: String, reason: String) -> Bool {
let code = smsCode.trimmingCharacters(in: .whitespacesAndNewlines)
let reason = reason.trimmingCharacters(in: .whitespacesAndNewlines)
if code.isEmpty {
errorMessage = "请输入收到的短信验证码"
return false
}
if reason.isEmpty {
errorMessage = "请输入注销原因"
return false
}
if reason.count > 50 {
errorMessage = "注销原因不能超过 50 个字"
return false
}
errorMessage = nil
return true
}
/// 校验输入后申请;服务端明确接受后只读查询一次冷静期截止时间,再交由页面退出。
func submit(smsCode: String, reason: String, api: any StoreAccountDeregistrationServing) async {
guard canContinue, step == .verification else { return }
let code = smsCode.trimmingCharacters(in: .whitespacesAndNewlines)
let reason = reason.trimmingCharacters(in: .whitespacesAndNewlines)
guard validateSubmissionInput(smsCode: code, reason: reason) else { return }
var verifiedEligibility: StoreAccountDeregistrationEligibility?
var verifiedStatus: StoreAccountDeregistrationStatus?
isBusy = true
errorMessage = nil
defer { isBusy = false }
do {
try await checkReady(api: api)
verifiedEligibility = eligibility
verifiedStatus = status
previousCancellationFingerprint = status?.cancellationFingerprint
try submissionStore?.recordSubmissionIntent(previousStatus: status)
submissionAttempted = true
step = .unresolvedRequest
eligibility = nil
try await api.apply(smsCode: code, reason: reason)
submissionAccepted = true
// 申请成功已经成立;状态查询只用于结果页展示,失败或尚未进入冷静期都不能推翻成功响应。
if let acceptedStatus = try? await api.status(), acceptedStatus.isCooling {
status = acceptedStatus
submittedCoolingUntil = acceptedStatus.coolingUntil
}
} catch {
if !submissionAccepted, isVerificationCodeRejection(error) {
submissionStore?.clearRejectedSubmission()
submissionAttempted = false
eligibility = verifiedEligibility
status = verifiedStatus
step = .verification
errorMessage = error.localizedDescription
return
}
// 业务码明确拒绝申请时可重新核验;网络/解码错误不能证明服务端没有收到申请。
if !submissionAccepted, case APIError.serverCode = error {
submissionStore?.clearRejectedSubmission()
submissionAttempted = false
step = .conditions
}
invalidate(error)
}
}
/// 旧接口没有独立错误码,验证码拒绝只能按服务端业务提示识别。
private func isVerificationCodeRejection(_ error: Error) -> Bool {
guard case let APIError.serverCode(_, message) = error else { return false }
return message.localizedCaseInsensitiveContains("验证码")
|| message.localizedCaseInsensitiveContains("verification code")
|| message.localizedCaseInsensitiveContains("sms code")
}
private func checkReady(api: any StoreAccountDeregistrationServing) async throws {
try await reload(api: api)
guard status?.permitsPreparation == true, eligibility?.permitsApplication == true else {
throw StoreAccountDeregistrationFlowError.conditionsChanged
}
}
private func reload(api: any StoreAccountDeregistrationServing) async throws {
eligibility = nil
status = nil
let currentStatus = try await api.status()
status = currentStatus
if !currentStatus.permitsPreparation || submissionAttempted { step = .unresolvedRequest }
let current = try await api.eligibility()
guard current.storeUserID == storeUserID, storeUserID > 0 else {
throw StoreAccountDeregistrationError.sessionChanged
}
eligibility = current
let eligibilityStatus = StoreAccountDeregistrationStatus(deregister: current.deregister)
if submissionAttempted, let fingerprint = currentStatus.cancellationFingerprint,
eligibilityStatus.cancellationFingerprint == fingerprint {
let reconciled = submissionStore.map { $0.clearCancelledSubmission(matching: currentStatus) }
?? (fingerprint != previousCancellationFingerprint)
if reconciled {
submissionAttempted = false
submissionAccepted = false
}
}
if !current.permitsPreparation { step = .unresolvedRequest }
if step == .unresolvedRequest, currentStatus.permitsPreparation, current.permitsPreparation, !submissionAttempted {
step = .conditions
}
if step == .verification, !current.permitsApplication { step = .conditions }
}
private func invalidate(_ error: Error) {
eligibility = nil
errorMessage = error.localizedDescription
if !submissionAttempted, status?.permitsPreparation == true { step = .conditions }
}
}
/// 本地交互检查失败,不替代服务端业务错误。
private enum StoreAccountDeregistrationFlowError: LocalizedError {
case assetsChanged
case conditionsChanged
case existingRequest
case confirmationIncomplete
var errorDescription: String? {
switch self {
case .assetsChanged: "资产余额已变化,请刷新后重新确认"
case .conditionsChanged: "注销条件已变化,请刷新并处理全部阻断项"
case .existingRequest: "已查询到注销记录,请先核实申请状态"
case .confirmationIncomplete: "资产确认尚未完成,请刷新后重试"
}
}
}
@@ -57,6 +57,7 @@ enum LoginValidationError: Equatable {
enum LoginResolution {
case completed(V9AuthResponse, AccountSwitchAccount)
case needsAccountSelection(AccountSelectionPayload)
case needsDeregistrationConfirmation(DeregistrationLoginConfirmation)
}
/// 登录流程错误实体,表示 token、账号列表或账号 ID 异常。
@@ -124,8 +125,40 @@ struct AccountSwitchAccount: Identifiable, Hashable {
let storeId: Int?
let storeName: String
let scenicId: Int?
let status: Int
let isCurrent: Bool
/// 创建统一账号展示模型;门店状态默认为正常,兼容尚未返回 `status` 的旧响应。
init(
accountType: String,
businessUserId: Int,
title: String,
subtitle: String,
phone: String,
realName: String,
avatar: String,
scenicName: String,
storeId: Int?,
storeName: String,
scenicId: Int?,
status: Int = 1,
isCurrent: Bool
) {
self.accountType = accountType
self.businessUserId = businessUserId
self.title = title
self.subtitle = subtitle
self.phone = phone
self.realName = realName
self.avatar = avatar
self.scenicName = scenicName
self.storeId = storeId
self.storeName = storeName
self.scenicId = scenicId
self.status = status
self.isCurrent = isCurrent
}
var id: String {
"\(accountType)_\(businessUserId)"
}
@@ -134,6 +167,11 @@ struct AccountSwitchAccount: Identifiable, Hashable {
accountType == V9StoreUser.accountTypeValue
}
/// `status == 2` 表示门店身份已提交注销申请,登录前需要用户明确确认撤销。
var requiresDeregistrationConfirmation: Bool {
isStoreUser && status == 2
}
var accountTypeLabel: String {
isStoreUser ? "门店账号" : "景区账号"
}
@@ -146,6 +184,12 @@ struct AccountSwitchAccount: Identifiable, Hashable {
}
}
/// 冷静期门店身份的待确认登录信息;确认后继续调用 `set-user`,由后端自动撤销注销申请。
struct DeregistrationLoginConfirmation: Equatable {
let tempToken: String
let account: AccountSwitchAccount
}
/// 登录账号选择载荷,保存临时 token 和待用户选择的账号列表。
struct AccountSelectionPayload: Equatable, Identifiable {
let id = UUID()
@@ -285,6 +329,7 @@ struct V9StoreUser: Decodable, Equatable {
let roleName: String
let appRoleCode: String
let appRoleName: String
let status: Int
let isCurrent: Bool
var businessUserId: Int {
@@ -308,6 +353,7 @@ struct V9StoreUser: Decodable, Equatable {
storeId: storeId > 0 ? storeId : nil,
storeName: storeName,
scenicId: scenicId > 0 ? scenicId : nil,
status: status,
isCurrent: isCurrent
)
}
@@ -327,6 +373,7 @@ struct V9StoreUser: Decodable, Equatable {
case roleName = "role_name"
case appRoleCode = "app_role_code"
case appRoleName = "app_role_name"
case status
case isCurrent = "is_current"
}
@@ -346,6 +393,7 @@ struct V9StoreUser: Decodable, Equatable {
roleName = try container.decodeLossyString(forKey: .roleName)
appRoleCode = try container.decodeLossyString(forKey: .appRoleCode)
appRoleName = try container.decodeLossyString(forKey: .appRoleName)
status = try container.decodeLossyInt(forKey: .status) ?? 1
isCurrent = try container.decodeLossyBool(forKey: .isCurrent) ?? false
}
}
@@ -30,6 +30,37 @@ enum MessageJSONValue: Decodable, Hashable, Sendable {
self = .object(try container.decode([String: MessageJSONValue].self))
}
}
/// 将消息字段转换为合法的正整数标识;兼容后端返回数字或数字字符串。
var positiveInt: Int? {
let value: Int?
switch self {
case let .number(number):
guard number.isFinite, number.rounded() == number else { return nil }
value = Int(exactly: number)
case let .string(string):
value = Int(string.trimmingCharacters(in: .whitespacesAndNewlines))
default:
value = nil
}
guard let value, value > 0 else { return nil }
return value
}
/// 返回对象类型的原始键值。
var objectValue: [String: MessageJSONValue]? {
switch self {
case let .object(value):
return value
case let .string(value):
guard let data = value.data(using: .utf8),
let decoded = try? JSONDecoder().decode(MessageJSONValue.self, from: data)
else { return nil }
return decoded.objectValue
default:
return nil
}
}
}
/// 消息未读数量响应,用于同步首页红点和桌面图标角标。
@@ -203,6 +234,20 @@ struct MessageItem: Decodable, Hashable, Sendable {
return MessageDateFormatter.formatDateTime(createdAt) ?? createdAt
}
/// 是否为 AI 修图任务通知;人工修图的 `type = 10` 不属于该类型。
var isAIRetouchTaskNotification: Bool {
type == 14
}
/// AI 修图批次 ID,兼容 `extra_data` 直接字段及 `extra_data.data` 包装结构。
var aiRetouchBatchId: Int? {
guard isAIRetouchTaskNotification, let extraData else { return nil }
if let batchId = extraData["ai_retouch_batch_id"]?.positiveInt {
return batchId
}
return extraData["data"]?.objectValue?["ai_retouch_batch_id"]?.positiveInt
}
/// 返回已读状态的消息副本。
func markedRead() -> MessageItem {
MessageItem(
@@ -163,6 +163,16 @@ final class MessageDetailViewModel {
self.message = message
}
/// AI 修图任务通知在详情页展示任务入口。
var showsAIRetouchTaskAction: Bool {
message.isAIRetouchTaskNotification
}
/// 当前消息携带的 AI 修图批次 ID;缺失时由页面降级进入任务列表。
var aiRetouchBatchId: Int? {
message.aiRetouchBatchId
}
/// 删除当前消息。
func delete(api: any MessageCenterServing) async {
guard message.id > 0 else {
@@ -0,0 +1,48 @@
import Foundation
/// 线下收款接口抽象,供生产 API 与测试替身共用。
@MainActor
protocol OfflineCollectionServing: AnyObject {
func statistics(scenicId: Int) async throws -> OfflineCollectionStatisticsResponse
func details(scenicId: Int, date: String) async throws -> OfflineCollectionDetailsResponse
func register(_ request: OfflineCollectionRegisterRequest) async throws -> OfflineCollectionRegisterResponse
func supplement(_ request: OfflineSettlementRequest) async throws -> OfflineSettlementResult
}
/// 线下收款真实网络 API。
@MainActor
final class OfflineCollectionAPI: OfflineCollectionServing {
private let client: APIClient
private let prefix = "/api/yf-handset-app/photog/offline-pay-collect"
init(client: APIClient) {
self.client = client
}
func statistics(scenicId: Int) async throws -> OfflineCollectionStatisticsResponse {
try await client.send(APIRequest(
method: .get,
path: "\(prefix)/statistics",
queryItems: [URLQueryItem(name: "scenic_id", value: String(scenicId))]
))
}
func details(scenicId: Int, date: String) async throws -> OfflineCollectionDetailsResponse {
try await client.send(APIRequest(
method: .get,
path: "\(prefix)/details",
queryItems: [
URLQueryItem(name: "scenic_id", value: String(scenicId)),
URLQueryItem(name: "date", value: date),
]
))
}
func register(_ request: OfflineCollectionRegisterRequest) async throws -> OfflineCollectionRegisterResponse {
try await client.send(APIRequest(method: .post, path: "\(prefix)/register", body: request))
}
func supplement(_ request: OfflineSettlementRequest) async throws -> OfflineSettlementResult {
try await client.send(APIRequest(method: .post, path: "\(prefix)/supplement", body: request))
}
}
@@ -0,0 +1,110 @@
import Foundation
/// 日清日历的展示模式。
enum OfflineCollectionCalendarMode: Sendable, Equatable {
case week
case month
}
/// 周/月日历日期运算状态,不依赖 UIKit,便于单元测试。
struct OfflineCollectionCalendarState: Sendable, Equatable {
private let calendar: Calendar
private(set) var selectedDate: Date
private(set) var maximumDate: Date
private(set) var mode: OfflineCollectionCalendarMode
init(
selectedDate: Date,
maximumDate: Date,
mode: OfflineCollectionCalendarMode = .week,
calendar: Calendar = OfflineCollectionDate.calendar
) {
self.calendar = calendar
self.maximumDate = calendar.startOfDay(for: maximumDate)
self.selectedDate = min(calendar.startOfDay(for: selectedDate), self.maximumDate)
self.mode = mode
}
/// 切换周历和月历并保留选中日期。
mutating func toggleMode() {
mode = mode == .week ? .month : .week
}
/// 选择不晚于服务端今日的日期。
mutating func select(_ date: Date) -> Bool {
let value = calendar.startOfDay(for: date)
guard value <= maximumDate else { return false }
selectedDate = value
return true
}
/// 横滑一个周或一个月,并返回新的选中日期。
@discardableResult
mutating func movePage(_ offset: Int) -> Date {
let candidate: Date
switch mode {
case .week:
candidate = calendar.date(byAdding: .day, value: offset * 7, to: selectedDate) ?? selectedDate
case .month:
let current = calendar.dateComponents([.year, .month, .day], from: selectedDate)
let monthStart = calendar.date(from: DateComponents(year: current.year, month: current.month, day: 1)) ?? selectedDate
let targetMonth = calendar.date(byAdding: .month, value: offset, to: monthStart) ?? monthStart
let days = calendar.range(of: .day, in: .month, for: targetMonth)?.count ?? 1
candidate = calendar.date(byAdding: .day, value: min(current.day ?? 1, days) - 1, to: targetMonth) ?? targetMonth
}
selectedDate = min(calendar.startOfDay(for: candidate), maximumDate)
return selectedDate
}
/// 判断指定方向是否存在不晚于服务端今日的周或月页面。
func canMovePage(_ offset: Int) -> Bool {
guard offset != 0 else { return false }
if offset < 0 { return true }
switch mode {
case .week:
guard let candidate = calendar.date(byAdding: .day, value: offset * 7, to: selectedDate) else { return false }
let weekday = calendar.component(.weekday, from: candidate)
let daysFromMonday = (weekday + 5) % 7
let targetMonday = calendar.date(byAdding: .day, value: -daysFromMonday, to: candidate) ?? candidate
return calendar.startOfDay(for: targetMonday) <= maximumDate
case .month:
let current = calendar.dateComponents([.year, .month], from: selectedDate)
guard let monthStart = calendar.date(from: current),
let targetMonth = calendar.date(byAdding: .month, value: offset, to: monthStart) else { return false }
let maximumMonth = calendar.date(
from: calendar.dateComponents([.year, .month], from: maximumDate)
) ?? maximumDate
return targetMonth <= maximumMonth
}
}
/// 当前周的周一至周日。
var weekDates: [Date] {
let weekday = calendar.component(.weekday, from: selectedDate)
let daysFromMonday = (weekday + 5) % 7
let monday = calendar.date(byAdding: .day, value: -daysFromMonday, to: selectedDate) ?? selectedDate
return (0 ..< 7).compactMap { calendar.date(byAdding: .day, value: $0, to: monday) }
}
/// 当前月份固定六行、周一开周的 42 个日期。
var monthDates: [Date] {
let parts = calendar.dateComponents([.year, .month], from: selectedDate)
let first = calendar.date(from: parts) ?? selectedDate
let weekday = calendar.component(.weekday, from: first)
let leading = (weekday + 5) % 7
let start = calendar.date(byAdding: .day, value: -leading, to: first) ?? first
return (0 ..< 42).compactMap { calendar.date(byAdding: .day, value: $0, to: start) }
}
/// 当前选中日期所在周在固定六行月历中的零基行号。
var selectedWeekRowIndex: Int {
let index = monthDates.firstIndex { calendar.isDate($0, inSameDayAs: selectedDate) } ?? 0
return index / 7
}
/// 当前选中月份标题。
var monthTitle: String {
let parts = calendar.dateComponents([.year, .month], from: selectedDate)
return "\(parts.year ?? 0)年\(parts.month ?? 0)月"
}
}
@@ -0,0 +1,376 @@
import Foundation
/// 线下收款方式,对应后端 `pay_method`。
enum OfflineCollectionPaymentMethod: Int, Codable, CaseIterable, Sendable, Hashable {
case wechat = 2
case alipay = 1
case cash = 3
var displayName: String {
switch self { case .wechat: "微信"; case .alipay: "支付宝"; case .cash: "现金" }
}
var systemImageName: String {
switch self { case .wechat: "message.fill"; case .alipay: "a.circle.fill"; case .cash: "banknote.fill" }
}
var assetName: String {
switch self { case .wechat: "payment_method_wechat"; case .alipay: "payment_method_alipay"; case .cash: "payment_method_cash" }
}
}
/// 线下收款记录的补缴展示状态。
enum OfflineCollectionStatus: Int, Codable, Sendable, Hashable {
case pending = 0
case settled = 1
case overdue = 2
var displayName: String {
switch self { case .pending: "待补缴"; case .settled: "已补缴"; case .overdue: "逾期未补缴" }
}
}
/// 当前登录账号在线下收款页面中的展示和请求上下文。
struct OfflineCollectionContext: Sendable, Hashable {
let collectorName: String
let storeName: String
let scenicId: Int
let scenicName: String
static func current(appStore: AppStore = .shared) -> OfflineCollectionContext {
let session = appStore.session
let stores = appStore.permissions.rolePermissionList().flatMap(\.store)
let store = stores.first(where: { $0.id == session.currentStoreId }) ?? stores.first
let name = [session.realName, session.userName, session.accountDisplayName]
.first { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } ?? "-"
return OfflineCollectionContext(
collectorName: name,
storeName: store?.name.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "-",
scenicId: session.currentScenicId,
scenicName: session.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "-"
)
}
}
/// 日清页展示的一笔线下收款记录。
struct OfflineCollectionRecord: Sendable, Hashable {
let collectNo: String
let amountFen: Int
let paymentMethod: OfflineCollectionPaymentMethod
let timeText: String
let status: OfflineCollectionStatus
}
/// 某一营业日的线下收款汇总。
struct OfflineDailySummary: Sendable, Hashable {
let businessDate: String
let totalCount: Int
let totalAmountFen: Int
let settledCount: Int
let settledAmountFen: Int
let pendingCount: Int
let pendingAmountFen: Int
static func empty(_ date: String) -> OfflineDailySummary {
OfflineDailySummary(
businessDate: date, totalCount: 0, totalAmountFen: 0,
settledCount: 0, settledAmountFen: 0, pendingCount: 0, pendingAmountFen: 0
)
}
}
/// 统计接口中的一个待补缴日期标记。
struct OfflinePendingDate: Decodable, Sendable, Hashable {
let date: String
let unpaidAmount: String
let unpaidCount: Int
enum CodingKeys: String, CodingKey {
case date
case unpaidAmount = "unpaid_amount"
case unpaidCount = "unpaid_count"
}
var unpaidAmountFen: Int { OfflineCollectionMoney.responseFen(unpaidAmount) ?? 0 }
}
/// 线下收款统计接口响应。
struct OfflineCollectionStatisticsResponse: Decodable, Sendable {
/// 服务端今日营业日及金额汇总。
struct Today: Decodable, Sendable {
let date: String
let totalAmount: String
let paidAmount: String
let unpaidAmount: String
let collectCount: Int
enum CodingKeys: String, CodingKey {
case date
case totalAmount = "total_amount"
case paidAmount = "paid_amount"
case unpaidAmount = "unpaid_amount"
case collectCount = "collect_count"
}
}
/// 当前账号在所选景区内的全部待补缴汇总。
struct Pending: Decodable, Sendable {
let amount: String
let collectCount: Int
let dateCount: Int
let dates: [OfflinePendingDate]
enum CodingKeys: String, CodingKey {
case amount
case collectCount = "collect_count"
case dateCount = "date_count"
case dates
}
}
let today: Today
let pending: Pending
var todaySummary: OfflineDailySummary {
let pendingCount = pending.dates.first(where: { $0.date == today.date })?.unpaidCount ?? 0
return OfflineDailySummary(
businessDate: today.date,
totalCount: today.collectCount,
totalAmountFen: OfflineCollectionMoney.responseFen(today.totalAmount) ?? 0,
settledCount: max(0, today.collectCount - pendingCount),
settledAmountFen: OfflineCollectionMoney.responseFen(today.paidAmount) ?? 0,
pendingCount: pendingCount,
pendingAmountFen: OfflineCollectionMoney.responseFen(today.unpaidAmount) ?? 0
)
}
}
/// 单日详情接口响应。
struct OfflineCollectionDetailsResponse: Decodable, Sendable {
/// 单日详情中的一笔登记记录。
struct Collect: Decodable, Sendable {
let collectNo: String
let amount: String
let payMethod: Int
let status: Int
let time: String
enum CodingKeys: String, CodingKey {
case collectNo = "collect_no"
case amount
case payMethod = "pay_method"
case status
case time
}
}
let date: String
let totalAmount: String
let collectCount: Int
let paidAmount: String
let paidCount: Int
let unpaidAmount: String
let unpaidCount: Int
let status: Int?
let statusText: String?
let collects: [Collect]
enum CodingKeys: String, CodingKey {
case date
case totalAmount = "total_amount"
case collectCount = "collect_count"
case paidAmount = "paid_amount"
case paidCount = "paid_count"
case unpaidAmount = "unpaid_amount"
case unpaidCount = "unpaid_count"
case status
case statusText = "status_text"
case collects
}
var summary: OfflineDailySummary {
OfflineDailySummary(
businessDate: date,
totalCount: collectCount,
totalAmountFen: OfflineCollectionMoney.responseFen(totalAmount) ?? 0,
settledCount: paidCount,
settledAmountFen: OfflineCollectionMoney.responseFen(paidAmount) ?? 0,
pendingCount: unpaidCount,
pendingAmountFen: OfflineCollectionMoney.responseFen(unpaidAmount) ?? 0
)
}
func records(serverToday: String) -> [OfflineCollectionRecord] {
collects.compactMap { item in
guard let method = OfflineCollectionPaymentMethod(rawValue: item.payMethod) else { return nil }
let rawStatus = OfflineCollectionStatus(rawValue: item.status) ?? .pending
let status: OfflineCollectionStatus = rawStatus == .pending && date < serverToday ? .overdue : rawStatus
return OfflineCollectionRecord(
collectNo: item.collectNo,
amountFen: OfflineCollectionMoney.responseFen(item.amount) ?? 0,
paymentMethod: method,
timeText: String(item.time.prefix(5)),
status: status
)
}
}
}
/// 登记一笔线下收款的请求。
struct OfflineCollectionRegisterRequest: Encodable, Sendable {
let scenicId: Int
let amount: String
let payMethod: Int
enum CodingKeys: String, CodingKey {
case scenicId = "scenic_id"
case amount
case payMethod = "pay_method"
}
}
/// 登记线下收款响应。
struct OfflineCollectionRegisterResponse: Decodable, Sendable {
let collectNo: String
let amount: String
let payMethod: Int
let status: Int
let createdAt: String
enum CodingKeys: String, CodingKey {
case collectNo = "collect_no"
case amount
case payMethod = "pay_method"
case status
case createdAt = "created_at"
}
var record: OfflineCollectionRecord {
OfflineCollectionRecord(
collectNo: collectNo,
amountFen: OfflineCollectionMoney.responseFen(amount) ?? 0,
paymentMethod: OfflineCollectionPaymentMethod(rawValue: payMethod) ?? .wechat,
timeText: OfflineCollectionDate.timePart(createdAt),
status: OfflineCollectionStatus(rawValue: status) ?? .pending
)
}
}
/// 补缴确认数据;金额和笔数仅用于客户端确认,后端只接收景区和日期。
struct OfflineSettlementRequest: Encodable, Sendable, Hashable {
let scenicId: Int
let businessDate: String
let amountFen: Int
let pendingCount: Int
enum CodingKeys: String, CodingKey {
case scenicId = "scenic_id"
case businessDate = "date"
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(scenicId, forKey: .scenicId)
try container.encode(businessDate, forKey: .businessDate)
}
}
/// 一次成功补缴的返回结果。
struct OfflineSettlementResult: Decodable, Sendable, Hashable {
let date: String
let updatedCount: Int
enum CodingKeys: String, CodingKey {
case date
case updatedCount = "updated_count"
}
}
/// 登记成功弹窗所需数据。
struct OfflineCollectionRegistrationReceipt: Sendable {
let record: OfflineCollectionRecord
let businessDate: String
let summary: OfflineDailySummary?
}
/// 线下收款业务的客户端异常。
enum OfflineCollectionError: LocalizedError, Sendable, Equatable {
case missingScenic
case invalidAmount
case noPendingRecords
var errorDescription: String? {
switch self {
case .missingScenic: "请先选择景区"
case .invalidAmount: "请输入0.01~99,999.99元的有效金额"
case .noPendingRecords: "当前营业日无待补缴记录"
}
}
}
/// 线下收款金额的精确解析与格式化工具。
enum OfflineCollectionMoney {
static let maximumFen = 9_999_999
static func acceptsEditingText(_ text: String) -> Bool {
guard !text.contains(where: { !$0.isNumber && $0 != "." }) else { return false }
let parts = text.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count <= 2 else { return false }
return (parts.first?.count ?? 0) <= 5 && (parts.count == 2 ? parts[1].count : 0) <= 2
}
static func parseFen(_ rawValue: String) -> Int? {
guard !rawValue.isEmpty, rawValue == rawValue.trimmingCharacters(in: .whitespacesAndNewlines),
acceptsEditingText(rawValue), rawValue != "." else { return nil }
let parts = rawValue.split(separator: ".", omittingEmptySubsequences: false)
guard let whole = Int(parts[0].isEmpty ? "0" : String(parts[0])) else { return nil }
let fraction = parts.count == 2 ? String(parts[1]) : ""
let cents = fraction.isEmpty ? 0 : (fraction.count == 1 ? (Int(fraction) ?? 0) * 10 : (Int(fraction) ?? 0))
let value = whole * 100 + cents
return (1 ... maximumFen).contains(value) ? value : nil
}
static func responseFen(_ value: String) -> Int? { parseFen(value) }
static func apiAmount(_ fen: Int) -> String { "\(fen / 100).\(String(format: "%02d", fen % 100))" }
static func displayAmount(_ fen: Int) -> String {
let whole = fen / 100
let cents = fen % 100
if cents == 0 { return "\(whole)" }
if cents.isMultiple(of: 10) { return "\(whole).\(cents / 10)" }
return "\(whole).\(String(format: "%02d", cents))"
}
static func display(_ fen: Int) -> String { "¥\(displayAmount(fen))" }
}
/// 线下收款统一使用的营业日工具。
enum OfflineCollectionDate {
static var calendar: Calendar {
var calendar = Calendar(identifier: .gregorian)
calendar.locale = Locale(identifier: "zh_CN")
calendar.timeZone = TimeZone(identifier: "Asia/Shanghai")!
calendar.firstWeekday = 2
return calendar
}
static func businessDate(for date: Date, calendar: Calendar = calendar) -> String {
let values = calendar.dateComponents([.year, .month, .day], from: date)
return String(format: "%04d-%02d-%02d", values.year ?? 0, values.month ?? 0, values.day ?? 0)
}
static func date(from value: String, calendar: Calendar = calendar) -> Date? {
let formatter = DateFormatter()
formatter.calendar = calendar
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = calendar.timeZone
formatter.dateFormat = "yyyy-MM-dd"
return formatter.date(from: value)
}
static func timePart(_ value: String) -> String {
String((value.split(separator: " ").last ?? Substring(value)).prefix(5))
}
}
private extension String {
var nonEmpty: String? { isEmpty ? nil : self }
}
@@ -0,0 +1,275 @@
import Foundation
/// 收款首页线下收款区域的加载状态与业务数据。
final class OfflineCollectionHomeViewModel {
private(set) var statistics: OfflineCollectionStatisticsResponse?
private(set) var isLoading = false
private(set) var errorMessage: String?
let context: OfflineCollectionContext
private let api: any OfflineCollectionServing
var onStateChange: (() -> Void)?
init(context: OfflineCollectionContext = .current(), api: any OfflineCollectionServing) {
self.context = context
self.api = api
}
var todayBusinessDate: String? { statistics?.today.date }
var todaySummary: OfflineDailySummary {
statistics?.todaySummary ?? .empty(todayBusinessDate ?? "")
}
var overdueDates: [OfflinePendingDate] {
guard let statistics else { return [] }
return statistics.pending.dates.filter { $0.date < statistics.today.date }
}
var earliestOverdueBusinessDate: String? { overdueDates.first?.date }
var overdueRecordCount: Int { overdueDates.reduce(0) { $0 + $1.unpaidCount } }
var overdueAmountFen: Int { overdueDates.reduce(0) { $0 + $1.unpaidAmountFen } }
/// 从真实统计接口刷新首页卡片。
func load() async {
guard context.scenicId > 0 else {
errorMessage = OfflineCollectionError.missingScenic.localizedDescription
onStateChange?()
return
}
isLoading = true
errorMessage = nil
onStateChange?()
do {
statistics = try await api.statistics(scenicId: context.scenicId)
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
onStateChange?()
}
}
/// 线下收款登记页 ViewModel,负责表单校验和登记后的统计刷新。
final class OfflineCollectionRegistrationViewModel {
private(set) var amountText = ""
private(set) var paymentMethod: OfflineCollectionPaymentMethod = .wechat
private(set) var isSubmitting = false
let context: OfflineCollectionContext
let api: any OfflineCollectionServing
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var onRegistrationSuccess: ((OfflineCollectionRegistrationReceipt) -> Void)?
init(context: OfflineCollectionContext = .current(), api: any OfflineCollectionServing) {
self.context = context
self.api = api
}
var canSubmit: Bool {
!isSubmitting && context.scenicId > 0 && OfflineCollectionMoney.parseFen(amountText) != nil
}
func updateAmount(_ value: String) {
guard OfflineCollectionMoney.acceptsEditingText(value) else { return }
guard value != amountText else { return }
amountText = value
onStateChange?()
}
func selectPaymentMethod(_ method: OfflineCollectionPaymentMethod) {
guard method != paymentMethod else { return }
paymentMethod = method
onStateChange?()
}
/// 按后端现有契约提交一笔线下收款。
func submit() async {
guard !isSubmitting else { return }
guard context.scenicId > 0 else {
onShowMessage?(OfflineCollectionError.missingScenic.localizedDescription)
return
}
guard let amountFen = OfflineCollectionMoney.parseFen(amountText) else {
onShowMessage?(OfflineCollectionError.invalidAmount.localizedDescription)
return
}
isSubmitting = true
onStateChange?()
defer {
isSubmitting = false
onStateChange?()
}
do {
let response = try await api.register(OfflineCollectionRegisterRequest(
scenicId: context.scenicId,
amount: OfflineCollectionMoney.apiAmount(amountFen),
payMethod: paymentMethod.rawValue
))
let statistics = try? await api.statistics(scenicId: context.scenicId)
let responseDate = String(response.createdAt.prefix(10))
onRegistrationSuccess?(OfflineCollectionRegistrationReceipt(
record: response.record,
businessDate: statistics?.today.date ?? responseDate,
summary: statistics?.todaySummary
))
} catch {
onShowMessage?(error.localizedDescription)
}
}
func startAnotherRegistration() {
amountText = ""
onStateChange?()
}
}
/// 日清页的补缴呈现状态。
enum OfflineSettlementPresentationState: Sendable, Equatable {
case idle
case processing
case success(OfflineSettlementResult)
case failed(String, canRetry: Bool)
}
/// 日清页 ViewModel,管理服务端营业日、日期标记、详情请求与补缴快照。
final class OfflineCollectionDailyViewModel {
private(set) var businessDate: String
private(set) var serverToday: String
private(set) var summary: OfflineDailySummary
private(set) var records: [OfflineCollectionRecord] = []
private(set) var pendingDates: Set<String> = []
private(set) var isLoading = false
private(set) var errorMessage: String?
private(set) var settlementState: OfflineSettlementPresentationState = .idle
private(set) var preparedRequest: OfflineSettlementRequest?
let context: OfflineCollectionContext
private let api: any OfflineCollectionServing
private var detailRequestVersion = 0
var onStateChange: (() -> Void)?
init(businessDate: String, context: OfflineCollectionContext = .current(), api: any OfflineCollectionServing) {
self.businessDate = businessDate
serverToday = businessDate
summary = .empty(businessDate)
self.context = context
self.api = api
}
var isToday: Bool { businessDate == serverToday }
var canSettle: Bool {
summary.pendingAmountFen > 0 && summary.pendingCount > 0 && settlementState != .processing && !isLoading
}
/// 首次进入或页面重新出现时刷新统计和当前详情。
func refresh() async {
guard context.scenicId > 0 else {
errorMessage = OfflineCollectionError.missingScenic.localizedDescription
onStateChange?()
return
}
isLoading = true
errorMessage = nil
onStateChange?()
do {
let statistics = try await api.statistics(scenicId: context.scenicId)
serverToday = statistics.today.date
pendingDates = Set(statistics.pending.dates.map(\.date))
if businessDate.isEmpty || businessDate > serverToday { businessDate = serverToday }
await loadDetails(for: businessDate)
} catch {
isLoading = false
errorMessage = error.localizedDescription
onStateChange?()
}
}
/// 选择任意不晚于服务端今日的日期,并只接受最后一次请求结果。
func selectBusinessDate(_ date: String) async {
guard date <= serverToday, settlementState != .processing else { return }
businessDate = date
preparedRequest = nil
settlementState = .idle
await loadDetails(for: date)
}
private func loadDetails(for date: String) async {
detailRequestVersion += 1
let version = detailRequestVersion
isLoading = true
errorMessage = nil
records = []
summary = .empty(date)
onStateChange?()
do {
let details = try await api.details(scenicId: context.scenicId, date: date)
guard version == detailRequestVersion, date == businessDate else { return }
summary = details.summary
records = details.records(serverToday: serverToday)
isLoading = false
onStateChange?()
} catch is CancellationError {
return
} catch {
guard version == detailRequestVersion, date == businessDate else { return }
isLoading = false
errorMessage = error.localizedDescription
onStateChange?()
}
}
func prepareSettlement() throws -> OfflineSettlementRequest {
guard canSettle else { throw OfflineCollectionError.noPendingRecords }
let request = OfflineSettlementRequest(
scenicId: context.scenicId,
businessDate: businessDate,
amountFen: summary.pendingAmountFen,
pendingCount: summary.pendingCount
)
preparedRequest = request
return request
}
func cancelPreparedSettlement() {
guard settlementState != .processing else { return }
preparedRequest = nil
}
/// 按后端现有契约补缴指定日期;失败时允许用户再次提交。
func confirmSettlement() async {
guard settlementState != .processing, let request = preparedRequest else { return }
settlementState = .processing
onStateChange?()
do {
let result = try await api.supplement(request)
preparedRequest = nil
settlementState = .success(result)
await refreshAfterSettlement()
} catch {
settlementState = .failed(error.localizedDescription, canRetry: true)
onStateChange?()
}
}
private func refreshAfterSettlement() async {
if let statistics = try? await api.statistics(scenicId: context.scenicId) {
serverToday = statistics.today.date
pendingDates = Set(statistics.pending.dates.map(\.date))
}
detailRequestVersion += 1
let version = detailRequestVersion
if let details = try? await api.details(scenicId: context.scenicId, date: businessDate), version == detailRequestVersion {
summary = details.summary
records = details.records(serverToday: serverToday)
}
isLoading = false
onStateChange?()
}
func clearSettlementFeedback() {
guard settlementState != .processing else { return }
settlementState = .idle
onStateChange?()
}
}
@@ -0,0 +1,816 @@
# AI 修图任务中心需求与接口设计
> 文档状态:待产品、后端、Android、iOS 联审
> 更新日期:2026-08-14
> 适用范围:随心瞰商家版 AI 修图任务,不包含人工修图任务
## 1. 背景与目标
AI 修图属于异步长耗时任务。当前用户提交后只能等待或主动返回相册刷新,无法明确知道任务是否仍在排队、预计何时完成、哪些照片成功或失败。
本需求增加:
1. 当前账号全部相册的 AI 修图任务列表。
2. 单次 AI 修图任务详情。
3. AI 修图终态消息推送。
4. 从推送和任务列表进入对应任务的完整导航链路;提交成功后仅 Toast 提示并返回相册管理。
本期解决“看得到进度、完成会通知、结果可直达”的问题,不增加任务取消、批量重试、历史版本管理或后台供应商诊断能力。
## 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
→ 客户端 Toast 提示“AI修图任务已提交,完成后将通过消息通知”
→ 关闭模板页及可能存在的照片预览页,回到相册管理并刷新列表
→ 用户可离开页面
→ 任务进入终态后收到 type = 14 推送
→ 点击推送进入任务详情
→ 查看成功结果或进入相册处理失败项
```
### 3.2 页面入口
- 相册管理页导航栏右侧增加“修图任务”,进入当前账号的全局任务列表;新增相册页不展示该入口。
- AI 修图提交成功仅展示自动消失的 Toast,不弹出查看任务确认框,也不提供立即跳转操作。
- 提交成功后回到相册管理页,并刷新相册摘要、数量和当前素材列表。
- 任务列表点击卡片进入对应任务详情。
- `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 设计稿
![AI 修图任务列表设计稿](../../../docs/design/ai-retouch-task-center/01-task-list.png)
### 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 设计稿
![AI 修图任务详情设计稿 V2](../../../docs/design/ai-retouch-task-center/02-task-detail-v2.png)
### 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,不把设计稿作为页面背景切图。
@@ -0,0 +1,527 @@
# AI 修图接口文档
## 接口列表
| # | 方法 | 路径 | 用途 |
|---|---|---|---|
| 1 | `GET` | `/api/yf-handset-app/photog/travel-album/material-list` | 获取相册网格素材和预览初始数据 |
| 2 | `GET` | `/api/yf-handset-app/photog/travel-album/material-info` | 获取/刷新当前原图及关联图 |
| 3 | `GET` | `/api/yf-handset-app/photog/travel-album/ai-retouch-options` | 获取 AI 模板、选择规则和剩余额度 |
| 4 | `POST` | `/api/yf-handset-app/photog/travel-album/ai-retouch` | 提交首次修图或重新修图任务 |
| 5 | `GET` | `/api/yf-handset-app/photog/travel-album/ai-retouch-job-info` | 查询 AI 修图任务进度和结果 |
| 6 | `POST` | `/api/yf-handset-app/photog/travel-album/delete-material` | 单张/批量删除素材及关联图 |
## 接口详情
### 获取相册素材列表
```http
GET /api/yf-handset-app/photog/travel-album/material-list?user_equity_travel_id=88&limit=30&cursor=opaque_cursor&sort=created_at_desc
```
| 参数 | 必填 | 说明 |
|---|---|---|
| `user_equity_travel_id` | 是 | 相册 ID |
| `limit` | 否 | 默认 30,最大 100 |
| `cursor` | 否 | 服务端返回的不透明游标,首页不传 |
| `sort` | 否 | `created_at_desc` 或 `created_at_asc` |
| `material_type` | 否 | `original`、`cover`;不传表示全部 |
响应:
```json
{
"code": 100000,
"msg": "success",
"data": {
"items": [
{
"id": "2031",
"user_equity_travel_id": "88",
"material_type": "original",
"original_asset": {
"id": "asset_8001",
"url": "https://cdn.example.com/albums/88/IMG_1024.jpg",
"thumbnail_url": "https://cdn.example.com/albums/88/IMG_1024_thumb.jpg",
"file_name": "IMG_1024.jpg",
"mime_type": "image/jpeg",
"file_size": 4821931,
"width": 4032,
"height": 3024,
"version": 1,
"created_at": "2026-08-09T01:00:00.000Z"
},
"display_status": "uploaded",
"variant_slots": [],
"revision": 7,
"created_at": "2026-08-09T01:00:00.000Z",
"updated_at": "2026-08-11T07:30:18.000Z"
}
],
"next_cursor": "next_opaque_cursor",
"has_more": true
}
}
```
- 只返回网格顶层素材:原图和独立封面。
- 原图如存在关联图,`variant_slots` 包含预览页 Tab 初始数据。
- 建议使用游标分页,因为 AI 任务完成时可能实时插入封面素材。
### 获取/刷新单个原图项目
```http
GET /api/yf-handset-app/photog/travel-album/material-info?user_equity_travel_id=88&material_id=2031
```
查询参数 `user_equity_travel_id` 和 `material_id` 均必填。响应的 `data` 直接返完整 `material_project`,字段与列表 Item 完全一致。该接口供预览页刷新和提交后定向更新使用。
如 `material_id` 已删除,返回 `404 MATERIAL_NOT_FOUND`。
### 获取 AI 修图选项
```http
GET /api/yf-handset-app/photog/travel-album/ai-retouch-options?user_equity_travel_id=88&scope=batch&source_count=4
```
| `scope` | 使用场景 |
|---|---|
| `batch` | 网格多选 AI 修图,或预览页尚无 AI 结果 Tab 时首次修图 |
| `all_variants` | 预览页已有 AI 结果 Tab;当前为原图、精修后或氛围感均使用此范围 |
| `refined_only` | 仅重修精修结果的接口能力,当前客户端无独立入口 |
| `atmosphere_only` | 仅重修氛围感结果的接口能力,当前客户端无独立入口 |
`user_equity_travel_id`、`scope` 和 `source_count` 均必填。`source_count` 用于计算封面模板是否显示和必选。
响应:
```json
{
"code": 100000,
"msg": "success",
"data": {
"scope": "batch",
"source_count": 4,
"groups": [
{
"type": "refined",
"title": "原图精修",
"required": true,
"selection_mode": "single",
"quota_cost": {
"mode": "per_source",
"units": 1
},
"templates": [
{
"id": "tpl_refined_12",
"name": "清透精修",
"preview_url": "https://cdn.example.com/templates/refined_12.jpg",
"before_url": "https://cdn.example.com/templates/refined_12_before.jpg",
"after_url": "https://cdn.example.com/templates/refined_12_after.jpg",
"enabled": true
}
]
},
{
"type": "atmosphere",
"title": "氛围感修图",
"required": false,
"selection_mode": "single",
"quota_cost": {
"mode": "per_source",
"units": 1
},
"templates": [
{
"id": "tpl_atmosphere_06",
"name": "暖阳",
"preview_url": "https://cdn.example.com/templates/atmosphere_06.jpg",
"before_url": "https://cdn.example.com/templates/atmosphere_06_before.jpg",
"after_url": "https://cdn.example.com/templates/atmosphere_06_after.jpg",
"enabled": true
}
]
},
{
"type": "cover",
"title": "封面风格模板",
"required": true,
"selection_mode": "single",
"quota_cost": {
"mode": "per_job",
"units": 0
},
"templates": [
{
"id": "tpl_cover_03",
"name": "旅行画册",
"preview_url": "https://cdn.example.com/templates/cover_03.jpg",
"before_url": "https://cdn.example.com/templates/cover_03_before.jpg",
"after_url": "https://cdn.example.com/templates/cover_03_after.jpg",
"enabled": true
}
]
}
],
"quota": {
"unit": "retouch_time",
"remaining": 12,
"reserved": 3,
"display_text": "剩余 12 次",
"updated_at": "2026-08-11T07:29:58.000Z"
},
"limits": {
"minimum_source_count": 1,
"maximum_source_count": 50,
"cover_minimum_source_count": 4
}
}
}
```
设计要点:
- 不再让 App 传 `scenic_id`,后端从相册归属景区获取可用模板。
- 由后端返回 `required` 和可见分组,避免多端各写一套“4 张显示封面”规则。
- `preview_url` 用于模板卡片缩略图;`before_url` 与 `after_url` 必须是同尺寸、同构图的配对图片,供客户端滑动对比。
- 客户端可默认选中必选分组的第一个可用模板,可选分组默认不选。
- 此接口用于 UI 配置;提交时后端仍必须根据真实素材 ID 重新校验。
- 同一响应返回当前用户的剩余可用额度和每类输出的额度单价,弹窗无需再发起第二个额度请求。
- `quota.remaining` 是扣除其他未完成任务已预占数量后、当前立即可用的额度;弹窗底部使用此值显示“剩余 N 次”。
- 额度文案位于模板选择弹窗的固定底部区域,不随中间模板列表滚动。
- `quota.reserved` 是当前用户其他未完成任务的预占额度,仅用于解释账户状态。
- 客户端预估本次消耗时,对已选输出求和:`per_source` 类型为 `source_count * units`,`per_job` 类型为 `units`。封面始终为 0。
- 本文档默认精修和氛围感每生成一张各消耗 1 次。如实际商业规则变更,后端只需调整 `quota_cost`,客户端不需改接口。
### 创建 AI 修图任务
```http
POST /api/yf-handset-app/photog/travel-album/ai-retouch
Idempotency-Key: 9A7820CE-78AB-45C6-9E0F-5DF268995379
Content-Type: application/json
```
#### 5.4.1 网格批量修图
```json
{
"user_equity_travel_id": "88",
"scope": "batch",
"source_material_ids": ["2031", "2032", "2033", "2034"],
"outputs": [
{
"type": "refined",
"template_id": "tpl_refined_12"
},
{
"type": "atmosphere",
"template_id": "tpl_atmosphere_06"
},
{
"type": "cover",
"template_id": "tpl_cover_03"
}
]
}
```
#### 5.4.2 预览页已有 AI 结果 Tab
```json
{
"user_equity_travel_id": "88",
"scope": "all_variants",
"source_material_ids": ["2031"],
"outputs": [
{
"type": "atmosphere",
"template_id": "tpl_atmosphere_09"
}
]
}
```
已有精修或氛围感结果时,无论当前位于原图、精修后还是氛围感 Tab,精修和氛围感模板均为选填,但至少选择一种。`outputs` 只传本次选中的结果类型;缺失表示“本次不处理”,不表示删除或覆盖对应的已有结果。
#### 5.4.3 只重新精修
```json
{
"user_equity_travel_id": "88",
"scope": "refined_only",
"source_material_ids": ["2031"],
"outputs": [
{
"type": "refined",
"template_id": "tpl_refined_18"
}
]
}
```
#### 5.4.4 只重新生成氛围感
```json
{
"user_equity_travel_id": "88",
"scope": "atmosphere_only",
"source_material_ids": ["2031"],
"outputs": [
{
"type": "atmosphere",
"template_id": "tpl_atmosphere_11"
}
]
}
```
#### 5.4.5 提交校验矩阵
| `scope` | 原图数 | 精修 | 氛围感 | 封面 |
|---|---:|---|---|---|
| `batch` | 1 至 50 | 必选 | 可选 | 少于 4 张禁止;4 张及以上必选 |
| `all_variants` | 必须为 1 | 可选(与氛围感至少一项) | 可选(与精修至少一项) | 禁止 |
| `refined_only` | 必须为 1 | 必选 | 禁止 | 禁止 |
| `atmosphere_only` | 必须为 1 | 禁止 | 必选 | 禁止 |
另外:
- `source_material_ids` 必须去重,且全部为 `original` 类型。
- 模板必须启用、类型匹配,且适用于相册所属景区。
- 后端必须使用提交时的最新额度计费规则重新计算消耗,不信任客户端的预估数值。
- 封面输出不论首次生成、重试或任务结果如何,额度消耗始终为 0。
- 每个输出槽位均采用 `replace_on_success`:成功后替换旧版本,失败保留旧版本。
- 批量选中已有 AI 结果的原图时,允许重新生成,只覆盖本次 `outputs` 列出的类型。
- 同一原图、同一槽位已有 `queued/processing` 任务时,整次请求返回 `409 RETOUCH_TARGET_BUSY`,并列出冲突项。
#### 5.4.6 提交响应
HTTP 状态码:`202 Accepted`
```json
{
"code": 100000,
"msg": "AI 修图任务已提交",
"data": {
"job": {
"id": "job_01K2E4",
"user_equity_travel_id": "88",
"scope": "all_variants",
"status": "queued",
"source_material_ids": ["2031"],
"progress": {
"total": 2,
"queued": 2,
"processing": 0,
"succeeded": 0,
"failed": 0
},
"created_at": "2026-08-11T07:30:15.123Z",
"started_at": null,
"finished_at": null
},
"quota_reservation": {
"before_remaining": 12,
"reserved_units": 2,
"remaining_after_reservation": 10,
"cover_units": 0
},
"affected_projects": [
{
"id": "2031",
"user_equity_travel_id": "88",
"material_type": "original",
"original_asset": {
"id": "asset_8001",
"url": "https://cdn.example.com/albums/88/IMG_1024.jpg",
"thumbnail_url": "https://cdn.example.com/albums/88/IMG_1024_thumb.jpg",
"file_name": "IMG_1024.jpg",
"mime_type": "image/jpeg",
"file_size": 4821931,
"width": 4032,
"height": 3024,
"version": 1,
"created_at": "2026-08-09T01:00:00.000Z"
},
"display_status": "pending",
"variant_slots": [
{
"type": "refined",
"asset": null,
"latest_generation": {
"job_id": "job_01K2E4",
"status": "queued",
"is_replacement": false,
"template": {
"id": "tpl_refined_15",
"name": "自然精修"
},
"requested_at": "2026-08-11T07:30:15.123Z",
"started_at": null,
"finished_at": null,
"error": null
}
},
{
"type": "atmosphere",
"asset": null,
"latest_generation": {
"job_id": "job_01K2E4",
"status": "queued",
"is_replacement": false,
"template": {
"id": "tpl_atmosphere_09",
"name": "暖阳"
},
"requested_at": "2026-08-11T07:30:15.123Z",
"started_at": null,
"finished_at": null,
"error": null
}
}
],
"revision": 8,
"created_at": "2026-08-09T01:00:00.000Z",
"updated_at": "2026-08-11T07:30:15.123Z"
}
]
}
}
```
必须返回 `job.id`、`quota_reservation` 和更新后的 `affected_projects`。客户端用 `remaining_after_reservation` 立即更新剩余额度,并更新 Cell 状态;已有 `asset` 的重修 Tab 可同步显示处理态,首次生成且 `asset = null` 的槽位不显示 Tab。
### 查询 AI 修图任务
```http
GET /api/yf-handset-app/photog/travel-album/ai-retouch-job-info?user_equity_travel_id=88&job_id=job_01K2E4
```
响应:
```json
{
"code": 100000,
"msg": "success",
"data": {
"id": "job_01K2E4",
"user_equity_travel_id": "88",
"scope": "batch",
"status": "partially_succeeded",
"source_material_ids": ["2031", "2032", "2033", "2034"],
"progress": {
"total": 9,
"queued": 0,
"processing": 0,
"succeeded": 8,
"failed": 1
},
"quota_settlement": {
"status": "settled",
"reserved_units": 8,
"consumed_units": 7,
"released_units": 1,
"cover_units": 0
},
"targets": [
{
"source_material_id": "2031",
"output_type": "refined",
"status": "succeeded",
"result_asset_id": "asset_9201",
"error": null
},
{
"source_material_id": "2032",
"output_type": "atmosphere",
"status": "failed",
"result_asset_id": null,
"error": {
"code": "AI_PROVIDER_TIMEOUT",
"message": "AI 服务处理超时,请重试",
"retryable": true
}
},
{
"source_material_id": null,
"output_type": "cover",
"status": "succeeded",
"result_material_id": "cover_301",
"error": null
}
],
"created_at": "2026-08-11T07:30:15.123Z",
"started_at": "2026-08-11T07:30:18.000Z",
"finished_at": "2026-08-11T07:33:00.000Z"
}
}
```
| 整体状态 | 含义 |
|---|---|
| `queued` | 已受理,尚无子任务开始 |
| `processing` | 至少一个子任务正在执行,且尚未全部完成 |
| `succeeded` | 所有子任务成功 |
| `partially_succeeded` | 子任务全部结束,且既有成功也有失败 |
| `failed` | 子任务全部结束,没有任何成功结果 |
| `canceled` | 任务因素材删除或管理操作被取消 |
额度结算规则:
- 任务提交成功时先预占所有非封面子任务的额度,封面不进入预占。
- 每个精修/氛围感子任务成功后,将对应预占转为实际消耗。
- 子任务失败或取消时释放对应预占。因此部分成功示例中,预占 8 次、成功消耗 7 次、失败退回 1 次。
- `quota_settlement.status` 取值为 `reserved | partially_settled | settled`。
客户端如只关心当前预览图,优先调用单素材接口;本接口主要用于批量进度、问题排查和任务详情。
### 批量删除素材
```http
POST /api/yf-handset-app/photog/travel-album/delete-material
Content-Type: application/json
```
请求:
```json
{
"user_equity_travel_id": "88",
"material_ids": ["2031", "2032", "cover_301"]
}
```
为兼容旧客户端,原单张请求体继续有效:
```json
{
"id": "2031"
}
```
新请求使用 `user_equity_travel_id + material_ids`,旧请求使用 `id`,两组参数不同时传入。
响应:
```json
{
"code": 100000,
"msg": "删除成功",
"data": {
"deleted_material_ids": ["2031", "2032", "cover_301"],
"deleted_variant_asset_ids": ["asset_9101", "asset_9102"],
"canceled_job_ids": ["job_01K2E4"],
"oss_cleanup_status": "scheduled"
}
}
```
语义:
- 传入原图 ID:级联软删除精修图、氛围感图,并取消该原图的未完成子任务。
- 传入独立封面 ID:只删除该封面素材。
- 删除原图不级联删除以它为输入之一的已生成封面。
- 数据库业务删除采用一个事务,默认全部成功或全部失败。
- 任一 ID 不属于当前相册或无权删除时,整次请求失败。已删除的 ID 可视为幂等成功。
- OSS 文件清理异步执行,不影响业务删除成功。
@@ -0,0 +1,168 @@
# AI 修图需求文档
> 本文档描述旅拍相册中 AI 修图的产品需求和业务规则。后端接口建议见同目录《AI 修图接口文档》。
## 1. 目标与范围
- 相册管理页面展示用户已在其他流程上传到 OSS、并已在后端登记的图片素材。
- 用户可从网格页批量发起 AI 修图,也可从单图预览页首次修图或重新修图。
- 精修图和氛围感图是某张原图的“关联图”;封面图是相册内的独立素材,不归属于任何一张原图。
- AI 生成为异步长流程,页面必须能分辨已提交、排队、处理、成功和失败。
## 2. 核心概念
### 2.1 原图项目
一张原图与它的精修图、氛围感图共同组成一个原图项目。原图的素材 ID 是项目的稳定标识,OSS URL 不作为业务标识。
### 2.2 关联图
- `refined`:精修图,一张原图最多保留一个当前有效版本。
- `atmosphere`:氛围感图,一张原图最多保留一个当前有效版本。
- 关联图不作为独立 Cell 出现在相册网格中,只在该原图的预览页 Tab 中展示。
### 2.3 封面图
- 一次选择不少于 4 张原图时,必须选择封面模板。
- 封面模板使用本次选中的多张原图生成封面图。
- 封面图不关联某张原图,生成成功后作为 `cover` 类型的独立素材出现在相册网格中。
- 删除参与生成封面的原图时,不级联删除已生成的独立封面图。
## 3. 相册管理页
### 3.1 网格列表
- 网格展示原图和独立封面图,不直接展示精修图、氛围感图。
- 列表需支持稳定分页。AI 任务执行期间可能插入新的封面素材,不应造成重复或漏项。
- 点击原图 Cell 进入图片预览页,可横向滑动切换其他原图项目。
### 3.2 Cell 修图状态
Cell 左上角展示稳定的修图状态:
| 状态 | 含义 |
|---|---|
| 已上传 | 原图已登记,从未提交 AI 修图,当前也没有任务 |
| 待处理 | AI 任务已提交,正在排队 |
| 修图中 | AI 任务已开始执行 |
| AI 已修 | 最新任务已成功,存在有效 AI 结果 |
| 失败 | 最新任务执行失败,可能仍保留上一版成功结果 |
状态以后端返回的枚举代码为准,客户端自行映射中文文案,不依赖可变的中文状态名。
### 3.3 批量 AI 修图
1. 用户进入多选模式并选择一张或多张原图。
2. 点击底部“AI 修图”,弹出模板选择页。
3. 精修模板必选且单选。
4. 氛围感模板可选且单选,已选时可取消。
5. 选中原图数量小于 4 时不展示封面模板。
6. 选中原图数量大于等于 4 时展示封面模板,且必须单选一个。
7. 提交后,每张原图分别生成精修图,选了氛围感模板时再分别生成氛围感图。
8. 选了封面模板时,整个批次额外生成独立封面图。
9. 提交成功指后端已受理任务,不代表图片已生成。
### 3.4 修图额度
- 模板选择弹窗的底部固定显示当前用户可用的 AI 修图剩余额度,文案例如“剩余 12 次”,不随模板列表滚动消失。
- 精修和氛围感生成会消耗修图额度;封面模板生成不消耗任何修图额度。
- 本需求默认按生成结果张数计费:每张原图的精修结果消耗 1 次,每张原图的氛围感结果消耗 1 次,封面结果消耗 0 次。最终单价由后端返回,客户端不硬编码。
- 重新修图与首次修图使用相同的额度规则。
- 后端受理任务时预占本次所需额度,避免用户并发提交造成超额。
- 只对最终生成成功的精修/氛围感结果扣减额度;失败或取消的子任务释放对应预占额度。
- 如剩余可用额度不足,提交按钮禁用并显示明确提示;后端提交接口仍必须做最终校验。
### 3.5 批量删除
- 用户多选原图后点击“删除”,删除选中原图及各自的精修图、氛围感图,并取消关联的未完成任务。已有任务记录保留用于审计和排查,但不再对客户端展示已删除素材。
- 已生成的独立封面图不在级联删除范围内,除非用户明确选中该封面素材本身。
- 批量删除应只调用一次后端接口,后端应返回整体或逐项结果,不由客户端循环调用单删接口。
## 4. 图片预览页
### 4.1 横向切换
- 从网格点击某张原图进入预览页。
- 横向滑动在相册内的不同原图项目之间切换。
- 切换到新的原图项目时,默认显示其“原图”。
### 4.2 底部操作
页面底部固定显示三个按钮:
- AI 修图
- 删除
- 刷新
### 4.3 关联图 Tab
- 如果当前原图没有任何已生成的关联图,不显示 Tab;首次生成任务排队或处理期间仍不显示。
- 存在至少一张已成功的关联图时显示 Tab。
- Tab 最多包含“原图”、“精修后”、“氛围感”;关联图 Tab 只在对应图片已生成时显示。
- 点击 Tab 切换当前原图项目内的展示图片,不切换原图项目。
- 某个已有结果的 Tab 重新提交修图后,仍可展示上一版图片,同时通过该结果槽位的任务状态展示“待处理/修图中/失败”。
- 首次生成尚无图片 URL 时,通过原图 Cell 状态和项目任务状态表达进度,不创建空 Tab。
### 4.4 预览页删除
- 删除的对象始终是当前原图项目,而不是当前 Tab 上的单张关联图。
- 删除原图及其所有关联图,并取消关联的未完成任务;历史任务记录保留用于审计。
- 删除成功后显示下一个原图项目;如果删除的是末项,则回退上一项;无剩余项时关闭预览页。
### 4.5 预览页 AI 修图
| 当前情况 | 模板要求 | 生成与覆盖规则 |
|---|---|---|
| 不显示 Tab | 精修必选,氛围感可选 | 首次生成关联的精修图,可选生成氛围感图 |
| 已显示 Tab,当前为“原图” | 精修、氛围感均可选,至少选择一种 | 只重新生成本次选中的结果类型;新结果成功后替换对应旧结果,未选类型保持不变 |
| 已显示 Tab,当前为“精修后” | 精修、氛围感均可选,至少选择一种 | 只重新生成本次选中的结果类型;新结果成功后替换对应旧结果,未选类型保持不变 |
| 已显示 Tab,当前为“氛围感” | 精修、氛围感均可选,至少选择一种 | 只重新生成本次选中的结果类型;新结果成功后替换对应旧结果,未选类型保持不变 |
重新修图期间应保留上一版成功图片可见;只有新结果成功时才原子替换当前版本。失败时继续保留旧版本,并返回可展示的错误信息。
### 4.6 刷新
- 点击“刷新”只获取当前显示原图项目的最新数据,包括原图、关联图、各结果槽位的任务状态和整体修图状态。
- 不应为刷新一张图重新拉取整个分页列表。
- 服务端对已替换的图片应返回新的资源版本或新 URL,避免 CDN/客户端缓存继续显示旧图。
## 5. 异步任务规则
- 每次提交返回唯一 `job_id`、服务端接收时间和当前状态。
- 每次提交同时返回额度预占数、提交前剩余额度和预占后可用额度。
- 任务状态至少包含:`queued`、`processing`、`succeeded`、`partially_succeeded`、`failed`、`canceled`。
- 精修、氛围感和封面子任务可以独立成功或失败,服务端需保留逐原图、逐结果类型的状态。
- 客户端重试同一次提交时不得创建重复任务,由幂等键保证。
- 同一原图、同一结果类型存在未完成任务时,服务端拒绝再次提交,避免旧任务晚完成后覆盖新结果。
## 6. 删除与数据一致性
- 删除原图项目需在一个后端业务操作中完成:标记原图删除、标记关联图删除、取消未完成任务。
- 数据库状态成功后再异步清理 OSS 文件,不应因 OSS 删除失败导致客户端删除失败。
- AI 回调必须检查任务和素材是否已删除或已被新版本替代,过期回调不得恢复已删除素材或覆盖新结果。
## 7. 异常与提示
- 模板加载失败:保留页面并允许重试。
- 模板已下线:提交时返回明确错误码,客户端刷新模板列表。
- 素材不存在、不属于当前相册或不是原图:整次提交不受理,并返回问题素材 ID。
- 数量不满足封面规则:返回稳定错误码和最小数量。
- 任务部分成功:保留已成功结果,失败槽位提供错误码、错误信息和是否可重试。
- 网络超时:客户端使用原幂等键重试,不可产生两个任务。
## 8. 权限与校验
- 后端必须从登录态校验用户对相册、原图和模板的访问权,不信任客户端传入的用户 ID、景区 ID 或批次 ID。
- 模板是否适用于相册所属景区,由后端根据相册 ID 推导和校验。
- 后端负责校验精修、氛围感和封面模板的必选/可选规则,客户端校验只用于交互提示。
## 9. 默认产品决策
为使接口和异步覆盖行为可实现,本文档采用以下默认决策:
1. 重新修图不先删旧图;新图成功后才原子切换,失败仍保留旧图。
2. 在“原图” Tab 重新修图时,若用户未选氛围感模板,保留现有氛围感图,只替换精修图。
3. 封面图只在生成成功后插入网格;生成前由所选原图的修图状态和任务详情表达进度。
4. 每个关联图类型只对外暴露一个当前有效版本;历史版本是否长期保留属于后端存储策略,不在当前客户端功能中展示。
> 第 2 条是对“氛围感模板可选”的安全解释:未选不等于删除旧结果。如产品希望未选时删除旧氛围感图,应增加明确的“移除氛围感”操作,不建议让后端根据字段缺失隐式删除。
@@ -0,0 +1,92 @@
# AI 自动修图后端接口改造(精简版)
## 一、需要修改的接口
### 1. 创建相册
`POST /api/yf-handset-app/photog/travel-album/create`
请求新增:
```json
{
"auto_retouch_config": {
"enabled": true,
"refined_template_id": 12
}
}
```
规则:
- `enabled = true` 时,`refined_template_id` 必填且模板必须有效。
- `enabled = false` 时,服务端将 `refined_template_id` 规范化为 `null`。
- 响应返回完整 `auto_retouch_config`。
### 2. 相册详情和列表
- `GET /api/yf-handset-app/photog/travel-album/info`
- `GET /api/yf-handset-app/photog/travel-album/list`
每个相册新增响应字段:
```json
{
"auto_retouch_config": {
"enabled": true,
"refined_template_id": 12
}
}
```
规则:
- 历史相册没有配置时按关闭状态返回。
## 二、需要新增的接口
### 更新相册自动修图配置
`POST /api/yf-handset-app/photog/travel-album/auto-retouch-config`
请求:
```json
{
"user_equity_travel_id": 88,
"enabled": true,
"refined_template_id": 12
}
```
成功响应 `data`:
```json
{
"enabled": true,
"refined_template_id": 12
}
```
规则:
- 返回服务端规范化后的完整配置。
- 多设备同时修改时采用最后一次写入生效。
## 三、无需新增但需要确保可用的接口
- `POST .../upload-material`:成功后必须返回稳定的服务端素材 `id`。
- `GET .../ai-retouch-templates`:直接使用现有 `refined_templates`,无需新增模板描述字段。
- `POST .../ai-retouch`:请求和响应保持现状,不增加 `client_request_id`。
- `GET .../ai-retouch-job-info`:支持按任务批次查询 `queued / processing / succeeded / partially_succeeded / failed / canceled` 状态。
客户端会在 `upload-material` 成功后提交 AI 任务,并在前台每 8 秒查询任务状态。AI 额度不足、模板失效或任务失败都不能回滚原图上传结果。
由于 `ai-retouch` 不提供幂等能力,客户端在请求超时、断网等结果不确定的情况下不得自动重复提交;只能先同步素材或任务状态。若无法确认是否已创建任务,需提示用户“提交状态未知”,避免直接重试导致重复任务或重复扣额。
## 四、后端验收重点
1. 创建、详情和列表中的配置字段保持一致。
2. 配置关闭时服务端将 `refined_template_id` 规范化为 `null`。
3. 原图登记成功后可以使用现有 `ai-retouch` 接口创建单素材精修任务。
4. AI 任务失败或额度不足时,原图上传结果保持成功状态。
@@ -0,0 +1,52 @@
# AI 自动修图接口补充
本文仅描述相册级自动修图新增契约;现有手动批量 AI 修图接口保持不变。
## 相册配置
`create` 请求新增:
```json
{
"auto_retouch_config": {
"enabled": true,
"refined_template_id": 12
}
}
```
`create`、`info`、`list` 响应返回服务端规范化后的完整配置:
```json
{
"auto_retouch_config": {
"enabled": true,
"refined_template_id": 12
}
}
```
旧相册缺少 `auto_retouch_config` 时,客户端按关闭处理。
## 更新配置
```http
POST /api/yf-handset-app/photog/travel-album/auto-retouch-config
Content-Type: application/json
```
```json
{
"user_equity_travel_id": 88,
"enabled": true,
"refined_template_id": 12
}
```
响应 `data` 为服务端规范化后的完整配置。多设备同时修改时采用最后一次写入生效。
## AI 任务提交
`POST ai-retouch` 请求和响应保持现状,不增加 `client_request_id`。请求结果不确定时客户端不得自动重复提交,应先同步素材或任务状态;无法确认时提示用户“提交状态未知”。
自动修图只使用现有模板接口的 `refined_templates`,不展示模板描述,也不自动生成氛围感或封面。
@@ -50,11 +50,26 @@ protocol TravelAlbumServing {
/// 拉取当前景区可用的 AI 修图模板。
func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse
/// 更新相册级自动 AI 修图配置。
func updateAutoRetouchConfiguration(
_ request: TravelAlbumAutoRetouchConfigurationRequest
) async throws -> TravelAlbumAutoRetouchConfiguration
/// 提交相册素材 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 +212,51 @@ 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)
/// 更新自动修图配置并返回服务端最新版本。
func updateAutoRetouchConfiguration(
_ request: TravelAlbumAutoRetouchConfigurationRequest
) async throws -> TravelAlbumAutoRetouchConfiguration {
try await client.send(
APIRequest(method: .post, path: "\(basePath)/auto-retouch-config", body: request)
)
}
/// 提交重新修图任务;服务端返回的批次与额度信息当前无需消费。
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(method: .post, path: "\(basePath)/ai-reretouch", 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)
)
}
/// 拉取指定 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())
}
}
@@ -5,6 +5,36 @@
import Foundation
/// 相册级自动 AI 修图配置,由服务端同步并在每次上传开始时固定快照。
struct TravelAlbumAutoRetouchConfiguration: Codable, Sendable, Equatable, Hashable {
let enabled: Bool
let refinedTemplateId: Int?
enum CodingKeys: String, CodingKey {
case enabled
case refinedTemplateId = "refined_template_id"
}
/// 默认关闭自动修图,用于兼容尚未返回配置字段的旧接口。
static let disabled = TravelAlbumAutoRetouchConfiguration(
enabled: false,
refinedTemplateId: nil
)
/// 创建经过规范化的自动修图配置。
init(enabled: Bool, refinedTemplateId: Int?) {
let validTemplateId = refinedTemplateId.flatMap { $0 > 0 ? $0 : nil }
self.enabled = enabled
self.refinedTemplateId = enabled ? validTemplateId : nil
}
/// 上传页紧凑设置项的展示文案。
var displayTitle: String { enabled ? "AI修图" : "不修图" }
/// 当前配置是否可以用于提交自动修图任务。
var isValid: Bool { !enabled || refinedTemplateId != nil }
}
/// 旅拍相册用户信息,对齐 Android `TravelAlbumUserEntity`。
struct TravelAlbumUser: Decodable, Sendable, Equatable, Hashable {
let id: Int
@@ -33,6 +63,7 @@ struct TravelAlbum: Decodable, Sendable, Equatable, Hashable, Identifiable {
let createdAt: String
let updatedAt: String
let user: TravelAlbumUser?
let autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration
enum CodingKeys: String, CodingKey {
case id
@@ -50,6 +81,31 @@ struct TravelAlbum: Decodable, Sendable, Equatable, Hashable, Identifiable {
case createdAt = "created_at"
case updatedAt = "updated_at"
case user
case autoRetouchConfiguration = "auto_retouch_config"
}
/// 解码相册详情;旧响应缺少自动修图配置时按关闭状态兼容。
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(Int.self, forKey: .id)
storeUserId = try container.decode(Int.self, forKey: .storeUserId)
name = try container.decode(String.self, forKey: .name)
type = try container.decode(Int.self, forKey: .type)
orderNumber = try container.decode(String.self, forKey: .orderNumber)
materialNum = try container.decode(Int.self, forKey: .materialNum)
materialPrice = try container.decode(Int.self, forKey: .materialPrice)
materialPackagePrice = try container.decode(Int.self, forKey: .materialPackagePrice)
photoPrice = try container.decode(Int.self, forKey: .photoPrice)
coverUrl = try container.decode(String.self, forKey: .coverUrl)
userId = try container.decode(Int.self, forKey: .userId)
status = try container.decode(Int.self, forKey: .status)
createdAt = try container.decode(String.self, forKey: .createdAt)
updatedAt = try container.decode(String.self, forKey: .updatedAt)
user = try container.decodeIfPresent(TravelAlbumUser.self, forKey: .user)
autoRetouchConfiguration = try container.decodeIfPresent(
TravelAlbumAutoRetouchConfiguration.self,
forKey: .autoRetouchConfiguration
) ?? .disabled
}
init(
@@ -67,7 +123,8 @@ struct TravelAlbum: Decodable, Sendable, Equatable, Hashable, Identifiable {
status: Int = 0,
createdAt: String = "",
updatedAt: String = "",
user: TravelAlbumUser? = nil
user: TravelAlbumUser? = nil,
autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled
) {
self.id = id
self.storeUserId = storeUserId
@@ -84,6 +141,7 @@ struct TravelAlbum: Decodable, Sendable, Equatable, Hashable, Identifiable {
self.createdAt = createdAt
self.updatedAt = updatedAt
self.user = user
self.autoRetouchConfiguration = autoRetouchConfiguration
}
/// 展示用手机号。
@@ -224,7 +282,7 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
}
}
/// 相册素材网格角标类别,用于稳定映射文案优先级和语义颜色。
/// 相册素材网格角标类别,用于稳定映射独立业务状态和语义颜色。
enum TravelAlbumMaterialBadgeKind: Sendable, Equatable {
case purchased
case pending
@@ -241,14 +299,15 @@ struct TravelAlbumMaterialBadgePresentation: Sendable, Equatable {
}
extension TravelAlbumMaterial {
/// 按 AI 修图状态和购买状态生成网格角标;返回 nil 时隐藏角标。
var badgePresentation: TravelAlbumMaterialBadgePresentation? {
if aiRetouchStatus == 0 {
return isPurchased
? TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
: nil
}
/// 生成购买状态角标;购买状态与修图状态互不覆盖。
var purchaseBadgePresentation: TravelAlbumMaterialBadgePresentation? {
isPurchased
? TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
: nil
}
/// 生成 AI 修图状态角标;未进入修图流程时返回 nil。
var aiRetouchBadgePresentation: TravelAlbumMaterialBadgePresentation? {
let statusName = aiRetouchStatusName.trimmingCharacters(in: .whitespacesAndNewlines)
switch aiRetouchStatus {
case 1:
@@ -286,6 +345,7 @@ struct TravelAlbumCreateRequest: Encodable, Sendable, Equatable {
let materialPrice: Double?
let materialPackagePrice: Double?
let photoPrice: Double?
let autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration
enum CodingKeys: String, CodingKey {
case name
@@ -295,6 +355,67 @@ struct TravelAlbumCreateRequest: Encodable, Sendable, Equatable {
case materialPrice = "material_price"
case materialPackagePrice = "material_package_price"
case photoPrice = "photo_price"
case autoRetouchConfiguration = "auto_retouch_config"
}
private enum AutoRetouchConfigurationCodingKeys: String, CodingKey {
case enabled
case refinedTemplateId = "refined_template_id"
}
/// 创建相册请求;自动修图配置默认关闭以兼容现有调用方。
init(
name: String,
type: Int,
orderNumber: String?,
materialNum: Int?,
materialPrice: Double?,
materialPackagePrice: Double?,
photoPrice: Double?,
autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled
) {
self.name = name
self.type = type
self.orderNumber = orderNumber
self.materialNum = materialNum
self.materialPrice = materialPrice
self.materialPackagePrice = materialPackagePrice
self.photoPrice = photoPrice
self.autoRetouchConfiguration = autoRetouchConfiguration
}
/// 编码创建参数;相册配置版本由服务端生成,不随创建请求上送。
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(name, forKey: .name)
try container.encode(type, forKey: .type)
try container.encodeIfPresent(orderNumber, forKey: .orderNumber)
try container.encodeIfPresent(materialNum, forKey: .materialNum)
try container.encodeIfPresent(materialPrice, forKey: .materialPrice)
try container.encodeIfPresent(materialPackagePrice, forKey: .materialPackagePrice)
try container.encodeIfPresent(photoPrice, forKey: .photoPrice)
var configuration = container.nestedContainer(
keyedBy: AutoRetouchConfigurationCodingKeys.self,
forKey: .autoRetouchConfiguration
)
try configuration.encode(autoRetouchConfiguration.enabled, forKey: .enabled)
try configuration.encodeIfPresent(
autoRetouchConfiguration.refinedTemplateId,
forKey: .refinedTemplateId
)
}
}
/// 更新相册自动修图配置的请求参数。
struct TravelAlbumAutoRetouchConfigurationRequest: Encodable, Sendable, Equatable {
let userEquityTravelId: Int
let enabled: Bool
let refinedTemplateId: Int?
enum CodingKeys: String, CodingKey {
case userEquityTravelId = "user_equity_travel_id"
case enabled
case refinedTemplateId = "refined_template_id"
}
}
@@ -336,6 +457,28 @@ struct TravelAlbumListResponse<Item: Decodable & Sendable & Equatable>: Decodabl
/// 旅拍相册创建响应。
struct TravelAlbumCreateResponse: Decodable, Sendable, Equatable {
let id: Int
let autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration
enum CodingKeys: String, CodingKey {
case id
case autoRetouchConfiguration = "auto_retouch_config"
}
/// 创建相册响应;后端暂未回传配置时按关闭状态兼容。
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(Int.self, forKey: .id)
autoRetouchConfiguration = try container.decodeIfPresent(
TravelAlbumAutoRetouchConfiguration.self,
forKey: .autoRetouchConfiguration
) ?? .disabled
}
/// 创建相册响应测试数据。
init(id: Int, autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled) {
self.id = id
self.autoRetouchConfiguration = autoRetouchConfiguration
}
}
/// 旅拍相册小程序码响应。
@@ -358,7 +501,7 @@ enum TravelAlbumAIRetouchTemplateCategory: Int, Sendable, Hashable {
switch self {
case .refined: "原图精修"
case .atmosphere: "氛围感修图"
case .cover: "封面风格模板"
case .cover: "封面风格"
}
}
}
@@ -386,10 +529,16 @@ enum TravelAlbumAIRetouchWorkflow: Sendable, Equatable {
}
}
/// 当前分组是否允许不选择;仅首次修图的氛围感模板选填。
/// 当前分组是否允许不选择;首次修图仅氛围感选填,已有 AI 结果的预览重修两类模板均选填。
func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool {
if case .initial = self, category == .atmosphere { return true }
return false
switch self {
case .initial:
return category == .atmosphere
case .reretouch(_, _, .all):
return category == .refined || category == .atmosphere
case .reretouch:
return false
}
}
/// 工作流目标是否满足接口的最小参数要求。
@@ -403,23 +552,45 @@ enum TravelAlbumAIRetouchWorkflow: Sendable, Equatable {
}
}
/// AI 修图模板,包含业务 ID、展示名称和预览图地址。
/// AI 修图模板,包含业务 ID、展示名称、卡片预览图及前后对比图片地址。
struct TravelAlbumAIRetouchTemplate: Decodable, Sendable, Equatable, Hashable, Identifiable {
let id: Int
let name: String
let previewURL: String
let beforeURL: String
let afterURL: String
enum CodingKeys: String, CodingKey {
case id
case name
case previewURL = "preview_url"
case beforeURL = "before_url"
case afterURL = "after_url"
}
/// 创建 AI 修图模板。
init(id: Int, name: String, previewURL: String) {
init(
id: Int,
name: String,
previewURL: String,
beforeURL: String = "",
afterURL: String = ""
) {
self.id = id
self.name = name
self.previewURL = previewURL
self.beforeURL = beforeURL
self.afterURL = afterURL
}
/// 解码模板;前后对比字段缺失或为 null 时按空字符串兼容旧接口响应。
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(Int.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
previewURL = try container.decode(String.self, forKey: .previewURL)
beforeURL = (try? container.decodeIfPresent(String.self, forKey: .beforeURL)) ?? ""
afterURL = (try? container.decodeIfPresent(String.self, forKey: .afterURL)) ?? ""
}
}
@@ -467,6 +638,7 @@ struct TravelAlbumAIRetouchRequest: Encodable, Sendable, Equatable {
let refinedTemplateId: Int
let atmosphereTemplateId: Int?
let coverTemplateId: Int?
let clientRequestId: String?
enum CodingKeys: String, CodingKey {
case userEquityTravelId = "user_equity_travel_id"
@@ -474,6 +646,24 @@ struct TravelAlbumAIRetouchRequest: Encodable, Sendable, Equatable {
case refinedTemplateId = "refined_template_id"
case atmosphereTemplateId = "atmosphere_template_id"
case coverTemplateId = "cover_template_id"
case clientRequestId = "client_request_id"
}
/// 创建首次 AI 修图请求;手动修图可不传幂等标识,自动修图必须传稳定标识。
init(
userEquityTravelId: Int,
materialIds: [Int],
refinedTemplateId: Int,
atmosphereTemplateId: Int?,
coverTemplateId: Int?,
clientRequestId: String? = nil
) {
self.userEquityTravelId = userEquityTravelId
self.materialIds = materialIds
self.refinedTemplateId = refinedTemplateId
self.atmosphereTemplateId = atmosphereTemplateId
self.coverTemplateId = coverTemplateId
self.clientRequestId = clientRequestId
}
}
@@ -5,6 +5,32 @@
import Foundation
/// 前后图片对比页所需的通用内容,与具体入口和 UI 框架解耦。
struct BeforeAfterComparisonContent: Sendable, Equatable {
let beforeURL: String
let afterURL: String
let beforeLabel: String
let afterLabel: String
/// 创建前后对比内容,默认使用 AI 修图模块的中文角标。
init(
beforeURL: String,
afterURL: String,
beforeLabel: String = "原图",
afterLabel: String = "效果图"
) {
self.beforeURL = beforeURL.trimmingCharacters(in: .whitespacesAndNewlines)
self.afterURL = afterURL.trimmingCharacters(in: .whitespacesAndNewlines)
self.beforeLabel = beforeLabel
self.afterLabel = afterLabel
}
/// 两张图片地址均存在时才允许进入对比页。
var isValid: Bool {
!beforeURL.isEmpty && !afterURL.isEmpty
}
}
/// 相册预览页横向滑动策略,由调用方通过配置注入。
enum TravelAlbumPreviewSwipeMode: Sendable, Equatable {
/// 横滑只切换原图项目,关联图通过 Tab 切换。
@@ -85,6 +111,19 @@ struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
assets.first { $0.kind == kind }
}
/// 为精修或氛围感结果构建与原图的对比内容;其他类型或无效地址返回 nil。
func comparisonContent(for kind: TravelAlbumPreviewAssetKind) -> BeforeAfterComparisonContent? {
guard kind == .retouched || kind == .atmosphere,
let original = asset(for: .original),
let result = asset(for: kind)
else { return nil }
let content = BeforeAfterComparisonContent(
beforeURL: original.displayURL,
afterURL: result.displayURL
)
return content.isValid ? content : nil
}
/// 将素材映射为原图及实际存在的 AI 精修、氛围感结果图。
init(material: TravelAlbumMaterial) {
originalMaterialId = material.id
@@ -136,7 +175,7 @@ struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
self.assets = assets.filter { seenKinds.insert($0.kind).inserted }
}
/// 根据当前 Tab 生成首次修图或覆盖重修工作流。
/// 根据是否已有 AI 结果生成首次修图或双模板覆盖重修工作流。
func aiRetouchWorkflow(
albumId: Int,
selectedKind: TravelAlbumPreviewAssetKind
@@ -144,16 +183,16 @@ struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
guard hasVariants else {
return .initial(albumId: albumId, materialIds: [originalMaterialId])
}
switch selectedKind {
case .original:
return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .all)
case .retouched:
return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .refined)
case .atmosphere:
return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .atmosphere)
case .cover:
return nil
}
guard selectedKind != .cover else { return nil }
return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .all)
}
}
extension TravelAlbumAIRetouchTemplate {
/// 使用接口返回的模板示例前后图构建对比页内容。
var comparisonContent: BeforeAfterComparisonContent? {
let content = BeforeAfterComparisonContent(beforeURL: beforeURL, afterURL: afterURL)
return content.isValid ? content : nil
}
}
@@ -19,6 +19,13 @@ struct TravelAlbumOTGPhotoItem: Hashable, Sendable {
var errorMessage: String?
let localPath: String
var remoteUrl: String
var serverMaterialId: Int = 0
var autoRetouchState: TravelAlbumAutoRetouchState = .none
var autoRetouchTemplateId: Int? = nil
var autoRetouchClientRequestId: String = ""
var autoRetouchBatchId: Int = 0
var autoRetouchAttempt: Int = 0
var autoRetouchErrorMessage: String? = nil
/// 是否未上传完成。
var isNotUploaded: Bool {
@@ -31,6 +38,16 @@ struct TravelAlbumOTGPhotoItem: Hashable, Sendable {
}
}
/// OTG 照片自动修图阶段,与原图上传状态独立保存。
enum TravelAlbumAutoRetouchState: String, Codable, Sendable, Equatable, Hashable {
case none = "NONE"
case pendingSubmission = "PENDING_SUBMISSION"
case submitting = "SUBMITTING"
case processing = "PROCESSING"
case completed = "COMPLETED"
case failed = "FAILED"
}
/// OTG 传输页半小时维度时间槽。
struct TravelAlbumOTGTimeSlot: Hashable, Sendable {
let id: String
@@ -85,6 +102,14 @@ enum TravelAlbumOTGTransferMode: String, CaseIterable, Sendable {
self == .liveUpload
}
/// 模式选择 Sheet 的辅助说明。
var detailText: String {
switch self {
case .liveUpload: return "相机拍摄后,照片自动传输并上传到当前相册"
case .postTransfer: return "拍摄完成后,再选择照片批量传输"
}
}
/// 根据展示标题解析传输模式。
static func option(title: String) -> TravelAlbumOTGTransferMode? {
allCases.first { $0.title == title }
@@ -343,7 +368,14 @@ extension TravelAlbumOTGPhotoRecord {
progress: progress,
errorMessage: errorMessage,
localPath: localPath,
remoteUrl: remoteUrl
remoteUrl: remoteUrl,
serverMaterialId: serverMaterialId,
autoRetouchState: autoRetouchState,
autoRetouchTemplateId: autoRetouchTemplateId,
autoRetouchClientRequestId: autoRetouchClientRequestId,
autoRetouchBatchId: autoRetouchBatchId,
autoRetouchAttempt: autoRetouchAttempt,
autoRetouchErrorMessage: autoRetouchErrorMessage
)
}
}
@@ -56,6 +56,13 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
let albumId: Int
let userId: String
var remoteUrl: String
var serverMaterialId: Int
var autoRetouchState: TravelAlbumAutoRetouchState
var autoRetouchTemplateId: Int?
var autoRetouchClientRequestId: String
var autoRetouchBatchId: Int
var autoRetouchAttempt: Int
var autoRetouchErrorMessage: String?
var updatedAt: Int64
/// 创建 OTG 本地照片记录。
@@ -74,6 +81,13 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
albumId: Int,
userId: String,
remoteUrl: String = "",
serverMaterialId: Int = 0,
autoRetouchState: TravelAlbumAutoRetouchState = .none,
autoRetouchTemplateId: Int? = nil,
autoRetouchClientRequestId: String = "",
autoRetouchBatchId: Int = 0,
autoRetouchAttempt: Int = 0,
autoRetouchErrorMessage: String? = nil,
updatedAt: Int64 = Int64(Date().timeIntervalSince1970 * 1000)
) {
self.id = id
@@ -90,12 +104,22 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
self.albumId = albumId
self.userId = userId
self.remoteUrl = remoteUrl
self.serverMaterialId = max(0, serverMaterialId)
self.autoRetouchState = autoRetouchState
self.autoRetouchTemplateId = autoRetouchTemplateId
self.autoRetouchClientRequestId = autoRetouchClientRequestId
self.autoRetouchBatchId = max(0, autoRetouchBatchId)
self.autoRetouchAttempt = max(0, autoRetouchAttempt)
self.autoRetouchErrorMessage = autoRetouchErrorMessage
self.updatedAt = updatedAt
}
private enum CodingKeys: String, CodingKey {
case id, sourceId, clientPhotoId, fileName, localPath, thumbnailPath, capturedAt
case fileSizeBytes, status, progress, errorMessage, albumId, userId, remoteUrl, updatedAt
case serverMaterialId, autoRetouchState, autoRetouchTemplateId
case autoRetouchClientRequestId, autoRetouchBatchId, autoRetouchAttempt
case autoRetouchErrorMessage
}
/// 解码本地索引;旧版本缺少 `clientPhotoId` 时先保留为空,由 Store 一次性迁移并回写。
@@ -115,16 +139,34 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
albumId = try container.decode(Int.self, forKey: .albumId)
userId = try container.decode(String.self, forKey: .userId)
remoteUrl = try container.decodeIfPresent(String.self, forKey: .remoteUrl) ?? ""
serverMaterialId = try container.decodeIfPresent(Int.self, forKey: .serverMaterialId) ?? 0
autoRetouchState = try container.decodeIfPresent(
TravelAlbumAutoRetouchState.self,
forKey: .autoRetouchState
) ?? .none
autoRetouchTemplateId = try container.decodeIfPresent(Int.self, forKey: .autoRetouchTemplateId)
autoRetouchClientRequestId = try container.decodeIfPresent(
String.self,
forKey: .autoRetouchClientRequestId
) ?? ""
autoRetouchBatchId = try container.decodeIfPresent(Int.self, forKey: .autoRetouchBatchId) ?? 0
autoRetouchAttempt = try container.decodeIfPresent(Int.self, forKey: .autoRetouchAttempt) ?? 0
autoRetouchErrorMessage = try container.decodeIfPresent(String.self, forKey: .autoRetouchErrorMessage)
updatedAt = try container.decodeIfPresent(Int64.self, forKey: .updatedAt) ?? 0
}
/// 把中断中的传输恢复为待上传,避免重进页面卡在上传中。
func normalizedAfterInterruptedTransfer() -> TravelAlbumOTGPhotoRecord {
guard status == .transferring || status == .uploading else { return self }
var copy = self
copy.status = .pending
copy.progress = 0
copy.errorMessage = nil
if status == .transferring || status == .uploading {
copy.status = .pending
copy.progress = 0
copy.errorMessage = nil
}
if autoRetouchState == .submitting {
copy.autoRetouchState = .pendingSubmission
}
guard copy != self else { return self }
copy.updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
return copy
}
@@ -0,0 +1,79 @@
//
// BeforeAfterComparisonViewModel.swift
// suixinkan
//
import Foundation
/// 前后对比分隔线的共享运动参数与计算规则。
enum BeforeAfterComparisonMotion {
static let automaticCenter: CGFloat = 0.5
static let automaticAmplitude: CGFloat = 0.3
static let automaticSpeed: Double = 0.8
static let manualRange: ClosedRange<CGFloat> = 0.05 ... 0.95
/// 返回指定时间点的自动往返位置,运动范围固定为 20% 至 80%。
static func automaticFraction(elapsed: TimeInterval) -> CGFloat {
automaticCenter + CGFloat(sin(elapsed * automaticSpeed)) * automaticAmplitude
}
/// 将手动拖动位置限制在两侧 5% 的安全边界内。
static func clampedManualFraction(_ fraction: CGFloat) -> CGFloat {
min(manualRange.upperBound, max(manualRange.lowerBound, fraction))
}
/// 计算从当前位置恢复正向自动运动所需的正弦相位时间。
static func phaseTime(for fraction: CGFloat) -> TimeInterval {
let normalized = Double(
min(1, max(-1, (fraction - automaticCenter) / automaticAmplitude))
)
return asin(normalized) / automaticSpeed
}
}
/// 前后对比图片在可用区域内完整展示时的等比布局计算。
enum BeforeAfterComparisonLayout {
static let fallbackAspectRatio: CGFloat = 3.0 / 4.0
/// 从图片尺寸提取有效宽高比,异常尺寸回退为 3:4。
static func aspectRatio(for imageSize: CGSize) -> CGFloat {
guard imageSize.width > 0, imageSize.height > 0 else { return fallbackAspectRatio }
let ratio = imageSize.width / imageSize.height
return ratio.isFinite && ratio > 0 ? ratio : fallbackAspectRatio
}
/// 在最大宽高内返回不裁剪、不拉伸的最大显示尺寸。
static func fittedSize(aspectRatio: CGFloat, maximumSize: CGSize) -> CGSize {
guard maximumSize.width > 0, maximumSize.height > 0 else { return .zero }
let ratio = aspectRatio.isFinite && aspectRatio > 0 ? aspectRatio : fallbackAspectRatio
if maximumSize.width / maximumSize.height > ratio {
return CGSize(width: maximumSize.height * ratio, height: maximumSize.height)
}
return CGSize(width: maximumSize.width, height: maximumSize.width / ratio)
}
}
/// 前后图片对比页的只读状态,负责校验并解析两张远程图片地址。
final class BeforeAfterComparisonViewModel {
let content: BeforeAfterComparisonContent
/// 创建指定内容的前后对比页状态。
init(content: BeforeAfterComparisonContent) {
self.content = content
}
/// 原图远程地址。
var beforeImageURL: URL? {
URL(string: content.beforeURL)
}
/// 效果图远程地址。
var afterImageURL: URL? {
URL(string: content.afterURL)
}
/// 两张图片地址都可供页面加载时返回 true。
var isValid: Bool {
content.isValid && beforeImageURL != nil && afterImageURL != nil
}
}
@@ -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?() }
}
@@ -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]) {
@@ -52,6 +52,28 @@ final class TravelAlbumAIRetouchTemplateViewModel {
workflow.visibleCategories
}
/// 当前工作流选择的原始照片数量。
var selectedPhotoCount: Int {
switch workflow {
case .initial(_, let materialIds): materialIds.count
case .reretouch: 1
}
}
/// 是否展示首次修图的氛围感与封面生成说明。
var shouldShowInitialTips: Bool {
if case .initial = workflow { return true }
return false
}
/// 首次修图页按照当前选图数量展示氛围感与封面生成规则。
var initialTipsText: String {
if visibleCategories.contains(.cover) {
return "Tips:氛围感修图为选填;封面风格为必选,将使用第一张照片另生成封面。"
}
return "Tips:氛围感修图为选填,选中后每张照片会额外生成1个独立结果。"
}
/// 当前分组是否为选填。
func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool {
workflow.isOptional(category)
@@ -68,7 +90,8 @@ final class TravelAlbumAIRetouchTemplateViewModel {
case .refined, .atmosphere:
return 1
case .all:
return 1 + (selectedAtmosphereTemplateId == nil ? 0 : 1)
return (selectedRefinedTemplateId == nil ? 0 : 1)
+ (selectedAtmosphereTemplateId == nil ? 0 : 1)
}
}
}
@@ -87,6 +110,11 @@ final class TravelAlbumAIRetouchTemplateViewModel {
return unavailableMessage(for: category)
}
}
if case .reretouch(_, _, .all) = workflow,
selectedRefinedTemplateId == nil,
selectedAtmosphereTemplateId == nil {
return "请至少选择一个修图模板"
}
guard let remainingQuota else {
return "剩余修图次数获取失败,请刷新后重试"
}
@@ -124,7 +152,9 @@ final class TravelAlbumAIRetouchTemplateViewModel {
atmosphereTemplates = response.atmosphereTemplates
coverTemplates = response.coverTemplates
remainingQuota = response.remainingQuota
selectedRefinedTemplateId = visibleCategories.contains(.refined) ? refinedTemplates.first?.id : nil
selectedRefinedTemplateId = visibleCategories.contains(.refined) && !isOptional(.refined)
? refinedTemplates.first?.id
: nil
selectedAtmosphereTemplateId = visibleCategories.contains(.atmosphere) && !isOptional(.atmosphere)
? atmosphereTemplates.first?.id
: nil
@@ -166,14 +196,14 @@ final class TravelAlbumAIRetouchTemplateViewModel {
}
}
/// 选择模板;仅选填分组允许再次点击取消,必选分组保持单选。
/// 选择模板;选填分组允许再次点击取消,必选分组保持单选。
func toggleTemplate(id: Int, category: TravelAlbumAIRetouchTemplateCategory) {
guard visibleCategories.contains(category),
templates(for: category).contains(where: { $0.id == id })
else { return }
switch category {
case .refined:
selectedRefinedTemplateId = id
selectedRefinedTemplateId = isOptional(category) && selectedRefinedTemplateId == id ? nil : id
case .atmosphere:
selectedAtmosphereTemplateId = isOptional(category) && selectedAtmosphereTemplateId == id ? nil : id
case .cover:
@@ -198,13 +228,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,
@@ -214,7 +245,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,
@@ -224,7 +255,7 @@ final class TravelAlbumAIRetouchTemplateViewModel {
)
)
}
onSubmitted?()
onSubmitted?(submission)
} catch is CancellationError {
return
} catch {
@@ -0,0 +1,97 @@
import Foundation
/// 自动修图设置状态,负责加载真实精修模板、单选和配置校验,不直接创建修图任务。
final class TravelAlbumAutoRetouchSettingViewModel {
let scenicId: Int
/// 是否在模板上方展示修图方式;创建页已选择 AI 时只展示模板。
let allowsModeSelection: Bool
private(set) var templates: [TravelAlbumAIRetouchTemplate] = []
private(set) var selectedTemplateId: Int?
private(set) var isEnabled: Bool
private(set) var isLoading = false
private(set) var errorMessage: String?
var onStateChange: (() -> Void)?
/// 创建自动修图配置状态。
init(
scenicId: Int,
configuration: TravelAlbumAutoRetouchConfiguration,
allowsModeSelection: Bool
) {
self.scenicId = scenicId
self.allowsModeSelection = allowsModeSelection
self.isEnabled = configuration.enabled || !allowsModeSelection
self.selectedTemplateId = configuration.refinedTemplateId
}
/// 关闭自动修图不依赖模板加载;开启时必须选择有效模板。
var canConfirm: Bool {
!isEnabled || (!isLoading && pendingConfiguration != nil)
}
/// 当前草稿模板名称,用于固定底部的选择摘要。
var selectedTemplateName: String? {
templates.first { $0.id == selectedTemplateId }?.name
}
/// 当前可提交配置;启用状态必须已经选择服务端模板。
var pendingConfiguration: TravelAlbumAutoRetouchConfiguration? {
if !isEnabled { return .disabled }
guard let selectedTemplateId,
templates.contains(where: { $0.id == selectedTemplateId }) else {
return nil
}
return TravelAlbumAutoRetouchConfiguration(
enabled: true,
refinedTemplateId: selectedTemplateId
)
}
/// 拉取当前景区的真实精修模板。
func loadTemplates(api: any TravelAlbumServing) async {
guard scenicId > 0 else {
errorMessage = "请先选择景区"
notify()
return
}
guard !isLoading else { return }
isLoading = true
errorMessage = nil
notify()
defer {
isLoading = false
notify()
}
do {
let response = try await api.aiRetouchTemplates(scenicId: scenicId)
templates = response.refinedTemplates
if !templates.contains(where: { $0.id == selectedTemplateId }) {
selectedTemplateId = nil
}
if templates.isEmpty { errorMessage = "暂无可用的原图精修模板" }
} catch is CancellationError {
return
} catch {
templates = []
errorMessage = error.localizedDescription.isEmpty ? "模板加载失败" : error.localizedDescription
}
}
/// 切换修图方式,保留本次草稿模板以便再次开启;关闭配置提交时仍不携带模板。
func selectMode(enabled: Bool) {
guard allowsModeSelection else { return }
isEnabled = enabled
notify()
}
/// 选择一个真实精修模板。
func selectTemplate(id: Int) {
guard templates.contains(where: { $0.id == id }) else { return }
isEnabled = true
selectedTemplateId = id
notify()
}
private func notify() { onStateChange?() }
}
@@ -48,6 +48,7 @@ final class TravelAlbumDetailViewModel {
private(set) var isRefreshing = false
private(set) var isSelectionMode = false
private(set) var selectedMaterialIds: Set<Int> = []
private var selectedMaterialPurchaseStates: [Int: Bool] = [:]
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
@@ -142,12 +143,29 @@ final class TravelAlbumDetailViewModel {
selectedTab == .all ? allPhotoCount : purchasedPhotoCount
}
/// 当前选中素材中的已购买数量。
var selectedPurchasedMaterialCount: Int {
selectedMaterialIds.count - selectedDeletableMaterialIds.count
}
/// 删除确认弹窗文案;选中已购素材时明确说明保护规则与实际删除数量。
var deleteSelectedConfirmationMessage: String {
let selectedCount = selectedMaterialIds.count
let purchasedCount = selectedPurchasedMaterialCount
guard purchasedCount > 0 else {
return "确定删除选中的 \(selectedCount) 张素材吗?"
}
let deletableCount = selectedCount - purchasedCount
return "已选择 \(selectedCount) 张素材,其中 \(purchasedCount) 张已购买。"
+ "不会删除已购买的项目,只会删除 \(deletableCount) 张未购买的项目。"
}
/// 切换素材 tab。
func selectTab(_ tab: Tab, api: any TravelAlbumServing) async {
guard selectedTab != tab else { return }
selectedTab = tab
isSelectionMode = false
selectedMaterialIds = []
clearMaterialSelection()
notifyStateChange()
await loadMaterials(reset: true, api: api)
}
@@ -223,10 +241,9 @@ final class TravelAlbumDetailViewModel {
/// 切换选择模式。
func toggleSelectionMode() {
guard selectedTab == .all else { return }
isSelectionMode.toggle()
if !isSelectionMode {
selectedMaterialIds = []
clearMaterialSelection()
}
notifyStateChange()
}
@@ -234,14 +251,12 @@ final class TravelAlbumDetailViewModel {
/// 切换素材选中状态。
func toggleMaterialSelection(_ material: TravelAlbumMaterial) {
guard isSelectionMode else { return }
guard material.status == 1 else {
onShowMessage?("仅未购买素材可删除")
return
}
if selectedMaterialIds.contains(material.id) {
selectedMaterialIds.remove(material.id)
selectedMaterialPurchaseStates.removeValue(forKey: material.id)
} else {
selectedMaterialIds.insert(material.id)
selectedMaterialPurchaseStates[material.id] = material.isPurchased
}
notifyStateChange()
}
@@ -249,15 +264,22 @@ final class TravelAlbumDetailViewModel {
/// AI 修图任务提交成功后退出选择模式并清空当前选择。
func completeAIRetouchSubmission() {
isSelectionMode = false
selectedMaterialIds = []
clearMaterialSelection()
notifyStateChange()
}
/// AI 修图提交成功后重置选择状态,并刷新相册摘要、数量与当前素材列表。
func refreshAfterAIRetouchSubmission(api: any TravelAlbumServing) async {
completeAIRetouchSubmission()
await refreshAll(api: api)
}
/// 预览页删除成功后移除对应素材,并同步全部/已购计数。
func removeMaterialAfterPreviewDeletion(id: Int) {
guard let index = materials.firstIndex(where: { $0.id == id }) else { return }
let material = materials.remove(at: index)
selectedMaterialIds.remove(id)
selectedMaterialPurchaseStates.removeValue(forKey: id)
allPhotoCount = max(0, allPhotoCount - 1)
if material.isPurchased {
purchasedPhotoCount = max(0, purchasedPhotoCount - 1)
@@ -267,16 +289,20 @@ final class TravelAlbumDetailViewModel {
/// 删除已选素材。
func deleteSelectedMaterials(api: any TravelAlbumServing) async {
let ids = selectedMaterialIds.sorted()
guard !ids.isEmpty else {
guard !selectedMaterialIds.isEmpty else {
onShowMessage?("请选择要删除的素材")
return
}
let ids = selectedDeletableMaterialIds
guard !ids.isEmpty else {
onShowMessage?("选中的素材均已购买,未删除任何项目")
return
}
do {
try await api.batchDeleteMaterials(ids: ids)
onShowMessage?("删除成功")
isSelectionMode = false
selectedMaterialIds = []
clearMaterialSelection()
await refreshAll(api: api)
} catch is CancellationError {
return
@@ -301,4 +327,18 @@ final class TravelAlbumDetailViewModel {
private func notifyStateChange() {
onStateChange?()
}
private func clearMaterialSelection() {
selectedMaterialIds = []
selectedMaterialPurchaseStates = [:]
}
private var selectedDeletableMaterialIds: [Int] {
selectedMaterialIds
.filter { id in
guard selectedMaterialPurchaseStates[id] == false else { return false }
return materials.first(where: { $0.id == id })?.isPurchased != true
}
.sorted()
}
}
@@ -52,7 +52,10 @@ final class TravelAlbumEntryViewModel {
}
/// 重新拉取相册列表。
func loadAlbums(api: any TravelAlbumServing) async {
func loadAlbums(
api: any TravelAlbumServing,
preservingContentOnFailure: Bool = false
) async {
isLoading = true
notifyStateChange()
defer {
@@ -67,12 +70,20 @@ final class TravelAlbumEntryViewModel {
} catch is CancellationError {
return
} catch {
albums = []
albumTotal = 0
if !preservingContentOnFailure {
albums = []
albumTotal = 0
}
onShowMessage?(error.localizedDescription)
}
}
/// 用户下拉刷新相册列表;失败时保留当前内容,避免页面瞬间清空。
func refreshAlbums(api: any TravelAlbumServing) async {
guard !isLoading else { return }
await loadAlbums(api: api, preservingContentOnFailure: true)
}
/// 打开创建相册弹窗。
func openCreateSheet(api: any TravelAlbumServing) async {
guard !isCreating else { return }
@@ -110,6 +121,7 @@ final class TravelAlbumEntryViewModel {
freeCount: String,
singlePrice: String,
packagePrice: String,
autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled,
order: TravelAlbumAvailableOrder?,
api: any TravelAlbumServing
) async {
@@ -132,6 +144,10 @@ final class TravelAlbumEntryViewModel {
onShowMessage?("请输入有效的单张照片价格")
return
}
if autoRetouchConfiguration.enabled && !autoRetouchConfiguration.isValid {
onShowMessage?("请选择有效的 AI 修图模板")
return
}
let albumName: String
switch mode {
@@ -151,7 +167,8 @@ final class TravelAlbumEntryViewModel {
materialNum: Int(freeCount) ?? 0,
materialPrice: materialPrice,
materialPackagePrice: Double(packagePrice) ?? 0,
photoPrice: 0
photoPrice: 0,
autoRetouchConfiguration: autoRetouchConfiguration
)
case .preOrder:
request = TravelAlbumCreateRequest(
@@ -161,7 +178,8 @@ final class TravelAlbumEntryViewModel {
materialNum: nil,
materialPrice: nil,
materialPackagePrice: nil,
photoPrice: nil
photoPrice: nil,
autoRetouchConfiguration: autoRetouchConfiguration
)
}
@@ -20,6 +20,22 @@ struct TravelAlbumPhoneAlbumImportItem: Sendable, Equatable {
}
}
/// OTG 自动修图预览的数据校验错误,避免进入缺少素材或结果的空白预览页。
enum TravelAlbumOTGPreviewError: LocalizedError, Equatable {
case materialUnavailable
case resultNotReady
/// 预览入口可直接展示给用户的错误说明。
var errorDescription: String? {
switch self {
case .materialUnavailable:
return "素材信息暂不可用,请返回相册刷新后重试"
case .resultNotReady:
return "修图结果暂未同步,请稍后重试"
}
}
}
/// 有线相机传输页 ViewModel,编排 ImageCaptureCore 连接、照片导入、本地缓存与上传登记。
@MainActor
final class WiredCameraTransferViewModel {
@@ -63,7 +79,12 @@ final class WiredCameraTransferViewModel {
private(set) var sonyMTPHint: String?
private(set) var isContentCatalogReady = false
let retouchOption = "不修图"
private(set) var autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration {
didSet { notifyStateChanged() }
}
private(set) var isUpdatingAutoRetouchConfiguration = false {
didSet { notifyStateChanged() }
}
private(set) var photoFormatOption: TravelAlbumOTGPhotoFormatOption = .jpg
private(set) var transferMode: TravelAlbumOTGTransferMode = .liveUpload {
didSet { notifyStateChanged() }
@@ -87,6 +108,8 @@ final class WiredCameraTransferViewModel {
private var queuedAutoUploadPhotoIds: Set<String> = []
private var suppressSelectedTimeSlotNotification = false
private var serverStatusSyncTask: Task<Void, Never>?
private var configurationSyncTask: Task<Void, Never>?
private var autoRetouchPollingTask: Task<Void, Never>?
@MainActor
init(
@@ -94,6 +117,8 @@ final class WiredCameraTransferViewModel {
albumTitle: String,
headerPhone: String,
scenicSpotLabel: String? = nil,
initialTransferMode: TravelAlbumOTGTransferMode? = nil,
initialAutoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled,
connectionManager: (any WiredCameraConnectionManaging)? = nil,
storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(),
uploader: (any TravelAlbumOTGUploading)? = nil,
@@ -112,7 +137,11 @@ final class WiredCameraTransferViewModel {
self.api = api ?? NetworkServices.shared.travelAlbumAPI
self.appStore = appStore
self.userDefaults = userDefaults
self.transferMode = Self.persistedTransferMode(in: userDefaults)
self.autoRetouchConfiguration = initialAutoRetouchConfiguration
self.transferMode = initialTransferMode ?? Self.persistedTransferMode(in: userDefaults)
if let initialTransferMode {
userDefaults.set(initialTransferMode.rawValue, forKey: Self.transferModeDefaultsKey)
}
}
/// 相机状态文案。
@@ -213,6 +242,40 @@ final class WiredCameraTransferViewModel {
transferMode.title
}
/// 自动修图设置 Chip 的展示文案。
var retouchOption: String { autoRetouchConfiguration.displayTitle }
/// 自动修图设置页复用当前注入的相册服务。
var autoRetouchAPI: any TravelAlbumServing { api }
/// 获取已完成自动修图照片的最新原图与精修图,仅供只读预览,不改变上传或修图状态。
func loadAutoRetouchPreviewProject(photoId: String) async throws -> TravelAlbumPreviewProject {
try Task.checkCancellation()
guard let record = persistedRecordsById[photoId],
record.autoRetouchState == .completed,
record.serverMaterialId > 0 else {
throw TravelAlbumOTGPreviewError.materialUnavailable
}
let material = try await api.materialInfo(
userEquityTravelId: albumId,
materialId: record.serverMaterialId
)
try Task.checkCancellation()
let project = TravelAlbumPreviewProject(material: material)
guard material.id == record.serverMaterialId,
let original = project.asset(for: .original), !original.displayURL.isEmpty else {
throw TravelAlbumOTGPreviewError.materialUnavailable
}
guard let retouched = project.asset(for: .retouched), !retouched.displayURL.isEmpty else {
throw TravelAlbumOTGPreviewError.resultNotReady
}
return TravelAlbumPreviewProject(
originalMaterialId: project.originalMaterialId,
aiRetouchBatchId: project.aiRetouchBatchId,
assets: [original, retouched]
)
}
/// 指定上传弹窗选项。
var specifyUploadOptions: [TravelAlbumOTGSpecifyUploadOption] {
TravelAlbumOTGSpecifyUploadOption.allCases
@@ -230,6 +293,9 @@ final class WiredCameraTransferViewModel {
syncFromConnectionManager()
loadPersistedPhotos()
syncServerUploadStatuses()
syncAutoRetouchConfiguration()
resumePendingAutoRetouches()
updateAutoRetouchPolling()
connectionManager.start()
}
@@ -237,10 +303,29 @@ final class WiredCameraTransferViewModel {
func stop() {
serverStatusSyncTask?.cancel()
serverStatusSyncTask = nil
configurationSyncTask?.cancel()
configurationSyncTask = nil
autoRetouchPollingTask?.cancel()
autoRetouchPollingTask = nil
connectionManager.unbindDelegate()
connectionManager.suspendLiveTransfer()
}
/// App 进入后台时停止配置请求和任务轮询,保留待恢复状态。
func applicationDidEnterBackground() {
configurationSyncTask?.cancel()
configurationSyncTask = nil
autoRetouchPollingTask?.cancel()
autoRetouchPollingTask = nil
}
/// App 回到前台时同步跨设备配置并恢复自动修图任务。
func applicationDidBecomeActive() {
syncAutoRetouchConfiguration()
resumePendingAutoRetouches()
updateAutoRetouchPolling()
}
/// 主动断开相机连接。
func disconnect() {
connectionManager.disconnect()
@@ -301,6 +386,50 @@ final class WiredCameraTransferViewModel {
userDefaults.set(mode.rawValue, forKey: Self.transferModeDefaultsKey)
}
/// 保存相册级自动修图配置;服务端成功后才更新页面状态。
func updateAutoRetouchConfiguration(_ configuration: TravelAlbumAutoRetouchConfiguration) async {
guard !isUpdatingAutoRetouchConfiguration else { return }
guard configuration.isValid else {
showMessage("请选择有效的 AI 修图模板")
return
}
isUpdatingAutoRetouchConfiguration = true
defer { isUpdatingAutoRetouchConfiguration = false }
do {
autoRetouchConfiguration = try await api.updateAutoRetouchConfiguration(
TravelAlbumAutoRetouchConfigurationRequest(
userEquityTravelId: albumId,
enabled: configuration.enabled,
refinedTemplateId: configuration.refinedTemplateId
)
)
showMessage(autoRetouchConfiguration.enabled ? "已开启 AI 自动修图" : "已关闭 AI 自动修图")
} catch {
showMessage(error.localizedDescription.isEmpty ? "自动修图设置保存失败" : error.localizedDescription)
}
}
/// 重试单张已上传照片的自动修图,不重复上传原图。
func retryAutoRetouch(photoId: String) {
guard var record = persistedRecordsById[photoId],
record.status == .uploaded,
record.serverMaterialId > 0,
record.autoRetouchState == .failed,
record.autoRetouchTemplateId != nil else {
showMessage("当前照片无法重新修图")
return
}
if record.autoRetouchBatchId > 0 {
record.autoRetouchAttempt += 1
record.autoRetouchBatchId = 0
record.autoRetouchClientRequestId = makeAutoRetouchClientRequestId(record: record)
}
record.autoRetouchState = .pendingSubmission
record.autoRetouchErrorMessage = nil
persist(record)
Task { await submitAutoRetouch(photoId: photoId) }
}
/// 切换上传格式。
func selectPhotoFormat(_ option: TravelAlbumOTGPhotoFormatOption) {
guard photoFormatOption != option else { return }
@@ -523,6 +652,166 @@ final class WiredCameraTransferViewModel {
}
}
private func syncAutoRetouchConfiguration() {
configurationSyncTask?.cancel()
configurationSyncTask = Task { [weak self] in
await self?.refreshAutoRetouchConfiguration()
}
}
private func refreshAutoRetouchConfiguration() async {
do {
let album = try await api.info(id: albumId)
try Task.checkCancellation()
autoRetouchConfiguration = album.autoRetouchConfiguration
} catch is CancellationError {
return
} catch {
OTGLog.error(.connection, "sync auto retouch configuration failed: \(error.localizedDescription)")
}
}
private func resumePendingAutoRetouches() {
let photoIds = persistedRecordsById.values.compactMap { record in
record.status == .uploaded && record.autoRetouchState == .pendingSubmission
? record.id
: nil
}
guard !photoIds.isEmpty else { return }
Task { [weak self] in
guard let self else { return }
for photoId in photoIds {
await submitAutoRetouch(photoId: photoId)
}
}
}
private func submitAutoRetouch(photoId: String) async {
guard var record = persistedRecordsById[photoId],
record.status == .uploaded,
record.serverMaterialId > 0,
let templateId = record.autoRetouchTemplateId,
record.autoRetouchState == .pendingSubmission || record.autoRetouchState == .failed else {
return
}
if record.autoRetouchClientRequestId.isEmpty {
record.autoRetouchClientRequestId = makeAutoRetouchClientRequestId(record: record)
}
record.autoRetouchState = .submitting
record.autoRetouchErrorMessage = nil
persist(record)
do {
let submission = try await api.submitAIRetouch(
TravelAlbumAIRetouchRequest(
userEquityTravelId: albumId,
materialIds: [record.serverMaterialId],
refinedTemplateId: templateId,
atmosphereTemplateId: nil,
coverTemplateId: nil,
clientRequestId: record.autoRetouchClientRequestId
)
)
guard var latest = persistedRecordsById[photoId] else { return }
latest.autoRetouchBatchId = submission.aiRetouchBatchId
latest.autoRetouchState = autoRetouchState(for: submission.status)
latest.autoRetouchErrorMessage = latest.autoRetouchState == .failed ? submission.status.title : nil
persist(latest)
updateAutoRetouchPolling()
} catch is CancellationError {
guard var latest = persistedRecordsById[photoId], latest.autoRetouchState == .submitting else { return }
latest.autoRetouchState = .pendingSubmission
persist(latest)
} catch {
guard var latest = persistedRecordsById[photoId] else { return }
if isAmbiguousAutoRetouchSubmissionFailure(error) {
latest.autoRetouchState = .pendingSubmission
latest.autoRetouchErrorMessage = "网络中断,恢复后将自动继续修图"
persist(latest)
showMessage(latest.autoRetouchErrorMessage ?? "网络恢复后将自动继续修图")
return
}
latest.autoRetouchState = .failed
latest.autoRetouchErrorMessage = error.localizedDescription.isEmpty
? "AI 修图任务提交失败"
: error.localizedDescription
persist(latest)
showMessage(latest.autoRetouchErrorMessage ?? "AI 修图任务提交失败")
}
}
private func updateAutoRetouchPolling() {
guard autoRetouchPollingTask == nil,
persistedRecordsById.values.contains(where: {
$0.autoRetouchState == .processing && $0.autoRetouchBatchId > 0
}) else { return }
autoRetouchPollingTask = Task { [weak self] in
guard let self else { return }
while !Task.isCancelled {
await refreshAutoRetouchJobs()
guard persistedRecordsById.values.contains(where: {
$0.autoRetouchState == .processing && $0.autoRetouchBatchId > 0
}) else { break }
try? await Task.sleep(for: .seconds(8))
}
autoRetouchPollingTask = nil
}
}
private func refreshAutoRetouchJobs() async {
let batchIds = Set(persistedRecordsById.values.compactMap { record in
record.autoRetouchState == .processing && record.autoRetouchBatchId > 0
? record.autoRetouchBatchId
: nil
})
for batchId in batchIds {
do {
let detail = try await api.aiRetouchJobInfo(batchId: batchId)
try Task.checkCancellation()
let state = autoRetouchState(for: detail.status)
guard state != .processing else { continue }
let matchingRecords = persistedRecordsById.values.filter { $0.autoRetouchBatchId == batchId }
for var record in matchingRecords {
record.autoRetouchState = state
record.autoRetouchErrorMessage = state == .failed ? detail.status.title : nil
persist(record)
}
} catch is CancellationError {
return
} catch {
OTGLog.error(.connection, "poll auto retouch job failed: \(batchId) \(error.localizedDescription)")
}
}
}
private func autoRetouchState(for status: TravelAlbumAIJobStatus) -> TravelAlbumAutoRetouchState {
switch status {
case .queued, .processing, .unknown:
return .processing
case .succeeded, .partiallySucceeded:
return .completed
case .failed, .canceled:
return .failed
}
}
private func makeAutoRetouchClientRequestId(record: TravelAlbumOTGPhotoRecord) -> String {
let templateId = record.autoRetouchTemplateId ?? 0
return "auto-\(albumId)-\(record.serverMaterialId)-tpl\(templateId)-a\(record.autoRetouchAttempt)"
}
private func isAmbiguousAutoRetouchSubmissionFailure(_ error: Error) -> Bool {
guard let apiError = error as? APIError else { return false }
switch apiError {
case .networkFailed, .invalidResponse, .emptyData, .decodeFailed:
return true
case .httpStatus(let status, _):
return status == 408 || status >= 500
case .invalidURL, .serverCode:
return false
}
}
private func applyMergedPhotos() {
let persistedItems = persistedRecordsById.values
.map { $0.toPhotoItem(storage: storage, albumId: albumId) }
@@ -588,6 +877,7 @@ final class WiredCameraTransferViewModel {
}
private func uploadPhoto(id: String) async {
let retouchSnapshot = autoRetouchConfiguration
do {
let record = try await localRecordForUpload(id: id)
updateRecord(id: id, status: .uploading, progress: max(record.progress, 1), error: nil)
@@ -597,13 +887,44 @@ final class WiredCameraTransferViewModel {
) { [weak self] progress in
self?.updateRecord(id: id, status: .uploading, progress: progress, error: nil)
}
updateRecord(id: id, status: .uploaded, progress: 100, error: nil, remoteUrl: material.fileUrl)
completeOriginalUpload(id: id, material: material, retouchSnapshot: retouchSnapshot)
if retouchSnapshot.enabled {
await submitAutoRetouch(photoId: id)
}
} catch {
updateRecord(id: id, status: .failed, progress: 0, error: error.localizedDescription)
showMessage(error.localizedDescription)
}
}
private func completeOriginalUpload(
id: String,
material: TravelAlbumMaterial,
retouchSnapshot: TravelAlbumAutoRetouchConfiguration
) {
guard var record = persistedRecordsById[id] else {
updateRecord(id: id, status: .uploaded, progress: 100, error: nil, remoteUrl: material.fileUrl)
return
}
record.status = .uploaded
record.progress = 100
record.errorMessage = nil
record.remoteUrl = material.fileUrl
record.serverMaterialId = material.id
record.autoRetouchTemplateId = retouchSnapshot.enabled ? retouchSnapshot.refinedTemplateId : nil
record.autoRetouchBatchId = 0
record.autoRetouchAttempt = 0
record.autoRetouchErrorMessage = nil
if retouchSnapshot.enabled, retouchSnapshot.refinedTemplateId != nil {
record.autoRetouchState = .pendingSubmission
record.autoRetouchClientRequestId = makeAutoRetouchClientRequestId(record: record)
} else {
record.autoRetouchState = .none
record.autoRetouchClientRequestId = ""
}
persist(record)
}
private func localRecordForUpload(id: String) async throws -> TravelAlbumOTGPhotoRecord {
if let record = persistedRecordsById[id],
!record.localPath.isEmpty,
@@ -653,6 +974,14 @@ final class WiredCameraTransferViewModel {
applyMergedPhotos()
}
private func persist(_ record: TravelAlbumOTGPhotoRecord) {
var updated = record
updated.updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
persistedRecordsById[updated.id] = updated
storage.upsert(updated, albumId: albumId)
applyMergedPhotos()
}
private func notifyStateChanged() {
if Thread.isMainThread {
onStateChange?()
+110 -17
View File
@@ -9,6 +9,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private var sessionExpiredDialog: SessionExpiredDialogViewController?
private var deregistrationCoordinator: StoreAccountDeregistrationRootCoordinator?
private var needsForegroundDeregistrationCheck = false
func scene(
_ scene: UIScene,
@@ -20,26 +22,39 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
AppNavigationBarAppearance.applyGlobalAppearance()
let window = UIWindow(windowScene: windowScene)
window.rootViewController = AppRouter.makeRootViewController()
window.makeKeyAndVisible()
window.rootViewController = UIViewController()
self.window = window
(UIApplication.shared.delegate as? AppDelegate)?.window = window
let appSession = AppStore.shared.session
deregistrationCoordinator = StoreAccountDeregistrationRootCoordinator(
window: window, session: appSession,
makeAPI: { identity in
StoreAccountDeregistrationAPI(client: NetworkServices.shared.apiClient, identity: identity) {
identity.matches(session: appSession)
}
},
makeSubmissionStore: { StoreAccountDeregistrationSubmissionStore(storeUserID: $0) },
makeBusinessRoot: { MainTabBarController() },
setBindingSuspended: { PushNotificationManager.shared.setAccountBindingSuspended($0) },
onBusinessResumed: {
PushNotificationManager.shared.handleLoginCompleted()
PushNotificationManager.shared.routePendingNotificationIfPossible()
}
)
PushNotificationManager.shared.attach(window: window)
registerNotifications()
if AppStore.shared.session.isLoggedIn {
DispatchQueue.main.async {
PushNotificationManager.shared.handleLoginCompleted()
}
}
refreshRootForCurrentSession()
window.makeKeyAndVisible()
if let response = connectionOptions.notificationResponse {
PushNotificationManager.shared.handleNotificationResponse(response)
}
}
func sceneDidDisconnect(_ scene: UIScene) {
deregistrationCoordinator?.cancelPendingCheck()
deregistrationCoordinator = nil
NotificationCenter.default.removeObserver(self)
if (UIApplication.shared.delegate as? AppDelegate)?.window === window {
(UIApplication.shared.delegate as? AppDelegate)?.window = nil
@@ -56,10 +71,30 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
}
func sceneDidBecomeActive(_ scene: UIScene) {
guard accessController == nil, deregistrationCoordinator?.isChecking != true else { return }
PushNotificationManager.shared.retryPendingRegistrationUpload()
}
func sceneDidEnterBackground(_ scene: UIScene) {
needsForegroundDeregistrationCheck = true
}
func sceneWillEnterForeground(_ scene: UIScene) {
guard needsForegroundDeregistrationCheck else { return }
needsForegroundDeregistrationCheck = false
guard sessionExpiredDialog == nil else { return }
deregistrationCoordinator?.resumeFromBackground()
}
private func registerNotifications() {
NotificationCenter.default.addObserver(
self, selector: #selector(handleStoreDeregistrationRestriction(_:)),
name: NotificationName.storeAccountDeregistrationRestricted, object: nil
)
NotificationCenter.default.addObserver(
self, selector: #selector(handleStoreDeregistrationSubmitted(_:)),
name: NotificationName.storeAccountDeregistrationSubmitted, object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleSessionDidExpire),
@@ -90,6 +125,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
guard AppStore.shared.session.isLoggedIn else { return }
guard sessionExpiredDialog == nil else { return }
deregistrationCoordinator?.cancelPendingCheck()
GlobalLoadingManager.shared.hideAll()
let dialog = SessionExpiredDialogViewController { [weak self] in
self?.transitionToLogin()
@@ -104,28 +140,85 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
}
private func transitionToLogin() {
sessionExpiredDialog = nil
PushNotificationManager.shared.handleLogout()
AppStore.shared.logout()
clearAuthenticatedSession()
AppRouter.setRoot(.login, on: window)
}
/// 统一清除当前会话及依赖登录态的后台能力,不执行页面跳转。
private func clearAuthenticatedSession() {
deregistrationCoordinator?.cancelPendingCheck()
sessionExpiredDialog?.dismiss(animated: false)
sessionExpiredDialog = nil
GlobalLoadingManager.shared.hideAll()
PushNotificationManager.shared.handleLogout()
PushNotificationManager.shared.setAccountBindingSuspended(false)
AppStore.shared.logout()
}
@objc private func handleUserDidLogout() {
transitionToLogin()
}
/// 申请明确受理后先退出,再展示只含“完成”的结果页;结果页不再依赖已清除的凭证。
@objc private func handleStoreDeregistrationSubmitted(_ notification: Notification) {
let identityName = notification.userInfo?[NotificationUserInfoKey.deregistrationIdentityName] as? String
?? "当前门店身份"
let coolingUntil = notification.userInfo?[NotificationUserInfoKey.deregistrationCoolingUntil] as? String
clearAuthenticatedSession()
guard !AppStore.shared.session.isLoggedIn else {
AppRouter.setRoot(.login, on: window)
return
}
let controller = StoreAccountDeregistrationSubmittedViewController(
identityName: identityName,
coolingUntil: coolingUntil
) { [weak self] in
AppRouter.setRoot(.login, on: self?.window)
}
AppRouter.setRoot(UINavigationController(rootViewController: controller), on: window)
}
@objc private func handleUserDidLogin() {
sessionExpiredDialog?.dismiss(animated: false)
sessionExpiredDialog = nil
AppRouter.setRoot(.mainTab, on: window)
DispatchQueue.main.async {
PushNotificationManager.shared.handleLoginCompleted()
PushNotificationManager.shared.routePendingNotificationIfPossible()
}
// v9 登录响应中的 store_users[].status 是登录是否需要注销确认的唯一依据。
// 旧 account-deregister/status 仅供用户主动进入注销设置流程时查询,不能再拦截正常登录。
refreshRootForCurrentSession()
}
@objc private func handleAccountDidSwitch() {
PushNotificationManager.shared.handleAccountSwitched()
if AppStore.shared.session.accountType == .storeUser {
refreshRootForCurrentSession()
} else {
deregistrationCoordinator?.cancelPendingCheck()
PushNotificationManager.shared.setAccountBindingSuspended(false)
PushNotificationManager.shared.handleAccountSwitched()
}
}
/// 仅检查真实根控制器,避免被旧的异步查询或页面导航状态误判。
private var accessController: StoreAccountDeregistrationAccessViewController? {
deregistrationCoordinator?.accessController
}
/// 冷启动和切换身份使用已有会话,不主动查询注销状态;新登录由独立入口核验。
private func refreshRootForCurrentSession() {
let session = AppStore.shared.session
guard session.isLoggedIn else {
deregistrationCoordinator?.cancelPendingCheck()
PushNotificationManager.shared.setAccountBindingSuspended(false)
AppRouter.setRoot(.login, on: window, animated: false)
return
}
deregistrationCoordinator?.restoreSession()
}
/// 150015 只限制原请求对应的当前门店身份;旧 Token 响应不会影响新身份。
@objc private func handleStoreDeregistrationRestriction(_ notification: Notification) {
let token = notification.userInfo?[NotificationUserInfoKey.deregistrationRequestToken] as? String
let session = AppStore.shared.session
guard StoreAccountDeregistrationAccessViewModel.restrictionApplies(requestToken: token, session: session) else { return }
deregistrationCoordinator?.recordRestriction(requestToken: token)
}
private func topViewController(from viewController: UIViewController?) -> UIViewController? {
@@ -163,7 +163,17 @@ final class AccountSelectionViewController: UIViewController, UITableViewDelegat
}
@objc private func confirmTapped() {
guard let selectedAccount else { return }
guard canConfirm, let selectedAccount else { return }
if selectedAccount.requiresDeregistrationConfirmation {
present(
makeDeregistrationLoginAlert(
account: selectedAccount,
onConfirm: { [weak self] in self?.onConfirm(selectedAccount) }
),
animated: true
)
return
}
onConfirm(selectedAccount)
}
@@ -177,6 +187,23 @@ final class AccountSelectionViewController: UIViewController, UITableViewDelegat
}
}
/// 创建冷静期账号登录确认弹窗,确认后 `set-user` 会由后端自动撤销原注销申请。
func makeDeregistrationLoginAlert(
account: AccountSwitchAccount,
onConfirm: @escaping () -> Void
) -> UIAlertController {
let accountName = account.title.trimmingCharacters(in: .whitespacesAndNewlines)
let displayName = accountName.isEmpty ? "该门店账号" : "“\(accountName)”"
let alert = UIAlertController(
title: "该账号正在注销",
message: "\(displayName)已提交注销申请,目前处于冷静期。确认登录后,将自动撤销之前的注销申请。",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "暂不登录", style: .cancel))
alert.addAction(UIAlertAction(title: "确认登录", style: .default) { _ in onConfirm() })
return alert
}
/// 账号选择列表 Cell。
private final class AccountSelectionCell: UITableViewCell {
static let reuseIdentifier = "AccountSelectionCell"
+40 -1
View File
@@ -11,7 +11,7 @@ import UIKit
final class LoginViewController: BaseViewController {
private let viewModel = LoginViewModel()
private let authAPI = NetworkServices.shared.authAPI
private let authAPI: AuthAPI
private let backgroundImageView = UIImageView()
private let welcomeLabel = UILabel()
@@ -29,6 +29,17 @@ final class LoginViewController: BaseViewController {
private weak var accountSelectionController: AccountSelectionViewController?
/// 默认使用共享登录服务,测试可注入Mock网络客户端而不修改真实会话。
init(authAPI: AuthAPI? = nil) {
self.authAPI = authAPI ?? NetworkServices.shared.authAPI
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var preferredStatusBarStyle: UIStatusBarStyle {
.lightContent
}
@@ -264,6 +275,7 @@ final class LoginViewController: BaseViewController {
}
private func performLogin() {
guard !viewModel.isLoading else { return }
viewModel.normalizeUsernameCountryCodeIfNeeded()
accountField.text = viewModel.normalizedUsername
@@ -278,6 +290,8 @@ final class LoginViewController: BaseViewController {
completeLogin(with: response, account: account)
case .needsAccountSelection:
break
case let .needsDeregistrationConfirmation(confirmation):
presentDeregistrationLoginConfirmation(confirmation)
}
} catch is CancellationError {
return
@@ -323,6 +337,31 @@ final class LoginViewController: BaseViewController {
}
}
private func presentDeregistrationLoginConfirmation(_ confirmation: DeregistrationLoginConfirmation) {
let alert = makeDeregistrationLoginAlert(
account: confirmation.account,
onConfirm: { [weak self] in
self?.confirmDeregistrationLogin(confirmation)
}
)
present(alert, animated: true)
}
private func confirmDeregistrationLogin(_ confirmation: DeregistrationLoginConfirmation) {
Task {
showLoading()
defer { hideLoading() }
do {
let response = try await viewModel.confirmDeregistrationLogin(confirmation, authAPI: authAPI)
completeLogin(with: response, account: confirmation.account)
} catch is CancellationError {
return
} catch {
showToast(error.localizedDescription)
}
}
}
private func completeLogin(with response: V9AuthResponse, account: AccountSwitchAccount) {
AuthSessionHelper.completeLogin(
with: response,
+28 -3
View File
@@ -130,6 +130,28 @@ final class LoginViewModel {
guard let payload = pendingAccountSelection, payload.hasTempToken else {
throw LoginFlowError.missingToken
}
let response = try await setUser(account, tempToken: payload.tempToken, authAPI: authAPI)
pendingAccountSelection = nil
notifyStateChange()
return response
}
/// 用户确认登录冷静期账号后继续换取正式 token;`set-user` 成功即由后端撤销原注销申请。
func confirmDeregistrationLogin(
_ confirmation: DeregistrationLoginConfirmation,
authAPI: AuthAPI
) async throws -> V9AuthResponse {
try await setUser(confirmation.account, tempToken: confirmation.tempToken, authAPI: authAPI)
}
private func setUser(
_ account: AccountSwitchAccount,
tempToken: String,
authAPI: AuthAPI
) async throws -> V9AuthResponse {
guard !tempToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw LoginFlowError.missingToken
}
guard account.businessUserId > 0 else {
throw LoginFlowError.invalidAccount
}
@@ -144,12 +166,10 @@ final class LoginViewModel {
notifyStateChange()
}
let response = try await authAPI.setUser(account.toSetUserRequest(), tokenOverride: payload.tempToken)
let response = try await authAPI.setUser(account.toSetUserRequest(), tokenOverride: tempToken)
guard !response.token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw LoginFlowError.missingToken
}
pendingAccountSelection = nil
notifyStateChange()
return response
}
@@ -178,6 +198,11 @@ final class LoginViewModel {
}
if accounts.count == 1, let account = accounts.first {
if account.requiresDeregistrationConfirmation {
return .needsDeregistrationConfirmation(
DeregistrationLoginConfirmation(tempToken: token, account: account)
)
}
let finalResponse = try await authAPI.setUser(account.toSetUserRequest(), tokenOverride: token)
guard !finalResponse.token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw LoginFlowError.missingToken
@@ -81,6 +81,10 @@ final class MainTabBarController: UITabBarController {
controller = PaymentCollectionDetailsViewController()
case .messageCenter:
controller = MessageCenterViewController()
case .aiRetouchTaskList:
controller = TravelAlbumAIJobListViewController()
case .aiRetouchTaskDetail(let batchId):
controller = TravelAlbumAIJobDetailViewController(batchId: batchId)
}
if let controller {
@@ -12,6 +12,7 @@ final class MessageDetailViewController: BaseViewController {
private let viewModel: MessageDetailViewModel
private let api: any MessageCenterServing
private let travelAlbumAPI: any TravelAlbumServing
private let scrollView = UIScrollView()
private let contentView = UIView()
@@ -20,13 +21,19 @@ final class MessageDetailViewController: BaseViewController {
private let timeContainer = UIView()
private let timeLabel = UILabel()
private let bodyLabel = UILabel()
private let taskDetailButton = UIButton(type: .system)
private let bottomBar = UIView()
private let deleteButton = UIButton(type: .system)
/// 初始化消息详情页。
init(message: MessageItem, api: (any MessageCenterServing)? = nil) {
init(
message: MessageItem,
api: (any MessageCenterServing)? = nil,
travelAlbumAPI: (any TravelAlbumServing)? = nil
) {
viewModel = MessageDetailViewModel(message: message)
self.api = api ?? NetworkServices.shared.messageCenterAPI
self.travelAlbumAPI = travelAlbumAPI ?? NetworkServices.shared.travelAlbumAPI
super.init(nibName: nil, bundle: nil)
}
@@ -67,6 +74,15 @@ final class MessageDetailViewController: BaseViewController {
bodyLabel.numberOfLines = 0
bodyLabel.textAlignment = .left
taskDetailButton.setTitle("查看任务详情", for: .normal)
taskDetailButton.setTitleColor(.white, for: .normal)
taskDetailButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
taskDetailButton.backgroundColor = AppColor.primary
taskDetailButton.layer.cornerRadius = 10
taskDetailButton.clipsToBounds = true
taskDetailButton.isHidden = !viewModel.showsAIRetouchTaskAction
taskDetailButton.accessibilityLabel = "查看AI修图任务详情"
bottomBar.backgroundColor = .white
deleteButton.setTitle("删除并返回", for: .normal)
deleteButton.setTitleColor(.white, for: .normal)
@@ -77,7 +93,7 @@ final class MessageDetailViewController: BaseViewController {
view.addSubview(scrollView)
scrollView.addSubview(contentView)
[typeImageView, titleLabel, timeContainer, bodyLabel].forEach(contentView.addSubview)
[typeImageView, titleLabel, timeContainer, bodyLabel, taskDetailButton].forEach(contentView.addSubview)
timeContainer.addSubview(timeLabel)
view.addSubview(bottomBar)
bottomBar.addSubview(deleteButton)
@@ -121,12 +137,23 @@ final class MessageDetailViewController: BaseViewController {
bodyLabel.snp.makeConstraints { make in
make.top.equalTo(timeContainer.snp.bottom).offset(24)
make.leading.trailing.equalToSuperview().inset(32)
make.bottom.lessThanOrEqualToSuperview().inset(24)
if !viewModel.showsAIRetouchTaskAction {
make.bottom.lessThanOrEqualToSuperview().inset(24)
}
}
if viewModel.showsAIRetouchTaskAction {
taskDetailButton.snp.makeConstraints { make in
make.top.equalTo(bodyLabel.snp.bottom).offset(24)
make.leading.trailing.equalToSuperview().inset(32)
make.height.equalTo(52)
make.bottom.lessThanOrEqualToSuperview().inset(24)
}
}
}
override func bindActions() {
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
taskDetailButton.addTarget(self, action: #selector(taskDetailTapped), for: .touchUpInside)
viewModel.onStateChange = { [weak self] in
Task { @MainActor in
self?.applyState()
@@ -145,6 +172,16 @@ final class MessageDetailViewController: BaseViewController {
}
}
@objc private func taskDetailTapped() {
let target: UIViewController
if let batchId = viewModel.aiRetouchBatchId {
target = TravelAlbumAIJobDetailViewController(batchId: batchId, api: travelAlbumAPI)
} else {
target = TravelAlbumAIJobListViewController(api: travelAlbumAPI)
}
navigationController?.pushViewController(target, animated: true)
}
@objc private func deleteTapped() {
let alert = UIAlertController(title: "删除消息", message: "确定要删除这条消息吗?", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
@@ -0,0 +1,630 @@
import SnapKit
import UIKit
/// 日清列表条目。
private enum OfflineDailyItem: Hashable {
case record(OfflineCollectionRecord)
case empty(String)
}
/// 日清记录在白色列表卡中的圆角位置。
private enum OfflineRecordPosition {
case single
case first
case middle
case last
}
/// 线下收款日清页,按 9.7 视觉稿展示日历、2×2 汇总、明细和补缴操作。
@MainActor
final class OfflineCollectionDailyViewController: BaseViewController {
private let viewModel: OfflineCollectionDailyViewModel
private let tableView = UITableView(frame: .zero, style: .plain)
private var dataSource: UITableViewDiffableDataSource<Int, OfflineDailyItem>!
private let headerContainer = UIView()
private let headerStack = UIStackView()
private let calendarView = OfflineCollectionCalendarView()
private var calendarHeightConstraint: Constraint?
private var isAnimatingHeaderResize = false
private let summaryCard = UIView()
private let summaryDateLabel = UILabel()
private let totalValue = UILabel()
private let countValue = UILabel()
private let settledValue = UILabel()
private let pendingValue = UILabel()
private let statusButton = UIButton(type: .system)
private let bottomContainer = UIView()
private let settlementButton = OfflineCollectionGradientButton(
startColor: UIColor(hex: 0xFF8B00),
endColor: UIColor(hex: 0xFF7200)
)
private var feedbackPresented = false
private var isShowingGlobalLoading = false
init(
businessDate: String,
context: OfflineCollectionContext = .current(),
api: any OfflineCollectionServing = NetworkServices.shared.offlineCollectionAPI
) {
viewModel = OfflineCollectionDailyViewModel(businessDate: businessDate, context: context, api: api)
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func setupNavigationBar() {
title = "线下收款日清"
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF7FAFF)
configureTableView()
configureHeader()
configureBottomBar()
view.addSubview(tableView)
view.addSubview(bottomContainer)
tableView.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
make.bottom.equalTo(bottomContainer.snp.top)
}
bottomContainer.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() }
}
override func bindActions() {
calendarView.onDateSelected = { [weak self] date in
guard let self else { return }
Task { await self.viewModel.selectBusinessDate(date) }
}
calendarView.onModeChanged = { [weak self] animated in self?.resizeHeader(animated: animated) }
viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyState() } }
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
Task { await viewModel.refresh() }
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
setGlobalLoadingVisible(false)
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if !isAnimatingHeaderResize {
resizeHeader()
}
}
private func configureTableView() {
tableView.backgroundColor = .clear
tableView.separatorStyle = .none
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 96
tableView.showsVerticalScrollIndicator = false
tableView.contentInset.bottom = 12
tableView.register(OfflineCollectionRecordCell.self, forCellReuseIdentifier: OfflineCollectionRecordCell.reuseIdentifier)
tableView.register(OfflineCollectionEmptyCell.self, forCellReuseIdentifier: OfflineCollectionEmptyCell.reuseIdentifier)
dataSource = makeDataSource()
}
private func configureHeader() {
headerStack.axis = .vertical
headerStack.spacing = 16
headerContainer.addSubview(headerStack)
headerStack.snp.makeConstraints { make in
make.top.equalToSuperview().offset(12)
make.bottom.equalToSuperview().inset(10)
make.leading.trailing.equalToSuperview().inset(16)
}
headerStack.addArrangedSubview(calendarView)
calendarView.snp.makeConstraints { make in
calendarHeightConstraint = make.height.equalTo(calendarView.preferredHeight).constraint
}
configureSummaryCard()
headerStack.addArrangedSubview(summaryCard)
let listTitle = UILabel()
listTitle.text = "收款明细"
listTitle.font = .systemFont(ofSize: 18, weight: .bold)
listTitle.textColor = UIColor(hex: 0x081739)
let listTitleContainer = UIView()
listTitleContainer.addSubview(listTitle)
listTitle.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(8)
make.trailing.centerY.equalToSuperview()
}
listTitleContainer.snp.makeConstraints { make in make.height.equalTo(42) }
headerStack.setCustomSpacing(18, after: summaryCard)
headerStack.addArrangedSubview(listTitleContainer)
statusButton.configuration = .plain()
statusButton.configuration?.baseForegroundColor = AppColor.primary
statusButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
statusButton.snp.makeConstraints { make in make.height.greaterThanOrEqualTo(44) }
statusButton.isHidden = true
headerStack.addArrangedSubview(statusButton)
tableView.tableHeaderView = headerContainer
}
private func configureSummaryCard() {
summaryCard.backgroundColor = .white
summaryCard.layer.cornerRadius = 12
summaryCard.layer.shadowColor = UIColor(hex: 0x8FA0B8).cgColor
summaryCard.layer.shadowOpacity = 0.12
summaryCard.layer.shadowRadius = 10
summaryCard.layer.shadowOffset = CGSize(width: 0, height: 4)
summaryDateLabel.font = .systemFont(ofSize: 17, weight: .bold)
summaryDateLabel.textColor = UIColor(hex: 0x081739)
let topLeft = metric(title: "线下收款总额", value: totalValue)
let topRight = metric(title: "登记笔数", value: countValue)
let bottomLeft = metric(title: "已补缴", value: settledValue)
let bottomRight = metric(title: "待补缴", value: pendingValue)
let grid = UIView()
[topLeft, topRight, bottomLeft, bottomRight].forEach(grid.addSubview)
topLeft.snp.makeConstraints { make in
make.top.leading.equalToSuperview()
make.width.equalToSuperview().multipliedBy(0.5)
make.height.equalToSuperview().multipliedBy(0.5)
}
topRight.snp.makeConstraints { make in
make.top.trailing.equalToSuperview()
make.width.height.equalTo(topLeft)
}
bottomLeft.snp.makeConstraints { make in
make.bottom.leading.equalToSuperview()
make.width.height.equalTo(topLeft)
}
bottomRight.snp.makeConstraints { make in
make.bottom.trailing.equalToSuperview()
make.width.height.equalTo(topLeft)
}
let horizontalDivider = UIView()
let verticalDivider = UIView()
[horizontalDivider, verticalDivider].forEach {
$0.backgroundColor = UIColor(hex: 0xE7EBF1)
grid.addSubview($0)
}
horizontalDivider.snp.makeConstraints { make in
make.leading.trailing.centerY.equalToSuperview()
make.height.equalTo(1)
}
verticalDivider.snp.makeConstraints { make in
make.top.bottom.centerX.equalToSuperview()
make.width.equalTo(1)
}
summaryCard.addSubview(summaryDateLabel)
summaryCard.addSubview(grid)
summaryDateLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(14)
make.leading.trailing.equalToSuperview().inset(16)
make.height.equalTo(22)
}
grid.snp.makeConstraints { make in
make.top.equalTo(summaryDateLabel.snp.bottom).offset(6)
make.leading.trailing.equalToSuperview().inset(10)
make.bottom.equalToSuperview().inset(8)
}
summaryCard.snp.makeConstraints { make in make.height.equalTo(196) }
}
private func metric(title: String, value: UILabel) -> UIView {
let titleLabel = UILabel()
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 12)
titleLabel.textColor = UIColor(hex: 0x7B8494)
titleLabel.textAlignment = .center
value.font = .systemFont(ofSize: 20, weight: .bold)
value.textColor = UIColor(hex: 0x081739)
value.textAlignment = .center
value.adjustsFontSizeToFitWidth = true
value.minimumScaleFactor = 0.72
let stack = UIStackView(arrangedSubviews: [titleLabel, value])
stack.axis = .vertical
stack.alignment = .fill
stack.spacing = 6
let container = UIView()
container.addSubview(stack)
stack.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(12)
}
return container
}
private func configureBottomBar() {
bottomContainer.backgroundColor = .white
bottomContainer.layer.shadowColor = UIColor(hex: 0x8090A8).cgColor
bottomContainer.layer.shadowOpacity = 0.12
bottomContainer.layer.shadowRadius = 12
bottomContainer.layer.shadowOffset = CGSize(width: 0, height: -3)
settlementButton.setTitle("当日暂无待补缴", for: .normal)
settlementButton.setTitleColor(.white, for: .normal)
settlementButton.titleLabel?.font = .systemFont(ofSize: 17, weight: .semibold)
settlementButton.layer.cornerRadius = 10
settlementButton.clipsToBounds = true
settlementButton.accessibilityIdentifier = "offlineCollection.settle"
settlementButton.addTarget(self, action: #selector(settlementTapped), for: .touchUpInside)
bottomContainer.addSubview(settlementButton)
settlementButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(14)
make.leading.trailing.equalToSuperview().inset(16)
make.height.equalTo(54)
make.bottom.equalTo(bottomContainer.safeAreaLayoutGuide).inset(14)
}
}
@MainActor private func applyState() {
let processing = viewModel.settlementState == .processing
setGlobalLoadingVisible(viewModel.isLoading || processing)
calendarView.apply(
selectedDate: viewModel.businessDate,
maximumDate: viewModel.serverToday,
pendingDates: viewModel.pendingDates
)
summaryDateLabel.text = formattedSummaryDate()
totalValue.text = OfflineCollectionMoney.display(viewModel.summary.totalAmountFen)
countValue.text = "\(viewModel.summary.totalCount)笔"
settledValue.text = OfflineCollectionMoney.display(viewModel.summary.settledAmountFen)
pendingValue.text = OfflineCollectionMoney.display(viewModel.summary.pendingAmountFen)
pendingValue.textColor = viewModel.summary.pendingCount > 0 ? UIColor(hex: 0xFF7600) : UIColor(hex: 0x081739)
if let error = viewModel.errorMessage, !viewModel.isLoading {
statusButton.isHidden = false
statusButton.isUserInteractionEnabled = true
statusButton.configuration?.title = "\(error) 点击重试"
UIAccessibility.post(notification: .announcement, argument: error)
} else {
statusButton.isHidden = true
}
settlementButton.isEnabled = viewModel.canSettle && !processing
if processing {
settlementButton.setTitle("补缴中", for: .normal)
} else if viewModel.summary.totalCount > 0 && viewModel.summary.pendingCount == 0 {
settlementButton.setTitle("本日已全部补缴", for: .normal)
} else if viewModel.summary.pendingCount == 0 {
settlementButton.setTitle("当日暂无待补缴", for: .normal)
} else {
settlementButton.setTitle(
"补缴本日全部 \(OfflineCollectionMoney.display(viewModel.summary.pendingAmountFen))",
for: .normal
)
}
settlementButton.alpha = viewModel.canSettle || processing ? 1 : 0.45
applySnapshot()
resizeHeader()
presentFeedbackIfNeeded()
}
private func formattedSummaryDate() -> String {
guard let date = OfflineCollectionDate.date(from: viewModel.businessDate) else { return viewModel.businessDate }
let calendar = OfflineCollectionDate.calendar
let text = "\(calendar.component(.month, from: date))月\(calendar.component(.day, from: date))日"
return viewModel.businessDate == viewModel.serverToday ? "\(text) · 今日" : text
}
private func setGlobalLoadingVisible(_ visible: Bool) {
guard visible != isShowingGlobalLoading else { return }
isShowingGlobalLoading = visible
visible ? showLoading() : hideLoading()
}
@MainActor private func applySnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<Int, OfflineDailyItem>()
snapshot.appendSections([0])
if viewModel.records.isEmpty {
let text = viewModel.errorMessage == nil && !viewModel.isLoading ? "本日暂无线下收款记录" : ""
snapshot.appendItems([.empty(text)])
} else {
snapshot.appendItems(viewModel.records.map(OfflineDailyItem.record))
}
dataSource.apply(snapshot, animatingDifferences: false)
}
private func makeDataSource() -> UITableViewDiffableDataSource<Int, OfflineDailyItem> {
UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item in
switch item {
case let .record(record):
let cell = tableView.dequeueReusableCell(
withIdentifier: OfflineCollectionRecordCell.reuseIdentifier,
for: indexPath
) as! OfflineCollectionRecordCell
let count = self?.viewModel.records.count ?? 1
let position: OfflineRecordPosition
if count == 1 { position = .single }
else if indexPath.row == 0 { position = .first }
else if indexPath.row == count - 1 { position = .last }
else { position = .middle }
cell.apply(record, position: position)
return cell
case let .empty(text):
let cell = tableView.dequeueReusableCell(
withIdentifier: OfflineCollectionEmptyCell.reuseIdentifier,
for: indexPath
) as! OfflineCollectionEmptyCell
cell.apply(text)
return cell
}
}
}
private func resizeHeader(animated: Bool = false) {
guard tableView.bounds.width > 0 else { return }
if isAnimatingHeaderResize, !animated { return }
headerContainer.frame.size.width = tableView.bounds.width
let targetCalendarHeight = calendarView.preferredHeight
if animated,
!UIAccessibility.isReduceMotionEnabled,
headerContainer.frame.height > 0,
calendarView.bounds.height > 0 {
let targetHeaderHeight = headerContainer.frame.height + targetCalendarHeight - calendarView.bounds.height
guard abs(headerContainer.frame.height - targetHeaderHeight) > 0.5 else { return }
calendarHeightConstraint?.update(offset: targetCalendarHeight)
isAnimatingHeaderResize = true
UIView.animate(
withDuration: 0.3,
delay: 0,
options: [.curveEaseInOut, .beginFromCurrentState],
animations: { [weak self] in
guard let self else { return }
headerContainer.frame.size.height = targetHeaderHeight
headerContainer.layoutIfNeeded()
tableView.tableHeaderView = headerContainer
tableView.layoutIfNeeded()
},
completion: { [weak self] _ in
guard let self else { return }
isAnimatingHeaderResize = false
resizeHeader()
}
)
return
}
calendarHeightConstraint?.update(offset: targetCalendarHeight)
headerContainer.setNeedsLayout()
headerContainer.layoutIfNeeded()
let height = headerContainer.systemLayoutSizeFitting(
CGSize(width: tableView.bounds.width, height: UIView.layoutFittingCompressedSize.height),
withHorizontalFittingPriority: .required,
verticalFittingPriority: .fittingSizeLevel
).height
guard abs(headerContainer.frame.height - height) > 0.5 else { return }
let updates = { [weak self] in
guard let self else { return }
headerContainer.frame.size.height = height
tableView.tableHeaderView = headerContainer
tableView.layoutIfNeeded()
}
updates()
}
@MainActor private func presentFeedbackIfNeeded() {
guard !feedbackPresented else { return }
switch viewModel.settlementState {
case .idle, .processing:
return
case .success:
// 成功后页面数据已经刷新,直接清理反馈状态,不再阻断用户操作。
viewModel.clearSettlementFeedback()
case let .failed(message, canRetry):
feedbackPresented = true
let alert = UIAlertController(title: "补缴失败", message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "暂不处理", style: .cancel) { [weak self] _ in
self?.viewModel.cancelPreparedSettlement()
self?.finishFeedback()
})
if canRetry {
alert.addAction(UIAlertAction(title: "重新补缴", style: .default) { [weak self] _ in
guard let self else { return }
self.feedbackPresented = false
Task { await self.viewModel.confirmSettlement() }
})
}
present(alert, animated: true)
}
}
private func finishFeedback() {
feedbackPresented = false
viewModel.clearSettlementFeedback()
}
@objc private func retryTapped() {
Task { await viewModel.refresh() }
}
@objc private func settlementTapped() {
do {
let request = try viewModel.prepareSettlement()
let message = "营业日:\(request.businessDate)\n待补缴记录:\(request.pendingCount) 笔\n本次补缴金额:\(OfflineCollectionMoney.display(request.amountFen))"
let alert = UIAlertController(title: "确认补缴", message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel) { [weak self] _ in
self?.viewModel.cancelPreparedSettlement()
})
alert.addAction(UIAlertAction(title: "确认补缴", style: .default) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.confirmSettlement() }
})
present(alert, animated: true)
} catch {
showToast(error.localizedDescription)
}
}
}
/// 日清页的一笔收款记录行,纯展示支付方式、编号、金额、状态与时间。
private final class OfflineCollectionRecordCell: UITableViewCell {
static let reuseIdentifier = "OfflineCollectionRecordCell"
private let card = UIView()
private let iconView = UIImageView()
private let titleLabel = UILabel()
private let idLabel = UILabel()
private let amountLabel = UILabel()
private let statusLabel = UILabel()
private let timeLabel = UILabel()
private let separator = UIView()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
backgroundColor = .clear
contentView.backgroundColor = .clear
card.backgroundColor = .white
iconView.contentMode = .scaleAspectFit
titleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
titleLabel.textColor = UIColor(hex: 0x081739)
idLabel.font = .systemFont(ofSize: 9.5, weight: .medium)
idLabel.textColor = UIColor(hex: 0x6F7B8F)
idLabel.textAlignment = .left
idLabel.numberOfLines = 0
idLabel.lineBreakMode = .byCharWrapping
idLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
idLabel.setContentCompressionResistancePriority(.required, for: .vertical)
amountLabel.font = .systemFont(ofSize: 16, weight: .bold)
amountLabel.textColor = UIColor(hex: 0x081739)
amountLabel.textAlignment = .right
amountLabel.adjustsFontSizeToFitWidth = true
amountLabel.minimumScaleFactor = 0.72
statusLabel.font = .systemFont(ofSize: 11, weight: .medium)
statusLabel.textAlignment = .center
statusLabel.layer.cornerRadius = 7
statusLabel.clipsToBounds = true
timeLabel.font = .systemFont(ofSize: 12)
timeLabel.textColor = UIColor(hex: 0x7B8494)
timeLabel.textAlignment = .right
separator.backgroundColor = UIColor(hex: 0xE7EBF1)
let idContainer = UIView()
idContainer.backgroundColor = UIColor(hex: 0xF0F2F6)
idContainer.layer.cornerRadius = 5
idContainer.clipsToBounds = true
idContainer.addSubview(idLabel)
idLabel.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 3, left: 6, bottom: 3, right: 6))
}
card.addSubview(iconView)
card.addSubview(titleLabel)
card.addSubview(idContainer)
card.addSubview(amountLabel)
card.addSubview(statusLabel)
card.addSubview(timeLabel)
card.addSubview(separator)
contentView.addSubview(card)
card.snp.makeConstraints { make in
make.top.bottom.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(16)
make.height.greaterThanOrEqualTo(90)
}
iconView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.top.equalToSuperview().offset(10)
make.width.height.equalTo(38)
}
titleLabel.snp.makeConstraints { make in
make.leading.equalTo(iconView.snp.trailing).offset(10)
make.centerY.equalTo(iconView)
}
amountLabel.snp.makeConstraints { make in
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(4)
make.centerY.equalTo(iconView)
make.width.equalTo(68)
}
statusLabel.snp.makeConstraints { make in
make.leading.equalTo(amountLabel.snp.trailing).offset(4)
make.trailing.equalToSuperview().inset(12)
make.centerY.equalTo(iconView)
make.width.equalTo(50)
make.height.equalTo(27)
}
timeLabel.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(12)
make.centerY.equalTo(idContainer)
make.width.equalTo(40)
}
idContainer.snp.makeConstraints { make in
make.leading.equalTo(iconView)
make.top.equalTo(iconView.snp.bottom).offset(6)
make.trailing.lessThanOrEqualTo(timeLabel.snp.leading).offset(-8)
make.bottom.equalToSuperview().inset(10)
}
separator.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.trailing.equalToSuperview().inset(8)
make.bottom.equalToSuperview()
make.height.equalTo(1 / UIScreen.main.scale)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func apply(_ record: OfflineCollectionRecord, position: OfflineRecordPosition) {
iconView.image = UIImage(named: record.paymentMethod.assetName)
titleLabel.text = record.paymentMethod == .wechat ? "微信收款" : record.paymentMethod.displayName
idLabel.text = "编号 \(record.collectNo)"
amountLabel.text = OfflineCollectionMoney.display(record.amountFen)
let settled = record.status == .settled
statusLabel.text = settled ? "已补缴" : "未补缴"
statusLabel.textColor = settled ? UIColor(hex: 0x16A34A) : UIColor(hex: 0xFF7600)
statusLabel.backgroundColor = settled ? UIColor(hex: 0xEAF8EF) : UIColor(hex: 0xFFF0E2)
timeLabel.text = record.timeText
separator.isHidden = position == .single || position == .last
card.layer.cornerRadius = 12
switch position {
case .single:
card.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner]
case .first:
card.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
case .middle:
card.layer.maskedCorners = []
case .last:
card.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
}
accessibilityLabel = "\(titleLabel.text ?? ""),编号 \(record.collectNo),\(amountLabel.text ?? ""),\(statusLabel.text ?? ""),\(record.timeText)"
}
}
/// 日清页空数据占位。
private final class OfflineCollectionEmptyCell: UITableViewCell {
static let reuseIdentifier = "OfflineCollectionEmptyCell"
private let label = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
backgroundColor = .clear
label.font = .systemFont(ofSize: 14)
label.textColor = UIColor(hex: 0x7B8494)
label.textAlignment = .center
contentView.addSubview(label)
label.snp.makeConstraints { make in make.edges.equalToSuperview().inset(36) }
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func apply(_ text: String) {
label.text = text
}
}
@@ -0,0 +1,465 @@
import SnapKit
import UIKit
/// 线下收款登记页,按 9.7 视觉稿展示金额、支付方式和登记说明。
@MainActor
final class OfflineCollectionRegistrationViewController: BaseViewController {
private let viewModel: OfflineCollectionRegistrationViewModel
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let amountCard = UIView()
private let amountField = UITextField()
private let methodCard = UIView()
private let methodStack = UIStackView()
private var methodButtons: [OfflineCollectionPaymentMethod: OfflinePaymentMethodButton] = [:]
private let explanationCard = UIView()
private let contextLabel = UILabel()
private let bottomContainer = UIView()
private let submitButton = OfflineCollectionGradientButton(
startColor: UIColor(hex: 0x087BFF),
endColor: UIColor(hex: 0x0067F4)
)
private var hasFocusedAmountField = false
private var isShowingGlobalLoading = false
init(
context: OfflineCollectionContext = .current(),
api: any OfflineCollectionServing = NetworkServices.shared.offlineCollectionAPI
) {
viewModel = OfflineCollectionRegistrationViewModel(context: context, api: api)
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func setupNavigationBar() {
title = "线下收款登记"
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF7FAFF)
configureScrollContent()
configureAmountCard()
configureMethodCard()
configureExplanationCard()
configureContextLabel()
configureBottomBar()
view.addSubview(scrollView)
scrollView.addSubview(contentStack)
[amountCard, methodCard, explanationCard, contextLabel].forEach(contentStack.addArrangedSubview)
view.addSubview(bottomContainer)
bottomContainer.addSubview(submitButton)
}
override func setupConstraints() {
bottomContainer.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(view.keyboardLayoutGuide.snp.top)
}
submitButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(18)
make.height.equalTo(56)
make.bottom.equalToSuperview().inset(16)
}
scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
make.bottom.equalTo(bottomContainer.snp.top)
}
contentStack.snp.makeConstraints { make in
make.edges.equalTo(scrollView.contentLayoutGuide).inset(UIEdgeInsets(top: 16, left: 16, bottom: 24, right: 16))
make.width.equalTo(scrollView.frameLayoutGuide).offset(-32)
}
}
override func bindActions() {
amountField.addTarget(self, action: #selector(amountChanged), for: .editingChanged)
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyState() } }
viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } }
viewModel.onRegistrationSuccess = { [weak self] receipt in Task { @MainActor in self?.showSuccess(receipt) } }
}
override func viewDidLoad() {
super.viewDidLoad()
applyState()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
guard !hasFocusedAmountField else { return }
hasFocusedAmountField = true
amountField.becomeFirstResponder()
moveAmountCursorToEnd()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
setGlobalLoadingVisible(false)
}
private func configureScrollContent() {
scrollView.keyboardDismissMode = .interactive
scrollView.alwaysBounceVertical = true
scrollView.showsVerticalScrollIndicator = false
contentStack.axis = .vertical
contentStack.spacing = 16
}
private func configureAmountCard() {
configureCard(amountCard)
let titleLabel = makeTitleLabel("收款金额")
let helperLabel = UILabel()
helperLabel.text = "单笔金额 0.01~99,999.99 元"
helperLabel.font = .systemFont(ofSize: 13)
helperLabel.textColor = UIColor(hex: 0x7B8494)
helperLabel.adjustsFontSizeToFitWidth = true
helperLabel.minimumScaleFactor = 0.82
let currencyLabel = UILabel()
currencyLabel.text = "¥"
currencyLabel.font = .systemFont(ofSize: 34, weight: .semibold)
currencyLabel.textColor = UIColor(hex: 0x081739)
amountField.placeholder = "0.00"
amountField.font = .systemFont(ofSize: 42, weight: .bold)
amountField.textColor = UIColor(hex: 0x081739)
amountField.tintColor = AppColor.primary
amountField.textAlignment = .right
amountField.keyboardType = .decimalPad
amountField.adjustsFontSizeToFitWidth = true
amountField.minimumFontSize = 30
amountField.delegate = self
amountField.accessibilityLabel = "收款金额"
amountField.accessibilityIdentifier = "offlineCollection.amount"
let amountInputStack = UIStackView(arrangedSubviews: [currencyLabel, amountField])
amountInputStack.axis = .horizontal
amountInputStack.alignment = .center
amountInputStack.spacing = 6
let amountRow = UIView()
amountRow.addSubview(helperLabel)
amountRow.addSubview(amountInputStack)
helperLabel.snp.makeConstraints { make in
make.leading.centerY.equalToSuperview()
make.trailing.lessThanOrEqualTo(amountInputStack.snp.leading).offset(-8)
}
amountInputStack.snp.makeConstraints { make in
make.trailing.centerY.equalToSuperview()
make.width.lessThanOrEqualTo(205)
}
amountField.snp.makeConstraints { make in
make.height.equalTo(56)
make.width.greaterThanOrEqualTo(98)
}
let underline = UIView()
underline.backgroundColor = UIColor(hex: 0x1684FC)
underline.snp.makeConstraints { make in make.height.equalTo(1) }
let quickAmounts: [(String, Int)] = [("¥50", 5_000), ("¥100", 10_000), ("¥200", 20_000), ("¥500", 50_000)]
let quickStack = UIStackView()
quickStack.axis = .horizontal
quickStack.distribution = .fillEqually
quickStack.spacing = 12
quickAmounts.forEach { title, fen in
let button = UIButton(type: .system)
button.tag = fen
button.setTitle(title, for: .normal)
button.setTitleColor(UIColor(hex: 0x1677FF), for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium)
button.backgroundColor = UIColor(hex: 0xF5F8FD)
button.layer.cornerRadius = 8
button.layer.borderWidth = 1
button.layer.borderColor = UIColor(hex: 0xE3E8F0).cgColor
button.addTarget(self, action: #selector(quickAmountTapped(_:)), for: .touchUpInside)
button.snp.makeConstraints { make in make.height.equalTo(36) }
quickStack.addArrangedSubview(button)
}
let stack = UIStackView(arrangedSubviews: [titleLabel, amountRow, underline, quickStack])
stack.axis = .vertical
stack.setCustomSpacing(8, after: titleLabel)
stack.setCustomSpacing(2, after: amountRow)
stack.setCustomSpacing(22, after: underline)
amountCard.addSubview(stack)
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(20) }
amountRow.snp.makeConstraints { make in make.height.equalTo(56) }
}
private func configureMethodCard() {
configureCard(methodCard)
let titleLabel = makeTitleLabel("收款方式")
methodStack.axis = .horizontal
methodStack.distribution = .fillEqually
methodStack.spacing = 12
for method in OfflineCollectionPaymentMethod.allCases {
let button = OfflinePaymentMethodButton(method: method)
button.addTarget(self, action: #selector(methodTapped(_:)), for: .touchUpInside)
methodButtons[method] = button
methodStack.addArrangedSubview(button)
}
let stack = UIStackView(arrangedSubviews: [titleLabel, methodStack])
stack.axis = .vertical
stack.spacing = 20
methodCard.addSubview(stack)
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(20) }
methodStack.snp.makeConstraints { make in make.height.equalTo(120) }
}
private func configureExplanationCard() {
configureCard(explanationCard)
let iconView = UIImageView(image: UIImage(named: "offline_security_shield"))
iconView.contentMode = .scaleAspectFit
iconView.accessibilityLabel = "安全说明"
let label = UILabel()
label.text = "登记后计入今日待补缴,不生成订单"
label.font = .systemFont(ofSize: 15)
label.textColor = UIColor(hex: 0x22304D)
label.numberOfLines = 0
explanationCard.addSubview(iconView)
explanationCard.addSubview(label)
iconView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(20)
make.centerY.equalToSuperview()
make.width.height.equalTo(44)
}
label.snp.makeConstraints { make in
make.leading.equalTo(iconView.snp.trailing).offset(14)
make.trailing.equalToSuperview().inset(18)
make.top.bottom.equalToSuperview().inset(20)
}
explanationCard.snp.makeConstraints { make in make.height.greaterThanOrEqualTo(72) }
}
private func configureContextLabel() {
contextLabel.text = "当前:\(viewModel.context.collectorName) · \(viewModel.context.storeName) · \(viewModel.context.scenicName)"
contextLabel.font = .systemFont(ofSize: 13)
contextLabel.textColor = UIColor(hex: 0x7B8494)
contextLabel.numberOfLines = 0
contextLabel.textAlignment = .center
contextLabel.snp.makeConstraints { make in make.height.greaterThanOrEqualTo(44) }
}
private func configureBottomBar() {
bottomContainer.backgroundColor = .white
bottomContainer.layer.shadowColor = UIColor(hex: 0x8090A8).cgColor
bottomContainer.layer.shadowOpacity = 0.12
bottomContainer.layer.shadowRadius = 12
bottomContainer.layer.shadowOffset = CGSize(width: 0, height: -3)
submitButton.setTitle("确认登记", for: .normal)
submitButton.setTitleColor(.white, for: .normal)
submitButton.titleLabel?.font = .systemFont(ofSize: 18, weight: .semibold)
submitButton.layer.cornerRadius = 10
submitButton.clipsToBounds = true
submitButton.accessibilityIdentifier = "offlineCollection.submit"
}
@MainActor private func applyState() {
setGlobalLoadingVisible(viewModel.isSubmitting)
if amountField.text != viewModel.amountText {
amountField.text = viewModel.amountText
moveAmountCursorToEnd()
}
methodButtons.forEach { method, button in button.setSelected(method == viewModel.paymentMethod) }
submitButton.isEnabled = viewModel.canSubmit && !viewModel.isSubmitting
submitButton.alpha = viewModel.canSubmit || viewModel.isSubmitting ? 1 : 0.45
}
@MainActor private func showSuccess(_ receipt: OfflineCollectionRegistrationReceipt) {
setGlobalLoadingVisible(false)
amountField.resignFirstResponder()
let totalText = receipt.summary.map { OfflineCollectionMoney.display($0.totalAmountFen) } ?? "—"
let pendingText = receipt.summary.map { OfflineCollectionMoney.display($0.pendingAmountFen) } ?? "—"
let message = """
本次登记 \(OfflineCollectionMoney.display(receipt.record.amountFen))
今日累计线下收款 \(totalText)
今日待补缴 \(pendingText)
"""
let alert = UIAlertController(title: "登记成功", message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "继续登记", style: .default) { [weak self] _ in
self?.viewModel.startAnotherRegistration()
self?.amountField.becomeFirstResponder()
self?.moveAmountCursorToEnd()
})
alert.addAction(UIAlertAction(title: "查看今日明细", style: .default) { [weak self] _ in
guard let self else { return }
self.navigationController?.pushViewController(
OfflineCollectionDailyViewController(
businessDate: receipt.businessDate,
context: self.viewModel.context,
api: self.viewModel.api
),
animated: true
)
})
present(alert, animated: true)
}
private func setGlobalLoadingVisible(_ visible: Bool) {
guard visible != isShowingGlobalLoading else { return }
isShowingGlobalLoading = visible
visible ? showLoading() : hideLoading()
}
private func makeTitleLabel(_ text: String) -> UILabel {
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: 17, weight: .semibold)
label.textColor = UIColor(hex: 0x081739)
return label
}
private func configureCard(_ card: UIView) {
card.backgroundColor = .white
card.layer.cornerRadius = 12
card.layer.shadowColor = UIColor(hex: 0x8FA0B8).cgColor
card.layer.shadowOpacity = 0.12
card.layer.shadowRadius = 10
card.layer.shadowOffset = CGSize(width: 0, height: 4)
}
private func moveAmountCursorToEnd() {
guard amountField.isFirstResponder else { return }
let end = amountField.endOfDocument
amountField.selectedTextRange = amountField.textRange(from: end, to: end)
}
@objc private func amountChanged() {
viewModel.updateAmount(amountField.text ?? "")
moveAmountCursorToEnd()
}
@objc private func quickAmountTapped(_ sender: UIButton) {
viewModel.updateAmount(OfflineCollectionMoney.apiAmount(sender.tag))
amountField.becomeFirstResponder()
moveAmountCursorToEnd()
}
@objc private func methodTapped(_ sender: OfflinePaymentMethodButton) {
viewModel.selectPaymentMethod(sender.method)
}
@objc private func submitTapped() {
Task { await viewModel.submit() }
}
}
extension OfflineCollectionRegistrationViewController: UITextFieldDelegate {
func textField(
_ textField: UITextField,
shouldChangeCharactersIn range: NSRange,
replacementString string: String
) -> Bool {
guard let current = textField.text, let swiftRange = Range(range, in: current) else { return false }
return OfflineCollectionMoney.acceptsEditingText(current.replacingCharacters(in: swiftRange, with: string))
}
}
/// 登记页的收款方式单选卡,展示官方品牌图标和右上角选中标记。
@MainActor
final class OfflinePaymentMethodButton: UIControl {
let method: OfflineCollectionPaymentMethod
private let iconView = UIImageView()
private let titleLabel = UILabel()
private let selectionBadge = UIView()
private let checkmarkView = UIImageView(
image: UIImage(
systemName: "checkmark",
withConfiguration: UIImage.SymbolConfiguration(pointSize: 10, weight: .bold)
)
)
init(method: OfflineCollectionPaymentMethod) {
self.method = method
super.init(frame: .zero)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func setSelected(_ selected: Bool) {
isSelected = selected
backgroundColor = selected ? UIColor(hex: 0xF1F7FF) : .white
layer.borderColor = (selected ? UIColor(hex: 0x1684FC) : UIColor(hex: 0xE2E7EF)).cgColor
layer.borderWidth = selected ? 1.5 : 1
selectionBadge.isHidden = !selected
titleLabel.textColor = UIColor(hex: 0x081739)
accessibilityTraits = selected ? [.button, .selected] : .button
}
private func setupUI() {
layer.cornerRadius = 10
clipsToBounds = false
accessibilityLabel = method.displayName
iconView.image = UIImage(named: method.assetName)?.withRenderingMode(.alwaysOriginal)
iconView.contentMode = .scaleAspectFit
titleLabel.text = method.displayName
titleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
titleLabel.textAlignment = .center
selectionBadge.backgroundColor = UIColor(hex: 0x1684FC)
selectionBadge.layer.cornerRadius = 10
selectionBadge.layer.borderColor = UIColor.white.cgColor
selectionBadge.layer.borderWidth = 2
selectionBadge.isHidden = true
selectionBadge.isUserInteractionEnabled = false
checkmarkView.tintColor = .white
checkmarkView.contentMode = .scaleAspectFit
let stack = UIStackView(arrangedSubviews: [iconView, titleLabel])
stack.axis = .vertical
stack.alignment = .center
stack.spacing = 12
stack.isUserInteractionEnabled = false
addSubview(stack)
addSubview(selectionBadge)
selectionBadge.addSubview(checkmarkView)
stack.snp.makeConstraints { make in make.center.equalToSuperview() }
iconView.snp.makeConstraints { make in make.width.height.equalTo(46) }
selectionBadge.snp.makeConstraints { make in
make.width.height.equalTo(20)
make.top.trailing.equalToSuperview().inset(6)
}
checkmarkView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.width.height.equalTo(11)
}
setSelected(false)
}
}
/// 线下收款页面共用的双端色渐变按钮。
@MainActor
final class OfflineCollectionGradientButton: UIButton {
private let gradientLayer = CAGradientLayer()
init(startColor: UIColor, endColor: UIColor) {
super.init(frame: .zero)
gradientLayer.colors = [startColor.cgColor, endColor.cgColor]
gradientLayer.startPoint = CGPoint(x: 0, y: 0.5)
gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
layer.insertSublayer(gradientLayer, at: 0)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func layoutSubviews() {
super.layoutSubviews()
gradientLayer.frame = bounds
gradientLayer.cornerRadius = layer.cornerRadius
}
override var isEnabled: Bool {
didSet { gradientLayer.opacity = isEnabled ? 1 : 0.45 }
}
}
@@ -0,0 +1,347 @@
import SnapKit
import UIKit
/// 日清页顶部可横滑的周/月日历组件。
@MainActor
final class OfflineCollectionCalendarView: UIView {
var onDateSelected: ((String) -> Void)?
var onModeChanged: ((_ animated: Bool) -> Void)?
private let titleLabel = UILabel()
private let toggleButton = UIButton(type: .system)
private let previousButton = UIButton(type: .system)
private let nextButton = UIButton(type: .system)
private let rowsStack = UIStackView()
private var rowStacks: [UIStackView] = []
private var dayButtons: [OfflineCollectionDayButton] = []
private var pendingDates: Set<String> = []
private var state = OfflineCollectionCalendarState(selectedDate: Date(), maximumDate: Date())
private let calendar = OfflineCollectionDate.calendar
private var isAnimatingModeChange = false
/// 当前周/月模式期望占用的固定高度,供外层表头同步执行高度动画。
var preferredHeight: CGFloat {
state.mode == .week ? 160 : 378
}
override var intrinsicContentSize: CGSize {
CGSize(width: UIView.noIntrinsicMetric, height: preferredHeight)
}
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
/// 使用选中日、服务端今日和待补缴日期刷新组件。
func apply(selectedDate: String, maximumDate: String, pendingDates: Set<String>) {
guard let selected = OfflineCollectionDate.date(from: selectedDate),
let maximum = OfflineCollectionDate.date(from: maximumDate) else { return }
self.pendingDates = pendingDates
state = OfflineCollectionCalendarState(
selectedDate: selected,
maximumDate: maximum,
mode: state.mode,
calendar: calendar
)
reloadDates()
}
private func setupUI() {
backgroundColor = .white
layer.cornerRadius = AppRadius.lg
layer.shadowColor = UIColor(hex: 0x8FA0B8).cgColor
layer.shadowOpacity = 0.12
layer.shadowRadius = 10
layer.shadowOffset = CGSize(width: 0, height: 4)
titleLabel.font = .systemFont(ofSize: 20, weight: .bold)
titleLabel.textColor = UIColor(hex: 0x081739)
toggleButton.configuration = .plain()
toggleButton.configuration?.baseForegroundColor = UIColor(hex: 0x081739)
toggleButton.configuration?.imagePlacement = .trailing
toggleButton.configuration?.imagePadding = 8
toggleButton.configuration?.contentInsets = NSDirectionalEdgeInsets(top: 7, leading: 14, bottom: 7, trailing: 12)
toggleButton.layer.cornerRadius = 9
toggleButton.layer.borderWidth = 1
toggleButton.layer.borderColor = UIColor(hex: 0xE2E7EF).cgColor
toggleButton.addTarget(self, action: #selector(toggleMode), for: .touchUpInside)
toggleButton.accessibilityIdentifier = "offlineCollection.calendar.toggle"
configureNavigationButton(previousButton, imageName: "chevron.left", action: #selector(previousPage))
configureNavigationButton(nextButton, imageName: "chevron.right", action: #selector(nextPage))
let spacer = UIView()
let header = UIStackView(arrangedSubviews: [titleLabel, spacer, previousButton, toggleButton, nextButton])
header.axis = .horizontal
header.alignment = .center
header.spacing = 8
let weekdayStack = UIStackView()
weekdayStack.axis = .horizontal
weekdayStack.distribution = .fillEqually
["一", "二", "三", "四", "五", "六", "日"].forEach { value in
let label = UILabel()
label.text = value
label.font = .systemFont(ofSize: 13, weight: .medium)
label.textColor = UIColor(hex: 0x657084)
label.textAlignment = .center
weekdayStack.addArrangedSubview(label)
}
rowsStack.axis = .vertical
rowsStack.distribution = .fillEqually
rowsStack.spacing = 1
for _ in 0 ..< 6 {
let row = UIStackView()
row.axis = .horizontal
row.distribution = .fillEqually
for _ in 0 ..< 7 {
let button = OfflineCollectionDayButton()
button.addTarget(self, action: #selector(dayTapped(_:)), for: .touchUpInside)
dayButtons.append(button)
row.addArrangedSubview(button)
}
rowStacks.append(row)
rowsStack.addArrangedSubview(row)
}
addSubview(header)
addSubview(weekdayStack)
addSubview(rowsStack)
header.snp.makeConstraints { make in
make.top.equalToSuperview().offset(14)
make.leading.equalToSuperview().offset(16)
make.trailing.equalToSuperview().inset(10)
make.height.equalTo(44)
}
weekdayStack.snp.makeConstraints { make in
make.top.equalTo(header.snp.bottom).offset(8)
make.leading.trailing.equalToSuperview().inset(10)
make.height.equalTo(24)
}
rowsStack.snp.makeConstraints { make in
make.top.equalTo(weekdayStack.snp.bottom).offset(4)
make.leading.trailing.equalToSuperview().inset(10)
make.bottom.equalToSuperview().inset(12)
}
let left = UISwipeGestureRecognizer(target: self, action: #selector(swiped(_:)))
left.direction = .left
let right = UISwipeGestureRecognizer(target: self, action: #selector(swiped(_:)))
right.direction = .right
addGestureRecognizer(left)
addGestureRecognizer(right)
reloadDates()
}
private func configureNavigationButton(_ button: UIButton, imageName: String, action: Selector) {
button.setImage(UIImage(systemName: imageName), for: .normal)
button.tintColor = UIColor(hex: 0x081739)
button.addTarget(self, action: action, for: .touchUpInside)
button.snp.makeConstraints { make in make.width.height.equalTo(44) }
}
private func reloadDates() {
UIView.performWithoutAnimation {
titleLabel.text = state.monthTitle
titleLabel.layoutIfNeeded()
}
let isMonth = state.mode == .month
toggleButton.configuration?.title = isMonth ? "周" : "月"
toggleButton.configuration?.image = UIImage(systemName: isMonth ? "chevron.up" : "chevron.down")
toggleButton.accessibilityLabel = isMonth ? "切换为周历" : "切换为月历"
previousButton.accessibilityLabel = isMonth ? "上一月" : "上一周"
nextButton.accessibilityLabel = isMonth ? "下一月" : "下一周"
nextButton.isEnabled = state.canMovePage(1) && !isAnimatingModeChange
nextButton.alpha = nextButton.isEnabled ? 1 : 0.35
previousButton.isEnabled = !isAnimatingModeChange
previousButton.alpha = previousButton.isEnabled ? 1 : 0.35
toggleButton.isEnabled = !isAnimatingModeChange
let dates = state.monthDates
let selectedMonth = calendar.component(.month, from: state.selectedDate)
for (index, button) in dayButtons.enumerated() {
guard index < dates.count else {
button.isHidden = true
continue
}
let date = dates[index]
let key = OfflineCollectionDate.businessDate(for: date, calendar: calendar)
button.isHidden = false
button.apply(
date: date,
key: key,
selected: calendar.isDate(date, inSameDayAs: state.selectedDate),
pending: pendingDates.contains(key),
today: calendar.isDate(date, inSameDayAs: state.maximumDate),
enabled: date <= state.maximumDate,
inCurrentMonth: !isMonth || calendar.component(.month, from: date) == selectedMonth,
calendar: calendar
)
}
if !isAnimatingModeChange {
applyRowVisibility(expanded: isMonth, selectedRow: state.selectedWeekRowIndex)
}
invalidateIntrinsicContentSize()
}
private func applyRowVisibility(expanded: Bool, selectedRow: Int) {
for (index, row) in rowStacks.enumerated() {
let visible = expanded || index == selectedRow
row.isHidden = !visible
row.alpha = visible ? 1 : 0
}
}
@objc private func toggleMode() {
guard !isAnimatingModeChange else { return }
let selectedRow = state.selectedWeekRowIndex
state.toggleMode()
let expanded = state.mode == .month
let animated = !UIAccessibility.isReduceMotionEnabled
guard animated else {
reloadDates()
applyRowVisibility(expanded: expanded, selectedRow: selectedRow)
invalidateIntrinsicContentSize()
onModeChanged?(false)
return
}
isAnimatingModeChange = true
rowsStack.isUserInteractionEnabled = false
reloadDates()
invalidateIntrinsicContentSize()
onModeChanged?(true)
let animator = UIViewPropertyAnimator(duration: 0.3, curve: .easeInOut) { [weak self] in
guard let self else { return }
for (index, row) in rowStacks.enumerated() where index != selectedRow {
row.isHidden = !expanded
row.alpha = expanded ? 1 : 0
}
layoutIfNeeded()
superview?.layoutIfNeeded()
}
animator.addCompletion { [weak self] _ in
guard let self else { return }
isAnimatingModeChange = false
rowsStack.isUserInteractionEnabled = true
applyRowVisibility(expanded: expanded, selectedRow: selectedRow)
reloadDates()
}
animator.startAnimation()
}
@objc private func dayTapped(_ sender: OfflineCollectionDayButton) {
guard let date = sender.date, state.select(date) else { return }
reloadDates()
onDateSelected?(OfflineCollectionDate.businessDate(for: date, calendar: calendar))
}
@objc private func swiped(_ gesture: UISwipeGestureRecognizer) {
let offset = gesture.direction == .left ? 1 : -1
movePage(offset)
}
@objc private func previousPage() {
movePage(-1)
}
@objc private func nextPage() {
movePage(1)
}
private func movePage(_ offset: Int) {
guard !isAnimatingModeChange, state.canMovePage(offset) else { return }
let oldDate = state.selectedDate
let date = state.movePage(offset)
guard !calendar.isDate(oldDate, inSameDayAs: date) else { return }
if !UIAccessibility.isReduceMotionEnabled {
let transition = CATransition()
transition.duration = 0.22
transition.type = .push
transition.subtype = offset > 0 ? .fromRight : .fromLeft
transition.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
rowsStack.layer.add(transition, forKey: "offlineCollection.calendar.page")
}
reloadDates()
onDateSelected?(OfflineCollectionDate.businessDate(for: date, calendar: calendar))
}
}
/// 日历中的单个日期按钮,同时表达选中、今日和待补缴状态。
private final class OfflineCollectionDayButton: UIControl {
private let stateBackgroundView = UIView()
private let dayLabel = UILabel()
private let pendingDot = UIView()
private(set) var date: Date?
override init(frame: CGRect) {
super.init(frame: frame)
stateBackgroundView.isUserInteractionEnabled = false
stateBackgroundView.layer.cornerRadius = 11
dayLabel.font = .systemFont(ofSize: 17, weight: .medium)
dayLabel.textAlignment = .center
pendingDot.layer.cornerRadius = 3.5
addSubview(stateBackgroundView)
addSubview(dayLabel)
addSubview(pendingDot)
stateBackgroundView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.width.height.equalTo(36)
}
dayLabel.snp.makeConstraints { make in make.center.equalTo(stateBackgroundView) }
pendingDot.snp.makeConstraints { make in
make.top.trailing.equalTo(stateBackgroundView)
make.width.height.equalTo(7)
}
snp.makeConstraints { make in make.height.greaterThanOrEqualTo(44) }
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func apply(
date: Date,
key: String,
selected: Bool,
pending: Bool,
today: Bool,
enabled: Bool,
inCurrentMonth: Bool,
calendar: Calendar
) {
self.date = date
isEnabled = enabled
dayLabel.text = String(calendar.component(.day, from: date))
let warningColor = UIColor(hex: 0xFF7600)
pendingDot.isHidden = !pending
pendingDot.backgroundColor = warningColor
pendingDot.layer.borderWidth = selected ? 1.5 : 0
pendingDot.layer.borderColor = UIColor.white.cgColor
backgroundColor = .clear
stateBackgroundView.backgroundColor = selected ? AppColor.primary : (pending ? UIColor(hex: 0xFFF0E2) : .clear)
stateBackgroundView.layer.cornerRadius = today && !selected && !pending ? 18 : 11
stateBackgroundView.layer.borderWidth = today && !selected ? 1 : 0
stateBackgroundView.layer.borderColor = AppColor.primary.cgColor
if selected {
dayLabel.textColor = .white
} else if !enabled || !inCurrentMonth {
dayLabel.textColor = AppColor.textTertiary
} else if pending {
dayLabel.textColor = warningColor
} else if today {
dayLabel.textColor = AppColor.primary
} else {
dayLabel.textColor = AppColor.textPrimary
}
alpha = enabled ? 1 : 0.35
accessibilityLabel = "\(key)\(today ? ",今天" : "")\(pending ? ",待补缴" : "")\(selected ? ",已选中" : "")"
accessibilityTraits = selected ? [.button, .selected] : .button
}
}
@@ -0,0 +1,170 @@
import SnapKit
import UIKit
/// 收款页中的线下收款入口、今日汇总和逾期提醒区域。
@MainActor
final class OfflineCollectionHomeView: UIView {
var onRegister: (() -> Void)?
var onOpenToday: (() -> Void)?
var onOpenOverdue: (() -> Void)?
var onRetry: (() -> Void)?
private let stack = UIStackView()
private let statusButton = UIButton(type: .system)
private let overdueCard = OfflineCollectionHomeCard()
private let registerCard = OfflineCollectionHomeCard()
private let todayCard = OfflineCollectionHomeCard()
override init(frame: CGRect) {
super.init(frame: frame)
stack.axis = .vertical
stack.spacing = AppSpacing.md
let title = UILabel()
title.text = "线下收款"
title.font = .systemFont(ofSize: 16, weight: .semibold)
title.textColor = AppColor.textPrimary
stack.addArrangedSubview(title)
stack.addArrangedSubview(statusButton)
stack.addArrangedSubview(overdueCard)
stack.addArrangedSubview(registerCard)
stack.addArrangedSubview(todayCard)
addSubview(stack)
stack.snp.makeConstraints { make in make.edges.equalToSuperview() }
title.snp.makeConstraints { make in make.height.equalTo(24) }
statusButton.configuration = .plain()
statusButton.configuration?.baseForegroundColor = AppColor.primary
statusButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
statusButton.isHidden = true
registerCard.apply(
title: "线下收款登记",
detail: "线下收款后,请及时登记并在当日完成补缴",
action: "去登记",
image: "plus.circle.fill",
tint: AppColor.primary,
background: .white
)
registerCard.addTarget(self, action: #selector(registerTapped), for: .touchUpInside)
todayCard.addTarget(self, action: #selector(todayTapped), for: .touchUpInside)
overdueCard.addTarget(self, action: #selector(overdueTapped), for: .touchUpInside)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
/// 根据真实统计接口状态刷新区域。
func apply(
today: OfflineDailySummary,
overdueDayCount: Int,
overdueRecordCount: Int,
overdueAmountFen: Int,
errorMessage: String?
) {
if let errorMessage {
statusButton.configuration?.title = "\(errorMessage) 点击重试"
statusButton.configuration?.showsActivityIndicator = false
statusButton.isUserInteractionEnabled = true
statusButton.isHidden = false
statusButton.accessibilityTraits.insert(.button)
} else {
statusButton.isHidden = true
}
let hasOverdue = overdueDayCount > 0
overdueCard.isHidden = !hasOverdue
if hasOverdue {
overdueCard.apply(
title: "存在逾期未补缴",
detail: "\(overdueDayCount) 个营业日,共 \(overdueRecordCount) 笔,待补缴 \(OfflineCollectionMoney.display(overdueAmountFen))",
action: "立即处理",
image: "exclamationmark.circle.fill",
tint: AppColor.danger,
background: UIColor(hex: 0xFFF1F0)
)
}
let settled = today.totalCount > 0 && today.pendingCount == 0
todayCard.apply(
title: "今日待补缴 \(OfflineCollectionMoney.display(today.pendingAmountFen))",
detail: "\(today.pendingCount) 笔 · 今日已登记 \(OfflineCollectionMoney.display(today.totalAmountFen))\(settled ? " · 已结清" : "")",
action: today.pendingCount > 0 ? "去补缴" : "查看明细",
image: "calendar",
tint: today.pendingCount > 0 ? UIColor(hex: 0xD97706) : AppColor.primary,
background: .white
)
}
@objc private func retryTapped() { onRetry?() }
@objc private func registerTapped() { onRegister?() }
@objc private func todayTapped() { onOpenToday?() }
@objc private func overdueTapped() { onOpenOverdue?() }
}
/// 线下收款首页区域的统一可点击卡片。
private final class OfflineCollectionHomeCard: UIControl {
private let iconView = UIImageView()
private let titleLabel = UILabel()
private let detailLabel = UILabel()
private let actionLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
layer.cornerRadius = AppRadius.lg
clipsToBounds = true
isAccessibilityElement = true
iconView.contentMode = .scaleAspectFit
titleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
titleLabel.textColor = AppColor.textPrimary
detailLabel.font = .systemFont(ofSize: 13)
detailLabel.textColor = AppColor.textSecondary
detailLabel.numberOfLines = 0
actionLabel.font = .systemFont(ofSize: 13, weight: .semibold)
actionLabel.textColor = AppColor.primary
actionLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
let textStack = UIStackView(arrangedSubviews: [titleLabel, detailLabel])
textStack.axis = .vertical
textStack.spacing = 5
[iconView, titleLabel, detailLabel, actionLabel, textStack].forEach {
$0.isUserInteractionEnabled = false
}
addSubview(iconView)
addSubview(textStack)
addSubview(actionLabel)
iconView.snp.makeConstraints { make in make.leading.equalToSuperview().inset(AppSpacing.md); make.centerY.equalToSuperview(); make.width.height.equalTo(30) }
textStack.snp.makeConstraints { make in make.leading.equalTo(iconView.snp.trailing).offset(AppSpacing.sm); make.top.bottom.equalToSuperview().inset(AppSpacing.md); make.trailing.lessThanOrEqualTo(actionLabel.snp.leading).offset(-AppSpacing.sm) }
actionLabel.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(AppSpacing.md); make.centerY.equalToSuperview() }
snp.makeConstraints { make in make.height.greaterThanOrEqualTo(86) }
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
guard isEnabled,
isUserInteractionEnabled,
!isHidden,
alpha > 0.01,
self.point(inside: point, with: event) else { return nil }
return self
}
override var isHighlighted: Bool {
didSet {
UIView.animate(withDuration: 0.12) {
self.alpha = self.isHighlighted ? 0.78 : 1
}
}
}
func apply(title: String, detail: String, action: String, image: String, tint: UIColor, background: UIColor) {
titleLabel.text = title
detailLabel.text = detail
actionLabel.text = "\(action) ›"
iconView.image = UIImage(systemName: image)
iconView.tintColor = tint
backgroundColor = background
accessibilityLabel = "\(title),\(detail),\(action)"
accessibilityTraits = .button
}
}
@@ -12,6 +12,11 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
private let viewModel = PaymentCollectionDetailsViewModel()
private let paymentAPI = NetworkServices.shared.paymentAPI
private let offlineCollectionAPI = NetworkServices.shared.offlineCollectionAPI
private lazy var offlineCollectionViewModel = OfflineCollectionHomeViewModel(
context: .current(),
api: offlineCollectionAPI
)
private let scrollView = UIScrollView()
private let contentContainerView = UIView()
@@ -48,10 +53,12 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
private let voiceCardView = UIView()
private let voiceTitleLabel = UILabel()
private let voiceSwitch = UISwitch()
private let offlineCollectionView = OfflineCollectionHomeView()
private var amountDialog: PaymentSetAmountDialogView?
private var appliedBrandConfig: PayPageConfig?
private var appliedBrandingRefreshVersion = -1
private var isShowingOfflineCollectionLoading = false
private var previousStandardAppearance: UINavigationBarAppearance?
private var previousScrollEdgeAppearance: UINavigationBarAppearance?
@@ -101,8 +108,8 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
let usesBranding = viewModel.usesNalatiBranding
view.backgroundColor = usesBranding ? .clear : AppColor.pageBackground
scrollView.showsVerticalScrollIndicator = !usesBranding
scrollView.isScrollEnabled = !usesBranding
scrollView.alwaysBounceVertical = false
scrollView.isScrollEnabled = true
scrollView.alwaysBounceVertical = true
contentStack.axis = .vertical
contentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.md
@@ -201,6 +208,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
contentStack.addArrangedSubview(recordRow)
contentStack.addArrangedSubview(voiceCardView)
}
contentStack.addArrangedSubview(offlineCollectionView)
let qrDisplayView = usesBranding ? qrContainerView : qrImageView
let qrContentStack = UIStackView(arrangedSubviews: [
@@ -330,15 +338,14 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
make.edges.equalToSuperview()
make.width.equalTo(scrollView.snp.width)
if viewModel.usesNalatiBranding {
make.height.equalTo(scrollView.snp.height)
make.height.greaterThanOrEqualTo(scrollView.snp.height)
}
}
contentStack.snp.makeConstraints { make in
make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2)
if viewModel.usesNalatiBranding {
make.centerX.centerY.equalToSuperview()
make.top.greaterThanOrEqualToSuperview()
make.bottom.lessThanOrEqualToSuperview()
make.centerX.equalToSuperview()
make.top.bottom.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
} else {
make.edges.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
}
@@ -358,6 +365,22 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside)
recordRow.addTarget(self, action: #selector(recordTapped), for: .touchUpInside)
voiceSwitch.addTarget(self, action: #selector(voiceSwitchChanged), for: .valueChanged)
offlineCollectionViewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyOfflineCollection() }
}
offlineCollectionView.onRetry = { [weak self] in
Task { await self?.offlineCollectionViewModel.load() }
}
offlineCollectionView.onRegister = { [weak self] in self?.openOfflineRegistration() }
offlineCollectionView.onOpenToday = { [weak self] in
guard let self, let date = self.offlineCollectionViewModel.todayBusinessDate else { return }
self.openOfflineDaily(date: date)
}
offlineCollectionView.onOpenOverdue = { [weak self] in
guard let self, let date = self.offlineCollectionViewModel.earliestOverdueBusinessDate else { return }
self.openOfflineDaily(date: date)
}
}
override func viewDidLoad() {
@@ -369,10 +392,13 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
applyBrandNavigationAppearanceIfNeeded()
applyOfflineCollection()
Task { await offlineCollectionViewModel.load() }
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
setOfflineCollectionLoadingVisible(false)
restoreNavigationAppearanceIfNeeded()
}
@@ -432,6 +458,26 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
amountDialog?.dismiss()
amountDialog = nil
}
applyOfflineCollection()
}
@MainActor
private func applyOfflineCollection() {
setOfflineCollectionLoadingVisible(offlineCollectionViewModel.isLoading)
offlineCollectionView.apply(
today: offlineCollectionViewModel.todaySummary,
overdueDayCount: offlineCollectionViewModel.overdueDates.count,
overdueRecordCount: offlineCollectionViewModel.overdueRecordCount,
overdueAmountFen: offlineCollectionViewModel.overdueAmountFen,
errorMessage: offlineCollectionViewModel.errorMessage
)
}
@MainActor
private func setOfflineCollectionLoadingVisible(_ visible: Bool) {
guard visible != isShowingOfflineCollectionLoading else { return }
isShowingOfflineCollectionLoading = visible
visible ? showLoading() : hideLoading()
}
private func setActionRowAvailable(_ isAvailable: Bool) {
@@ -668,6 +714,23 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
@objc private func voiceSwitchChanged() {
viewModel.toggleReceiveVoice()
}
private func openOfflineRegistration() {
let controller = OfflineCollectionRegistrationViewController(
context: offlineCollectionViewModel.context,
api: offlineCollectionAPI
)
navigationController?.pushViewController(controller, animated: true)
}
private func openOfflineDaily(date: String) {
let controller = OfflineCollectionDailyViewController(
businessDate: date,
context: offlineCollectionViewModel.context,
api: offlineCollectionAPI
)
navigationController?.pushViewController(controller, animated: true)
}
}
/// 收款详情信息行。

Some files were not shown because too many files have changed in this diff Show More