From e529bb594246dc02246c689ab4ebac452f1947c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Mon, 31 Aug 2026 09:43:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E9=97=A8=E5=BA=97?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E6=B3=A8=E9=94=80=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/账号注销后端接口需求-简洁版.md | 43 ++ docs/账号注销后端接口需求.md | 431 ++++++++++++ docs/门店身份注销接入说明.md | 160 +++++ docs/门店身份注销接口实测.md | 342 ++++++++++ docs/门店身份注销旧接口返回数据说明.md | 477 ++++++++++++++ suixinkan/App/AppRouter.swift | 4 +- suixinkan/AppDelegate.swift | 3 +- suixinkan/Common/NotificationName.swift | 6 + suixinkan/Core/Networking/APIClient.swift | 41 +- suixinkan/Core/Networking/APIRequest.swift | 6 +- .../Core/Push/PushNotificationManager.swift | 29 +- .../API/StoreAccountDeregistrationAPI.swift | 105 +++ .../StoreAccountDeregistrationModels.swift | 102 +++ .../StoreAccountDeregistrationResponses.swift | 193 ++++++ ...AccountDeregistrationSubmissionStore.swift | 60 ++ ...AccountDeregistrationAccessViewModel.swift | 129 ++++ .../StoreAccountDeregistrationViewModel.swift | 271 ++++++++ suixinkan/SceneDelegate.swift | 91 ++- .../AccountSelectionViewController.swift | 2 +- suixinkan/UI/Login/LoginViewController.swift | 14 +- .../Profile/AccountSwitchViewController.swift | 10 +- .../UI/Setting/SettingViewController.swift | 32 +- ...ntDeregistrationAccessViewController.swift | 286 ++++++++ ...AccountDeregistrationRootCoordinator.swift | 148 +++++ .../StoreAccountDeregistrationStyle.swift | 84 +++ ...eAccountDeregistrationViewController.swift | 409 ++++++++++++ suixinkanTests/LoginViewModelTests.swift | 169 +++++ suixinkanTests/PushNotificationTests.swift | 19 + suixinkanTests/SettingViewModelTests.swift | 18 + .../StoreAccountDeregistrationAPITests.swift | 201 ++++++ ...toreAccountDeregistrationAccessTests.swift | 393 +++++++++++ ...reAccountDeregistrationCombinedTests.swift | 618 +++++++++++++++++ .../StoreAccountDeregistrationFixtures.swift | 139 ++++ .../StoreAccountDeregistrationRootTests.swift | 450 +++++++++++++ ...eAccountDeregistrationViewModelTests.swift | 622 ++++++++++++++++++ 35 files changed, 6062 insertions(+), 45 deletions(-) create mode 100644 docs/账号注销后端接口需求-简洁版.md create mode 100644 docs/账号注销后端接口需求.md create mode 100644 docs/门店身份注销接入说明.md create mode 100644 docs/门店身份注销接口实测.md create mode 100644 docs/门店身份注销旧接口返回数据说明.md create mode 100644 suixinkan/Features/AccountDeletion/API/StoreAccountDeregistrationAPI.swift create mode 100644 suixinkan/Features/AccountDeletion/Models/StoreAccountDeregistrationModels.swift create mode 100644 suixinkan/Features/AccountDeletion/Models/StoreAccountDeregistrationResponses.swift create mode 100644 suixinkan/Features/AccountDeletion/Models/StoreAccountDeregistrationSubmissionStore.swift create mode 100644 suixinkan/Features/AccountDeletion/ViewModels/StoreAccountDeregistrationAccessViewModel.swift create mode 100644 suixinkan/Features/AccountDeletion/ViewModels/StoreAccountDeregistrationViewModel.swift create mode 100644 suixinkan/UI/Setting/StoreAccountDeregistrationAccessViewController.swift create mode 100644 suixinkan/UI/Setting/StoreAccountDeregistrationRootCoordinator.swift create mode 100644 suixinkan/UI/Setting/StoreAccountDeregistrationStyle.swift create mode 100644 suixinkan/UI/Setting/StoreAccountDeregistrationViewController.swift create mode 100644 suixinkanTests/StoreAccountDeregistrationAPITests.swift create mode 100644 suixinkanTests/StoreAccountDeregistrationAccessTests.swift create mode 100644 suixinkanTests/StoreAccountDeregistrationCombinedTests.swift create mode 100644 suixinkanTests/StoreAccountDeregistrationFixtures.swift create mode 100644 suixinkanTests/StoreAccountDeregistrationRootTests.swift create mode 100644 suixinkanTests/StoreAccountDeregistrationViewModelTests.swift diff --git a/docs/账号注销后端接口需求-简洁版.md b/docs/账号注销后端接口需求-简洁版.md new file mode 100644 index 0000000..994aa12 --- /dev/null +++ b/docs/账号注销后端接口需求-简洁版.md @@ -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 的固定验证码和“放弃资产”文案仅用于演示,不能直接作为真实业务规则。 diff --git a/docs/账号注销后端接口需求.md b/docs/账号注销后端接口需求.md new file mode 100644 index 0000000..36e4ab1 --- /dev/null +++ b/docs/账号注销后端接口需求.md @@ -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 文案不能直接作为最终业务承诺,应随实际后端处理规则调整。 diff --git a/docs/门店身份注销接入说明.md b/docs/门店身份注销接入说明.md new file mode 100644 index 0000000..66676c6 --- /dev/null +++ b/docs/门店身份注销接入说明.md @@ -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/`。 diff --git a/docs/门店身份注销接口实测.md b/docs/门店身份注销接口实测.md new file mode 100644 index 0000000..dcf17d0 --- /dev/null +++ b/docs/门店身份注销接口实测.md @@ -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/`。 diff --git a/docs/门店身份注销旧接口返回数据说明.md b/docs/门店身份注销旧接口返回数据说明.md new file mode 100644 index 0000000..1789cb4 --- /dev/null +++ b/docs/门店身份注销旧接口返回数据说明.md @@ -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 | 全部阻断项;无阻断时为 `[]` | +| `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)。 diff --git a/suixinkan/App/AppRouter.swift b/suixinkan/App/AppRouter.swift index 745c0bc..ba84d4c 100644 --- a/suixinkan/App/AppRouter.swift +++ b/suixinkan/App/AppRouter.swift @@ -6,6 +6,7 @@ import UIKit /// 应用根页面路由。 +@MainActor enum AppRouter { enum Root { @@ -37,7 +38,8 @@ enum AppRouter { } } - private static func setRoot(_ viewController: UIViewController, on window: UIWindow?, animated: Bool) { + /// 切换到显式构造的根页面,用于业务进入前的注销状态核验。 + static func setRoot(_ viewController: UIViewController, on window: UIWindow?, animated: Bool = true) { guard let window else { return } guard animated, let snapshot = window.snapshotView(afterScreenUpdates: true) else { diff --git a/suixinkan/AppDelegate.swift b/suixinkan/AppDelegate.swift index 4baab3f..787865c 100644 --- a/suixinkan/AppDelegate.swift +++ b/suixinkan/AppDelegate.swift @@ -19,7 +19,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { if AppStore.shared.session.privacyAgreementAccepted, !AppStore.shared.session.token.isEmpty { AMapBootstrap.configureIfNeeded() } - PushNotificationManager.shared.initializeIfPrivacyAccepted(launchOptions: launchOptions) + let pushManager = PushNotificationManager.shared + pushManager.initializeIfPrivacyAccepted(launchOptions: launchOptions) return true } diff --git a/suixinkan/Common/NotificationName.swift b/suixinkan/Common/NotificationName.swift index e45700f..89f6443 100644 --- a/suixinkan/Common/NotificationName.swift +++ b/suixinkan/Common/NotificationName.swift @@ -29,6 +29,9 @@ enum NotificationName { /// Token 失效或鉴权失败,需重新登录 static let sessionDidExpire = name("sessionDidExpire") + /// 当前门店身份受限或申请结果待核实;保留凭证转入只读状态查询。 + static let storeAccountDeregistrationRestricted = name("storeAccountDeregistrationRestricted") + // MARK: - Scenic /// 当前景区切换 @@ -68,6 +71,9 @@ enum NotificationName { /// `Notification.userInfo` 字典键的统一入口。 enum NotificationUserInfoKey { + /// 产生注销限制错误的原请求凭证,仅在内存中匹配当前会话,禁止记录日志。 + static let deregistrationRequestToken = "deregistrationRequestToken" + static let scenicId = "scenicId" static let scenicName = "scenicName" static let orderId = "orderId" diff --git a/suixinkan/Core/Networking/APIClient.swift b/suixinkan/Core/Networking/APIClient.swift index 1693a86..f1feca4 100644 --- a/suixinkan/Core/Networking/APIClient.swift +++ b/suixinkan/Core/Networking/APIClient.swift @@ -19,6 +19,7 @@ final class APIClient { private let session: URLSessionProtocol private let encoder: JSONEncoder private let decoder: JSONDecoder + private let notificationCenter: NotificationCenter private var authTokenProvider: (() -> String?)? private let environment: APIEnvironment @@ -32,12 +33,14 @@ final class APIClient { encoder: JSONEncoder = JSONEncoder(), decoder: JSONDecoder = JSONDecoder(), appVersion: String = AppClientInfo.appVersion(), - osType: String = AppClientInfo.osType + osType: String = AppClientInfo.osType, + notificationCenter: NotificationCenter = .default ) { self.environment = environment self.session = session self.encoder = encoder self.decoder = decoder + self.notificationCenter = notificationCenter self.appVersion = appVersion.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "1.0.0" self.osType = osType.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? AppClientInfo.osType } @@ -61,7 +64,7 @@ final class APIClient { tokenOverride: String? = nil ) async throws -> Response { let request = try makeURLRequest(apiRequest, tokenOverride: tokenOverride) - logRequest(request) + logRequest(request, includeBody: apiRequest.logsPayload) let data: Data let response: URLResponse @@ -80,12 +83,12 @@ final class APIClient { throw APIError.networkFailed(error.localizedDescription) } - logResponse(for: request, response: response, data: data) + logResponse(for: request, response: response, data: data, includeBody: apiRequest.logsPayload) do { try validateHTTPResponse(response, data: data) return try decodeEnvelope(Response.self, from: data) } catch let error as APIError { - notifySessionExpiredIfNeeded(for: error) + notifySessionErrorIfNeeded(for: error, request: request) throw error } } @@ -141,7 +144,7 @@ final class APIClient { try validateHTTPResponse(response, data: data) return try decodeEnvelope(Response.self, from: data) } catch let error as APIError { - notifySessionExpiredIfNeeded(for: error) + notifySessionErrorIfNeeded(for: error, request: request) throw error } } @@ -212,7 +215,7 @@ final class APIClient { try validateHTTPResponse(response, data: responseData) return try decodeEnvelope(Response.self, from: responseData) } catch let error as APIError { - notifySessionExpiredIfNeeded(for: error) + notifySessionErrorIfNeeded(for: error, request: request) throw error } } @@ -267,6 +270,9 @@ final class APIClient { } guard 200 ..< 300 ~= httpResponse.statusCode else { + if let envelope = try? decoder.decode(ErrorEnvelope.self, from: data), envelope.code == 150015 { + throw APIError.serverCode(150015, parseHTTPErrorMessage(data: data)) + } throw APIError.httpStatus(httpResponse.statusCode, parseHTTPErrorMessage(data: data)) } } @@ -295,10 +301,19 @@ final class APIClient { return payload } - /// Token 失效时广播 sessionDidExpire,触发全局登出。 - private func notifySessionExpiredIfNeeded(for error: APIError) { + /// 区分身份注销受限与凭证失效;150015 附带原请求 Token,便于忽略切换身份后的旧错误。 + private func notifySessionErrorIfNeeded(for error: APIError, request: URLRequest) { + if case .serverCode(150015, _) = error { + guard let token = request.value(forHTTPHeaderField: "token"), !token.isEmpty else { return } + notificationCenter.post( + name: NotificationName.storeAccountDeregistrationRestricted, + object: nil, + userInfo: [NotificationUserInfoKey.deregistrationRequestToken: token] + ) + return + } guard APIError.isAuthenticationExpired(error) else { return } - NotificationCenter.default.post(name: NotificationName.sessionDidExpire, object: nil) + notificationCenter.post(name: NotificationName.sessionDidExpire, object: nil) } /// 从 HTTP 错误响应中提取更适合展示给用户的错误信息。 @@ -341,7 +356,7 @@ final class APIClient { } /// 在 Debug 环境打印请求信息(含 GET 查询参数与 POST 请求体,对齐 Android Ktor `LogLevel.BODY`)。 - private func logRequest(_ request: URLRequest) { + private func logRequest(_ request: URLRequest, includeBody: Bool = true) { #if DEBUG let method = request.httpMethod ?? "REQUEST" let url = request.url?.absoluteString ?? "" @@ -359,7 +374,7 @@ final class APIClient { } let contentType = request.value(forHTTPHeaderField: "Content-Type") - if let body = Self.debugRequestBody(from: request.httpBody, contentType: contentType) { + if includeBody, let body = Self.debugRequestBody(from: request.httpBody, contentType: contentType) { lines.append("body:\n\(body)") } @@ -368,12 +383,12 @@ final class APIClient { } /// 在 Debug 环境打印响应状态和响应体。 - private func logResponse(for request: URLRequest, response: URLResponse, data: Data) { + private func logResponse(for request: URLRequest, response: URLResponse, data: Data, includeBody: Bool = true) { #if DEBUG let method = request.httpMethod ?? "REQUEST" let url = request.url?.absoluteString ?? "" let statusCode = (response as? HTTPURLResponse).map { String($0.statusCode) } ?? "unknown" - let body = Self.debugResponseBody(from: data) + let body = includeBody ? Self.debugResponseBody(from: data) : "" print("[API][Response] \(method) \(url) status=\(statusCode)\n\(body)") #endif } diff --git a/suixinkan/Core/Networking/APIRequest.swift b/suixinkan/Core/Networking/APIRequest.swift index fd1c633..eec0166 100644 --- a/suixinkan/Core/Networking/APIRequest.swift +++ b/suixinkan/Core/Networking/APIRequest.swift @@ -20,6 +20,8 @@ nonisolated struct APIRequest { var queryItems: [URLQueryItem] var headers: [String: String] var body: AnyEncodable? + /// 敏感请求关闭正文日志,避免验证码和注销资产进入调试日志。 + var logsPayload: Bool /// 创建一个 API 请求,并把可编码请求体擦除为统一的 AnyEncodable。 init( @@ -27,13 +29,15 @@ nonisolated struct APIRequest { path: String, queryItems: [URLQueryItem] = [], headers: [String: String] = [:], - body: Body? = Optional.none + body: Body? = Optional.none, + logsPayload: Bool = true ) { self.method = method self.path = path self.queryItems = queryItems self.headers = headers self.body = body.map(AnyEncodable.init) + self.logsPayload = logsPayload } } diff --git a/suixinkan/Core/Push/PushNotificationManager.swift b/suixinkan/Core/Push/PushNotificationManager.swift index 469fe63..47737be 100644 --- a/suixinkan/Core/Push/PushNotificationManager.swift +++ b/suixinkan/Core/Push/PushNotificationManager.swift @@ -116,6 +116,8 @@ final class PushNotificationManager: NSObject { private var isInitialized = false private var didRequestAuthorization = false private var uploadTask: Task? + private var uploadAttemptID: UUID? + private var isAccountBindingSuspended = false private var queuedForcedUpload = false private var isFetchingRegistrationID = false private var queuedForcedFetch = false @@ -161,7 +163,7 @@ final class PushNotificationManager: NSObject { /// 登录成功后请求通知权限,并强制绑定当前账号。 func handleLoginCompleted() { initializeIfPrivacyAccepted() - guard isInitialized, appStore.session.isLoggedIn else { return } + guard !isAccountBindingSuspended, isInitialized, appStore.session.isLoggedIn else { return } if !didRequestAuthorization { didRequestAuthorization = true sdk.requestAuthorization(delegate: self) @@ -171,7 +173,7 @@ final class PushNotificationManager: NSObject { /// 账号切换后把同一设备重新绑定到新的业务账号。 func handleAccountSwitched() { - guard appStore.session.isLoggedIn else { return } + guard !isAccountBindingSuspended, appStore.session.isLoggedIn else { return } bindCurrentAccount() } @@ -179,11 +181,22 @@ final class PushNotificationManager: NSObject { func handleLogout() { uploadTask?.cancel() uploadTask = nil + uploadAttemptID = nil queuedForcedUpload = false router.resetPendingRoute() Task { await updateApplicationIconBadgeCount(0) } } + /// 注销核验或受限期间暂停业务账号绑定,保留 Token、Registration ID 与待处理通知。 + func setAccountBindingSuspended(_ suspended: Bool) { + isAccountBindingSuspended = suspended + guard suspended else { return } + uploadTask?.cancel() + uploadTask = nil + uploadAttemptID = nil + queuedForcedUpload = false + } + /// 将桌面 App Icon 角标更新为最新未读消息数量。 func updateApplicationIconBadgeCount(_ count: Int) async { await applicationIconBadgeSetter.setBadgeCount(max(count, 0)) @@ -191,13 +204,14 @@ final class PushNotificationManager: NSObject { /// App 回到前台时补偿失败或尚未完成的 Registration ID 上报。 func retryPendingRegistrationUpload() { - guard appStore.session.isLoggedIn else { return } + guard !isAccountBindingSuspended, appStore.session.isLoggedIn else { return } uploadCachedRegistrationID(force: false) refreshRegistrationID(forceUpload: false) } /// 登录根页面建立后继续执行通知点击暂存的路由。 func routePendingNotificationIfPossible() { + guard !isAccountBindingSuspended else { return } router.routePendingIfPossible() } @@ -323,7 +337,7 @@ final class PushNotificationManager: NSObject { } private func upload(registrationID: String, force: Bool) { - guard appStore.session.isLoggedIn, + guard !isAccountBindingSuspended, appStore.session.isLoggedIn, let uploadedKey = appStore.session.accountScopedKey(Key.uploadedRegistrationIDSuffix) else { return } @@ -336,12 +350,15 @@ final class PushNotificationManager: NSObject { } let accountScope = appStore.session.accountCachePrefix + let attemptID = UUID() + uploadAttemptID = attemptID uploadTask = Task { [weak self] in guard let self else { return } var succeeded = false do { try await self.api.registerJPushID(registrationID) - if self.appStore.session.accountCachePrefix == accountScope { + if self.uploadAttemptID == attemptID, !self.isAccountBindingSuspended, + self.appStore.session.accountCachePrefix == accountScope { self.defaults.set(registrationID, forKey: uploadedKey) } succeeded = true @@ -353,7 +370,9 @@ final class PushNotificationManager: NSObject { #endif } + guard self.uploadAttemptID == attemptID else { return } self.uploadTask = nil + self.uploadAttemptID = nil let shouldForceAgain = self.queuedForcedUpload self.queuedForcedUpload = false if succeeded, shouldForceAgain { diff --git a/suixinkan/Features/AccountDeletion/API/StoreAccountDeregistrationAPI.swift b/suixinkan/Features/AccountDeletion/API/StoreAccountDeregistrationAPI.swift new file mode 100644 index 0000000..c8b998d --- /dev/null +++ b/suixinkan/Features/AccountDeletion/API/StoreAccountDeregistrationAPI.swift @@ -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(_ request: APIRequest) 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 + } +} diff --git a/suixinkan/Features/AccountDeletion/Models/StoreAccountDeregistrationModels.swift b/suixinkan/Features/AccountDeletion/Models/StoreAccountDeregistrationModels.swift new file mode 100644 index 0000000..4d7cf15 --- /dev/null +++ b/suixinkan/Features/AccountDeletion/Models/StoreAccountDeregistrationModels.swift @@ -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)) + } + } +} diff --git a/suixinkan/Features/AccountDeletion/Models/StoreAccountDeregistrationResponses.swift b/suixinkan/Features/AccountDeletion/Models/StoreAccountDeregistrationResponses.swift new file mode 100644 index 0000000..58b9069 --- /dev/null +++ b/suixinkan/Features/AccountDeletion/Models/StoreAccountDeregistrationResponses.swift @@ -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 +} diff --git a/suixinkan/Features/AccountDeletion/Models/StoreAccountDeregistrationSubmissionStore.swift b/suixinkan/Features/AccountDeletion/Models/StoreAccountDeregistrationSubmissionStore.swift new file mode 100644 index 0000000..1caeb61 --- /dev/null +++ b/suixinkan/Features/AccountDeletion/Models/StoreAccountDeregistrationSubmissionStore.swift @@ -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? { "无法保存提交核验记录,请稍后重试" } +} diff --git a/suixinkan/Features/AccountDeletion/ViewModels/StoreAccountDeregistrationAccessViewModel.swift b/suixinkan/Features/AccountDeletion/ViewModels/StoreAccountDeregistrationAccessViewModel.swift new file mode 100644 index 0000000..200f14f --- /dev/null +++ b/suixinkan/Features/AccountDeletion/ViewModels/StoreAccountDeregistrationAccessViewModel.swift @@ -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 + } +} diff --git a/suixinkan/Features/AccountDeletion/ViewModels/StoreAccountDeregistrationViewModel.swift b/suixinkan/Features/AccountDeletion/ViewModels/StoreAccountDeregistrationViewModel.swift new file mode 100644 index 0000000..0575a3c --- /dev/null +++ b/suixinkan/Features/AccountDeletion/ViewModels/StoreAccountDeregistrationViewModel.swift @@ -0,0 +1,271 @@ +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 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 } + } + + /// 校验输入后申请;服务端明确接受即交由页面退出,不再查询状态或推断正式完成。 + 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 !code.isEmpty, !reason.isEmpty else { + errorMessage = "请输入收到的短信验证码和注销原因" + return + } + isBusy = true + errorMessage = nil + defer { isBusy = false } + do { + try await checkReady(api: api) + previousCancellationFingerprint = status?.cancellationFingerprint + try submissionStore?.recordSubmissionIntent(previousStatus: status) + submissionAttempted = true + step = .unresolvedRequest + eligibility = nil + try await api.apply(smsCode: code, reason: reason) + submissionAccepted = true + } catch { + // 业务码明确拒绝申请时可重新核验;网络/解码错误不能证明服务端没有收到申请。 + if !submissionAccepted, case APIError.serverCode = error { + submissionStore?.clearRejectedSubmission() + submissionAttempted = false + step = .conditions + } + invalidate(error) + } + } + + 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: "资产确认尚未完成,请刷新后重试" + } + } +} diff --git a/suixinkan/SceneDelegate.swift b/suixinkan/SceneDelegate.swift index 95bac71..f992d1c 100644 --- a/suixinkan/SceneDelegate.swift +++ b/suixinkan/SceneDelegate.swift @@ -9,6 +9,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? private var sessionExpiredDialog: SessionExpiredDialogViewController? + private var deregistrationCoordinator: StoreAccountDeregistrationRootCoordinator? + private var needsForegroundDeregistrationCheck = false func scene( _ scene: UIScene, @@ -20,26 +22,39 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { AppNavigationBarAppearance.applyGlobalAppearance() let window = UIWindow(windowScene: windowScene) - window.rootViewController = AppRouter.makeRootViewController() - window.makeKeyAndVisible() + window.rootViewController = UIViewController() self.window = window (UIApplication.shared.delegate as? AppDelegate)?.window = window + let appSession = AppStore.shared.session + deregistrationCoordinator = StoreAccountDeregistrationRootCoordinator( + window: window, session: appSession, + makeAPI: { identity in + StoreAccountDeregistrationAPI(client: NetworkServices.shared.apiClient, identity: identity) { + identity.matches(session: appSession) + } + }, + makeSubmissionStore: { StoreAccountDeregistrationSubmissionStore(storeUserID: $0) }, + makeBusinessRoot: { MainTabBarController() }, + setBindingSuspended: { PushNotificationManager.shared.setAccountBindingSuspended($0) }, + onBusinessResumed: { + PushNotificationManager.shared.handleLoginCompleted() + PushNotificationManager.shared.routePendingNotificationIfPossible() + } + ) PushNotificationManager.shared.attach(window: window) registerNotifications() - - if AppStore.shared.session.isLoggedIn { - DispatchQueue.main.async { - PushNotificationManager.shared.handleLoginCompleted() - } - } + refreshRootForCurrentSession() + window.makeKeyAndVisible() if let response = connectionOptions.notificationResponse { PushNotificationManager.shared.handleNotificationResponse(response) } } func sceneDidDisconnect(_ scene: UIScene) { + deregistrationCoordinator?.cancelPendingCheck() + deregistrationCoordinator = nil NotificationCenter.default.removeObserver(self) if (UIApplication.shared.delegate as? AppDelegate)?.window === window { (UIApplication.shared.delegate as? AppDelegate)?.window = nil @@ -56,10 +71,26 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } func sceneDidBecomeActive(_ scene: UIScene) { + guard accessController == nil, deregistrationCoordinator?.isChecking != true else { return } PushNotificationManager.shared.retryPendingRegistrationUpload() } + func sceneDidEnterBackground(_ scene: UIScene) { + needsForegroundDeregistrationCheck = true + } + + func sceneWillEnterForeground(_ scene: UIScene) { + guard needsForegroundDeregistrationCheck else { return } + needsForegroundDeregistrationCheck = false + guard sessionExpiredDialog == nil else { return } + deregistrationCoordinator?.resumeFromBackground() + } + private func registerNotifications() { + NotificationCenter.default.addObserver( + self, selector: #selector(handleStoreDeregistrationRestriction(_:)), + name: NotificationName.storeAccountDeregistrationRestricted, object: nil + ) NotificationCenter.default.addObserver( self, selector: #selector(handleSessionDidExpire), @@ -90,6 +121,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { guard AppStore.shared.session.isLoggedIn else { return } guard sessionExpiredDialog == nil else { return } + deregistrationCoordinator?.cancelPendingCheck() GlobalLoadingManager.shared.hideAll() let dialog = SessionExpiredDialogViewController { [weak self] in self?.transitionToLogin() @@ -104,8 +136,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } private func transitionToLogin() { + deregistrationCoordinator?.cancelPendingCheck() sessionExpiredDialog = nil PushNotificationManager.shared.handleLogout() + PushNotificationManager.shared.setAccountBindingSuspended(false) AppStore.shared.logout() AppRouter.setRoot(.login, on: window) } @@ -117,15 +151,46 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { @objc private func handleUserDidLogin() { sessionExpiredDialog?.dismiss(animated: false) sessionExpiredDialog = nil - AppRouter.setRoot(.mainTab, on: window) - DispatchQueue.main.async { - PushNotificationManager.shared.handleLoginCompleted() - PushNotificationManager.shared.routePendingNotificationIfPossible() + if AppStore.shared.session.isLoggedIn, AppStore.shared.session.accountType == .storeUser { + deregistrationCoordinator?.check() + } else { + refreshRootForCurrentSession() } } @objc private func handleAccountDidSwitch() { - PushNotificationManager.shared.handleAccountSwitched() + if AppStore.shared.session.accountType == .storeUser { + refreshRootForCurrentSession() + } else { + deregistrationCoordinator?.cancelPendingCheck() + PushNotificationManager.shared.setAccountBindingSuspended(false) + PushNotificationManager.shared.handleAccountSwitched() + } + } + + /// 仅检查真实根控制器,避免被旧的异步查询或页面导航状态误判。 + private var accessController: StoreAccountDeregistrationAccessViewController? { + deregistrationCoordinator?.accessController + } + + /// 冷启动和切换身份使用已有会话,不主动查询注销状态;新登录由独立入口核验。 + private func refreshRootForCurrentSession() { + let session = AppStore.shared.session + guard session.isLoggedIn else { + deregistrationCoordinator?.cancelPendingCheck() + PushNotificationManager.shared.setAccountBindingSuspended(false) + AppRouter.setRoot(.login, on: window, animated: false) + return + } + deregistrationCoordinator?.restoreSession() + } + + /// 150015 只限制原请求对应的当前门店身份;旧 Token 响应不会影响新身份。 + @objc private func handleStoreDeregistrationRestriction(_ notification: Notification) { + let token = notification.userInfo?[NotificationUserInfoKey.deregistrationRequestToken] as? String + let session = AppStore.shared.session + guard StoreAccountDeregistrationAccessViewModel.restrictionApplies(requestToken: token, session: session) else { return } + deregistrationCoordinator?.recordRestriction(requestToken: token) } private func topViewController(from viewController: UIViewController?) -> UIViewController? { diff --git a/suixinkan/UI/Login/AccountSelectionViewController.swift b/suixinkan/UI/Login/AccountSelectionViewController.swift index 74fc843..8443055 100644 --- a/suixinkan/UI/Login/AccountSelectionViewController.swift +++ b/suixinkan/UI/Login/AccountSelectionViewController.swift @@ -163,7 +163,7 @@ final class AccountSelectionViewController: UIViewController, UITableViewDelegat } @objc private func confirmTapped() { - guard let selectedAccount else { return } + guard canConfirm, let selectedAccount else { return } onConfirm(selectedAccount) } diff --git a/suixinkan/UI/Login/LoginViewController.swift b/suixinkan/UI/Login/LoginViewController.swift index fdc5118..9d97172 100644 --- a/suixinkan/UI/Login/LoginViewController.swift +++ b/suixinkan/UI/Login/LoginViewController.swift @@ -11,7 +11,7 @@ import UIKit final class LoginViewController: BaseViewController { private let viewModel = LoginViewModel() - private let authAPI = NetworkServices.shared.authAPI + private let authAPI: AuthAPI private let backgroundImageView = UIImageView() private let welcomeLabel = UILabel() @@ -29,6 +29,17 @@ final class LoginViewController: BaseViewController { private weak var accountSelectionController: AccountSelectionViewController? + /// 默认使用共享登录服务,测试可注入Mock网络客户端而不修改真实会话。 + init(authAPI: AuthAPI? = nil) { + self.authAPI = authAPI ?? NetworkServices.shared.authAPI + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent } @@ -264,6 +275,7 @@ final class LoginViewController: BaseViewController { } private func performLogin() { + guard !viewModel.isLoading else { return } viewModel.normalizeUsernameCountryCodeIfNeeded() accountField.text = viewModel.normalizedUsername diff --git a/suixinkan/UI/Profile/AccountSwitchViewController.swift b/suixinkan/UI/Profile/AccountSwitchViewController.swift index 7a865e8..a4adeeb 100644 --- a/suixinkan/UI/Profile/AccountSwitchViewController.swift +++ b/suixinkan/UI/Profile/AccountSwitchViewController.swift @@ -96,14 +96,8 @@ final class AccountSwitchViewController: BaseViewController, UITableViewDelegate } private var currentAccountId: String? { - let store = AppStore.shared - if store.session.currentStoreId > 0 { - 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 + let scope = AppStore.shared.session.accountCachePrefix + return scope.isEmpty ? nil : scope } private func isCurrentAccount(_ account: AccountSwitchAccount) -> Bool { diff --git a/suixinkan/UI/Setting/SettingViewController.swift b/suixinkan/UI/Setting/SettingViewController.swift index 2f52403..e277d89 100644 --- a/suixinkan/UI/Setting/SettingViewController.swift +++ b/suixinkan/UI/Setting/SettingViewController.swift @@ -14,6 +14,7 @@ final class SettingViewController: BaseViewController { private let contentView = UIView() private let cardView = UIView() private let rowsStack = UIStackView() + private let deregistrationRow = SettingMenuRow(title: "注销当前门店身份", titleColor: AppColor.danger, showsDivider: false) private let versionRow = SettingMenuRow(title: "系统版本", showsChevron: false) private let copyrightLabel = UILabel() @@ -68,6 +69,10 @@ final class SettingViewController: BaseViewController { rows[2].addTarget(self, action: #selector(copyDownloadTapped), for: .touchUpInside) rows[3].addTarget(self, action: #selector(userAgreementTapped), 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() { @@ -119,6 +124,29 @@ final class SettingViewController: BaseViewController { 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) { let destination = viewModel.agreementDestination(for: kind) navigationController?.pushViewController( @@ -138,8 +166,10 @@ final class SettingMenuRow: UIControl { private let divider = UIView() private let showsChevron: Bool + /// 创建菜单行,可为注销等操作单独指定标题颜色,不影响其他行。 init( title: String, + titleColor: UIColor = UIColor(hex: 0x4B5563), value: String? = nil, valueColor: UIColor = AppColor.textPrimary, showsChevron: Bool = true, @@ -150,6 +180,7 @@ final class SettingMenuRow: UIControl { setupUI() setupConstraints() titleLabel.text = title + titleLabel.textColor = titleColor valueLabel.text = value valueLabel.textColor = valueColor chevronImageView.isHidden = !showsChevron @@ -175,7 +206,6 @@ final class SettingMenuRow: UIControl { private func setupUI() { titleLabel.font = .systemFont(ofSize: 14) - titleLabel.textColor = UIColor(hex: 0x4B5563) valueLabel.font = .systemFont(ofSize: 14) valueLabel.textColor = AppColor.textPrimary diff --git a/suixinkan/UI/Setting/StoreAccountDeregistrationAccessViewController.swift b/suixinkan/UI/Setting/StoreAccountDeregistrationAccessViewController.swift new file mode 100644 index 0000000..91bf8d2 --- /dev/null +++ b/suixinkan/UI/Setting/StoreAccountDeregistrationAccessViewController.swift @@ -0,0 +1,286 @@ +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("clock", size: 36) + private let deadlineLabel = Style.label(size: 19, weight: .semibold) + private let warningLabel = Style.label(size: 13, color: AppColor.textSecondary) + private var deadlineCard = UIView() + private let messageLabel = UILabel() + private let retryButton = UIButton(type: .system) + private let conditionsButton = UIButton(type: .system) + private let cancelButton = UIButton(type: .system) + private let logoutButton = UIButton(type: .system) + private var queryTask: Task? + private var needsForegroundRefresh = false + + /// 显式注入会话与 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 = "注销账号" + } + + override func setupUI() { + view.backgroundColor = AppColor.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 = 20 + stateIcon.snp.makeConstraints { $0.height.equalTo(64) } + titleLabel.textAlignment = .center + messageLabel.numberOfLines = 0 + messageLabel.font = .systemFont(ofSize: 15) + messageLabel.textColor = AppColor.textSecondary + messageLabel.textAlignment = .center + let hero = Style.stack([stateIcon, titleLabel, messageLabel], spacing: 12) + stack.addArrangedSubview(hero) + let identity = Style.stack([ + Style.label("当前门店身份", size: 12, color: AppColor.textSecondary), + Style.label(self.identity?.displayName ?? "当前身份", size: 17, weight: .semibold) + ], spacing: 8) + stack.addArrangedSubview(Style.card(identity)) + deadlineLabel.accessibilityIdentifier = "deregister.access.deadline" + deadlineCard = Style.card(Style.stack([ + Style.label("冷静期截止时间", size: 13, color: AppColor.textSecondary), + deadlineLabel, + Style.label("以服务端时间为准,到期后仍需复核。", size: 12, color: AppColor.textSecondary) + ], spacing: 10)) + stack.addArrangedSubview(deadlineCard) + warningLabel.text = "重新登录或选中此身份,会自动撤销尚未完成的注销申请。其他身份不受影响。" + stack.addArrangedSubview(warningLabel) + conditionsButton.setTitle("查看注销条件", for: .normal) + conditionsButton.titleLabel?.font = .systemFont(ofSize: 14) + conditionsButton.snp.makeConstraints { $0.height.greaterThanOrEqualTo(44) } + stack.addArrangedSubview(conditionsButton) + + 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: 4) + 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) + conditionsButton.addTarget(self, action: #selector(conditionsTapped), 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" + conditionsButton.accessibilityIdentifier = "deregister.access.conditions" + } + + 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() } + } + + /// 在途旧查询不能覆盖刚收到的受限信号;之后由用户主动重新查询。 + 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 + conditionsButton.isEnabled = !busy && api != nil + conditionsButton.isHidden = ![.unresolved, .cooling].contains(viewModel.decision) + cancelButton.isHidden = !cooling + cancelButton.isEnabled = !busy && api != nil + logoutButton.isEnabled = !busy + Style.configure(logoutButton, title: "退出登录", primary: cooling) + deadlineCard.isHidden = !cooling + warningLabel.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 = (viewModel.status?.remainingSeconds ?? 0) > 0 + ? "当前处于冷静期,期间暂停普通业务。\n你仍可以撤销申请,恢复使用。" + : "冷静期已结束,正在等待服务端复核。\n最终结果请刷新后查看。" + 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)) + } + + @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 conditionsTapped() { + guard let identity, let api, identity.matches(session: session), + [.unresolved, .cooling].contains(viewModel.decision) else { return } + let controller = StoreAccountDeregistrationViewController( + identityName: identity.displayName, + viewModel: StoreAccountDeregistrationViewModel(storeUserID: Int(identity.userID) ?? 0), api: api, + readOnly: true + ) + navigationController?.pushViewController(controller, animated: true) + } + + @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) + } +} diff --git a/suixinkan/UI/Setting/StoreAccountDeregistrationRootCoordinator.swift b/suixinkan/UI/Setting/StoreAccountDeregistrationRootCoordinator.swift new file mode 100644 index 0000000..3a1e8e1 --- /dev/null +++ b/suixinkan/UI/Setting/StoreAccountDeregistrationRootCoordinator.swift @@ -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? + 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() + } + } +} diff --git a/suixinkan/UI/Setting/StoreAccountDeregistrationStyle.swift b/suixinkan/UI/Setting/StoreAccountDeregistrationStyle.swift new file mode 100644 index 0000000..1385782 --- /dev/null +++ b/suixinkan/UI/Setting/StoreAccountDeregistrationStyle.swift @@ -0,0 +1,84 @@ +import SnapKit +import UIKit + +/// 注销页面共用的轻量视觉组件,仅负责颜色、字号和布局,不包含业务状态。 +@MainActor +enum StoreAccountDeregistrationStyle { + /// 创建支持多行的系统字体标签。 + static func label(_ text: String = "", size: CGFloat = 15, weight: UIFont.Weight = .regular, + color: UIColor = AppColor.textPrimary) -> UILabel { + let label = UILabel() + label.text = text + label.font = .systemFont(ofSize: size, weight: weight) + label.textColor = color + 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) -> UIView { + let card = UIView() + card.backgroundColor = .white + card.layer.cornerRadius = 16 + 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 = AppColor.primary + config.baseForegroundColor = primary ? .white : AppColor.primary + config.background.cornerRadius = 12 + 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 ? AppColor.primary : AppColor.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 : AppColor.primary) : AppColor.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 = AppColor.primary + image.contentMode = .scaleAspectFit + image.setContentHuggingPriority(.required, for: .horizontal) + return image + } +} diff --git a/suixinkan/UI/Setting/StoreAccountDeregistrationViewController.swift b/suixinkan/UI/Setting/StoreAccountDeregistrationViewController.swift new file mode 100644 index 0000000..71015c5 --- /dev/null +++ b/suixinkan/UI/Setting/StoreAccountDeregistrationViewController.swift @@ -0,0 +1,409 @@ +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: () -> 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 firstStep = Style.label("1 确认资产", size: 13, weight: .semibold) + private let secondStep = Style.label("2 手机验证", size: 13, weight: .semibold) + private let heading = Style.label(size: 24, weight: .semibold) + private let subtitle = Style.label(size: 14, color: AppColor.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: AppColor.textSecondary) + private let pointsState = Style.label(size: 12, color: AppColor.textSecondary) + private let assetHint = Style.label(size: 13, color: AppColor.textSecondary) + private let blockersLabel = Style.label(size: 14, color: AppColor.textSecondary) + private let riskLabel = Style.label(size: 13, color: AppColor.warning) + private let statusLabel = Style.label(size: 13, color: AppColor.textSecondary) + private let errorLabel = Style.label(size: 14, color: AppColor.danger) + private let codeField = UITextField() + private let reasonField = UITextField() + private let smsButton = UIButton(type: .system) + private let continueButton = Style.button("确认资产并继续", id: "deregister.continue") + private let submitButton = Style.button("提交注销申请", id: "deregister.submit") + private let backButton = UIButton(type: .system) + private let footerHint = Style.label(size: 12, color: AppColor.textSecondary) + private var blockersCard = UIView() + private var errorCard = UIView() + private var actionTask: Task? + private var previousPopGestureEnabled: Bool? + private var previousViewportHeight: CGFloat = 0 + + /// 注入当前身份及旧接口,不读取任意手机号,也不自动确认资产。 + init(identityName: String, viewModel: StoreAccountDeregistrationViewModel, + api: any StoreAccountDeregistrationServing, readOnly: Bool = false, + onUnresolvedSubmission: (() -> Void)? = nil, + onSubmissionAccepted: @escaping () -> Void = { + NotificationCenter.default.post(name: NotificationName.userDidLogout, object: nil) + }) { + self.identityName = identityName + self.viewModel = viewModel + self.api = api + self.readOnly = readOnly + self.onUnresolvedSubmission = onUnresolvedSubmission + self.onSubmissionAccepted = onSubmissionAccepted + 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 = AppColor.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.distribution = .fillEqually + steps.addArrangedSubview(firstStep) + steps.addArrangedSubview(secondStep) + steps.isHidden = readOnly + content.addArrangedSubview(Style.stack([heading, subtitle], spacing: 8)) + let identityText = Style.stack([ + Style.label("当前门店身份", size: 12, color: AppColor.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 + content.addArrangedSubview(Style.card(identityRow)) + 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 = AppColor.dangerBackground + content.addArrangedSubview(errorCard) + content.addArrangedSubview(statusLabel) + statusLabel.accessibilityIdentifier = "deregister.status" + errorLabel.accessibilityIdentifier = "deregister.error" + + view.addSubview(bottomBar) + bottomBar.backgroundColor = .white + footerHint.textAlignment = .center + let actions = Style.stack([continueButton, submitButton, footerHint], 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 buildConditions() { + let columns = UIStackView() + columns.axis = .horizontal + columns.distribution = .fillEqually + columns.spacing = 16 + columns.addArrangedSubview(Style.stack([ + Style.label("现金余额", size: 13, color: AppColor.textSecondary), walletAmount, walletState + ], spacing: 8)) + columns.addArrangedSubview(Style.stack([ + Style.label("积分", size: 13, color: AppColor.textSecondary), pointsAmount, pointsState + ], spacing: 8)) + 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([ + Style.label("注销须知", size: 16, weight: .semibold), + notice("person.crop.circle", title: "仅注销当前身份", detail: "同手机号的其他身份不受影响。"), + notice("clock", title: "7 天冷静期", detail: "提交后可撤销申请,到期由服务端复核。"), + notice("exclamationmark.shield", title: "正式完成后不可恢复", detail: "历史订单、财务及审计记录按规则保留。") + ], spacing: 16) + conditionStack.addArrangedSubview(Style.card(notices)) + } + + private func notice(_ icon: String, title: String, detail: String) -> UIView { + let image = Style.icon(icon, size: 18) + image.snp.makeConstraints { $0.width.equalTo(22) } + let row = UIStackView(arrangedSubviews: [image, Style.stack([ + Style.label(title, size: 14, weight: .medium), + Style.label(detail, size: 13, color: AppColor.textSecondary) + ], spacing: 4)]) + row.axis = .horizontal + row.alignment = .top + row.spacing = 10 + return row + } + + private func buildVerification() { + codeField.placeholder = "请输入短信验证码" + codeField.keyboardType = .numberPad + codeField.textContentType = .oneTimeCode + codeField.accessibilityIdentifier = "deregister.code" + codeField.accessibilityLabel = "短信验证码" + reasonField.placeholder = "请输入注销原因" + reasonField.accessibilityIdentifier = "deregister.reason" + reasonField.accessibilityLabel = "注销原因" + for field in [codeField, reasonField] { + field.font = .systemFont(ofSize: 16) + field.autocorrectionType = .no + // 本页通过keyboardLayoutGuide和滚动区域避让,避免全局键盘库重复抬升整页。 + field.iq.enableMode = .disabled + field.addTarget(self, action: #selector(inputChanged), for: .editingChanged) + field.addTarget(self, action: #selector(revealFocusedInput), for: .editingDidBegin) + field.snp.makeConstraints { $0.height.equalTo(48) } + } + 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.spacing = 12 + verificationStack.addArrangedSubview(Style.card(Style.stack([ + Style.label("短信验证", size: 16, weight: .semibold), codeRow, + Style.label("验证码将发送至当前身份绑定的手机号。", size: 13, color: AppColor.textSecondary) + ], spacing: 8))) + verificationStack.addArrangedSubview(Style.card(Style.stack([ + Style.label("注销原因", size: 16, weight: .semibold), reasonField + ], spacing: 8))) + backButton.setTitle("返回查看资产与须知", for: .normal) + backButton.titleLabel?.font = .systemFont(ofSize: 14) + backButton.accessibilityIdentifier = "deregister.back" + backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside) + backButton.snp.makeConstraints { $0.height.greaterThanOrEqualTo(44) } + verificationStack.addArrangedSubview(backButton) + } + + 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].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 ? "完成验证后,即可提交注销申请。" : "仅注销此身份,其他身份不受影响。" + firstStep.textColor = verifying ? AppColor.textSecondary : AppColor.primary + secondStep.textColor = verifying ? AppColor.primary : AppColor.textSecondary + 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.isEnabled = !busy + backButton.isEnabled = !busy + refreshControl.isEnabled = !busy + footerHint.text = verifying ? "7 天内可撤销,正式完成后不可恢复" : "请确认资产及注销须知后继续" + 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 ? AppColor.primary : AppColor.textSecondary + pointsState.textColor = value.pointsWaived ? AppColor.primary : AppColor.textSecondary + let assetIssues = value.blockers.filter(\.isAssetConfirmation) + assetHint.text = readOnly ? "以当前查询结果为准,冷静期内不能再次确认资产。" + : (assetIssues.contains { $0.code == "WAIVER_STALE" } + ? "资产已变化,请按最新金额重新确认。" + : "正式注销时,将清空已确认放弃的现金和积分。") + 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.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 var hasVerificationInput: Bool { + !(codeField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !(reasonField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + 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() + 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 alert = UIAlertController(title: "确认放弃账号资产?", message: + "当前身份:\(identityName)\n\n现金余额:¥\(snapshot.walletBalance)\n积分:\(snapshot.pointsBalance)\n\n我确认自愿放弃以上现金余额,并确认自愿放弃以上积分。正式注销时将按规则清零。", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "暂不确认", style: .cancel)) + alert.addAction(UIAlertAction(title: "确认并继续", style: .destructive) { [weak self] _ in + guard let self else { return } + self.run { [self] in await self.viewModel.confirmAssetsAndContinue(snapshot: snapshot, api: self.api) } + }) + present(alert, animated: true) + } + + @objc private func backTapped() { + view.endEditing(true) + viewModel.returnToConditions() + applyViewModel() + } + + @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 ?? "" + view.endEditing(true) + let alert = UIAlertController(title: "提交注销申请?", message: + "仅注销“\(identityName)”。提交成功后将退出登录,并进入 7 天冷静期,最终由服务端复核;正式完成不可恢复。\n\n重新登录或选中此身份会自动撤销尚未完成的申请。", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "暂不提交", style: .cancel)) + alert.addAction(UIAlertAction(title: "确认提交申请", style: .destructive) { [weak self] _ in + self?.submitConfirmedApplication(smsCode: code, reason: reason) + }) + present(alert, animated: true) + } + + /// 用户确认最终弹窗后提交;仅明确成功触发一次退出,未知结果仍进入核验流程。 + func submitConfirmedApplication(smsCode: String, reason: String) { + guard !readOnly else { return } + run { [self] in await viewModel.submit(smsCode: smsCode, reason: reason, api: api) } + } +} diff --git a/suixinkanTests/LoginViewModelTests.swift b/suixinkanTests/LoginViewModelTests.swift index c077891..abc221e 100644 --- a/suixinkanTests/LoginViewModelTests.swift +++ b/suixinkanTests/LoginViewModelTests.swift @@ -3,12 +3,181 @@ // suixinkanTests // +import UIKit import XCTest @testable import suixinkan @MainActor /// 登录页 ViewModel 测试,覆盖手机号规范化、表单校验和登录流程。 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"]) + } + + /// 普通门店身份选择直接回调,不再无条件弹出注销提示。 + 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) -> 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, + isCurrent: false + ) + } + + private func allSubviews(in view: UIView) -> [UIView] { + view.subviews.flatMap { [$0] + allSubviews(in: $0) } + } + func testNormalizeUsernameCountryCodeRemovesChinaPrefix() { let viewModel = LoginViewModel() viewModel.updateAccount("+86 186 5185 7230") diff --git a/suixinkanTests/PushNotificationTests.swift b/suixinkanTests/PushNotificationTests.swift index aaef0cc..73ae235 100644 --- a/suixinkanTests/PushNotificationTests.swift +++ b/suixinkanTests/PushNotificationTests.swift @@ -130,6 +130,25 @@ final class PushNotificationTests: XCTestCase { 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 { appStore.session.privacyAgreementAccepted = true let sdk = PushSDKMock(registrationID: "reg-id") diff --git a/suixinkanTests/SettingViewModelTests.swift b/suixinkanTests/SettingViewModelTests.swift index 402a865..dda1d84 100644 --- a/suixinkanTests/SettingViewModelTests.swift +++ b/suixinkanTests/SettingViewModelTests.swift @@ -3,11 +3,29 @@ // suixinkanTests // +import UIKit import XCTest @testable import suixinkan /// 设置中心 ViewModel 测试。 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() { let viewModel = SettingViewModel( infoDictionary: [ diff --git a/suixinkanTests/StoreAccountDeregistrationAPITests.swift b/suixinkanTests/StoreAccountDeregistrationAPITests.swift new file mode 100644 index 0000000..d2ebc92 --- /dev/null +++ b/suixinkanTests/StoreAccountDeregistrationAPITests.swift @@ -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) + } +} diff --git a/suixinkanTests/StoreAccountDeregistrationAccessTests.swift b/suixinkanTests/StoreAccountDeregistrationAccessTests.swift new file mode 100644 index 0000000..c562074 --- /dev/null +++ b/suixinkanTests/StoreAccountDeregistrationAccessTests.swift @@ -0,0 +1,393 @@ +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" + 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))) + 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) + } +} diff --git a/suixinkanTests/StoreAccountDeregistrationCombinedTests.swift b/suixinkanTests/StoreAccountDeregistrationCombinedTests.swift new file mode 100644 index 0000000..da45e15 --- /dev/null +++ b/suixinkanTests/StoreAccountDeregistrationCombinedTests.swift @@ -0,0 +1,618 @@ +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 { + /// 明确受理后立即调用一次退出,不等待后续GET;在途连点及成功后再次点击均不重发。 + func testAcceptedSubmissionExitsOnceWithoutPostSubmissionQuery() async throws { + let service = CombinedDeregistrationService() + service.walletWaived = true + service.pointsWaived = true + service.holdApply = true + service.onApply = { service.readError = APIError.networkFailed("成功后不应再查询") } + 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 unresolvedCount = 0 + let controller = StoreAccountDeregistrationViewController( + identityName: "测试门店", viewModel: model, api: service, + onUnresolvedSubmission: { unresolvedCount += 1 }, onSubmissionAccepted: { 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.statusCount, initialStatusCount + 1, "只保留提交前校验") + XCTAssertEqual(service.eligibilityCount, initialEligibilityCount + 1) + XCTAssertTrue(model.submissionAccepted) + 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: { logoutCount += 1 }) + let window = makeWindow(controller, size: UIScreen.main.bounds.size) + defer { close(window) } + await waitUntil { model.canContinue } + model.beginVerification() + 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(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? UITextField) + 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(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: { 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? UITextField) + code.text = "654321" + reason.text = "不再使用" + reason.sendActions(for: .editingChanged) + let submit = try XCTUnwrap(find(controller.view, "deregister.submit") as? UIButton) + submit.sendActions(for: .touchUpInside) + await waitUntil { controller.presentedViewController is UIAlertController } + let alert = try XCTUnwrap(controller.presentedViewController as? UIAlertController) + XCTAssertTrue(alert.message?.contains("提交成功后将退出登录") == true) + XCTAssertTrue(alert.message?.contains("当前测试身份") == true) + XCTAssertTrue(alert.message?.contains("7 天冷静期") == true) + XCTAssertTrue(alert.message?.contains("正式完成不可恢复") == true) + XCTAssertEqual(alert.actions.map(\.title), ["暂不提交", "确认提交申请"]) + XCTAssertTrue(service.mutations.isEmpty) + XCTAssertEqual(logoutCount, 0) + } + + /// 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 UIAlertController } + let alert = try XCTUnwrap(controller.presentedViewController as? UIAlertController) + XCTAssertEqual(alert.actions.map(\.title), ["暂不确认", "确认并继续"]) + XCTAssertTrue(alert.message?.contains("现金余额:¥0.00") == true) + XCTAssertTrue(alert.message?.contains("积分:0") == true) + XCTAssertTrue(alert.message?.contains("确认自愿放弃以上现金余额,并确认自愿放弃以上积分") == true) + 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 code = try XCTUnwrap(find(controller.view, "deregister.code") as? UITextField) + let reason = try XCTUnwrap(find(controller.view, "deregister.reason") as? UITextField) + 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) + XCTAssertFalse(submit.isEnabled) + capture(window, name: "redesign-verification") + code.text = "123456" + code.sendActions(for: .editingChanged) + XCTAssertFalse(submit.isEnabled) + reason.text = "测试" + reason.sendActions(for: .editingChanged) + XCTAssertTrue(submit.isEnabled) + let navigationFrame = controller.navigationController?.view.frame + code.becomeFirstResponder() + await waitUntil { controller.view.keyboardLayoutGuide.layoutFrame.height > controller.view.safeAreaInsets.bottom + 80 } + 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 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? + var statusOverride: StoreAccountDeregistrationStatus? + var statusCount = 0 + var eligibilityCount = 0 + var applyError: Error? + var onApply: (() -> Void)? + var holdApply = false + var applyContinuation: CheckedContinuation? + var holdNextStatus = false + var statusContinuation: CheckedContinuation? + + 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") + 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") } +} diff --git a/suixinkanTests/StoreAccountDeregistrationFixtures.swift b/suixinkanTests/StoreAccountDeregistrationFixtures.swift new file mode 100644 index 0000000..cc6ad7a --- /dev/null +++ b/suixinkanTests/StoreAccountDeregistrationFixtures.swift @@ -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)]) + } +} diff --git a/suixinkanTests/StoreAccountDeregistrationRootTests.swift b/suixinkanTests/StoreAccountDeregistrationRootTests.swift new file mode 100644 index 0000000..73a6b75 --- /dev/null +++ b/suixinkanTests/StoreAccountDeregistrationRootTests.swift @@ -0,0 +1,450 @@ +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) + } + + /// 新登录得到身份凭证后仍先核验,普通页面前台恢复不追加查询。 + func testLoginChecksStatusBeforeEnteringBusiness() 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? + 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") } +} diff --git a/suixinkanTests/StoreAccountDeregistrationViewModelTests.swift b/suixinkanTests/StoreAccountDeregistrationViewModelTests.swift new file mode 100644 index 0000000..a1365a5 --- /dev/null +++ b/suixinkanTests/StoreAccountDeregistrationViewModelTests.swift @@ -0,0 +1,622 @@ +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 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 testServerRejectionRequiresFreshEligibility() 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) + XCTAssertFalse(model.canContinue) + XCTAssertEqual(model.errorMessage, "验证码错误") + await model.refresh(api: service) + XCTAssertTrue(model.canContinue) + } + + /// 展示全部真实阻断项,初始加载不会调用任何修改接口。 + 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 lastCode: String? + var lastReason: String? + + init() throws { current = try StoreAccountDeregistrationFixtures.eligibility() } + func eligibility() async throws -> StoreAccountDeregistrationEligibility { + if let readError { throw readError } + return current + } + func status() async throws -> StoreAccountDeregistrationStatus { + 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) + } +}