新增线下收款记录和 ai 修图优化

This commit is contained in:
han xin
2026-08-21 15:51:52 +08:00
parent 6fe9928b49
commit 5cf4409bad
40 changed files with 5482 additions and 56 deletions
@@ -0,0 +1,134 @@
//
// AccountDeletionModels.swift
// suixinkan
//
import Foundation
/// 注销核验资产,使用中性文案展示当前账号拥有的数据与权益。
struct AccountDeletionAssetSummary: Codable, Hashable, Sendable {
/// 注销核验覆盖的资产类型。
enum Kind: String, Codable, CaseIterable, Sendable {
case wallet
case works
case projects
case cloudFiles
}
let kind: Kind
let title: String
let valueText: String
}
/// 注销前置核验结果,包含资产快照和注销影响说明。
struct AccountDeletionPrecheck: Equatable, Sendable {
let assets: [AccountDeletionAssetSummary]
let consequences: [String]
}
/// 注销申请当前状态。
enum AccountDeletionRequestStatus: String, Codable, Sendable {
case pending
case canceled
case completed
}
/// 注销申请记录,保存7天冷静期及用户确认的资产范围。
struct AccountDeletionRequest: Codable, Equatable, Sendable {
let id: UUID
let clientRequestID: UUID
let username: String
let submittedAt: Date
let scheduledDeletionAt: Date
var status: AccountDeletionRequestStatus
let acknowledgedAssetKinds: [AccountDeletionAssetSummary.Kind]
}
/// 登录时需要处理的注销状态。
enum AccountDeletionLoginState: Equatable, Sendable {
case none
case pending(AccountDeletionRequest)
case completed(AccountDeletionRequest)
}
/// 注销短信验证页所需的稳定上下文。
struct AccountDeletionVerificationContext: Equatable, Sendable {
let username: String
let maskedPhone: String
let assets: [AccountDeletionAssetSummary]
}
/// 注销业务错误,统一提供可直接展示的中文提示。
enum AccountDeletionError: LocalizedError, Equatable {
case missingIdentity
case invalidVerificationCode
case incompleteAcknowledgement
case noPendingRequest
case cancellationExpired
case deletionCompleted
case persistenceFailed
var errorDescription: String? {
switch self {
case .missingIdentity:
"当前登录账号缺少手机号,暂时无法提交注销"
case .invalidVerificationCode:
"验证码错误,请输入演示验证码 123456"
case .incompleteAcknowledgement:
"请先确认已了解全部资产和注销后果"
case .noPendingRequest:
"当前账号没有待取消的注销申请"
case .cancellationExpired:
"7天取消期限已过,无法恢复账号"
case .deletionCompleted:
"账号已完成注销,无法继续登录"
case .persistenceFailed:
"注销状态保存失败,请稍后重试"
}
}
}
/// 注销账号身份工具,统一手机号规范化、掩码和当前登录身份解析。
enum AccountDeletionIdentity {
/// 从当前会话读取登录手机号,优先使用登录页保存的原始账号。
static func currentUsername(session: AppSessionStore = AppStore.shared.session) -> String? {
let candidates = [session.lastLoginUsername, session.phone]
return candidates
.compactMap { $0 }
.map(normalizedUsername)
.first { !$0.isEmpty }
}
/// 将手机号账号规范化为纯数字,并兼容 +86 前缀。
static func normalizedUsername(_ value: String) -> String {
var digits = value.filter(\.isNumber)
if digits.hasPrefix("86"), digits.count == 13 {
digits.removeFirst(2)
}
return digits
}
/// 将手机号转换为用于页面展示的脱敏格式。
static func maskedPhone(_ value: String) -> String {
let username = normalizedUsername(value)
guard username.count == 11 else { return username }
let start = username.prefix(3)
let end = username.suffix(4)
return "\(start)****\(end)"
}
}
/// 注销时间格式化工具,统一成功页和登录恢复弹窗的中文时间。
enum AccountDeletionDateFormatter {
/// 将服务端计划删除时间格式化为中文日期时间。
static func displayText(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
formatter.timeZone = TimeZone(identifier: "Asia/Shanghai")
formatter.dateFormat = "yyyy年M月d日 HH:mm"
return formatter.string(from: date)
}
}
@@ -0,0 +1,198 @@
//
// AccountDeletionMockService.swift
// suixinkan
//
import Foundation
/// 注销服务协议,隔离前置核验、短信验证、提交和取消能力。
protocol AccountDeletionServing: AnyObject {
/// 加载整个登录账号的注销资产快照。
func loadPrecheck(username: String) async throws -> AccountDeletionPrecheck
/// 向当前绑定手机号发送注销验证码。
func sendVerificationCode(to username: String) async throws
/// 提交注销申请并开始7天冷静期。
func submitDeletion(
username: String,
verificationCode: String,
acknowledgedAssetKinds: Set<AccountDeletionAssetSummary.Kind>,
clientRequestID: UUID
) async throws -> AccountDeletionRequest
/// 查询指定登录账号在登录时应处理的注销状态。
func loginState(for username: String) -> AccountDeletionLoginState
/// 在7天冷静期内取消注销申请。
func cancelDeletion(username: String) throws -> AccountDeletionRequest
}
/// 注销功能 Mock 服务,使用 UserDefaults 完成可重复演示的7天注销闭环。
final class AccountDeletionMockService: AccountDeletionServing {
/// 应用内共享的注销 Mock 服务。
static let shared = AccountDeletionMockService()
/// Mock 短信验证码。
static let verificationCode = "123456"
private let defaults: UserDefaults
private let now: () -> Date
private let responseDelayNanoseconds: UInt64
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private let keyPrefix = "account_deletion_mock_v1_"
/// 创建注销 Mock 服务,测试可注入独立存储、时间和响应延迟。
init(
defaults: UserDefaults = .standard,
now: @escaping () -> Date = Date.init,
responseDelayNanoseconds: UInt64 = 180_000_000
) {
self.defaults = defaults
self.now = now
self.responseDelayNanoseconds = responseDelayNanoseconds
}
func loadPrecheck(username: String) async throws -> AccountDeletionPrecheck {
try await simulateDelay()
_ = try validateUsername(username)
return AccountDeletionPrecheck(
assets: [
AccountDeletionAssetSummary(kind: .wallet, title: "钱包余额", valueText: "¥286.50"),
AccountDeletionAssetSummary(kind: .works, title: "作品与相册", valueText: "36个"),
AccountDeletionAssetSummary(kind: .projects, title: "项目", valueText: "4个"),
AccountDeletionAssetSummary(kind: .cloudFiles, title: "云盘文件", valueText: "8.6 GB"),
],
consequences: [
"所有景区与门店账号将解除",
"个人作品和云盘文件将删除",
"提交后7天内再次登录可取消注销",
]
)
}
func sendVerificationCode(to username: String) async throws {
try await simulateDelay()
_ = try validateUsername(username)
}
func submitDeletion(
username: String,
verificationCode: String,
acknowledgedAssetKinds: Set<AccountDeletionAssetSummary.Kind>,
clientRequestID: UUID
) async throws -> AccountDeletionRequest {
try await simulateDelay()
let normalized = try validateUsername(username)
guard verificationCode == Self.verificationCode else {
throw AccountDeletionError.invalidVerificationCode
}
guard acknowledgedAssetKinds == Set(AccountDeletionAssetSummary.Kind.allCases) else {
throw AccountDeletionError.incompleteAcknowledgement
}
if let existing = storedRequest(for: normalized), existing.status == .pending {
return existing
}
if let existing = storedRequest(for: normalized), existing.status == .completed {
throw AccountDeletionError.deletionCompleted
}
let submittedAt = now()
guard let scheduledDeletionAt = Calendar(identifier: .gregorian)
.date(byAdding: .day, value: 7, to: submittedAt) else {
throw AccountDeletionError.persistenceFailed
}
let request = AccountDeletionRequest(
id: UUID(),
clientRequestID: clientRequestID,
username: normalized,
submittedAt: submittedAt,
scheduledDeletionAt: scheduledDeletionAt,
status: .pending,
acknowledgedAssetKinds: AccountDeletionAssetSummary.Kind.allCases
)
try save(request)
return request
}
func loginState(for username: String) -> AccountDeletionLoginState {
let normalized = AccountDeletionIdentity.normalizedUsername(username)
guard var request = storedRequest(for: normalized) else { return .none }
switch request.status {
case .canceled:
return .none
case .completed:
return .completed(request)
case .pending:
guard now() < request.scheduledDeletionAt else {
request.status = .completed
try? save(request)
return .completed(request)
}
return .pending(request)
}
}
func cancelDeletion(username: String) throws -> AccountDeletionRequest {
let normalized = try validateUsername(username)
guard var request = storedRequest(for: normalized) else {
throw AccountDeletionError.noPendingRequest
}
guard request.status == .pending else {
if request.status == .completed {
throw AccountDeletionError.cancellationExpired
}
throw AccountDeletionError.noPendingRequest
}
guard now() < request.scheduledDeletionAt else {
request.status = .completed
try save(request)
throw AccountDeletionError.cancellationExpired
}
request.status = .canceled
try save(request)
return request
}
/// 删除指定账号的 Mock 注销状态,仅供单元测试与调试使用。
func reset(username: String) {
let normalized = AccountDeletionIdentity.normalizedUsername(username)
defaults.removeObject(forKey: storageKey(username: normalized))
}
private func validateUsername(_ username: String) throws -> String {
let normalized = AccountDeletionIdentity.normalizedUsername(username)
guard normalized.count == 11, normalized.first == "1" else {
throw AccountDeletionError.missingIdentity
}
return normalized
}
private func storedRequest(for username: String) -> AccountDeletionRequest? {
guard let data = defaults.data(forKey: storageKey(username: username)) else { return nil }
return try? decoder.decode(AccountDeletionRequest.self, from: data)
}
private func save(_ request: AccountDeletionRequest) throws {
do {
let data = try encoder.encode(request)
defaults.set(data, forKey: storageKey(username: request.username))
} catch {
throw AccountDeletionError.persistenceFailed
}
}
private func storageKey(username: String) -> String {
keyPrefix + username
}
private func simulateDelay() async throws {
guard responseDelayNanoseconds > 0 else { return }
try await Task.sleep(nanoseconds: responseDelayNanoseconds)
}
}
@@ -0,0 +1,184 @@
//
// AccountDeletionViewModels.swift
// suixinkan
//
import Foundation
/// 注销资产核验页 ViewModel,管理资产快照、确认状态与验证页上下文。
final class AccountDeletionViewModel {
private let service: any AccountDeletionServing
let username: String
private(set) var assets: [AccountDeletionAssetSummary] = []
private(set) var consequences: [String] = []
private(set) var isAcknowledged = false
private(set) var isLoading = false
private(set) var errorMessage: String?
var onStateChange: (() -> Void)?
/// 创建注销核验 ViewModel,默认读取当前登录账号并使用共享 Mock 服务。
init(
username: String? = AccountDeletionIdentity.currentUsername(),
service: any AccountDeletionServing = AccountDeletionMockService.shared
) {
self.username = AccountDeletionIdentity.normalizedUsername(username ?? "")
self.service = service
}
/// 勾选后才允许进入短信验证。
var isContinueEnabled: Bool {
!assets.isEmpty && isAcknowledged && !isLoading
}
/// 资产加载完成后主按钮保持可点击,未勾选时由业务校验给出明确提示。
var isContinueButtonEnabled: Bool {
!assets.isEmpty && !isLoading
}
/// 拉取注销前置核验结果。
func load() async {
guard !isLoading else { return }
isLoading = true
errorMessage = nil
notifyStateChange()
defer {
isLoading = false
notifyStateChange()
}
do {
let precheck = try await service.loadPrecheck(username: username)
assets = precheck.assets
consequences = precheck.consequences
} catch {
errorMessage = error.localizedDescription
}
}
/// 切换用户对注销影响的确认状态。
func toggleAcknowledgement() {
isAcknowledged.toggle()
notifyStateChange()
}
/// 生成短信验证页上下文。
func makeVerificationContext() throws -> AccountDeletionVerificationContext {
guard isContinueEnabled else {
throw AccountDeletionError.incompleteAcknowledgement
}
guard !username.isEmpty else {
throw AccountDeletionError.missingIdentity
}
return AccountDeletionVerificationContext(
username: username,
maskedPhone: AccountDeletionIdentity.maskedPhone(username),
assets: assets
)
}
/// 使用当前服务创建短信验证 ViewModel,保持同一套 Mock 状态。
func makeVerificationViewModel() throws -> AccountDeletionVerificationViewModel {
try AccountDeletionVerificationViewModel(
context: makeVerificationContext(),
service: service
)
}
private func notifyStateChange() {
onStateChange?()
}
}
/// 注销短信验证页 ViewModel,负责验证码发送、校验和提交申请。
final class AccountDeletionVerificationViewModel {
private let service: any AccountDeletionServing
private let clientRequestID = UUID()
let context: AccountDeletionVerificationContext
private(set) var verificationCode = ""
private(set) var isSendingCode = false
private(set) var isSubmitting = false
private(set) var hasSentCode = false
private(set) var errorMessage: String?
var onStateChange: (() -> Void)?
/// 创建短信验证 ViewModel。
init(
context: AccountDeletionVerificationContext,
service: any AccountDeletionServing = AccountDeletionMockService.shared
) throws {
guard !context.username.isEmpty else {
throw AccountDeletionError.missingIdentity
}
self.context = context
self.service = service
}
/// 验证码满足6位数字且当前未提交时允许继续。
var isSubmitEnabled: Bool {
verificationCode.count == 6
&& verificationCode.allSatisfy(\.isNumber)
&& !isSubmitting
}
/// 更新用户输入的验证码,只保留前6位数字。
func updateVerificationCode(_ value: String) {
verificationCode = String(value.filter(\.isNumber).prefix(6))
errorMessage = nil
notifyStateChange()
}
/// 发送 Mock 短信验证码。
func sendVerificationCode() async {
guard !isSendingCode else { return }
isSendingCode = true
errorMessage = nil
notifyStateChange()
defer {
isSendingCode = false
notifyStateChange()
}
do {
try await service.sendVerificationCode(to: context.username)
hasSentCode = true
} catch {
errorMessage = error.localizedDescription
}
}
/// 提交注销申请并返回计划删除时间。
func submit() async throws -> AccountDeletionRequest {
guard isSubmitEnabled else {
throw AccountDeletionError.invalidVerificationCode
}
guard !isSubmitting else { throw CancellationError() }
isSubmitting = true
errorMessage = nil
notifyStateChange()
defer {
isSubmitting = false
notifyStateChange()
}
do {
return try await service.submitDeletion(
username: context.username,
verificationCode: verificationCode,
acknowledgedAssetKinds: Set(context.assets.map(\.kind)),
clientRequestID: clientRequestID
)
} catch {
errorMessage = error.localizedDescription
throw error
}
}
private func notifyStateChange() {
onStateChange?()
}
}