16 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
100 changed files with 13750 additions and 291 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

+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; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements; CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1020101; CURRENT_PROJECT_VERSION = 1040101;
DEVELOPMENT_TEAM = 56GVN5RNVN; DEVELOPMENT_TEAM = 56GVN5RNVN;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
@@ -460,7 +460,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.2.1; MARKETING_VERSION = 1.4.1;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
@@ -502,7 +502,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements; CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1020101; CURRENT_PROJECT_VERSION = 1040101;
DEVELOPMENT_TEAM = 56GVN5RNVN; DEVELOPMENT_TEAM = 56GVN5RNVN;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
@@ -526,7 +526,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.2.1; MARKETING_VERSION = 1.4.1;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
@@ -741,7 +741,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements; CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1020101; CURRENT_PROJECT_VERSION = 1040101;
DEVELOPMENT_TEAM = 56GVN5RNVN; DEVELOPMENT_TEAM = 56GVN5RNVN;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -761,7 +761,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.2.1; MARKETING_VERSION = 1.4.1;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
+3 -1
View File
@@ -6,6 +6,7 @@
import UIKit import UIKit
/// 应用根页面路由。 /// 应用根页面路由。
@MainActor
enum AppRouter { enum AppRouter {
enum Root { 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 let window else { return }
guard animated, let snapshot = window.snapshotView(afterScreenUpdates: true) else { guard animated, let snapshot = window.snapshotView(afterScreenUpdates: true) else {
+3
View File
@@ -17,6 +17,7 @@ final class NetworkServices {
let orderAPI: OrderAPI let orderAPI: OrderAPI
let homeAPI: HomeAPI let homeAPI: HomeAPI
let paymentAPI: PaymentAPI let paymentAPI: PaymentAPI
let offlineCollectionAPI: OfflineCollectionAPI
let taskAPI: TaskAPI let taskAPI: TaskAPI
let inviteAPI: InviteAPI let inviteAPI: InviteAPI
let walletAPI: WalletAPI let walletAPI: WalletAPI
@@ -43,6 +44,7 @@ final class NetworkServices {
orderAPI = OrderAPI(client: client) orderAPI = OrderAPI(client: client)
homeAPI = HomeAPI(client: client) homeAPI = HomeAPI(client: client)
paymentAPI = PaymentAPI(client: client) paymentAPI = PaymentAPI(client: client)
offlineCollectionAPI = OfflineCollectionAPI(client: client)
taskAPI = TaskAPI(client: client) taskAPI = TaskAPI(client: client)
inviteAPI = InviteAPI(client: client) inviteAPI = InviteAPI(client: client)
walletAPI = WalletAPI(client: client) walletAPI = WalletAPI(client: client)
@@ -74,6 +76,7 @@ final class NetworkServices {
orderAPI = OrderAPI(client: apiClient) orderAPI = OrderAPI(client: apiClient)
homeAPI = HomeAPI(client: apiClient) homeAPI = HomeAPI(client: apiClient)
paymentAPI = PaymentAPI(client: apiClient) paymentAPI = PaymentAPI(client: apiClient)
offlineCollectionAPI = OfflineCollectionAPI(client: apiClient)
taskAPI = TaskAPI(client: apiClient) taskAPI = TaskAPI(client: apiClient)
inviteAPI = InviteAPI(client: apiClient) inviteAPI = InviteAPI(client: apiClient)
walletAPI = WalletAPI(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 { if AppStore.shared.session.privacyAgreementAccepted, !AppStore.shared.session.token.isEmpty {
AMapBootstrap.configureIfNeeded() AMapBootstrap.configureIfNeeded()
} }
PushNotificationManager.shared.initializeIfPrivacyAccepted(launchOptions: launchOptions) let pushManager = PushNotificationManager.shared
pushManager.initializeIfPrivacyAccepted(launchOptions: launchOptions)
return true 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

@@ -4,6 +4,14 @@
"filename" : "ai_retouch_template_placeholder.png", "filename" : "ai_retouch_template_placeholder.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "1x" "scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info" : { "info" : {
+15
View File
@@ -29,6 +29,12 @@ enum NotificationName {
/// Token 失效或鉴权失败,需重新登录 /// Token 失效或鉴权失败,需重新登录
static let sessionDidExpire = name("sessionDidExpire") static let sessionDidExpire = name("sessionDidExpire")
/// 当前门店身份受限或申请结果待核实;保留凭证转入只读状态查询。
static let storeAccountDeregistrationRestricted = name("storeAccountDeregistrationRestricted")
/// 当前门店身份的注销申请已被服务端明确受理;先清理登录态,再展示提交结果页。
static let storeAccountDeregistrationSubmitted = name("storeAccountDeregistrationSubmitted")
// MARK: - Scenic // MARK: - Scenic
/// 当前景区切换 /// 当前景区切换
@@ -68,6 +74,15 @@ enum NotificationName {
/// `Notification.userInfo` 字典键的统一入口。 /// `Notification.userInfo` 字典键的统一入口。
enum NotificationUserInfoKey { enum NotificationUserInfoKey {
/// 产生注销限制错误的原请求凭证,仅在内存中匹配当前会话,禁止记录日志。
static let deregistrationRequestToken = "deregistrationRequestToken"
/// 注销申请提交时冻结的身份展示名称,不包含身份凭证。
static let deregistrationIdentityName = "deregistrationIdentityName"
/// 注销申请明确受理后查询到的服务端冷静期截止时间。
static let deregistrationCoolingUntil = "deregistrationCoolingUntil"
static let scenicId = "scenicId" static let scenicId = "scenicId"
static let scenicName = "scenicName" static let scenicName = "scenicName"
static let orderId = "orderId" static let orderId = "orderId"
+28 -13
View File
@@ -19,6 +19,7 @@ final class APIClient {
private let session: URLSessionProtocol private let session: URLSessionProtocol
private let encoder: JSONEncoder private let encoder: JSONEncoder
private let decoder: JSONDecoder private let decoder: JSONDecoder
private let notificationCenter: NotificationCenter
private var authTokenProvider: (() -> String?)? private var authTokenProvider: (() -> String?)?
private let environment: APIEnvironment private let environment: APIEnvironment
@@ -32,12 +33,14 @@ final class APIClient {
encoder: JSONEncoder = JSONEncoder(), encoder: JSONEncoder = JSONEncoder(),
decoder: JSONDecoder = JSONDecoder(), decoder: JSONDecoder = JSONDecoder(),
appVersion: String = AppClientInfo.appVersion(), appVersion: String = AppClientInfo.appVersion(),
osType: String = AppClientInfo.osType osType: String = AppClientInfo.osType,
notificationCenter: NotificationCenter = .default
) { ) {
self.environment = environment self.environment = environment
self.session = session self.session = session
self.encoder = encoder self.encoder = encoder
self.decoder = decoder self.decoder = decoder
self.notificationCenter = notificationCenter
self.appVersion = appVersion.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "1.0.0" self.appVersion = appVersion.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "1.0.0"
self.osType = osType.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? AppClientInfo.osType self.osType = osType.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? AppClientInfo.osType
} }
@@ -61,7 +64,7 @@ final class APIClient {
tokenOverride: String? = nil tokenOverride: String? = nil
) async throws -> Response { ) async throws -> Response {
let request = try makeURLRequest(apiRequest, tokenOverride: tokenOverride) let request = try makeURLRequest(apiRequest, tokenOverride: tokenOverride)
logRequest(request) logRequest(request, includeBody: apiRequest.logsPayload)
let data: Data let data: Data
let response: URLResponse let response: URLResponse
@@ -80,12 +83,12 @@ final class APIClient {
throw APIError.networkFailed(error.localizedDescription) throw APIError.networkFailed(error.localizedDescription)
} }
logResponse(for: request, response: response, data: data) logResponse(for: request, response: response, data: data, includeBody: apiRequest.logsPayload)
do { do {
try validateHTTPResponse(response, data: data) try validateHTTPResponse(response, data: data)
return try decodeEnvelope(Response.self, from: data) return try decodeEnvelope(Response.self, from: data)
} catch let error as APIError { } catch let error as APIError {
notifySessionExpiredIfNeeded(for: error) notifySessionErrorIfNeeded(for: error, request: request)
throw error throw error
} }
} }
@@ -141,7 +144,7 @@ final class APIClient {
try validateHTTPResponse(response, data: data) try validateHTTPResponse(response, data: data)
return try decodeEnvelope(Response.self, from: data) return try decodeEnvelope(Response.self, from: data)
} catch let error as APIError { } catch let error as APIError {
notifySessionExpiredIfNeeded(for: error) notifySessionErrorIfNeeded(for: error, request: request)
throw error throw error
} }
} }
@@ -212,7 +215,7 @@ final class APIClient {
try validateHTTPResponse(response, data: responseData) try validateHTTPResponse(response, data: responseData)
return try decodeEnvelope(Response.self, from: responseData) return try decodeEnvelope(Response.self, from: responseData)
} catch let error as APIError { } catch let error as APIError {
notifySessionExpiredIfNeeded(for: error) notifySessionErrorIfNeeded(for: error, request: request)
throw error throw error
} }
} }
@@ -267,6 +270,9 @@ final class APIClient {
} }
guard 200 ..< 300 ~= httpResponse.statusCode else { 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)) throw APIError.httpStatus(httpResponse.statusCode, parseHTTPErrorMessage(data: data))
} }
} }
@@ -295,10 +301,19 @@ final class APIClient {
return payload return payload
} }
/// Token 失效时广播 sessionDidExpire,触发全局登出。 /// 区分身份注销受限与凭证失效;150015 附带原请求 Token,便于忽略切换身份后的旧错误。
private func notifySessionExpiredIfNeeded(for error: APIError) { 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 } guard APIError.isAuthenticationExpired(error) else { return }
NotificationCenter.default.post(name: NotificationName.sessionDidExpire, object: nil) notificationCenter.post(name: NotificationName.sessionDidExpire, object: nil)
} }
/// 从 HTTP 错误响应中提取更适合展示给用户的错误信息。 /// 从 HTTP 错误响应中提取更适合展示给用户的错误信息。
@@ -341,7 +356,7 @@ final class APIClient {
} }
/// 在 Debug 环境打印请求信息(含 GET 查询参数与 POST 请求体,对齐 Android Ktor `LogLevel.BODY`)。 /// 在 Debug 环境打印请求信息(含 GET 查询参数与 POST 请求体,对齐 Android Ktor `LogLevel.BODY`)。
private func logRequest(_ request: URLRequest) { private func logRequest(_ request: URLRequest, includeBody: Bool = true) {
#if DEBUG #if DEBUG
let method = request.httpMethod ?? "REQUEST" let method = request.httpMethod ?? "REQUEST"
let url = request.url?.absoluteString ?? "<invalid url>" let url = request.url?.absoluteString ?? "<invalid url>"
@@ -359,7 +374,7 @@ final class APIClient {
} }
let contentType = request.value(forHTTPHeaderField: "Content-Type") 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)") lines.append("body:\n\(body)")
} }
@@ -368,12 +383,12 @@ final class APIClient {
} }
/// 在 Debug 环境打印响应状态和响应体。 /// 在 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 #if DEBUG
let method = request.httpMethod ?? "REQUEST" let method = request.httpMethod ?? "REQUEST"
let url = request.url?.absoluteString ?? "<invalid url>" let url = request.url?.absoluteString ?? "<invalid url>"
let statusCode = (response as? HTTPURLResponse).map { String($0.statusCode) } ?? "unknown" 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)") print("[API][Response] \(method) \(url) status=\(statusCode)\n\(body)")
#endif #endif
} }
+5 -1
View File
@@ -20,6 +20,8 @@ nonisolated struct APIRequest<Response: Decodable> {
var queryItems: [URLQueryItem] var queryItems: [URLQueryItem]
var headers: [String: String] var headers: [String: String]
var body: AnyEncodable? var body: AnyEncodable?
/// 敏感请求关闭正文日志,避免验证码和注销资产进入调试日志。
var logsPayload: Bool
/// 创建一个 API 请求,并把可编码请求体擦除为统一的 AnyEncodable。 /// 创建一个 API 请求,并把可编码请求体擦除为统一的 AnyEncodable。
init<Body: Encodable>( init<Body: Encodable>(
@@ -27,13 +29,15 @@ nonisolated struct APIRequest<Response: Decodable> {
path: String, path: String,
queryItems: [URLQueryItem] = [], queryItems: [URLQueryItem] = [],
headers: [String: String] = [:], headers: [String: String] = [:],
body: Body? = Optional<EmptyPayload>.none body: Body? = Optional<EmptyPayload>.none,
logsPayload: Bool = true
) { ) {
self.method = method self.method = method
self.path = path self.path = path
self.queryItems = queryItems self.queryItems = queryItems
self.headers = headers self.headers = headers
self.body = body.map(AnyEncodable.init) self.body = body.map(AnyEncodable.init)
self.logsPayload = logsPayload
} }
} }
@@ -116,6 +116,8 @@ final class PushNotificationManager: NSObject {
private var isInitialized = false private var isInitialized = false
private var didRequestAuthorization = false private var didRequestAuthorization = false
private var uploadTask: Task<Void, Never>? private var uploadTask: Task<Void, Never>?
private var uploadAttemptID: UUID?
private var isAccountBindingSuspended = false
private var queuedForcedUpload = false private var queuedForcedUpload = false
private var isFetchingRegistrationID = false private var isFetchingRegistrationID = false
private var queuedForcedFetch = false private var queuedForcedFetch = false
@@ -161,7 +163,7 @@ final class PushNotificationManager: NSObject {
/// 登录成功后请求通知权限,并强制绑定当前账号。 /// 登录成功后请求通知权限,并强制绑定当前账号。
func handleLoginCompleted() { func handleLoginCompleted() {
initializeIfPrivacyAccepted() initializeIfPrivacyAccepted()
guard isInitialized, appStore.session.isLoggedIn else { return } guard !isAccountBindingSuspended, isInitialized, appStore.session.isLoggedIn else { return }
if !didRequestAuthorization { if !didRequestAuthorization {
didRequestAuthorization = true didRequestAuthorization = true
sdk.requestAuthorization(delegate: self) sdk.requestAuthorization(delegate: self)
@@ -171,7 +173,7 @@ final class PushNotificationManager: NSObject {
/// 账号切换后把同一设备重新绑定到新的业务账号。 /// 账号切换后把同一设备重新绑定到新的业务账号。
func handleAccountSwitched() { func handleAccountSwitched() {
guard appStore.session.isLoggedIn else { return } guard !isAccountBindingSuspended, appStore.session.isLoggedIn else { return }
bindCurrentAccount() bindCurrentAccount()
} }
@@ -179,11 +181,22 @@ final class PushNotificationManager: NSObject {
func handleLogout() { func handleLogout() {
uploadTask?.cancel() uploadTask?.cancel()
uploadTask = nil uploadTask = nil
uploadAttemptID = nil
queuedForcedUpload = false queuedForcedUpload = false
router.resetPendingRoute() router.resetPendingRoute()
Task { await updateApplicationIconBadgeCount(0) } 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 角标更新为最新未读消息数量。 /// 将桌面 App Icon 角标更新为最新未读消息数量。
func updateApplicationIconBadgeCount(_ count: Int) async { func updateApplicationIconBadgeCount(_ count: Int) async {
await applicationIconBadgeSetter.setBadgeCount(max(count, 0)) await applicationIconBadgeSetter.setBadgeCount(max(count, 0))
@@ -191,13 +204,14 @@ final class PushNotificationManager: NSObject {
/// App 回到前台时补偿失败或尚未完成的 Registration ID 上报。 /// App 回到前台时补偿失败或尚未完成的 Registration ID 上报。
func retryPendingRegistrationUpload() { func retryPendingRegistrationUpload() {
guard appStore.session.isLoggedIn else { return } guard !isAccountBindingSuspended, appStore.session.isLoggedIn else { return }
uploadCachedRegistrationID(force: false) uploadCachedRegistrationID(force: false)
refreshRegistrationID(forceUpload: false) refreshRegistrationID(forceUpload: false)
} }
/// 登录根页面建立后继续执行通知点击暂存的路由。 /// 登录根页面建立后继续执行通知点击暂存的路由。
func routePendingNotificationIfPossible() { func routePendingNotificationIfPossible() {
guard !isAccountBindingSuspended else { return }
router.routePendingIfPossible() router.routePendingIfPossible()
} }
@@ -323,7 +337,7 @@ final class PushNotificationManager: NSObject {
} }
private func upload(registrationID: String, force: Bool) { private func upload(registrationID: String, force: Bool) {
guard appStore.session.isLoggedIn, guard !isAccountBindingSuspended, appStore.session.isLoggedIn,
let uploadedKey = appStore.session.accountScopedKey(Key.uploadedRegistrationIDSuffix) let uploadedKey = appStore.session.accountScopedKey(Key.uploadedRegistrationIDSuffix)
else { return } else { return }
@@ -336,12 +350,15 @@ final class PushNotificationManager: NSObject {
} }
let accountScope = appStore.session.accountCachePrefix let accountScope = appStore.session.accountCachePrefix
let attemptID = UUID()
uploadAttemptID = attemptID
uploadTask = Task { [weak self] in uploadTask = Task { [weak self] in
guard let self else { return } guard let self else { return }
var succeeded = false var succeeded = false
do { do {
try await self.api.registerJPushID(registrationID) 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) self.defaults.set(registrationID, forKey: uploadedKey)
} }
succeeded = true succeeded = true
@@ -353,7 +370,9 @@ final class PushNotificationManager: NSObject {
#endif #endif
} }
guard self.uploadAttemptID == attemptID else { return }
self.uploadTask = nil self.uploadTask = nil
self.uploadAttemptID = nil
let shouldForceAgain = self.queuedForcedUpload let shouldForceAgain = self.queuedForcedUpload
self.queuedForcedUpload = false self.queuedForcedUpload = false
if succeeded, shouldForceAgain { 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 { enum LoginResolution {
case completed(V9AuthResponse, AccountSwitchAccount) case completed(V9AuthResponse, AccountSwitchAccount)
case needsAccountSelection(AccountSelectionPayload) case needsAccountSelection(AccountSelectionPayload)
case needsDeregistrationConfirmation(DeregistrationLoginConfirmation)
} }
/// 登录流程错误实体,表示 token、账号列表或账号 ID 异常。 /// 登录流程错误实体,表示 token、账号列表或账号 ID 异常。
@@ -124,8 +125,40 @@ struct AccountSwitchAccount: Identifiable, Hashable {
let storeId: Int? let storeId: Int?
let storeName: String let storeName: String
let scenicId: Int? let scenicId: Int?
let status: Int
let isCurrent: Bool 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 { var id: String {
"\(accountType)_\(businessUserId)" "\(accountType)_\(businessUserId)"
} }
@@ -134,6 +167,11 @@ struct AccountSwitchAccount: Identifiable, Hashable {
accountType == V9StoreUser.accountTypeValue accountType == V9StoreUser.accountTypeValue
} }
/// `status == 2` 表示门店身份已提交注销申请,登录前需要用户明确确认撤销。
var requiresDeregistrationConfirmation: Bool {
isStoreUser && status == 2
}
var accountTypeLabel: String { var accountTypeLabel: String {
isStoreUser ? "门店账号" : "景区账号" isStoreUser ? "门店账号" : "景区账号"
} }
@@ -146,6 +184,12 @@ struct AccountSwitchAccount: Identifiable, Hashable {
} }
} }
/// 冷静期门店身份的待确认登录信息;确认后继续调用 `set-user`,由后端自动撤销注销申请。
struct DeregistrationLoginConfirmation: Equatable {
let tempToken: String
let account: AccountSwitchAccount
}
/// 登录账号选择载荷,保存临时 token 和待用户选择的账号列表。 /// 登录账号选择载荷,保存临时 token 和待用户选择的账号列表。
struct AccountSelectionPayload: Equatable, Identifiable { struct AccountSelectionPayload: Equatable, Identifiable {
let id = UUID() let id = UUID()
@@ -285,6 +329,7 @@ struct V9StoreUser: Decodable, Equatable {
let roleName: String let roleName: String
let appRoleCode: String let appRoleCode: String
let appRoleName: String let appRoleName: String
let status: Int
let isCurrent: Bool let isCurrent: Bool
var businessUserId: Int { var businessUserId: Int {
@@ -308,6 +353,7 @@ struct V9StoreUser: Decodable, Equatable {
storeId: storeId > 0 ? storeId : nil, storeId: storeId > 0 ? storeId : nil,
storeName: storeName, storeName: storeName,
scenicId: scenicId > 0 ? scenicId : nil, scenicId: scenicId > 0 ? scenicId : nil,
status: status,
isCurrent: isCurrent isCurrent: isCurrent
) )
} }
@@ -327,6 +373,7 @@ struct V9StoreUser: Decodable, Equatable {
case roleName = "role_name" case roleName = "role_name"
case appRoleCode = "app_role_code" case appRoleCode = "app_role_code"
case appRoleName = "app_role_name" case appRoleName = "app_role_name"
case status
case isCurrent = "is_current" case isCurrent = "is_current"
} }
@@ -346,6 +393,7 @@ struct V9StoreUser: Decodable, Equatable {
roleName = try container.decodeLossyString(forKey: .roleName) roleName = try container.decodeLossyString(forKey: .roleName)
appRoleCode = try container.decodeLossyString(forKey: .appRoleCode) appRoleCode = try container.decodeLossyString(forKey: .appRoleCode)
appRoleName = try container.decodeLossyString(forKey: .appRoleName) appRoleName = try container.decodeLossyString(forKey: .appRoleName)
status = try container.decodeLossyInt(forKey: .status) ?? 1
isCurrent = try container.decodeLossyBool(forKey: .isCurrent) ?? false isCurrent = try container.decodeLossyBool(forKey: .isCurrent) ?? false
} }
} }
@@ -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?()
}
}
@@ -86,10 +86,10 @@ GET /api/yf-handset-app/photog/travel-album/ai-retouch-options?user_equity_trave
| `scope` | 使用场景 | | `scope` | 使用场景 |
|---|---| |---|---|
| `batch` | 网格多选 AI 修图 | | `batch` | 网格多选 AI 修图,或预览页尚无 AI 结果 Tab 时首次修图 |
| `all_variants` | 预览页无 Tab,或当前是原图 Tab | | `all_variants` | 预览页已有 AI 结果 Tab;当前为原图、精修后或氛围感均使用此范围 |
| `refined_only` | 当前是精修后 Tab | | `refined_only` | 仅重修精修结果的接口能力,当前客户端无独立入口 |
| `atmosphere_only` | 当前是氛围感 Tab | | `atmosphere_only` | 仅重修氛围感结果的接口能力,当前客户端无独立入口 |
`user_equity_travel_id`、`scope` 和 `source_count` 均必填。`source_count` 用于计算封面模板是否显示和必选。 `user_equity_travel_id`、`scope` 和 `source_count` 均必填。`source_count` 用于计算封面模板是否显示和必选。
@@ -226,7 +226,7 @@ Content-Type: application/json
} }
``` ```
#### 5.4.2 预览页原图 Tab #### 5.4.2 预览页已有 AI 结果 Tab
```json ```json
{ {
@@ -234,10 +234,6 @@ Content-Type: application/json
"scope": "all_variants", "scope": "all_variants",
"source_material_ids": ["2031"], "source_material_ids": ["2031"],
"outputs": [ "outputs": [
{
"type": "refined",
"template_id": "tpl_refined_15"
},
{ {
"type": "atmosphere", "type": "atmosphere",
"template_id": "tpl_atmosphere_09" "template_id": "tpl_atmosphere_09"
@@ -246,7 +242,7 @@ Content-Type: application/json
} }
``` ```
如用户未选氛围感模板,`outputs` 中不传 `atmosphere`。缺失表示“本次不处理”,不表示删除现有氛围感图。 已有精修或氛围感结果时,无论当前位于原图、精修后还是氛围感 Tab,精修和氛围感模板均为选填,但至少选择一种。`outputs` 只传本次选中的结果类型;缺失表示“本次不处理”,不表示删除或覆盖对应的已有结果。
#### 5.4.3 只重新精修 #### 5.4.3 只重新精修
@@ -285,7 +281,7 @@ Content-Type: application/json
| `scope` | 原图数 | 精修 | 氛围感 | 封面 | | `scope` | 原图数 | 精修 | 氛围感 | 封面 |
|---|---:|---|---|---| |---|---:|---|---|---|
| `batch` | 1 至 50 | 必选 | 可选 | 少于 4 张禁止;4 张及以上必选 | | `batch` | 1 至 50 | 必选 | 可选 | 少于 4 张禁止;4 张及以上必选 |
| `all_variants` | 必须为 1 | 必选 | 可选 | 禁止 | | `all_variants` | 必须为 1 | 可选(与氛围感至少一项) | 可选(与精修至少一项) | 禁止 |
| `refined_only` | 必须为 1 | 必选 | 禁止 | 禁止 | | `refined_only` | 必须为 1 | 必选 | 禁止 | 禁止 |
| `atmosphere_only` | 必须为 1 | 禁止 | 必选 | 禁止 | | `atmosphere_only` | 必须为 1 | 禁止 | 必选 | 禁止 |
@@ -114,9 +114,9 @@ Cell 左上角展示稳定的修图状态:
| 当前情况 | 模板要求 | 生成与覆盖规则 | | 当前情况 | 模板要求 | 生成与覆盖规则 |
|---|---|---| |---|---|---|
| 不显示 Tab | 精修必选,氛围感可选 | 首次生成关联的精修图,可选生成氛围感图 | | 不显示 Tab | 精修必选,氛围感可选 | 首次生成关联的精修图,可选生成氛围感图 |
| 已显示 Tab,当前为“原图” | 精修必选,氛围感可选 | 重新生成精修图;如选氛围感则也重新生成。新结果成功后原子替换对应旧结果 | | 已显示 Tab,当前为“原图” | 精修、氛围感均可选,至少选择一种 | 只重新生成本次选中的结果类型;新结果成功后替换对应旧结果,未选类型保持不变 |
| 已显示 Tab,当前为“精修后” | 精修必选 | 只重新生成精修图,成功后覆盖旧精修图,不影响氛围感图 | | 已显示 Tab,当前为“精修后” | 精修、氛围感均可选,至少选择一种 | 只重新生成本次选中的结果类型;新结果成功后替换对应旧结果,未选类型保持不变 |
| 已显示 Tab,当前为“氛围感” | 氛围感必选 | 只重新生成氛围感图,成功后覆盖旧氛围感图,不影响精修图 | | 已显示 Tab,当前为“氛围感” | 精修、氛围感均可选,至少选择一种 | 只重新生成本次选中的结果类型;新结果成功后替换对应旧结果,未选类型保持不变 |
重新修图期间应保留上一版成功图片可见;只有新结果成功时才原子替换当前版本。失败时继续保留旧版本,并返回可展示的错误信息。 重新修图期间应保留上一版成功图片可见;只有新结果成功时才原子替换当前版本。失败时继续保留旧版本,并返回可展示的错误信息。
@@ -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,6 +50,11 @@ protocol TravelAlbumServing {
/// 拉取当前景区可用的 AI 修图模板。 /// 拉取当前景区可用的 AI 修图模板。
func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse
/// 更新相册级自动 AI 修图配置。
func updateAutoRetouchConfiguration(
_ request: TravelAlbumAutoRetouchConfigurationRequest
) async throws -> TravelAlbumAutoRetouchConfiguration
/// 提交相册素材 AI 修图任务。 /// 提交相册素材 AI 修图任务。
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission
@@ -207,6 +212,15 @@ final class TravelAlbumAPI: TravelAlbumServing {
) )
} }
/// 更新自动修图配置并返回服务端最新版本。
func updateAutoRetouchConfiguration(
_ request: TravelAlbumAutoRetouchConfigurationRequest
) async throws -> TravelAlbumAutoRetouchConfiguration {
try await client.send(
APIRequest(method: .post, path: "\(basePath)/auto-retouch-config", body: request)
)
}
/// 提交相册素材 AI 修图任务并返回任务摘要。 /// 提交相册素材 AI 修图任务并返回任务摘要。
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission { func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission {
try await client.send(APIRequest(method: .post, path: "\(basePath)/ai-retouch", body: request)) try await client.send(APIRequest(method: .post, path: "\(basePath)/ai-retouch", body: request))
@@ -5,6 +5,36 @@
import Foundation 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`。 /// 旅拍相册用户信息,对齐 Android `TravelAlbumUserEntity`。
struct TravelAlbumUser: Decodable, Sendable, Equatable, Hashable { struct TravelAlbumUser: Decodable, Sendable, Equatable, Hashable {
let id: Int let id: Int
@@ -33,6 +63,7 @@ struct TravelAlbum: Decodable, Sendable, Equatable, Hashable, Identifiable {
let createdAt: String let createdAt: String
let updatedAt: String let updatedAt: String
let user: TravelAlbumUser? let user: TravelAlbumUser?
let autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id case id
@@ -50,6 +81,31 @@ struct TravelAlbum: Decodable, Sendable, Equatable, Hashable, Identifiable {
case createdAt = "created_at" case createdAt = "created_at"
case updatedAt = "updated_at" case updatedAt = "updated_at"
case user 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( init(
@@ -67,7 +123,8 @@ struct TravelAlbum: Decodable, Sendable, Equatable, Hashable, Identifiable {
status: Int = 0, status: Int = 0,
createdAt: String = "", createdAt: String = "",
updatedAt: String = "", updatedAt: String = "",
user: TravelAlbumUser? = nil user: TravelAlbumUser? = nil,
autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled
) { ) {
self.id = id self.id = id
self.storeUserId = storeUserId self.storeUserId = storeUserId
@@ -84,6 +141,7 @@ struct TravelAlbum: Decodable, Sendable, Equatable, Hashable, Identifiable {
self.createdAt = createdAt self.createdAt = createdAt
self.updatedAt = updatedAt self.updatedAt = updatedAt
self.user = user self.user = user
self.autoRetouchConfiguration = autoRetouchConfiguration
} }
/// 展示用手机号。 /// 展示用手机号。
@@ -224,7 +282,7 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
} }
} }
/// 相册素材网格角标类别,用于稳定映射文案优先级和语义颜色。 /// 相册素材网格角标类别,用于稳定映射独立业务状态和语义颜色。
enum TravelAlbumMaterialBadgeKind: Sendable, Equatable { enum TravelAlbumMaterialBadgeKind: Sendable, Equatable {
case purchased case purchased
case pending case pending
@@ -241,14 +299,15 @@ struct TravelAlbumMaterialBadgePresentation: Sendable, Equatable {
} }
extension TravelAlbumMaterial { extension TravelAlbumMaterial {
/// 按 AI 修图状态和购买状态生成网格角标;返回 nil 时隐藏角标。 /// 生成购买状态角标;购买状态与修图状态互不覆盖。
var badgePresentation: TravelAlbumMaterialBadgePresentation? { var purchaseBadgePresentation: TravelAlbumMaterialBadgePresentation? {
if aiRetouchStatus == 0 { isPurchased
return isPurchased ? TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
? TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购") : nil
: nil }
}
/// 生成 AI 修图状态角标;未进入修图流程时返回 nil。
var aiRetouchBadgePresentation: TravelAlbumMaterialBadgePresentation? {
let statusName = aiRetouchStatusName.trimmingCharacters(in: .whitespacesAndNewlines) let statusName = aiRetouchStatusName.trimmingCharacters(in: .whitespacesAndNewlines)
switch aiRetouchStatus { switch aiRetouchStatus {
case 1: case 1:
@@ -286,6 +345,7 @@ struct TravelAlbumCreateRequest: Encodable, Sendable, Equatable {
let materialPrice: Double? let materialPrice: Double?
let materialPackagePrice: Double? let materialPackagePrice: Double?
let photoPrice: Double? let photoPrice: Double?
let autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case name case name
@@ -295,6 +355,67 @@ struct TravelAlbumCreateRequest: Encodable, Sendable, Equatable {
case materialPrice = "material_price" case materialPrice = "material_price"
case materialPackagePrice = "material_package_price" case materialPackagePrice = "material_package_price"
case photoPrice = "photo_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 { struct TravelAlbumCreateResponse: Decodable, Sendable, Equatable {
let id: Int 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
}
} }
/// 旅拍相册小程序码响应。 /// 旅拍相册小程序码响应。
@@ -386,14 +529,13 @@ enum TravelAlbumAIRetouchWorkflow: Sendable, Equatable {
} }
} }
/// 当前分组是否允许不选择;首次修图和原图 Tab 覆盖重修的氛围感模板选填。 /// 当前分组是否允许不选择;首次修图仅氛围感选填,已有 AI 结果的预览重修两类模板均选填。
func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool { func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool {
guard category == .atmosphere else { return false }
switch self { switch self {
case .initial: case .initial:
return true return category == .atmosphere
case .reretouch(_, _, .all): case .reretouch(_, _, .all):
return true return category == .refined || category == .atmosphere
case .reretouch: case .reretouch:
return false return false
} }
@@ -496,6 +638,7 @@ struct TravelAlbumAIRetouchRequest: Encodable, Sendable, Equatable {
let refinedTemplateId: Int let refinedTemplateId: Int
let atmosphereTemplateId: Int? let atmosphereTemplateId: Int?
let coverTemplateId: Int? let coverTemplateId: Int?
let clientRequestId: String?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case userEquityTravelId = "user_equity_travel_id" case userEquityTravelId = "user_equity_travel_id"
@@ -503,6 +646,24 @@ struct TravelAlbumAIRetouchRequest: Encodable, Sendable, Equatable {
case refinedTemplateId = "refined_template_id" case refinedTemplateId = "refined_template_id"
case atmosphereTemplateId = "atmosphere_template_id" case atmosphereTemplateId = "atmosphere_template_id"
case coverTemplateId = "cover_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
} }
} }
@@ -175,7 +175,7 @@ struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
self.assets = assets.filter { seenKinds.insert($0.kind).inserted } self.assets = assets.filter { seenKinds.insert($0.kind).inserted }
} }
/// 根据当前 Tab 生成首次修图或覆盖重修工作流。 /// 根据是否已有 AI 结果生成首次修图或双模板覆盖重修工作流。
func aiRetouchWorkflow( func aiRetouchWorkflow(
albumId: Int, albumId: Int,
selectedKind: TravelAlbumPreviewAssetKind selectedKind: TravelAlbumPreviewAssetKind
@@ -183,16 +183,8 @@ struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
guard hasVariants else { guard hasVariants else {
return .initial(albumId: albumId, materialIds: [originalMaterialId]) return .initial(albumId: albumId, materialIds: [originalMaterialId])
} }
switch selectedKind { guard selectedKind != .cover else { return nil }
case .original: return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .all)
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
}
} }
} }
@@ -19,6 +19,13 @@ struct TravelAlbumOTGPhotoItem: Hashable, Sendable {
var errorMessage: String? var errorMessage: String?
let localPath: String let localPath: String
var remoteUrl: 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 { 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 传输页半小时维度时间槽。 /// OTG 传输页半小时维度时间槽。
struct TravelAlbumOTGTimeSlot: Hashable, Sendable { struct TravelAlbumOTGTimeSlot: Hashable, Sendable {
let id: String let id: String
@@ -85,6 +102,14 @@ enum TravelAlbumOTGTransferMode: String, CaseIterable, Sendable {
self == .liveUpload self == .liveUpload
} }
/// 模式选择 Sheet 的辅助说明。
var detailText: String {
switch self {
case .liveUpload: return "相机拍摄后,照片自动传输并上传到当前相册"
case .postTransfer: return "拍摄完成后,再选择照片批量传输"
}
}
/// 根据展示标题解析传输模式。 /// 根据展示标题解析传输模式。
static func option(title: String) -> TravelAlbumOTGTransferMode? { static func option(title: String) -> TravelAlbumOTGTransferMode? {
allCases.first { $0.title == title } allCases.first { $0.title == title }
@@ -343,7 +368,14 @@ extension TravelAlbumOTGPhotoRecord {
progress: progress, progress: progress,
errorMessage: errorMessage, errorMessage: errorMessage,
localPath: localPath, 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 albumId: Int
let userId: String let userId: String
var remoteUrl: 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 var updatedAt: Int64
/// 创建 OTG 本地照片记录。 /// 创建 OTG 本地照片记录。
@@ -74,6 +81,13 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
albumId: Int, albumId: Int,
userId: String, userId: String,
remoteUrl: 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) updatedAt: Int64 = Int64(Date().timeIntervalSince1970 * 1000)
) { ) {
self.id = id self.id = id
@@ -90,12 +104,22 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
self.albumId = albumId self.albumId = albumId
self.userId = userId self.userId = userId
self.remoteUrl = remoteUrl 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 self.updatedAt = updatedAt
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case id, sourceId, clientPhotoId, fileName, localPath, thumbnailPath, capturedAt case id, sourceId, clientPhotoId, fileName, localPath, thumbnailPath, capturedAt
case fileSizeBytes, status, progress, errorMessage, albumId, userId, remoteUrl, updatedAt case fileSizeBytes, status, progress, errorMessage, albumId, userId, remoteUrl, updatedAt
case serverMaterialId, autoRetouchState, autoRetouchTemplateId
case autoRetouchClientRequestId, autoRetouchBatchId, autoRetouchAttempt
case autoRetouchErrorMessage
} }
/// 解码本地索引;旧版本缺少 `clientPhotoId` 时先保留为空,由 Store 一次性迁移并回写。 /// 解码本地索引;旧版本缺少 `clientPhotoId` 时先保留为空,由 Store 一次性迁移并回写。
@@ -115,16 +139,34 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
albumId = try container.decode(Int.self, forKey: .albumId) albumId = try container.decode(Int.self, forKey: .albumId)
userId = try container.decode(String.self, forKey: .userId) userId = try container.decode(String.self, forKey: .userId)
remoteUrl = try container.decodeIfPresent(String.self, forKey: .remoteUrl) ?? "" 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 updatedAt = try container.decodeIfPresent(Int64.self, forKey: .updatedAt) ?? 0
} }
/// 把中断中的传输恢复为待上传,避免重进页面卡在上传中。 /// 把中断中的传输恢复为待上传,避免重进页面卡在上传中。
func normalizedAfterInterruptedTransfer() -> TravelAlbumOTGPhotoRecord { func normalizedAfterInterruptedTransfer() -> TravelAlbumOTGPhotoRecord {
guard status == .transferring || status == .uploading else { return self }
var copy = self var copy = self
copy.status = .pending if status == .transferring || status == .uploading {
copy.progress = 0 copy.status = .pending
copy.errorMessage = nil copy.progress = 0
copy.errorMessage = nil
}
if autoRetouchState == .submitting {
copy.autoRetouchState = .pendingSubmission
}
guard copy != self else { return self }
copy.updatedAt = Int64(Date().timeIntervalSince1970 * 1000) copy.updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
return copy return copy
} }
@@ -90,7 +90,8 @@ final class TravelAlbumAIRetouchTemplateViewModel {
case .refined, .atmosphere: case .refined, .atmosphere:
return 1 return 1
case .all: case .all:
return 1 + (selectedAtmosphereTemplateId == nil ? 0 : 1) return (selectedRefinedTemplateId == nil ? 0 : 1)
+ (selectedAtmosphereTemplateId == nil ? 0 : 1)
} }
} }
} }
@@ -109,6 +110,11 @@ final class TravelAlbumAIRetouchTemplateViewModel {
return unavailableMessage(for: category) return unavailableMessage(for: category)
} }
} }
if case .reretouch(_, _, .all) = workflow,
selectedRefinedTemplateId == nil,
selectedAtmosphereTemplateId == nil {
return "请至少选择一个修图模板"
}
guard let remainingQuota else { guard let remainingQuota else {
return "剩余修图次数获取失败,请刷新后重试" return "剩余修图次数获取失败,请刷新后重试"
} }
@@ -146,7 +152,9 @@ final class TravelAlbumAIRetouchTemplateViewModel {
atmosphereTemplates = response.atmosphereTemplates atmosphereTemplates = response.atmosphereTemplates
coverTemplates = response.coverTemplates coverTemplates = response.coverTemplates
remainingQuota = response.remainingQuota 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) selectedAtmosphereTemplateId = visibleCategories.contains(.atmosphere) && !isOptional(.atmosphere)
? atmosphereTemplates.first?.id ? atmosphereTemplates.first?.id
: nil : nil
@@ -188,14 +196,14 @@ final class TravelAlbumAIRetouchTemplateViewModel {
} }
} }
/// 选择模板;仅选填分组允许再次点击取消,必选分组保持单选。 /// 选择模板;选填分组允许再次点击取消,必选分组保持单选。
func toggleTemplate(id: Int, category: TravelAlbumAIRetouchTemplateCategory) { func toggleTemplate(id: Int, category: TravelAlbumAIRetouchTemplateCategory) {
guard visibleCategories.contains(category), guard visibleCategories.contains(category),
templates(for: category).contains(where: { $0.id == id }) templates(for: category).contains(where: { $0.id == id })
else { return } else { return }
switch category { switch category {
case .refined: case .refined:
selectedRefinedTemplateId = id selectedRefinedTemplateId = isOptional(category) && selectedRefinedTemplateId == id ? nil : id
case .atmosphere: case .atmosphere:
selectedAtmosphereTemplateId = isOptional(category) && selectedAtmosphereTemplateId == id ? nil : id selectedAtmosphereTemplateId = isOptional(category) && selectedAtmosphereTemplateId == id ? nil : id
case .cover: case .cover:
@@ -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 isRefreshing = false
private(set) var isSelectionMode = false private(set) var isSelectionMode = false
private(set) var selectedMaterialIds: Set<Int> = [] private(set) var selectedMaterialIds: Set<Int> = []
private var selectedMaterialPurchaseStates: [Int: Bool] = [:]
var onStateChange: (() -> Void)? var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)? var onShowMessage: ((String) -> Void)?
@@ -142,12 +143,29 @@ final class TravelAlbumDetailViewModel {
selectedTab == .all ? allPhotoCount : purchasedPhotoCount 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。 /// 切换素材 tab。
func selectTab(_ tab: Tab, api: any TravelAlbumServing) async { func selectTab(_ tab: Tab, api: any TravelAlbumServing) async {
guard selectedTab != tab else { return } guard selectedTab != tab else { return }
selectedTab = tab selectedTab = tab
isSelectionMode = false isSelectionMode = false
selectedMaterialIds = [] clearMaterialSelection()
notifyStateChange() notifyStateChange()
await loadMaterials(reset: true, api: api) await loadMaterials(reset: true, api: api)
} }
@@ -223,10 +241,9 @@ final class TravelAlbumDetailViewModel {
/// 切换选择模式。 /// 切换选择模式。
func toggleSelectionMode() { func toggleSelectionMode() {
guard selectedTab == .all else { return }
isSelectionMode.toggle() isSelectionMode.toggle()
if !isSelectionMode { if !isSelectionMode {
selectedMaterialIds = [] clearMaterialSelection()
} }
notifyStateChange() notifyStateChange()
} }
@@ -234,14 +251,12 @@ final class TravelAlbumDetailViewModel {
/// 切换素材选中状态。 /// 切换素材选中状态。
func toggleMaterialSelection(_ material: TravelAlbumMaterial) { func toggleMaterialSelection(_ material: TravelAlbumMaterial) {
guard isSelectionMode else { return } guard isSelectionMode else { return }
guard material.status == 1 else {
onShowMessage?("仅未购买素材可删除")
return
}
if selectedMaterialIds.contains(material.id) { if selectedMaterialIds.contains(material.id) {
selectedMaterialIds.remove(material.id) selectedMaterialIds.remove(material.id)
selectedMaterialPurchaseStates.removeValue(forKey: material.id)
} else { } else {
selectedMaterialIds.insert(material.id) selectedMaterialIds.insert(material.id)
selectedMaterialPurchaseStates[material.id] = material.isPurchased
} }
notifyStateChange() notifyStateChange()
} }
@@ -249,7 +264,7 @@ final class TravelAlbumDetailViewModel {
/// AI 修图任务提交成功后退出选择模式并清空当前选择。 /// AI 修图任务提交成功后退出选择模式并清空当前选择。
func completeAIRetouchSubmission() { func completeAIRetouchSubmission() {
isSelectionMode = false isSelectionMode = false
selectedMaterialIds = [] clearMaterialSelection()
notifyStateChange() notifyStateChange()
} }
@@ -264,6 +279,7 @@ final class TravelAlbumDetailViewModel {
guard let index = materials.firstIndex(where: { $0.id == id }) else { return } guard let index = materials.firstIndex(where: { $0.id == id }) else { return }
let material = materials.remove(at: index) let material = materials.remove(at: index)
selectedMaterialIds.remove(id) selectedMaterialIds.remove(id)
selectedMaterialPurchaseStates.removeValue(forKey: id)
allPhotoCount = max(0, allPhotoCount - 1) allPhotoCount = max(0, allPhotoCount - 1)
if material.isPurchased { if material.isPurchased {
purchasedPhotoCount = max(0, purchasedPhotoCount - 1) purchasedPhotoCount = max(0, purchasedPhotoCount - 1)
@@ -273,16 +289,20 @@ final class TravelAlbumDetailViewModel {
/// 删除已选素材。 /// 删除已选素材。
func deleteSelectedMaterials(api: any TravelAlbumServing) async { func deleteSelectedMaterials(api: any TravelAlbumServing) async {
let ids = selectedMaterialIds.sorted() guard !selectedMaterialIds.isEmpty else {
guard !ids.isEmpty else {
onShowMessage?("请选择要删除的素材") onShowMessage?("请选择要删除的素材")
return return
} }
let ids = selectedDeletableMaterialIds
guard !ids.isEmpty else {
onShowMessage?("选中的素材均已购买,未删除任何项目")
return
}
do { do {
try await api.batchDeleteMaterials(ids: ids) try await api.batchDeleteMaterials(ids: ids)
onShowMessage?("删除成功") onShowMessage?("删除成功")
isSelectionMode = false isSelectionMode = false
selectedMaterialIds = [] clearMaterialSelection()
await refreshAll(api: api) await refreshAll(api: api)
} catch is CancellationError { } catch is CancellationError {
return return
@@ -307,4 +327,18 @@ final class TravelAlbumDetailViewModel {
private func notifyStateChange() { private func notifyStateChange() {
onStateChange?() 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()
}
} }
@@ -121,6 +121,7 @@ final class TravelAlbumEntryViewModel {
freeCount: String, freeCount: String,
singlePrice: String, singlePrice: String,
packagePrice: String, packagePrice: String,
autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled,
order: TravelAlbumAvailableOrder?, order: TravelAlbumAvailableOrder?,
api: any TravelAlbumServing api: any TravelAlbumServing
) async { ) async {
@@ -143,6 +144,10 @@ final class TravelAlbumEntryViewModel {
onShowMessage?("请输入有效的单张照片价格") onShowMessage?("请输入有效的单张照片价格")
return return
} }
if autoRetouchConfiguration.enabled && !autoRetouchConfiguration.isValid {
onShowMessage?("请选择有效的 AI 修图模板")
return
}
let albumName: String let albumName: String
switch mode { switch mode {
@@ -162,7 +167,8 @@ final class TravelAlbumEntryViewModel {
materialNum: Int(freeCount) ?? 0, materialNum: Int(freeCount) ?? 0,
materialPrice: materialPrice, materialPrice: materialPrice,
materialPackagePrice: Double(packagePrice) ?? 0, materialPackagePrice: Double(packagePrice) ?? 0,
photoPrice: 0 photoPrice: 0,
autoRetouchConfiguration: autoRetouchConfiguration
) )
case .preOrder: case .preOrder:
request = TravelAlbumCreateRequest( request = TravelAlbumCreateRequest(
@@ -172,7 +178,8 @@ final class TravelAlbumEntryViewModel {
materialNum: nil, materialNum: nil,
materialPrice: nil, materialPrice: nil,
materialPackagePrice: 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 连接、照片导入、本地缓存与上传登记。 /// 有线相机传输页 ViewModel,编排 ImageCaptureCore 连接、照片导入、本地缓存与上传登记。
@MainActor @MainActor
final class WiredCameraTransferViewModel { final class WiredCameraTransferViewModel {
@@ -63,7 +79,12 @@ final class WiredCameraTransferViewModel {
private(set) var sonyMTPHint: String? private(set) var sonyMTPHint: String?
private(set) var isContentCatalogReady = false 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 photoFormatOption: TravelAlbumOTGPhotoFormatOption = .jpg
private(set) var transferMode: TravelAlbumOTGTransferMode = .liveUpload { private(set) var transferMode: TravelAlbumOTGTransferMode = .liveUpload {
didSet { notifyStateChanged() } didSet { notifyStateChanged() }
@@ -87,6 +108,8 @@ final class WiredCameraTransferViewModel {
private var queuedAutoUploadPhotoIds: Set<String> = [] private var queuedAutoUploadPhotoIds: Set<String> = []
private var suppressSelectedTimeSlotNotification = false private var suppressSelectedTimeSlotNotification = false
private var serverStatusSyncTask: Task<Void, Never>? private var serverStatusSyncTask: Task<Void, Never>?
private var configurationSyncTask: Task<Void, Never>?
private var autoRetouchPollingTask: Task<Void, Never>?
@MainActor @MainActor
init( init(
@@ -94,6 +117,8 @@ final class WiredCameraTransferViewModel {
albumTitle: String, albumTitle: String,
headerPhone: String, headerPhone: String,
scenicSpotLabel: String? = nil, scenicSpotLabel: String? = nil,
initialTransferMode: TravelAlbumOTGTransferMode? = nil,
initialAutoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled,
connectionManager: (any WiredCameraConnectionManaging)? = nil, connectionManager: (any WiredCameraConnectionManaging)? = nil,
storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(), storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(),
uploader: (any TravelAlbumOTGUploading)? = nil, uploader: (any TravelAlbumOTGUploading)? = nil,
@@ -112,7 +137,11 @@ final class WiredCameraTransferViewModel {
self.api = api ?? NetworkServices.shared.travelAlbumAPI self.api = api ?? NetworkServices.shared.travelAlbumAPI
self.appStore = appStore self.appStore = appStore
self.userDefaults = userDefaults 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 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] { var specifyUploadOptions: [TravelAlbumOTGSpecifyUploadOption] {
TravelAlbumOTGSpecifyUploadOption.allCases TravelAlbumOTGSpecifyUploadOption.allCases
@@ -230,6 +293,9 @@ final class WiredCameraTransferViewModel {
syncFromConnectionManager() syncFromConnectionManager()
loadPersistedPhotos() loadPersistedPhotos()
syncServerUploadStatuses() syncServerUploadStatuses()
syncAutoRetouchConfiguration()
resumePendingAutoRetouches()
updateAutoRetouchPolling()
connectionManager.start() connectionManager.start()
} }
@@ -237,10 +303,29 @@ final class WiredCameraTransferViewModel {
func stop() { func stop() {
serverStatusSyncTask?.cancel() serverStatusSyncTask?.cancel()
serverStatusSyncTask = nil serverStatusSyncTask = nil
configurationSyncTask?.cancel()
configurationSyncTask = nil
autoRetouchPollingTask?.cancel()
autoRetouchPollingTask = nil
connectionManager.unbindDelegate() connectionManager.unbindDelegate()
connectionManager.suspendLiveTransfer() connectionManager.suspendLiveTransfer()
} }
/// App 进入后台时停止配置请求和任务轮询,保留待恢复状态。
func applicationDidEnterBackground() {
configurationSyncTask?.cancel()
configurationSyncTask = nil
autoRetouchPollingTask?.cancel()
autoRetouchPollingTask = nil
}
/// App 回到前台时同步跨设备配置并恢复自动修图任务。
func applicationDidBecomeActive() {
syncAutoRetouchConfiguration()
resumePendingAutoRetouches()
updateAutoRetouchPolling()
}
/// 主动断开相机连接。 /// 主动断开相机连接。
func disconnect() { func disconnect() {
connectionManager.disconnect() connectionManager.disconnect()
@@ -301,6 +386,50 @@ final class WiredCameraTransferViewModel {
userDefaults.set(mode.rawValue, forKey: Self.transferModeDefaultsKey) 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) { func selectPhotoFormat(_ option: TravelAlbumOTGPhotoFormatOption) {
guard photoFormatOption != option else { return } 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() { private func applyMergedPhotos() {
let persistedItems = persistedRecordsById.values let persistedItems = persistedRecordsById.values
.map { $0.toPhotoItem(storage: storage, albumId: albumId) } .map { $0.toPhotoItem(storage: storage, albumId: albumId) }
@@ -588,6 +877,7 @@ final class WiredCameraTransferViewModel {
} }
private func uploadPhoto(id: String) async { private func uploadPhoto(id: String) async {
let retouchSnapshot = autoRetouchConfiguration
do { do {
let record = try await localRecordForUpload(id: id) let record = try await localRecordForUpload(id: id)
updateRecord(id: id, status: .uploading, progress: max(record.progress, 1), error: nil) updateRecord(id: id, status: .uploading, progress: max(record.progress, 1), error: nil)
@@ -597,13 +887,44 @@ final class WiredCameraTransferViewModel {
) { [weak self] progress in ) { [weak self] progress in
self?.updateRecord(id: id, status: .uploading, progress: progress, error: nil) 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 { } catch {
updateRecord(id: id, status: .failed, progress: 0, error: error.localizedDescription) updateRecord(id: id, status: .failed, progress: 0, error: error.localizedDescription)
showMessage(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 { private func localRecordForUpload(id: String) async throws -> TravelAlbumOTGPhotoRecord {
if let record = persistedRecordsById[id], if let record = persistedRecordsById[id],
!record.localPath.isEmpty, !record.localPath.isEmpty,
@@ -653,6 +974,14 @@ final class WiredCameraTransferViewModel {
applyMergedPhotos() 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() { private func notifyStateChanged() {
if Thread.isMainThread { if Thread.isMainThread {
onStateChange?() onStateChange?()
+110 -17
View File
@@ -9,6 +9,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow? var window: UIWindow?
private var sessionExpiredDialog: SessionExpiredDialogViewController? private var sessionExpiredDialog: SessionExpiredDialogViewController?
private var deregistrationCoordinator: StoreAccountDeregistrationRootCoordinator?
private var needsForegroundDeregistrationCheck = false
func scene( func scene(
_ scene: UIScene, _ scene: UIScene,
@@ -20,26 +22,39 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
AppNavigationBarAppearance.applyGlobalAppearance() AppNavigationBarAppearance.applyGlobalAppearance()
let window = UIWindow(windowScene: windowScene) let window = UIWindow(windowScene: windowScene)
window.rootViewController = AppRouter.makeRootViewController() window.rootViewController = UIViewController()
window.makeKeyAndVisible()
self.window = window self.window = window
(UIApplication.shared.delegate as? AppDelegate)?.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) PushNotificationManager.shared.attach(window: window)
registerNotifications() registerNotifications()
refreshRootForCurrentSession()
if AppStore.shared.session.isLoggedIn { window.makeKeyAndVisible()
DispatchQueue.main.async {
PushNotificationManager.shared.handleLoginCompleted()
}
}
if let response = connectionOptions.notificationResponse { if let response = connectionOptions.notificationResponse {
PushNotificationManager.shared.handleNotificationResponse(response) PushNotificationManager.shared.handleNotificationResponse(response)
} }
} }
func sceneDidDisconnect(_ scene: UIScene) { func sceneDidDisconnect(_ scene: UIScene) {
deregistrationCoordinator?.cancelPendingCheck()
deregistrationCoordinator = nil
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)
if (UIApplication.shared.delegate as? AppDelegate)?.window === window { if (UIApplication.shared.delegate as? AppDelegate)?.window === window {
(UIApplication.shared.delegate as? AppDelegate)?.window = nil (UIApplication.shared.delegate as? AppDelegate)?.window = nil
@@ -56,10 +71,30 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
} }
func sceneDidBecomeActive(_ scene: UIScene) { func sceneDidBecomeActive(_ scene: UIScene) {
guard accessController == nil, deregistrationCoordinator?.isChecking != true else { return }
PushNotificationManager.shared.retryPendingRegistrationUpload() 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() { 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( NotificationCenter.default.addObserver(
self, self,
selector: #selector(handleSessionDidExpire), selector: #selector(handleSessionDidExpire),
@@ -90,6 +125,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
guard AppStore.shared.session.isLoggedIn else { return } guard AppStore.shared.session.isLoggedIn else { return }
guard sessionExpiredDialog == nil else { return } guard sessionExpiredDialog == nil else { return }
deregistrationCoordinator?.cancelPendingCheck()
GlobalLoadingManager.shared.hideAll() GlobalLoadingManager.shared.hideAll()
let dialog = SessionExpiredDialogViewController { [weak self] in let dialog = SessionExpiredDialogViewController { [weak self] in
self?.transitionToLogin() self?.transitionToLogin()
@@ -104,28 +140,85 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
} }
private func transitionToLogin() { private func transitionToLogin() {
sessionExpiredDialog = nil clearAuthenticatedSession()
PushNotificationManager.shared.handleLogout()
AppStore.shared.logout()
AppRouter.setRoot(.login, on: window) 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() { @objc private func handleUserDidLogout() {
transitionToLogin() 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() { @objc private func handleUserDidLogin() {
sessionExpiredDialog?.dismiss(animated: false) sessionExpiredDialog?.dismiss(animated: false)
sessionExpiredDialog = nil sessionExpiredDialog = nil
AppRouter.setRoot(.mainTab, on: window) // v9 登录响应中的 store_users[].status 是登录是否需要注销确认的唯一依据。
DispatchQueue.main.async { // 旧 account-deregister/status 仅供用户主动进入注销设置流程时查询,不能再拦截正常登录。
PushNotificationManager.shared.handleLoginCompleted() refreshRootForCurrentSession()
PushNotificationManager.shared.routePendingNotificationIfPossible()
}
} }
@objc private func handleAccountDidSwitch() { @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? { private func topViewController(from viewController: UIViewController?) -> UIViewController? {
@@ -163,7 +163,17 @@ final class AccountSelectionViewController: UIViewController, UITableViewDelegat
} }
@objc private func confirmTapped() { @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) 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。 /// 账号选择列表 Cell。
private final class AccountSelectionCell: UITableViewCell { private final class AccountSelectionCell: UITableViewCell {
static let reuseIdentifier = "AccountSelectionCell" static let reuseIdentifier = "AccountSelectionCell"
+40 -1
View File
@@ -11,7 +11,7 @@ import UIKit
final class LoginViewController: BaseViewController { final class LoginViewController: BaseViewController {
private let viewModel = LoginViewModel() private let viewModel = LoginViewModel()
private let authAPI = NetworkServices.shared.authAPI private let authAPI: AuthAPI
private let backgroundImageView = UIImageView() private let backgroundImageView = UIImageView()
private let welcomeLabel = UILabel() private let welcomeLabel = UILabel()
@@ -29,6 +29,17 @@ final class LoginViewController: BaseViewController {
private weak var accountSelectionController: AccountSelectionViewController? 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 { override var preferredStatusBarStyle: UIStatusBarStyle {
.lightContent .lightContent
} }
@@ -264,6 +275,7 @@ final class LoginViewController: BaseViewController {
} }
private func performLogin() { private func performLogin() {
guard !viewModel.isLoading else { return }
viewModel.normalizeUsernameCountryCodeIfNeeded() viewModel.normalizeUsernameCountryCodeIfNeeded()
accountField.text = viewModel.normalizedUsername accountField.text = viewModel.normalizedUsername
@@ -278,6 +290,8 @@ final class LoginViewController: BaseViewController {
completeLogin(with: response, account: account) completeLogin(with: response, account: account)
case .needsAccountSelection: case .needsAccountSelection:
break break
case let .needsDeregistrationConfirmation(confirmation):
presentDeregistrationLoginConfirmation(confirmation)
} }
} catch is CancellationError { } catch is CancellationError {
return 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) { private func completeLogin(with response: V9AuthResponse, account: AccountSwitchAccount) {
AuthSessionHelper.completeLogin( AuthSessionHelper.completeLogin(
with: response, with: response,
+28 -3
View File
@@ -130,6 +130,28 @@ final class LoginViewModel {
guard let payload = pendingAccountSelection, payload.hasTempToken else { guard let payload = pendingAccountSelection, payload.hasTempToken else {
throw LoginFlowError.missingToken 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 { guard account.businessUserId > 0 else {
throw LoginFlowError.invalidAccount throw LoginFlowError.invalidAccount
} }
@@ -144,12 +166,10 @@ final class LoginViewModel {
notifyStateChange() 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 { guard !response.token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw LoginFlowError.missingToken throw LoginFlowError.missingToken
} }
pendingAccountSelection = nil
notifyStateChange()
return response return response
} }
@@ -178,6 +198,11 @@ final class LoginViewModel {
} }
if accounts.count == 1, let account = accounts.first { 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) let finalResponse = try await authAPI.setUser(account.toSetUserRequest(), tokenOverride: token)
guard !finalResponse.token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { guard !finalResponse.token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw LoginFlowError.missingToken throw LoginFlowError.missingToken
@@ -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 viewModel = PaymentCollectionDetailsViewModel()
private let paymentAPI = NetworkServices.shared.paymentAPI 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 scrollView = UIScrollView()
private let contentContainerView = UIView() private let contentContainerView = UIView()
@@ -48,10 +53,12 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
private let voiceCardView = UIView() private let voiceCardView = UIView()
private let voiceTitleLabel = UILabel() private let voiceTitleLabel = UILabel()
private let voiceSwitch = UISwitch() private let voiceSwitch = UISwitch()
private let offlineCollectionView = OfflineCollectionHomeView()
private var amountDialog: PaymentSetAmountDialogView? private var amountDialog: PaymentSetAmountDialogView?
private var appliedBrandConfig: PayPageConfig? private var appliedBrandConfig: PayPageConfig?
private var appliedBrandingRefreshVersion = -1 private var appliedBrandingRefreshVersion = -1
private var isShowingOfflineCollectionLoading = false
private var previousStandardAppearance: UINavigationBarAppearance? private var previousStandardAppearance: UINavigationBarAppearance?
private var previousScrollEdgeAppearance: UINavigationBarAppearance? private var previousScrollEdgeAppearance: UINavigationBarAppearance?
@@ -101,8 +108,8 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
let usesBranding = viewModel.usesNalatiBranding let usesBranding = viewModel.usesNalatiBranding
view.backgroundColor = usesBranding ? .clear : AppColor.pageBackground view.backgroundColor = usesBranding ? .clear : AppColor.pageBackground
scrollView.showsVerticalScrollIndicator = !usesBranding scrollView.showsVerticalScrollIndicator = !usesBranding
scrollView.isScrollEnabled = !usesBranding scrollView.isScrollEnabled = true
scrollView.alwaysBounceVertical = false scrollView.alwaysBounceVertical = true
contentStack.axis = .vertical contentStack.axis = .vertical
contentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.md contentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.md
@@ -201,6 +208,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
contentStack.addArrangedSubview(recordRow) contentStack.addArrangedSubview(recordRow)
contentStack.addArrangedSubview(voiceCardView) contentStack.addArrangedSubview(voiceCardView)
} }
contentStack.addArrangedSubview(offlineCollectionView)
let qrDisplayView = usesBranding ? qrContainerView : qrImageView let qrDisplayView = usesBranding ? qrContainerView : qrImageView
let qrContentStack = UIStackView(arrangedSubviews: [ let qrContentStack = UIStackView(arrangedSubviews: [
@@ -330,15 +338,14 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
make.edges.equalToSuperview() make.edges.equalToSuperview()
make.width.equalTo(scrollView.snp.width) make.width.equalTo(scrollView.snp.width)
if viewModel.usesNalatiBranding { if viewModel.usesNalatiBranding {
make.height.equalTo(scrollView.snp.height) make.height.greaterThanOrEqualTo(scrollView.snp.height)
} }
} }
contentStack.snp.makeConstraints { make in contentStack.snp.makeConstraints { make in
make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2) make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2)
if viewModel.usesNalatiBranding { if viewModel.usesNalatiBranding {
make.centerX.centerY.equalToSuperview() make.centerX.equalToSuperview()
make.top.greaterThanOrEqualToSuperview() make.top.bottom.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
make.bottom.lessThanOrEqualToSuperview()
} else { } else {
make.edges.equalToSuperview().inset(AppSpacing.screenHorizontalInset) make.edges.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
} }
@@ -358,6 +365,22 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside) refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside)
recordRow.addTarget(self, action: #selector(recordTapped), for: .touchUpInside) recordRow.addTarget(self, action: #selector(recordTapped), for: .touchUpInside)
voiceSwitch.addTarget(self, action: #selector(voiceSwitchChanged), for: .valueChanged) 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() { override func viewDidLoad() {
@@ -369,10 +392,13 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
applyBrandNavigationAppearanceIfNeeded() applyBrandNavigationAppearanceIfNeeded()
applyOfflineCollection()
Task { await offlineCollectionViewModel.load() }
} }
override func viewWillDisappear(_ animated: Bool) { override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated) super.viewWillDisappear(animated)
setOfflineCollectionLoadingVisible(false)
restoreNavigationAppearanceIfNeeded() restoreNavigationAppearanceIfNeeded()
} }
@@ -432,6 +458,26 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
amountDialog?.dismiss() amountDialog?.dismiss()
amountDialog = nil 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) { private func setActionRowAvailable(_ isAvailable: Bool) {
@@ -668,6 +714,23 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
@objc private func voiceSwitchChanged() { @objc private func voiceSwitchChanged() {
viewModel.toggleReceiveVoice() 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)
}
} }
/// 收款详情信息行。 /// 收款详情信息行。
@@ -96,14 +96,8 @@ final class AccountSwitchViewController: BaseViewController, UITableViewDelegate
} }
private var currentAccountId: String? { private var currentAccountId: String? {
let store = AppStore.shared let scope = AppStore.shared.session.accountCachePrefix
if store.session.currentStoreId > 0 { return scope.isEmpty ? nil : scope
return "\(V9StoreUser.accountTypeValue)_\(store.session.currentStoreId)"
}
if store.session.currentScenicId > 0 {
return "\(V9ScenicUser.accountTypeValue)_\(store.session.userId)"
}
return store.session.userId.isEmpty ? nil : store.session.userId
} }
private func isCurrentAccount(_ account: AccountSwitchAccount) -> Bool { private func isCurrentAccount(_ account: AccountSwitchAccount) -> Bool {
@@ -126,6 +120,16 @@ final class AccountSwitchViewController: BaseViewController, UITableViewDelegate
navigationController?.popViewController(animated: true) navigationController?.popViewController(animated: true)
return return
} }
if account.requiresDeregistrationConfirmation {
present(
makeDeregistrationLoginAlert(account: account) { [weak self] in
guard let self else { return }
Task { await self.switchAccount(account) }
},
animated: true
)
return
}
Task { await switchAccount(account) } Task { await switchAccount(account) }
} }
@@ -14,6 +14,7 @@ final class SettingViewController: BaseViewController {
private let contentView = UIView() private let contentView = UIView()
private let cardView = UIView() private let cardView = UIView()
private let rowsStack = UIStackView() private let rowsStack = UIStackView()
private let deregistrationRow = SettingMenuRow(title: "注销当前门店身份", titleColor: AppColor.danger, showsDivider: false)
private let versionRow = SettingMenuRow(title: "系统版本", showsChevron: false) private let versionRow = SettingMenuRow(title: "系统版本", showsChevron: false)
private let copyrightLabel = UILabel() private let copyrightLabel = UILabel()
@@ -68,6 +69,10 @@ final class SettingViewController: BaseViewController {
rows[2].addTarget(self, action: #selector(copyDownloadTapped), for: .touchUpInside) rows[2].addTarget(self, action: #selector(copyDownloadTapped), for: .touchUpInside)
rows[3].addTarget(self, action: #selector(userAgreementTapped), for: .touchUpInside) rows[3].addTarget(self, action: #selector(userAgreementTapped), for: .touchUpInside)
rows[4].addTarget(self, action: #selector(privacyTapped), for: .touchUpInside) rows[4].addTarget(self, action: #selector(privacyTapped), for: .touchUpInside)
if AppStore.shared.session.accountType == .storeUser {
rowsStack.addArrangedSubview(deregistrationRow)
deregistrationRow.addTarget(self, action: #selector(deregistrationTapped), for: .touchUpInside)
}
} }
override func setupConstraints() { override func setupConstraints() {
@@ -119,6 +124,29 @@ final class SettingViewController: BaseViewController {
openAgreement(.privacyPolicy) openAgreement(.privacyPolicy)
} }
/// 仅从当前门店业务会话创建注销流程,冻结 Token 与门店用户 ID。
@objc private func deregistrationTapped() {
do {
let session = AppStore.shared.session
let identity = try StoreAccountDeregistrationIdentity(session: session)
let api = StoreAccountDeregistrationAPI(client: NetworkServices.shared.apiClient, identity: identity) {
identity.matches(session: session)
}
let controller = StoreAccountDeregistrationViewController(
identityName: identity.displayName,
viewModel: StoreAccountDeregistrationViewModel(
storeUserID: Int(identity.userID) ?? 0,
submissionStore: StoreAccountDeregistrationSubmissionStore(storeUserID: identity.userID)
), api: api, onUnresolvedSubmission: {
NotificationCenter.default.post(name: NotificationName.storeAccountDeregistrationRestricted,
object: nil, userInfo: [NotificationUserInfoKey.deregistrationRequestToken: identity.token])
}
)
controller.hidesBottomBarWhenPushed = true
navigationController?.pushViewController(controller, animated: true)
} catch { showError(error.localizedDescription) }
}
private func openAgreement(_ kind: SettingAgreementKind) { private func openAgreement(_ kind: SettingAgreementKind) {
let destination = viewModel.agreementDestination(for: kind) let destination = viewModel.agreementDestination(for: kind)
navigationController?.pushViewController( navigationController?.pushViewController(
@@ -138,8 +166,10 @@ final class SettingMenuRow: UIControl {
private let divider = UIView() private let divider = UIView()
private let showsChevron: Bool private let showsChevron: Bool
/// 创建菜单行,可为注销等操作单独指定标题颜色,不影响其他行。
init( init(
title: String, title: String,
titleColor: UIColor = UIColor(hex: 0x4B5563),
value: String? = nil, value: String? = nil,
valueColor: UIColor = AppColor.textPrimary, valueColor: UIColor = AppColor.textPrimary,
showsChevron: Bool = true, showsChevron: Bool = true,
@@ -150,6 +180,7 @@ final class SettingMenuRow: UIControl {
setupUI() setupUI()
setupConstraints() setupConstraints()
titleLabel.text = title titleLabel.text = title
titleLabel.textColor = titleColor
valueLabel.text = value valueLabel.text = value
valueLabel.textColor = valueColor valueLabel.textColor = valueColor
chevronImageView.isHidden = !showsChevron chevronImageView.isHidden = !showsChevron
@@ -175,7 +206,6 @@ final class SettingMenuRow: UIControl {
private func setupUI() { private func setupUI() {
titleLabel.font = .systemFont(ofSize: 14) titleLabel.font = .systemFont(ofSize: 14)
titleLabel.textColor = UIColor(hex: 0x4B5563)
valueLabel.font = .systemFont(ofSize: 14) valueLabel.font = .systemFont(ofSize: 14)
valueLabel.textColor = AppColor.textPrimary valueLabel.textColor = AppColor.textPrimary
@@ -0,0 +1,298 @@
import SnapKit
import UIKit
/// 业务根页面之前核验注销状态;冷静期提供明确撤销,未知状态只允许查询和主动退出。
@MainActor
final class StoreAccountDeregistrationAccessViewController: BaseViewController {
/// 冻结的门店身份,用于 Scene 对旧请求的隔离。
let identity: StoreAccountDeregistrationIdentity?
private let api: (any StoreAccountDeregistrationServing)?
private let session: AppSessionStore
private let onAllowed: (StoreAccountDeregistrationAccessViewController) -> Void
private let onAccessDenied: () -> Void
private let viewModel: StoreAccountDeregistrationAccessViewModel
private let hasInitialResult: Bool
private typealias Style = StoreAccountDeregistrationStyle
private let scrollView = UIScrollView()
private let refreshControl = UIRefreshControl()
private let stack = UIStackView()
private let bottomBar = UIView()
private let titleLabel = Style.label(size: 24, weight: .semibold)
private let stateIcon = Style.icon("lock", size: 36)
private let stateIconBadge = UIView()
private let deadlineLabel = Style.label(size: 19, weight: .semibold)
private let infoLabel = Style.label("重新登录该身份自动撤销申请", size: 13, color: Style.textSecondary)
private var deadlineCard = UIView()
private var infoCard = UIView()
private let messageLabel = UILabel()
private let retryButton = UIButton(type: .system)
private let cancelButton = UIButton(type: .system)
private let logoutButton = UIButton(type: .system)
private var queryTask: Task<Void, Never>?
private var needsForegroundRefresh = false
private var previousPopGestureEnabled: Bool?
/// 显式注入会话与 API;身份构造失败时仍展示错误,不自动退出或重新登录。
init(identity: StoreAccountDeregistrationIdentity?, api: (any StoreAccountDeregistrationServing)?,
session: AppSessionStore, requiresSubmissionReconciliation: Bool = false,
submissionStore: (any StoreAccountDeregistrationSubmissionTracking)? = nil,
initialViewModel: StoreAccountDeregistrationAccessViewModel? = nil,
onAccessDenied: @escaping () -> Void = {},
onAllowed: @escaping (StoreAccountDeregistrationAccessViewController) -> Void) {
self.identity = identity
self.api = api
self.session = session
self.onAllowed = onAllowed
self.onAccessDenied = onAccessDenied
hasInitialResult = initialViewModel != nil
viewModel = initialViewModel ?? StoreAccountDeregistrationAccessViewModel(
requiresSubmissionReconciliation: requiresSubmissionReconciliation, submissionStore: submissionStore)
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func setupNavigationBar() {
title = "注销账号"
navigationItem.hidesBackButton = true
}
override func setupUI() {
view.backgroundColor = Style.pageBackground
view.addSubview(scrollView)
scrollView.alwaysBounceVertical = true
refreshControl.accessibilityIdentifier = "deregister.access.refresh"
refreshControl.accessibilityLabel = "下拉刷新"
refreshControl.tintColor = .clear
refreshControl.addTarget(self, action: #selector(retryTapped), for: .valueChanged)
scrollView.refreshControl = refreshControl
scrollView.addSubview(stack)
stack.axis = .vertical
stack.spacing = 18
stateIconBadge.backgroundColor = Style.infoBackground
stateIconBadge.layer.cornerRadius = 44
stateIconBadge.addSubview(stateIcon)
stateIconBadge.snp.makeConstraints { $0.width.height.equalTo(88) }
stateIcon.snp.makeConstraints { $0.center.equalToSuperview(); $0.width.height.equalTo(44) }
titleLabel.textAlignment = .center
messageLabel.numberOfLines = 0
messageLabel.font = .systemFont(ofSize: 15)
messageLabel.textColor = Style.textSecondary
messageLabel.textAlignment = .center
let iconRow = UIView()
iconRow.addSubview(stateIconBadge)
stateIconBadge.snp.makeConstraints { $0.top.bottom.centerX.equalToSuperview() }
let hero = Style.stack([iconRow, titleLabel, messageLabel], spacing: 12)
stack.addArrangedSubview(hero)
deadlineLabel.accessibilityIdentifier = "deregister.access.deadline"
let deadlineIcon = Style.icon("calendar", size: 22)
deadlineIcon.snp.makeConstraints { $0.width.equalTo(28) }
let deadlineText = Style.stack([
Style.label("冷静期截止时间", size: 13, color: Style.textSecondary), deadlineLabel
], spacing: 8)
let deadlineRow = UIStackView(arrangedSubviews: [deadlineIcon, deadlineText])
deadlineRow.axis = .horizontal
deadlineRow.alignment = .center
deadlineRow.spacing = 12
deadlineCard = Style.card(deadlineRow)
stack.addArrangedSubview(deadlineCard)
let infoIcon = Style.icon("info.circle", size: 17)
infoIcon.snp.makeConstraints { $0.width.equalTo(22) }
let infoRow = UIStackView(arrangedSubviews: [infoIcon, infoLabel])
infoRow.axis = .horizontal
infoRow.alignment = .center
infoRow.spacing = 8
infoCard = Style.card(infoRow, background: Style.infoBackground)
stack.addArrangedSubview(infoCard)
view.addSubview(bottomBar)
bottomBar.backgroundColor = .white
Style.configure(retryButton, title: "重新查询状态")
Style.configure(logoutButton, title: "退出登录")
Style.configure(cancelButton, title: "撤销注销申请", primary: false)
let actions = Style.stack([retryButton, logoutButton, cancelButton], spacing: 12)
bottomBar.addSubview(actions)
actions.snp.makeConstraints { $0.edges.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)) }
for button in [retryButton, logoutButton, cancelButton] {
button.snp.makeConstraints { $0.height.greaterThanOrEqualTo(50) }
}
retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
logoutButton.addTarget(self, action: #selector(logoutTapped), for: .touchUpInside)
messageLabel.accessibilityIdentifier = "deregister.access.message"
titleLabel.accessibilityIdentifier = "deregister.access.title"
retryButton.accessibilityIdentifier = "deregister.access.retry"
cancelButton.accessibilityIdentifier = "deregister.access.cancel"
logoutButton.accessibilityIdentifier = "deregister.access.logout"
}
override func setupConstraints() {
bottomBar.snp.makeConstraints {
$0.leading.trailing.equalToSuperview()
$0.bottom.equalTo(view.safeAreaLayoutGuide)
}
scrollView.snp.makeConstraints {
$0.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
$0.bottom.equalTo(bottomBar.snp.top)
}
stack.snp.makeConstraints {
$0.top.equalTo(scrollView.contentLayoutGuide).offset(24)
$0.leading.trailing.bottom.equalTo(scrollView.contentLayoutGuide).inset(16)
$0.width.equalTo(scrollView.frameLayoutGuide).offset(-32)
}
}
override func viewDidLoad() {
super.viewDidLoad()
if hasInitialResult { applyViewModel() } else { retryTapped() }
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
previousPopGestureEnabled = navigationController?.interactivePopGestureRecognizer?.isEnabled
navigationItem.hidesBackButton = true
navigationController?.interactivePopGestureRecognizer?.isEnabled = false
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if let previousPopGestureEnabled {
navigationController?.interactivePopGestureRecognizer?.isEnabled = previousPopGestureEnabled
}
}
/// 在途旧查询不能覆盖刚收到的受限信号;之后由用户主动重新查询。
func recordRestriction() {
viewModel.recordRestriction()
onAccessDenied()
if isViewLoaded { applyViewModel() }
}
/// 从后台回来只读重查;废弃后台前的在途结果,不重发申请或撤销操作。
func refreshAfterBackground() {
viewModel.recordRestriction()
navigationController?.popToRootViewController(animated: false)
if presentedViewController is UIAlertController { dismiss(animated: false) }
if queryTask != nil {
needsForegroundRefresh = true
applyViewModel()
} else {
retryTapped()
}
}
private func applyViewModel() {
let busy = queryTask != nil
let cooling = viewModel.decision == .cooling
retryButton.isEnabled = !busy && api != nil
retryButton.isHidden = cooling
refreshControl.isEnabled = !busy && api != nil
cancelButton.isHidden = !cooling
cancelButton.isEnabled = !busy && api != nil
logoutButton.isEnabled = !busy
Style.configure(logoutButton, title: "退出登录", primary: cooling)
deadlineCard.isHidden = !cooling
infoCard.isHidden = !cooling
deadlineLabel.text = viewModel.status?.coolingUntil ?? "—"
guard identity != nil, api != nil else {
titleLabel.text = "暂时无法查询"
messageLabel.text = "当前身份信息不完整,请联系管理员。"
stateIcon.image = UIImage(systemName: "exclamationmark.circle")
return
}
var symbol = "clock"
switch viewModel.decision {
case .notChecked, .checking:
titleLabel.text = nil
messageLabel.text = nil
case .allowed:
titleLabel.text = "当前身份可正常使用"
messageLabel.text = "正在返回首页"
symbol = "checkmark.circle"
case .cooling:
titleLabel.text = "注销申请已提交"
messageLabel.text = "当前身份:\(identity?.displayName ?? "当前身份")"
symbol = "lock"
case .unresolved:
titleLabel.text = "注销状态待确认"
messageLabel.text = "暂时未获取到明确结果,请刷新重试。\n请勿重复提交;如持续出现,请联系管理员。"
symbol = "questionmark.circle"
case .failed:
titleLabel.text = "暂时无法查询"
messageLabel.text = "请检查网络后重试。\n查询失败不会撤销你的注销申请。"
symbol = "wifi.exclamationmark"
case .obsolete:
titleLabel.text = "当前身份已变化"
messageLabel.text = "请返回当前账号后重试。"
symbol = "person.crop.circle.badge.exclamationmark"
}
stateIcon.image = UIImage(systemName: symbol,
withConfiguration: UIImage.SymbolConfiguration(pointSize: 36, weight: .medium))
navigationItem.hidesBackButton = true
navigationController?.interactivePopGestureRecognizer?.isEnabled = false
}
@objc private func cancelTapped() {
guard queryTask == nil, let identity, let api, identity.matches(session: session),
viewModel.decision == .cooling else { return }
let alert = UIAlertController(title: "撤销当前身份的注销申请?",
message: "将撤销“\(identity.displayName)”的申请,服务端确认后恢复使用。其他身份不受影响。",
preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "保留注销申请", style: .cancel))
alert.addAction(UIAlertAction(title: "确认撤销", style: .destructive) { [weak self] _ in
guard let self, self.queryTask == nil else { return }
self.queryTask = Task { @MainActor [weak self] in
guard let self else { return }
self.showLoading()
await self.viewModel.cancel(api: api) { [session = self.session] in identity.matches(session: session) }
self.hideLoading()
self.finishQuery()
}
self.applyViewModel()
})
present(alert, animated: true)
}
@objc private func retryTapped() {
guard queryTask == nil, let identity, let api else {
refreshControl.endRefreshing()
applyViewModel()
return
}
queryTask = Task { @MainActor [weak self] in
guard let self else { return }
self.showLoading()
await self.viewModel.verify(api: api) { [session = self.session] in identity.matches(session: session) }
self.hideLoading()
self.finishQuery()
}
applyViewModel()
}
private func finishQuery() {
queryTask = nil
refreshControl.endRefreshing()
if needsForegroundRefresh {
needsForegroundRefresh = false
retryTapped()
return
}
applyViewModel()
if viewModel.decision == .allowed, identity?.matches(session: session) == true {
onAllowed(self)
} else {
onAccessDenied()
}
}
@objc private func logoutTapped() {
let alert = UIAlertController(title: "退出登录?", message:
"退出本身不会撤销申请,但会清除本机登录凭证。再次登录或选中此身份会自动撤销尚未完成的申请。", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "继续保留查询", style: .cancel))
alert.addAction(UIAlertAction(title: "退出登录", style: .destructive) { _ in
NotificationCenter.default.post(name: NotificationName.userDidLogout, object: nil)
})
present(alert, animated: true)
}
}
@@ -0,0 +1,148 @@
import UIKit
/// 登录后或收到业务限制时核验门店注销状态;普通启动和前台恢复不主动查询。
@MainActor
final class StoreAccountDeregistrationRootCoordinator {
private weak var window: UIWindow?
private let session: AppSessionStore
private let makeAPI: (StoreAccountDeregistrationIdentity) -> any StoreAccountDeregistrationServing
private let makeSubmissionStore: (String) -> any StoreAccountDeregistrationSubmissionTracking
private let makeBusinessRoot: () -> UIViewController
private let setBindingSuspended: (Bool) -> Void
private let onBusinessResumed: () -> Void
private var pendingCheck: PendingCheck?
/// 登录核验期间保留原页面与身份;只允许该次请求结束其持有的全局加载。
private final class PendingCheck {
let identity: StoreAccountDeregistrationIdentity
let root: UIViewController?
let viewModel: StoreAccountDeregistrationAccessViewModel
var task: Task<Void, Never>?
var needsForegroundRefresh = false
init(identity: StoreAccountDeregistrationIdentity, root: UIViewController?,
viewModel: StoreAccountDeregistrationAccessViewModel) {
self.identity = identity
self.root = root
self.viewModel = viewModel
}
}
/// 注入窗口、会话、服务和业务恢复动作;测试无需访问共享会话或真实网络。
init(window: UIWindow, session: AppSessionStore,
makeAPI: @escaping (StoreAccountDeregistrationIdentity) -> any StoreAccountDeregistrationServing,
makeSubmissionStore: @escaping (String) -> any StoreAccountDeregistrationSubmissionTracking,
makeBusinessRoot: @escaping () -> UIViewController,
setBindingSuspended: @escaping (Bool) -> Void,
onBusinessResumed: @escaping () -> Void) {
self.window = window
self.session = session
self.makeAPI = makeAPI
self.makeSubmissionStore = makeSubmissionStore
self.makeBusinessRoot = makeBusinessRoot
self.setBindingSuspended = setBindingSuspended
self.onBusinessResumed = onBusinessResumed
}
/// 只认当前窗口实际显示的核验根,不用旧请求保存的控制器判断。
var accessController: StoreAccountDeregistrationAccessViewController? {
(window?.rootViewController as? UINavigationController)?.viewControllers.first
as? StoreAccountDeregistrationAccessViewController
}
/// 正在原登录页面上核验时,也应暂停推送账号绑定。
var isChecking: Bool { pendingCheck != nil }
/// 使用已有登录会话直接恢复首页,不创建注销服务或发起状态查询。
func restoreSession() {
guard let window, session.isLoggedIn else { return }
cancelPendingCheck()
AppRouter.setRoot(makeBusinessRoot(), on: window, animated: false)
setBindingSuspended(false)
onBusinessResumed()
}
/// 保留登录页背景并显示全局加载;核验通过进入首页,有异常结果才展示状态页。
func check() {
guard let window, session.isLoggedIn, session.accountType == .storeUser else { return }
if let pendingCheck, pendingCheck.identity.matches(session: session),
window.rootViewController === pendingCheck.root { return }
cancelPendingCheck()
setBindingSuspended(true)
guard let identity = try? StoreAccountDeregistrationIdentity(session: session) else {
showResult(identity: nil, api: nil, viewModel: StoreAccountDeregistrationAccessViewModel())
return
}
let api = makeAPI(identity)
let model = StoreAccountDeregistrationAccessViewModel(submissionStore: makeSubmissionStore(identity.userID))
let pending = PendingCheck(identity: identity, root: window.rootViewController, viewModel: model)
pendingCheck = pending
GlobalLoadingManager.shared.show()
pending.task = Task { @MainActor [weak self, session] in
await model.verify(api: api) { identity.matches(session: session) }
guard let self, self.pendingCheck === pending else { return }
let isCurrent = self.window?.rootViewController === pending.root && identity.matches(session: session)
self.pendingCheck = nil
pending.task = nil
GlobalLoadingManager.shared.hide()
guard isCurrent else { return }
if pending.needsForegroundRefresh {
self.check()
} else if model.decision == .allowed {
self.restoreSession()
} else {
self.showResult(identity: identity, api: api, viewModel: model)
}
}
}
/// 退出或切换会话时释放本次加载;迟到的旧响应不能关闭新请求的加载或替换新根。
func cancelPendingCheck() {
guard let pending = pendingCheck else { return }
pendingCheck = nil
pending.task?.cancel()
pending.task = nil
GlobalLoadingManager.shared.hide()
}
private func showResult(identity: StoreAccountDeregistrationIdentity?,
api: (any StoreAccountDeregistrationServing)?,
viewModel: StoreAccountDeregistrationAccessViewModel) {
guard let window else { return }
let controller = StoreAccountDeregistrationAccessViewController(
identity: identity, api: api, session: session, initialViewModel: viewModel
) { [weak self] candidate in
guard let self, self.accessController === candidate,
let identity = candidate.identity, identity.matches(session: self.session) else { return }
self.restoreSession()
}
AppRouter.setRoot(UINavigationController(rootViewController: controller), on: window, animated: false)
}
/// 普通页面返回前台不查询;仅已受限的核验页刷新,并废弃后台前的旧查询。
func resumeFromBackground() {
guard session.isLoggedIn, session.accountType == .storeUser else { return }
if let pendingCheck, pendingCheck.identity.matches(session: session),
window?.rootViewController === pendingCheck.root {
pendingCheck.viewModel.recordRestriction()
pendingCheck.needsForegroundRefresh = true
} else if let controller = accessController, controller.identity?.matches(session: session) == true {
setBindingSuspended(true)
controller.refreshAfterBackground()
}
}
/// 仅原请求 Token 与当前门店会话一致时限制业务,不恢复被新页面替换的旧根。
func recordRestriction(requestToken: String?) {
guard StoreAccountDeregistrationAccessViewModel.restrictionApplies(requestToken: requestToken, session: session) else { return }
setBindingSuspended(true)
if let pendingCheck, pendingCheck.identity.matches(session: session),
window?.rootViewController === pendingCheck.root {
pendingCheck.viewModel.recordRestriction()
} else if let controller = accessController, controller.identity?.matches(session: session) == true {
controller.recordRestriction()
} else {
check()
}
}
}
@@ -0,0 +1,264 @@
import SnapKit
import UIKit
/// 注销页面共用的轻量视觉组件,仅负责颜色、字号和布局,不包含业务状态。
@MainActor
enum StoreAccountDeregistrationStyle {
/// 注销流程独立视觉令牌,与设计稿保持一致且不修改全局主题。
static let primary = UIColor(hex: 0x1677FF)
static let pageBackground = UIColor(hex: 0xF5F7FA)
static let textPrimary = UIColor(hex: 0x172033)
static let textSecondary = UIColor(hex: 0x7A8496)
static let border = UIColor(hex: 0xE7ECF3)
static let infoBackground = UIColor(hex: 0xEEF5FF)
static let danger = UIColor(hex: 0xE5484D)
/// 创建支持多行的系统字体标签。
static func label(_ text: String = "", size: CGFloat = 15, weight: UIFont.Weight = .regular,
color: UIColor? = nil) -> UILabel {
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: size, weight: weight)
label.textColor = color ?? textPrimary
label.numberOfLines = 0
return label
}
/// 创建统一间距的纵向内容组。
static func stack(_ views: [UIView], spacing: CGFloat = 12) -> UIStackView {
let stack = UIStackView(arrangedSubviews: views)
stack.axis = .vertical
stack.spacing = spacing
return stack
}
/// 白色圆角卡片,内容由页面提供。
static func card(_ content: UIView, background: UIColor = .white) -> UIView {
let card = UIView()
card.backgroundColor = background
card.layer.cornerRadius = 16
card.layer.borderWidth = background == .white ? 0.5 : 0
card.layer.borderColor = border.cgColor
card.addSubview(content)
content.snp.makeConstraints { $0.edges.equalToSuperview().inset(16) }
return card
}
/// 统一主次按钮;禁用态及加载期间不依赖系统默认的灰底样式。
static func button(_ title: String, id: String, primary: Bool = true) -> UIButton {
let button = UIButton(type: .system)
button.accessibilityIdentifier = id
configure(button, title: title, primary: primary)
button.snp.makeConstraints { $0.height.greaterThanOrEqualTo(50) }
return button
}
/// 更新按钮角色和文案,保留清晰的主次关系。
static func configure(_ button: UIButton, title: String, primary: Bool = true) {
var config = primary ? UIButton.Configuration.filled() : .plain()
config.title = title
config.baseBackgroundColor = StoreAccountDeregistrationStyle.primary
config.baseForegroundColor = primary ? .white : StoreAccountDeregistrationStyle.primary
config.background.cornerRadius = 12
if !primary {
config.background.strokeColor = StoreAccountDeregistrationStyle.primary
config.background.strokeWidth = 1
}
config.contentInsets = .init(top: 14, leading: 16, bottom: 14, trailing: 16)
config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
var attributes = attributes
attributes.font = UIFont.systemFont(ofSize: 16, weight: .semibold)
return attributes
}
button.configuration = config
button.configurationUpdateHandler = { button in
let enabled = button.isEnabled
var updated = button.configuration
updated?.background.backgroundColorTransformer = UIConfigurationColorTransformer { _ in
primary ? (enabled ? StoreAccountDeregistrationStyle.primary : StoreAccountDeregistrationStyle.primary.withAlphaComponent(0.12)) : .clear
}
updated?.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
var attributes = attributes
attributes.font = UIFont.systemFont(ofSize: 16, weight: .semibold)
attributes.foregroundColor = enabled ? (primary ? .white : StoreAccountDeregistrationStyle.primary) : StoreAccountDeregistrationStyle.textSecondary
return attributes
}
button.configuration = updated
}
}
/// 以统一的SF Symbol展示提示图标,不加载额外图片资源。
static func icon(_ name: String, size: CGFloat = 22) -> UIImageView {
let image = UIImageView(image: UIImage(systemName: name,
withConfiguration: UIImage.SymbolConfiguration(pointSize: size, weight: .medium)))
image.tintColor = primary
image.contentMode = .scaleAspectFit
image.setContentHuggingPriority(.required, for: .horizontal)
return image
}
/// 带浅色圆形底的状态图标,用于冷静期和身份信息强调。
static func iconBadge(_ name: String, iconSize: CGFloat = 30, diameter: CGFloat = 88) -> UIView {
let container = UIView()
container.backgroundColor = infoBackground
container.layer.cornerRadius = diameter / 2
let image = icon(name, size: iconSize)
container.addSubview(image)
container.snp.makeConstraints { $0.width.height.equalTo(diameter) }
image.snp.makeConstraints { $0.center.equalToSuperview(); $0.width.height.equalTo(iconSize + 8) }
return container
}
/// 为危险确认操作应用红色实心按钮样式。
static func configureDestructive(_ button: UIButton, title: String) {
var config = UIButton.Configuration.filled()
config.title = title
config.baseBackgroundColor = danger
config.baseForegroundColor = .white
config.background.cornerRadius = 12
config.contentInsets = .init(top: 13, leading: 16, bottom: 13, trailing: 16)
config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
var attributes = attributes
attributes.font = .systemFont(ofSize: 16, weight: .semibold)
return attributes
}
button.configuration = config
}
}
/// 注销资产及最终提交共用的高保真底部确认弹层。
@MainActor
final class StoreAccountDeregistrationConfirmationSheetViewController: UIViewController {
/// 弹层展示的一行摘要数据。
struct Summary {
let icon: String
let title: String
let value: String
}
private typealias Style = StoreAccountDeregistrationStyle
private let sheetTitle: String
private let summaries: [Summary]
private let notices: [(icon: String, text: String, danger: Bool)]
private let cancelTitle: String
private let confirmTitle: String
private let onConfirm: () -> Void
private let cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system)
private let contentStack = UIStackView()
/// 创建只在用户明确确认后回调的弹层;下拉或取消不会触发业务请求。
init(title: String, summaries: [Summary], notices: [(String, String, Bool)],
cancelTitle: String, confirmTitle: String, onConfirm: @escaping () -> Void) {
sheetTitle = title
self.summaries = summaries
self.notices = notices
self.cancelTitle = cancelTitle
self.confirmTitle = confirmTitle
self.onConfirm = onConfirm
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .pageSheet
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
let titleLabel = Style.label(sheetTitle, size: 20, weight: .semibold)
titleLabel.textAlignment = .center
let summaryStack = Style.stack([], spacing: 0)
for (index, summary) in summaries.enumerated() {
let icon = Style.icon(summary.icon, size: 18)
icon.snp.makeConstraints { $0.width.equalTo(24) }
let title = Style.label(summary.title, size: 14, color: Style.textSecondary)
let value = Style.label(summary.value, size: 15, weight: .semibold)
value.textAlignment = .right
let row = UIStackView(arrangedSubviews: [icon, title, UIView(), value])
row.axis = .horizontal
row.alignment = .center
row.spacing = 8
row.snp.makeConstraints { $0.height.greaterThanOrEqualTo(48) }
summaryStack.addArrangedSubview(row)
if index < summaries.count - 1 {
let divider = UIView()
divider.backgroundColor = Style.border
divider.snp.makeConstraints { $0.height.equalTo(0.5) }
summaryStack.addArrangedSubview(divider)
}
}
let summaryCard = Style.card(summaryStack)
summaryCard.layer.cornerRadius = 12
let noticeStack = Style.stack([], spacing: 12)
for notice in notices {
let icon = Style.icon(notice.icon, size: 16)
icon.tintColor = notice.danger ? Style.danger : Style.primary
icon.snp.makeConstraints { $0.width.equalTo(22) }
let text = Style.label(notice.text, size: 13,
color: notice.danger ? Style.danger : Style.textSecondary)
let row = UIStackView(arrangedSubviews: [icon, text])
row.axis = .horizontal
row.alignment = .top
row.spacing = 8
noticeStack.addArrangedSubview(row)
}
Style.configure(cancelButton, title: cancelTitle, primary: false)
Style.configureDestructive(confirmButton, title: confirmTitle)
cancelButton.accessibilityIdentifier = "deregister.sheet.cancel"
confirmButton.accessibilityIdentifier = "deregister.sheet.confirm"
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
let actions = UIStackView(arrangedSubviews: [cancelButton, confirmButton])
actions.axis = .horizontal
actions.distribution = .fillEqually
actions.spacing = 12
actions.snp.makeConstraints { $0.height.equalTo(50) }
contentStack.axis = .vertical
contentStack.spacing = 16
contentStack.addArrangedSubview(titleLabel)
contentStack.addArrangedSubview(summaryCard)
if !notices.isEmpty { contentStack.addArrangedSubview(noticeStack) }
contentStack.addArrangedSubview(actions)
view.addSubview(contentStack)
contentStack.snp.makeConstraints {
$0.top.equalTo(view.safeAreaLayoutGuide).offset(28)
$0.leading.trailing.equalToSuperview().inset(16)
$0.bottom.lessThanOrEqualTo(view.safeAreaLayoutGuide).inset(12)
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
guard let sheet = sheetPresentationController else { return }
view.layoutIfNeeded()
let sheetWidth = presentingViewController?.view.bounds.width ?? view.bounds.width
let contentWidth = max(0, sheetWidth - 32)
let contentHeight = contentStack.systemLayoutSizeFitting(
CGSize(width: contentWidth, height: UIView.layoutFittingCompressedSize.height),
withHorizontalFittingPriority: .required,
verticalFittingPriority: .fittingSizeLevel
).height
// 自定义 detent 会由系统计入底部安全区,这里只计算内容和视觉间距,避免重复留白。
let preferredHeight = ceil(28 + contentHeight + 12)
let identifier = UISheetPresentationController.Detent.Identifier("accountDeregistrationConfirmation")
sheet.detents = [.custom(identifier: identifier) { context in
min(preferredHeight, context.maximumDetentValue)
}]
sheet.selectedDetentIdentifier = identifier
sheet.prefersGrabberVisible = true
sheet.prefersScrollingExpandsWhenScrolledToEdge = false
sheet.preferredCornerRadius = 22
preferredContentSize.height = preferredHeight
}
@objc private func cancelTapped() { dismiss(animated: true) }
@objc private func confirmTapped() {
confirmButton.isEnabled = false
dismiss(animated: true) { [onConfirm] in onConfirm() }
}
}
@@ -0,0 +1,118 @@
import SnapKit
import UIKit
/// 注销申请明确受理后的退出态结果页;不持有会话、凭证或注销接口。
@MainActor
final class StoreAccountDeregistrationSubmittedViewController: BaseViewController {
private typealias Style = StoreAccountDeregistrationStyle
private let identityName: String
private let coolingUntil: String?
private let onDone: () -> Void
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let bottomBar = UIView()
private let doneButton = Style.button("完成", id: "deregister.submitted.done")
private var didFinish = false
/// 使用提交时冻结的展示信息创建页面;点击完成后由 Scene 进入登录页。
init(identityName: String, coolingUntil: String?, onDone: @escaping () -> Void) {
let normalizedName = identityName.trimmingCharacters(in: .whitespacesAndNewlines)
self.identityName = normalizedName.isEmpty ? "当前门店身份" : normalizedName
self.coolingUntil = coolingUntil?.trimmingCharacters(in: .whitespacesAndNewlines)
self.onDone = onDone
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func setupNavigationBar() {
title = "注销账号"
navigationItem.hidesBackButton = true
}
override func setupUI() {
view.backgroundColor = Style.pageBackground
view.addSubview(scrollView)
scrollView.alwaysBounceVertical = true
scrollView.addSubview(contentStack)
contentStack.axis = .vertical
contentStack.spacing = 18
let badge = Style.iconBadge("lock", iconSize: 36)
let badgeRow = UIView()
badgeRow.addSubview(badge)
badge.snp.makeConstraints { $0.top.bottom.centerX.equalToSuperview() }
let titleLabel = Style.label("注销申请已提交", size: 24, weight: .semibold)
titleLabel.textAlignment = .center
titleLabel.accessibilityIdentifier = "deregister.submitted.title"
let identityLabel = Style.label("当前身份:\(identityName)", size: 15, color: Style.textSecondary)
identityLabel.textAlignment = .center
identityLabel.accessibilityIdentifier = "deregister.submitted.identity"
contentStack.addArrangedSubview(Style.stack([badgeRow, titleLabel, identityLabel], spacing: 12))
let deadlineIcon = Style.icon("calendar", size: 22)
deadlineIcon.snp.makeConstraints { $0.width.equalTo(28) }
let deadlineTextValue = coolingUntil.flatMap { $0.isEmpty ? nil : $0 } ?? "以服务端状态为准"
let deadlineLabel = Style.label(deadlineTextValue, size: 19, weight: .semibold)
deadlineLabel.accessibilityIdentifier = "deregister.submitted.deadline"
let deadlineText = Style.stack([
Style.label("冷静期截止时间", size: 13, color: Style.textSecondary),
deadlineLabel
], spacing: 8)
let deadlineRow = UIStackView(arrangedSubviews: [deadlineIcon, deadlineText])
deadlineRow.axis = .horizontal
deadlineRow.alignment = .center
deadlineRow.spacing = 12
contentStack.addArrangedSubview(Style.card(deadlineRow))
let infoIcon = Style.icon("info.circle", size: 17)
infoIcon.snp.makeConstraints { $0.width.equalTo(22) }
let infoLabel = Style.label("重新登录该身份自动撤销申请", size: 13, color: Style.textSecondary)
let infoRow = UIStackView(arrangedSubviews: [infoIcon, infoLabel])
infoRow.axis = .horizontal
infoRow.alignment = .center
infoRow.spacing = 8
contentStack.addArrangedSubview(Style.card(infoRow, background: Style.infoBackground))
view.addSubview(bottomBar)
bottomBar.backgroundColor = .white
bottomBar.addSubview(doneButton)
doneButton.addTarget(self, action: #selector(doneTapped), for: .touchUpInside)
doneButton.snp.makeConstraints {
$0.top.equalToSuperview().offset(12)
$0.leading.trailing.equalToSuperview().inset(16)
$0.bottom.equalToSuperview().inset(12)
}
}
override func setupConstraints() {
bottomBar.snp.makeConstraints {
$0.leading.trailing.equalToSuperview()
$0.bottom.equalTo(view.safeAreaLayoutGuide)
}
scrollView.snp.makeConstraints {
$0.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
$0.bottom.equalTo(bottomBar.snp.top)
}
contentStack.snp.makeConstraints {
$0.top.equalTo(scrollView.contentLayoutGuide).offset(32)
$0.leading.trailing.bottom.equalTo(scrollView.contentLayoutGuide).inset(16)
$0.width.equalTo(scrollView.frameLayoutGuide).offset(-32)
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationItem.hidesBackButton = true
navigationController?.interactivePopGestureRecognizer?.isEnabled = false
}
@objc private func doneTapped() {
guard !didFinish else { return }
didFinish = true
doneButton.isEnabled = false
onDone()
}
}
@@ -0,0 +1,547 @@
import IQKeyboardCore
import IQKeyboardManagerSwift
import SnapKit
import UIKit
/// 简洁两步注销页面;一次确认两项资产,短信和实际申请仍由用户主动操作。
@MainActor
final class StoreAccountDeregistrationViewController: BaseViewController {
private typealias Style = StoreAccountDeregistrationStyle
private let viewModel: StoreAccountDeregistrationViewModel
private let api: any StoreAccountDeregistrationServing
private let identityName: String
private let readOnly: Bool
private let onUnresolvedSubmission: (() -> Void)?
private let onSubmissionAccepted: (String?) -> Void
private var didHandleAcceptedSubmission = false
private let scrollView = UIScrollView()
private let refreshControl = UIRefreshControl()
private let content = UIStackView()
private let bottomBar = UIView()
private let steps = UIStackView()
private let firstStepNumber = Style.label("1", size: 11, weight: .semibold)
private let secondStepNumber = Style.label("2", size: 11, weight: .semibold)
private let firstStep = Style.label("确认资产", size: 13, weight: .semibold)
private let secondStep = Style.label("手机验证", size: 13, weight: .semibold)
private let firstStepView = UIView()
private let secondStepView = UIView()
private let heading = Style.label(size: 24, weight: .semibold)
private let subtitle = Style.label(size: 14, color: Style.textSecondary)
private let conditionStack = UIStackView()
private let verificationStack = UIStackView()
private let walletAmount = Style.label("—", size: 28, weight: .semibold)
private let pointsAmount = Style.label("—", size: 28, weight: .semibold)
private let walletState = Style.label(size: 12, color: Style.textSecondary)
private let pointsState = Style.label(size: 12, color: Style.textSecondary)
private let assetHint = Style.label(size: 13, color: Style.textSecondary)
private let blockersLabel = Style.label(size: 14, color: Style.textSecondary)
private let riskLabel = Style.label(size: 13, color: AppColor.warning)
private let statusLabel = Style.label(size: 13, color: Style.textSecondary)
private let errorLabel = Style.label(size: 14, color: Style.danger)
private let codeField = UITextField()
private let reasonField = UITextView()
private let reasonTitle = Style.label(size: 16, weight: .semibold)
private let reasonPlaceholder = Style.label("请填写注销原因", size: 14, color: Style.textSecondary)
private let reasonCount = Style.label("0/50", size: 12, color: Style.textSecondary)
private let reasonValidationLabel = Style.label("请填写注销原因", size: 12, color: Style.danger)
private let reasonContainer = UIView()
private let smsButton = UIButton(type: .system)
private let continueButton = Style.button("确认资产并继续", id: "deregister.continue")
private let submitButton = Style.button("提交注销申请", id: "deregister.submit")
private var identityCard = UIView()
private var blockersCard = UIView()
private var errorCard = UIView()
private var actionTask: Task<Void, Never>?
private var previousPopGestureEnabled: Bool?
private var previousViewportHeight: CGFloat = 0
private var reasonWasBlurred = false
/// 注入当前身份及旧接口,不读取任意手机号,也不自动确认资产。
init(identityName: String, viewModel: StoreAccountDeregistrationViewModel,
api: any StoreAccountDeregistrationServing, readOnly: Bool = false,
onUnresolvedSubmission: (() -> Void)? = nil,
onSubmissionAccepted: ((String?) -> Void)? = nil) {
let frozenIdentityName = identityName
self.onSubmissionAccepted = onSubmissionAccepted ?? { coolingUntil in
var userInfo = [NotificationUserInfoKey.deregistrationIdentityName: frozenIdentityName]
if let coolingUntil { userInfo[NotificationUserInfoKey.deregistrationCoolingUntil] = coolingUntil }
NotificationCenter.default.post(
name: NotificationName.storeAccountDeregistrationSubmitted,
object: nil,
userInfo: userInfo
)
}
self.identityName = identityName
self.viewModel = viewModel
self.api = api
self.readOnly = readOnly
self.onUnresolvedSubmission = onUnresolvedSubmission
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func setupNavigationBar() {
title = readOnly ? "注销条件" : "注销账号"
}
override func setupUI() {
view.backgroundColor = Style.pageBackground
view.addSubview(scrollView)
refreshControl.accessibilityIdentifier = "deregister.refresh"
refreshControl.accessibilityLabel = "下拉刷新"
refreshControl.tintColor = .clear
refreshControl.addTarget(self, action: #selector(refreshTapped), for: .valueChanged)
scrollView.refreshControl = refreshControl
scrollView.addSubview(content)
content.axis = .vertical
content.spacing = 16
content.addArrangedSubview(steps)
steps.axis = .horizontal
steps.alignment = .fill
steps.distribution = .fillEqually
steps.spacing = 12
configureStep(firstStepView, number: firstStepNumber, title: firstStep,
identifier: "deregister.step.assets", accessibilityLabel: "第 1 步,确认资产")
configureStep(secondStepView, number: secondStepNumber, title: secondStep,
identifier: "deregister.step.verification", accessibilityLabel: "第 2 步,手机验证")
steps.addArrangedSubview(firstStepView)
steps.addArrangedSubview(secondStepView)
steps.snp.makeConstraints { $0.height.equalTo(46) }
steps.isHidden = readOnly
content.addArrangedSubview(Style.stack([heading, subtitle], spacing: 8))
let identityText = Style.stack([
Style.label("当前门店身份", size: 12, color: Style.textSecondary),
Style.label(identityName, size: 17, weight: .semibold)
], spacing: 5)
let identityRow = UIStackView(arrangedSubviews: [Style.icon("person.crop.circle"), identityText])
identityRow.axis = .horizontal
identityRow.alignment = .center
identityRow.spacing = 12
identityCard = Style.card(identityRow)
content.addArrangedSubview(identityCard)
conditionStack.axis = .vertical
conditionStack.spacing = 16
verificationStack.axis = .vertical
verificationStack.spacing = 16
buildConditions()
buildVerification()
content.addArrangedSubview(conditionStack)
content.addArrangedSubview(verificationStack)
errorCard = Style.card(errorLabel)
errorCard.backgroundColor = UIColor(hex: 0xFFF1F1)
content.addArrangedSubview(errorCard)
content.addArrangedSubview(statusLabel)
statusLabel.accessibilityIdentifier = "deregister.status"
errorLabel.accessibilityIdentifier = "deregister.error"
view.addSubview(bottomBar)
bottomBar.backgroundColor = .white
let actions = Style.stack([continueButton, submitButton], spacing: 10)
bottomBar.addSubview(actions)
actions.snp.makeConstraints { $0.edges.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)) }
bottomBar.isHidden = readOnly
continueButton.addTarget(self, action: #selector(continueTapped), for: .touchUpInside)
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
scrollView.keyboardDismissMode = .onDrag
scrollView.alwaysBounceVertical = true
}
private func configureStep(_ container: UIView, number: UILabel, title: UILabel,
identifier: String, accessibilityLabel: String) {
number.numberOfLines = 1
title.numberOfLines = 1
number.textAlignment = .center
number.layer.cornerRadius = 10
number.clipsToBounds = true
number.snp.makeConstraints { $0.width.height.equalTo(20) }
let row = UIStackView(arrangedSubviews: [number, title])
row.axis = .horizontal
row.alignment = .center
row.spacing = 7
title.setContentCompressionResistancePriority(.required, for: .horizontal)
container.layer.cornerRadius = 12
container.accessibilityIdentifier = identifier
container.isAccessibilityElement = true
container.accessibilityLabel = accessibilityLabel
container.addSubview(row)
row.snp.makeConstraints { $0.center.equalToSuperview() }
}
private func buildConditions() {
let columns = UIStackView()
columns.axis = .horizontal
columns.distribution = .fill
columns.spacing = 16
let walletColumn = Style.stack([
Style.label("现金余额", size: 13, color: Style.textSecondary), walletAmount, walletState
], spacing: 8)
columns.addArrangedSubview(walletColumn)
let divider = UIView()
divider.backgroundColor = Style.border
divider.snp.makeConstraints { $0.width.equalTo(1) }
columns.addArrangedSubview(divider)
let pointsColumn = Style.stack([
Style.label("积分", size: 13, color: Style.textSecondary), pointsAmount, pointsState
], spacing: 8)
columns.addArrangedSubview(pointsColumn)
walletColumn.snp.makeConstraints { $0.width.equalTo(pointsColumn) }
walletAmount.adjustsFontSizeToFitWidth = true
walletAmount.minimumScaleFactor = 0.6
walletAmount.numberOfLines = 1
pointsAmount.adjustsFontSizeToFitWidth = true
pointsAmount.minimumScaleFactor = 0.6
pointsAmount.numberOfLines = 1
walletState.accessibilityIdentifier = "deregister.wallet.state"
pointsState.accessibilityIdentifier = "deregister.points.state"
let assets = Style.card(Style.stack([
Style.label("账号资产", size: 16, weight: .semibold), columns, assetHint
], spacing: 16))
assets.accessibilityIdentifier = "deregister.assets"
conditionStack.addArrangedSubview(assets)
blockersLabel.accessibilityIdentifier = "deregister.blockers"
blockersCard = Style.card(Style.stack([
Style.label("待处理事项", size: 16, weight: .semibold), blockersLabel, riskLabel
]))
conditionStack.addArrangedSubview(blockersCard)
let notices = Style.stack([
notice("person.crop.circle", title: "仅注销当前身份"),
notice("clock", title: "7 天冷静期"),
notice("exclamationmark.shield", title: "正式完成后不可恢复")
], spacing: 16)
conditionStack.addArrangedSubview(Style.card(notices, background: Style.infoBackground))
}
private func notice(_ icon: String, title: String) -> UIView {
let image = Style.icon(icon, size: 18)
image.snp.makeConstraints { $0.width.equalTo(22) }
let row = UIStackView(arrangedSubviews: [image, Style.label(title, size: 14, weight: .medium)])
row.axis = .horizontal
row.alignment = .center
row.spacing = 10
return row
}
private func buildVerification() {
codeField.placeholder = "请输入短信验证码"
codeField.keyboardType = .numberPad
codeField.textContentType = .oneTimeCode
codeField.accessibilityIdentifier = "deregister.code"
codeField.accessibilityLabel = "短信验证码"
reasonField.accessibilityIdentifier = "deregister.reason"
reasonField.accessibilityLabel = "注销原因,必填"
codeField.font = .systemFont(ofSize: 16)
codeField.autocorrectionType = .no
codeField.iq.enableMode = .disabled
codeField.addTarget(self, action: #selector(inputChanged), for: .editingChanged)
codeField.addTarget(self, action: #selector(revealFocusedInput), for: .editingDidBegin)
reasonField.font = .systemFont(ofSize: 15)
reasonField.textColor = Style.textPrimary
reasonField.backgroundColor = .clear
reasonField.delegate = self
reasonField.autocorrectionType = .no
reasonField.iq.enableMode = .disabled
reasonField.textContainerInset = .init(top: 12, left: 12, bottom: 24, right: 12)
codeField.snp.makeConstraints { $0.height.equalTo(50) }
reasonField.snp.makeConstraints { $0.height.equalTo(96) }
smsButton.setTitle("获取验证码", for: .normal)
smsButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
smsButton.accessibilityIdentifier = "deregister.sms"
smsButton.setContentHuggingPriority(.required, for: .horizontal)
smsButton.setContentCompressionResistancePriority(.required, for: .horizontal)
smsButton.addTarget(self, action: #selector(smsTapped), for: .touchUpInside)
smsButton.snp.makeConstraints { $0.height.greaterThanOrEqualTo(44) }
let codeRow = UIStackView(arrangedSubviews: [codeField, smsButton])
codeRow.axis = .horizontal
codeRow.alignment = .center
codeRow.spacing = 12
codeRow.layoutMargins = .init(top: 0, left: 14, bottom: 0, right: 8)
codeRow.isLayoutMarginsRelativeArrangement = true
codeRow.layer.cornerRadius = 10
codeRow.layer.borderWidth = 1
codeRow.layer.borderColor = Style.border.cgColor
let smsTitle = Style.label("短信验证码", size: 16, weight: .semibold)
reasonContainer.layer.cornerRadius = 10
reasonContainer.layer.borderWidth = 1
reasonContainer.layer.borderColor = Style.border.cgColor
reasonContainer.addSubview(reasonField)
reasonContainer.addSubview(reasonPlaceholder)
reasonContainer.addSubview(reasonCount)
reasonField.snp.makeConstraints { $0.edges.equalToSuperview() }
reasonPlaceholder.snp.makeConstraints { $0.top.leading.equalToSuperview().inset(16) }
reasonCount.snp.makeConstraints { $0.trailing.bottom.equalToSuperview().inset(12) }
let titleText = NSMutableAttributedString(
string: "注销原因 ",
attributes: [.font: UIFont.systemFont(ofSize: 16, weight: .semibold), .foregroundColor: Style.textPrimary]
)
titleText.append(NSAttributedString(
string: "*",
attributes: [.font: UIFont.systemFont(ofSize: 16, weight: .semibold), .foregroundColor: Style.danger]
))
reasonTitle.attributedText = titleText
reasonTitle.accessibilityLabel = "注销原因,必填"
reasonTitle.accessibilityIdentifier = "deregister.reason.title"
reasonValidationLabel.isHidden = true
reasonValidationLabel.accessibilityIdentifier = "deregister.reason.error"
let form = Style.stack([smsTitle, codeRow, reasonTitle, reasonContainer, reasonValidationLabel], spacing: 12)
form.setCustomSpacing(18, after: codeRow)
verificationStack.addArrangedSubview(Style.card(form))
let helperIcon = Style.icon("shield", size: 16)
helperIcon.tintColor = Style.textSecondary
helperIcon.snp.makeConstraints { $0.width.equalTo(22) }
let helper = UIStackView(arrangedSubviews: [
helperIcon,
Style.label("验证码将发送至当前身份绑定的手机号。", size: 13, color: Style.textSecondary)
])
helper.axis = .horizontal
helper.alignment = .center
helper.spacing = 8
verificationStack.addArrangedSubview(helper)
}
override func setupConstraints() {
bottomBar.snp.makeConstraints {
$0.leading.trailing.equalToSuperview()
$0.bottom.equalTo(view.keyboardLayoutGuide.snp.top)
}
scrollView.snp.makeConstraints {
$0.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
if readOnly { $0.bottom.equalTo(view.safeAreaLayoutGuide) }
else { $0.bottom.equalTo(bottomBar.snp.top) }
}
content.snp.makeConstraints {
$0.edges.equalTo(scrollView.contentLayoutGuide).inset(16)
$0.width.equalTo(scrollView.frameLayoutGuide).offset(-32)
}
}
override func viewDidLoad() {
super.viewDidLoad()
applyViewModel()
refreshTapped()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
previousPopGestureEnabled = navigationController?.interactivePopGestureRecognizer?.isEnabled
applyViewModel()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if let previousPopGestureEnabled {
navigationController?.interactivePopGestureRecognizer?.isEnabled = previousPopGestureEnabled
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
guard scrollView.bounds.height != previousViewportHeight else { return }
previousViewportHeight = scrollView.bounds.height
revealFocusedInput()
}
@objc private func revealFocusedInput() {
guard let field = ([codeField, reasonField] as [UIView]).first(where: \.isFirstResponder) else { return }
let rect = field.convert(field.bounds, to: scrollView).insetBy(dx: 0, dy: -12)
scrollView.scrollRectToVisible(rect, animated: false)
}
private func applyViewModel() {
let busy = actionTask != nil || viewModel.isBusy
let verifying = viewModel.step == .verification && !readOnly
let unresolved = viewModel.step == .unresolvedRequest && !readOnly
heading.text = readOnly ? "当前注销条件" : (verifying ? "验证绑定手机号" : "注销前,请确认")
subtitle.text = verifying ? "完成验证后,即可提交注销申请。" : "仅注销此身份,其他身份不受影响。"
applyStepStyle(firstStepView, number: firstStepNumber, title: firstStep, selected: !verifying)
applyStepStyle(secondStepView, number: secondStepNumber, title: secondStep, selected: verifying)
identityCard.isHidden = verifying || unresolved
conditionStack.isHidden = verifying || unresolved
verificationStack.isHidden = !verifying
continueButton.isHidden = verifying || readOnly
submitButton.isHidden = !verifying
continueButton.configuration?.title = viewModel.requiresAssetConfirmation ? "确认资产并继续" : "下一步"
continueButton.isEnabled = !busy && viewModel.canConfirmAssetsAndContinue
submitButton.isEnabled = !busy && viewModel.canContinue && hasVerificationInput
smsButton.isEnabled = !busy && viewModel.canContinue
codeField.isEnabled = !busy
reasonField.isEditable = !busy
applyReasonInputStyle()
refreshControl.isEnabled = !busy
errorLabel.text = viewModel.errorMessage
errorCard.isHidden = viewModel.errorMessage == nil
if let value = viewModel.eligibility {
walletAmount.text = "¥\(value.walletBalance)"
pointsAmount.text = "\(value.pointsBalance)"
walletState.text = value.walletWaived ? "已确认放弃" : "待确认放弃"
pointsState.text = value.pointsWaived ? "已确认放弃" : "待确认放弃"
walletState.textColor = value.walletWaived ? Style.primary : Style.textSecondary
pointsState.textColor = value.pointsWaived ? Style.primary : Style.textSecondary
let assetIssues = value.blockers.filter(\.isAssetConfirmation)
let needsAssetHint = readOnly || assetIssues.contains { $0.code == "WAIVER_STALE" }
assetHint.isHidden = !needsAssetHint
assetHint.text = readOnly ? "以当前查询结果为准,冷静期内不能再次确认资产。"
: "资产已变化,请按最新金额重新确认。"
blockersCard.isHidden = value.businessBlockers.isEmpty
blockersLabel.text = value.businessBlockers.map { "• \($0.message)\n \($0.guidance)" }.joined(separator: "\n\n")
riskLabel.text = value.eligibleAt.map { "业务风险期预计结束:\($0)" }
riskLabel.isHidden = value.eligibleAt == nil
} else {
walletAmount.text = "—"
pointsAmount.text = "—"
walletState.text = "待查询"
pointsState.text = "待查询"
assetHint.isHidden = false
assetHint.text = busy ? "正在查询资产与注销条件…" : "暂未获取到资产,请刷新重试。"
blockersCard.isHidden = true
}
statusLabel.text = unresolved ? "申请状态待确认,请刷新后重试,暂勿重复提交。"
: (viewModel.status?.isCancelled == true ? "上次申请已撤销,可重新申请。" : nil)
statusLabel.isHidden = statusLabel.text == nil
let preventsBack = busy || unresolved || (!readOnly && viewModel.submissionAttempted)
navigationItem.hidesBackButton = preventsBack
if view.window != nil {
navigationController?.interactivePopGestureRecognizer?.isEnabled = !preventsBack && (previousPopGestureEnabled ?? true)
}
}
private func applyStepStyle(_ container: UIView, number: UILabel, title: UILabel, selected: Bool) {
container.backgroundColor = selected ? Style.primary : UIColor(hex: 0xE9EEF5)
container.accessibilityTraits = selected ? [.selected] : []
number.backgroundColor = selected ? UIColor.white.withAlphaComponent(0.2) : UIColor(hex: 0xDCE3EC)
number.textColor = selected ? .white : Style.textSecondary
title.textColor = selected ? .white : Style.textSecondary
}
private var hasVerificationInput: Bool {
let code = (codeField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let reason = (reasonField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return !code.isEmpty && !reason.isEmpty && reason.count <= 50
}
private func applyReasonInputStyle() {
let missing = (reasonField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
let invalid = reasonWasBlurred && missing
reasonContainer.layer.borderColor = reasonField.isFirstResponder
? Style.primary.cgColor
: (invalid ? Style.danger.cgColor : Style.border.cgColor)
reasonValidationLabel.isHidden = !invalid
}
private func run(_ operation: @escaping @MainActor () async -> Void) {
guard actionTask == nil, !didHandleAcceptedSubmission else {
refreshControl.endRefreshing()
return
}
actionTask = Task { @MainActor [weak self] in
guard let self else { return }
self.showLoading()
await operation()
self.hideLoading()
self.refreshControl.endRefreshing()
self.actionTask = nil
if !self.readOnly, self.viewModel.submissionAccepted {
self.didHandleAcceptedSubmission = true
self.onSubmissionAccepted(self.viewModel.submittedCoolingUntil)
return
}
self.applyViewModel()
if !self.readOnly, self.viewModel.submissionAttempted || self.viewModel.status?.isCooling == true {
self.onUnresolvedSubmission?()
}
}
applyViewModel()
}
@objc private func inputChanged() { applyViewModel() }
@objc private func refreshTapped() { run { [self] in await self.viewModel.refresh(api: self.api) } }
@objc private func continueTapped() {
guard !readOnly, viewModel.canConfirmAssetsAndContinue, let snapshot = viewModel.eligibility else { return }
if !viewModel.requiresAssetConfirmation {
run { [self] in await self.viewModel.confirmAssetsAndContinue(snapshot: snapshot, api: self.api) }
return
}
let sheet = StoreAccountDeregistrationConfirmationSheetViewController(
title: "确认放弃账号资产",
summaries: [
.init(icon: "person.crop.circle", title: "当前身份", value: identityName),
.init(icon: "banknote", title: "现金余额", value: "¥\(snapshot.walletBalance)"),
.init(icon: "star.circle", title: "积分", value: "\(snapshot.pointsBalance)")
],
notices: [],
cancelTitle: "暂不确认", confirmTitle: "确认放弃"
) { [weak self] in
guard let self else { return }
self.run { [self] in await self.viewModel.confirmAssetsAndContinue(snapshot: snapshot, api: self.api) }
}
present(sheet, animated: true)
}
@objc private func smsTapped() {
guard !readOnly else { return }
run { [self] in
if await self.viewModel.sendSMS(api: self.api) { showToast("验证码已发送至当前身份绑定手机号") }
}
}
@objc private func submitTapped() {
guard !readOnly, viewModel.canContinue, hasVerificationInput else { return }
let code = codeField.text ?? ""
let reason = (reasonField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
view.endEditing(true)
let sheet = StoreAccountDeregistrationConfirmationSheetViewController(
title: "确认提交注销申请?",
summaries: [
.init(icon: "person.crop.circle", title: "当前身份", value: identityName),
.init(icon: "clock", title: "冷静期", value: "7 天"),
.init(icon: "text.bubble", title: "注销原因", value: reason)
],
notices: [
("info.circle", "注销后当前身份将无法使用", false),
("checkmark.shield", "冷静期内可撤销,重新登录该身份自动撤销申请", false),
("exclamationmark.circle.fill", "正式完成后不可恢复,历史记录保留", true)
],
cancelTitle: "我再想想", confirmTitle: "确认提交"
) { [weak self] in
self?.submitConfirmedApplication(smsCode: code, reason: reason)
}
present(sheet, animated: true)
}
/// 用户确认最终弹窗后提交;仅明确成功触发一次退出态结果页,未知结果仍进入核验流程。
func submitConfirmedApplication(smsCode: String, reason: String) {
guard !readOnly, viewModel.validateSubmissionInput(smsCode: smsCode, reason: reason) else {
applyViewModel()
return
}
run { [self] in await viewModel.submit(smsCode: smsCode, reason: reason, api: api) }
}
}
extension StoreAccountDeregistrationViewController: UITextViewDelegate {
/// 注销原因必填;限制长度并同步必填状态、占位和计数展示。
func textViewDidChange(_ textView: UITextView) {
if textView.text.count > 50 {
textView.text = String(textView.text.prefix(50))
}
reasonPlaceholder.isHidden = !textView.text.isEmpty
reasonCount.text = "\(textView.text.count)/50"
if !textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
reasonWasBlurred = false
}
applyViewModel()
}
func textViewDidBeginEditing(_ textView: UITextView) {
applyReasonInputStyle()
revealFocusedInput()
}
func textViewDidEndEditing(_ textView: UITextView) {
reasonWasBlurred = true
applyReasonInputStyle()
}
}
@@ -22,8 +22,14 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
private let freeCountField = UITextField() private let freeCountField = UITextField()
private let singlePriceField = UITextField() private let singlePriceField = UITextField()
private let packagePriceField = UITextField() private let packagePriceField = UITextField()
private let autoRetouchSectionView = UIView()
private let autoRetouchTitleLabel = UILabel()
private let autoRetouchDetailLabel = UILabel()
private let noRetouchOption = TravelAlbumModeOptionView()
private let aiRetouchOption = TravelAlbumModeOptionView()
private let cancelButton = UIButton(type: .system) private let cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system) private let confirmButton = UIButton(type: .system)
private var autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled
init(viewModel: TravelAlbumEntryViewModel, api: any TravelAlbumServing) { init(viewModel: TravelAlbumEntryViewModel, api: any TravelAlbumServing) {
self.viewModel = viewModel self.viewModel = viewModel
@@ -31,8 +37,14 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .pageSheet modalPresentationStyle = .pageSheet
if let sheetPresentationController { if let sheetPresentationController {
sheetPresentationController.detents = [.medium(), .large()] let formDetent = UISheetPresentationController.Detent.Identifier("createTravelAlbumForm")
sheetPresentationController.detents = [
.custom(identifier: formDetent) { min(650, $0.maximumDetentValue) },
.large(),
]
sheetPresentationController.selectedDetentIdentifier = formDetent
sheetPresentationController.prefersGrabberVisible = false sheetPresentationController.prefersGrabberVisible = false
sheetPresentationController.preferredCornerRadius = 22
} }
} }
@@ -62,6 +74,7 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
configureTextField(freeCountField, placeholder: "请输入免费张数", keyboardType: .numberPad) configureTextField(freeCountField, placeholder: "请输入免费张数", keyboardType: .numberPad)
configureTextField(singlePriceField, placeholder: "请输入单张照片价格", keyboardType: .decimalPad) configureTextField(singlePriceField, placeholder: "请输入单张照片价格", keyboardType: .decimalPad)
configureTextField(packagePriceField, placeholder: "请输入打包价格", keyboardType: .decimalPad) configureTextField(packagePriceField, placeholder: "请输入打包价格", keyboardType: .decimalPad)
configureAutoRetouchSection()
configureActionButton(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xF4F4F4), titleColor: AppColor.textSecondary) configureActionButton(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xF4F4F4), titleColor: AppColor.textSecondary)
configureActionButton(confirmButton, title: "确定", backgroundColor: AppColor.primary, titleColor: .white) configureActionButton(confirmButton, title: "确定", backgroundColor: AppColor.primary, titleColor: .white)
@@ -112,6 +125,8 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
[freeCountField, singlePriceField, packagePriceField].forEach { [freeCountField, singlePriceField, packagePriceField].forEach {
$0.addTarget(self, action: #selector(textFieldEditingChanged(_:)), for: .editingChanged) $0.addTarget(self, action: #selector(textFieldEditingChanged(_:)), for: .editingChanged)
} }
noRetouchOption.addTarget(self, action: #selector(noRetouchTapped), for: .touchUpInside)
aiRetouchOption.addTarget(self, action: #selector(aiRetouchTapped), for: .touchUpInside)
} }
override func viewDidDisappear(_ animated: Bool) { override func viewDidDisappear(_ animated: Bool) {
@@ -148,6 +163,42 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
fieldsStack.addArrangedSubview(makeFieldGroup(title: "免费张数", required: false, field: freeCountField)) fieldsStack.addArrangedSubview(makeFieldGroup(title: "免费张数", required: false, field: freeCountField))
fieldsStack.addArrangedSubview(makeFieldGroup(title: "单张照片价格(元)", required: true, field: singlePriceField)) fieldsStack.addArrangedSubview(makeFieldGroup(title: "单张照片价格(元)", required: true, field: singlePriceField))
fieldsStack.addArrangedSubview(makeFieldGroup(title: "打包价格(元)", required: false, field: packagePriceField)) fieldsStack.addArrangedSubview(makeFieldGroup(title: "打包价格(元)", required: false, field: packagePriceField))
fieldsStack.addArrangedSubview(autoRetouchSectionView)
}
private func configureAutoRetouchSection() {
autoRetouchSectionView.backgroundColor = .white
autoRetouchSectionView.layer.cornerRadius = 10
autoRetouchSectionView.layer.borderWidth = 1
autoRetouchSectionView.layer.borderColor = AppColor.border.cgColor
autoRetouchTitleLabel.text = "修图方式"
autoRetouchTitleLabel.font = .systemFont(ofSize: 14, weight: .medium)
autoRetouchTitleLabel.textColor = AppColor.textPrimary
autoRetouchDetailLabel.text = "选择 AI 修图后,需要再选一个效果模板"
autoRetouchDetailLabel.font = .systemFont(ofSize: 12)
autoRetouchDetailLabel.textColor = AppColor.textSecondary
let optionsStack = UIStackView(arrangedSubviews: [noRetouchOption, aiRetouchOption])
optionsStack.axis = .horizontal
optionsStack.spacing = 10
optionsStack.distribution = .fillEqually
autoRetouchSectionView.addSubview(autoRetouchTitleLabel)
autoRetouchSectionView.addSubview(autoRetouchDetailLabel)
autoRetouchSectionView.addSubview(optionsStack)
autoRetouchTitleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(12)
make.leading.trailing.equalToSuperview().inset(14)
}
autoRetouchDetailLabel.snp.makeConstraints { make in
make.top.equalTo(autoRetouchTitleLabel.snp.bottom).offset(4)
make.leading.trailing.equalTo(autoRetouchTitleLabel)
}
optionsStack.snp.makeConstraints { make in
make.top.equalTo(autoRetouchDetailLabel.snp.bottom).offset(12)
make.leading.trailing.bottom.equalToSuperview().inset(12)
make.height.equalTo(76)
}
updateAutoRetouchSection()
} }
private func makeFieldGroup(title: String, required: Bool, field: UITextField) -> UIView { private func makeFieldGroup(title: String, required: Bool, field: UITextField) -> UIView {
@@ -188,6 +239,7 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
freeCount: freeCountField.text ?? "", freeCount: freeCountField.text ?? "",
singlePrice: singlePriceField.text ?? "", singlePrice: singlePriceField.text ?? "",
packagePrice: packagePriceField.text ?? "", packagePrice: packagePriceField.text ?? "",
autoRetouchConfiguration: autoRetouchConfiguration,
order: nil, order: nil,
api: api api: api
) )
@@ -199,6 +251,42 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
} }
} }
@objc private func noRetouchTapped() {
autoRetouchConfiguration = .disabled
updateAutoRetouchSection()
}
@objc private func aiRetouchTapped() {
guard presentedViewController == nil else { return }
let settingViewModel = TravelAlbumAutoRetouchSettingViewModel(
scenicId: AppStore.shared.session.currentScenicId,
configuration: autoRetouchConfiguration,
allowsModeSelection: false
)
let controller = TravelAlbumAutoRetouchSettingSheetViewController(
viewModel: settingViewModel,
api: api
)
controller.onConfirm = { [weak self] configuration in
self?.autoRetouchConfiguration = configuration
self?.updateAutoRetouchSection()
}
present(controller, animated: true)
}
private func updateAutoRetouchSection() {
noRetouchOption.apply(title: "不修图", desc: "保留原图", selected: !autoRetouchConfiguration.enabled)
aiRetouchOption.apply(
title: "AI 修图",
desc: autoRetouchConfiguration.enabled ? "已选择真实 AI 模板" : "点击选模板",
selected: autoRetouchConfiguration.enabled
)
noRetouchOption.accessibilityLabel = "不修图,保留原图"
aiRetouchOption.accessibilityLabel = "AI 修图,点击选择模板"
noRetouchOption.accessibilityValue = autoRetouchConfiguration.enabled ? "未选择" : "已选择"
aiRetouchOption.accessibilityValue = autoRetouchConfiguration.enabled ? "已选择" : "未选择"
}
@objc private func textFieldEditingChanged(_ field: UITextField) { @objc private func textFieldEditingChanged(_ field: UITextField) {
let text = field.text ?? "" let text = field.text ?? ""
if field === freeCountField { if field === freeCountField {
@@ -533,7 +533,7 @@ private final class AIJobDetailAlbumCard: UIView {
} }
} }
/// 任务内容卡,以浅蓝标签展示各输出目标数量并补充额度结算。 /// 任务内容卡,以三色紧凑标签展示各输出目标数量并补充额度结算。
private final class AIJobDetailContentCard: UIView { private final class AIJobDetailContentCard: UIView {
private let outputStack = UIStackView() private let outputStack = UIStackView()
private let quotaLabel = UILabel() private let quotaLabel = UILabel()
@@ -548,7 +548,8 @@ private final class AIJobDetailContentCard: UIView {
titleLabel.textColor = AIJobDetailStyle.textPrimary titleLabel.textColor = AIJobDetailStyle.textPrimary
outputStack.axis = .horizontal outputStack.axis = .horizontal
outputStack.spacing = 10 outputStack.spacing = 10
outputStack.distribution = .fillEqually outputStack.distribution = .fill
outputStack.alignment = .center
quotaLabel.font = .systemFont(ofSize: 12) quotaLabel.font = .systemFont(ofSize: 12)
quotaLabel.textColor = AIJobDetailStyle.textSecondary quotaLabel.textColor = AIJobDetailStyle.textSecondary
quotaLabel.numberOfLines = 0 quotaLabel.numberOfLines = 0
@@ -565,6 +566,12 @@ private final class AIJobDetailContentCard: UIView {
func apply(_ detail: TravelAlbumAIJobDetail) { func apply(_ detail: TravelAlbumAIJobDetail) {
outputStack.arrangedSubviews.forEach { $0.removeFromSuperview() } outputStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
detail.outputs.forEach { outputStack.addArrangedSubview(makeChip($0)) } detail.outputs.forEach { outputStack.addArrangedSubview(makeChip($0)) }
if let lastChip = outputStack.arrangedSubviews.last {
let spacer = UIView()
spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
outputStack.setCustomSpacing(0, after: lastChip)
outputStack.addArrangedSubview(spacer)
}
outputStack.isHidden = detail.outputs.isEmpty outputStack.isHidden = detail.outputs.isEmpty
let quota = detail.quotaSettlement let quota = detail.quotaSettlement
quotaLabel.text = "额度:预占 \(quota.reservedUnits) · 消耗 \(quota.consumedUnits) · 释放 \(quota.releasedUnits)" quotaLabel.text = "额度:预占 \(quota.reservedUnits) · 消耗 \(quota.consumedUnits) · 释放 \(quota.releasedUnits)"
@@ -573,32 +580,42 @@ private final class AIJobDetailContentCard: UIView {
} }
private func makeChip(_ output: TravelAlbumAIJobOutput) -> UIView { private func makeChip(_ output: TravelAlbumAIJobOutput) -> UIView {
let container = UIView() AIJobDetailOutputChip(
container.backgroundColor = AppColor.primaryLight text: "\(output.type.shortTitle) \(output.count)张",
container.layer.cornerRadius = 9 backgroundColor: output.type.chipBackgroundColor,
let iconView = UIImageView(image: UIImage(systemName: output.type.symbolName)) accessibilityIdentifier: "aiRetouchJob.contentChip.\(output.type.chipIdentifier)"
iconView.tintColor = AppColor.primary )
iconView.contentMode = .scaleAspectFit }
let label = UILabel() }
label.text = "\(output.type.shortTitle) \(output.count) 张"
/// 依据文字固有宽度展示的任务输出类型标签。
private final class AIJobDetailOutputChip: UIView {
private let label = UILabel()
init(text: String, backgroundColor: UIColor, accessibilityIdentifier: String) {
super.init(frame: .zero)
self.backgroundColor = backgroundColor
self.accessibilityIdentifier = accessibilityIdentifier
layer.cornerRadius = 9
setContentHuggingPriority(.required, for: .horizontal)
setContentCompressionResistancePriority(.required, for: .horizontal)
label.text = text
label.font = .systemFont(ofSize: 14, weight: .medium) label.font = .systemFont(ofSize: 14, weight: .medium)
label.textColor = AIJobDetailStyle.textPrimary label.textColor = AIJobDetailStyle.textPrimary
label.adjustsFontSizeToFitWidth = true label.setContentCompressionResistancePriority(.required, for: .horizontal)
label.minimumScaleFactor = 0.75 addSubview(label)
container.addSubview(iconView)
container.addSubview(label)
iconView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(10)
make.centerY.equalToSuperview()
make.size.equalTo(18)
}
label.snp.makeConstraints { make in label.snp.makeConstraints { make in
make.leading.equalTo(iconView.snp.trailing).offset(6) make.leading.trailing.equalToSuperview().inset(12)
make.trailing.equalToSuperview().inset(8)
make.centerY.equalToSuperview() make.centerY.equalToSuperview()
} }
container.snp.makeConstraints { $0.height.equalTo(44) } snp.makeConstraints { $0.height.equalTo(44) }
return container }
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override var intrinsicContentSize: CGSize {
CGSize(width: ceil(label.intrinsicContentSize.width) + 24, height: 44)
} }
} }
@@ -663,6 +680,9 @@ private final class AIJobDetailTargetRow: UIView {
private let templateLabel = UILabel() private let templateLabel = UILabel()
private let statusIconView = UIImageView() private let statusIconView = UIImageView()
private let statusLabel = UILabel() private let statusLabel = UILabel()
private let statusStack = UIStackView()
private let statusRow = UIStackView()
private let contentStack = UIStackView()
private let resultButton = UIButton(type: .system) private let resultButton = UIButton(type: .system)
private let errorContainer = UIView() private let errorContainer = UIView()
private let errorIconView = UIImageView(image: UIImage(systemName: "exclamationmark.circle.fill")) private let errorIconView = UIImageView(image: UIImage(systemName: "exclamationmark.circle.fill"))
@@ -679,8 +699,11 @@ private final class AIJobDetailTargetRow: UIView {
titleLabel.font = .systemFont(ofSize: 15, weight: .medium) titleLabel.font = .systemFont(ofSize: 15, weight: .medium)
titleLabel.textColor = AIJobDetailStyle.textPrimary titleLabel.textColor = AIJobDetailStyle.textPrimary
titleLabel.lineBreakMode = .byTruncatingMiddle titleLabel.lineBreakMode = .byTruncatingMiddle
titleLabel.numberOfLines = 1
templateLabel.font = .systemFont(ofSize: 13) templateLabel.font = .systemFont(ofSize: 13)
templateLabel.textColor = AIJobDetailStyle.textSecondary templateLabel.textColor = AIJobDetailStyle.textSecondary
templateLabel.lineBreakMode = .byTruncatingTail
templateLabel.numberOfLines = 1
statusIconView.contentMode = .scaleAspectFit statusIconView.contentMode = .scaleAspectFit
statusLabel.font = .systemFont(ofSize: 14, weight: .medium) statusLabel.font = .systemFont(ofSize: 14, weight: .medium)
resultButton.setTitle("查看结果", for: .normal) resultButton.setTitle("查看结果", for: .normal)
@@ -693,49 +716,45 @@ private final class AIJobDetailTargetRow: UIView {
errorLabel.textColor = AppColor.danger errorLabel.textColor = AppColor.danger
errorLabel.numberOfLines = 0 errorLabel.numberOfLines = 0
let statusStack = UIStackView(arrangedSubviews: [statusIconView, statusLabel]) statusStack.addArrangedSubview(statusIconView)
statusStack.addArrangedSubview(statusLabel)
statusStack.axis = .horizontal statusStack.axis = .horizontal
statusStack.spacing = 6 statusStack.spacing = 6
statusStack.alignment = .center statusStack.alignment = .center
statusStack.setContentCompressionResistancePriority(.required, for: .horizontal)
statusLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
let statusSpacer = UIView()
statusRow.addArrangedSubview(statusStack)
statusRow.addArrangedSubview(statusSpacer)
statusRow.addArrangedSubview(resultButton)
statusRow.axis = .horizontal
statusRow.spacing = 8
statusRow.alignment = .center
contentStack.addArrangedSubview(titleLabel)
contentStack.addArrangedSubview(templateLabel)
contentStack.addArrangedSubview(statusRow)
contentStack.addArrangedSubview(errorContainer)
contentStack.axis = .vertical
contentStack.spacing = 0
contentStack.setCustomSpacing(7, after: titleLabel)
contentStack.setCustomSpacing(8, after: templateLabel)
contentStack.setCustomSpacing(12, after: statusRow)
statusIconView.snp.makeConstraints { $0.size.equalTo(18) } statusIconView.snp.makeConstraints { $0.size.equalTo(18) }
addSubview(thumbnailView) addSubview(thumbnailView)
addSubview(titleLabel) addSubview(contentStack)
addSubview(templateLabel)
addSubview(statusStack)
addSubview(resultButton)
addSubview(errorContainer)
errorContainer.addSubview(errorIconView) errorContainer.addSubview(errorIconView)
errorContainer.addSubview(errorLabel) errorContainer.addSubview(errorLabel)
thumbnailView.snp.makeConstraints { make in thumbnailView.snp.makeConstraints { make in
make.leading.top.equalToSuperview().offset(4) make.leading.top.equalToSuperview().offset(4)
make.size.equalTo(80) make.size.equalTo(80)
make.bottom.lessThanOrEqualToSuperview().inset(12)
} }
titleLabel.snp.makeConstraints { make in contentStack.snp.makeConstraints { make in
make.leading.equalTo(thumbnailView.snp.trailing).offset(14) make.leading.equalTo(thumbnailView.snp.trailing).offset(14)
make.top.equalTo(thumbnailView).offset(18) make.top.equalTo(thumbnailView).offset(18)
make.trailing.lessThanOrEqualTo(statusStack.snp.leading).offset(-8)
}
templateLabel.snp.makeConstraints { make in
make.leading.equalTo(titleLabel)
make.top.equalTo(titleLabel.snp.bottom).offset(7)
make.trailing.lessThanOrEqualTo(statusStack.snp.leading).offset(-8)
}
statusStack.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(4)
make.centerY.equalTo(thumbnailView).offset(-7)
}
resultButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(4)
make.top.equalTo(statusStack.snp.bottom).offset(4)
make.height.equalTo(28)
}
errorContainer.snp.makeConstraints { make in
make.top.equalTo(templateLabel.snp.bottom).offset(12)
make.leading.equalTo(titleLabel)
make.trailing.equalToSuperview().inset(4) make.trailing.equalToSuperview().inset(4)
make.bottom.equalToSuperview().inset(12) make.bottom.equalToSuperview().inset(12)
make.height.greaterThanOrEqualTo(38)
} }
errorIconView.snp.makeConstraints { make in errorIconView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(10) make.leading.equalToSuperview().offset(10)
@@ -780,21 +799,9 @@ private final class AIJobDetailTargetRow: UIView {
errorLabel.text = target.displayFailureMessage.map { "失败原因:\($0)" } errorLabel.text = target.displayFailureMessage.map { "失败原因:\($0)" }
let showsError = target.displayFailureMessage != nil let showsError = target.displayFailureMessage != nil
errorContainer.isHidden = !showsError errorContainer.isHidden = !showsError
errorContainer.snp.remakeConstraints { make in titleLabel.accessibilityIdentifier = "aiRetouchJob.target.\(target.targetId).title"
if showsError { templateLabel.accessibilityIdentifier = "aiRetouchJob.target.\(target.targetId).template"
make.top.equalTo(templateLabel.snp.bottom).offset(12) statusStack.accessibilityIdentifier = "aiRetouchJob.target.\(target.targetId).status"
} else {
make.top.equalTo(thumbnailView.snp.bottom)
}
make.leading.equalTo(titleLabel)
make.trailing.equalToSuperview().inset(4)
make.bottom.equalToSuperview().inset(12)
if showsError {
make.height.greaterThanOrEqualTo(38)
} else {
make.height.equalTo(0)
}
}
accessibilityLabel = [titleLabel.text, templateLabel.text, statusLabel.text, errorLabel.text] accessibilityLabel = [titleLabel.text, templateLabel.text, statusLabel.text, errorLabel.text]
.compactMap { $0 }.joined(separator: ",") .compactMap { $0 }.joined(separator: ",")
} }
@@ -896,12 +903,21 @@ private extension TravelAlbumAIJobOutputType {
} }
} }
var symbolName: String { var chipBackgroundColor: UIColor {
switch self { switch self {
case .refined: "wand.and.stars" case .refined: UIColor(hex: 0xEAF3FF)
case .atmosphere: "sun.max" case .atmosphere: UIColor(hex: 0xFFF2DC)
case .cover: "bookmark" case .cover: UIColor(hex: 0xF2ECFF)
case .unknown: "sparkles" case .unknown: UIColor(hex: 0xF1F5F9)
}
}
var chipIdentifier: String {
switch self {
case .refined: "refined"
case .atmosphere: "atmosphere"
case .cover: "cover"
case .unknown: "unknown"
} }
} }
} }
@@ -0,0 +1,392 @@
import SnapKit
import UIKit
/// 相册自动修图设置:在同一页切换修图方式、展开模板并确认草稿。
final class TravelAlbumAutoRetouchSettingSheetViewController: BaseViewController {
/// 确认草稿后通知调用方保存相册配置。
var onConfirm: ((TravelAlbumAutoRetouchConfiguration) -> Void)?
private let viewModel: TravelAlbumAutoRetouchSettingViewModel
private let api: any TravelAlbumServing
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let headerStack = UIStackView()
private let modeStack = UIStackView()
private let originalOption = AutoRetouchModeControl(title: "不修图", detail: "保留相机原始照片", symbol: "photo")
private let aiOption = AutoRetouchModeControl(title: "AI 修图", detail: "上传后自动套用模板", symbol: "wand.and.stars")
private let contentRegion = UIView()
private let templateHeader = UIStackView()
private lazy var templateCollectionView = UICollectionView(frame: .zero, collectionViewLayout: makeTemplateLayout())
private var templateDataSource: UICollectionViewDiffableDataSource<Int, TravelAlbumAIRetouchTemplate>!
private let originalHint = UIStackView()
private let statusStack = UIStackView()
private let activityIndicator = UIActivityIndicatorView(style: .medium)
private let statusLabel = UILabel()
private let retryButton = UIButton(type: .system)
private let footer = UIView()
private let selectionLabel = UILabel()
private let cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system)
private var lastTemplatesVisible: Bool?
/// 创建设置 Sheet;方式选择与模板区域共用同一份配置草稿。
init(viewModel: TravelAlbumAutoRetouchSettingViewModel, api: any TravelAlbumServing) {
self.viewModel = viewModel
self.api = api
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .pageSheet
if let sheet = sheetPresentationController {
sheet.detents = [.large()]
sheet.selectedDetentIdentifier = .large
sheet.prefersGrabberVisible = true
sheet.preferredCornerRadius = 24
}
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF6F8FC)
titleLabel.text = viewModel.allowsModeSelection ? "选择修图方式" : "选择修图模板"
titleLabel.font = .systemFont(ofSize: 22, weight: .bold)
titleLabel.textColor = AppColor.textPrimary
titleLabel.accessibilityTraits = .header
subtitleLabel.text = "设置仅对后续上传的照片生效"
subtitleLabel.font = .systemFont(ofSize: 13)
subtitleLabel.textColor = AppColor.textSecondary
headerStack.axis = .vertical
headerStack.spacing = 8
headerStack.addArrangedSubview(titleLabel)
headerStack.addArrangedSubview(subtitleLabel)
headerStack.setCustomSpacing(20, after: subtitleLabel)
modeStack.axis = .horizontal
modeStack.distribution = .fillEqually
modeStack.spacing = 12
modeStack.accessibilityIdentifier = "travelAlbum.autoRetouchModeOptions"
originalOption.accessibilityIdentifier = "travelAlbum.autoRetouchOriginalOption"
aiOption.accessibilityIdentifier = "travelAlbum.autoRetouchAIOption"
modeStack.addArrangedSubview(originalOption)
modeStack.addArrangedSubview(aiOption)
headerStack.addArrangedSubview(modeStack)
modeStack.isHidden = !viewModel.allowsModeSelection
let templateTitle = UILabel()
templateTitle.text = viewModel.allowsModeSelection ? "选择修图模板" : "精修模板"
templateTitle.font = .systemFont(ofSize: 17, weight: .semibold)
templateTitle.textColor = AppColor.textPrimary
templateTitle.accessibilityTraits = .header
let templateTips = UILabel()
templateTips.text = "单选模板 · 点击预览查看前后效果"
templateTips.font = .systemFont(ofSize: 12)
templateTips.textColor = AppColor.textSecondary
templateHeader.axis = .vertical
templateHeader.spacing = 5
templateHeader.addArrangedSubview(templateTitle)
templateHeader.addArrangedSubview(templateTips)
templateCollectionView.backgroundColor = .clear
templateCollectionView.alwaysBounceVertical = true
templateCollectionView.accessibilityIdentifier = "travelAlbum.autoRetouchTemplateCollection"
templateCollectionView.delegate = self
templateCollectionView.register(TravelAlbumAIRetouchTemplateCell.self,
forCellWithReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier)
configureDataSource()
let hintIcon = UIImageView(image: UIImage(systemName: "photo.on.rectangle.angled"))
hintIcon.tintColor = AppColor.primary.withAlphaComponent(0.6)
hintIcon.contentMode = .scaleAspectFit
let hintTitle = UILabel()
hintTitle.text = "保留原图,直接上传"
hintTitle.font = .systemFont(ofSize: 17, weight: .semibold)
hintTitle.textColor = AppColor.textPrimary
let hintDetail = UILabel()
hintDetail.text = "选择上方 AI 修图,即可在这里挑选模板"
hintDetail.font = .systemFont(ofSize: 13)
hintDetail.textColor = AppColor.textSecondary
hintDetail.numberOfLines = 2
hintDetail.textAlignment = .center
originalHint.axis = .vertical
originalHint.alignment = .center
originalHint.spacing = 12
originalHint.accessibilityIdentifier = "travelAlbum.autoRetouchOriginalHint"
originalHint.addArrangedSubview(hintIcon)
originalHint.addArrangedSubview(hintTitle)
originalHint.addArrangedSubview(hintDetail)
hintIcon.snp.makeConstraints { $0.size.equalTo(48) }
statusStack.axis = .vertical
statusStack.alignment = .center
statusStack.spacing = 12
statusStack.addArrangedSubview(activityIndicator)
statusStack.addArrangedSubview(statusLabel)
statusStack.addArrangedSubview(retryButton)
activityIndicator.color = AppColor.primary
statusLabel.font = .systemFont(ofSize: 14)
statusLabel.textColor = AppColor.textSecondary
statusLabel.textAlignment = .center
statusLabel.numberOfLines = 0
retryButton.setTitle("重新加载", for: .normal)
retryButton.tintColor = AppColor.primary
retryButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold)
retryButton.snp.makeConstraints { $0.height.equalTo(44) }
footer.backgroundColor = .white
footer.layer.shadowColor = UIColor(hex: 0x182B49).cgColor
footer.layer.shadowOpacity = 0.04
footer.layer.shadowRadius = 12
footer.layer.shadowOffset = CGSize(width: 0, height: -3)
selectionLabel.font = .systemFont(ofSize: 13, weight: .medium)
selectionLabel.textColor = AppColor.textSecondary
selectionLabel.lineBreakMode = .byTruncatingTail
selectionLabel.accessibilityIdentifier = "travelAlbum.autoRetouchSelectionSummary"
configureAction(cancelButton, title: "取消", filled: false)
configureAction(confirmButton, title: "确定", filled: true)
confirmButton.accessibilityIdentifier = "travelAlbum.autoRetouchConfirmButton"
view.addSubview(headerStack)
view.addSubview(contentRegion)
contentRegion.addSubview(templateHeader)
contentRegion.addSubview(templateCollectionView)
contentRegion.addSubview(originalHint)
contentRegion.addSubview(statusStack)
view.addSubview(footer)
footer.addSubview(selectionLabel)
footer.addSubview(cancelButton)
footer.addSubview(confirmButton)
}
override func setupConstraints() {
headerStack.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(16)
make.leading.trailing.equalToSuperview().inset(16)
}
contentRegion.snp.makeConstraints { make in
make.top.equalTo(headerStack.snp.bottom).offset(22)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(footer.snp.top).offset(-8)
}
templateHeader.snp.makeConstraints { make in
make.top.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(16)
}
templateCollectionView.snp.makeConstraints { make in
make.top.equalTo(templateHeader.snp.bottom).offset(12)
make.leading.trailing.bottom.equalToSuperview()
}
originalHint.snp.makeConstraints { make in
make.centerY.equalToSuperview().offset(-12)
make.leading.trailing.equalToSuperview().inset(24)
}
statusStack.snp.makeConstraints { make in
make.center.equalTo(templateCollectionView)
make.leading.trailing.equalToSuperview().inset(32)
}
footer.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
selectionLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(14)
make.leading.trailing.equalToSuperview().inset(16)
}
cancelButton.snp.makeConstraints { make in
make.top.equalTo(selectionLabel.snp.bottom).offset(12)
make.leading.equalToSuperview().offset(16)
make.height.equalTo(50)
make.width.equalTo(96)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
}
confirmButton.snp.makeConstraints { make in
make.leading.equalTo(cancelButton.snp.trailing).offset(12)
make.trailing.equalToSuperview().offset(-16)
make.top.bottom.equalTo(cancelButton)
}
}
override func bindActions() {
originalOption.addTarget(self, action: #selector(originalTapped), for: .touchUpInside)
aiOption.addTarget(self, action: #selector(aiTapped), for: .touchUpInside)
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
}
override func viewDidLoad() {
super.viewDidLoad()
applyViewModel()
Task { await viewModel.loadTemplates(api: api) }
}
private func configureDataSource() {
templateDataSource = UICollectionViewDiffableDataSource<Int, TravelAlbumAIRetouchTemplate>(
collectionView: templateCollectionView
) { [weak self] collectionView, indexPath, template in
guard let self else { return nil }
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier, for: indexPath
) as! TravelAlbumAIRetouchTemplateCell
cell.apply(template: template, selected: self.viewModel.selectedTemplateId == template.id)
cell.onPreviewTapped = { [weak self] in self?.showPreview(for: template) }
return cell
}
}
private func makeTemplateLayout() -> UICollectionViewCompositionalLayout {
let item = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)
))
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(156)),
subitem: item, count: 3
)
group.interItemSpacing = .fixed(12)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 12
section.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 16, bottom: 12, trailing: 16)
return UICollectionViewCompositionalLayout(section: section)
}
private func showPreview(for template: TravelAlbumAIRetouchTemplate) {
guard presentedViewController == nil else { return }
guard let content = template.comparisonContent else { showToast("暂无对比预览"); return }
present(BeforeAfterComparisonViewController(viewModel: BeforeAfterComparisonViewModel(content: content)), animated: true)
}
@MainActor
private func applyViewModel() {
let showsTemplates = viewModel.isEnabled
originalOption.apply(selected: !showsTemplates)
aiOption.apply(selected: showsTemplates)
confirmButton.isEnabled = viewModel.canConfirm
confirmButton.alpha = viewModel.canConfirm ? 1 : 0.4
selectionLabel.text = !showsTemplates ? "将保留原图,不进行 AI 修图"
: viewModel.selectedTemplateName.map { "已选择:\($0)" } ?? "请选择一个修图模板"
selectionLabel.textColor = showsTemplates && viewModel.selectedTemplateName != nil ? AppColor.primary : AppColor.textSecondary
let updateVisibility = {
self.originalHint.isHidden = showsTemplates
self.templateHeader.isHidden = !showsTemplates
self.templateCollectionView.isHidden = !showsTemplates || self.viewModel.isLoading || self.viewModel.errorMessage != nil
self.statusStack.isHidden = !showsTemplates || (!self.viewModel.isLoading && self.viewModel.errorMessage == nil)
}
if let previous = lastTemplatesVisible, previous != showsTemplates,
view.window != nil, !UIAccessibility.isReduceMotionEnabled {
UIView.transition(with: contentRegion, duration: 0.2, options: [.transitionCrossDissolve, .beginFromCurrentState], animations: updateVisibility)
} else {
updateVisibility()
}
lastTemplatesVisible = showsTemplates
if viewModel.isLoading {
activityIndicator.startAnimating()
} else {
activityIndicator.stopAnimating()
}
activityIndicator.isHidden = !viewModel.isLoading
statusLabel.text = viewModel.isLoading ? "正在加载修图模板…" : viewModel.errorMessage
retryButton.isHidden = viewModel.isLoading
let previousTemplates = Set(templateDataSource.snapshot().itemIdentifiers)
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbumAIRetouchTemplate>()
snapshot.appendSections([0])
snapshot.appendItems(viewModel.templates)
snapshot.reconfigureItems(viewModel.templates.filter(previousTemplates.contains))
templateDataSource.apply(snapshot, animatingDifferences: true)
}
private func configureAction(_ button: UIButton, title: String, filled: Bool) {
button.setTitle(title, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
button.layer.cornerRadius = 14
button.backgroundColor = filled ? AppColor.primary : .white
button.setTitleColor(filled ? .white : AppColor.textSecondary, for: .normal)
button.layer.borderWidth = filled ? 0 : 1
button.layer.borderColor = UIColor(hex: 0xDFE5EF).cgColor
}
@objc private func originalTapped() { viewModel.selectMode(enabled: false) }
@objc private func aiTapped() { viewModel.selectMode(enabled: true) }
@objc private func cancelTapped() { dismiss(animated: true) }
@objc private func retryTapped() { Task { await viewModel.loadTemplates(api: api) } }
@objc private func confirmTapped() {
guard viewModel.canConfirm, let configuration = viewModel.pendingConfiguration else { return }
let completion = onConfirm
dismiss(animated: true) { completion?(configuration) }
}
}
extension TravelAlbumAutoRetouchSettingSheetViewController: UICollectionViewDelegate {
/// 点击模板更新草稿;预览按钮独立处理,不改变选择。
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let template = templateDataSource.itemIdentifier(for: indexPath) else { return }
viewModel.selectTemplate(id: template.id)
}
}
/// 修图方式单选卡片,使用图标、边框和勾选共同传达当前选择。
private final class AutoRetouchModeControl: UIControl {
private let iconContainer = UIView()
private let iconView = UIImageView()
private let checkView = UIImageView()
private let titleLabel = UILabel()
/// 创建可点击的方式卡片;所有内容由整个控件统一响应触摸。
init(title: String, detail: String, symbol: String) {
super.init(frame: .zero)
layer.cornerRadius = 16
layer.borderWidth = 1
iconContainer.layer.cornerRadius = 10
iconView.image = UIImage(systemName: symbol)
iconView.contentMode = .scaleAspectFit
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
let detailLabel = UILabel()
detailLabel.text = detail
detailLabel.font = .systemFont(ofSize: 12)
detailLabel.textColor = AppColor.textSecondary
detailLabel.numberOfLines = 2
checkView.contentMode = .scaleAspectFit
[iconContainer, iconView, checkView, titleLabel, detailLabel].forEach {
$0.isUserInteractionEnabled = false
addSubview($0)
}
iconContainer.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(12)
make.size.equalTo(32)
}
iconView.snp.makeConstraints { $0.edges.equalTo(iconContainer).inset(7) }
checkView.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(12)
make.size.equalTo(22)
}
titleLabel.snp.makeConstraints { make in
make.top.equalTo(iconContainer.snp.bottom).offset(10)
make.leading.trailing.equalToSuperview().inset(12)
}
detailLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(4)
make.leading.trailing.equalTo(titleLabel)
make.bottom.equalToSuperview().inset(12)
}
isAccessibilityElement = true
accessibilityLabel = "\(title),\(detail)"
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
/// 更新卡片背景与单选语义。
func apply(selected: Bool) {
isSelected = selected
backgroundColor = selected ? AppColor.primaryLight : .white
layer.borderColor = (selected ? AppColor.primary : UIColor(hex: 0xDFE5EF)).cgColor
layer.borderWidth = selected ? 1.5 : 1
iconContainer.backgroundColor = selected ? AppColor.primary : UIColor(hex: 0xEFF2F7)
iconView.tintColor = selected ? .white : AppColor.textSecondary
titleLabel.textColor = selected ? AppColor.primary : AppColor.textPrimary
checkView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
checkView.tintColor = selected ? AppColor.primary : UIColor(hex: 0xC7CFDC)
accessibilityValue = selected ? "已选择" : "未选择"
accessibilityTraits = selected ? [.button, .selected] : [.button]
}
}
@@ -293,14 +293,11 @@ final class TravelAlbumDetailViewController: BaseViewController {
sortButton.accessibilityValue = viewModel.sortOption.title sortButton.accessibilityValue = viewModel.sortOption.title
sortButton.menu = makeSortMenu() sortButton.menu = makeSortMenu()
let canSelectMaterials = viewModel.selectedTab == .all
selectButton.isHidden = false selectButton.isHidden = false
selectButton.isEnabled = canSelectMaterials selectButton.isEnabled = true
selectButton.alpha = canSelectMaterials ? 1 : 0.45 selectButton.alpha = 1
selectButton.setTitle(viewModel.isSelectionMode ? "完成" : "选择", for: .normal) selectButton.setTitle(viewModel.isSelectionMode ? "完成" : "选择", for: .normal)
selectButton.accessibilityValue = canSelectMaterials selectButton.accessibilityValue = viewModel.isSelectionMode ? "选择模式已开启" : "选择模式已关闭"
? (viewModel.isSelectionMode ? "选择模式已开启" : "选择模式已关闭")
: "已购照片不可删除"
let selectedCount = viewModel.selectedMaterialIds.count let selectedCount = viewModel.selectedMaterialIds.count
let hasSelection = selectedCount > 0 let hasSelection = selectedCount > 0
@@ -487,7 +484,11 @@ final class TravelAlbumDetailViewController: BaseViewController {
@objc private func deleteSelectedTapped() { @objc private func deleteSelectedTapped() {
let count = viewModel.selectedMaterialIds.count let count = viewModel.selectedMaterialIds.count
guard count > 0 else { return } guard count > 0 else { return }
let alert = UIAlertController(title: "删除素材", message: "确定删除选中的 \(count) 张素材吗?", preferredStyle: .alert) let alert = UIAlertController(
title: "删除素材",
message: viewModel.deleteSelectedConfirmationMessage,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "取消", style: .cancel)) alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
guard let self else { return } guard let self else { return }
@@ -497,13 +498,25 @@ final class TravelAlbumDetailViewController: BaseViewController {
} }
@objc private func uploadTapped() { @objc private func uploadTapped() {
guard presentedViewController == nil else { return }
let selector = TravelAlbumTransferModeSheetViewController()
selector.onModeSelected = { [weak self] mode in
self?.openWiredTransfer(mode: mode)
}
present(selector, animated: true)
}
private func openWiredTransfer(mode: TravelAlbumOTGTransferMode) {
let album = viewModel.album let album = viewModel.album
refreshState.markRefreshNeeded() refreshState.markRefreshNeeded()
let controller = WiredCameraTransferViewController( let controller = WiredCameraTransferViewController(
viewModel: WiredCameraTransferViewModel( viewModel: WiredCameraTransferViewModel(
albumId: album?.id ?? viewModel.albumId, albumId: album?.id ?? viewModel.albumId,
albumTitle: album?.name ?? "", albumTitle: album?.name ?? "",
headerPhone: album?.displayPhone ?? "" headerPhone: album?.displayPhone ?? "",
initialTransferMode: mode,
initialAutoRetouchConfiguration: album?.autoRetouchConfiguration ?? .disabled,
api: api
) )
) )
navigationController?.pushViewController(controller, animated: true) navigationController?.pushViewController(controller, animated: true)
@@ -694,14 +707,16 @@ private final class TravelAlbumInfoCard: UIView {
} }
} }
/// 旅拍相册素材网格单元,展示正方形缩略图、文件名、大小和选择状态。 /// 旅拍相册素材网格单元,独立展示购买、修图和选择状态。
final class TravelAlbumMaterialCell: UICollectionViewCell { final class TravelAlbumMaterialCell: UICollectionViewCell {
static let reuseIdentifier = "TravelAlbumMaterialCell" static let reuseIdentifier = "TravelAlbumMaterialCell"
private let imageView = UIImageView() private let imageView = UIImageView()
private let checkImageView = UIImageView() private let checkImageView = UIImageView()
private let badgeView = UIView() private let purchaseBadgeView = UIView()
private let badgeLabel = UILabel() private let purchaseBadgeLabel = UILabel()
private let aiRetouchBadgeView = UIView()
private let aiRetouchBadgeLabel = UILabel()
private let nameLabel = UILabel() private let nameLabel = UILabel()
private let sizeLabel = UILabel() private let sizeLabel = UILabel()
@@ -715,18 +730,18 @@ final class TravelAlbumMaterialCell: UICollectionViewCell {
checkImageView.backgroundColor = UIColor.black.withAlphaComponent(0.35) checkImageView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
checkImageView.layer.cornerRadius = 11 checkImageView.layer.cornerRadius = 11
checkImageView.accessibilityIdentifier = "travelAlbum.materialSelectionCheck" checkImageView.accessibilityIdentifier = "travelAlbum.materialSelectionCheck"
badgeView.layer.cornerRadius = 5 configureBadge(
badgeView.clipsToBounds = true purchaseBadgeView,
badgeView.isHidden = true label: purchaseBadgeLabel,
badgeView.isAccessibilityElement = false viewIdentifier: "travelAlbum.materialPurchaseBadge",
badgeView.accessibilityIdentifier = "travelAlbum.materialStatusBadge" labelIdentifier: "travelAlbum.materialPurchaseBadgeLabel"
badgeLabel.font = .systemFont(ofSize: 10, weight: .semibold) )
badgeLabel.textColor = .white configureBadge(
badgeLabel.textAlignment = .center aiRetouchBadgeView,
badgeLabel.isAccessibilityElement = false label: aiRetouchBadgeLabel,
badgeLabel.accessibilityIdentifier = "travelAlbum.materialStatusBadgeLabel" viewIdentifier: "travelAlbum.materialAIRetouchBadge",
badgeLabel.setContentHuggingPriority(.required, for: .horizontal) labelIdentifier: "travelAlbum.materialAIRetouchBadgeLabel"
badgeLabel.setContentCompressionResistancePriority(.required, for: .horizontal) )
nameLabel.font = .systemFont(ofSize: 12, weight: .medium) nameLabel.font = .systemFont(ofSize: 12, weight: .medium)
nameLabel.textColor = TravelAlbumDetailStyle.textPrimary nameLabel.textColor = TravelAlbumDetailStyle.textPrimary
nameLabel.lineBreakMode = .byTruncatingMiddle nameLabel.lineBreakMode = .byTruncatingMiddle
@@ -734,8 +749,8 @@ final class TravelAlbumMaterialCell: UICollectionViewCell {
sizeLabel.textColor = TravelAlbumDetailStyle.textSecondary sizeLabel.textColor = TravelAlbumDetailStyle.textSecondary
contentView.addSubview(imageView) contentView.addSubview(imageView)
imageView.addSubview(badgeView) imageView.addSubview(purchaseBadgeView)
badgeView.addSubview(badgeLabel) imageView.addSubview(aiRetouchBadgeView)
imageView.addSubview(checkImageView) imageView.addSubview(checkImageView)
contentView.addSubview(nameLabel) contentView.addSubview(nameLabel)
contentView.addSubview(sizeLabel) contentView.addSubview(sizeLabel)
@@ -747,14 +762,13 @@ final class TravelAlbumMaterialCell: UICollectionViewCell {
make.top.trailing.equalToSuperview().inset(6) make.top.trailing.equalToSuperview().inset(6)
make.size.equalTo(22) make.size.equalTo(22)
} }
badgeView.snp.makeConstraints { make in aiRetouchBadgeView.snp.makeConstraints { make in
make.top.leading.equalToSuperview().inset(6) make.top.leading.equalToSuperview().inset(6)
make.trailing.lessThanOrEqualTo(checkImageView.snp.leading).offset(-4) make.trailing.lessThanOrEqualTo(checkImageView.snp.leading).offset(-4)
} }
badgeLabel.snp.makeConstraints { make in purchaseBadgeView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset( make.leading.bottom.equalToSuperview().inset(6)
UIEdgeInsets(top: 3, left: 6, bottom: 3, right: 6) make.trailing.lessThanOrEqualToSuperview().inset(6)
)
} }
nameLabel.snp.makeConstraints { make in nameLabel.snp.makeConstraints { make in
make.top.equalTo(imageView.snp.bottom).offset(6) make.top.equalTo(imageView.snp.bottom).offset(6)
@@ -784,14 +798,54 @@ final class TravelAlbumMaterialCell: UICollectionViewCell {
checkImageView.isHidden = !selectionMode checkImageView.isHidden = !selectionMode
checkImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle") checkImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
checkImageView.tintColor = selected ? TravelAlbumDetailStyle.primary : .white checkImageView.tintColor = selected ? TravelAlbumDetailStyle.primary : .white
let badge = material.badgePresentation let purchaseBadge = material.purchaseBadgePresentation
badgeView.isHidden = badge == nil let aiRetouchBadge = material.aiRetouchBadgePresentation
badgeLabel.text = badge?.text applyBadge(purchaseBadge, to: purchaseBadgeView, label: purchaseBadgeLabel)
badgeView.backgroundColor = badge.map { TravelAlbumDetailStyle.badgeColor(for: $0.kind) } applyBadge(aiRetouchBadge, to: aiRetouchBadgeView, label: aiRetouchBadgeLabel)
nameLabel.text = material.fileName.isEmpty ? "未命名照片" : material.fileName nameLabel.text = material.fileName.isEmpty ? "未命名照片" : material.fileName
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize) sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize)
let badgeAccessibilityText = badge.map { ",状态:\($0.text)" } ?? "" let purchaseAccessibilityText = purchaseBadge.map { ",购买状态:\($0.text)" } ?? ""
accessibilityLabel = "\(nameLabel.text ?? "照片"),\(sizeLabel.text ?? "")\(badgeAccessibilityText)" let aiRetouchAccessibilityText = aiRetouchBadge.map { ",修图状态:\($0.text)" } ?? ""
accessibilityLabel = "\(nameLabel.text ?? "照片"),\(sizeLabel.text ?? "")"
+ purchaseAccessibilityText
+ aiRetouchAccessibilityText
accessibilityValue = selectionMode ? (selected ? "已选择" : "未选择") : nil accessibilityValue = selectionMode ? (selected ? "已选择" : "未选择") : nil
} }
private func configureBadge(
_ badgeView: UIView,
label: UILabel,
viewIdentifier: String,
labelIdentifier: String
) {
badgeView.layer.cornerRadius = 5
badgeView.clipsToBounds = true
badgeView.isHidden = true
badgeView.isAccessibilityElement = false
badgeView.accessibilityIdentifier = viewIdentifier
label.font = .systemFont(ofSize: 10, weight: .semibold)
label.textColor = .white
label.textAlignment = .center
label.lineBreakMode = .byTruncatingTail
label.isAccessibilityElement = false
label.accessibilityIdentifier = labelIdentifier
label.setContentHuggingPriority(.required, for: .horizontal)
label.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
badgeView.addSubview(label)
label.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(
UIEdgeInsets(top: 3, left: 6, bottom: 3, right: 6)
)
}
}
private func applyBadge(
_ presentation: TravelAlbumMaterialBadgePresentation?,
to badgeView: UIView,
label: UILabel
) {
badgeView.isHidden = presentation == nil
label.text = presentation?.text
badgeView.backgroundColor = presentation.map { TravelAlbumDetailStyle.badgeColor(for: $0.kind) }
}
} }
@@ -91,7 +91,15 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
self.allowsActions = allowsActions self.allowsActions = allowsActions
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .fullScreen modalPresentationStyle = .fullScreen
rebuildNodes(keepingProjectIndex: max(0, min(startProjectIndex, projects.count - 1)), kind: startKind) let resolvedProjectIndex = max(0, min(startProjectIndex, self.projects.count - 1))
let initialProject = self.projects.indices.contains(resolvedProjectIndex)
? self.projects[resolvedProjectIndex]
: nil
let resolvedStartKind = initialProject?.asset(for: startKind) == nil
? initialProject?.orderedAssets.first?.kind ?? .original
: startKind
selectedKind = resolvedStartKind
rebuildNodes(keepingProjectIndex: resolvedProjectIndex, kind: resolvedStartKind)
} }
@available(*, unavailable) @available(*, unavailable)
@@ -165,6 +173,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
sizeLabel.textColor = UIColor.white.withAlphaComponent(0.52) sizeLabel.textColor = UIColor.white.withAlphaComponent(0.52)
sizeLabel.font = .systemFont(ofSize: 13, weight: .regular) sizeLabel.font = .systemFont(ofSize: 13, weight: .regular)
sizeLabel.textAlignment = .center sizeLabel.textAlignment = .center
sizeLabel.accessibilityIdentifier = "travelAlbum.previewFileSizeLabel"
counterLabel.textColor = UIColor.white.withAlphaComponent(0.82) counterLabel.textColor = UIColor.white.withAlphaComponent(0.82)
counterLabel.font = .monospacedDigitSystemFont(ofSize: 15, weight: .medium) counterLabel.font = .monospacedDigitSystemFont(ofSize: 15, weight: .medium)
@@ -423,7 +432,9 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
guard let node = currentNode else { return } guard let node = currentNode else { return }
let asset = currentAsset let asset = currentAsset
titleLabel.text = asset?.fileName.isEmpty == false ? asset?.fileName : "未命名照片" titleLabel.text = asset?.fileName.isEmpty == false ? asset?.fileName : "未命名照片"
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(asset?.fileSize ?? 0) let fileSize = asset?.fileSize ?? 0
sizeLabel.text = fileSize > 0 ? TravelAlbumDisplayFormatter.fileSizeText(fileSize) : nil
sizeLabel.isHidden = fileSize <= 0
counterLabel.text = "\(node.projectIndex + 1)/\(max(totalCount, projects.count))" counterLabel.text = "\(node.projectIndex + 1)/\(max(totalCount, projects.count))"
counterLabel.accessibilityLabel = "第 \(node.projectIndex + 1) 张,共 \(max(totalCount, projects.count)) 张" counterLabel.accessibilityLabel = "第 \(node.projectIndex + 1) 张,共 \(max(totalCount, projects.count)) 张"
highResolutionButton.isEnabled = asset != nil highResolutionButton.isEnabled = asset != nil
@@ -982,6 +993,56 @@ extension TravelAlbumPhotoPreviewViewController: UICollectionViewDataSource, UIC
} }
} }
/// 深色图片预览中使用的高对比度延迟加载指示器。
@MainActor
private final class TravelAlbumPreviewLoadingIndicator: Indicator {
private let containerView = UIView()
private let activityIndicator = UIActivityIndicatorView(style: .medium)
private var revealTask: Task<Void, Never>?
var view: IndicatorView { containerView }
init() {
containerView.backgroundColor = UIColor(red: 39 / 255, green: 39 / 255, blue: 42 / 255, alpha: 0.94)
containerView.layer.cornerRadius = 22
containerView.isAccessibilityElement = true
containerView.accessibilityIdentifier = "travelAlbum.previewLoadingIndicator"
containerView.accessibilityLabel = "图片加载中"
activityIndicator.color = UIColor.white.withAlphaComponent(0.92)
activityIndicator.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
containerView.addSubview(activityIndicator)
activityIndicator.snp.makeConstraints { $0.center.equalToSuperview() }
}
func startAnimatingView() {
revealTask?.cancel()
containerView.alpha = 0
containerView.isHidden = true
revealTask = Task { @MainActor [weak self] in
try? await Task.sleep(for: .milliseconds(150))
guard !Task.isCancelled, let self else { return }
activityIndicator.startAnimating()
containerView.isHidden = false
UIView.animate(
withDuration: UIAccessibility.isReduceMotionEnabled ? 0 : 0.12,
animations: { self.containerView.alpha = 1 }
)
}
}
func stopAnimatingView() {
revealTask?.cancel()
revealTask = nil
activityIndicator.stopAnimating()
containerView.alpha = 0
containerView.isHidden = true
}
func sizeStrategy(in imageView: KFCrossPlatformImageView) -> IndicatorSizeStrategy {
.size(CGSize(width: 44, height: 44))
}
}
/// 预览图片 Cell,使用 UIScrollView 提供远程加载、双击和双指缩放。 /// 预览图片 Cell,使用 UIScrollView 提供远程加载、双击和双指缩放。
private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollViewDelegate { private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollViewDelegate {
static let reuseIdentifier = "TravelAlbumPreviewImageCell" static let reuseIdentifier = "TravelAlbumPreviewImageCell"
@@ -1054,7 +1115,7 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV
imageView.contentMode = .scaleAspectFit imageView.contentMode = .scaleAspectFit
imageView.backgroundColor = .black imageView.backgroundColor = .black
imageView.kf.indicatorType = .activity imageView.kf.indicatorType = .custom(indicator: TravelAlbumPreviewLoadingIndicator())
imageView.accessibilityIdentifier = "travelAlbum.previewImageView" imageView.accessibilityIdentifier = "travelAlbum.previewImageView"
retryButton.setTitle("图片加载失败,点击重试", for: .normal) retryButton.setTitle("图片加载失败,点击重试", for: .normal)
@@ -0,0 +1,138 @@
import SnapKit
import UIKit
/// 相册管理上传入口的传输模式选择 Sheet。
final class TravelAlbumTransferModeSheetViewController: UIViewController {
var onModeSelected: ((TravelAlbumOTGTransferMode) -> Void)?
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let optionsStack = UIStackView()
private let cancelButton = UIButton(type: .system)
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
modalPresentationStyle = .pageSheet
if let sheetPresentationController {
let identifier = UISheetPresentationController.Detent.Identifier("travelAlbumTransferMode")
sheetPresentationController.detents = [
.custom(identifier: identifier) { min(356, $0.maximumDetentValue) },
]
sheetPresentationController.selectedDetentIdentifier = identifier
sheetPresentationController.prefersGrabberVisible = true
sheetPresentationController.preferredCornerRadius = 22
}
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
setupConstraints()
}
private func setupUI() {
view.backgroundColor = .white
titleLabel.text = "选择传输模式"
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
titleLabel.textColor = AppColor.textPrimary
titleLabel.textAlignment = .center
subtitleLabel.text = "选择后进入对应的照片传输页面"
subtitleLabel.font = .systemFont(ofSize: 13)
subtitleLabel.textColor = AppColor.textSecondary
subtitleLabel.textAlignment = .center
optionsStack.axis = .vertical
optionsStack.spacing = 12
TravelAlbumOTGTransferMode.allCases.enumerated().forEach { index, mode in
optionsStack.addArrangedSubview(makeOption(mode: mode, index: index))
}
cancelButton.setTitle("取消", for: .normal)
cancelButton.setTitleColor(AppColor.textSecondary, for: .normal)
cancelButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
cancelButton.backgroundColor = UIColor(hex: 0xF4F5F7)
cancelButton.layer.cornerRadius = 12
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
view.addSubview(titleLabel)
view.addSubview(subtitleLabel)
view.addSubview(optionsStack)
view.addSubview(cancelButton)
}
private func setupConstraints() {
titleLabel.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
make.leading.trailing.equalToSuperview().inset(20)
}
subtitleLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(7)
make.leading.trailing.equalToSuperview().inset(20)
}
optionsStack.snp.makeConstraints { make in
make.top.equalTo(subtitleLabel.snp.bottom).offset(18)
make.leading.trailing.equalToSuperview().inset(16)
}
cancelButton.snp.makeConstraints { make in
make.top.equalTo(optionsStack.snp.bottom).offset(14)
make.leading.trailing.equalTo(optionsStack)
make.height.equalTo(48)
make.bottom.lessThanOrEqualTo(view.safeAreaLayoutGuide).offset(-10)
}
}
private func makeOption(mode: TravelAlbumOTGTransferMode, index: Int) -> UIView {
let container = UIView()
container.backgroundColor = UIColor(hex: 0xF7F9FC)
container.layer.cornerRadius = 12
container.layer.borderWidth = 1
container.layer.borderColor = UIColor(hex: 0xE6ECF5).cgColor
let title = UILabel()
title.text = mode.title
title.font = .systemFont(ofSize: 16, weight: .semibold)
title.textColor = AppColor.textPrimary
let detail = UILabel()
detail.text = mode.detailText
detail.font = .systemFont(ofSize: 12)
detail.textColor = AppColor.textSecondary
detail.numberOfLines = 2
let enter = UILabel()
enter.text = "进入"
enter.font = .systemFont(ofSize: 13, weight: .semibold)
enter.textColor = AppColor.primary
let button = UIButton(type: .custom)
button.tag = index
button.accessibilityLabel = "\(mode.title),\(mode.detailText)"
button.addTarget(self, action: #selector(modeTapped(_:)), for: .touchUpInside)
container.addSubview(title)
container.addSubview(detail)
container.addSubview(enter)
container.addSubview(button)
title.snp.makeConstraints { make in
make.top.equalToSuperview().offset(13)
make.leading.equalToSuperview().offset(16)
make.trailing.lessThanOrEqualTo(enter.snp.leading).offset(-12)
}
detail.snp.makeConstraints { make in
make.top.equalTo(title.snp.bottom).offset(5)
make.leading.equalTo(title)
make.trailing.lessThanOrEqualTo(enter.snp.leading).offset(-12)
}
enter.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-16)
make.centerY.equalToSuperview()
}
button.snp.makeConstraints { $0.edges.equalToSuperview() }
container.snp.makeConstraints { $0.height.equalTo(78) }
return container
}
@objc private func modeTapped(_ sender: UIButton) {
guard TravelAlbumOTGTransferMode.allCases.indices.contains(sender.tag) else { return }
let mode = TravelAlbumOTGTransferMode.allCases[sender.tag]
let completion = onModeSelected
dismiss(animated: true) { completion?(mode) }
}
@objc private func cancelTapped() { dismiss(animated: true) }
}
@@ -40,7 +40,7 @@ final class WiredCameraTransferViewController: BaseViewController {
private let refreshButton = UIButton(type: .system) private let refreshButton = UIButton(type: .system)
private let helpLabel = UILabel() private let helpLabel = UILabel()
private let chipsStack = UIStackView() private let chipsStack = UIStackView()
private let retouchButton = WiredTransferSettingChipButton() private let retouchButton = WiredTransferSettingChipButton(showsChevron: true)
private let formatButton = WiredTransferSettingChipButton() private let formatButton = WiredTransferSettingChipButton()
private let modeButton = WiredTransferSettingChipButton(showsChevron: true) private let modeButton = WiredTransferSettingChipButton(showsChevron: true)
private let settingsStatsDivider = UIView() private let settingsStatsDivider = UIView()
@@ -71,6 +71,7 @@ final class WiredCameraTransferViewController: BaseViewController {
private let specifyButton = UIButton(type: .system) private let specifyButton = UIButton(type: .system)
private var isHistoryImportButtonVisible = false private var isHistoryImportButtonVisible = false
private var previousNavigationBarStyle: (tintColor: UIColor?, barStyle: UIBarStyle, isTranslucent: Bool)? private var previousNavigationBarStyle: (tintColor: UIColor?, barStyle: UIBarStyle, isTranslucent: Bool)?
private var previewLoadingTask: Task<Void, Never>?
init(viewModel: WiredCameraTransferViewModel) { init(viewModel: WiredCameraTransferViewModel) {
self.viewModel = viewModel self.viewModel = viewModel
@@ -91,13 +92,64 @@ final class WiredCameraTransferViewController: BaseViewController {
titleStack.alignment = .center titleStack.alignment = .center
titleStack.spacing = 1 titleStack.spacing = 1
navigationItem.titleView = titleStack navigationItem.titleView = titleStack
var taskConfiguration = UIButton.Configuration.plain()
taskConfiguration.title = "修图任务"
taskConfiguration.image = UIImage(systemName: "list.bullet")?.withTintColor(.white, renderingMode: .alwaysOriginal)
taskConfiguration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 14, weight: .medium)
taskConfiguration.imagePadding = 4
taskConfiguration.baseForegroundColor = .white
taskConfiguration.imageColorTransformer = UIConfigurationColorTransformer { _ in .white }
taskConfiguration.background.backgroundColor = .clear
taskConfiguration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 4, bottom: 0, trailing: 4)
taskConfiguration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
var outgoing = incoming
outgoing.font = .systemFont(ofSize: 13, weight: .medium)
outgoing.foregroundColor = .white
return outgoing
}
let taskButton = UIButton(type: .custom)
taskButton.configuration = taskConfiguration
taskButton.tintColor = .white
taskButton.configurationUpdateHandler = { button in
button.alpha = button.isHighlighted ? 0.6 : 1
}
taskButton.accessibilityLabel = "查看AI修图任务"
taskButton.accessibilityIdentifier = "wiredTransfer.aiRetouchTasksButton"
taskButton.addTarget(self, action: #selector(openAIJobList), for: .touchUpInside)
taskButton.snp.makeConstraints { make in
make.height.equalTo(44)
make.width.greaterThanOrEqualTo(44)
}
let taskItem = UIBarButtonItem(customView: taskButton)
taskItem.tintColor = .white
taskItem.accessibilityLabel = "查看AI修图任务"
if #available(iOS 26.0, *) {
// 蓝色导航栏使用轻量入口,避免系统共享玻璃背景形成深色胶囊。
taskItem.hidesSharedBackground = true
}
navigationItem.rightBarButtonItem = taskItem
} }
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
applyViewModel() applyViewModel()
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationEnteredBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationBecameActive),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
} }
deinit { NotificationCenter.default.removeObserver(self) }
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
applyNavigationBarStyle() applyNavigationBarStyle()
@@ -107,6 +159,7 @@ final class WiredCameraTransferViewController: BaseViewController {
override func viewWillDisappear(_ animated: Bool) { override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated) super.viewWillDisappear(animated)
previewLoadingTask?.cancel()
viewModel.stop() viewModel.stop()
restoreNavigationBarStyle() restoreNavigationBarStyle()
} }
@@ -161,7 +214,6 @@ final class WiredCameraTransferViewController: BaseViewController {
[retouchButton, formatButton, modeButton].forEach { [retouchButton, formatButton, modeButton].forEach {
chipsStack.addArrangedSubview($0) chipsStack.addArrangedSubview($0)
} }
retouchButton.isUserInteractionEnabled = false
formatButton.isUserInteractionEnabled = false formatButton.isUserInteractionEnabled = false
settingsStatsDivider.backgroundColor = AppColor.border settingsStatsDivider.backgroundColor = AppColor.border
@@ -189,6 +241,7 @@ final class WiredCameraTransferViewController: BaseViewController {
collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout()) collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
collectionView.backgroundColor = .white collectionView.backgroundColor = .white
collectionView.accessibilityIdentifier = "wiredTransfer.photoCollectionView"
collectionView.delegate = self collectionView.delegate = self
collectionView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 8, right: 0) collectionView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 8, right: 0)
collectionView.register(WiredTransferPhotoCell.self, forCellWithReuseIdentifier: WiredTransferPhotoCell.reuseIdentifier) collectionView.register(WiredTransferPhotoCell.self, forCellWithReuseIdentifier: WiredTransferPhotoCell.reuseIdentifier)
@@ -387,6 +440,7 @@ final class WiredCameraTransferViewController: BaseViewController {
Task { @MainActor in self?.showToast(message) } Task { @MainActor in self?.showToast(message) }
} }
refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside) refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside)
retouchButton.addTarget(self, action: #selector(retouchTapped), for: .touchUpInside)
batchButton.addTarget(self, action: #selector(batchTapped), for: .touchUpInside) batchButton.addTarget(self, action: #selector(batchTapped), for: .touchUpInside)
historyImportButton.addTarget(self, action: #selector(historyImportTapped), for: .touchUpInside) historyImportButton.addTarget(self, action: #selector(historyImportTapped), for: .touchUpInside)
albumImportButton.addTarget(self, action: #selector(albumImportTapped), for: .touchUpInside) albumImportButton.addTarget(self, action: #selector(albumImportTapped), for: .touchUpInside)
@@ -415,7 +469,8 @@ final class WiredCameraTransferViewController: BaseViewController {
statusLabel.backgroundColor = (isFailed ? AppColor.danger : AppColor.primary).withAlphaComponent(0.10) statusLabel.backgroundColor = (isFailed ? AppColor.danger : AppColor.primary).withAlphaComponent(0.10)
refreshButton.setTitle(viewModel.actionButtonText, for: .normal) refreshButton.setTitle(viewModel.actionButtonText, for: .normal)
retouchButton.apply(title: viewModel.retouchOption) retouchButton.apply(title: viewModel.isUpdatingAutoRetouchConfiguration ? "保存中" : viewModel.retouchOption)
retouchButton.isEnabled = !viewModel.isUpdatingAutoRetouchConfiguration
formatButton.apply(title: "JPG") formatButton.apply(title: "JPG")
modeButton.apply(title: viewModel.transferModeOption) modeButton.apply(title: viewModel.transferModeOption)
helpLabel.attributedText = helpText(viewModel.sonyMTPHint) helpLabel.attributedText = helpText(viewModel.sonyMTPHint)
@@ -567,6 +622,7 @@ final class WiredCameraTransferViewController: BaseViewController {
let selected = viewModel.selectedPhotoIds.contains(item.id) let selected = viewModel.selectedPhotoIds.contains(item.id)
cell.apply(item: item, selectionMode: viewModel.selectUploadMode, selected: selected) cell.apply(item: item, selectionMode: viewModel.selectUploadMode, selected: selected)
cell.onRetry = { [weak self] in self?.viewModel.retryPhoto(photoId: item.id) } cell.onRetry = { [weak self] in self?.viewModel.retryPhoto(photoId: item.id) }
cell.onRetryRetouch = { [weak self] in self?.viewModel.retryAutoRetouch(photoId: item.id) }
cell.onDelete = { [weak self] in self?.viewModel.deletePhoto(photoId: item.id) } cell.onDelete = { [weak self] in self?.viewModel.deletePhoto(photoId: item.id) }
} }
@@ -711,6 +767,39 @@ final class WiredCameraTransferViewController: BaseViewController {
viewModel.refreshCameraFiles() viewModel.refreshCameraFiles()
} }
@objc private func openAIJobList() {
guard let navigationController,
navigationController.topViewController === self,
presentedViewController == nil,
previewLoadingTask == nil else { return }
navigationController.pushViewController(
TravelAlbumAIJobListViewController(api: viewModel.autoRetouchAPI),
animated: true
)
}
@objc private func applicationEnteredBackground() { viewModel.applicationDidEnterBackground() }
@objc private func applicationBecameActive() { viewModel.applicationDidBecomeActive() }
@objc private func retouchTapped() {
guard presentedViewController == nil else { return }
let settingViewModel = TravelAlbumAutoRetouchSettingViewModel(
scenicId: AppStore.shared.session.currentScenicId,
configuration: viewModel.autoRetouchConfiguration,
allowsModeSelection: true
)
let controller = TravelAlbumAutoRetouchSettingSheetViewController(
viewModel: settingViewModel,
api: viewModel.autoRetouchAPI
)
controller.onConfirm = { [weak self] configuration in
guard let self else { return }
Task { await self.viewModel.updateAutoRetouchConfiguration(configuration) }
}
present(controller, animated: true)
}
@objc private func batchTapped() { @objc private func batchTapped() {
viewModel.onBatchUploadButtonClick() viewModel.onBatchUploadButtonClick()
} }
@@ -788,6 +877,11 @@ final class WiredCameraTransferViewController: BaseViewController {
} }
private func presentPhotoPreview(_ item: TravelAlbumOTGPhotoItem) { private func presentPhotoPreview(_ item: TravelAlbumOTGPhotoItem) {
guard previewLoadingTask == nil, presentedViewController == nil else { return }
if item.autoRetouchState == .completed {
presentAutoRetouchPreview(photoId: item.id)
return
}
guard let url = item.thumbnailURL else { guard let url = item.thumbnailURL else {
showToast("暂无可预览图片") showToast("暂无可预览图片")
return return
@@ -797,6 +891,39 @@ final class WiredCameraTransferViewController: BaseViewController {
: MediaPreviewItem(source: .remoteImage(url)) : MediaPreviewItem(source: .remoteImage(url))
MediaPreviewViewController.present(from: self, items: [previewItem], startIndex: 0) MediaPreviewViewController.present(from: self, items: [previewItem], startIndex: 0)
} }
private func presentAutoRetouchPreview(photoId: String) {
previewLoadingTask = Task { @MainActor [weak self] in
guard let self else { return }
showLoading()
defer {
hideLoading()
previewLoadingTask = nil
}
do {
let project = try await viewModel.loadAutoRetouchPreviewProject(photoId: photoId)
try Task.checkCancellation()
guard viewIfLoaded?.window != nil,
presentedViewController == nil,
!viewModel.selectUploadMode else { return }
present(
TravelAlbumPhotoPreviewViewController(
projects: [project],
totalCount: 1,
startProjectIndex: 0,
startKind: .retouched,
allowsActions: false
),
animated: true
)
} catch is CancellationError {
// 离开传输页后不再弹出预览或错误提示。
} catch {
guard !Task.isCancelled else { return }
showToast(error.localizedDescription.isEmpty ? "修图结果加载失败,请重试" : error.localizedDescription)
}
}
}
} }
extension WiredCameraTransferViewController: PHPickerViewControllerDelegate { extension WiredCameraTransferViewController: PHPickerViewControllerDelegate {
@@ -1111,12 +1238,13 @@ private final class WiredTransferSectionHeaderView: UICollectionReusableView {
} }
/// 有线传输照片列表 Cell。 /// 有线传输照片列表 Cell。
private final class WiredTransferPhotoCell: UICollectionViewCell { final class WiredTransferPhotoCell: UICollectionViewCell {
static let reuseIdentifier = "WiredTransferPhotoCell" static let reuseIdentifier = "WiredTransferPhotoCell"
private static let previewImageSize = CGSize(width: 96, height: 96) private static let previewImageSize = CGSize(width: 96, height: 96)
private let selectionIconView = UIImageView() private let selectionIconView = UIImageView()
private let imageView = UIImageView() private let imageView = UIImageView()
private let retouchBadgeLabel = UILabel()
private let statusLabel = UILabel() private let statusLabel = UILabel()
private let titleLabel = UILabel() private let titleLabel = UILabel()
private let sizeLabel = UILabel() private let sizeLabel = UILabel()
@@ -1125,6 +1253,7 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
private let separatorView = UIView() private let separatorView = UIView()
var onRetry: (() -> Void)? var onRetry: (() -> Void)?
var onRetryRetouch: (() -> Void)?
var onDelete: (() -> Void)? var onDelete: (() -> Void)?
override init(frame: CGRect) { override init(frame: CGRect) {
@@ -1142,10 +1271,12 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
imageView.kf.cancelDownloadTask() imageView.kf.cancelDownloadTask()
imageView.image = nil imageView.image = nil
onRetry = nil onRetry = nil
onRetryRetouch = nil
onDelete = nil onDelete = nil
menuButton.menu = nil menuButton.menu = nil
} }
/// 分别渲染原图上传进度与自动修图角标,修图状态不参与进度计算。
func apply(item: TravelAlbumOTGPhotoItem, selectionMode: Bool, selected: Bool) { func apply(item: TravelAlbumOTGPhotoItem, selectionMode: Bool, selected: Bool) {
let canSelect = item.canSelectForUpload let canSelect = item.canSelectForUpload
let rowAlpha: CGFloat = selectionMode && !canSelect ? 0.45 : 1 let rowAlpha: CGFloat = selectionMode && !canSelect ? 0.45 : 1
@@ -1172,22 +1303,64 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
statusLabel.text = statusText(item.status) statusLabel.text = statusText(item.status)
statusLabel.textColor = statusTextColor(item.status) statusLabel.textColor = statusTextColor(item.status)
statusLabel.backgroundColor = statusBackgroundColor(item.status) statusLabel.backgroundColor = statusBackgroundColor(item.status)
applyRetouchBadge(state: item.autoRetouchState)
progressView.isHidden = item.status != .uploading && item.status != .transferring progressView.isHidden = item.status != .uploading && item.status != .transferring
progressView.progress = Float(item.progress) / 100.0 progressView.progress = Float(item.progress) / 100.0
selectionIconView.isHidden = !selectionMode selectionIconView.isHidden = !selectionMode
selectionIconView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle") selectionIconView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
selectionIconView.tintColor = canSelect ? (selected ? AppColor.primary : AppColor.textTertiary) : AppColor.textTertiary.withAlphaComponent(0.5) selectionIconView.tintColor = canSelect ? (selected ? AppColor.primary : AppColor.textTertiary) : AppColor.textTertiary.withAlphaComponent(0.5)
menuButton.isHidden = selectionMode menuButton.isHidden = selectionMode
menuButton.menu = makeMenu(canRetry: item.status == .failed || item.status == .pending) menuButton.menu = makeMenu(
canRetryUpload: item.status == .failed || item.status == .pending,
canRetryRetouch: item.autoRetouchState == .failed
)
accessibilityLabel = "\(item.fileName),\(statusText(item.status))"
if !retouchBadgeLabel.isHidden, let retouchStatus = retouchBadgeLabel.accessibilityLabel {
accessibilityLabel?.append(",修图状态:\(retouchStatus)")
}
if let error = item.autoRetouchErrorMessage, !error.isEmpty {
accessibilityHint = "失败原因:\(error)"
} else {
accessibilityHint = nil
}
updateImageConstraints(selectionMode: selectionMode) updateImageConstraints(selectionMode: selectionMode)
} }
private func applyRetouchBadge(state: TravelAlbumAutoRetouchState) {
retouchBadgeLabel.isHidden = state == .none
switch state {
case .none:
retouchBadgeLabel.backgroundColor = .clear
retouchBadgeLabel.accessibilityLabel = nil
case .pendingSubmission, .submitting, .processing:
retouchBadgeLabel.backgroundColor = UIColor(hex: 0x7C3AED)
retouchBadgeLabel.accessibilityLabel = "修图中"
case .completed:
retouchBadgeLabel.backgroundColor = UIColor(hex: 0x047857)
retouchBadgeLabel.accessibilityLabel = "修图成功"
case .failed:
retouchBadgeLabel.backgroundColor = AppColor.danger
retouchBadgeLabel.accessibilityLabel = "修图失败"
}
}
private func setupUI() { private func setupUI() {
contentView.backgroundColor = .white contentView.backgroundColor = .white
selectionIconView.contentMode = .scaleAspectFit selectionIconView.contentMode = .scaleAspectFit
imageView.contentMode = .scaleAspectFill imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true imageView.clipsToBounds = true
imageView.layer.cornerRadius = 6 imageView.layer.cornerRadius = 6
imageView.accessibilityIdentifier = "wiredTransfer.thumbnail"
retouchBadgeLabel.text = "修"
retouchBadgeLabel.font = .systemFont(ofSize: 9, weight: .bold)
retouchBadgeLabel.textColor = .white
retouchBadgeLabel.textAlignment = .center
retouchBadgeLabel.backgroundColor = .clear
retouchBadgeLabel.layer.cornerRadius = 8
retouchBadgeLabel.clipsToBounds = true
retouchBadgeLabel.isHidden = true
retouchBadgeLabel.accessibilityIdentifier = "wiredTransfer.retouchBadge"
statusLabel.accessibilityIdentifier = "wiredTransfer.uploadStatus"
statusLabel.font = .systemFont(ofSize: 9) statusLabel.font = .systemFont(ofSize: 9)
statusLabel.textAlignment = .center statusLabel.textAlignment = .center
statusLabel.layer.cornerRadius = 3 statusLabel.layer.cornerRadius = 3
@@ -1203,6 +1376,7 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
sizeLabel.textColor = AppColor.textTertiary sizeLabel.textColor = AppColor.textTertiary
progressView.progressTintColor = AppColor.primary progressView.progressTintColor = AppColor.primary
progressView.trackTintColor = AppColor.border progressView.trackTintColor = AppColor.border
progressView.accessibilityIdentifier = "wiredTransfer.uploadProgress"
menuButton.setImage(UIImage(systemName: "ellipsis"), for: .normal) menuButton.setImage(UIImage(systemName: "ellipsis"), for: .normal)
menuButton.tintColor = AppColor.textSecondary menuButton.tintColor = AppColor.textSecondary
menuButton.showsMenuAsPrimaryAction = true menuButton.showsMenuAsPrimaryAction = true
@@ -1210,6 +1384,7 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
contentView.addSubview(selectionIconView) contentView.addSubview(selectionIconView)
contentView.addSubview(imageView) contentView.addSubview(imageView)
contentView.addSubview(retouchBadgeLabel)
contentView.addSubview(titleLabel) contentView.addSubview(titleLabel)
contentView.addSubview(statusLabel) contentView.addSubview(statusLabel)
contentView.addSubview(sizeLabel) contentView.addSubview(sizeLabel)
@@ -1223,6 +1398,11 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
make.size.equalTo(20) make.size.equalTo(20)
} }
updateImageConstraints(selectionMode: true) updateImageConstraints(selectionMode: true)
retouchBadgeLabel.snp.makeConstraints { make in
make.top.equalTo(imageView).offset(2)
make.trailing.equalTo(imageView).offset(-2)
make.size.equalTo(16)
}
titleLabel.snp.makeConstraints { make in titleLabel.snp.makeConstraints { make in
make.top.equalTo(imageView).offset(1) make.top.equalTo(imageView).offset(1)
make.leading.equalTo(imageView.snp.trailing).offset(8) make.leading.equalTo(imageView.snp.trailing).offset(8)
@@ -1257,15 +1437,19 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
} }
} }
private func makeMenu(canRetry: Bool) -> UIMenu { private func makeMenu(canRetryUpload: Bool, canRetryRetouch: Bool) -> UIMenu {
let retry = UIAction(title: "重传", image: UIImage(systemName: "arrow.clockwise")) { [weak self] _ in let retry = UIAction(title: "重传", image: UIImage(systemName: "arrow.clockwise")) { [weak self] _ in
self?.onRetry?() self?.onRetry?()
} }
retry.attributes = canRetry ? [] : [.disabled] retry.attributes = canRetryUpload ? [] : [.disabled]
let retryRetouch = UIAction(title: "重新修图", image: UIImage(systemName: "wand.and.stars")) { [weak self] _ in
self?.onRetryRetouch?()
}
retryRetouch.attributes = canRetryRetouch ? [] : [.disabled]
let delete = UIAction(title: "删除", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in let delete = UIAction(title: "删除", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in
self?.onDelete?() self?.onDelete?()
} }
return UIMenu(children: [retry, delete]) return UIMenu(children: [retry, retryRetouch, delete])
} }
private func updateImageConstraints(selectionMode: Bool) { private func updateImageConstraints(selectionMode: Bool) {
@@ -47,6 +47,34 @@ final class AccountSwitchViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.selectedAccount?.businessUserId, 9001) XCTAssertEqual(viewModel.selectedAccount?.businessUserId, 9001)
} }
func testSwitchableAccountsPreserveCoolingOffStoreStatus() async throws {
let json = """
{
"code": 100000,
"msg": "success",
"data": {
"store_users": [
{
"account_type": "store_user",
"store_user_id": 2001,
"store_name": "冷静期门店",
"status": 2
}
]
}
}
""".data(using: .utf8)!
let session = MockURLSession(responses: [json])
let api = ProfileAPI(client: APIClient(environment: .testing, session: session))
let viewModel = AccountSwitchViewModel()
try await viewModel.load(api: api, force: true)
XCTAssertEqual(viewModel.accounts.first?.status, 2)
XCTAssertTrue(viewModel.accounts.first?.requiresDeregistrationConfirmation == true)
}
func testSwitchAccountThrowsWhenBusinessUserIdInvalid() async { func testSwitchAccountThrowsWhenBusinessUserIdInvalid() async {
let viewModel = AccountSwitchViewModel() let viewModel = AccountSwitchViewModel()
let account = AccountSwitchAccount( let account = AccountSwitchAccount(
+5
View File
@@ -48,6 +48,7 @@ final class AuthModelsTests: XCTestCase {
"scenic_name": "示例景区", "scenic_name": "示例景区",
"store_id": 20, "store_id": 20,
"store_name": "示例门店", "store_name": "示例门店",
"status": 2,
"role_name": "店长", "role_name": "店长",
"is_current": false "is_current": false
} }
@@ -63,6 +64,8 @@ final class AuthModelsTests: XCTestCase {
XCTAssertEqual(response.accounts.map(\.businessUserId), [101, 201]) XCTAssertEqual(response.accounts.map(\.businessUserId), [101, 201])
XCTAssertEqual(response.accounts.map(\.subtitle), ["示例景区 · 摄影师", "示例景区 · 店长"]) XCTAssertEqual(response.accounts.map(\.subtitle), ["示例景区 · 摄影师", "示例景区 · 店长"])
XCTAssertEqual(response.accounts.map(\.realName), ["张三", "李四"]) XCTAssertEqual(response.accounts.map(\.realName), ["张三", "李四"])
XCTAssertEqual(response.storeUsers.first?.status, 2)
XCTAssertTrue(response.storeUsers.first?.toAccountSwitchAccount().requiresDeregistrationConfirmation == true)
} }
func testV9AuthResponseDecodesMissingAccountListsAsEmpty() throws { func testV9AuthResponseDecodesMissingAccountListsAsEmpty() throws {
@@ -137,6 +140,8 @@ final class AuthModelsTests: XCTestCase {
XCTAssertEqual(user.toAccountSwitchAccount().subtitle, "示例景区 · 店长") XCTAssertEqual(user.toAccountSwitchAccount().subtitle, "示例景区 · 店长")
XCTAssertEqual(user.toAccountSwitchAccount().realName, "张三") XCTAssertEqual(user.toAccountSwitchAccount().realName, "张三")
XCTAssertEqual(user.toAccountSwitchAccount().toSetUserRequest(), SetUserRequest(storeUserId: 201)) XCTAssertEqual(user.toAccountSwitchAccount().toSetUserRequest(), SetUserRequest(storeUserId: 201))
XCTAssertEqual(user.status, 1)
XCTAssertFalse(user.toAccountSwitchAccount().requiresDeregistrationConfirmation)
} }
func testV9AccountModelsIgnoreBackendIdentifiersAndUseRealNameFallback() throws { func testV9AccountModelsIgnoreBackendIdentifiersAndUseRealNameFallback() throws {
+206
View File
@@ -3,12 +3,218 @@
// suixinkanTests // suixinkanTests
// //
import UIKit
import XCTest import XCTest
@testable import suixinkan @testable import suixinkan
@MainActor @MainActor
/// 登录页 ViewModel 测试,覆盖手机号规范化、表单校验和登录流程。 /// 登录页 ViewModel 测试,覆盖手机号规范化、表单校验和登录流程。
final class LoginViewModelTests: XCTestCase { final class LoginViewModelTests: XCTestCase {
/// 之前按手机号保存的 Mock 完成记录不能阻断旧接口下仍可用的其他业务身份。
func testLegacyPhoneWideMockRecordDoesNotBlockServerLogin() async throws {
let username = "13900000001"
let key = "account_deletion_mock_v1_" + username
let defaults = UserDefaults.standard
let previous = defaults.object(forKey: key)
defer {
if let previous { defaults.set(previous, forKey: key) }
else { defaults.removeObject(forKey: key) }
}
let oldRecord: [String: Any] = [
"id": UUID().uuidString,
"clientRequestID": UUID().uuidString,
"username": username,
"submittedAt": 0,
"scheduledDeletionAt": 0,
"status": "completed",
"acknowledgedAssetKinds": [],
]
defaults.set(try JSONSerialization.data(withJSONObject: oldRecord), forKey: key)
let login = Data(#"{"code":100000,"data":{"token":"temporary","scenic_users":[],"store_users":[{"id":101,"store_user_id":101,"store_id":25,"store_name":"另一门店"}]}}"#.utf8)
let selected = Data(#"{"code":100000,"data":{"token":"business","scenic_users":[],"store_users":[]}}"#.utf8)
let session = MockURLSession(responses: [login, selected])
let viewModel = LoginViewModel()
viewModel.updateAccount(username)
viewModel.updatePassword("test-password")
let api = AuthAPI(client: APIClient(environment: .testing, session: session))
let result = try await viewModel.login(authAPI: api)
guard case let .completed(response, account) = result else {
return XCTFail("可用门店身份应正常登录")
}
XCTAssertEqual(response.token, "business")
XCTAssertEqual(account.businessUserId, 101)
XCTAssertEqual(session.requests.map { $0.url?.path }, ["/api/app/v9/login", "/api/app/v9/set-user"])
}
/// 单一门店身份处于注销冷静期时,必须先返回确认状态,不能自动调用 set-user 撤销申请。
func testSingleCoolingOffStoreAccountRequiresConfirmationBeforeSetUser() async throws {
let login = Data(#"{"code":100000,"data":{"token":"temporary","scenic_users":[],"store_users":[{"store_user_id":101,"store_name":"待注销门店","status":2}]}}"#.utf8)
let selected = Data(#"{"code":100000,"data":{"token":"business","scenic_users":[],"store_users":[]}}"#.utf8)
let session = MockURLSession(responses: [login, selected])
let viewModel = LoginViewModel()
viewModel.updateAccount("13900000001")
viewModel.updatePassword("test-password")
let api = AuthAPI(client: APIClient(environment: .testing, session: session))
let resolution = try await viewModel.login(authAPI: api)
guard case let .needsDeregistrationConfirmation(confirmation) = resolution else {
return XCTFail("冷静期门店身份应等待用户确认")
}
XCTAssertEqual(confirmation.account.status, 2)
XCTAssertEqual(session.requests.map { $0.url?.path }, ["/api/app/v9/login"])
let response = try await viewModel.confirmDeregistrationLogin(confirmation, authAPI: api)
XCTAssertEqual(response.token, "business")
XCTAssertEqual(session.requests.map { $0.url?.path }, ["/api/app/v9/login", "/api/app/v9/set-user"])
}
/// 冷静期确认弹窗需明确告知登录将撤销注销申请,并提供保留申请的取消入口。
func testDeregistrationLoginAlertExplainsAutomaticCancellation() {
let alert = makeDeregistrationLoginAlert(
account: reentryAccount(isStore: true, status: 2),
onConfirm: {}
)
XCTAssertEqual(alert.title, "该账号正在注销")
XCTAssertTrue(alert.message?.contains("自动撤销之前的注销申请") == true)
XCTAssertEqual(alert.actions.map(\.title), ["暂不登录", "确认登录"])
}
/// 普通门店身份选择直接回调,不再无条件弹出注销提示。
func testSelectingStoreIdentityContinuesWithoutDeregistrationPrompt() throws {
let account = reentryAccount(isStore: true)
var confirmed: [AccountSwitchAccount] = []
let controller = AccountSelectionViewController(
payload: AccountSelectionPayload(tempToken: "temporary", accounts: [account]),
isLoading: false,
onCancel: {},
onConfirm: { confirmed.append($0) }
)
controller.loadViewIfNeeded()
let button = try XCTUnwrap(allSubviews(in: controller.view).compactMap { $0 as? UIButton }
.first { $0.currentTitle == "进入系统" })
button.sendActions(for: .touchUpInside)
XCTAssertEqual(confirmed, [account])
XCTAssertNil(controller.presentedViewController)
}
/// 登录按钮和键盘完成键均直接请求登录;空身份响应避免写入真实会话。
func testLoginPageRequestsLoginWithoutDeregistrationPrompt() async throws {
for useKeyboard in [false, true] {
let response = Data(#"{"code":100000,"data":{"token":"temporary","scenic_users":[],"store_users":[]}}"#.utf8)
let session = MockURLSession(responses: [response])
let controller = LoginViewController(authAPI: AuthAPI(client: APIClient(environment: .testing, session: session)))
controller.loadViewIfNeeded()
let views = allSubviews(in: controller.view)
let account = try XCTUnwrap(views.compactMap { $0 as? LoginTextField }.first)
let password = try XCTUnwrap(views.compactMap { $0 as? PasswordInputField }.first)
let agreement = try XCTUnwrap(views.compactMap { $0 as? AgreementRowView }.first)
account.text = "13900000001"
account.onTextChange?(account.text)
password.text = "mock-password"
password.onTextChange?(password.text)
if !agreement.isChecked { agreement.onCheckedChange?() }
let button = try XCTUnwrap(views.compactMap { $0 as? UIButton }.first { $0.currentTitle == "登录" })
if useKeyboard { password.onReturnKey?() }
else { button.sendActions(for: .touchUpInside) }
for _ in 0..<100 {
if !session.requests.isEmpty, button.isEnabled { break }
try await Task.sleep(nanoseconds: 10_000_000)
}
XCTAssertEqual(session.requests.map { $0.url?.path }, ["/api/app/v9/login"])
XCTAssertNil(controller.presentedViewController)
}
}
/// 移除注销提示后仍必须勾选用户协议,不能绕过原有表单校验。
func testLoginPageStillRequiresPrivacyAgreement() async throws {
let session = MockURLSession(responses: [])
let controller = LoginViewController(authAPI: AuthAPI(client: APIClient(environment: .testing, session: session)))
controller.loadViewIfNeeded()
let views = allSubviews(in: controller.view)
let account = try XCTUnwrap(views.compactMap { $0 as? LoginTextField }.first)
let password = try XCTUnwrap(views.compactMap { $0 as? PasswordInputField }.first)
let agreement = try XCTUnwrap(views.compactMap { $0 as? AgreementRowView }.first)
account.text = "13900000001"
account.onTextChange?(account.text)
password.onTextChange?("mock-password")
if agreement.isChecked { agreement.onCheckedChange?() }
let button = try XCTUnwrap(views.compactMap { $0 as? UIButton }.first { $0.currentTitle == "登录" })
button.sendActions(for: .touchUpInside)
await Task.yield()
XCTAssertTrue(session.requests.isEmpty)
XCTAssertNil(controller.presentedViewController)
}
/// 身份选择处于加载状态时仍不允许再次确认。
func testSelectingStoreIdentityWhileLoadingDoesNotConfirm() throws {
var confirmed: [AccountSwitchAccount] = []
let controller = AccountSelectionViewController(
payload: AccountSelectionPayload(tempToken: "temporary", accounts: [reentryAccount(isStore: true)]),
isLoading: true, onCancel: {}, onConfirm: { confirmed.append($0) })
controller.loadViewIfNeeded()
let button = try XCTUnwrap(allSubviews(in: controller.view).compactMap { $0 as? UIButton }
.first { $0.currentTitle == "进入系统" })
button.sendActions(for: .touchUpInside)
XCTAssertFalse(button.isEnabled)
XCTAssertTrue(confirmed.isEmpty)
XCTAssertNil(controller.presentedViewController)
}
/// 景区身份不受旧门店注销流程影响,不应被多余确认拦截。
func testSelectingScenicIdentityContinuesWithoutDeregistrationPrompt() throws {
let account = reentryAccount(isStore: false)
var confirmed: [AccountSwitchAccount] = []
let controller = AccountSelectionViewController(
payload: AccountSelectionPayload(tempToken: "temporary", accounts: [account]),
isLoading: false,
onCancel: {},
onConfirm: { confirmed.append($0) }
)
controller.loadViewIfNeeded()
let button = try XCTUnwrap(allSubviews(in: controller.view).compactMap { $0 as? UIButton }
.first { $0.currentTitle == "进入系统" })
button.sendActions(for: .touchUpInside)
XCTAssertEqual(confirmed, [account])
XCTAssertNil(controller.presentedViewController)
}
private func reentryAccount(isStore: Bool, status: Int = 1) -> AccountSwitchAccount {
AccountSwitchAccount(
accountType: isStore ? "store_user" : "scenic_user",
businessUserId: 101,
title: isStore ? "测试门店" : "测试景区",
subtitle: "摄影师",
phone: "13900000001",
realName: "测试用户",
avatar: "",
scenicName: "测试景区",
storeId: isStore ? 25 : nil,
storeName: isStore ? "测试门店" : "",
scenicId: 10,
status: status,
isCurrent: false
)
}
private func allSubviews(in view: UIView) -> [UIView] {
view.subviews.flatMap { [$0] + allSubviews(in: $0) }
}
func testNormalizeUsernameCountryCodeRemovesChinaPrefix() { func testNormalizeUsernameCountryCodeRemovesChinaPrefix() {
let viewModel = LoginViewModel() let viewModel = LoginViewModel()
viewModel.updateAccount("+86 186 5185 7230") viewModel.updateAccount("+86 186 5185 7230")
@@ -0,0 +1,263 @@
import XCTest
import UIKit
@testable import suixinkan
/// 线下收款接口契约与精确金额测试。
@MainActor
final class OfflineCollectionAPITests: XCTestCase {
func testStatisticsAndDetailsUseRequiredQueriesWithoutPagination() async throws {
let statistics = Data(#"{"code":100000,"msg":"success","data":{"today":{"date":"2026-08-25","total_amount":"350.00","paid_amount":"100.00","unpaid_amount":"250.00","collect_count":3},"pending":{"amount":"250.00","collect_count":2,"date_count":1,"dates":[{"date":"2026-08-25","unpaid_amount":"250.00","unpaid_count":2}]}}}"#.utf8)
let details = Data(#"{"code":100000,"msg":"success","data":{"date":"2026-08-24","total_amount":"0.00","collect_count":0,"paid_amount":"0.00","paid_count":0,"unpaid_amount":"0.00","unpaid_count":0,"status":1,"status_text":"本日已结清","collects":[]}}"#.utf8)
let session = MockURLSession(responses: [statistics, details])
let api = OfflineCollectionAPI(client: APIClient(environment: .testing, session: session))
let response = try await api.statistics(scenicId: 100)
let empty = try await api.details(scenicId: 100, date: "2026-08-24")
XCTAssertEqual(response.today.date, "2026-08-25")
XCTAssertEqual(response.todaySummary.pendingAmountFen, 25_000)
XCTAssertEqual(empty.collects.count, 0)
let statisticsQuery = URLComponents(url: try XCTUnwrap(session.requests[0].url), resolvingAgainstBaseURL: false)?.queryItems
XCTAssertEqual(statisticsQuery, [URLQueryItem(name: "scenic_id", value: "100")])
let detailQuery = try XCTUnwrap(URLComponents(url: try XCTUnwrap(session.requests[1].url), resolvingAgainstBaseURL: false)?.queryItems)
XCTAssertTrue(detailQuery.contains(URLQueryItem(name: "scenic_id", value: "100")))
XCTAssertTrue(detailQuery.contains(URLQueryItem(name: "date", value: "2026-08-24")))
XCTAssertFalse(detailQuery.contains { $0.name == "page" || $0.name == "page_size" })
}
func testRegisterAndSupplementBodiesMatchBackendContract() async throws {
let registered = Data(#"{"code":100000,"msg":"success","data":{"collect_no":"OC1","amount":"12.30","pay_method":2,"pay_method_text":"微信","status":0,"status_text":"未补缴","id":101,"created_at":"2026-08-25 09:30:00"}}"#.utf8)
let supplemented = Data(#"{"code":100000,"msg":"success","data":{"date":"2026-08-25","updated_count":1}}"#.utf8)
let session = MockURLSession(responses: [registered, supplemented])
let api = OfflineCollectionAPI(client: APIClient(environment: .testing, session: session))
_ = try await api.register(.init(scenicId: 100, amount: "12.30", payMethod: 2))
_ = try await api.supplement(.init(scenicId: 100, businessDate: "2026-08-25", amountFen: 1_230, pendingCount: 1))
let registerBody = try jsonBody(session.requests[0])
XCTAssertNil(registerBody["request_id"])
XCTAssertEqual(registerBody["scenic_id"] as? Int, 100)
XCTAssertEqual(registerBody["amount"] as? String, "12.30")
XCTAssertNil(registerBody["status"])
let supplementBody = try jsonBody(session.requests[1])
XCTAssertEqual(supplementBody["date"] as? String, "2026-08-25")
XCTAssertEqual(supplementBody["scenic_id"] as? Int, 100)
XCTAssertNil(supplementBody["request_id"])
XCTAssertNil(supplementBody["expected_unpaid_amount"])
XCTAssertNil(supplementBody["expected_unpaid_count"])
}
func testMoneyNeverUsesFloatingPoint() {
XCTAssertEqual(OfflineCollectionMoney.parseFen("0.01"), 1)
XCTAssertEqual(OfflineCollectionMoney.parseFen("12.3"), 1_230)
XCTAssertEqual(OfflineCollectionMoney.apiAmount(9_999_999), "99999.99")
XCTAssertEqual(OfflineCollectionMoney.displayAmount(200), "2")
XCTAssertEqual(OfflineCollectionMoney.displayAmount(210), "2.1")
XCTAssertEqual(OfflineCollectionMoney.displayAmount(222), "2.22")
XCTAssertNil(OfflineCollectionMoney.parseFen("0"))
XCTAssertNil(OfflineCollectionMoney.parseFen("100000.00"))
XCTAssertNil(OfflineCollectionMoney.parseFen("1.001"))
}
private func jsonBody(_ request: URLRequest) throws -> [String: Any] {
try XCTUnwrap(JSONSerialization.jsonObject(with: try XCTUnwrap(request.httpBody)) as? [String: Any])
}
}
/// 线下收款周/月日历边界测试。
final class OfflineCollectionCalendarTests: XCTestCase {
func testMondayFirstWeekAndSixRowMonth() throws {
let selected = try XCTUnwrap(OfflineCollectionDate.date(from: "2026-08-25"))
var state = OfflineCollectionCalendarState(selectedDate: selected, maximumDate: selected)
XCTAssertEqual(state.weekDates.map { OfflineCollectionDate.businessDate(for: $0) }, [
"2026-08-24", "2026-08-25", "2026-08-26", "2026-08-27", "2026-08-28", "2026-08-29", "2026-08-30",
])
state.toggleMode()
XCTAssertEqual(state.monthDates.count, 42)
XCTAssertEqual(OfflineCollectionDate.businessDate(for: state.monthDates[0]), "2026-07-27")
}
func testMonthSwipeClampsDayAndFutureDate() throws {
let maximum = try XCTUnwrap(OfflineCollectionDate.date(from: "2026-08-25"))
let january = try XCTUnwrap(OfflineCollectionDate.date(from: "2024-01-31"))
var state = OfflineCollectionCalendarState(selectedDate: january, maximumDate: maximum, mode: .month)
state.movePage(1)
XCTAssertEqual(OfflineCollectionDate.businessDate(for: state.selectedDate), "2024-02-29")
XCTAssertFalse(state.select(try XCTUnwrap(OfflineCollectionDate.date(from: "2026-08-26"))))
state.movePage(40)
XCTAssertEqual(OfflineCollectionDate.businessDate(for: state.selectedDate), "2026-08-25")
}
func testCalendarPageNavigationKeepsPositionAndUsesTodayAtFutureBoundary() throws {
let maximum = try XCTUnwrap(OfflineCollectionDate.date(from: "2026-08-26"))
let friday = try XCTUnwrap(OfflineCollectionDate.date(from: "2026-08-21"))
var week = OfflineCollectionCalendarState(selectedDate: friday, maximumDate: maximum)
XCTAssertTrue(week.canMovePage(1))
week.movePage(1)
XCTAssertEqual(OfflineCollectionDate.businessDate(for: week.selectedDate), "2026-08-26")
XCTAssertFalse(week.canMovePage(1))
let tuesday = try XCTUnwrap(OfflineCollectionDate.date(from: "2026-08-25"))
week = OfflineCollectionCalendarState(selectedDate: tuesday, maximumDate: maximum)
week.movePage(-1)
XCTAssertEqual(OfflineCollectionDate.businessDate(for: week.selectedDate), "2026-08-18")
let march = try XCTUnwrap(OfflineCollectionDate.date(from: "2025-03-31"))
var month = OfflineCollectionCalendarState(selectedDate: march, maximumDate: maximum, mode: .month)
month.movePage(-1)
XCTAssertEqual(OfflineCollectionDate.businessDate(for: month.selectedDate), "2025-02-28")
}
func testSelectedWeekRowIndexAndModeTogglePreserveDate() throws {
let selected = try XCTUnwrap(OfflineCollectionDate.date(from: "2026-08-12"))
var state = OfflineCollectionCalendarState(selectedDate: selected, maximumDate: selected)
XCTAssertEqual(state.selectedWeekRowIndex, 2)
state.toggleMode()
XCTAssertEqual(OfflineCollectionDate.businessDate(for: state.selectedDate), "2026-08-12")
XCTAssertEqual(state.selectedWeekRowIndex, 2)
}
}
/// 线下收款 ViewModel 的并发与幂等行为测试。
@MainActor
final class OfflineCollectionViewModelTests: XCTestCase {
func testRegistrationViewMatchesReferenceStructure() throws {
let controller = OfflineCollectionRegistrationViewController(
context: .init(collectorName: "张三", storeName: "那拉提旅拍一店", scenicId: 100, scenicName: "那拉提景区"),
api: OfflineCollectionFakeAPI()
)
controller.loadViewIfNeeded()
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
controller.view.layoutIfNeeded()
let subviews = allSubviews(in: controller.view)
let labels = subviews.compactMap { ($0 as? UILabel)?.text }
let amountField = try XCTUnwrap(subviews.compactMap { $0 as? UITextField }.first { $0.accessibilityLabel == "收款金额" })
let methodButtons = subviews.compactMap { $0 as? OfflinePaymentMethodButton }
let submitButton = try XCTUnwrap(subviews.compactMap { $0 as? UIButton }.first { $0.accessibilityIdentifier == "offlineCollection.submit" })
XCTAssertEqual(controller.title, "线下收款登记")
XCTAssertTrue(labels.contains("收款金额"))
XCTAssertTrue(labels.contains("收款方式"))
XCTAssertTrue(labels.contains("登记后将计入今日待补缴,不生成订单。"))
XCTAssertTrue(labels.contains("当前:张三 · 那拉提旅拍一店 · 那拉提景区"))
XCTAssertEqual(amountField.font?.pointSize, 38)
XCTAssertEqual(amountField.textAlignment, .right)
XCTAssertEqual(methodButtons.count, 3)
XCTAssertEqual(submitButton.configuration?.title, "确认登记")
XCTAssertFalse(submitButton.configuration?.showsActivityIndicator ?? false)
}
func testDailyViewControllerLoadsWithoutConstraintCrash() {
let controller = OfflineCollectionDailyViewController(
businessDate: "2026-08-25",
context: .init(collectorName: "张三", storeName: "门店", scenicId: 100, scenicName: "景区"),
api: OfflineCollectionFakeAPI()
)
controller.loadViewIfNeeded()
XCTAssertNotNil(controller.view)
}
func testOfflineCollectionHomeCardsUseTheirEntireSurfaceForNavigation() throws {
let homeView = OfflineCollectionHomeView(frame: CGRect(x: 0, y: 0, width: 390, height: 300))
var openedRegistration = false
var openedToday = false
homeView.onRegister = { openedRegistration = true }
homeView.onOpenToday = { openedToday = true }
homeView.apply(
today: .init(businessDate: "2026-08-25", totalCount: 1, totalAmountFen: 1_230, settledCount: 0, settledAmountFen: 0, pendingCount: 1, pendingAmountFen: 1_230),
overdueDayCount: 0,
overdueRecordCount: 0,
overdueAmountFen: 0,
errorMessage: nil
)
homeView.setNeedsLayout()
homeView.layoutIfNeeded()
let controls = allSubviews(in: homeView).compactMap { $0 as? UIControl }
let registrationCard = try XCTUnwrap(controls.first { $0.accessibilityLabel?.hasPrefix("线下收款登记") == true })
let todayCard = try XCTUnwrap(controls.first { $0.accessibilityLabel?.hasPrefix("今日待补缴") == true })
XCTAssertTrue(registrationCard.hitTest(CGPoint(x: registrationCard.bounds.midX, y: registrationCard.bounds.midY), with: nil) === registrationCard)
XCTAssertTrue(todayCard.hitTest(CGPoint(x: todayCard.bounds.midX, y: todayCard.bounds.midY), with: nil) === todayCard)
registrationCard.sendActions(for: .touchUpInside)
todayCard.sendActions(for: .touchUpInside)
XCTAssertTrue(openedRegistration)
XCTAssertTrue(openedToday)
}
func testRapidDateChangesDiscardOlderResponse() async throws {
let api = OfflineCollectionFakeAPI()
api.detailDelays["2026-08-24"] = 150_000_000
let viewModel = OfflineCollectionDailyViewModel(
businessDate: "2026-08-25",
context: .init(collectorName: "张三", storeName: "门店", scenicId: 100, scenicName: "景区"),
api: api
)
let older = Task { await viewModel.selectBusinessDate("2026-08-24") }
try await Task.sleep(nanoseconds: 20_000_000)
await viewModel.selectBusinessDate("2026-08-25")
await older.value
XCTAssertEqual(viewModel.businessDate, "2026-08-25")
XCTAssertEqual(viewModel.summary.businessDate, "2026-08-25")
}
func testRegistrationRetryUsesExistingBackendRequestShape() async {
let api = OfflineCollectionFakeAPI()
api.registerFailuresRemaining = 1
let viewModel = OfflineCollectionRegistrationViewModel(
context: .init(collectorName: "张三", storeName: "门店", scenicId: 100, scenicName: "景区"),
api: api
)
viewModel.updateAmount("12.30")
await viewModel.submit()
await viewModel.submit()
XCTAssertEqual(api.registerRequests.count, 2)
XCTAssertEqual(api.registerRequests[0].amount, api.registerRequests[1].amount)
}
private func allSubviews(in view: UIView) -> [UIView] {
view.subviews.flatMap { [$0] + allSubviews(in: $0) }
}
}
/// 为 ViewModel 测试提供可控延时和失败次数的线下收款接口替身。
@MainActor
private final class OfflineCollectionFakeAPI: OfflineCollectionServing {
var detailDelays: [String: UInt64] = [:]
var registerFailuresRemaining = 0
private(set) var registerRequests: [OfflineCollectionRegisterRequest] = []
func statistics(scenicId: Int) async throws -> OfflineCollectionStatisticsResponse {
try decode(#"{"today":{"date":"2026-08-25","total_amount":"12.30","paid_amount":"0.00","unpaid_amount":"12.30","collect_count":1},"pending":{"amount":"12.30","collect_count":1,"date_count":1,"dates":[{"date":"2026-08-25","unpaid_amount":"12.30","unpaid_count":1}]}}"#)
}
func details(scenicId: Int, date: String) async throws -> OfflineCollectionDetailsResponse {
if let delay = detailDelays[date] { try? await Task.sleep(nanoseconds: delay) }
return try decode(#"{"date":"\#(date)","total_amount":"0.00","collect_count":0,"paid_amount":"0.00","paid_count":0,"unpaid_amount":"0.00","unpaid_count":0,"status":1,"status_text":"本日已结清","collects":[]}"#)
}
func register(_ request: OfflineCollectionRegisterRequest) async throws -> OfflineCollectionRegisterResponse {
registerRequests.append(request)
if registerFailuresRemaining > 0 {
registerFailuresRemaining -= 1
throw URLError(.networkConnectionLost)
}
return try decode(#"{"collect_no":"OC1","amount":"12.30","pay_method":2,"pay_method_text":"微信","status":0,"status_text":"未补缴","id":101,"created_at":"2026-08-25 09:30:00"}"#)
}
func supplement(_ request: OfflineSettlementRequest) async throws -> OfflineSettlementResult {
try decode(#"{"date":"2026-08-25","updated_count":1}"#)
}
private func decode<T: Decodable>(_ json: String) throws -> T {
try JSONDecoder().decode(T.self, from: Data(json.utf8))
}
}
@@ -130,6 +130,25 @@ final class PushNotificationTests: XCTestCase {
XCTAssertTrue(api.registrationIDs.isEmpty) XCTAssertTrue(api.registrationIDs.isEmpty)
} }
/// 注销状态核验期间,不因 SDK 回调或前台重试调用普通业务绑定接口。
func testDeregistrationCheckSuspendsBindingWithoutClearingSession() async {
authenticate(userID: "100")
let token = appStore.session.token
let api = PushRegistrationAPIMock()
let manager = makeManager(sdk: PushSDKMock(registrationID: "reg-id"), api: api)
manager.setAccountBindingSuspended(true)
manager.initializeIfPrivacyAccepted()
manager.handleLoginCompleted()
manager.retryPendingRegistrationUpload()
for _ in 0..<10 { await Task.yield() }
XCTAssertTrue(api.registrationIDs.isEmpty)
XCTAssertEqual(appStore.session.token, token)
manager.setAccountBindingSuspended(false)
manager.handleLoginCompleted()
await waitUntil { api.registrationIDs.count == 1 }
XCTAssertEqual(api.registrationIDs, ["reg-id"])
}
func testLoggedOutStateDoesNotUpload() async { func testLoggedOutStateDoesNotUpload() async {
appStore.session.privacyAgreementAccepted = true appStore.session.privacyAgreementAccepted = true
let sdk = PushSDKMock(registrationID: "reg-id") let sdk = PushSDKMock(registrationID: "reg-id")
@@ -3,11 +3,29 @@
// suixinkanTests // suixinkanTests
// //
import UIKit
import XCTest import XCTest
@testable import suixinkan @testable import suixinkan
/// 设置中心 ViewModel 测试。 /// 设置中心 ViewModel 测试。
final class SettingViewModelTests: XCTestCase { final class SettingViewModelTests: XCTestCase {
/// 注销入口标题使用危险操作红色,普通菜单和右侧文字保持原色。
@MainActor
func testDeregistrationMenuTitleUsesRedWithoutChangingOtherMenuColors() throws {
let row = SettingMenuRow(title: "注销当前门店身份", titleColor: AppColor.danger, value: "查看", showsDivider: false)
let normalRow = SettingMenuRow(title: "关于我们")
let title = try XCTUnwrap(row.subviews.compactMap { $0 as? UILabel }.first)
let normalTitle = try XCTUnwrap(normalRow.subviews.compactMap { $0 as? UILabel }.first)
let rightStack = try XCTUnwrap(row.subviews.compactMap { $0 as? UIStackView }.first)
let value = try XCTUnwrap(rightStack.arrangedSubviews.compactMap { $0 as? UILabel }.first)
XCTAssertEqual(title.textColor, AppColor.danger)
XCTAssertEqual(normalTitle.textColor, UIColor(hex: 0x4B5563))
XCTAssertEqual(value.textColor, AppColor.textPrimary)
row.isHighlighted = true
XCTAssertEqual(title.textColor, AppColor.danger)
}
func testAppVersionUsesClientInfoFormatting() { func testAppVersionUsesClientInfoFormatting() {
let viewModel = SettingViewModel( let viewModel = SettingViewModel(
infoDictionary: [ infoDictionary: [
@@ -0,0 +1,201 @@
import XCTest
@testable import suixinkan
/// 验证旧文档已明确的请求契约和门店身份隔离;不替代尚缺响应样例的业务映射测试。
@MainActor
final class StoreAccountDeregistrationAPITests: XCTestCase {
private var suiteName = ""
private var defaults: UserDefaults!
private var store: AppSessionStore!
override func setUp() async throws {
try await super.setUp()
suiteName = "StoreAccountDeregistrationAPITests.\(UUID().uuidString)"
defaults = UserDefaults(suiteName: suiteName)!
store = AppSessionStore(defaults: defaults)
store.accountType = .storeUser
store.userId = "101"
store.currentStoreId = 25
store.accountDisplayName = "测试门店·摄影师"
store.token = "store-101-token"
}
override func tearDown() async throws {
defaults.removePersistentDomain(forName: suiteName)
store = nil
defaults = nil
try await super.tearDown()
}
/// 门店身份 ID 与门店实体 ID 不可混用,景区身份及无凭证状态不可构造上下文。
func testIdentityUsesStoreUserIDAndRejectsUnsupportedSessions() throws {
let identity = try StoreAccountDeregistrationIdentity(session: store)
XCTAssertEqual(identity.userID, "101")
XCTAssertEqual(identity.displayName, "测试门店·摄影师")
XCTAssertTrue(identity.matches(session: store))
store.accountType = .scenicUser
XCTAssertFalse(identity.matches(session: store))
XCTAssertThrowsError(try StoreAccountDeregistrationIdentity(session: store)) { error in
XCTAssertEqual(error as? StoreAccountDeregistrationError, .unsupportedIdentity)
}
store.accountType = .storeUser
store.token = ""
XCTAssertThrowsError(try StoreAccountDeregistrationIdentity(session: store)) { error in
XCTAssertEqual(error as? StoreAccountDeregistrationError, .invalidSession)
}
}
/// 七个请求使用文档中的路径、方法和字段;禁止向接口传入任意手机号或其他身份 ID。
func testAllSevenEndpointsMatchLegacyDocument() async throws {
let success = Data(#"{"code":100000,"msg":"success","data":null}"#.utf8)
let eligibility = try StoreAccountDeregistrationFixtures.envelope(StoreAccountDeregistrationFixtures.eligibilityData())
let status = Data(#"{"code":100000,"msg":"success","data":{"deregister":null}}"#.utf8)
let session = MockURLSession(responses: [eligibility, success, success, success, success, status, success])
let api = try makeAPI(session: session)
let eligibilityPayload = try await api.eligibility()
try await api.waiveWallet()
try await api.waivePoints()
try await api.sendSMS()
try await api.apply(smsCode: "654321", reason: "不再使用")
let statusPayload = try await api.status()
try await api.cancel()
let paths = ["eligibility", "waivers/wallet", "waivers/points", "send-sms", "apply", "status", "cancel"]
XCTAssertEqual(session.requests.map { $0.url?.path }, paths.map { "/api/yf-handset-app/account-deregister/" + $0 })
XCTAssertEqual(session.requests.map(\.httpMethod), ["GET", "POST", "POST", "POST", "POST", "GET", "POST"])
for request in session.requests {
XCTAssertEqual(request.value(forHTTPHeaderField: "token"), "store-101-token")
XCTAssertNil(request.url?.query)
}
for index in [1, 2] {
let data = try XCTUnwrap(session.requests[index].httpBody)
let body = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Bool])
XCTAssertEqual(body, ["accepted": true])
}
let applyData = try XCTUnwrap(session.requests[4].httpBody)
let applyBody = try XCTUnwrap(JSONSerialization.jsonObject(with: applyData) as? [String: String])
XCTAssertEqual(applyBody, ["sms_code": "654321", "reason": "不再使用"])
for index in [0, 3, 5, 6] { XCTAssertNil(session.requests[index].httpBody) }
XCTAssertFalse(eligibilityPayload.canApply)
XCTAssertEqual(eligibilityPayload.blockers.count, 4)
XCTAssertEqual(statusPayload.deregister, .null)
}
/// 创建流程后切换到同手机号的另一身份,必须在请求发出前拒绝旧流程操作。
func testIdentitySwitchPreventsSendingRequest() async throws {
let session = MockURLSession(responses: [])
let api = try makeAPI(session: session)
store.userId = "102"
store.token = "store-102-token"
do {
try await api.waiveWallet()
XCTFail("切换身份后不能继续提交原确认页")
} catch {
XCTAssertEqual(error as? StoreAccountDeregistrationError, .sessionChanged)
}
XCTAssertTrue(session.requests.isEmpty)
}
/// 同一身份重新登录取得新凭证,也必须重新进入注销流程。
func testTokenChangePreventsSendingRequest() async throws {
let session = MockURLSession(responses: [])
let api = try makeAPI(session: session)
store.token = "new-token"
do {
try await api.apply(smsCode: "654321", reason: "不再使用")
XCTFail("凭证变化后不能继续旧流程")
} catch {
XCTAssertEqual(error as? StoreAccountDeregistrationError, .sessionChanged)
}
XCTAssertTrue(session.requests.isEmpty)
}
/// 请求等待期间切换身份,旧响应不得应用到新身份页面。
func testResponseFromPreviousIdentityIsDiscarded() async throws {
let session = DeregistrationCallbackSession { [store] in store?.userId = "102" }
let api = try makeAPI(session: session)
do {
_ = try await api.status()
XCTFail("旧身份响应不能应用到新身份")
} catch {
XCTAssertEqual(error as? StoreAccountDeregistrationError, .sessionChanged)
}
XCTAssertEqual(session.requests.first?.value(forHTTPHeaderField: "token"), "store-101-token")
}
/// 余额变化等后端错误保留业务码,不自动重试资产放弃或提交操作。
func testMutationPreservesServerFailureWithoutRetry() async throws {
let data = try TestJSON.errorEnvelope(code: 100001, msg: "余额已变化,请重新确认")
let session = MockURLSession(responses: [data])
let api = try makeAPI(session: session)
do {
try await api.waivePoints()
XCTFail("不能把服务端失败当作确认成功")
} catch let APIError.serverCode(code, message) {
XCTAssertEqual(code, 100001)
XCTAssertEqual(message, "余额已变化,请重新确认")
}
XCTAssertEqual(session.requests.count, 1)
}
/// 150015 表示当前身份受限,不是手机号主账号退出,也不能清掉查询与撤销所需凭证。
func testRestrictionCodeIsNotAuthenticationExpiry() async throws {
let data = try TestJSON.errorEnvelope(code: 150015, msg: "当前身份正在注销")
let session = MockURLSession(responses: [data])
let api = try makeAPI(session: session)
do {
try await api.sendSMS()
XCTFail("限制状态应交由业务层处理")
} catch {
XCTAssertFalse(APIError.isAuthenticationExpired(error))
guard case APIError.serverCode(150015, _) = error else {
return XCTFail("应保留旧接口明确约定的限制码")
}
}
XCTAssertEqual(store.token, "store-101-token")
}
/// 文档未定义的字段和状态值必须原样保留,不把未知值解释成可以注销。
func testRawResponsePreservesUnknownValuesAndDecimalPrecision() throws {
let data = Data(#"{"unknown_status":17,"value":123456789.12,"optional":null,"nested":[true,"17",{}]}"#.utf8)
let payload = try JSONDecoder().decode(StoreAccountDeregistrationJSON.self, from: data)
guard case let .object(fields) = payload else { return XCTFail("应保留对象") }
XCTAssertEqual(fields["unknown_status"], .number(17))
XCTAssertEqual(fields["value"], .number(Decimal(string: "123456789.12")!))
XCTAssertEqual(fields["optional"], .null)
XCTAssertEqual(fields["nested"], .array([.bool(true), .string("17"), .object([:])]))
}
private func makeAPI(session: URLSessionProtocol) throws -> StoreAccountDeregistrationAPI {
let identity = try StoreAccountDeregistrationIdentity(session: store)
let client = APIClient(environment: .testing, session: session)
client.bindAuthTokenProvider { "must-not-use-global-token" }
return StoreAccountDeregistrationAPI(client: client, identity: identity) { [store] in
guard let store else { return false }
return identity.matches(session: store)
}
}
}
/// 在返回数据前模拟会话变化,用于验证异步旧响应隔离。
private final class DeregistrationCallbackSession: URLSessionProtocol {
private let onRequest: @MainActor () -> Void
private(set) var requests: [URLRequest] = []
/// 注入等待期间发生的主线程会话操作。
init(onRequest: @escaping @MainActor () -> Void) {
self.onRequest = onRequest
}
/// 返回最小合法 Envelope,不假定业务状态字段。
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
requests.append(request)
await onRequest()
let data = Data(#"{"code":100000,"msg":"success","data":{"deregister":null}}"#.utf8)
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (data, response)
}
}
@@ -0,0 +1,399 @@
import UIKit
import XCTest
@testable import suixinkan
/// 只读进入核验与受限错误隔离测试,不使用设备上的真实会话。
@MainActor
final class StoreAccountDeregistrationAccessTests: XCTestCase {
/// 已知冷静期后重查到旧null/草稿不能放行,完整撤销记录才解除限制。
func testObservedCoolingDoesNotRegressToOldUnsubmittedState() async throws {
for stale in [StoreAccountDeregistrationStatus(deregister: .null), try StoreAccountDeregistrationFixtures.draftStatus()] {
let service = DeregistrationStatusStub()
service.result = try StoreAccountDeregistrationFixtures.lifecycleStatus().deregister
let model = StoreAccountDeregistrationAccessViewModel()
await model.verify(api: service, isCurrentIdentity: { true })
service.result = stale.deregister
await model.verify(api: service, isCurrentIdentity: { true })
XCTAssertEqual(model.decision, .unresolved)
service.result = try StoreAccountDeregistrationFixtures.lifecycleStatus(cancelled: true).deregister
await model.verify(api: service, isCurrentIdentity: { true })
XCTAssertEqual(model.decision, .allowed)
}
}
/// 在 iPhone 上渲染不可返回的冷静期页面,只保留退出和撤销入口。
func testCoolingPageRendersCancellationEntryOnPhysicalDevice() async throws {
let name = "DeregistrationCoolingPage.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: name)!
defer { defaults.removePersistentDomain(forName: name) }
let session = AppSessionStore(defaults: defaults)
session.accountType = .storeUser
session.userId = "101"
session.token = "isolated-token"
session.accountDisplayName = "示例门店"
let identity = try StoreAccountDeregistrationIdentity(session: session)
let service = try DeregistrationCancellationStub()
let controller = StoreAccountDeregistrationAccessViewController(identity: identity, api: service, session: session) { _ in
XCTFail("冷静期不进入业务")
}
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = UINavigationController(rootViewController: controller)
window.makeKeyAndVisible()
defer { window.isHidden = true }
controller.loadViewIfNeeded()
let views = allSubviews(controller.view)
let cancel = try XCTUnwrap(views.first { $0.accessibilityIdentifier == "deregister.access.cancel" } as? UIButton)
let message = try XCTUnwrap(views.first { $0.accessibilityIdentifier == "deregister.access.message" } as? UILabel)
for _ in 0..<100 {
if !cancel.isHidden, cancel.isEnabled { break }
try await Task.sleep(nanoseconds: 10_000_000)
}
window.layoutIfNeeded()
XCTAssertFalse(cancel.isHidden)
XCTAssertTrue(cancel.isEnabled)
XCTAssertTrue(message.text?.contains("当前身份:") == true)
let deadline = try XCTUnwrap(views.first { $0.accessibilityIdentifier == "deregister.access.deadline" } as? UILabel)
XCTAssertEqual(deadline.text, "2026-09-04 14:23:40")
XCTAssertFalse(message.text?.contains("604776") == true)
XCTAssertTrue(window.bounds.contains(cancel.convert(cancel.bounds, to: window)))
XCTAssertTrue(controller.navigationItem.hidesBackButton)
XCTAssertEqual((window.rootViewController as? UINavigationController)?.interactivePopGestureRecognizer?.isEnabled, false)
let texts = views.compactMap { ($0 as? UILabel)?.text }
XCTAssertTrue(texts.contains("重新登录该身份自动撤销申请"))
XCTAssertNil(views.first { $0.accessibilityIdentifier == "deregister.access.conditions" })
let attachment = XCTAttachment(image: UIGraphicsImageRenderer(bounds: window.bounds).image { context in
window.layer.render(in: context.cgContext)
})
attachment.name = "store-deregistration-cooling"
attachment.lifetime = .keepAlways
add(attachment)
XCTAssertEqual(service.calls, ["status"])
}
private func allSubviews(_ view: UIView) -> [UIView] { view.subviews.flatMap { [$0] + allSubviews($0) } }
/// 实测冷静期不进入业务;恰好到期仍待服务端复核,不能凭本机时间完成注销。
func testCoolingAndExactDeadlineRemainRestricted() async throws {
for seconds in [604776, 0] {
let service = DeregistrationStatusStub()
service.result = try StoreAccountDeregistrationFixtures.lifecycleStatus(overrides: ["remaining_seconds": seconds]).deregister
let model = StoreAccountDeregistrationAccessViewModel()
await model.verify(api: service, isCurrentIdentity: { true })
XCTAssertEqual(model.decision, .cooling)
XCTAssertEqual(model.status?.remainingSeconds, Int64(seconds))
}
}
/// 撤销必须先核对申请,POST 后再次查询为已撤销才恢复;不会重复 POST。
func testCancelRechecksStatusAndRestoresBusinessOnlyAfterConfirmation() async throws {
let service = try DeregistrationCancellationStub()
let model = StoreAccountDeregistrationAccessViewModel()
await model.verify(api: service, isCurrentIdentity: { true })
await model.cancel(api: service, isCurrentIdentity: { true })
XCTAssertEqual(model.decision, .allowed)
XCTAssertEqual(service.calls, ["status", "status", "cancel", "status"])
await model.cancel(api: service, isCurrentIdentity: { true })
XCTAssertEqual(service.calls.filter { $0 == "cancel" }.count, 1)
}
/// 撤销响应丢失不自动重试,之后只读查到9即可恢复。
func testLostCancellationResponseDoesNotRetryMutation() async throws {
let service = try DeregistrationCancellationStub()
service.cancelError = APIError.networkFailed("lost response")
let model = StoreAccountDeregistrationAccessViewModel()
await model.verify(api: service, isCurrentIdentity: { true })
await model.cancel(api: service, isCurrentIdentity: { true })
guard case .failed = model.decision else { return XCTFail("结果未知不能直接放行") }
await model.cancel(api: service, isCurrentIdentity: { true })
await model.verify(api: service, isCurrentIdentity: { true })
XCTAssertEqual(model.decision, .allowed)
XCTAssertEqual(service.calls.filter { $0 == "cancel" }.count, 1)
}
/// 后端未更新为撤销时继续受限,即使 POST 返回成功。
func testSuccessfulCancelWithoutConfirmedStatusDoesNotAllowBusiness() async throws {
let service = try DeregistrationCancellationStub()
service.afterCancel = service.current
let model = StoreAccountDeregistrationAccessViewModel()
await model.verify(api: service, isCurrentIdentity: { true })
await model.cancel(api: service, isCurrentIdentity: { true })
XCTAssertEqual(model.decision, .cooling)
}
/// 已看到冷静期后,取消前后的旧 null/草稿不能作为撤销成功的依据。
func testOldNullOrDraftDuringCancellationDoesNotAllowBusiness() async throws {
for stale in [StoreAccountDeregistrationStatus(deregister: .null), try StoreAccountDeregistrationFixtures.draftStatus()] {
for beforePost in [true, false] {
let service = try DeregistrationCancellationStub()
let model = StoreAccountDeregistrationAccessViewModel()
await model.verify(api: service, isCurrentIdentity: { true })
if beforePost { service.current = stale } else { service.afterCancel = stale }
await model.cancel(api: service, isCurrentIdentity: { true })
XCTAssertEqual(model.decision, .unresolved)
XCTAssertEqual(service.calls.contains("cancel"), !beforePost)
}
}
}
/// 已被撤销或换成另一申请时,旧弹窗不能撤销新申请。
func testChangedApplicationBeforeCancelPreventsPost() async throws {
for cancelled in [true, false] {
let service = try DeregistrationCancellationStub()
let model = StoreAccountDeregistrationAccessViewModel()
await model.verify(api: service, isCurrentIdentity: { true })
service.current = try StoreAccountDeregistrationFixtures.lifecycleStatus(cancelled: cancelled, overrides: ["id": 302])
await model.cancel(api: service, isCurrentIdentity: { true })
XCTAssertFalse(service.calls.contains("cancel"))
XCTAssertEqual(model.decision, cancelled ? .allowed : .cooling)
}
}
/// 取消请求期间切换账号或收到更新的限制信号,旧结果不能解除新会话的限制。
func testCancellationDiscardsChangedIdentityAndNewRestriction() async throws {
for changeIdentity in [true, false] {
var current = true
let service = try DeregistrationCancellationStub()
let model = StoreAccountDeregistrationAccessViewModel()
await model.verify(api: service, isCurrentIdentity: { current })
service.onCancel = {
if changeIdentity { current = false } else { model.recordRestriction() }
}
await model.cancel(api: service, isCurrentIdentity: { current })
XCTAssertEqual(model.decision, changeIdentity ? .obsolete : .unresolved)
XCTAssertEqual(service.calls, ["status", "status", "cancel"])
}
}
/// 冷启动可用新撤销记录核销意图,但再次申请后相同的旧9状态不能放行。
func testCancelledStatusReconcilesOnlyNewCancellation() async throws {
let name = "DeregistrationCancelledRecovery.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: name)!
defer { defaults.removePersistentDomain(forName: name) }
let store = StoreAccountDeregistrationSubmissionStore(storeUserID: "101", environment: .testing, defaults: defaults)
let service = DeregistrationStatusStub()
let cancelled = try StoreAccountDeregistrationFixtures.lifecycleStatus(cancelled: true)
service.result = cancelled.deregister
try store.recordSubmissionIntent()
let restored = StoreAccountDeregistrationAccessViewModel(submissionStore: store)
await restored.verify(api: service, isCurrentIdentity: { true })
XCTAssertEqual(restored.decision, .allowed)
XCTAssertFalse(store.hasUnresolvedSubmission)
try store.recordSubmissionIntent(previousStatus: cancelled)
let reapplied = StoreAccountDeregistrationAccessViewModel(submissionStore: store)
await reapplied.verify(api: service, isCurrentIdentity: { true })
XCTAssertEqual(reapplied.decision, .unresolved)
XCTAssertTrue(store.hasUnresolvedSubmission)
service.result = try StoreAccountDeregistrationFixtures.lifecycleStatus(cancelled: true,
overrides: ["cancel_time": "2026-08-28 15:00:00"]).deregister
await reapplied.verify(api: service, isCurrentIdentity: { true })
XCTAssertEqual(reapplied.decision, .allowed)
XCTAssertFalse(store.hasUnresolvedSubmission)
}
func testRequiresSuccessfulNullStatusBeforeAllowingBusiness() async {
let model = StoreAccountDeregistrationAccessViewModel()
let service = DeregistrationStatusStub()
XCTAssertEqual(model.decision, .notChecked)
await model.verify(api: service, isCurrentIdentity: { true })
XCTAssertEqual(model.decision, .allowed)
XCTAssertEqual(service.queryCount, 1)
}
func testUnknownNonemptyRecordDoesNotAllowBusiness() async {
let service = DeregistrationStatusStub()
service.result = .object(["unknown_status": .number(17)])
let model = StoreAccountDeregistrationAccessViewModel()
await model.verify(api: service, isCurrentIdentity: { true })
XCTAssertEqual(model.decision, .unresolved)
}
/// 资产确认创建的是未提交草稿,冷启动不能将其误当成冷静期限制。
func testObservedDraftAllowsBusinessWithoutMutation() async throws {
let service = DeregistrationStatusStub()
service.result = try StoreAccountDeregistrationFixtures.draftStatus().deregister
let model = StoreAccountDeregistrationAccessViewModel()
await model.verify(api: service, isCurrentIdentity: { true })
XCTAssertEqual(model.decision, .allowed)
XCTAssertEqual(service.queryCount, 1)
}
/// 提交结果不明时,旧草稿响应不能清除待核实的提交意图。
func testLocalUnresolvedIntentStillBlocksOnDraftStatus() async throws {
let service = DeregistrationStatusStub()
service.result = try StoreAccountDeregistrationFixtures.draftStatus().deregister
let model = StoreAccountDeregistrationAccessViewModel(requiresSubmissionReconciliation: true)
await model.verify(api: service, isCurrentIdentity: { true })
XCTAssertEqual(model.decision, .unresolved)
}
func testLocalUnresolvedIntentDoesNotAllowBusinessOnNullStatus() async {
let model = StoreAccountDeregistrationAccessViewModel(requiresSubmissionReconciliation: true)
await model.verify(api: DeregistrationStatusStub(), isCurrentIdentity: { true })
XCTAssertEqual(model.decision, .unresolved)
}
func testFailureDoesNotMeanNoApplication() async {
let service = DeregistrationStatusStub()
service.error = APIError.networkFailed("offline")
let model = StoreAccountDeregistrationAccessViewModel()
await model.verify(api: service, isCurrentIdentity: { true })
guard case .failed = model.decision else { return XCTFail("网络失败必须保持未核验") }
service.error = APIError.serverCode(150015, "身份受限")
await model.verify(api: service, isCurrentIdentity: { true })
XCTAssertEqual(model.decision, .unresolved)
}
func testChangedIdentityPreventsQuery() async {
let service = DeregistrationStatusStub()
let model = StoreAccountDeregistrationAccessViewModel()
await model.verify(api: service, isCurrentIdentity: { false })
XCTAssertEqual(model.decision, .obsolete)
XCTAssertEqual(service.queryCount, 0)
}
func testChangedIdentityDiscardsLateResponseAndError() async {
for fails in [false, true] {
var current = true
let service = DeregistrationStatusStub()
service.onQuery = { current = false }
service.error = fails ? APIError.networkFailed("old error") : nil
let model = StoreAccountDeregistrationAccessViewModel()
await model.verify(api: service, isCurrentIdentity: { current })
XCTAssertEqual(model.decision, .obsolete)
}
}
func testNewRestrictionWinsOverOldNormalResponse() async {
let model = StoreAccountDeregistrationAccessViewModel()
let service = DeregistrationStatusStub()
service.onQuery = { model.recordRestriction() }
await model.verify(api: service, isCurrentIdentity: { true })
XCTAssertEqual(model.decision, .unresolved)
service.onQuery = nil
await model.verify(api: service, isCurrentIdentity: { true })
XCTAssertEqual(model.decision, .allowed)
}
func testRestrictionOnlyMatchesCurrentStoreToken() {
let name = "DeregistrationAccessTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: name)!
defer { defaults.removePersistentDomain(forName: name) }
let session = AppSessionStore(defaults: defaults)
session.accountType = .storeUser
session.token = "current-store-token"
XCTAssertTrue(StoreAccountDeregistrationAccessViewModel.restrictionApplies(requestToken: session.token, session: session))
XCTAssertFalse(StoreAccountDeregistrationAccessViewModel.restrictionApplies(requestToken: "old-token", session: session))
XCTAssertFalse(StoreAccountDeregistrationAccessViewModel.restrictionApplies(requestToken: nil, session: session))
session.accountType = .scenicUser
XCTAssertFalse(StoreAccountDeregistrationAccessViewModel.restrictionApplies(requestToken: session.token, session: session))
}
func testRestrictionNotificationUsesOriginalRequestTokenWithoutExpiringSession() async throws {
let token = "test-original-token"
let center = NotificationCenter()
let restricted = XCTNSNotificationExpectation(name: NotificationName.storeAccountDeregistrationRestricted, object: nil, notificationCenter: center)
restricted.handler = { notification in
let requestToken = notification.userInfo?[NotificationUserInfoKey.deregistrationRequestToken] as? String
return requestToken == token
}
let expired = XCTNSNotificationExpectation(name: NotificationName.sessionDidExpire, object: nil, notificationCenter: center)
expired.isInverted = true
let network = MockURLSession(responses: [StoreAccountDeregistrationFixtures.observedCoolingRestrictionEnvelope])
let client = APIClient(environment: .testing, session: network, notificationCenter: center)
client.bindAuthTokenProvider { "new-current-token" }
do {
let _: EmptyPayload = try await client.send(APIRequest(method: .get, path: "/test/business"), tokenOverride: token)
XCTFail("必须保留150015错误")
} catch {
XCTAssertFalse(APIError.isAuthenticationExpired(error))
guard case APIError.serverCode(150015, let message) = error else {
return XCTFail("真实150015响应不能被误判为解码失败")
}
XCTAssertEqual(message, "账号处于注销冷静期,请先撤销注销后再继续使用")
}
await fulfillment(of: [restricted, expired], timeout: 0.1)
}
func testAccessPageQueriesOnlyStatusBeforeAllowingEntry() async throws {
let name = "DeregistrationAccessPageTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: name)!
defer { defaults.removePersistentDomain(forName: name) }
let session = AppSessionStore(defaults: defaults)
session.accountType = .storeUser
session.userId = "101"
session.token = "isolated-token"
let identity = try StoreAccountDeregistrationIdentity(session: session)
let network = MockURLSession(responses: [Data(#"{"code":100000,"data":{"deregister":null}}"#.utf8)])
let api = StoreAccountDeregistrationAPI(client: APIClient(environment: .testing, session: network), identity: identity) {
identity.matches(session: session)
}
let allowed = expectation(description: "状态查询后允许进入")
let controller = StoreAccountDeregistrationAccessViewController(identity: identity, api: api, session: session) { _ in
allowed.fulfill()
}
controller.loadViewIfNeeded()
await fulfillment(of: [allowed], timeout: 2)
XCTAssertEqual(network.requests.map(\.httpMethod), ["GET"])
XCTAssertEqual(network.requests.first?.url?.path, "/api/yf-handset-app/account-deregister/status")
XCTAssertEqual(session.token, "isolated-token")
}
/// 防御性测试:HTTP 拒绝中明确携带150015时仍是注销限制;并非已实测到该HTTP组合。
func testExplicitDeregistrationCodeInHTTPFailureIsNotTokenExpiry() async throws {
let network = MockURLSession(responses: [try TestJSON.errorEnvelope(code: 150015, msg: "注销限制")], statusCode: 403)
let client = APIClient(environment: .testing, session: network)
do {
let _: EmptyPayload = try await client.send(APIRequest(method: .get, path: "/test/business"), tokenOverride: "isolated-token")
XCTFail("应保留150015限制")
} catch {
XCTAssertFalse(APIError.isAuthenticationExpired(error))
guard case APIError.serverCode(150015, _) = error else { return XCTFail("应保留业务限制码") }
}
}
}
/// 撤销专用内存服务,可模拟响应丢失、申请变化及并发限制,不调用真实网络。
@MainActor
private final class DeregistrationCancellationStub: StoreAccountDeregistrationServing {
var current: StoreAccountDeregistrationStatus
var afterCancel: StoreAccountDeregistrationStatus
var cancelError: Error?
var onCancel: (() -> Void)?
var calls: [String] = []
init() throws {
current = try StoreAccountDeregistrationFixtures.lifecycleStatus()
afterCancel = try StoreAccountDeregistrationFixtures.lifecycleStatus(cancelled: true)
}
func status() async throws -> StoreAccountDeregistrationStatus { calls.append("status"); return current }
func cancel() async throws {
calls.append("cancel")
current = afterCancel
onCancel?()
if let cancelError { throw cancelError }
}
func eligibility() async throws -> StoreAccountDeregistrationEligibility {
XCTFail("状态核验无需调用条件接口")
return try StoreAccountDeregistrationFixtures.eligibility()
}
func waiveWallet() async throws { XCTFail("不应确认资产") }
func waivePoints() async throws { XCTFail("不应确认资产") }
func sendSMS() async throws { XCTFail("不应发送短信") }
func apply(smsCode: String, reason: String) async throws { XCTFail("不应申请") }
}
/// 状态查询的可控替身,模拟服务端返回和查询途中发生的会话变化。
@MainActor
private final class DeregistrationStatusStub: StoreAccountDeregistrationStatusServing {
var result: StoreAccountDeregistrationJSON = .null
var error: Error?
var onQuery: (() -> Void)?
private(set) var queryCount = 0
func status() async throws -> StoreAccountDeregistrationStatus {
queryCount += 1
onQuery?()
if let error { throw error }
return StoreAccountDeregistrationStatus(deregister: result)
}
}
@@ -0,0 +1,764 @@
import UIKit
import XCTest
@testable import suixinkan
/// 合并入口仍调用两条旧接口,覆盖部分成功、快照变化与重复操作;所有请求只在内存中执行。
@MainActor
final class StoreAccountDeregistrationCombinedTests: XCTestCase {
/// 零资产也按现金、积分顺序分别确认,全部核实后才进入验证码页。
func testConfirmsBothZeroAssetsInOrder() async throws {
let service = CombinedDeregistrationService()
let model = try await readyModel(service)
XCTAssertTrue(model.requiresAssetConfirmation)
await model.confirmAssetsAndContinue(snapshot: try XCTUnwrap(model.eligibility), api: service)
XCTAssertEqual(service.mutations, ["wallet", "points"])
XCTAssertEqual(model.step, .verification)
XCTAssertTrue(model.canContinue)
XCTAssertFalse(model.requiresAssetConfirmation)
}
/// 第二项失败保留现金确认结果,用户重试时不重复确认第一项。
func testPartialFailureOnlyRetriesUnconfirmedAsset() async throws {
let service = CombinedDeregistrationService()
service.pointsError = APIError.networkFailed("积分确认失败")
let model = try await readyModel(service)
await model.confirmAssetsAndContinue(snapshot: try XCTUnwrap(model.eligibility), api: service)
XCTAssertEqual(model.step, .conditions)
XCTAssertEqual(model.eligibility?.walletWaived, true)
XCTAssertEqual(model.eligibility?.pointsWaived, false)
XCTAssertTrue(model.errorMessage?.contains("现金余额已确认,积分尚未确认") == true)
XCTAssertEqual(service.mutations, ["wallet", "points"])
service.pointsError = nil
await model.confirmAssetsAndContinue(snapshot: try XCTUnwrap(model.eligibility), api: service)
XCTAssertEqual(service.mutations, ["wallet", "points", "points"])
XCTAssertEqual(model.step, .verification)
}
/// 已确认资产不重复提交,两项均已确认时仅查询并进入下一步。
func testSkipsPreviouslyConfirmedAssets() async throws {
for both in [false, true] {
let service = CombinedDeregistrationService()
service.walletWaived = true
service.pointsWaived = both
let model = try await readyModel(service)
await model.confirmAssetsAndContinue(snapshot: try XCTUnwrap(model.eligibility), api: service)
XCTAssertEqual(service.mutations, both ? [] : ["points"])
XCTAssertEqual(model.step, .verification)
}
}
/// 用户看到的金额与提交前快照不同,不提交任何资产确认。
func testChangedBalanceBeforeConfirmationRequiresNewConsent() async throws {
let service = CombinedDeregistrationService()
let model = try await readyModel(service)
let snapshot = try XCTUnwrap(model.eligibility)
service.points = 10
await model.confirmAssetsAndContinue(snapshot: snapshot, api: service)
XCTAssertTrue(service.mutations.isEmpty)
XCTAssertEqual(model.eligibility?.pointsBalance, 10)
XCTAssertTrue(model.requiresAssetConfirmation)
XCTAssertEqual(model.step, .conditions)
XCTAssertNotNil(model.errorMessage)
}
/// 两条接口之间余额变化时停止,不自动同意新余额。
func testBalanceChangeBetweenRequestsStopsSecondMutation() async throws {
let service = CombinedDeregistrationService()
service.onWallet = { service.points = 10 }
let model = try await readyModel(service)
await model.confirmAssetsAndContinue(snapshot: try XCTUnwrap(model.eligibility), api: service)
XCTAssertEqual(service.mutations, ["wallet"])
XCTAssertEqual(model.eligibility?.pointsBalance, 10)
XCTAssertEqual(model.step, .conditions)
XCTAssertTrue(model.requiresAssetConfirmation)
}
/// 财务归属变化即使金额相同也必须重新核对。
func testFinanceIdentityChangeStopsConfirmation() async throws {
let service = CombinedDeregistrationService()
let model = try await readyModel(service)
let snapshot = try XCTUnwrap(model.eligibility)
service.financeID = 202
await model.confirmAssetsAndContinue(snapshot: snapshot, api: service)
XCTAssertTrue(service.mutations.isEmpty)
XCTAssertEqual(model.step, .conditions)
XCTAssertTrue(model.requiresAssetConfirmation)
}
/// 请求期间身份变化,丢弃当前资料且不继续第二项。
func testIdentityChangeBetweenRequestsStopsFlow() async throws {
let service = CombinedDeregistrationService()
service.onWallet = { service.userID = 102 }
let model = try await readyModel(service)
await model.confirmAssetsAndContinue(snapshot: try XCTUnwrap(model.eligibility), api: service)
XCTAssertEqual(service.mutations, ["wallet"])
XCTAssertNil(model.eligibility)
XCTAssertFalse(model.canContinue)
XCTAssertFalse(model.canConfirmAssetsAndContinue)
}
/// 连点不能启动第二组请求。
func testRepeatedTapWhileBusyDoesNotDuplicateMutations() async throws {
let service = CombinedDeregistrationService()
service.holdWallet = true
let model = try await readyModel(service)
let snapshot = try XCTUnwrap(model.eligibility)
let first = Task { await model.confirmAssetsAndContinue(snapshot: snapshot, api: service) }
for _ in 0..<100 {
if service.walletContinuation != nil { break }
try await Task.sleep(nanoseconds: 10_000_000)
}
XCTAssertNotNil(service.walletContinuation)
await model.confirmAssetsAndContinue(snapshot: snapshot, api: service)
XCTAssertEqual(service.mutations, ["wallet"])
service.walletContinuation?.resume()
service.walletContinuation = nil
await first.value
XCTAssertEqual(service.mutations, ["wallet", "points"])
}
/// 非资产阻断不能被合并按钮绕过,未知代码同样阻止继续。
func testBusinessBlockersPreventAnyConfirmation() async throws {
for code in ["ORDER_UNFULFILLED", "RISK_WINDOW_NOT_EXPIRED", "UNKNOWN_NEW_RULE"] {
let service = CombinedDeregistrationService()
service.extraBlockers = [["code": code, "message": "需处理", "action": "contact_support"]]
let model = try await readyModel(service)
XCTAssertFalse(model.canConfirmAssetsAndContinue)
await model.confirmAssetsAndContinue(snapshot: try XCTUnwrap(model.eligibility), api: service)
XCTAssertTrue(service.mutations.isEmpty)
}
}
/// POST成功但GET未确认时停止,不乐观置为完成。
func testUnconfirmedWalletResponseDoesNotProceedToPoints() async throws {
let service = CombinedDeregistrationService()
service.confirmWalletOnServer = false
let model = try await readyModel(service)
await model.confirmAssetsAndContinue(snapshot: try XCTUnwrap(model.eligibility), api: service)
XCTAssertEqual(service.mutations, ["wallet"])
XCTAssertEqual(model.eligibility?.walletWaived, false)
XCTAssertFalse(model.canContinue)
XCTAssertEqual(model.step, .conditions)
}
/// 响应丢失后只读发现两项成功,不自动重发;下一次主动继续无需再确认。
func testLostPointsResponseReconcilesWithoutResubmitting() async throws {
let service = CombinedDeregistrationService()
service.pointsError = APIError.networkFailed("响应丢失")
service.commitPointsBeforeError = true
let model = try await readyModel(service)
await model.confirmAssetsAndContinue(snapshot: try XCTUnwrap(model.eligibility), api: service)
XCTAssertEqual(service.mutations, ["wallet", "points"])
XCTAssertEqual(model.step, .conditions)
XCTAssertFalse(model.requiresAssetConfirmation)
await model.confirmAssetsAndContinue(snapshot: try XCTUnwrap(model.eligibility), api: service)
XCTAssertEqual(service.mutations, ["wallet", "points"])
XCTAssertEqual(model.step, .verification)
}
/// 已确认项在点击下一步期间失效时必须重新确认,不能无弹窗再次提交。
func testExpiredConfirmationRequiresFreshConsent() async throws {
let service = CombinedDeregistrationService()
service.walletWaived = true
service.pointsWaived = true
let model = try await readyModel(service)
let snapshot = try XCTUnwrap(model.eligibility)
service.walletWaived = false
await model.confirmAssetsAndContinue(snapshot: snapshot, api: service)
XCTAssertTrue(service.mutations.isEmpty)
XCTAssertTrue(model.requiresAssetConfirmation)
XCTAssertEqual(model.step, .conditions)
}
/// 确认后的查询失败时清空操作权限,避免使用旧金额继续。
func testReadFailureAfterFirstMutationDisablesContinue() async throws {
let service = CombinedDeregistrationService()
service.onWallet = { service.readError = APIError.networkFailed("offline") }
let model = try await readyModel(service)
await model.confirmAssetsAndContinue(snapshot: try XCTUnwrap(model.eligibility), api: service)
XCTAssertEqual(service.mutations, ["wallet"])
XCTAssertNil(model.eligibility)
XCTAssertFalse(model.canConfirmAssetsAndContinue)
XCTAssertFalse(model.canContinue)
}
private func readyModel(_ service: CombinedDeregistrationService) async throws -> StoreAccountDeregistrationViewModel {
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
await model.refresh(api: service)
_ = try XCTUnwrap(model.eligibility)
return model
}
}
/// 两步页面的实际UIKit布局和交互测试;独立窗口不触碰用户真实会话。
@MainActor
final class StoreAccountDeregistrationRedesignTests: XCTestCase {
override func setUp() {
super.setUp()
GlobalLoadingManager.shared.hideAll()
}
override func tearDown() {
GlobalLoadingManager.shared.hideAll()
super.tearDown()
}
/// 明确受理后只读获取截止时间并调用一次退出;在途连点及成功后再次点击均不重发。
func testAcceptedSubmissionCapturesCoolingDeadlineAndExitsOnce() async throws {
let service = CombinedDeregistrationService()
service.walletWaived = true
service.pointsWaived = true
service.holdApply = true
let coolingStatus = try StoreAccountDeregistrationFixtures.lifecycleStatus()
service.onApply = { service.statusOverride = coolingStatus }
let suite = "DeregistrationLogout.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
let store = StoreAccountDeregistrationSubmissionStore(storeUserID: "101", environment: .testing, defaults: defaults)
defer { defaults.removePersistentDomain(forName: suite) }
let model = StoreAccountDeregistrationViewModel(storeUserID: 101, submissionStore: store)
var logoutCount = 0
var acceptedDeadline: String?
var unresolvedCount = 0
let controller = StoreAccountDeregistrationViewController(
identityName: "测试门店", viewModel: model, api: service,
onUnresolvedSubmission: { unresolvedCount += 1 }, onSubmissionAccepted: { deadline in
acceptedDeadline = deadline
logoutCount += 1
})
let window = makeWindow(controller, size: UIScreen.main.bounds.size)
defer { service.finishApply(); close(window) }
await waitUntil { model.canContinue }
model.beginVerification()
let initialStatusCount = service.statusCount
let initialEligibilityCount = service.eligibilityCount
controller.submitConfirmedApplication(smsCode: "654321", reason: " 不再使用 ")
await waitUntil { service.applyContinuation != nil }
controller.submitConfirmedApplication(smsCode: "654321", reason: " 不再使用 ")
XCTAssertEqual(logoutCount, 0)
XCTAssertTrue(model.isBusy)
XCTAssertTrue(GlobalLoadingManager.shared.isShowing)
XCTAssertNotNil(allViews(window).first { $0 is GlobalLoadingOverlayView })
capture(window, name: "deregister-submitting-global-loading")
service.finishApply()
await waitUntil { logoutCount == 1 }
controller.submitConfirmedApplication(smsCode: "654321", reason: " 不再使用 ")
XCTAssertEqual(logoutCount, 1)
XCTAssertEqual(unresolvedCount, 0)
XCTAssertEqual(service.mutations, ["apply"])
XCTAssertEqual(service.lastReason, "不再使用")
XCTAssertEqual(service.statusCount, initialStatusCount + 2, "提交前校验并在成功后只读获取截止时间")
XCTAssertEqual(service.eligibilityCount, initialEligibilityCount + 1)
XCTAssertTrue(model.submissionAccepted)
XCTAssertEqual(model.submittedCoolingUntil, coolingStatus.coolingUntil)
XCTAssertEqual(acceptedDeadline, coolingStatus.coolingUntil)
XCTAssertNil(model.errorMessage)
XCTAssertTrue(store.hasUnresolvedSubmission, "保留意图供下次登录核实")
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
}
/// 业务拒绝不退出;网络响应丢失只转入状态核验,不当作成功或自动重试。
func testRejectedAndUncertainSubmissionDoNotLogout() async throws {
for uncertain in [false, true] {
let service = CombinedDeregistrationService()
service.walletWaived = true
service.pointsWaived = true
service.applyError = uncertain ? APIError.networkFailed("响应丢失") : APIError.serverCode(100001, "验证码错误")
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
var logoutCount = 0
var unresolvedCount = 0
let controller = StoreAccountDeregistrationViewController(
identityName: "测试门店", viewModel: model, api: service,
onUnresolvedSubmission: { unresolvedCount += 1 }, onSubmissionAccepted: { _ in logoutCount += 1 })
let window = makeWindow(controller, size: UIScreen.main.bounds.size)
defer { close(window) }
await waitUntil { model.canContinue }
model.beginVerification()
let codeField = try XCTUnwrap(find(controller.view, "deregister.code") as? UITextField)
let reasonField = try XCTUnwrap(find(controller.view, "deregister.reason") as? UITextView)
codeField.text = "654321"
reasonField.text = "不再使用"
controller.submitConfirmedApplication(smsCode: "654321", reason: "不再使用")
await waitUntil { model.errorMessage != nil && !model.isBusy }
XCTAssertEqual(logoutCount, 0)
XCTAssertEqual(unresolvedCount, uncertain ? 1 : 0)
XCTAssertEqual(model.submissionAttempted, uncertain)
XCTAssertFalse(model.submissionAccepted)
XCTAssertEqual(model.step, uncertain ? .unresolvedRequest : .verification)
XCTAssertEqual(model.canContinue, !uncertain)
let submit = try XCTUnwrap(find(controller.view, "deregister.submit") as? UIButton)
XCTAssertEqual(submit.isHidden, uncertain)
if !uncertain {
XCTAssertEqual(codeField.text, "654321")
XCTAssertEqual(reasonField.text, "不再使用")
}
XCTAssertEqual(service.mutations, ["apply"])
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
}
}
/// 两步页面下拉只查询;使用统一加载,成功或失败都结束刷新且保留用户输入。
func testPullRefreshUsesGlobalLoadingAndPreservesVerificationInput() async throws {
for failed in [false, true] {
let service = CombinedDeregistrationService()
service.walletWaived = true
service.pointsWaived = true
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
let controller = StoreAccountDeregistrationViewController(identityName: "测试门店", viewModel: model, api: service)
let window = makeWindow(controller, size: CGSize(width: 375, height: 667))
defer { service.finishStatus(); close(window) }
await waitUntil { model.canContinue }
let next = try XCTUnwrap(find(controller.view, "deregister.continue") as? UIButton)
next.sendActions(for: .touchUpInside)
await waitUntil { model.step == .verification }
let code = try XCTUnwrap(find(controller.view, "deregister.code") as? UITextField)
let reason = try XCTUnwrap(find(controller.view, "deregister.reason") as? UITextView)
code.text = "654321"
reason.text = "不再使用"
let scroll = try XCTUnwrap(allViews(controller.view).first { $0 is UIScrollView } as? UIScrollView)
let refresh = try XCTUnwrap(scroll.refreshControl)
XCTAssertNil(controller.navigationItem.rightBarButtonItem)
XCTAssertTrue(scroll.alwaysBounceVertical)
let count = service.statusCount
service.holdNextStatus = true
refresh.beginRefreshing()
refresh.sendActions(for: .valueChanged)
await waitUntil { service.statusContinuation != nil }
XCTAssertTrue(GlobalLoadingManager.shared.isShowing)
XCTAssertFalse(next.isEnabled)
refresh.sendActions(for: .valueChanged)
XCTAssertEqual(service.statusCount, count + 1)
if failed { service.readError = APIError.networkFailed("offline") }
service.finishStatus()
await waitUntil { !model.isBusy && !GlobalLoadingManager.shared.isShowing }
XCTAssertFalse(refresh.isRefreshing)
XCTAssertEqual(code.text, "654321")
XCTAssertEqual(reason.text, "不再使用")
XCTAssertEqual(model.errorMessage != nil, failed)
XCTAssertTrue(service.mutations.isEmpty)
}
}
/// 只读条件页同样支持下拉,不含旧导航刷新或其他身份分类提示。
func testReadOnlyConditionsHavePullRefreshAndSimplifiedCopy() async throws {
let service = CombinedDeregistrationService()
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
let controller = StoreAccountDeregistrationViewController(identityName: "测试门店", viewModel: model, api: service, readOnly: true)
let window = makeWindow(controller, size: CGSize(width: 375, height: 667))
defer { close(window) }
await waitUntil { model.eligibility != nil }
XCTAssertNil(controller.navigationItem.rightBarButtonItem)
let scroll = try XCTUnwrap(allViews(controller.view).first { $0 is UIScrollView } as? UIScrollView)
let refresh = try XCTUnwrap(scroll.refreshControl)
let count = service.statusCount
refresh.sendActions(for: .valueChanged)
await waitUntil { service.statusCount == count + 1 && !model.isBusy }
let texts = allViews(controller.view).compactMap { ($0 as? UILabel)?.text }.joined(separator: "\n")
XCTAssertFalse(texts.contains("景区"))
XCTAssertTrue(texts.contains("仅注销当前身份"))
XCTAssertFalse(texts.contains("同手机号的其他身份不受影响"))
XCTAssertFalse(texts.contains("到期由服务端复核"))
XCTAssertFalse(texts.contains("历史订单、财务及审计记录"))
XCTAssertFalse(StoreAccountDeregistrationError.unsupportedIdentity.localizedDescription.contains("景区"))
XCTAssertTrue(service.mutations.isEmpty)
XCTAssertFalse(refresh.isRefreshing)
}
/// 点击提交仅显示高保真最终确认;原因必填并在确认弹层展示规范化内容。
func testSubmitRequiresFinalConfirmationWithLogoutNotice() async throws {
let service = CombinedDeregistrationService()
service.walletWaived = true
service.pointsWaived = true
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
var logoutCount = 0
let controller = StoreAccountDeregistrationViewController(
identityName: "当前测试身份", viewModel: model, api: service,
onSubmissionAccepted: { _ in logoutCount += 1 })
let window = makeWindow(controller, size: UIScreen.main.bounds.size)
defer { close(window) }
await waitUntil { model.canContinue }
let next = try XCTUnwrap(find(controller.view, "deregister.continue") as? UIButton)
next.sendActions(for: .touchUpInside)
await waitUntil { model.step == .verification }
let code = try XCTUnwrap(find(controller.view, "deregister.code") as? UITextField)
let reason = try XCTUnwrap(find(controller.view, "deregister.reason") as? UITextView)
code.text = "654321"
code.sendActions(for: .editingChanged)
let submit = try XCTUnwrap(find(controller.view, "deregister.submit") as? UIButton)
XCTAssertFalse(submit.isEnabled, "验证码和注销原因均为必填项")
reason.text = " 门店停业 "
controller.textViewDidChange(reason)
XCTAssertTrue(submit.isEnabled)
submit.sendActions(for: .touchUpInside)
await waitUntil { controller.presentedViewController is StoreAccountDeregistrationConfirmationSheetViewController }
let sheet = try XCTUnwrap(controller.presentedViewController as? StoreAccountDeregistrationConfirmationSheetViewController)
sheet.loadViewIfNeeded()
let texts = allViews(sheet.view).compactMap { ($0 as? UILabel)?.text }.joined(separator: "\n")
XCTAssertTrue(texts.contains("确认提交注销申请?"))
XCTAssertTrue(texts.contains("当前测试身份"))
XCTAssertTrue(texts.contains("7 天"))
XCTAssertTrue(texts.contains("注销原因"))
XCTAssertTrue(texts.contains("门店停业"))
XCTAssertTrue(texts.contains("正式完成后不可恢复"))
XCTAssertEqual((find(sheet.view, "deregister.sheet.cancel") as? UIButton)?.configuration?.title, "我再想想")
XCTAssertEqual((find(sheet.view, "deregister.sheet.confirm") as? UIButton)?.configuration?.title, "确认提交")
try? await Task.sleep(nanoseconds: 350_000_000)
try assertSheetHugsContent(sheet)
capture(window, name: "redesign-submit-confirmation")
XCTAssertTrue(service.mutations.isEmpty)
XCTAssertEqual(logoutCount, 0)
}
/// 申请受理后的退出态结果页只保留“完成”,不能返回或继续操作注销接口。
func testSubmittedPageOnlyAllowsCompletingToLogin() throws {
var doneCount = 0
let controller = StoreAccountDeregistrationSubmittedViewController(
identityName: " 当前测试身份 ",
coolingUntil: "2026-09-07 18:30:00"
) {
doneCount += 1
}
let window = makeWindow(controller, size: UIScreen.main.bounds.size)
defer { close(window) }
window.layoutIfNeeded()
let title = try XCTUnwrap(find(controller.view, "deregister.submitted.title") as? UILabel)
let identity = try XCTUnwrap(find(controller.view, "deregister.submitted.identity") as? UILabel)
let deadline = try XCTUnwrap(find(controller.view, "deregister.submitted.deadline") as? UILabel)
let done = try XCTUnwrap(find(controller.view, "deregister.submitted.done") as? UIButton)
XCTAssertEqual(title.text, "注销申请已提交")
XCTAssertEqual(identity.text, "当前身份:当前测试身份")
XCTAssertEqual(deadline.text, "2026-09-07 18:30:00")
XCTAssertEqual(done.configuration?.title, "完成")
XCTAssertNil(find(controller.view, "deregister.access.logout"))
XCTAssertNil(find(controller.view, "deregister.access.cancel"))
XCTAssertTrue(controller.navigationItem.hidesBackButton)
XCTAssertFalse(controller.navigationController?.interactivePopGestureRecognizer?.isEnabled ?? true)
XCTAssertTrue(window.bounds.contains(done.convert(done.bounds, to: window)))
capture(window, name: "redesign-submitted-signed-out")
done.sendActions(for: .touchUpInside)
done.sendActions(for: .touchUpInside)
XCTAssertEqual(doneCount, 1)
}
/// iPhone 11 画布按最终设计稿渲染资产页和资产确认弹层;步骤标题必须保持横排。
func testDesignReferenceAssetScreensOnPhysicalDevice() async throws {
let service = CombinedDeregistrationService()
service.walletFen = 1260
service.points = 20
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
let controller = StoreAccountDeregistrationViewController(identityName: "示例门店", viewModel: model, api: service)
let window = makeWindow(controller, size: UIScreen.main.bounds.size)
defer { close(window) }
await waitUntil { model.canConfirmAssetsAndContinue }
window.layoutIfNeeded()
let labels = allViews(controller.view).compactMap { $0 as? UILabel }
for title in ["确认资产", "手机验证"] {
let label = try XCTUnwrap(labels.first { $0.text == title })
XCTAssertEqual(label.numberOfLines, 1)
XCTAssertLessThan(label.bounds.height, 28)
}
let assetStep = try XCTUnwrap(find(controller.view, "deregister.step.assets"))
let verificationStep = try XCTUnwrap(find(controller.view, "deregister.step.verification"))
XCTAssertEqual(assetStep.bounds.width, verificationStep.bounds.width, accuracy: 0.5)
XCTAssertEqual(assetStep.bounds.height, 46, accuracy: 0.5)
XCTAssertEqual(verificationStep.bounds.height, 46, accuracy: 0.5)
XCTAssertEqual(assetStep.backgroundColor, StoreAccountDeregistrationStyle.primary)
XCTAssertNotEqual(assetStep.backgroundColor, verificationStep.backgroundColor)
XCTAssertTrue(assetStep.accessibilityTraits.contains(.selected))
let pageTexts = labels.compactMap(\.text).joined(separator: "\n")
XCTAssertFalse(pageTexts.contains("同手机号的其他身份不受影响"))
XCTAssertFalse(pageTexts.contains("到期由服务端复核"))
XCTAssertFalse(pageTexts.contains("历史订单、财务及审计记录"))
capture(window, name: "redesign-assets-iphone11")
let next = try XCTUnwrap(find(controller.view, "deregister.continue") as? UIButton)
next.sendActions(for: .touchUpInside)
await waitUntil { controller.presentedViewController is StoreAccountDeregistrationConfirmationSheetViewController }
try? await Task.sleep(nanoseconds: 350_000_000)
let sheet = try XCTUnwrap(controller.presentedViewController as? StoreAccountDeregistrationConfirmationSheetViewController)
try assertSheetHugsContent(sheet)
capture(window, name: "redesign-asset-confirmation")
XCTAssertTrue(service.mutations.isEmpty)
}
/// 375pt小屏上仅一个主按钮,零资产确认也先弹窗,没有自动提交。
func testSingleAssetButtonShowsOneExplicitConfirmation() async throws {
let service = CombinedDeregistrationService()
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
let controller = StoreAccountDeregistrationViewController(identityName: "北大科技园·测试身份", viewModel: model, api: service)
let window = makeWindow(controller, size: CGSize(width: 375, height: 667))
defer { close(window) }
await waitUntil { model.canConfirmAssetsAndContinue }
window.layoutIfNeeded()
let next = try XCTUnwrap(find(controller.view, "deregister.continue") as? UIButton)
XCTAssertEqual(next.configuration?.title, "确认资产并继续")
XCTAssertNil(controller.navigationItem.rightBarButtonItem)
XCTAssertNotNil(find(controller.view, "deregister.refresh") as? UIRefreshControl)
let texts = allViews(controller.view).compactMap { ($0 as? UILabel)?.text }.joined(separator: "\n")
XCTAssertFalse(texts.contains("景区"))
XCTAssertNil(find(controller.view, "deregister.wallet"))
XCTAssertNil(find(controller.view, "deregister.points"))
XCTAssertTrue(window.bounds.contains(next.convert(next.bounds, to: window)))
capture(window, name: "redesign-assets-375")
next.sendActions(for: .touchUpInside)
await waitUntil { controller.presentedViewController is StoreAccountDeregistrationConfirmationSheetViewController }
let sheet = try XCTUnwrap(controller.presentedViewController as? StoreAccountDeregistrationConfirmationSheetViewController)
sheet.loadViewIfNeeded()
let sheetTexts = allViews(sheet.view).compactMap { ($0 as? UILabel)?.text }.joined(separator: "\n")
XCTAssertTrue(sheetTexts.contains("确认放弃账号资产"))
XCTAssertTrue(sheetTexts.contains("¥0.00"))
XCTAssertTrue(sheetTexts.contains("积分\n0"))
XCTAssertFalse(sheetTexts.contains("自愿放弃以上资产"))
XCTAssertFalse(sheetTexts.contains("零余额也需要确认"))
XCTAssertEqual((find(sheet.view, "deregister.sheet.cancel") as? UIButton)?.configuration?.title, "暂不确认")
XCTAssertEqual((find(sheet.view, "deregister.sheet.confirm") as? UIButton)?.configuration?.title, "确认放弃")
XCTAssertTrue(service.mutations.isEmpty)
}
/// 已确认资产不再弹窗;验证码和原因均必填,多行输入与错误提示不被键盘遮挡。
func testVerificationLayoutInputAndKeyboard() async throws {
let service = CombinedDeregistrationService()
service.walletWaived = true
service.pointsWaived = true
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
let controller = StoreAccountDeregistrationViewController(identityName: "北大科技园·测试身份", viewModel: model, api: service)
let window = makeWindow(controller, size: UIScreen.main.bounds.size)
defer { close(window) }
await waitUntil { model.canContinue }
let next = try XCTUnwrap(find(controller.view, "deregister.continue") as? UIButton)
next.sendActions(for: .touchUpInside)
await waitUntil { model.step == .verification }
window.layoutIfNeeded()
let assetStep = try XCTUnwrap(find(controller.view, "deregister.step.assets"))
let verificationStep = try XCTUnwrap(find(controller.view, "deregister.step.verification"))
XCTAssertNotEqual(assetStep.backgroundColor, verificationStep.backgroundColor)
XCTAssertTrue(verificationStep.accessibilityTraits.contains(.selected))
XCTAssertFalse(assetStep.accessibilityTraits.contains(.selected))
let code = try XCTUnwrap(find(controller.view, "deregister.code") as? UITextField)
let reason = try XCTUnwrap(find(controller.view, "deregister.reason") as? UITextView)
let sms = try XCTUnwrap(find(controller.view, "deregister.sms") as? UIButton)
let submit = try XCTUnwrap(find(controller.view, "deregister.submit") as? UIButton)
XCTAssertEqual(code.textContentType, .oneTimeCode)
XCTAssertEqual(code.keyboardType, .numberPad)
XCTAssertEqual(code.superview, sms.superview)
XCTAssertEqual(find(controller.view, "deregister.reason.title")?.accessibilityLabel, "注销原因,必填")
XCTAssertFalse(submit.isEnabled)
capture(window, name: "redesign-verification")
code.text = "123456"
code.sendActions(for: .editingChanged)
XCTAssertFalse(submit.isEnabled)
reason.becomeFirstResponder()
reason.resignFirstResponder()
controller.textViewDidEndEditing(reason)
let reasonError = try XCTUnwrap(find(controller.view, "deregister.reason.error") as? UILabel)
XCTAssertFalse(reasonError.isHidden)
XCTAssertEqual(reasonError.text, "请填写注销原因")
reason.text = "测试"
controller.textViewDidChange(reason)
XCTAssertTrue(submit.isEnabled)
XCTAssertTrue(reasonError.isHidden)
let navigationFrame = controller.navigationController?.view.frame
XCTAssertTrue(code.becomeFirstResponder())
try? await Task.sleep(nanoseconds: 100_000_000)
window.layoutIfNeeded()
let frame = submit.convert(submit.bounds, to: controller.view)
XCTAssertLessThanOrEqual(frame.maxY, controller.view.keyboardLayoutGuide.layoutFrame.minY + 1)
XCTAssertGreaterThan(frame.minY, 0)
capture(window, name: "redesign-verification-keyboard")
reason.becomeFirstResponder()
let scroll = try XCTUnwrap(allViews(controller.view).first { $0 is UIScrollView } as? UIScrollView)
await waitUntil {
window.layoutIfNeeded()
return scroll.bounds.contains(reason.convert(reason.bounds, to: scroll))
}
XCTAssertEqual(controller.navigationController?.view.frame, navigationFrame)
capture(window, name: "redesign-reason-keyboard")
reason.resignFirstResponder()
XCTAssertTrue(service.mutations.isEmpty)
}
/// 非零资产与长阻断说明仍可滚动,业务未满足时不能继续。
func testNonzeroAssetsAndBusinessBlockers() async throws {
let service = CombinedDeregistrationService()
service.walletFen = 129900
service.points = 1280
service.extraBlockers = [["code": "ORDER_UNFULFILLED", "message": "账户仍有未履约订单或带单,请处理完成后再申请注销。", "action": "complete_orders"]]
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
let controller = StoreAccountDeregistrationViewController(identityName: "较长的门店身份名称用于验证自动换行展示", viewModel: model, api: service)
let window = makeWindow(controller, size: CGSize(width: 375, height: 667))
defer { close(window) }
await waitUntil { model.eligibility != nil }
window.layoutIfNeeded()
let next = try XCTUnwrap(find(controller.view, "deregister.continue") as? UIButton)
XCTAssertFalse(next.isEnabled)
let blockers = try XCTUnwrap(find(controller.view, "deregister.blockers") as? UILabel)
XCTAssertTrue(blockers.text?.contains("未履约订单") == true)
let texts = allViews(controller.view).compactMap { ($0 as? UILabel)?.text }.joined(separator: "\n")
XCTAssertTrue(texts.contains("¥1299.00"))
XCTAssertTrue(texts.contains("1280"))
XCTAssertTrue(window.bounds.contains(next.convert(next.bounds, to: window)))
capture(window, name: "redesign-business-blockers")
XCTAssertTrue(service.mutations.isEmpty)
}
/// 失败与未知状态给出重试入口,不展示调试字段或伪造注销完成。
func testUnknownAndFailedStatusPages() async throws {
for failed in [false, true] {
let service = CombinedDeregistrationService()
service.statusOverride = .init(deregister: .object(["status": .number(123)]))
service.readError = failed ? APIError.networkFailed("debug-internal-error") : nil
let suite = "RedesignStatus.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
let session = AppSessionStore(defaults: defaults)
session.accountType = .storeUser; session.userId = "101"; session.token = "mock-token"
session.accountDisplayName = "测试门店"
let controller = StoreAccountDeregistrationAccessViewController(identity: try .init(session: session), api: service, session: session) { _ in XCTFail("不能进入业务") }
let window = makeWindow(controller, size: CGSize(width: 375, height: 667))
defer { close(window); defaults.removePersistentDomain(forName: suite) }
let title = try XCTUnwrap(find(controller.view, "deregister.access.title") as? UILabel)
await waitUntil { title.text == (failed ? "暂时无法查询" : "注销状态待确认") }
let retry = try XCTUnwrap(find(controller.view, "deregister.access.retry") as? UIButton)
let cancel = try XCTUnwrap(find(controller.view, "deregister.access.cancel") as? UIButton)
XCTAssertTrue(retry.isEnabled)
XCTAssertFalse(retry.isHidden)
XCTAssertTrue(cancel.isHidden)
let texts = allViews(controller.view).compactMap { ($0 as? UILabel)?.text }.joined(separator: "\n")
XCTAssertFalse(texts.contains("debug-internal-error"))
XCTAssertFalse(texts.contains("本机提交记录"))
window.layoutIfNeeded()
capture(window, name: failed ? "redesign-query-failed" : "redesign-status-unknown")
}
}
private weak var previousKeyWindow: UIWindow?
private func makeWindow(_ controller: UIViewController, size: CGSize) -> UIWindow {
let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
previousKeyWindow = scene?.windows.first(where: \.isKeyWindow)
let window = scene.map { UIWindow(windowScene: $0) } ?? UIWindow(frame: CGRect(origin: .zero, size: size))
window.rootViewController = UINavigationController(rootViewController: controller)
window.makeKeyAndVisible()
window.frame = CGRect(origin: .zero, size: size)
window.layoutIfNeeded()
controller.loadViewIfNeeded()
return window
}
private func close(_ window: UIWindow) {
window.endEditing(true)
window.rootViewController?.dismiss(animated: false)
window.isHidden = true
window.rootViewController = nil
previousKeyWindow?.makeKeyAndVisible()
}
private func allViews(_ view: UIView) -> [UIView] { view.subviews.flatMap { [$0] + allViews($0) } }
private func find(_ view: UIView, _ id: String) -> UIView? { allViews(view).first { $0.accessibilityIdentifier == id } }
private func assertSheetHugsContent(_ sheet: StoreAccountDeregistrationConfirmationSheetViewController) throws {
sheet.view.layoutIfNeeded()
let confirm = try XCTUnwrap(find(sheet.view, "deregister.sheet.confirm"))
let buttonBottom = confirm.convert(confirm.bounds, to: sheet.view).maxY
let visualGap = sheet.view.bounds.height - sheet.view.safeAreaInsets.bottom - buttonBottom
XCTAssertGreaterThanOrEqual(visualGap, 10)
XCTAssertLessThanOrEqual(visualGap, 18, "弹层底部只应保留 12pt 操作间距")
}
private func waitUntil(_ condition: () -> Bool) async {
for _ in 0..<150 {
if condition() { return }
try? await Task.sleep(nanoseconds: 10_000_000)
}
XCTFail("等待页面状态超时")
}
private func capture(_ window: UIWindow, name: String) {
let attachment = XCTAttachment(image: UIGraphicsImageRenderer(bounds: window.bounds).image { _ in
window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
})
attachment.name = name
attachment.lifetime = .keepAlways
add(attachment)
}
}
/// 可控制部分成功与异步停顿的Mock;不会读写AppStore或调用真实接口。
@MainActor
private final class CombinedDeregistrationService: StoreAccountDeregistrationServing {
var userID = 101
var financeID = 201
var walletFen: Int64 = 0
var points: Int64 = 0
var walletWaived = false
var pointsWaived = false
var extraBlockers: [[String: String]] = []
var mutations: [String] = []
var pointsError: Error?
var readError: Error?
var onWallet: (() -> Void)?
var confirmWalletOnServer = true
var commitPointsBeforeError = false
var holdWallet = false
var walletContinuation: CheckedContinuation<Void, Never>?
var statusOverride: StoreAccountDeregistrationStatus?
var statusCount = 0
var eligibilityCount = 0
var applyError: Error?
var onApply: (() -> Void)?
var holdApply = false
var applyContinuation: CheckedContinuation<Void, Never>?
var holdNextStatus = false
var statusContinuation: CheckedContinuation<Void, Never>?
var lastReason: String?
func eligibility() async throws -> StoreAccountDeregistrationEligibility {
eligibilityCount += 1
if let readError { throw readError }
var blockers = extraBlockers
if !walletWaived { blockers.append(["code": "WALLET_WAIVER_MISSING", "message": "请确认现金余额", "action": "confirm_wallet_waiver"]) }
if !pointsWaived { blockers.append(["code": "POINTS_WAIVER_MISSING", "message": "请确认积分", "action": "confirm_points_waiver"]) }
return try StoreAccountDeregistrationFixtures.eligibility(overrides: [
"store_user_id": userID, "finance_identity_id": financeID,
"can_apply": blockers.isEmpty, "wallet_balance_fen": walletFen,
"wallet_balance": String(format: "%.2f", Double(walletFen) / 100),
"points_balance": points, "wallet_waived": walletWaived, "points_waived": pointsWaived,
"unfulfilled_count": 0, "fulfillment_in_progress_count": 0,
"risk_end_at": NSNull(), "eligible_at": NSNull(),
"blockers": blockers, "deregister": StoreAccountDeregistrationFixtures.draftRecord()
])
}
func status() async throws -> StoreAccountDeregistrationStatus {
statusCount += 1
if holdNextStatus {
holdNextStatus = false
await withCheckedContinuation { statusContinuation = $0 }
}
if let readError { throw readError }
return try statusOverride ?? StoreAccountDeregistrationFixtures.draftStatus()
}
func waiveWallet() async throws {
mutations.append("wallet")
if holdWallet { await withCheckedContinuation { walletContinuation = $0 } }
walletWaived = confirmWalletOnServer
onWallet?()
}
func waivePoints() async throws {
mutations.append("points")
if commitPointsBeforeError { pointsWaived = true }
if let pointsError { throw pointsError }
pointsWaived = true
}
func sendSMS() async throws { mutations.append("sms") }
func apply(smsCode: String, reason: String) async throws {
mutations.append("apply")
lastReason = reason
if holdApply { await withCheckedContinuation { applyContinuation = $0 } }
if let applyError { throw applyError }
onApply?()
}
func finishApply() {
let continuation = applyContinuation
applyContinuation = nil
continuation?.resume()
}
func finishStatus() {
let continuation = statusContinuation
statusContinuation = nil
continuation?.resume()
}
func cancel() async throws { mutations.append("cancel") }
}
@@ -0,0 +1,139 @@
import Foundation
@testable import suixinkan
/// 从已实测响应整理的脱敏测试夹具;覆盖变化场景时显式覆盖字段,不宣称为真实服务端返回。
enum StoreAccountDeregistrationFixtures {
/// 2026-08-28 15:29 实测 userinfo 的150015响应;无time字段,data.status为字符串。
static let observedCoolingRestrictionEnvelope = Data(#"""
{
"code": 150015,
"msg": "账号处于注销冷静期,请先撤销注销后再继续使用",
"data": {
"status": "cooling",
"cooling_until": "2026-09-04 15:27:24",
"remaining_seconds": 604681
}
}
"""#.utf8)
/// 同次150015请求后的真实status响应,仅将注销记录ID替换为测试值301。
static let observedCoolingStatusEnvelope = Data(#"""
{
"code": 100000,
"msg": "success",
"data": {
"deregister": {
"id": 301,
"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": 604681,
"cancel_time": null,
"blocked_code": "",
"blocked_reason": "",
"completed_at": null
}
},
"time": "2026-08-28 15:29:22"
}
"""#.utf8)
/// 真实样例的结构,身份 ID 替换为测试用的 101/201。
static func eligibilityData(overrides: [String: Any] = [:]) throws -> Data {
var data: [String: Any] = [
"can_apply": false, "store_user_id": 101, "finance_identity_id": 201,
"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": NSNull(),
]
data.merge(overrides) { _, new in new }
return try JSONSerialization.data(withJSONObject: data)
}
/// 解码已实测结构,供协议替身返回值使用。
static func eligibility(overrides: [String: Any] = [:]) throws -> StoreAccountDeregistrationEligibility {
try JSONDecoder().decode(StoreAccountDeregistrationEligibility.self, from: eligibilityData(overrides: overrides))
}
/// 构造所有条件满足的规则测试输入;不是通过真实资产放弃或申请获得的响应。
static func ready(overrides: [String: Any] = [:]) throws -> StoreAccountDeregistrationEligibility {
var fields: [String: Any] = ["can_apply": true, "wallet_waived": true, "points_waived": true,
"unfulfilled_count": 0, "blockers": []]
fields.merge(overrides) { _, new in new }
return try eligibility(overrides: fields)
}
/// 2026-08-28 零资产确认后的真实草稿结构,仅将记录 ID 替换为测试值 301。
static func draftRecord(overrides: [String: Any] = [:]) -> [String: Any] {
var record: [String: Any] = [
"id": 301, "status": 0, "status_label": "待确认", "reason": "",
"apply_time": NSNull(), "cooling_until": NSNull(), "remaining_seconds": 0,
"cancel_time": NSNull(), "blocked_code": "", "blocked_reason": "", "completed_at": NSNull(),
]
record.merge(overrides) { _, new in new }
return record
}
/// 解码实测草稿状态;覆盖字段时用于验证未知或矛盾记录不会放行。
static func draftStatus(overrides: [String: Any] = [:]) throws -> StoreAccountDeregistrationStatus {
try JSONDecoder().decode(StoreAccountDeregistrationStatus.self,
from: JSONSerialization.data(withJSONObject: ["deregister": draftRecord(overrides: overrides)]))
}
/// 两次真实确认之间及之后的条件结构,没有历史业务时风险时间为 null。
static func draftEligibility(pointsWaived: Bool) throws -> StoreAccountDeregistrationEligibility {
try eligibility(overrides: [
"can_apply": pointsWaived, "wallet_waived": true, "points_waived": pointsWaived,
"unfulfilled_count": 0, "risk_end_at": NSNull(), "eligible_at": NSNull(),
"blockers": pointsWaived ? [] : [
["code": "POINTS_WAIVER_MISSING", "message": "请先确认放弃积分", "action": "confirm_points_waiver"],
],
"deregister": draftRecord(),
])
}
/// 实测的冷静期和主动撤销记录,ID 脱敏;覆盖字段只用于防御性测试。
static func lifecycleRecord(cancelled: Bool = false, overrides: [String: Any] = [:]) -> [String: Any] {
var record = draftRecord(overrides: [
"status": cancelled ? 9 : 1, "status_label": cancelled ? "已撤销" : "冷静期中",
"reason": "API test; cancel immediately", "apply_time": "2026-08-28 14:23:40",
"cooling_until": "2026-09-04 14:23:40", "remaining_seconds": cancelled ? 0 : 604776,
"cancel_time": cancelled ? "2026-08-28 14:24:04" : NSNull(),
"blocked_code": cancelled ? "CANCELLED_BY_USER" : "",
"blocked_reason": cancelled ? "用户主动撤销注销" : "",
])
record.merge(overrides) { _, new in new }
return record
}
/// 解码冷静期/已撤销实测样例。
static func lifecycleStatus(cancelled: Bool = false, overrides: [String: Any] = [:]) throws -> StoreAccountDeregistrationStatus {
try JSONDecoder().decode(StoreAccountDeregistrationStatus.self,
from: JSONSerialization.data(withJSONObject: ["deregister": lifecycleRecord(cancelled: cancelled, overrides: overrides)]))
}
/// 冷静期及撤销后真实查询均返回两项确认失效,不能在冷静期重新确认资产。
static func lifecycleEligibility(cancelled: Bool = false) throws -> StoreAccountDeregistrationEligibility {
try eligibility(overrides: ["unfulfilled_count": 0, "risk_end_at": NSNull(), "eligible_at": NSNull(),
"blockers": [
["code": "WALLET_WAIVER_MISSING", "message": "请先确认放弃现金余额", "action": "confirm_wallet_waiver"],
["code": "POINTS_WAIVER_MISSING", "message": "请先确认放弃积分", "action": "confirm_points_waiver"],
], "deregister": lifecycleRecord(cancelled: cancelled)])
}
/// 统一成功 Envelope,仅用于 MockURLSession。
static func envelope(_ data: Data) throws -> Data {
try JSONSerialization.data(withJSONObject: ["code": 100000, "msg": "success",
"data": JSONSerialization.jsonObject(with: data)])
}
}
@@ -0,0 +1,466 @@
import UIKit
import XCTest
@testable import suixinkan
/// 用独立窗口和真实核验控制器验证前台、多端及过期响应;不访问设备真实会话。
@MainActor
final class StoreAccountDeregistrationRootTests: XCTestCase {
/// 重放真实userinfo限制及status响应,贯通业务API、错误通知、状态核验和根页面,不重新登录或撤销。
func testObservedBusinessRestrictionRoutesToCoolingWithoutLosingSession() async throws {
let network = MockURLSession(responses: [
StoreAccountDeregistrationFixtures.observedCoolingRestrictionEnvelope,
StoreAccountDeregistrationFixtures.observedCoolingStatusEnvelope,
])
let center = NotificationCenter()
let client = APIClient(environment: .testing, session: network, notificationCenter: center)
let context = DeregistrationRootContext(networkClient: client)
defer { context.cleanUp() }
client.bindAuthTokenProvider { [weak context] in context?.session.token }
let originalToken = context.session.token
let originalUserID = context.session.userId
let expired = XCTNSNotificationExpectation(name: NotificationName.sessionDidExpire,
object: nil, notificationCenter: center)
expired.isInverted = true
let observer = center.addObserver(forName: NotificationName.storeAccountDeregistrationRestricted,
object: nil, queue: .main) { [weak coordinator = context.coordinator] notification in
let token = notification.userInfo?[NotificationUserInfoKey.deregistrationRequestToken] as? String
Task { @MainActor [weak coordinator] in coordinator?.recordRestriction(requestToken: token) }
}
defer { center.removeObserver(observer) }
context.startRestoringSession()
let businessRoot = context.window.rootViewController
do {
_ = try await ProfileAPI(client: client).userInfo()
XCTFail("冷静期不能取得普通业务数据")
} catch {
guard case APIError.serverCode(150015, let message) = error else {
return XCTFail("真实错误data结构不能被误判为UserInfo解码失败:\(error)")
}
XCTAssertEqual(message, "账号处于注销冷静期,请先撤销注销后再继续使用")
XCTAssertFalse(APIError.isAuthenticationExpired(error))
}
await waitUntil { context.message.contains("2026-09-04 15:27:24") }
XCTAssertNotNil(context.coordinator.accessController)
XCTAssertFalse(context.window.rootViewController === businessRoot)
XCTAssertTrue(context.message.contains("注销申请已提交"))
XCTAssertFalse(context.message.contains("604681"))
XCTAssertTrue(context.bindingSuspended)
XCTAssertEqual(context.resumeCount, 1)
XCTAssertEqual(context.createdRoots, 1)
XCTAssertEqual(context.session.token, originalToken)
XCTAssertEqual(context.session.userId, originalUserID)
XCTAssertTrue(context.session.isLoggedIn)
XCTAssertEqual(network.requests.map { $0.url?.path }, [
"/api/yf-handset-app/userinfo",
"/api/yf-handset-app/account-deregister/status",
])
XCTAssertEqual(network.requests.map(\.httpMethod), ["GET", "GET"])
XCTAssertTrue(network.requests.allSatisfy { $0.value(forHTTPHeaderField: "token") == originalToken })
XCTAssertTrue(context.service.mutations.isEmpty)
await fulfillment(of: [expired], timeout: 0.1)
}
/// 已登录冷启动和普通前台恢复均不查注销状态,直接保留正常导航与推送。
func testStartupAndForegroundDoNotQueryAndPreserveNavigation() throws {
let context = DeregistrationRootContext()
defer { context.cleanUp() }
context.service.readError = APIError.networkFailed("注销查询离线不应影响普通启动")
context.startRestoringSession()
let original = try XCTUnwrap(context.window.rootViewController as? UINavigationController)
let details = UIViewController()
original.pushViewController(details, animated: false)
context.coordinator.resumeFromBackground()
context.coordinator.resumeFromBackground()
XCTAssertNil(context.coordinator.accessController)
XCTAssertTrue(context.window.rootViewController === original)
XCTAssertTrue(original.topViewController === details)
XCTAssertEqual(context.createdRoots, 1)
XCTAssertFalse(context.bindingSuspended)
XCTAssertEqual(context.service.statusCount, 0)
XCTAssertEqual(context.createdAPIs, 0)
XCTAssertEqual(context.resumeCount, 1)
}
/// 新登录只以 v9 的门店 status 判定;旧注销状态接口和本地未决记录都不能再拦截 status 为正常的登录。
func testNormalLoginRestoresBusinessWithoutLegacyDeregistrationCheck() async {
let context = DeregistrationRootContext()
defer { context.cleanUp() }
context.service.readError = APIError.networkFailed("旧注销查询不应影响 v9 正常登录")
context.startRestoringSession()
XCTAssertNil(context.coordinator.accessController)
XCTAssertFalse(context.coordinator.isChecking)
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
XCTAssertFalse(context.bindingSuspended)
XCTAssertEqual(context.createdRoots, 1)
XCTAssertEqual(context.resumeCount, 1)
XCTAssertEqual(context.service.statusCount, 0)
}
/// 旧核验入口仅在业务限制等已知受限事件发生时使用,不是正常登录路径的一部分。
func testExplicitRestrictionCheckStillKeepsOriginalPageUntilResult() async {
let context = DeregistrationRootContext()
defer { context.cleanUp() }
context.service.holdNextRead = true
await context.startChecking()
let loginRoot = context.window.rootViewController
await waitUntil { context.service.pendingRead != nil }
XCTAssertNil(context.coordinator.accessController)
XCTAssertTrue(context.window.rootViewController === loginRoot)
XCTAssertTrue(context.coordinator.isChecking)
XCTAssertTrue(GlobalLoadingManager.shared.isShowing)
XCTAssertFalse(context.message.contains("正在查询注销状态"))
XCTAssertTrue(context.bindingSuspended)
XCTAssertEqual(context.createdRoots, 0)
context.service.finishRead()
await waitUntil { context.resumeCount == 1 }
context.coordinator.resumeFromBackground()
XCTAssertNil(context.coordinator.accessController)
XCTAssertFalse(context.bindingSuspended)
XCTAssertEqual(context.service.statusCount, 1)
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
}
/// 登录查询失败后才显示错误结果,不重复查询;下拉刷新恢复后结束全局加载。
func testLoginFailureAndPullToRefreshUseGlobalLoading() async throws {
let context = DeregistrationRootContext()
defer { context.cleanUp() }
context.service.readError = APIError.networkFailed("offline")
await context.startChecking()
await waitUntil { context.retryEnabled }
let gate = try XCTUnwrap(context.coordinator.accessController)
XCTAssertTrue(context.message.contains("暂时无法查询"))
XCTAssertNil(gate.navigationItem.rightBarButtonItem)
XCTAssertEqual(context.service.statusCount, 1)
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
let scroll = try XCTUnwrap(gate.view.subviews.first { $0 is UIScrollView } as? UIScrollView)
let refresh = try XCTUnwrap(scroll.refreshControl)
context.service.readError = nil
context.service.holdNextRead = true
refresh.beginRefreshing()
refresh.sendActions(for: .valueChanged)
await waitUntil { context.service.pendingRead != nil }
XCTAssertTrue(GlobalLoadingManager.shared.isShowing)
refresh.sendActions(for: .valueChanged)
XCTAssertEqual(context.service.statusCount, 2)
context.service.finishRead()
await waitUntil { context.resumeCount == 1 }
XCTAssertFalse(refresh.isRefreshing)
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
XCTAssertTrue(context.service.mutations.isEmpty)
}
/// 离开登录核验立即释放其加载,旧响应不能关闭其他请求的加载或恢复首页。
func testCancelledLoginCheckDoesNotHideNewLoadingOrReplaceRoot() async throws {
let context = DeregistrationRootContext()
defer { context.cleanUp() }
context.service.holdNextRead = true
await context.startChecking()
await waitUntil { context.service.pendingRead != nil }
context.coordinator.check()
XCTAssertEqual(context.service.statusCount, 1, "同一登录核验不能重复发起")
context.coordinator.cancelPendingCheck()
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
let newRoot = UIViewController()
context.window.rootViewController = newRoot
GlobalLoadingManager.shared.show()
defer { GlobalLoadingManager.shared.hide() }
context.service.finishRead()
try await Task.sleep(nanoseconds: 30_000_000)
XCTAssertTrue(GlobalLoadingManager.shared.isShowing)
XCTAssertTrue(context.window.rootViewController === newRoot)
XCTAssertEqual(context.resumeCount, 0)
}
/// 业务限制才触发冷静期查询;受限页前台刷新可确认其他设备撤销,不发送修改请求。
func testRemoteApplicationAndCancellation() async throws {
let context = DeregistrationRootContext()
defer { context.cleanUp() }
await context.startChecking()
await waitUntil { context.resumeCount == 1 }
let original = context.window.rootViewController
context.service.current = try StoreAccountDeregistrationFixtures.lifecycleStatus()
context.coordinator.recordRestriction(requestToken: context.session.token)
await waitUntil { context.message.contains("注销申请已提交") }
XCTAssertTrue(context.bindingSuspended)
XCTAssertEqual(context.resumeCount, 1)
let gate = context.coordinator.accessController
context.service.current = try StoreAccountDeregistrationFixtures.lifecycleStatus(cancelled: true)
context.coordinator.resumeFromBackground()
XCTAssertTrue(context.coordinator.accessController === gate)
await waitUntil { context.resumeCount == 2 }
XCTAssertFalse(context.window.rootViewController === original)
XCTAssertEqual(context.createdRoots, 2)
XCTAssertFalse(context.bindingSuspended)
XCTAssertTrue(context.service.mutations.isEmpty)
}
/// 业务限制后离线保持核验页,受限页返回前台成功重查后才恢复。
func testRestrictionQueryFailureRemainsRestricted() async {
let context = DeregistrationRootContext()
defer { context.cleanUp() }
await context.startChecking()
await waitUntil { context.resumeCount == 1 }
context.service.readError = APIError.networkFailed("offline")
context.coordinator.recordRestriction(requestToken: context.session.token)
await waitUntil { context.message.contains("暂时无法查询") }
XCTAssertNotNil(context.coordinator.accessController)
XCTAssertTrue(context.bindingSuspended)
XCTAssertEqual(context.resumeCount, 1)
context.service.readError = nil
context.coordinator.resumeFromBackground()
await waitUntil { context.resumeCount == 2 }
XCTAssertNil(context.coordinator.accessController)
}
/// 新150015优先于旧正常响应,其他身份Token的通知不应影响当前页面。
func testRestrictionOverridesOldResponseAndIgnoresOtherToken() async {
let context = DeregistrationRootContext()
defer { context.cleanUp() }
await context.startChecking()
await waitUntil { context.resumeCount == 1 }
let original = context.window.rootViewController
context.coordinator.recordRestriction(requestToken: "other-token")
XCTAssertTrue(context.window.rootViewController === original)
context.service.holdNextRead = true
context.coordinator.check()
await waitUntil { context.service.pendingRead != nil }
context.coordinator.recordRestriction(requestToken: context.session.token)
context.service.finishRead()
await waitUntil { context.retryEnabled }
XCTAssertNotNil(context.coordinator.accessController)
XCTAssertTrue(context.bindingSuspended)
XCTAssertEqual(context.resumeCount, 1)
}
/// 在途查询回到前台后作废,完成后只再查一次最新状态,不重发修改操作。
func testForegroundDiscardsBackgroundResponse() async throws {
let context = DeregistrationRootContext()
defer { context.cleanUp() }
context.service.holdNextRead = true
await context.startChecking()
await waitUntil { context.service.pendingRead != nil }
let loginRoot = context.window.rootViewController
context.coordinator.resumeFromBackground()
XCTAssertTrue(context.window.rootViewController === loginRoot)
context.service.current = try StoreAccountDeregistrationFixtures.lifecycleStatus()
context.service.finishRead()
await waitUntil { context.message.contains("注销申请已提交") }
XCTAssertNotNil(context.coordinator.accessController)
XCTAssertEqual(context.service.statusCount, 2)
XCTAssertEqual(context.resumeCount, 0)
XCTAssertTrue(context.service.mutations.isEmpty)
}
/// 窗口根或会话被替换后,旧查询完成不能恢复旧账号。
func testLateQueryDoesNotReplaceNewSessionOrRoot() async throws {
for changeSession in [true, false] {
let context = DeregistrationRootContext()
defer { context.cleanUp() }
context.service.holdNextRead = true
await context.startChecking()
await waitUntil { context.service.pendingRead != nil }
if changeSession { context.session.token = "new-token"; context.session.userId = "102" }
let replacement = UIViewController()
context.window.rootViewController = replacement
context.service.finishRead()
try await Task.sleep(nanoseconds: 30_000_000)
XCTAssertTrue(context.window.rootViewController === replacement)
XCTAssertEqual(context.resumeCount, 0)
}
}
/// 切换身份恢复新首页,不沿用前一身份的导航栈,也不额外查询注销状态。
func testSwitchIdentityRestoresNewRootWithoutQuery() {
let context = DeregistrationRootContext()
defer { context.cleanUp() }
context.startRestoringSession()
let original = context.window.rootViewController
context.session.userId = "102"
context.session.token = "new-token"
context.coordinator.restoreSession()
XCTAssertFalse(context.window.rootViewController === original)
XCTAssertEqual(context.createdRoots, 2)
XCTAssertEqual(context.resumeCount, 2)
XCTAssertEqual(context.service.statusCount, 0)
}
/// 景区和未登录会话不调用门店注销接口。
func testNonStoreAndLoggedOutForegroundDoesNotQuery() {
let context = DeregistrationRootContext()
defer { context.cleanUp() }
context.session.accountType = .scenicUser
context.coordinator.resumeFromBackground()
context.session.accountType = .storeUser
context.session.token = ""
context.coordinator.resumeFromBackground()
context.coordinator.restoreSession()
XCTAssertEqual(context.service.statusCount, 0)
XCTAssertEqual(context.createdRoots, 0)
XCTAssertFalse(context.bindingSuspended)
}
/// 点击撤销先显示针对当前身份的确认;后台恢复撤掉旧弹窗,不发送撤销请求。
func testCancellationRequiresConfirmationAndForegroundDismissesStaleAlert() async throws {
let context = DeregistrationRootContext()
defer { context.cleanUp() }
context.service.current = try StoreAccountDeregistrationFixtures.lifecycleStatus()
await context.startChecking()
await waitUntil { context.message.contains("注销申请已提交") }
let gate = try XCTUnwrap(context.coordinator.accessController)
let cancel = try XCTUnwrap(context.button("deregister.access.cancel"))
cancel.sendActions(for: .touchUpInside)
await waitUntil { gate.presentedViewController is UIAlertController }
let alert = try XCTUnwrap(gate.presentedViewController as? UIAlertController)
XCTAssertEqual(alert.title, "撤销当前身份的注销申请?")
XCTAssertTrue(alert.message?.contains(gate.identity?.displayName ?? "missing") == true)
XCTAssertEqual(alert.actions.map(\.title), ["保留注销申请", "确认撤销"])
XCTAssertTrue(context.service.mutations.isEmpty)
// 等原生呈现动画结束,再模拟一次后台返回。
try await Task.sleep(nanoseconds: 400_000_000)
context.coordinator.resumeFromBackground()
await waitUntil { gate.presentedViewController == nil && context.retryEnabled }
XCTAssertTrue(context.service.mutations.isEmpty)
XCTAssertEqual(context.service.statusCount, 2)
}
private func waitUntil(_ condition: () -> Bool, file: StaticString = #filePath, line: UInt = #line) async {
for _ in 0..<150 {
if condition() { return }
try? await Task.sleep(nanoseconds: 10_000_000)
}
XCTFail("等待异步核验超时", file: file, line: line)
}
}
/// 每例使用独立会话、提交意图和窗口;业务恢复动作以计数替代推送及首页网络请求。
@MainActor
private final class DeregistrationRootContext {
let name = "DeregistrationRoot.\(UUID().uuidString)"
let defaults: UserDefaults
let session: AppSessionStore
let window: UIWindow
private weak var previousKeyWindow: UIWindow?
let service = DeregistrationRootService()
let networkClient: APIClient?
var createdRoots = 0
var createdAPIs = 0
var resumeCount = 0
var bindingSuspended = false
lazy var coordinator = StoreAccountDeregistrationRootCoordinator(
window: window, session: session, makeAPI: { [unowned self] identity in
self.createdAPIs += 1
if let client = self.networkClient {
return StoreAccountDeregistrationAPI(client: client, identity: identity) { [weak self] in
guard let self else { return false }
return identity.matches(session: self.session)
}
}
return self.service
},
makeSubmissionStore: { [unowned self] in
StoreAccountDeregistrationSubmissionStore(storeUserID: $0, environment: .testing, defaults: self.defaults)
},
makeBusinessRoot: { [unowned self] in
self.createdRoots += 1
return UINavigationController(rootViewController: UIViewController())
},
setBindingSuspended: { [unowned self] in self.bindingSuspended = $0 },
onBusinessResumed: { [unowned self] in self.resumeCount += 1 }
)
init(networkClient: APIClient? = nil) {
self.networkClient = networkClient
defaults = UserDefaults(suiteName: name)!
session = AppSessionStore(defaults: defaults)
session.accountType = .storeUser
session.userId = "101"
session.token = "isolated-store-token"
if let scene = UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }).first {
previousKeyWindow = scene.windows.first(where: \.isKeyWindow)
window = UIWindow(windowScene: scene)
} else {
window = UIWindow(frame: UIScreen.main.bounds)
}
}
func startChecking() async {
// 模拟已有登录页,核验期间不替换它,只在此窗口显示全局加载。
if window.rootViewController == nil {
window.rootViewController = UINavigationController(rootViewController: UIViewController())
}
window.makeKeyAndVisible()
window.layoutIfNeeded()
// 模拟用户在已显示的登录页点击登录,先等待前一个用例的键盘/窗口动画结束。
try? await Task.sleep(nanoseconds: 500_000_000)
coordinator.check()
}
func startRestoringSession() {
coordinator.restoreSession()
window.makeKeyAndVisible()
window.layoutIfNeeded()
}
var message: String {
allSubviews(window).compactMap { view -> String? in
guard ["deregister.access.message", "deregister.access.title", "deregister.access.deadline"]
.contains(view.accessibilityIdentifier ?? "") else { return nil }
return (view as? UILabel)?.text
}.joined(separator: "\n")
}
var retryEnabled: Bool {
button("deregister.access.retry")?.isEnabled == true
}
func button(_ identifier: String) -> UIButton? {
allSubviews(window).first { $0.accessibilityIdentifier == identifier } as? UIButton
}
func cleanUp() {
coordinator.cancelPendingCheck()
service.finishRead()
window.isHidden = true
window.rootViewController = nil
previousKeyWindow?.makeKeyAndVisible()
defaults.removePersistentDomain(forName: name)
}
private func allSubviews(_ view: UIView) -> [UIView] { view.subviews.flatMap { [$0] + allSubviews($0) } }
}
/// 能暂停单次 GET 并返回其旧快照,便于验证后台前在途响应不能放行。
@MainActor
private final class DeregistrationRootService: StoreAccountDeregistrationServing {
var current = StoreAccountDeregistrationStatus(deregister: .null)
var readError: Error?
var holdNextRead = false
var pendingRead: CheckedContinuation<StoreAccountDeregistrationStatus, Error>?
var pendingSnapshot: StoreAccountDeregistrationStatus?
var statusCount = 0
var mutations: [String] = []
func status() async throws -> StoreAccountDeregistrationStatus {
statusCount += 1
if holdNextRead {
holdNextRead = false
pendingSnapshot = current
return try await withCheckedThrowingContinuation { pendingRead = $0 }
}
if let readError { throw readError }
return current
}
func finishRead() {
let continuation = pendingRead
pendingRead = nil
if let pendingSnapshot { continuation?.resume(returning: pendingSnapshot) }
pendingSnapshot = nil
}
func eligibility() async throws -> StoreAccountDeregistrationEligibility {
try StoreAccountDeregistrationFixtures.lifecycleEligibility()
}
func cancel() async throws { mutations.append("cancel") }
func waiveWallet() async throws { mutations.append("wallet") }
func waivePoints() async throws { mutations.append("points") }
func sendSMS() async throws { mutations.append("sms") }
func apply(smsCode: String, reason: String) async throws { mutations.append("apply") }
}
@@ -0,0 +1,660 @@
import UIKit
import XCTest
@testable import suixinkan
/// 已确认的旧接口条件与交互规则测试;不通过真实网络修改账号或资产。
@MainActor
final class StoreAccountDeregistrationViewModelTests: XCTestCase {
/// 冷静期虽然返回缺少资产确认的阻断项,也不能重新确认资产或提交。
func testCoolingPreventsWaiversAndDuplicateApplication() async throws {
let service = try DeregistrationServiceStub()
service.current = try StoreAccountDeregistrationFixtures.lifecycleEligibility()
service.currentStatus = try StoreAccountDeregistrationFixtures.lifecycleStatus()
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
await model.refresh(api: service)
XCTAssertEqual(model.step, .unresolvedRequest)
XCTAssertFalse(model.canConfirm(.wallet))
XCTAssertFalse(model.canConfirm(.points))
XCTAssertFalse(model.canContinue)
await model.confirm(.wallet, snapshot: service.current, api: service)
model.beginVerification()
await model.submit(smsCode: "907182", reason: "测试", api: service)
XCTAssertTrue(service.mutations.isEmpty)
}
/// 新撤销记录解除本机提交保护,但两种资产仍须分别重新确认。
func testCancelledApplicationRestoresPreparationWithFreshWaivers() async throws {
let name = "DeregistrationCancelledFlow.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: name)!
defer { defaults.removePersistentDomain(forName: name) }
let store = StoreAccountDeregistrationSubmissionStore(storeUserID: "101", environment: .testing, defaults: defaults)
try store.recordSubmissionIntent()
let service = try DeregistrationServiceStub()
service.current = try StoreAccountDeregistrationFixtures.lifecycleEligibility(cancelled: true)
service.currentStatus = try StoreAccountDeregistrationFixtures.lifecycleStatus(cancelled: true)
let model = StoreAccountDeregistrationViewModel(storeUserID: 101, submissionStore: store)
await model.refresh(api: service)
XCTAssertFalse(model.submissionAttempted)
XCTAssertFalse(store.hasUnresolvedSubmission)
XCTAssertEqual(model.step, .conditions)
XCTAssertTrue(model.canConfirm(.wallet))
XCTAssertTrue(model.canConfirm(.points))
XCTAssertFalse(model.canContinue)
XCTAssertTrue(service.mutations.isEmpty)
}
/// 重新申请响应丢失时,旧撤销记录不能让当前或重建页面重复申请。
func testReapplicationDoesNotReconcileAgainstPreviousCancelledStatus() async throws {
let name = "DeregistrationReapplication.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: name)!
defer { defaults.removePersistentDomain(forName: name) }
let store = StoreAccountDeregistrationSubmissionStore(storeUserID: "101", environment: .testing, defaults: defaults)
let service = try DeregistrationServiceStub()
service.currentStatus = try StoreAccountDeregistrationFixtures.lifecycleStatus(cancelled: true)
service.current = try StoreAccountDeregistrationFixtures.ready(overrides: [
"deregister": StoreAccountDeregistrationFixtures.lifecycleRecord(cancelled: true)])
service.applyError = APIError.networkFailed("lost response")
let model = StoreAccountDeregistrationViewModel(storeUserID: 101, submissionStore: store)
await model.refresh(api: service)
model.beginVerification()
await model.submit(smsCode: "907182", reason: "测试", api: service)
await model.refresh(api: service)
XCTAssertTrue(model.submissionAttempted)
let restored = StoreAccountDeregistrationViewModel(storeUserID: 101, submissionStore: store)
await restored.refresh(api: service)
restored.beginVerification()
await restored.submit(smsCode: "907182", reason: "测试", api: service)
XCTAssertTrue(restored.submissionAttempted)
XCTAssertTrue(store.hasUnresolvedSubmission)
XCTAssertEqual(service.mutations, ["apply"])
}
/// 零余额真实样例仍有两项确认阻断,不允许继续。
func testObservedEligibilityRequiresSeparateZeroAssetWaivers() async throws {
let service = try DeregistrationServiceStub()
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
await model.refresh(api: service)
XCTAssertEqual(model.eligibility?.walletBalanceFen, 0)
XCTAssertEqual(model.eligibility?.pointsBalance, 0)
XCTAssertEqual(model.eligibility?.blockers.count, 4)
XCTAssertTrue(model.canConfirm(.wallet))
XCTAssertTrue(model.canConfirm(.points))
XCTAssertFalse(model.canContinue)
XCTAssertEqual(service.mutations, [])
}
/// 确认两种资产必须分别调用接口,且 UI 状态只跟随重新查询结果。
func testWalletAndPointsConfirmationsRemainIndependent() async throws {
let service = try DeregistrationServiceStub()
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
await model.refresh(api: service)
let snapshot = try XCTUnwrap(model.eligibility)
service.afterWaiver = try StoreAccountDeregistrationFixtures.eligibility(overrides: ["wallet_waived": true])
await model.confirm(.wallet, snapshot: snapshot, api: service)
XCTAssertEqual(service.mutations, ["wallet"])
XCTAssertFalse(model.canConfirm(.wallet))
XCTAssertTrue(model.canConfirm(.points))
service.afterWaiver = try StoreAccountDeregistrationFixtures.ready()
await model.confirm(.points, snapshot: try XCTUnwrap(model.eligibility), api: service)
XCTAssertEqual(service.mutations, ["wallet", "points"])
XCTAssertTrue(model.canContinue)
}
/// 真实后端第一次确认即创建草稿;第二次确认必须仍可执行,之后才允许验证码步骤。
func testObservedDraftCreatedByFirstWaiverCanContinueSecondWaiver() async throws {
let service = try DeregistrationServiceStub()
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
await model.refresh(api: service)
service.afterWaiver = try StoreAccountDeregistrationFixtures.draftEligibility(pointsWaived: false)
service.statusAfterWaiver = try StoreAccountDeregistrationFixtures.draftStatus()
await model.confirm(.wallet, snapshot: try XCTUnwrap(model.eligibility), api: service)
XCTAssertEqual(model.step, .conditions)
XCTAssertTrue(model.status?.isUnsubmittedDraft == true)
XCTAssertFalse(model.canConfirm(.wallet))
XCTAssertTrue(model.canConfirm(.points))
XCTAssertFalse(model.canContinue)
service.afterWaiver = try StoreAccountDeregistrationFixtures.draftEligibility(pointsWaived: true)
await model.confirm(.points, snapshot: try XCTUnwrap(model.eligibility), api: service)
XCTAssertEqual(service.mutations, ["wallet", "points"])
XCTAssertTrue(model.canContinue)
model.beginVerification()
XCTAssertEqual(model.step, .verification)
XCTAssertFalse(model.submissionAttempted)
}
/// 重建页面可继续已确认的草稿,短信和申请动作仅发送给内存 Mock。
func testReopenedDraftCanVerifyAndSubmitWithoutRepeatingWaivers() async throws {
let service = try DeregistrationServiceStub()
service.current = try StoreAccountDeregistrationFixtures.draftEligibility(pointsWaived: true)
service.currentStatus = try StoreAccountDeregistrationFixtures.draftStatus()
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
await model.refresh(api: service)
XCTAssertTrue(model.canContinue)
XCTAssertFalse(model.canConfirm(.wallet))
XCTAssertFalse(model.canConfirm(.points))
model.beginVerification()
let sent = await model.sendSMS(api: service)
XCTAssertTrue(sent)
await model.submit(smsCode: "907182", reason: "测试", api: service)
XCTAssertEqual(service.mutations, ["sms", "apply"])
XCTAssertTrue(model.submissionAccepted)
XCTAssertEqual(model.step, .unresolvedRequest)
XCTAssertFalse(model.canContinue)
}
/// 状态查询与条件查询任一出现未知记录,都不能以另一份草稿结果绕过限制。
func testDraftDoesNotOverrideUnknownRecordFromOtherQuery() async throws {
for unknownStatus in [false, true] {
let service = try DeregistrationServiceStub()
let draft = try StoreAccountDeregistrationFixtures.draftStatus()
let unknown = StoreAccountDeregistrationStatus(deregister: .object(["status": .number(987)]))
service.currentStatus = unknownStatus ? unknown : draft
service.current = unknownStatus ? try StoreAccountDeregistrationFixtures.draftEligibility(pointsWaived: true)
: try StoreAccountDeregistrationFixtures.ready(overrides: ["deregister": ["status": 987]])
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
await model.refresh(api: service)
XCTAssertFalse(model.canContinue)
XCTAssertFalse(model.canConfirm(.points))
XCTAssertEqual(model.step, .unresolvedRequest)
XCTAssertTrue(service.mutations.isEmpty)
}
}
/// 弹窗显示后余额变化时不能提交旧快照对应的放弃确认。
func testBalanceChangeDuringConfirmationPreventsWaiver() async throws {
let service = try DeregistrationServiceStub()
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
await model.refresh(api: service)
let snapshot = try XCTUnwrap(model.eligibility)
service.current = try StoreAccountDeregistrationFixtures.eligibility(overrides: ["points_balance": 1])
await model.confirm(.wallet, snapshot: snapshot, api: service)
XCTAssertTrue(service.mutations.isEmpty)
XCTAssertTrue(model.errorMessage?.contains("余额已变化") == true)
XCTAssertNil(model.eligibility)
}
/// 服务端确认请求成功但查询仍为 false 时不能乐观勾选。
func testWaiverDoesNotOptimisticallyUpdateConfirmation() async throws {
let service = try DeregistrationServiceStub()
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
await model.refresh(api: service)
await model.confirm(.wallet, snapshot: try XCTUnwrap(model.eligibility), api: service)
XCTAssertEqual(model.eligibility?.walletWaived, false)
XCTAssertFalse(model.canContinue)
}
/// 查询返回另一门店用户时不可显示其资产或继续操作。
func testWrongIdentityResponseIsDiscarded() async throws {
let service = try DeregistrationServiceStub()
service.current = try StoreAccountDeregistrationFixtures.ready(overrides: ["store_user_id": 102])
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
await model.refresh(api: service)
XCTAssertNil(model.eligibility)
XCTAssertFalse(model.canContinue)
XCTAssertNotNil(model.errorMessage)
}
/// 即使历史快照可提交,任何一次刷新失败后都必须重新核验。
func testRefreshFailureInvalidatesPreviouslyReadySnapshot() async throws {
let service = try DeregistrationServiceStub()
service.current = try StoreAccountDeregistrationFixtures.ready()
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
await model.refresh(api: service)
XCTAssertTrue(model.canContinue)
service.readError = APIError.networkFailed("offline")
await model.refresh(api: service)
XCTAssertFalse(model.canContinue)
XCTAssertNil(model.eligibility)
}
/// 任意未知记录都保持状态待核实;不推断完成、恢复或允许重新申请。
func testUnknownRequestDisablesAllNewApplicationActions() async throws {
let service = try DeregistrationServiceStub()
service.current = try StoreAccountDeregistrationFixtures.ready()
service.currentStatus = StoreAccountDeregistrationStatus(deregister: .object(["unknown_status": .number(17)]))
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
await model.refresh(api: service)
XCTAssertEqual(model.step, .unresolvedRequest)
XCTAssertFalse(model.canContinue)
XCTAssertFalse(model.canConfirm(.wallet))
let sent = await model.sendSMS(api: service)
XCTAssertFalse(sent)
await model.submit(smsCode: "654321", reason: "测试", api: service)
XCTAssertTrue(service.mutations.isEmpty)
}
/// 发送短信前重新检查全部条件,不能使用进入验证码页时的旧快照。
func testSMSRechecksEligibilityBeforeSending() async throws {
let service = try DeregistrationServiceStub()
service.current = try StoreAccountDeregistrationFixtures.ready()
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
await model.refresh(api: service)
model.beginVerification()
service.current = try StoreAccountDeregistrationFixtures.eligibility()
let sent = await model.sendSMS(api: service)
XCTAssertFalse(sent)
XCTAssertTrue(service.mutations.isEmpty)
XCTAssertEqual(model.step, .conditions)
}
/// 提交前仍须重新核验,不因先前已发送短信而绕过余额变化。
func testSubmitRechecksWaiversBeforeSending() async throws {
let service = try DeregistrationServiceStub()
service.current = try StoreAccountDeregistrationFixtures.ready()
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
await model.refresh(api: service)
model.beginVerification()
service.current = try StoreAccountDeregistrationFixtures.ready(overrides: ["points_waived": false])
await model.submit(smsCode: "654321", reason: "不再使用", api: service)
XCTAssertTrue(service.mutations.isEmpty)
XCTAssertFalse(model.submissionAttempted)
}
/// 实际输入交给后端校验,不使用固定验证码;成功仅记录申请已提交。
func testSubmitPassesUserInputAndPreventsRepeatSubmission() async throws {
let service = try DeregistrationServiceStub()
service.current = try StoreAccountDeregistrationFixtures.ready()
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
await model.refresh(api: service)
model.beginVerification()
await model.submit(smsCode: " 907182 ", reason: " 不再使用 ", api: service)
await model.submit(smsCode: "907182", reason: "不再使用", api: service)
XCTAssertEqual(service.mutations, ["apply"])
XCTAssertEqual(service.lastCode, "907182")
XCTAssertEqual(service.lastReason, "不再使用")
XCTAssertTrue(model.submissionAccepted)
XCTAssertEqual(model.step, .unresolvedRequest)
XCTAssertFalse(model.canContinue)
}
/// 验证码与原因在任何查询和提交意图之前校验;空白及超长原因不能触发网络请求。
func testSubmitRejectsMissingOrOversizedRequiredInputWithoutSideEffects() async throws {
let cases = [
(code: "", reason: "正常原因", message: "请输入收到的短信验证码"),
(code: "654321", reason: "", message: "请输入注销原因"),
(code: "654321", reason: " \n", message: "请输入注销原因"),
(code: "654321", reason: String(repeating: "原", count: 51), message: "注销原因不能超过 50 个字")
]
for item in cases {
let service = try DeregistrationServiceStub()
service.current = try StoreAccountDeregistrationFixtures.ready()
let suite = "DeregistrationRequiredReason.\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let store = StoreAccountDeregistrationSubmissionStore(
storeUserID: "101", environment: .testing, defaults: defaults
)
let model = StoreAccountDeregistrationViewModel(storeUserID: 101, submissionStore: store)
await model.refresh(api: service)
model.beginVerification()
let statusCount = service.statusCount
let eligibilityCount = service.eligibilityCount
await model.submit(smsCode: item.code, reason: item.reason, api: service)
XCTAssertEqual(model.errorMessage, item.message)
XCTAssertTrue(service.mutations.isEmpty)
XCTAssertEqual(service.statusCount, statusCount)
XCTAssertEqual(service.eligibilityCount, eligibilityCount)
XCTAssertFalse(model.submissionAttempted)
XCTAssertFalse(store.hasUnresolvedSubmission)
}
}
/// 申请响应丢失可能已被服务端受理,不能直接重试或显示失败后可重新申请。
func testLostApplyResponsePreventsAutomaticRetry() async throws {
let service = try DeregistrationServiceStub()
service.current = try StoreAccountDeregistrationFixtures.ready()
service.applyError = APIError.networkFailed("response lost")
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
await model.refresh(api: service)
model.beginVerification()
await model.submit(smsCode: "907182", reason: "不再使用", api: service)
await model.refresh(api: service)
model.beginVerification()
await model.submit(smsCode: "907182", reason: "不再使用", api: service)
XCTAssertEqual(service.mutations, ["apply"])
XCTAssertTrue(model.submissionAttempted)
XCTAssertFalse(model.submissionAccepted)
XCTAssertEqual(model.step, .unresolvedRequest)
}
/// 服务端明确拒绝验证码时保留已核验条件并停留在手机验证,不要求重新确认资产。
func testVerificationCodeRejectionStaysInVerification() async throws {
let service = try DeregistrationServiceStub()
service.current = try StoreAccountDeregistrationFixtures.ready()
service.applyError = APIError.serverCode(100001, "验证码错误")
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
await model.refresh(api: service)
model.beginVerification()
await model.submit(smsCode: "907182", reason: "不再使用", api: service)
XCTAssertFalse(model.submissionAttempted)
XCTAssertTrue(model.canContinue)
XCTAssertEqual(model.step, .verification)
XCTAssertNotNil(model.eligibility)
XCTAssertEqual(model.errorMessage, "验证码错误")
}
/// 展示全部真实阻断项,初始加载不会调用任何修改接口。
func testConditionsPageDisplaysAllBlockersWithoutMutation() async throws {
let service = try DeregistrationServiceStub()
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
let controller = StoreAccountDeregistrationViewController(identityName: "测试门店", viewModel: model, api: service)
controller.loadViewIfNeeded()
let views = allSubviews(controller.view)
let label = try XCTUnwrap(views.first { $0.accessibilityIdentifier == "deregister.blockers" } as? UILabel)
for _ in 0..<100 {
if label.text?.contains("账户仍有未履约订单或带单") == true { break }
try await Task.sleep(nanoseconds: 10_000_000)
}
for blocker in service.current.businessBlockers { XCTAssertTrue(label.text?.contains(blocker.message) == true) }
let wallet = try XCTUnwrap(views.first { $0.accessibilityIdentifier == "deregister.wallet.state" } as? UILabel)
let points = try XCTUnwrap(views.first { $0.accessibilityIdentifier == "deregister.points.state" } as? UILabel)
XCTAssertEqual(wallet.text, "待确认放弃")
XCTAssertEqual(points.text, "待确认放弃")
XCTAssertTrue(service.mutations.isEmpty)
let next = try XCTUnwrap(views.first { $0.accessibilityIdentifier == "deregister.continue" } as? UIButton)
XCTAssertFalse(next.isEnabled)
}
/// 验证页使用可注入服务并在真机渲染条件页面截图,供布局检查;不操作真实资产。
func testConditionsPageRendersOnPhysicalDevice() async throws {
let service = try DeregistrationServiceStub()
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
let controller = StoreAccountDeregistrationViewController(identityName: "测试门店·摄影师", viewModel: model, api: service)
let navigation = UINavigationController(rootViewController: controller)
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = navigation
window.makeKeyAndVisible()
defer { window.isHidden = true }
controller.loadViewIfNeeded()
let label = try XCTUnwrap(allSubviews(controller.view).first { $0.accessibilityIdentifier == "deregister.blockers" } as? UILabel)
for _ in 0..<100 {
if label.text?.contains("账户仍有未履约订单或带单") == true { break }
try await Task.sleep(nanoseconds: 10_000_000)
}
window.layoutIfNeeded()
XCTAssertTrue(label.text?.contains("账户仍有未履约订单或带单") == true)
let image = UIGraphicsImageRenderer(bounds: window.bounds).image { context in
window.layer.render(in: context.cgContext)
}
let attachment = XCTAttachment(image: image)
attachment.name = "store-deregistration-conditions"
attachment.lifetime = .keepAlways
add(attachment)
XCTAssertTrue(service.mutations.isEmpty)
}
/// 在真机渲染已确认草稿和验证码页;仅点击本地下一步,不触发任何真实或 Mock 修改请求。
func testDraftPageContinuesToVerificationAndRendersWithoutMutation() async throws {
let service = try DeregistrationServiceStub()
service.current = try StoreAccountDeregistrationFixtures.draftEligibility(pointsWaived: true)
service.currentStatus = try StoreAccountDeregistrationFixtures.draftStatus()
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
let controller = StoreAccountDeregistrationViewController(identityName: "测试门店", viewModel: model, api: service)
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = UINavigationController(rootViewController: controller)
window.makeKeyAndVisible()
defer { window.isHidden = true }
controller.loadViewIfNeeded()
let views = allSubviews(controller.view)
let next = try XCTUnwrap(views.first { $0.accessibilityIdentifier == "deregister.continue" } as? UIButton)
let status = try XCTUnwrap(views.first { $0.accessibilityIdentifier == "deregister.status" } as? UILabel)
for _ in 0..<100 {
if next.isEnabled { break }
try await Task.sleep(nanoseconds: 10_000_000)
}
XCTAssertTrue(next.isEnabled)
XCTAssertTrue(status.isHidden)
XCTAssertEqual(next.configuration?.title, "下一步")
XCTAssertFalse(views.contains { ($0 as? UILabel)?.text?.contains("待确认草稿") == true })
XCTAssertFalse(controller.navigationItem.hidesBackButton)
for stage in ["draft", "verification"] {
if stage == "verification" {
next.sendActions(for: .touchUpInside)
for _ in 0..<100 {
if model.step == .verification { break }
try await Task.sleep(nanoseconds: 10_000_000)
}
}
window.layoutIfNeeded()
let image = UIGraphicsImageRenderer(bounds: window.bounds).image { context in
window.layer.render(in: context.cgContext)
}
let attachment = XCTAttachment(image: image)
attachment.name = "store-deregistration-\(stage)"
attachment.lifetime = .keepAlways
add(attachment)
}
XCTAssertEqual(model.step, .verification)
let code = try XCTUnwrap(views.first { $0.accessibilityIdentifier == "deregister.code" } as? UITextField)
XCTAssertEqual(code.superview?.isHidden, false)
XCTAssertTrue(status.isHidden)
XCTAssertEqual(code.textContentType, .oneTimeCode)
XCTAssertTrue(service.mutations.isEmpty)
}
private func allSubviews(_ view: UIView) -> [UIView] { view.subviews.flatMap { [$0] + allSubviews($0) } }
/// 本机提交意图跨实例保存,且不能传播到其他门店或正式环境。
func testSubmissionIntentIsPersistentAndIsolatedByIdentityAndEnvironment() throws {
let name = "DeregistrationSubmissionStore.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: name)!
defer { defaults.removePersistentDomain(forName: name) }
let first = StoreAccountDeregistrationSubmissionStore(storeUserID: "101", environment: .testing, defaults: defaults)
let second = StoreAccountDeregistrationSubmissionStore(storeUserID: "102", environment: .testing, defaults: defaults)
let production = StoreAccountDeregistrationSubmissionStore(storeUserID: "101", environment: .production, defaults: defaults)
try first.recordSubmissionIntent()
XCTAssertTrue(StoreAccountDeregistrationSubmissionStore(storeUserID: "101", environment: .testing, defaults: defaults).hasUnresolvedSubmission)
XCTAssertFalse(second.hasUnresolvedSubmission)
XCTAssertFalse(production.hasUnresolvedSubmission)
try second.recordSubmissionIntent()
first.clearRejectedSubmission()
XCTAssertFalse(first.hasUnresolvedSubmission)
XCTAssertTrue(second.hasUnresolvedSubmission)
}
/// 申请响应丢失后创建新的 ViewModel,也不能因为暂时返回 null 再次申请。
func testLostApplyResponsePreventsRetryAcrossViewModelRecreation() async throws {
let name = "DeregistrationSubmissionRecovery.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: name)!
defer { defaults.removePersistentDomain(forName: name) }
let store = StoreAccountDeregistrationSubmissionStore(storeUserID: "101", environment: .testing, defaults: defaults)
let service = try DeregistrationServiceStub()
service.current = try StoreAccountDeregistrationFixtures.ready()
service.applyError = APIError.networkFailed("lost response")
let original = StoreAccountDeregistrationViewModel(storeUserID: 101, submissionStore: store)
await original.refresh(api: service)
original.beginVerification()
await original.submit(smsCode: "907182", reason: "不再使用", api: service)
let restored = StoreAccountDeregistrationViewModel(storeUserID: 101, submissionStore: store)
await restored.refresh(api: service)
restored.beginVerification()
await restored.submit(smsCode: "907182", reason: "不再使用", api: service)
XCTAssertTrue(store.hasUnresolvedSubmission)
XCTAssertEqual(restored.step, .unresolvedRequest)
XCTAssertFalse(restored.canContinue)
XCTAssertEqual(service.mutations, ["apply"])
}
/// 只有明确的服务端拒绝才能清理本次提交意图。
func testExplicitApplyRejectionClearsSubmissionIntent() async throws {
let name = "DeregistrationSubmissionRejection.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: name)!
defer { defaults.removePersistentDomain(forName: name) }
let store = StoreAccountDeregistrationSubmissionStore(storeUserID: "101", environment: .testing, defaults: defaults)
let service = try DeregistrationServiceStub()
service.current = try StoreAccountDeregistrationFixtures.ready()
service.applyError = APIError.serverCode(100001, "验证码无效")
let model = StoreAccountDeregistrationViewModel(storeUserID: 101, submissionStore: store)
await model.refresh(api: service)
model.beginVerification()
await model.submit(smsCode: "907182", reason: "不再使用", api: service)
XCTAssertFalse(store.hasUnresolvedSubmission)
XCTAssertFalse(model.submissionAttempted)
}
}
/// 仅内存中的服务替身,独立于 AppStore 和真实网络,不清理设备现有登录态。
@MainActor
private final class DeregistrationServiceStub: StoreAccountDeregistrationServing {
var current: StoreAccountDeregistrationEligibility
var currentStatus = StoreAccountDeregistrationStatus(deregister: .null)
var afterWaiver: StoreAccountDeregistrationEligibility?
var statusAfterWaiver: StoreAccountDeregistrationStatus?
var readError: Error?
var applyError: Error?
var mutations: [String] = []
var eligibilityCount = 0
var statusCount = 0
var lastCode: String?
var lastReason: String?
init() throws { current = try StoreAccountDeregistrationFixtures.eligibility() }
func eligibility() async throws -> StoreAccountDeregistrationEligibility {
eligibilityCount += 1
if let readError { throw readError }
return current
}
func status() async throws -> StoreAccountDeregistrationStatus {
statusCount += 1
if let readError { throw readError }
return currentStatus
}
func waiveWallet() async throws {
mutations.append("wallet")
if let afterWaiver { current = afterWaiver }
if let statusAfterWaiver { currentStatus = statusAfterWaiver }
}
func waivePoints() async throws {
mutations.append("points")
if let afterWaiver { current = afterWaiver }
if let statusAfterWaiver { currentStatus = statusAfterWaiver }
}
func sendSMS() async throws { mutations.append("sms") }
func apply(smsCode: String, reason: String) async throws {
mutations.append("apply")
lastCode = smsCode
lastReason = reason
if let applyError { throw applyError }
}
func cancel() async throws { mutations.append("cancel") }
}
/// 真实响应模型严格解码测试,不把未知或缺失的权限字段默认成允许注销。
final class StoreAccountDeregistrationResponseTests: XCTestCase {
/// 1/9 的区别以完整服务端状态为准,撤销记录仍保留冷静期时间。
func testObservedCoolingAndCancelledRecordsAreDistinct() throws {
let cooling = try StoreAccountDeregistrationFixtures.lifecycleStatus()
let cancelled = try StoreAccountDeregistrationFixtures.lifecycleStatus(cancelled: true)
XCTAssertTrue(cooling.isCooling)
XCTAssertFalse(cooling.permitsPreparation)
XCTAssertTrue(cancelled.isCancelled)
XCTAssertTrue(cancelled.permitsPreparation)
XCTAssertFalse(cancelled.isCooling)
XCTAssertEqual(cooling.coolingUntil, cancelled.coolingUntil)
XCTAssertEqual(cancelled.remainingSeconds, 0)
XCTAssertNotNil(cancelled.cancellationFingerprint)
}
/// 缺字段、终态矛盾和未知数字均保持受限,不推断尚未实测的完成/阻断枚举。
func testIncompleteOrContradictoryLifecycleDoesNotAllowBusiness() throws {
for cancelled in [true, false] {
for key in StoreAccountDeregistrationFixtures.lifecycleRecord(cancelled: cancelled).keys {
var record = StoreAccountDeregistrationFixtures.lifecycleRecord(cancelled: cancelled)
record.removeValue(forKey: key)
let status = try JSONDecoder().decode(StoreAccountDeregistrationStatus.self,
from: JSONSerialization.data(withJSONObject: ["deregister": record]))
XCTAssertFalse(status.isCooling, key)
XCTAssertFalse(status.permitsPreparation, key)
}
}
for fields: [String: Any] in [["status": 2], ["status": 3], ["status": "9"], ["id": 0],
["cancel_time": NSNull()], ["completed_at": "2026-09-04 14:23:40"], ["remaining_seconds": 1]] {
XCTAssertFalse(try StoreAccountDeregistrationFixtures.lifecycleStatus(cancelled: true, overrides: fields).permitsPreparation)
}
}
/// 新结构、旧版本布尔值与损坏的本地记录分别处理,不能静默清掉未知提交意图。
func testCancellationOnlyClearsSupportedSubmissionIntentFormats() throws {
let name = "DeregistrationIntentCompatibility.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: name)!
defer { defaults.removePersistentDomain(forName: name) }
let key = "store_deregister_submission_intent_v1_\(APIEnvironment.testing.baseURL.host!)_101"
let store = StoreAccountDeregistrationSubmissionStore(storeUserID: "101", environment: .testing, defaults: defaults)
let cancelled = try StoreAccountDeregistrationFixtures.lifecycleStatus(cancelled: true)
defaults.set(true, forKey: key)
XCTAssertTrue(store.clearCancelledSubmission(matching: cancelled))
XCTAssertFalse(store.hasUnresolvedSubmission)
for invalid: Any in [false, "unknown", ["unexpected": "value"], 2] {
defaults.set(invalid, forKey: key)
XCTAssertFalse(store.clearCancelledSubmission(matching: cancelled))
XCTAssertTrue(store.hasUnresolvedSubmission)
}
}
/// 实测草稿及无历史业务的 null 风险时间均可正常解析,不制造额外风险等待期。
func testObservedDraftAndNullRiskTimesAreSupported() throws {
let status = try StoreAccountDeregistrationFixtures.draftStatus()
let eligibility = try StoreAccountDeregistrationFixtures.draftEligibility(pointsWaived: true)
XCTAssertTrue(status.isUnsubmittedDraft)
XCTAssertTrue(status.permitsPreparation)
XCTAssertTrue(eligibility.permitsApplication)
XCTAssertNil(eligibility.riskEndAt)
XCTAssertNil(eligibility.eligibleAt)
XCTAssertFalse(try StoreAccountDeregistrationFixtures.draftEligibility(pointsWaived: false).permitsApplication)
}
/// 未实测数字、字符串或缺失状态都不能根据“待确认”文案猜测为草稿。
func testUnknownOrContradictoryDraftFieldsDoNotPermitPreparation() throws {
let cases: [[String: Any]] = [
["status": 987], ["status": "0"], ["status": false], ["status": NSNull()],
["id": 0], ["id": 1.5], ["status_label": ""], ["reason": NSNull()],
["apply_time": "2026-08-28 14:00:00"], ["cooling_until": "2026-09-04 14:00:00"],
["remaining_seconds": 1], ["cancel_time": "2026-08-28 14:00:00"],
["completed_at": "2026-08-28 14:00:00"], ["blocked_code": "UNKNOWN"],
["blocked_reason": "条件改变"],
]
for fields in cases {
XCTAssertFalse(try StoreAccountDeregistrationFixtures.draftStatus(overrides: fields).permitsPreparation,
"不能放行矛盾字段:\(fields)")
}
for key in StoreAccountDeregistrationFixtures.draftRecord().keys {
var record = StoreAccountDeregistrationFixtures.draftRecord()
record.removeValue(forKey: key)
let data = try JSONSerialization.data(withJSONObject: ["deregister": record])
let status = try JSONDecoder().decode(StoreAccountDeregistrationStatus.self, from: data)
XCTAssertFalse(status.permitsPreparation, "缺少 \(key) 不能当作完整草稿")
}
}
func testMissingCriticalFieldsFailDecoding() throws {
let data = try StoreAccountDeregistrationFixtures.eligibilityData()
let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
for field in ["can_apply", "wallet_balance_fen", "points_balance", "wallet_waived", "points_waived", "blockers", "deregister"] {
var incomplete = object
incomplete.removeValue(forKey: field)
XCTAssertThrowsError(try JSONDecoder().decode(StoreAccountDeregistrationEligibility.self,
from: JSONSerialization.data(withJSONObject: incomplete)), field)
}
XCTAssertThrowsError(try JSONDecoder().decode(StoreAccountDeregistrationStatus.self, from: Data("{}".utf8)))
}
func testCanApplyAloneDoesNotBypassOtherConditions() throws {
XCTAssertFalse(try StoreAccountDeregistrationFixtures.eligibility(overrides: ["can_apply": true]).permitsApplication)
for fields: [String: Any] in [["wallet_balance_fen": -1], ["points_balance": -1], ["wallet_waived": false],
["unfulfilled_count": 1], ["fulfillment_in_progress_count": 1],
["deregister": ["unknown": true]]] {
XCTAssertFalse(try StoreAccountDeregistrationFixtures.ready(overrides: fields).permitsApplication)
}
}
func testUnknownBlockerIsPreservedAndStillBlocksSubmission() throws {
let model = try StoreAccountDeregistrationFixtures.ready(overrides: ["blockers": [
["code": "FUTURE_RULE", "message": "新的限制", "action": "future_action"],
]])
XCTAssertEqual(model.blockers.first?.message, "新的限制")
XCTAssertFalse(model.permitsApplication)
XCTAssertTrue(model.blockers.first?.guidance.contains("客服") == true)
}
}
@@ -102,7 +102,7 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
) )
} }
func testReretouchWorkflowShowsAndSubmitsOnlyRequiredCategories() async { func testReretouchWorkflowAllowsAtmosphereOnlyFromOriginalTab() async {
let api = makeAPI() let api = makeAPI()
let refined = TravelAlbumAIRetouchTemplateViewModel( let refined = TravelAlbumAIRetouchTemplateViewModel(
scenicId: 18, scenicId: 18,
@@ -133,9 +133,19 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
) )
await all.loadTemplates(api: api) await all.loadTemplates(api: api)
XCTAssertEqual(all.visibleCategories, [.refined, .atmosphere]) XCTAssertEqual(all.visibleCategories, [.refined, .atmosphere])
XCTAssertEqual(all.selectedRefinedTemplateId, 11) XCTAssertNil(all.selectedRefinedTemplateId)
XCTAssertNil(all.selectedAtmosphereTemplateId) XCTAssertNil(all.selectedAtmosphereTemplateId)
XCTAssertTrue(all.isOptional(.refined))
XCTAssertTrue(all.isOptional(.atmosphere)) XCTAssertTrue(all.isOptional(.atmosphere))
XCTAssertEqual(all.requiredQuota, 0)
XCTAssertFalse(all.canSubmit)
XCTAssertEqual(all.validationMessage, "请至少选择一个修图模板")
all.toggleTemplate(id: 11, category: .refined)
XCTAssertEqual(all.selectedRefinedTemplateId, 11)
all.toggleTemplate(id: 11, category: .refined)
XCTAssertNil(all.selectedRefinedTemplateId)
all.toggleTemplate(id: 21, category: .atmosphere)
XCTAssertEqual(all.requiredQuota, 1) XCTAssertEqual(all.requiredQuota, 1)
XCTAssertTrue(all.canSubmit) XCTAssertTrue(all.canSubmit)
await all.submit(api: api) await all.submit(api: api)
@@ -159,8 +169,8 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
id: 9, id: 9,
aiRetouchBatchId: 90, aiRetouchBatchId: 90,
type: .all, type: .all,
refinedTemplateId: 11, refinedTemplateId: nil,
atmosphereTemplateId: nil atmosphereTemplateId: 21
), ),
]) ])
} }
@@ -183,7 +193,7 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
XCTAssertTrue(api.aiReretouchRequests.isEmpty) XCTAssertTrue(api.aiReretouchRequests.isEmpty)
} }
func testMissingOptionalAtmosphereDoesNotDisableAllReretouchWorkflow() async { func testMissingOptionalAtmosphereStillAllowsSelectingRefinedInAllReretouchWorkflow() async {
let api = makeAPI() let api = makeAPI()
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse( api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
refinedTemplates: [template(11, "清透")], refinedTemplates: [template(11, "清透")],
@@ -198,6 +208,8 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
await viewModel.loadTemplates(api: api) await viewModel.loadTemplates(api: api)
XCTAssertFalse(viewModel.canSubmit)
viewModel.toggleTemplate(id: 11, category: .refined)
XCTAssertTrue(viewModel.canSubmit) XCTAssertTrue(viewModel.canSubmit)
XCTAssertNil(viewModel.validationMessage) XCTAssertNil(viewModel.validationMessage)
XCTAssertEqual(viewModel.requiredQuota, 1) XCTAssertEqual(viewModel.requiredQuota, 1)
@@ -213,7 +225,7 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
) )
let reretouch = TravelAlbumAIRetouchTemplateViewModel( let reretouch = TravelAlbumAIRetouchTemplateViewModel(
scenicId: 18, scenicId: 18,
workflow: .reretouch(materialId: 7, batchId: 70, type: .all) workflow: .reretouch(materialId: 7, batchId: 70, type: .refined)
) )
await reretouch.loadTemplates(api: refinedAPI) await reretouch.loadTemplates(api: refinedAPI)
XCTAssertFalse(reretouch.canSubmit) XCTAssertFalse(reretouch.canSubmit)
+63 -2
View File
@@ -36,7 +36,11 @@ final class TravelAlbumAPITests: XCTestCase {
materialNum: 2, materialNum: 2,
materialPrice: 10.5, materialPrice: 10.5,
materialPackagePrice: 88, materialPackagePrice: 88,
photoPrice: 0 photoPrice: 0,
autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration(
enabled: true,
refinedTemplateId: 12
)
) )
) )
@@ -48,6 +52,60 @@ final class TravelAlbumAPITests: XCTestCase {
XCTAssertEqual(body?["type"] as? Int, 1) XCTAssertEqual(body?["type"] as? Int, 1)
XCTAssertEqual(body?["material_num"] as? Int, 2) XCTAssertEqual(body?["material_num"] as? Int, 2)
XCTAssertEqual(body?["material_price"] as? Double, 10.5) XCTAssertEqual(body?["material_price"] as? Double, 10.5)
let retouchConfiguration = body?["auto_retouch_config"] as? [String: Any]
XCTAssertEqual(retouchConfiguration?["enabled"] as? Bool, true)
XCTAssertEqual(retouchConfiguration?["refined_template_id"] as? Int, 12)
XCTAssertNil(retouchConfiguration?["version"])
XCTAssertNil(retouchConfiguration?["updated_at"])
}
func testInfoDecodesNewConfigurationAndDefaultsOldAlbumToDisabled() async throws {
let configuredAlbum = envelopeJSON(
#"{"id":8,"store_user_id":1,"name":"配置相册","type":1,"order_number":"","material_num":1,"material_price":10,"material_package_price":0,"photo_price":0,"cover_url":"","user_id":2,"status":1,"created_at":"","updated_at":"","auto_retouch_config":{"enabled":true,"refined_template_id":12}}"#
)
let legacyAlbum = envelopeJSON(
#"{"id":9,"store_user_id":1,"name":"旧相册","type":1,"order_number":"","material_num":1,"material_price":10,"material_package_price":0,"photo_price":0,"cover_url":"","user_id":2,"status":1,"created_at":"","updated_at":""}"#
)
let session = MockURLSession(responses: [configuredAlbum, legacyAlbum])
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
let configured = try await api.info(id: 8)
let legacy = try await api.info(id: 9)
XCTAssertEqual(
configured.autoRetouchConfiguration,
TravelAlbumAutoRetouchConfiguration(
enabled: true,
refinedTemplateId: 12
)
)
XCTAssertEqual(legacy.autoRetouchConfiguration, .disabled)
}
func testUpdateAutoRetouchConfigurationEncodesBodyAndDecodesNormalizedResponse() async throws {
let data = envelopeJSON(
#"{"enabled":true,"refined_template_id":18}"#
)
let session = MockURLSession(responses: [data])
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
let response = try await api.updateAutoRetouchConfiguration(
TravelAlbumAutoRetouchConfigurationRequest(
userEquityTravelId: 6,
enabled: true,
refinedTemplateId: 18
)
)
XCTAssertEqual(response.refinedTemplateId, 18)
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/auto-retouch-config")
let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(request.httpBody)) as? [String: Any]
XCTAssertEqual(body?["user_equity_travel_id"] as? Int, 6)
XCTAssertEqual(body?["enabled"] as? Bool, true)
XCTAssertEqual(body?["refined_template_id"] as? Int, 18)
XCTAssertNil(body?["expected_version"])
} }
func testMaterialListAndDeleteAndMpCode() async throws { func testMaterialListAndDeleteAndMpCode() async throws {
@@ -192,7 +250,8 @@ final class TravelAlbumAPITests: XCTestCase {
materialIds: [11, 12, 13, 14], materialIds: [11, 12, 13, 14],
refinedTemplateId: 21, refinedTemplateId: 21,
atmosphereTemplateId: 22, atmosphereTemplateId: 22,
coverTemplateId: 31 coverTemplateId: 31,
clientRequestId: "auto-6-11-tpl21-a0"
) )
) )
@@ -205,12 +264,14 @@ final class TravelAlbumAPITests: XCTestCase {
XCTAssertEqual(body?["refined_template_id"] as? Int, 21) XCTAssertEqual(body?["refined_template_id"] as? Int, 21)
XCTAssertEqual(body?["atmosphere_template_id"] as? Int, 22) XCTAssertEqual(body?["atmosphere_template_id"] as? Int, 22)
XCTAssertEqual(body?["cover_template_id"] as? Int, 31) XCTAssertEqual(body?["cover_template_id"] as? Int, 31)
XCTAssertEqual(body?["client_request_id"] as? String, "auto-6-11-tpl21-a0")
XCTAssertEqual(Set(body?.keys.map { $0 } ?? []), [ XCTAssertEqual(Set(body?.keys.map { $0 } ?? []), [
"user_equity_travel_id", "user_equity_travel_id",
"material_ids", "material_ids",
"refined_template_id", "refined_template_id",
"atmosphere_template_id", "atmosphere_template_id",
"cover_template_id", "cover_template_id",
"client_request_id",
]) ])
} }
@@ -14,7 +14,7 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
let api = TravelAlbumMockAPI() let api = TravelAlbumMockAPI()
let source = TravelAlbumAIJobSourceMaterial( let source = TravelAlbumAIJobSourceMaterial(
id: 691, id: 691,
fileName: "IMG_8293.JPG", fileName: "beauty_1757851944526_这个文件标题特别长用于验证布局不会挤压状态.jpg",
thumbnailURL: "" thumbnailURL: ""
) )
let failedTarget = TravelAlbumAIJobTarget( let failedTarget = TravelAlbumAIJobTarget(
@@ -104,6 +104,42 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
XCTAssertTrue(labels.contains("失败原因:AI服务处理超时,请重新修图")) XCTAssertTrue(labels.contains("失败原因:AI服务处理超时,请重新修图"))
XCTAssertFalse(labels.contains("去相册处理")) XCTAssertFalse(labels.contains("去相册处理"))
XCTAssertFalse(labels.contains { $0.contains("VENDOR_TIMEOUT") }) XCTAssertFalse(labels.contains { $0.contains("VENDOR_TIMEOUT") })
let contentCard = try XCTUnwrap(controller.view.findSubview {
$0.accessibilityIdentifier == "aiRetouchJob.detail.contentCard"
})
let refinedChip = try XCTUnwrap(controller.view.findSubview {
$0.accessibilityIdentifier == "aiRetouchJob.contentChip.refined"
})
let atmosphereChip = try XCTUnwrap(controller.view.findSubview {
$0.accessibilityIdentifier == "aiRetouchJob.contentChip.atmosphere"
})
let coverChip = try XCTUnwrap(controller.view.findSubview {
$0.accessibilityIdentifier == "aiRetouchJob.contentChip.cover"
})
XCTAssertEqual(refinedChip.backgroundColor?.travelAlbumTestHexRGB, 0xEAF3FF)
XCTAssertEqual(atmosphereChip.backgroundColor?.travelAlbumTestHexRGB, 0xFFF2DC)
XCTAssertEqual(coverChip.backgroundColor?.travelAlbumTestHexRGB, 0xF2ECFF)
XCTAssertNil(refinedChip.findSubview { $0 is UIImageView })
XCTAssertNil(atmosphereChip.findSubview { $0 is UIImageView })
XCTAssertNil(coverChip.findSubview { $0 is UIImageView })
XCTAssertGreaterThan(atmosphereChip.bounds.width, refinedChip.bounds.width)
let coverFrame = coverChip.convert(coverChip.bounds, to: contentCard)
XCTAssertLessThan(coverFrame.maxX, contentCard.bounds.maxX - 16)
let titleLabel = try XCTUnwrap(controller.view.findSubview {
$0.accessibilityIdentifier == "aiRetouchJob.target.3.title"
})
let templateLabel = try XCTUnwrap(controller.view.findSubview {
$0.accessibilityIdentifier == "aiRetouchJob.target.3.template"
})
let statusView = try XCTUnwrap(controller.view.findSubview {
$0.accessibilityIdentifier == "aiRetouchJob.target.3.status"
})
let titleFrame = titleLabel.convert(titleLabel.bounds, to: controller.view)
let templateFrame = templateLabel.convert(templateLabel.bounds, to: controller.view)
let statusFrame = statusView.convert(statusView.bounds, to: controller.view)
XCTAssertLessThanOrEqual(titleFrame.maxX, controller.view.bounds.maxX - 18)
XCTAssertGreaterThan(statusFrame.minY, templateFrame.maxY)
XCTAssertLessThanOrEqual(statusFrame.maxX, controller.view.bounds.maxX - 18)
let detailScreenshot = UIGraphicsImageRenderer(bounds: window.bounds).image { context in let detailScreenshot = UIGraphicsImageRenderer(bounds: window.bounds).image { context in
window.layer.render(in: context.cgContext) window.layer.render(in: context.cgContext)
} }
@@ -224,7 +260,7 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
XCTAssertFalse(GlobalLoadingManager.shared.isShowing) XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
} }
func testAIJobEntryOnlyAppearsOnAlbumManagementNavigationBar() { func testAlbumManagementShowsAIJobEntryButAlbumEntryDoesNot() {
let detail = TravelAlbumDetailViewController(albumId: 2, api: TravelAlbumMockAPI()) let detail = TravelAlbumDetailViewController(albumId: 2, api: TravelAlbumMockAPI())
detail.setupNavigationBar() detail.setupNavigationBar()
let entry = TravelAlbumEntryViewController(api: TravelAlbumMockAPI()) let entry = TravelAlbumEntryViewController(api: TravelAlbumMockAPI())
@@ -315,6 +351,56 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
XCTAssertEqual(counterContainer.bounds.width - counterLabel.frame.maxX, 12, accuracy: 0.5) XCTAssertEqual(counterContainer.bounds.width - counterLabel.frame.maxX, 12, accuracy: 0.5)
} }
func testPreviewAppliesRequestedInitialKindForCoverOnlyProject() throws {
let cover = TravelAlbumPreviewAsset(
id: "cover-897",
kind: .cover,
fileURL: "https://example.com/final.png",
coverURL: "https://example.com/final.png",
fileName: "AI封面",
fileSize: 0
)
let controller = TravelAlbumPhotoPreviewViewController(
projects: [TravelAlbumPreviewProject(originalMaterialId: 897, assets: [cover])],
totalCount: 1,
startProjectIndex: 0,
startKind: .cover,
allowsActions: false
)
controller.loadViewIfNeeded()
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
controller.view.layoutIfNeeded()
let highResolutionButton = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.previewHighResolutionButton"
} as? UIButton
)
let loadingIndicator = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.previewLoadingIndicator"
}
)
let activityIndicator = try XCTUnwrap(
loadingIndicator.findSubview { $0 is UIActivityIndicatorView } as? UIActivityIndicatorView
)
let fileSizeLabel = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.previewFileSizeLabel"
} as? UILabel
)
XCTAssertTrue(controller.view.allLabels().contains { $0.text == "AI封面" })
XCTAssertTrue(highResolutionButton.isEnabled)
XCTAssertTrue(fileSizeLabel.isHidden)
XCTAssertNil(fileSizeLabel.text)
XCTAssertEqual(fileSizeLabel.bounds.height, 0, accuracy: 0.5)
XCTAssertEqual(loadingIndicator.bounds.size, CGSize(width: 44, height: 44))
XCTAssertEqual(loadingIndicator.backgroundColor?.travelAlbumTestHexRGB, 0x27272A)
XCTAssertEqual(loadingIndicator.accessibilityLabel, "图片加载中")
XCTAssertEqual(activityIndicator.color.travelAlbumTestHexRGB, 0xFFFFFF)
}
func testPreviewSuccessfulDeletionShowsNextProjectAndPreventsDuplicateRequests() async throws { func testPreviewSuccessfulDeletionShowsNextProjectAndPreventsDuplicateRequests() async throws {
let api = TravelAlbumMockAPI() let api = TravelAlbumMockAPI()
api.deleteMaterialDelayNanoseconds = 1_000_000 api.deleteMaterialDelayNanoseconds = 1_000_000
@@ -832,6 +918,250 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
XCTAssertEqual(quotaLabel.accessibilityLabel, "剩余8张修图额度") XCTAssertEqual(quotaLabel.accessibilityLabel, "剩余8张修图额度")
} }
func testOriginalTabReretouchSheetShowsBothTemplateGroupsAsOptional() async throws {
let api = TravelAlbumMockAPI()
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
refinedTemplates: [TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "")],
atmosphereTemplates: [TravelAlbumAIRetouchTemplate(id: 2, name: "暖阳", previewURL: "")],
remainingQuota: 8
)
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
scenicId: 18,
workflow: .reretouch(materialId: 7, batchId: 70, type: .all)
)
let controller = TravelAlbumAIRetouchTemplateViewController(
viewModel: viewModel,
api: api,
onSubmitted: { _ in }
)
controller.loadViewIfNeeded()
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
await waitUntil { !viewModel.isLoading && api.aiRetouchTemplateScenicIds.count == 1 }
controller.view.layoutIfNeeded()
let confirmButton = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchConfirmButton"
} as? UIButton
)
let accessibleLabels = controller.view.allAccessibilityLabels()
XCTAssertTrue(accessibleLabels.contains("原图精修,选填"))
XCTAssertTrue(accessibleLabels.contains("氛围感修图,选填"))
XCTAssertFalse(confirmButton.isEnabled)
viewModel.toggleTemplate(id: 2, category: .atmosphere)
await waitUntil { confirmButton.isEnabled }
XCTAssertNil(viewModel.selectedRefinedTemplateId)
XCTAssertEqual(viewModel.selectedAtmosphereTemplateId, 2)
}
func testAutoRetouchGridUsesThreeColumnsAndKeepsFooterAndSelectionWhileScrolling() async throws {
let fixture = try await makeAutoRetouchSheet(templateCount: 19)
let controller = fixture.controller
let collection = fixture.collection
let confirm = try XCTUnwrap(controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.autoRetouchConfirmButton"
} as? UIButton)
XCTAssertTrue(collection.collectionViewLayout is UICollectionViewCompositionalLayout)
XCTAssertFalse(confirm.isDescendant(of: collection))
for width: CGFloat in [375, 390, 430] {
controller.view.frame = CGRect(x: 0, y: 0, width: width, height: 667)
controller.view.layoutIfNeeded()
collection.layoutIfNeeded()
let frames = try (0 ..< 4).map { index in
try XCTUnwrap(collection.layoutAttributesForItem(at: IndexPath(item: index, section: 0))).frame
}
XCTAssertEqual(frames[0].minX, 16, accuracy: 0.5)
XCTAssertEqual(frames[0].width, (width - 56) / 3, accuracy: 0.5)
XCTAssertEqual(frames[0].height, 156)
XCTAssertEqual(frames[1].minY, frames[0].minY)
XCTAssertEqual(frames[2].minY, frames[0].minY)
XCTAssertEqual(frames[1].minX - frames[0].maxX, 12, accuracy: 0.5)
XCTAssertEqual(frames[2].minX - frames[1].maxX, 12, accuracy: 0.5)
XCTAssertEqual(frames[3].minX, frames[0].minX)
XCTAssertEqual(frames[3].minY - frames[0].maxY, 12, accuracy: 0.5)
XCTAssertLessThanOrEqual(collection.contentSize.width, collection.bounds.width)
}
let footerFrame = confirm.convert(confirm.bounds, to: controller.view)
let last = IndexPath(item: 18, section: 0)
collection.scrollToItem(at: last, at: .bottom, animated: false)
collection.layoutIfNeeded()
let lastCell = try XCTUnwrap(collection.cellForItem(at: last) as? TravelAlbumAIRetouchTemplateCell)
XCTAssertEqual(lastCell.frame.minX, 16, accuracy: 0.5)
XCTAssertGreaterThan(collection.contentOffset.y, 0)
let scrollOffset = collection.contentOffset
collection.delegate?.collectionView?(collection, didSelectItemAt: last)
await waitUntil { lastCell.accessibilityValue == "已选择" }
XCTAssertEqual(fixture.viewModel.pendingConfiguration?.refinedTemplateId, 19)
XCTAssertEqual(collection.contentOffset, scrollOffset)
XCTAssertEqual(confirm.convert(confirm.bounds, to: controller.view), footerFrame)
XCTAssertLessThanOrEqual(collection.convert(collection.bounds, to: controller.view).maxY, footerFrame.minY)
XCTAssertTrue(confirm.isEnabled)
collection.delegate?.collectionView?(collection, didSelectItemAt: last)
XCTAssertEqual(fixture.viewModel.selectedTemplateId, 19)
collection.scrollToItem(at: IndexPath(item: 0, section: 0), at: .top, animated: false)
collection.layoutIfNeeded()
let first = try XCTUnwrap(collection.cellForItem(at: IndexPath(item: 0, section: 0)))
XCTAssertEqual(first.accessibilityValue, "未选择")
collection.scrollToItem(at: last, at: .bottom, animated: false)
collection.layoutIfNeeded()
XCTAssertEqual(collection.cellForItem(at: last)?.accessibilityValue, "已选择")
}
func testAutoRetouchModeSelectionExpandsTemplatesInlineAndKeepsDraftWhenToggling() async throws {
let fixture = try await makeAutoRetouchSheet(templateCount: 19, allowsModeSelection: true, configuration: .disabled)
let controller = fixture.controller
let original = try XCTUnwrap(controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.autoRetouchOriginalOption"
} as? UIControl)
let ai = try XCTUnwrap(controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.autoRetouchAIOption"
} as? UIControl)
let confirm = try XCTUnwrap(controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.autoRetouchConfirmButton"
} as? UIButton)
let summary = try XCTUnwrap(controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.autoRetouchSelectionSummary"
} as? UILabel)
XCTAssertTrue(original.isSelected)
XCTAssertTrue(fixture.collection.isHidden)
XCTAssertTrue(confirm.isEnabled)
let modeFrame = ai.convert(ai.bounds, to: controller.view)
ai.sendActions(for: .touchUpInside)
await waitUntil { !fixture.collection.isHidden }
controller.view.layoutIfNeeded()
fixture.collection.layoutIfNeeded()
XCTAssertTrue(ai.isSelected)
XCTAssertFalse(original.isSelected)
XCTAssertFalse(confirm.isEnabled)
XCTAssertNil(controller.presentedViewController)
XCTAssertEqual(ai.convert(ai.bounds, to: controller.view), modeFrame)
XCTAssertGreaterThan(fixture.collection.convert(fixture.collection.bounds, to: controller.view).minY, modeFrame.maxY)
fixture.viewModel.selectTemplate(id: 4)
await waitUntil { summary.text == "已选择:模板4" }
let last = IndexPath(item: 18, section: 0)
fixture.collection.scrollToItem(at: last, at: .bottom, animated: false)
fixture.collection.layoutIfNeeded()
let offset = fixture.collection.contentOffset
original.sendActions(for: .touchUpInside)
await waitUntil { fixture.collection.isHidden }
XCTAssertTrue(confirm.isEnabled)
XCTAssertEqual(fixture.viewModel.pendingConfiguration, .disabled)
ai.sendActions(for: .touchUpInside)
await waitUntil { !fixture.collection.isHidden }
XCTAssertEqual(fixture.viewModel.selectedTemplateId, 4)
XCTAssertEqual(fixture.collection.contentOffset, offset)
XCTAssertEqual(summary.text, "已选择:模板4")
XCTAssertTrue(confirm.isEnabled)
fixture.collection.scrollToItem(at: IndexPath(item: 0, section: 0), at: .top, animated: false)
controller.view.layoutIfNeeded()
fixture.collection.layoutIfNeeded()
let image = UIGraphicsImageRenderer(bounds: controller.view.bounds).image { context in
controller.view.layer.render(in: context.cgContext)
}
let attachment = XCTAttachment(image: image)
attachment.name = "自动修图-同页模板选择"
attachment.lifetime = .keepAlways
add(attachment)
}
func testAutoRetouchPreviewDoesNotSelectTemplateAndReturnsToSameScrollPosition() async throws {
let fixture = try await makeAutoRetouchSheet(templateCount: 19)
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
let host = UIViewController()
window.rootViewController = host
window.makeKeyAndVisible()
defer { window.isHidden = true }
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
host.present(fixture.controller, animated: false) { continuation.resume() }
}
fixture.controller.view.layoutIfNeeded()
let collection = fixture.collection
let last = IndexPath(item: 18, section: 0)
collection.scrollToItem(at: last, at: .bottom, animated: false)
collection.layoutIfNeeded()
let offset = collection.contentOffset
let cell = try XCTUnwrap(collection.cellForItem(at: last))
let preview = try XCTUnwrap(cell.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplatePreviewButton"
} as? UIButton)
preview.sendActions(for: .touchUpInside)
await waitUntil { fixture.controller.presentedViewController is BeforeAfterComparisonViewController }
let comparison = try XCTUnwrap(fixture.controller.presentedViewController as? BeforeAfterComparisonViewController)
XCTAssertEqual(fixture.viewModel.selectedTemplateId, 1)
XCTAssertEqual(fixture.viewModel.pendingConfiguration?.refinedTemplateId, 1)
let returned = expectation(description: "Return from comparison")
// 等待展示动画完成,再模拟预览返回。
if let transition = comparison.transitionCoordinator {
transition.animate(alongsideTransition: nil) { _ in
comparison.dismiss(animated: false) { returned.fulfill() }
}
} else {
comparison.dismiss(animated: false) { returned.fulfill() }
}
await fulfillment(of: [returned], timeout: 3)
XCTAssertNil(fixture.controller.presentedViewController)
XCTAssertEqual(collection.contentOffset, offset)
XCTAssertEqual(fixture.viewModel.selectedTemplateId, 1)
}
func testAutoRetouchMissingComparisonKeepsSelectionAndShowsMessage() async throws {
let fixture = try await makeAutoRetouchSheet(templateCount: 2, hasComparison: false)
let cell = try XCTUnwrap(fixture.collection.cellForItem(at: IndexPath(item: 1, section: 0)))
let preview = try XCTUnwrap(cell.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplatePreviewButton"
} as? UIButton)
preview.sendActions(for: .touchUpInside)
XCTAssertNil(fixture.controller.presentedViewController)
XCTAssertEqual(fixture.viewModel.selectedTemplateId, 1)
XCTAssertTrue(fixture.controller.view.allLabels().contains { $0.text == "暂无对比预览" })
}
private func makeAutoRetouchSheet(
templateCount: Int,
allowsModeSelection: Bool = false,
hasComparison: Bool = true,
configuration: TravelAlbumAutoRetouchConfiguration = TravelAlbumAutoRetouchConfiguration(enabled: true, refinedTemplateId: 1)
) async throws -> (
controller: TravelAlbumAutoRetouchSettingSheetViewController,
viewModel: TravelAlbumAutoRetouchSettingViewModel,
collection: UICollectionView
) {
let api = TravelAlbumMockAPI()
let imageURL = FileManager.default.temporaryDirectory.appendingPathComponent("auto-retouch-\(UUID().uuidString).png")
let imageData = try XCTUnwrap(UIImage(named: "ai_retouch_template_placeholder")?.pngData())
try imageData.write(to: imageURL)
addTeardownBlock { try? FileManager.default.removeItem(at: imageURL) }
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
refinedTemplates: (1 ... templateCount).map {
TravelAlbumAIRetouchTemplate(
id: $0, name: "模板\($0)", previewURL: "",
beforeURL: hasComparison ? imageURL.absoluteString : "",
afterURL: hasComparison ? imageURL.absoluteString : ""
)
}
)
let viewModel = TravelAlbumAutoRetouchSettingViewModel(
scenicId: 18,
configuration: configuration,
allowsModeSelection: allowsModeSelection
)
let controller = TravelAlbumAutoRetouchSettingSheetViewController(viewModel: viewModel, api: api)
controller.loadViewIfNeeded()
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 667)
let collection = try XCTUnwrap(controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.autoRetouchTemplateCollection"
} as? UICollectionView)
await waitUntil { collection.numberOfItems(inSection: 0) == templateCount && !viewModel.isLoading }
controller.view.layoutIfNeeded()
collection.layoutIfNeeded()
XCTAssertEqual(collection.numberOfItems(inSection: 0), templateCount)
return (controller, viewModel, collection)
}
func testAIRetouchTemplateCellExposesSelectedState() { func testAIRetouchTemplateCellExposesSelectedState() {
let cell = TravelAlbumAIRetouchTemplateCell(frame: .zero) let cell = TravelAlbumAIRetouchTemplateCell(frame: .zero)
let template = TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "") let template = TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "")
@@ -953,7 +1283,7 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
XCTAssertEqual(comparisonButton.bounds.size, CGSize(width: 48, height: 48)) XCTAssertEqual(comparisonButton.bounds.size, CGSize(width: 48, height: 48))
} }
func testPreviewAIRetouchUsesSelectedRetouchedTabWorkflowAndNotifiesSubmission() async throws { func testPreviewAIRetouchFromRetouchedTabAllowsBothOptionalTemplates() async throws {
UIView.setAnimationsEnabled(false) UIView.setAnimationsEnabled(false)
defer { UIView.setAnimationsEnabled(true) } defer { UIView.setAnimationsEnabled(true) }
let api = TravelAlbumMockAPI() let api = TravelAlbumMockAPI()
@@ -1020,7 +1350,16 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchConfirmButton" $0.accessibilityIdentifier == "travelAlbum.aiRetouchConfirmButton"
} as? UIButton } as? UIButton
) )
await waitUntil { api.aiRetouchTemplateScenicIds.count == 1 && confirmButton.isEnabled } let collectionView = try XCTUnwrap(
sheet.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplateCollection"
} as? UICollectionView
)
await waitUntil {
api.aiRetouchTemplateScenicIds.count == 1 &&
collectionView.numberOfSections > 2 &&
collectionView.numberOfItems(inSection: 2) == 1
}
sheet.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) sheet.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
sheet.view.layoutIfNeeded() sheet.view.layoutIfNeeded()
@@ -1028,7 +1367,11 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
label.isHidden ? nil : label.text label.isHidden ? nil : label.text
} }
XCTAssertTrue(visibleLabelTexts.contains("原图精修")) XCTAssertTrue(visibleLabelTexts.contains("原图精修"))
XCTAssertFalse(visibleLabelTexts.contains { $0.contains("氛围感修图") }) XCTAssertTrue(visibleLabelTexts.contains("氛围感修图"))
let accessibleLabels = sheet.view.allAccessibilityLabels()
XCTAssertTrue(accessibleLabels.contains("原图精修,选填"))
XCTAssertTrue(accessibleLabels.contains("氛围感修图,选填"))
XCTAssertFalse(confirmButton.isEnabled)
let selectionCountLabel = try XCTUnwrap( let selectionCountLabel = try XCTUnwrap(
sheet.view.findSubview { sheet.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchSelectionCountLabel" $0.accessibilityIdentifier == "travelAlbum.aiRetouchSelectionCountLabel"
@@ -1047,11 +1390,25 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
XCTAssertEqual(selectionCountLabel.text, "已选择 1 张照片") XCTAssertEqual(selectionCountLabel.text, "已选择 1 张照片")
XCTAssertTrue(tipsContainer.isHidden) XCTAssertTrue(tipsContainer.isHidden)
XCTAssertTrue(tipsLabel.isHidden) XCTAssertTrue(tipsLabel.isHidden)
collectionView.delegate?.collectionView?(
collectionView,
didSelectItemAt: IndexPath(item: 0, section: 2)
)
await waitUntil { confirmButton.isEnabled }
confirmButton.sendActions(for: .touchUpInside) confirmButton.sendActions(for: .touchUpInside)
await waitUntil { api.aiReretouchRequests.count == 1 } await waitUntil { api.aiReretouchRequests.count == 1 }
await waitUntil { submissionNotified } await waitUntil { submissionNotified }
XCTAssertEqual(api.aiReretouchRequests.first?.type, .refined) XCTAssertEqual(
api.aiReretouchRequests.first,
TravelAlbumAIReretouchRequest(
id: 7,
aiRetouchBatchId: 70,
type: .all,
refinedTemplateId: nil,
atmosphereTemplateId: 21
)
)
XCTAssertTrue(submissionNotified) XCTAssertTrue(submissionNotified)
XCTAssertTrue(window.rootViewController === controller) XCTAssertTrue(window.rootViewController === controller)
} }
@@ -1149,9 +1506,8 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
XCTAssertFalse(controller.view.allLabels().contains { $0.text == "刷新失败" }) XCTAssertFalse(controller.view.allLabels().contains { $0.text == "刷新失败" })
} }
func testMaterialCellShowsSemanticStatusBadgeWithoutOverlappingSelectionCheck() throws { func testMaterialCellShowsIndependentPurchaseAndAIRetouchBadgesWithoutOverlappingSelectionCheck() throws {
let cases: [(TravelAlbumMaterial, String, UInt)] = [ let cases: [(TravelAlbumMaterial, String, UInt)] = [
(TravelAlbumMaterial(isPurchased: true), "已购", 0x475569),
(TravelAlbumMaterial(aiRetouchStatus: 1), "待处理", 0xB45309), (TravelAlbumMaterial(aiRetouchStatus: 1), "待处理", 0xB45309),
(TravelAlbumMaterial(aiRetouchStatus: 2), "修图中", 0x1D4ED8), (TravelAlbumMaterial(aiRetouchStatus: 2), "修图中", 0x1D4ED8),
(TravelAlbumMaterial(aiRetouchStatus: 3), "AI已修", 0x047857), (TravelAlbumMaterial(aiRetouchStatus: 3), "AI已修", 0x047857),
@@ -1170,12 +1526,12 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
let badgeView = try XCTUnwrap( let badgeView = try XCTUnwrap(
cell.findSubview { cell.findSubview {
$0.accessibilityIdentifier == "travelAlbum.materialStatusBadge" $0.accessibilityIdentifier == "travelAlbum.materialAIRetouchBadge"
} }
) )
let badgeLabel = try XCTUnwrap( let badgeLabel = try XCTUnwrap(
cell.findSubview { cell.findSubview {
$0.accessibilityIdentifier == "travelAlbum.materialStatusBadgeLabel" $0.accessibilityIdentifier == "travelAlbum.materialAIRetouchBadgeLabel"
} as? UILabel } as? UILabel
) )
let selectionCheck = try XCTUnwrap( let selectionCheck = try XCTUnwrap(
@@ -1188,22 +1544,166 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
XCTAssertEqual(badgeLabel.text, expectedText) XCTAssertEqual(badgeLabel.text, expectedText)
XCTAssertEqual(badgeView.backgroundColor?.travelAlbumTestHexRGB, expectedColor) XCTAssertEqual(badgeView.backgroundColor?.travelAlbumTestHexRGB, expectedColor)
XCTAssertLessThan(badgeView.frame.maxX, selectionCheck.frame.minX) XCTAssertLessThan(badgeView.frame.maxX, selectionCheck.frame.minX)
XCTAssertTrue(cell.accessibilityLabel?.contains("状态:\(expectedText)") == true) XCTAssertTrue(cell.accessibilityLabel?.contains("修图状态:\(expectedText)") == true)
} }
let combinedCell = TravelAlbumMaterialCell(frame: CGRect(x: 0, y: 0, width: 176, height: 220))
combinedCell.apply(
material: TravelAlbumMaterial(isPurchased: true, aiRetouchStatus: 2),
selectionMode: true,
selected: false
)
combinedCell.layoutIfNeeded()
let purchaseBadge = try XCTUnwrap(
combinedCell.findSubview {
$0.accessibilityIdentifier == "travelAlbum.materialPurchaseBadge"
}
)
let purchaseLabel = try XCTUnwrap(
combinedCell.findSubview {
$0.accessibilityIdentifier == "travelAlbum.materialPurchaseBadgeLabel"
} as? UILabel
)
let aiRetouchBadge = try XCTUnwrap(
combinedCell.findSubview {
$0.accessibilityIdentifier == "travelAlbum.materialAIRetouchBadge"
}
)
XCTAssertFalse(purchaseBadge.isHidden)
XCTAssertFalse(aiRetouchBadge.isHidden)
XCTAssertEqual(purchaseLabel.text, "已购")
XCTAssertEqual(purchaseBadge.backgroundColor?.travelAlbumTestHexRGB, 0x475569)
XCTAssertGreaterThan(purchaseBadge.frame.minY, aiRetouchBadge.frame.maxY)
XCTAssertEqual(purchaseBadge.frame.minX, 6, accuracy: 0.5)
XCTAssertEqual(purchaseBadge.frame.maxY, 170, accuracy: 0.5)
XCTAssertTrue(combinedCell.accessibilityLabel?.contains("购买状态:已购") == true)
XCTAssertTrue(combinedCell.accessibilityLabel?.contains("修图状态:修图中") == true)
let hiddenCell = TravelAlbumMaterialCell(frame: CGRect(x: 0, y: 0, width: 176, height: 220)) let hiddenCell = TravelAlbumMaterialCell(frame: CGRect(x: 0, y: 0, width: 176, height: 220))
hiddenCell.apply( hiddenCell.apply(
material: TravelAlbumMaterial(aiRetouchStatus: 0, aiRetouchStatusName: "已上传"), material: TravelAlbumMaterial(aiRetouchStatus: 0, aiRetouchStatusName: "已上传"),
selectionMode: false, selectionMode: false,
selected: false selected: false
) )
let hiddenBadge = try XCTUnwrap( let hiddenPurchaseBadge = try XCTUnwrap(
hiddenCell.findSubview { hiddenCell.findSubview {
$0.accessibilityIdentifier == "travelAlbum.materialStatusBadge" $0.accessibilityIdentifier == "travelAlbum.materialPurchaseBadge"
} }
) )
XCTAssertTrue(hiddenBadge.isHidden) let hiddenAIRetouchBadge = try XCTUnwrap(
XCTAssertFalse(hiddenCell.accessibilityLabel?.contains("状态:") == true) hiddenCell.findSubview {
$0.accessibilityIdentifier == "travelAlbum.materialAIRetouchBadge"
}
)
XCTAssertTrue(hiddenPurchaseBadge.isHidden)
XCTAssertTrue(hiddenAIRetouchBadge.isHidden)
XCTAssertFalse(hiddenCell.accessibilityLabel?.contains("购买状态:") == true)
XCTAssertFalse(hiddenCell.accessibilityLabel?.contains("修图状态:") == true)
}
func testWiredTransferProgressUsesOnlyUploadStatusAndRealProgress() throws {
let cell = WiredTransferPhotoCell(frame: CGRect(x: 0, y: 0, width: 300, height: 66))
let progressView = try XCTUnwrap(cell.findSubview {
$0.accessibilityIdentifier == "wiredTransfer.uploadProgress"
} as? UIProgressView)
let uploadStates: [TravelAlbumOTGUploadStatus] = [.pending, .transferring, .uploading, .uploaded, .failed]
let retouchStates: [TravelAlbumAutoRetouchState] = [.none, .pendingSubmission, .submitting, .processing, .completed, .failed]
for uploadState in uploadStates {
for retouchState in retouchStates {
var item = makeWiredTransferPhotoItem(state: retouchState)
item.status = uploadState
item.progress = uploadState == .uploaded ? 100 : 37
cell.apply(item: item, selectionMode: false, selected: false)
XCTAssertEqual(progressView.isHidden, uploadState != .transferring && uploadState != .uploading)
XCTAssertEqual(progressView.progress, Float(item.progress) / 100, accuracy: 0.001)
}
}
}
func testWiredTransferRetouchBadgeShowsStateColorsAndKeepsCircularThumbnailLayout() throws {
let cases: [(TravelAlbumAutoRetouchState, String, UIColor)] = [
(.pendingSubmission, "修图中", UIColor(hex: 0x7C3AED)),
(.submitting, "修图中", UIColor(hex: 0x7C3AED)),
(.processing, "修图中", UIColor(hex: 0x7C3AED)),
(.completed, "修图成功", UIColor(hex: 0x047857)),
(.failed, "修图失败", AppColor.danger),
]
let cell = WiredTransferPhotoCell(frame: CGRect(x: 0, y: 0, width: 300, height: 66))
let badge = try XCTUnwrap(cell.findSubview {
$0.accessibilityIdentifier == "wiredTransfer.retouchBadge"
} as? UILabel)
let thumbnail = try XCTUnwrap(cell.findSubview {
$0.accessibilityIdentifier == "wiredTransfer.thumbnail"
})
for (state, expectedStatus, expectedColor) in cases {
cell.apply(item: makeWiredTransferPhotoItem(state: state), selectionMode: false, selected: false)
cell.layoutIfNeeded()
XCTAssertFalse(badge.isHidden)
XCTAssertEqual(badge.text, "修")
XCTAssertEqual(badge.backgroundColor?.travelAlbumTestHexRGB, expectedColor.travelAlbumTestHexRGB)
XCTAssertEqual(badge.textColor, .white)
XCTAssertEqual(badge.frame.size, CGSize(width: 16, height: 16))
// Auto Layout 对齐 alignmentRect,系统符号占位图的 frame 可能带有留白。
let badgeAlignmentRect = badge.alignmentRect(forFrame: badge.frame)
let thumbnailAlignmentRect = thumbnail.alignmentRect(forFrame: thumbnail.frame)
XCTAssertEqual(badgeAlignmentRect.minY, thumbnailAlignmentRect.minY + 2, accuracy: 0.1)
XCTAssertEqual(badgeAlignmentRect.maxX, thumbnailAlignmentRect.maxX - 2, accuracy: 0.1)
XCTAssertEqual(badge.layer.cornerRadius, 8)
XCTAssertEqual(badge.accessibilityLabel, expectedStatus)
XCTAssertTrue(cell.accessibilityLabel?.contains("修图状态:\(expectedStatus)") == true)
}
cell.prepareForReuse()
cell.apply(item: makeWiredTransferPhotoItem(state: .none), selectionMode: false, selected: false)
XCTAssertTrue(badge.isHidden)
XCTAssertNil(badge.accessibilityLabel)
XCTAssertFalse(cell.accessibilityLabel?.contains("修图状态:") == true)
}
func testWiredTransferUploadLabelIgnoresAllRetouchStates() throws {
let cell = WiredTransferPhotoCell(frame: CGRect(x: 0, y: 0, width: 300, height: 66))
let label = try XCTUnwrap(cell.findSubview {
$0.accessibilityIdentifier == "wiredTransfer.uploadStatus"
} as? UILabel)
let successColor = UIColor(hex: 0x16A34A)
let cases: [(TravelAlbumOTGUploadStatus, String, UIColor, UIColor)] = [
(.pending, "待上传", AppColor.textSecondary, AppColor.pageBackground),
(.transferring, "传输中", AppColor.primary, AppColor.primary.withAlphaComponent(0.12)),
(.uploading, "上传中", AppColor.primary, AppColor.primary.withAlphaComponent(0.12)),
(.uploaded, "已上传", successColor, successColor.withAlphaComponent(0.12)),
(.failed, "上传失败", AppColor.danger, AppColor.danger.withAlphaComponent(0.12)),
]
let retouchStates: [TravelAlbumAutoRetouchState] = [
.none, .pendingSubmission, .submitting, .processing, .completed, .failed,
]
for (uploadStatus, text, foreground, background) in cases {
for retouchState in retouchStates {
var item = makeWiredTransferPhotoItem(state: retouchState)
item.status = uploadStatus
cell.apply(item: item, selectionMode: false, selected: false)
XCTAssertFalse(label.isHidden)
XCTAssertEqual(label.text, text)
XCTAssertEqual(label.textColor, foreground)
XCTAssertEqual(label.backgroundColor, background)
XCTAssertTrue(cell.accessibilityLabel?.hasPrefix("photo.JPG,\(text)") == true)
}
}
}
private func makeWiredTransferPhotoItem(state: TravelAlbumAutoRetouchState) -> TravelAlbumOTGPhotoItem {
TravelAlbumOTGPhotoItem(
id: "photo", sourceId: "photo", fileName: "photo.JPG", thumbnailURL: nil,
capturedAt: "2026-08-27 12:00:00", fileSizeText: "1 KB", fileSizeBytes: 1024,
status: .uploaded, progress: 100, errorMessage: nil, localPath: "", remoteUrl: "",
autoRetouchState: state
)
} }
private func waitUntil(_ condition: @escaping () -> Bool) async { private func waitUntil(_ condition: @escaping () -> Bool) async {
+30 -16
View File
@@ -268,7 +268,7 @@ final class TravelAlbumModelsTests: XCTestCase {
) )
} }
func testPreviewProjectMapsCurrentTabToMinimalAIRetouchWorkflow() { func testPreviewProjectUsesAllReretouchWorkflowForEveryVisibleVariantTab() {
let originalOnly = makePreviewProject(id: 9, kinds: [.original]) let originalOnly = makePreviewProject(id: 9, kinds: [.original])
let variants = TravelAlbumPreviewProject( let variants = TravelAlbumPreviewProject(
originalMaterialId: 10, originalMaterialId: 10,
@@ -286,11 +286,11 @@ final class TravelAlbumModelsTests: XCTestCase {
) )
XCTAssertEqual( XCTAssertEqual(
variants.aiRetouchWorkflow(albumId: 7, selectedKind: .retouched), variants.aiRetouchWorkflow(albumId: 7, selectedKind: .retouched),
.reretouch(materialId: 10, batchId: 88, type: .refined) .reretouch(materialId: 10, batchId: 88, type: .all)
) )
XCTAssertEqual( XCTAssertEqual(
variants.aiRetouchWorkflow(albumId: 7, selectedKind: .atmosphere), variants.aiRetouchWorkflow(albumId: 7, selectedKind: .atmosphere),
.reretouch(materialId: 10, batchId: 88, type: .atmosphere) .reretouch(materialId: 10, batchId: 88, type: .all)
) )
XCTAssertNil(variants.aiRetouchWorkflow(albumId: 7, selectedKind: .cover)) XCTAssertNil(variants.aiRetouchWorkflow(albumId: 7, selectedKind: .cover))
} }
@@ -399,34 +399,48 @@ final class TravelAlbumModelsTests: XCTestCase {
XCTAssertEqual(material.aiAtmosphereURL, "") XCTAssertEqual(material.aiAtmosphereURL, "")
} }
func testTravelAlbumMaterialBadgePriorityAndFallbacks() { func testTravelAlbumMaterialPurchaseAndAIRetouchBadgesAreIndependent() {
XCTAssertNil(TravelAlbumMaterial(aiRetouchStatus: 0).badgePresentation) XCTAssertNil(TravelAlbumMaterial().purchaseBadgePresentation)
XCTAssertEqual( XCTAssertEqual(
TravelAlbumMaterial(isPurchased: true, aiRetouchStatus: 0, aiRetouchStatusName: "已上传") TravelAlbumMaterial(isPurchased: true).purchaseBadgePresentation,
.badgePresentation, TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
)
XCTAssertNil(TravelAlbumMaterial(isPurchased: true).aiRetouchBadgePresentation)
let purchasedAndProcessing = TravelAlbumMaterial(isPurchased: true, aiRetouchStatus: 2)
XCTAssertEqual(
purchasedAndProcessing.purchaseBadgePresentation,
TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购") TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
) )
XCTAssertEqual( XCTAssertEqual(
TravelAlbumMaterial(aiRetouchStatus: 1, aiRetouchStatusName: " 排队中 ").badgePresentation, purchasedAndProcessing.aiRetouchBadgePresentation,
TravelAlbumMaterialBadgePresentation(kind: .pending, text: "排队中")
)
XCTAssertEqual(
TravelAlbumMaterial(aiRetouchStatus: 2, aiRetouchStatusName: " ").badgePresentation,
TravelAlbumMaterialBadgePresentation(kind: .processing, text: "修图中") TravelAlbumMaterialBadgePresentation(kind: .processing, text: "修图中")
) )
XCTAssertEqual( XCTAssertEqual(
TravelAlbumMaterial(aiRetouchStatus: 3).badgePresentation, TravelAlbumMaterial(aiRetouchStatus: 1, aiRetouchStatusName: " 排队中 ")
.aiRetouchBadgePresentation,
TravelAlbumMaterialBadgePresentation(kind: .pending, text: "排队中")
)
XCTAssertEqual(
TravelAlbumMaterial(aiRetouchStatus: 2, aiRetouchStatusName: " ").aiRetouchBadgePresentation,
TravelAlbumMaterialBadgePresentation(kind: .processing, text: "修图中")
)
XCTAssertEqual(
TravelAlbumMaterial(aiRetouchStatus: 3).aiRetouchBadgePresentation,
TravelAlbumMaterialBadgePresentation(kind: .retouched, text: "AI已修") TravelAlbumMaterialBadgePresentation(kind: .retouched, text: "AI已修")
) )
XCTAssertEqual( XCTAssertEqual(
TravelAlbumMaterial(aiRetouchStatus: 3, aiRetouchStatusName: "AI封面").badgePresentation, TravelAlbumMaterial(aiRetouchStatus: 3, aiRetouchStatusName: "AI封面")
.aiRetouchBadgePresentation,
TravelAlbumMaterialBadgePresentation(kind: .cover, text: "AI封面") TravelAlbumMaterialBadgePresentation(kind: .cover, text: "AI封面")
) )
XCTAssertEqual( XCTAssertEqual(
TravelAlbumMaterial(aiRetouchStatus: 4).badgePresentation, TravelAlbumMaterial(aiRetouchStatus: 4).aiRetouchBadgePresentation,
TravelAlbumMaterialBadgePresentation(kind: .failed, text: "失败") TravelAlbumMaterialBadgePresentation(kind: .failed, text: "失败")
) )
XCTAssertNil(TravelAlbumMaterial(aiRetouchStatus: 99, aiRetouchStatusName: "未知").badgePresentation) XCTAssertNil(
TravelAlbumMaterial(aiRetouchStatus: 99, aiRetouchStatusName: "未知")
.aiRetouchBadgePresentation
)
} }
func testDisplayFormatters() { func testDisplayFormatters() {
+814 -7
View File
@@ -3,6 +3,7 @@
// suixinkanTests // suixinkanTests
// //
import UIKit
import XCTest import XCTest
@testable import suixinkan @testable import suixinkan
@@ -279,14 +280,22 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
XCTAssertEqual(TravelAlbumDisplayFormatter.creationTimeText(""), "--") XCTAssertEqual(TravelAlbumDisplayFormatter.creationTimeText(""), "--")
} }
func testOnlyUnpurchasedMaterialCanBeSelected() { func testAllMaterialsCanBeSelectedAndPurchasedTabSupportsSelectionMode() async {
let api = TravelAlbumMockAPI()
api.materialListResponses = [TravelAlbumListResponse(total: 1, list: [])]
let viewModel = TravelAlbumDetailViewModel(albumId: 2) let viewModel = TravelAlbumDetailViewModel(albumId: 2)
await viewModel.selectTab(.purchased, api: api)
viewModel.toggleSelectionMode() viewModel.toggleSelectionMode()
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, status: 2)) viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, isPurchased: true))
XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty) viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 2, isPurchased: false))
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, status: 1)) XCTAssertTrue(viewModel.isSelectionMode)
XCTAssertEqual(viewModel.selectedMaterialIds, [1]) XCTAssertEqual(viewModel.selectedMaterialIds, [1, 2])
XCTAssertEqual(viewModel.selectedPurchasedMaterialCount, 1)
XCTAssertEqual(
viewModel.deleteSelectedConfirmationMessage,
"已选择 2 张素材,其中 1 张已购买。不会删除已购买的项目,只会删除 1 张未购买的项目。"
)
} }
func testCompletingAIRetouchClearsSelectionModeAndSelectedMaterials() { func testCompletingAIRetouchClearsSelectionModeAndSelectedMaterials() {
@@ -405,11 +414,155 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
XCTAssertFalse(viewModel.isSelectionMode) XCTAssertFalse(viewModel.isSelectionMode)
XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty) XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty)
} }
func testDeleteSelectedMaterialsFiltersPurchasedItemsFromBatchRequest() async {
let api = TravelAlbumMockAPI()
api.materialListResponses = [
TravelAlbumListResponse(total: 0, list: []),
TravelAlbumListResponse(total: 0, list: []),
TravelAlbumListResponse(total: 0, list: []),
]
let viewModel = TravelAlbumDetailViewModel(albumId: 5)
viewModel.toggleSelectionMode()
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 10, isPurchased: true))
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 11, isPurchased: false))
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 12, isPurchased: true))
await viewModel.deleteSelectedMaterials(api: api)
XCTAssertEqual(api.deletedMaterialIDBatches, [[11]])
XCTAssertFalse(viewModel.isSelectionMode)
XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty)
}
func testDeleteSelectedMaterialsDoesNotRequestAPIWhenAllSelectedItemsArePurchased() async {
let api = TravelAlbumMockAPI()
let viewModel = TravelAlbumDetailViewModel(albumId: 5)
var message = ""
viewModel.onShowMessage = { message = $0 }
viewModel.toggleSelectionMode()
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 10, isPurchased: true))
await viewModel.deleteSelectedMaterials(api: api)
XCTAssertTrue(api.deletedMaterialIDBatches.isEmpty)
XCTAssertEqual(message, "选中的素材均已购买,未删除任何项目")
XCTAssertTrue(viewModel.isSelectionMode)
XCTAssertEqual(viewModel.selectedMaterialIds, [10])
}
}
/// 自动修图配置 ViewModel 测试。
@MainActor
final class TravelAlbumAutoRetouchSettingViewModelTests: XCTestCase {
func testModeToggleRetainsDraftButDisabledSubmissionOmitsTemplate() async {
let api = TravelAlbumMockAPI()
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(refinedTemplates: [
TravelAlbumAIRetouchTemplate(id: 12, name: "清透", previewURL: ""),
])
let viewModel = TravelAlbumAutoRetouchSettingViewModel(scenicId: 18, configuration: .disabled, allowsModeSelection: true)
await viewModel.loadTemplates(api: api)
viewModel.selectMode(enabled: true)
XCTAssertFalse(viewModel.canConfirm)
viewModel.selectTemplate(id: 12)
viewModel.selectMode(enabled: false)
XCTAssertEqual(viewModel.pendingConfiguration, .disabled)
XCTAssertEqual(viewModel.selectedTemplateId, 12)
viewModel.selectMode(enabled: true)
XCTAssertEqual(viewModel.pendingConfiguration?.refinedTemplateId, 12)
XCTAssertEqual(viewModel.selectedTemplateName, "清透")
XCTAssertTrue(viewModel.canConfirm)
}
func testOriginalModeRemainsConfirmableDuringTemplateLoadingAndFailure() async {
let api = TravelAlbumMockAPI()
api.aiRetouchTemplatesError = APIError.httpStatus(500, "模板服务不可用")
let viewModel = TravelAlbumAutoRetouchSettingViewModel(scenicId: 18, configuration: .disabled, allowsModeSelection: true)
var couldConfirmWhileLoading = false
viewModel.onStateChange = {
if viewModel.isLoading { couldConfirmWhileLoading = viewModel.canConfirm }
}
await viewModel.loadTemplates(api: api)
viewModel.onStateChange = nil
XCTAssertTrue(couldConfirmWhileLoading)
XCTAssertTrue(viewModel.canConfirm)
XCTAssertEqual(viewModel.pendingConfiguration, .disabled)
viewModel.selectMode(enabled: true)
XCTAssertFalse(viewModel.canConfirm)
}
func testTemplateOnlyEntryRequiresAISelectionAndCannotDisable() {
let viewModel = TravelAlbumAutoRetouchSettingViewModel(scenicId: 18, configuration: .disabled, allowsModeSelection: false)
XCTAssertTrue(viewModel.isEnabled)
XCTAssertFalse(viewModel.canConfirm)
XCTAssertNil(viewModel.pendingConfiguration)
viewModel.selectMode(enabled: false)
XCTAssertTrue(viewModel.isEnabled)
}
func testLoadsOnlyRefinedTemplatesAndKeepsSingleSelection() async {
let api = TravelAlbumMockAPI()
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
refinedTemplates: [
TravelAlbumAIRetouchTemplate(id: 11, name: "清透", previewURL: "https://cdn/11.jpg"),
TravelAlbumAIRetouchTemplate(id: 12, name: "质感", previewURL: "https://cdn/12.jpg"),
],
atmosphereTemplates: [
TravelAlbumAIRetouchTemplate(id: 21, name: "暖阳", previewURL: "https://cdn/21.jpg"),
],
coverTemplates: []
)
let viewModel = TravelAlbumAutoRetouchSettingViewModel(
scenicId: 18,
configuration: .disabled,
allowsModeSelection: true
)
viewModel.selectMode(enabled: true)
await viewModel.loadTemplates(api: api)
viewModel.selectTemplate(id: 11)
viewModel.selectTemplate(id: 12)
XCTAssertEqual(viewModel.templates.map(\.id), [11, 12])
XCTAssertEqual(viewModel.selectedTemplateId, 12)
XCTAssertEqual(viewModel.pendingConfiguration?.refinedTemplateId, 12)
XCTAssertEqual(api.aiRetouchTemplateScenicIds, [18])
}
func testTemplateLoadFailureKeepsConfigurationInvalidForRetry() async {
let api = TravelAlbumMockAPI()
api.aiRetouchTemplatesError = APIError.httpStatus(500, "模板服务不可用")
let viewModel = TravelAlbumAutoRetouchSettingViewModel(
scenicId: 18,
configuration: TravelAlbumAutoRetouchConfiguration(enabled: true, refinedTemplateId: 12),
allowsModeSelection: false
)
await viewModel.loadTemplates(api: api)
XCTAssertTrue(viewModel.templates.isEmpty)
XCTAssertNil(viewModel.pendingConfiguration)
XCTAssertFalse(viewModel.errorMessage?.isEmpty ?? true)
}
} }
/// 有线传输 ViewModel 测试。 /// 有线传输 ViewModel 测试。
@MainActor @MainActor
final class WiredCameraTransferViewModelTests: XCTestCase { final class WiredCameraTransferViewModelTests: XCTestCase {
func testLegacyOTGRecordDefaultsAutomaticRetouchMetadata() throws {
let data = Data(
#"{"id":"legacy","sourceId":"legacy","clientPhotoId":"client-id","fileName":"A.JPG","localPath":"originals/A.JPG","thumbnailPath":"","capturedAt":"2026-08-26 12:00:00","fileSizeBytes":1024,"status":"UPLOADED","progress":100,"albumId":9,"userId":"u1","remoteUrl":"https://cdn/A.JPG","updatedAt":1}"#.utf8
)
let record = try JSONDecoder().decode(TravelAlbumOTGPhotoRecord.self, from: data)
XCTAssertEqual(record.serverMaterialId, 0)
XCTAssertEqual(record.autoRetouchState, .none)
XCTAssertNil(record.autoRetouchTemplateId)
XCTAssertTrue(record.autoRetouchClientRequestId.isEmpty)
XCTAssertEqual(record.autoRetouchBatchId, 0)
}
func testDefaultDisconnectedStateAndOptions() { func testDefaultDisconnectedStateAndOptions() {
let viewModel = WiredCameraTransferViewModel( let viewModel = WiredCameraTransferViewModel(
albumId: 1, albumId: 1,
@@ -793,6 +946,635 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
XCTAssertEqual(api.materialClientPhotoIdsCallCount, 1) XCTAssertEqual(api.materialClientPhotoIdsCallCount, 1)
} }
func testOriginalUploadDoesNotSubmitAIRetouchWhenConfigurationIsDisabled() async throws {
let context = makeOTGTestContext()
let uploader = MockTravelAlbumOTGUploader()
let api = TravelAlbumMockAPI()
let record = try makePersistedOTGRecord(
context: context,
id: "without-retouch",
capturedAt: "2026-08-26 12:00:00",
status: .pending
)
context.store.save([record], albumId: 9)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader,
api: api,
initialAutoRetouchConfiguration: .disabled
)
viewModel.reloadLocalPhotos()
viewModel.retryPhoto(photoId: record.id)
await waitUntil { context.store.load(albumId: 9).first?.status == .uploaded }
XCTAssertEqual(uploader.uploadCallCount, 1)
XCTAssertTrue(api.aiRetouchRequests.isEmpty)
XCTAssertEqual(context.store.load(albumId: 9).first?.autoRetouchState, TravelAlbumAutoRetouchState.none)
}
func testEnabledConfigurationSubmitsOneIdempotentAIRetouchAfterOriginalUpload() async throws {
let context = makeOTGTestContext()
let uploader = MockTravelAlbumOTGUploader()
uploader.materialIds = [77]
let api = TravelAlbumMockAPI()
api.aiJobSubmission = TravelAlbumAIJobSubmission(
aiRetouchBatchId: 901,
userEquityTravelId: 9,
status: .queued,
progress: TravelAlbumAIJobProgress(
total: 1,
queued: 1,
processing: 0,
succeeded: 0,
failed: 0,
canceled: 0
),
createdAt: "2026-08-26T12:00:00Z"
)
let record = try makePersistedOTGRecord(
context: context,
id: "automatic-retouch",
capturedAt: "2026-08-26 12:00:00",
status: .pending
)
context.store.save([record], albumId: 9)
let configuration = TravelAlbumAutoRetouchConfiguration(
enabled: true,
refinedTemplateId: 12
)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader,
api: api,
initialAutoRetouchConfiguration: configuration
)
viewModel.reloadLocalPhotos()
viewModel.retryPhoto(photoId: record.id)
await waitUntil { api.aiRetouchRequests.count == 1 }
let request = try XCTUnwrap(api.aiRetouchRequests.first)
XCTAssertEqual(uploader.uploadCallCount, 1)
XCTAssertEqual(request.materialIds, [77])
XCTAssertEqual(request.refinedTemplateId, 12)
XCTAssertEqual(request.clientRequestId, "auto-9-77-tpl12-a0")
let persisted = try XCTUnwrap(context.store.load(albumId: 9).first)
XCTAssertEqual(persisted.status, .uploaded)
XCTAssertEqual(persisted.progress, 100)
XCTAssertEqual(persisted.serverMaterialId, 77)
XCTAssertEqual(persisted.autoRetouchBatchId, 901)
XCTAssertEqual(persisted.autoRetouchState, .processing)
}
func testAIRetouchSubmissionFailureKeepsUploadAndRetryDoesNotUploadOriginalAgain() async throws {
let context = makeOTGTestContext()
let uploader = MockTravelAlbumOTGUploader()
uploader.materialIds = [88]
let api = TravelAlbumMockAPI()
api.submitAIRetouchError = APIError.serverCode(190001, "AI 修图额度不足")
let record = try makePersistedOTGRecord(
context: context,
id: "retry-retouch",
capturedAt: "2026-08-26 12:00:00",
status: .pending
)
context.store.save([record], albumId: 9)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader,
api: api,
initialAutoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration(
enabled: true,
refinedTemplateId: 19
)
)
viewModel.reloadLocalPhotos()
viewModel.retryPhoto(photoId: record.id)
await waitUntil { context.store.load(albumId: 9).first?.autoRetouchState == .failed }
let firstRequestId = api.aiRetouchRequests.first?.clientRequestId
XCTAssertEqual(context.store.load(albumId: 9).first?.status, .uploaded)
XCTAssertEqual(context.store.load(albumId: 9).first?.progress, 100)
api.submitAIRetouchError = nil
viewModel.retryAutoRetouch(photoId: record.id)
await waitUntil { api.aiRetouchRequests.count == 2 }
XCTAssertEqual(uploader.uploadCallCount, 1)
XCTAssertEqual(api.aiRetouchRequests.last?.clientRequestId, firstRequestId)
XCTAssertEqual(context.store.load(albumId: 9).first?.status, .uploaded)
XCTAssertEqual(context.store.load(albumId: 9).first?.progress, 100)
}
func testAmbiguousNetworkFailureStaysPendingAndForegroundResumeReusesRequestId() async throws {
let context = makeOTGTestContext()
let uploader = MockTravelAlbumOTGUploader()
uploader.materialIds = [89]
let api = TravelAlbumMockAPI()
api.submitAIRetouchError = APIError.networkFailed("连接中断")
let record = try makePersistedOTGRecord(
context: context,
id: "ambiguous-retouch",
capturedAt: "2026-08-26 12:00:00",
status: .pending
)
context.store.save([record], albumId: 9)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader,
api: api,
initialAutoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration(
enabled: true,
refinedTemplateId: 20
)
)
viewModel.reloadLocalPhotos()
viewModel.retryPhoto(photoId: record.id)
await waitUntil { context.store.load(albumId: 9).first?.autoRetouchState == .pendingSubmission }
let firstRequestId = try XCTUnwrap(api.aiRetouchRequests.first?.clientRequestId)
api.submitAIRetouchError = nil
viewModel.applicationDidBecomeActive()
await waitUntil { api.aiRetouchRequests.count == 2 }
XCTAssertEqual(uploader.uploadCallCount, 1)
XCTAssertEqual(api.aiRetouchRequests.last?.clientRequestId, firstRequestId)
XCTAssertEqual(context.store.load(albumId: 9).first?.autoRetouchState, .processing)
}
func testTerminalTaskFailureRetryCreatesNewAttemptIdentifierWithoutReupload() async throws {
let context = makeOTGTestContext()
let uploader = MockTravelAlbumOTGUploader()
uploader.materialIds = [90]
let api = TravelAlbumMockAPI()
api.aiJobSubmission = TravelAlbumAIJobSubmission(
aiRetouchBatchId: 902,
userEquityTravelId: 9,
status: .failed,
progress: TravelAlbumAIJobProgress(
total: 1,
queued: 0,
processing: 0,
succeeded: 0,
failed: 1,
canceled: 0
),
createdAt: "2026-08-26T12:00:00Z"
)
let record = try makePersistedOTGRecord(
context: context,
id: "terminal-retouch",
capturedAt: "2026-08-26 12:00:00",
status: .pending
)
context.store.save([record], albumId: 9)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader,
api: api,
initialAutoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration(
enabled: true,
refinedTemplateId: 22
)
)
viewModel.reloadLocalPhotos()
viewModel.retryPhoto(photoId: record.id)
await waitUntil { context.store.load(albumId: 9).first?.autoRetouchState == .failed }
let firstRequestId = api.aiRetouchRequests.first?.clientRequestId
api.aiJobSubmission = TravelAlbumAIJobSubmission(
aiRetouchBatchId: 903,
userEquityTravelId: 9,
status: .queued,
progress: TravelAlbumAIJobProgress(
total: 1,
queued: 1,
processing: 0,
succeeded: 0,
failed: 0,
canceled: 0
),
createdAt: "2026-08-26T12:01:00Z"
)
viewModel.retryAutoRetouch(photoId: record.id)
await waitUntil { api.aiRetouchRequests.count == 2 }
XCTAssertEqual(uploader.uploadCallCount, 1)
XCTAssertNotEqual(api.aiRetouchRequests.last?.clientRequestId, firstRequestId)
XCTAssertEqual(api.aiRetouchRequests.last?.clientRequestId, "auto-9-90-tpl22-a1")
}
func testConfigurationChangeOnlyAffectsPhotosWhoseUploadHasNotStarted() async throws {
let context = makeOTGTestContext()
let uploader = MockTravelAlbumOTGUploader()
uploader.materialIds = [101, 102]
let api = TravelAlbumMockAPI()
let records = [
try makePersistedOTGRecord(
context: context,
id: "first-snapshot",
capturedAt: "2026-08-26 12:00:00",
status: .pending
),
try makePersistedOTGRecord(
context: context,
id: "second-snapshot",
capturedAt: "2026-08-26 12:01:00",
status: .pending
),
]
context.store.save(records, albumId: 9)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader,
api: api,
initialAutoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration(
enabled: true,
refinedTemplateId: 11
)
)
viewModel.reloadLocalPhotos()
viewModel.retryPhoto(photoId: records[0].id)
await waitUntil { api.aiRetouchRequests.count == 1 }
api.autoRetouchConfigurationResponse = TravelAlbumAutoRetouchConfiguration(
enabled: true,
refinedTemplateId: 12
)
await viewModel.updateAutoRetouchConfiguration(
TravelAlbumAutoRetouchConfiguration(enabled: true, refinedTemplateId: 12)
)
viewModel.retryPhoto(photoId: records[1].id)
await waitUntil { api.aiRetouchRequests.count == 2 }
XCTAssertEqual(api.aiRetouchRequests.map(\.refinedTemplateId), [11, 12])
XCTAssertEqual(api.aiRetouchRequests.map(\.clientRequestId), [
"auto-9-101-tpl11-a0",
"auto-9-102-tpl12-a0",
])
}
func testPendingAIRetouchResumesWithPersistedIdempotencyIdentifier() async {
let context = makeOTGTestContext()
let api = TravelAlbumMockAPI()
let persisted = TravelAlbumOTGPhotoRecord(
id: "resume-retouch",
fileName: "resume.JPG",
localPath: "originals/resume.JPG",
capturedAt: "2026-08-26 12:00:00",
fileSizeBytes: 1024,
status: .uploaded,
progress: 100,
albumId: 9,
userId: "u1",
remoteUrl: "https://cdn.example.com/resume.JPG",
serverMaterialId: 99,
autoRetouchState: .pendingSubmission,
autoRetouchTemplateId: 21,
autoRetouchClientRequestId: "persisted-request-id"
)
context.store.save([persisted], albumId: 9)
let uploader = MockTravelAlbumOTGUploader()
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader,
api: api
)
viewModel.reloadLocalPhotos()
viewModel.applicationDidBecomeActive()
await waitUntil { api.aiRetouchRequests.count == 1 }
XCTAssertEqual(uploader.uploadCallCount, 0)
XCTAssertEqual(api.aiRetouchRequests.first?.clientRequestId, "persisted-request-id")
XCTAssertEqual(api.aiRetouchRequests.first?.materialIds, [99])
}
func testConfigurationUpdateUsesServerNormalizedResponse() async {
let context = makeOTGTestContext()
let api = TravelAlbumMockAPI()
let serverConfiguration = TravelAlbumAutoRetouchConfiguration(
enabled: true,
refinedTemplateId: 31
)
api.autoRetouchConfigurationResponse = serverConfiguration
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
api: api,
initialAutoRetouchConfiguration: .disabled
)
var message = ""
viewModel.onShowMessage = { message = $0 }
await viewModel.updateAutoRetouchConfiguration(
TravelAlbumAutoRetouchConfiguration(enabled: true, refinedTemplateId: 22)
)
XCTAssertEqual(api.autoRetouchConfigurationRequests.first?.refinedTemplateId, 22)
XCTAssertEqual(viewModel.autoRetouchConfiguration, serverConfiguration)
XCTAssertEqual(message, "已开启 AI 自动修图")
}
}
/// OTG 修图任务入口、只读预览路由和失败恢复测试。
@MainActor
final class WiredCameraTransferPreviewTests: XCTestCase {
func testTaskButtonUsesCompactWhiteIconAndTitleWithoutSharedBackground() throws {
let fixture = try makeFixture()
let controller = WiredCameraTransferViewController(viewModel: fixture.viewModel)
controller.setupNavigationBar()
let album = TravelAlbumDetailViewController(albumId: 9, api: fixture.api)
album.setupNavigationBar()
let expected = try XCTUnwrap(album.navigationItem.rightBarButtonItems?.first { $0.title == "修图任务" })
let item = try XCTUnwrap(controller.navigationItem.rightBarButtonItem)
let button = try XCTUnwrap(item.customView as? UIButton)
let fittingSize = button.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
XCTAssertEqual(button.configuration?.title, expected.title)
XCTAssertNotNil(button.configuration?.image)
XCTAssertEqual(button.configuration?.image?.renderingMode, .alwaysOriginal)
XCTAssertEqual(button.buttonType, .custom)
XCTAssertEqual(button.accessibilityLabel, expected.accessibilityLabel)
XCTAssertEqual(button.configuration?.baseForegroundColor, .white)
XCTAssertEqual(button.tintColor, .white)
XCTAssertEqual(button.configuration?.background.backgroundColor, .clear)
XCTAssertEqual(button.accessibilityIdentifier, "wiredTransfer.aiRetouchTasksButton")
XCTAssertEqual(fittingSize.height, 44, accuracy: 0.1)
XCTAssertGreaterThanOrEqual(fittingSize.width, 44)
XCTAssertLessThanOrEqual(fittingSize.width, 90)
if #available(iOS 26.0, *) {
XCTAssertTrue(item.hidesSharedBackground)
}
XCTAssertTrue(button.isEnabled)
}
func testTaskButtonOpensExistingJobListWithInjectedAPIAndReturnsToTransferState() async throws {
let fixture = try makeFixture()
fixture.viewModel.selectTransferMode("拍后传输")
fixture.viewModel.selectTab(.uploaded)
let controller = WiredCameraTransferViewController(viewModel: fixture.viewModel)
let navigation = UINavigationController(rootViewController: controller)
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
window.rootViewController = navigation
window.makeKeyAndVisible()
defer {
window.isHidden = true
window.rootViewController = nil
fixture.viewModel.stop()
}
controller.loadViewIfNeeded()
let button = try XCTUnwrap(controller.navigationItem.rightBarButtonItem?.customView as? UIButton)
button.sendActions(for: .touchUpInside)
let jobList = try XCTUnwrap(navigation.topViewController as? TravelAlbumAIJobListViewController)
jobList.loadViewIfNeeded()
await waitUntil { !fixture.api.aiJobListRequests.isEmpty && navigation.transitionCoordinator == nil }
XCTAssertEqual(fixture.api.aiJobListRequests.first?.statusGroup, .all)
button.sendActions(for: .touchUpInside)
XCTAssertEqual(navigation.viewControllers.count, 2)
XCTAssertTrue(navigation.topViewController === jobList)
navigation.popViewController(animated: false)
XCTAssertTrue(navigation.topViewController === controller)
XCTAssertEqual(fixture.viewModel.transferMode, .postTransfer)
XCTAssertEqual(fixture.viewModel.selectedTab, .uploaded)
XCTAssertEqual(fixture.viewModel.photos.first?.status, .uploaded)
XCTAssertEqual(fixture.viewModel.photos.first?.progress, 100)
XCTAssertEqual((controller.navigationItem.rightBarButtonItem?.customView as? UIButton)?.configuration?.title, "修图任务")
XCTAssertEqual(navigation.navigationBar.tintColor, .white)
}
func testPreviewLoadsLatestOriginalAndRefinedResultWithoutChangingUploadState() async throws {
let fixture = try makeFixture()
let project = try await fixture.viewModel.loadAutoRetouchPreviewProject(photoId: "preview")
XCTAssertEqual(fixture.api.materialInfoRequests, [.init(userEquityTravelId: 9, materialId: 77)])
XCTAssertEqual(project.originalMaterialId, 77)
XCTAssertEqual(project.aiRetouchBatchId, 901)
XCTAssertEqual(project.orderedAssets.map(\.kind), [.original, .retouched])
XCTAssertEqual(project.asset(for: .retouched)?.displayURL, fixture.api.materialInfoResponse.aiRefinedURL)
XCTAssertNotNil(project.comparisonContent(for: .retouched))
XCTAssertEqual(fixture.viewModel.photos.first?.status, .uploaded)
XCTAssertEqual(fixture.viewModel.photos.first?.progress, 100)
XCTAssertEqual(fixture.viewModel.photos.first?.autoRetouchState, .completed)
XCTAssertTrue(fixture.api.aiRetouchRequests.isEmpty)
XCTAssertTrue(fixture.api.uploadMaterialRequests.isEmpty)
}
func testPreviewRejectsMissingMaterialIdAndUnfinishedRetouchWithoutRequest() async throws {
for (state, materialId) in [(TravelAlbumAutoRetouchState.completed, 0), (.processing, 77), (.none, 77)] {
let fixture = try makeFixture(state: state, materialId: materialId)
do {
_ = try await fixture.viewModel.loadAutoRetouchPreviewProject(photoId: "preview")
XCTFail("Invalid preview should fail")
} catch {
XCTAssertEqual(error as? TravelAlbumOTGPreviewError, .materialUnavailable)
}
XCTAssertTrue(fixture.api.materialInfoRequests.isEmpty)
}
}
func testPreviewRejectsMissingRefinedResultAndPropagatesRequestFailure() async throws {
let fixture = try makeFixture()
fixture.api.materialInfoResponse = TravelAlbumMaterial(id: 77, fileUrl: "file:///original.jpg", aiRefinedURL: " ")
do {
_ = try await fixture.viewModel.loadAutoRetouchPreviewProject(photoId: "preview")
XCTFail("Missing refined result should fail")
} catch {
XCTAssertEqual(error as? TravelAlbumOTGPreviewError, .resultNotReady)
}
let failure = APIError.networkFailed("网络不可用")
fixture.api.materialInfoError = failure
do {
_ = try await fixture.viewModel.loadAutoRetouchPreviewProject(photoId: "preview")
XCTFail("Request failure should propagate")
} catch {
XCTAssertEqual(error.localizedDescription, failure.localizedDescription)
}
XCTAssertEqual(fixture.viewModel.photos.first?.status, .uploaded)
XCTAssertEqual(fixture.viewModel.photos.first?.progress, 100)
XCTAssertEqual(fixture.viewModel.photos.first?.autoRetouchState, .completed)
}
func testCompletedPhotoOpensReadOnlyRefinedPreviewAndIgnoresDuplicateTaps() async throws {
let fixture = try makeFixture()
fixture.api.materialInfoDelayNanoseconds = 100_000_000
let page = try await showPage(viewModel: fixture.viewModel)
defer { closePage(page, viewModel: fixture.viewModel) }
tapPhoto(in: page.collection)
tapPhoto(in: page.collection)
await waitUntil { fixture.api.materialInfoRequests.count == 1 }
XCTAssertTrue(GlobalLoadingManager.shared.isShowing)
await waitUntil { page.controller.presentedViewController is TravelAlbumPhotoPreviewViewController }
let preview = try XCTUnwrap(page.controller.presentedViewController as? TravelAlbumPhotoPreviewViewController)
preview.loadViewIfNeeded()
let variants = try XCTUnwrap(findView(in: preview.view, identifier: "travelAlbum.previewVariantSegmentedControl") as? UISegmentedControl)
let actions = try XCTUnwrap(findView(in: preview.view, identifier: "travelAlbum.previewActionStack"))
let comparison = try XCTUnwrap(findView(in: preview.view, identifier: "travelAlbum.previewComparisonButton"))
XCTAssertEqual(variants.numberOfSegments, 2)
XCTAssertEqual(variants.titleForSegment(at: variants.selectedSegmentIndex), "精修后")
XCTAssertTrue(actions.isHidden)
XCTAssertFalse(comparison.isHidden)
XCTAssertEqual(fixture.api.materialInfoRequests.count, 1)
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
}
func testUnfinishedFailedAndUnretouchedPhotosKeepGenericPreview() async throws {
for state in [TravelAlbumAutoRetouchState.none, .processing, .failed] {
let fixture = try makeFixture(state: state)
let page = try await showPage(viewModel: fixture.viewModel)
defer { closePage(page, viewModel: fixture.viewModel) }
tapPhoto(in: page.collection)
await waitUntil { page.controller.presentedViewController != nil }
XCTAssertTrue(page.controller.presentedViewController is MediaPreviewViewController)
XCTAssertTrue(fixture.api.materialInfoRequests.isEmpty)
}
}
func testPreviewFailuresStayOnTransferPageAndStopLoading() async throws {
for scenario in 0..<3 {
let fixture = try makeFixture(materialId: scenario == 0 ? 0 : 77)
let expectedMessage: String
switch scenario {
case 0:
expectedMessage = TravelAlbumOTGPreviewError.materialUnavailable.localizedDescription
case 1:
let error = APIError.networkFailed("预览网络不可用")
fixture.api.materialInfoError = error
expectedMessage = error.localizedDescription
default:
fixture.api.materialInfoResponse = TravelAlbumMaterial(id: 77, fileUrl: "file:///original.jpg")
expectedMessage = TravelAlbumOTGPreviewError.resultNotReady.localizedDescription
}
let page = try await showPage(viewModel: fixture.viewModel)
defer { closePage(page, viewModel: fixture.viewModel) }
tapPhoto(in: page.collection)
await waitUntil { self.containsLabel(in: page.controller.view, text: expectedMessage) }
XCTAssertNil(page.controller.presentedViewController)
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
XCTAssertTrue(containsLabel(in: page.controller.view, text: expectedMessage))
}
}
func testSelectionModeDoesNotOpenPreview() async throws {
let fixture = try makeFixture()
let page = try await showPage(viewModel: fixture.viewModel)
defer { closePage(page, viewModel: fixture.viewModel) }
fixture.viewModel.onBatchUploadButtonClick()
tapPhoto(in: page.collection)
XCTAssertNil(page.controller.presentedViewController)
XCTAssertTrue(fixture.api.materialInfoRequests.isEmpty)
}
func testLeavingTransferPageCancelsPreviewLoading() async throws {
let fixture = try makeFixture()
fixture.api.materialInfoDelayNanoseconds = 500_000_000
let page = try await showPage(viewModel: fixture.viewModel)
defer { closePage(page, viewModel: fixture.viewModel) }
tapPhoto(in: page.collection)
await waitUntil { fixture.api.materialInfoRequests.count == 1 }
page.controller.viewWillDisappear(false)
await waitUntil { !GlobalLoadingManager.shared.isShowing }
XCTAssertNil(page.controller.presentedViewController)
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
}
private func makeFixture(
state: TravelAlbumAutoRetouchState = .completed,
materialId: Int = 77
) throws -> (viewModel: WiredCameraTransferViewModel, api: TravelAlbumMockAPI) {
let context = makeOTGTestContext()
var record = try makePersistedOTGRecord(context: context, id: "preview", capturedAt: "2026-08-27 12:00:00", status: .uploaded)
record.clientPhotoId = "preview-client"
record.progress = 100
record.serverMaterialId = materialId
record.autoRetouchState = state
record.autoRetouchBatchId = 901
context.store.save([record], albumId: 9)
let api = TravelAlbumMockAPI()
api.materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(clientPhotoIds: [record.clientPhotoId])
let imageURL = try XCTUnwrap(context.store.absoluteURL(for: record.localPath, albumId: 9)).absoluteString
api.materialInfoResponse = TravelAlbumMaterial(
id: 77, userEquityTravelId: 9, fileName: "preview.JPG", fileUrl: imageURL,
aiRetouchBatchId: 901, aiRefinedURL: imageURL, aiAtmosphereURL: imageURL
)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
api: api
)
viewModel.reloadLocalPhotos()
return (viewModel, api)
}
/// 测试页面与其窗口,保持预览展示期间的 UIKit 生命周期。
private struct Page {
let window: UIWindow
let controller: WiredCameraTransferViewController
let collection: UICollectionView
}
private func showPage(viewModel: WiredCameraTransferViewModel) async throws -> Page {
let controller = WiredCameraTransferViewController(viewModel: viewModel)
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
window.rootViewController = controller
window.makeKeyAndVisible()
controller.loadViewIfNeeded()
let collection = try XCTUnwrap(findView(in: controller.view, identifier: "wiredTransfer.photoCollectionView") as? UICollectionView)
await waitUntil { collection.numberOfSections > 0 && collection.numberOfItems(inSection: 0) > 0 }
controller.view.layoutIfNeeded()
return Page(window: window, controller: controller, collection: collection)
}
private func closePage(_ page: Page, viewModel: WiredCameraTransferViewModel) {
page.controller.dismiss(animated: false)
page.window.isHidden = true
page.window.rootViewController = nil
viewModel.stop()
}
private func tapPhoto(in collection: UICollectionView) {
collection.delegate?.collectionView?(collection, didSelectItemAt: IndexPath(item: 0, section: 0))
}
private func findView(in view: UIView, identifier: String) -> UIView? {
if view.accessibilityIdentifier == identifier { return view }
for subview in view.subviews {
if let match = findView(in: subview, identifier: identifier) { return match }
}
return nil
}
private func containsLabel(in view: UIView, text: String) -> Bool {
if let label = view as? UILabel, label.text == text { return true }
return view.subviews.contains { containsLabel(in: $0, text: text) }
}
} }
/// 旅拍相册相机历史导入 ViewModel 测试。 /// 旅拍相册相机历史导入 ViewModel 测试。
@@ -993,13 +1775,15 @@ private func makeWiredViewModel(
manager: MockWiredCameraConnectionManager, manager: MockWiredCameraConnectionManager,
uploader: MockTravelAlbumOTGUploader? = nil, uploader: MockTravelAlbumOTGUploader? = nil,
api: TravelAlbumMockAPI? = nil, api: TravelAlbumMockAPI? = nil,
userDefaults: UserDefaults = makeOTGTestDefaults() userDefaults: UserDefaults = makeOTGTestDefaults(),
initialAutoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled
) -> WiredCameraTransferViewModel { ) -> WiredCameraTransferViewModel {
prepareAppStoreForOTGTests() prepareAppStoreForOTGTests()
return WiredCameraTransferViewModel( return WiredCameraTransferViewModel(
albumId: albumId, albumId: albumId,
albumTitle: "相册", albumTitle: "相册",
headerPhone: "13800138000", headerPhone: "13800138000",
initialAutoRetouchConfiguration: initialAutoRetouchConfiguration,
connectionManager: manager, connectionManager: manager,
storage: context.store, storage: context.store,
uploader: uploader ?? MockTravelAlbumOTGUploader(), uploader: uploader ?? MockTravelAlbumOTGUploader(),
@@ -1204,6 +1988,8 @@ private final class MockCameraDriver: CameraDriver {
private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading { private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading {
private(set) var uploadCallCount = 0 private(set) var uploadCallCount = 0
private(set) var uploadedRecordIds: [String] = [] private(set) var uploadedRecordIds: [String] = []
var materialIds: [Int] = []
var error: Error?
func upload( func upload(
record: TravelAlbumOTGPhotoRecord, record: TravelAlbumOTGPhotoRecord,
@@ -1212,7 +1998,13 @@ private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading {
) async throws -> TravelAlbumMaterial { ) async throws -> TravelAlbumMaterial {
uploadCallCount += 1 uploadCallCount += 1
uploadedRecordIds.append(record.id) uploadedRecordIds.append(record.id)
return TravelAlbumMaterial(id: 1, fileName: record.fileName, fileUrl: "https://cdn.example.com/\(record.fileName)") if let error { throw error }
let materialId = materialIds.isEmpty ? uploadCallCount : materialIds.removeFirst()
return TravelAlbumMaterial(
id: materialId,
fileName: record.fileName,
fileUrl: "https://cdn.example.com/\(record.fileName)"
)
} }
} }
@@ -1246,10 +2038,13 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
var materialListFailingCallIndexes: Set<Int> = [] var materialListFailingCallIndexes: Set<Int> = []
var materialInfoResponse = TravelAlbumMaterial() var materialInfoResponse = TravelAlbumMaterial()
var materialInfoError: Error? var materialInfoError: Error?
var materialInfoDelayNanoseconds: UInt64 = 0
var uploadMaterialResponse = TravelAlbumMaterial() var uploadMaterialResponse = TravelAlbumMaterial()
var materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(clientPhotoIds: []) var materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(clientPhotoIds: [])
var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "") var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "")
var aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse() var aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse()
var autoRetouchConfigurationResponse: TravelAlbumAutoRetouchConfiguration = .disabled
var updateAutoRetouchConfigurationError: Error?
var createError: Error? var createError: Error?
var aiRetouchTemplatesError: Error? var aiRetouchTemplatesError: Error?
var submitAIRetouchError: Error? var submitAIRetouchError: Error?
@@ -1280,6 +2075,7 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
private(set) var deletedMaterialIDBatches: [[Int]] = [] private(set) var deletedMaterialIDBatches: [[Int]] = []
private(set) var aiRetouchTemplateScenicIds: [Int] = [] private(set) var aiRetouchTemplateScenicIds: [Int] = []
private(set) var aiRetouchRequests: [TravelAlbumAIRetouchRequest] = [] private(set) var aiRetouchRequests: [TravelAlbumAIRetouchRequest] = []
private(set) var autoRetouchConfigurationRequests: [TravelAlbumAutoRetouchConfigurationRequest] = []
private(set) var aiReretouchRequests: [TravelAlbumAIReretouchRequest] = [] private(set) var aiReretouchRequests: [TravelAlbumAIReretouchRequest] = []
private(set) var aiJobListRequests: [AIJobListRequest] = [] private(set) var aiJobListRequests: [AIJobListRequest] = []
@@ -1331,6 +2127,9 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
materialInfoRequests.append( materialInfoRequests.append(
MaterialInfoRequest(userEquityTravelId: userEquityTravelId, materialId: materialId) MaterialInfoRequest(userEquityTravelId: userEquityTravelId, materialId: materialId)
) )
if materialInfoDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: materialInfoDelayNanoseconds)
}
if let materialInfoError { throw materialInfoError } if let materialInfoError { throw materialInfoError }
return materialInfoResponse return materialInfoResponse
} }
@@ -1367,6 +2166,14 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
return aiRetouchTemplatesResponse return aiRetouchTemplatesResponse
} }
func updateAutoRetouchConfiguration(
_ request: TravelAlbumAutoRetouchConfigurationRequest
) async throws -> TravelAlbumAutoRetouchConfiguration {
autoRetouchConfigurationRequests.append(request)
if let updateAutoRetouchConfigurationError { throw updateAutoRetouchConfigurationError }
return autoRetouchConfigurationResponse
}
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission { func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission {
aiRetouchRequests.append(request) aiRetouchRequests.append(request)
if submitAIRetouchDelayNanoseconds > 0 { if submitAIRetouchDelayNanoseconds > 0 {