新增线下收款记录和 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?()
}
}
@@ -53,10 +53,11 @@ enum LoginValidationError: Equatable {
}
}
/// 登录结果实体,区分已完成登录和需要用户选择账号两种情况。
/// 登录结果实体,区分正常登录、账号选择和注销冷静期恢复三种情况。
enum LoginResolution {
case completed(V9AuthResponse, AccountSwitchAccount)
case needsAccountSelection(AccountSelectionPayload)
case accountDeletionPending(V9AuthResponse, AccountDeletionRequest)
}
/// 登录流程错误实体,表示 token、账号列表或账号 ID 异常。
@@ -0,0 +1,290 @@
//
// OfflineCollectionModels.swift
// suixinkan
//
import Foundation
/// 线下收款方式,与线上订单支付方式保持独立。
enum OfflineCollectionPaymentMethod: String, Codable, CaseIterable, Sendable, Hashable {
case wechat = "WECHAT"
case alipay = "ALIPAY"
case cash = "CASH"
/// 页面展示的中文名称。
var displayName: String {
switch self {
case .wechat: "微信"
case .alipay: "支付宝"
case .cash: "现金"
}
}
/// 页面使用的本地矢量图标资源名称。
var assetName: String {
switch self {
case .wechat: "payment_method_wechat"
case .alipay: "payment_method_alipay"
case .cash: "payment_method_cash"
}
}
}
/// 线下收款记录的补缴状态。
enum OfflineCollectionStatus: String, Codable, Sendable, Hashable {
case pending = "PENDING"
case settled = "SETTLED"
case overdue = "OVERDUE"
/// 状态对应的中文文案。
var displayName: String {
switch self {
case .pending: "待补缴"
case .settled: "已补缴"
case .overdue: "逾期未补缴"
}
}
}
/// 当前线下收款数据所属的用户、店铺与景区上下文。
struct OfflineCollectionContext: Codable, Sendable, Hashable {
let collectorId: String
let collectorName: String
let storeId: String
let storeName: String
let scenicId: String
let scenicName: String
/// 生成用于 UserDefaults 隔离不同账号和店铺数据的稳定键。
var storageScope: String {
[collectorId, storeId, scenicId]
.map { value in
value.unicodeScalars.map { CharacterSet.alphanumerics.contains($0) ? String($0) : "_" }.joined()
}
.joined(separator: "_")
}
/// 从当前登录上下文解析收款归属,缺少演示数据时使用文档约定的兜底值。
static func current(appStore: AppStore = .shared) -> OfflineCollectionContext {
let session = appStore.session
let stores = appStore.permissions.rolePermissionList().flatMap(\.store)
let currentStore = stores.first(where: { $0.id == session.currentStoreId }) ?? stores.first
let collectorName = [session.realName, session.userName, session.accountDisplayName]
.first { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
return OfflineCollectionContext(
collectorId: session.userId.nonEmptyValue ?? "U10086",
collectorName: collectorName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "张三",
storeId: currentStore.map { String($0.id) } ?? (session.currentStoreId > 0 ? String(session.currentStoreId) : "S1001"),
storeName: currentStore?.name.nonEmptyValue ?? "那拉提旅拍一店",
scenicId: session.currentScenicId > 0 ? String(session.currentScenicId) : "SC001",
scenicName: session.currentScenicName.nonEmptyValue ?? "那拉提景区"
)
}
}
/// 独立的线下收款记录,不包含任何订单字段。
struct OfflineCollectionRecord: Codable, Sendable, Hashable {
let id: String
let collectorId: String
let collectorName: String
let storeId: String
let storeName: String
let scenicId: String
let scenicName: String
let amountFen: Int
let paymentMethod: OfflineCollectionPaymentMethod
let registeredAt: Date
let receivedAt: Date
let businessDate: String
var status: OfflineCollectionStatus
var settlementBatchId: String?
var settledAt: Date?
}
/// 线下收款的独立补缴流水,仅关联本批次收款记录快照。
struct OfflineSettlementBatch: Codable, Sendable, Hashable {
let id: String
let businessDate: String
let collectorId: String
let collectorName: String
let storeId: String
let storeName: String
let scenicId: String
let scenicName: String
let recordIds: [String]
let recordCount: Int
let amountFen: Int
let paidAt: Date
let payerId: String
let payerName: String
let clientRequestId: String
/// 流水类型固定为线下收款补缴。
var transactionType: String { "OFFLINE_COLLECTION_SETTLEMENT" }
}
/// 某一营业日的线下收款汇总。
struct OfflineDailySummary: Sendable, Hashable {
let businessDate: String
let totalCount: Int
let totalAmountFen: Int
let settledCount: Int
let settledAmountFen: Int
let pendingCount: Int
let pendingAmountFen: Int
let hasOverdue: Bool
/// 按记录状态用整数“分”生成日汇总。
static func make(businessDate: String, records: [OfflineCollectionRecord]) -> OfflineDailySummary {
let settled = records.filter { $0.status == .settled }
let pending = records.filter { $0.status == .pending || $0.status == .overdue }
return OfflineDailySummary(
businessDate: businessDate,
totalCount: records.count,
totalAmountFen: records.reduce(0) { $0 + $1.amountFen },
settledCount: settled.count,
settledAmountFen: settled.reduce(0) { $0 + $1.amountFen },
pendingCount: pending.count,
pendingAmountFen: pending.reduce(0) { $0 + $1.amountFen },
hasOverdue: pending.contains { $0.status == .overdue }
)
}
}
/// 补缴确认时冻结的记录和金额快照,用于防止处理中新记录被误结清。
struct OfflineSettlementRequest: Sendable, Hashable {
let businessDate: String
let recordIds: [String]
let amountFen: Int
let clientRequestId: String
}
/// 登记成功后页面反馈所需的最小数据。
struct OfflineCollectionRegistrationReceipt: Sendable, Hashable {
let record: OfflineCollectionRecord
let summary: OfflineDailySummary
}
/// 线下收款 Mock 流程可识别的业务异常。
enum OfflineCollectionError: LocalizedError, Sendable, Equatable {
case invalidAmount
case createFailed
case noPendingRecords
case summaryMismatch
case recordsChanged
case settlementFailed
case persistenceFailed
/// 供页面展示的中文错误提示。
var errorDescription: String? {
switch self {
case .invalidAmount: "请输入0.01~99,999.99元的有效金额"
case .createFailed: "登记失败,请重试"
case .noPendingRecords: "当前营业日无待补缴记录"
case .summaryMismatch: "金额汇总异常,请刷新数据"
case .recordsChanged: "待补缴记录已变化,请重新确认"
case .settlementFailed: "补缴失败,请重试"
case .persistenceFailed: "本地数据保存失败,请重试"
}
}
}
/// 金额输入、整数分换算和展示的统一工具。
enum OfflineCollectionMoney {
static let maximumFen = 9_999_999
/// 判断文本是否可作为金额输入中间态。
static func acceptsEditingText(_ text: String) -> Bool {
guard !text.contains(where: { !$0.isNumber && $0 != "." }) else { return false }
let parts = text.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count <= 2 else { return false }
let wholeCount = parts.first?.count ?? 0
let fractionCount = parts.count == 2 ? parts[1].count : 0
return wholeCount <= 5 && fractionCount <= 2
}
/// 将用户输入精确转换为整数“分”,不经过浮点计算。
static func parseFen(_ rawValue: String) -> Int? {
let text = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, text == rawValue, acceptsEditingText(text), text != "." else { return nil }
let parts = text.split(separator: ".", omittingEmptySubsequences: false)
let wholeText = parts[0].isEmpty ? "0" : String(parts[0])
let fractionText = parts.count == 2 ? String(parts[1]) : ""
guard wholeText.allSatisfy(\.isNumber), fractionText.allSatisfy(\.isNumber),
let whole = Int(wholeText) else { return nil }
let cents: Int
switch fractionText.count {
case 0: cents = 0
case 1: cents = (Int(fractionText) ?? 0) * 10
case 2: cents = Int(fractionText) ?? 0
default: return nil
}
let amountFen = whole * 100 + cents
guard amountFen > 0, amountFen <= maximumFen else { return nil }
return amountFen
}
/// 将整数分格式化为带人民币符号且固定两位小数的文本。
static func display(_ amountFen: Int) -> String {
let sign = amountFen < 0 ? "-" : ""
let absolute = abs(amountFen)
return "\(sign)¥\(absolute / 100).\(String(format: "%02d", absolute % 100))"
}
}
/// 营业日与页面时间文案的统一格式化工具。
enum OfflineCollectionDate {
static let dailyCutoffTime = "23:59:59"
/// 按指定日历生成 YYYY-MM-DD 营业日。
static func businessDate(for date: Date, calendar: Calendar = .current) -> String {
let components = calendar.dateComponents([.year, .month, .day], from: date)
return String(
format: "%04d-%02d-%02d",
components.year ?? 0,
components.month ?? 0,
components.day ?? 0
)
}
/// 将营业日文本解析为当地日期。
static func date(from businessDate: String, calendar: Calendar = .current) -> Date? {
let formatter = DateFormatter()
formatter.calendar = calendar
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = calendar.timeZone
formatter.dateFormat = "yyyy-MM-dd"
return formatter.date(from: businessDate)
}
/// 将时间格式化为当日明细所需的 HH:mm。
static func timeText(_ date: Date, calendar: Calendar = .current) -> String {
let formatter = DateFormatter()
formatter.calendar = calendar
formatter.locale = Locale(identifier: "zh_CN")
formatter.timeZone = calendar.timeZone
formatter.dateFormat = "HH:mm"
return formatter.string(from: date)
}
/// 将时间格式化为补缴流水的完整时间。
static func dateTimeText(_ date: Date, calendar: Calendar = .current) -> String {
let formatter = DateFormatter()
formatter.calendar = calendar
formatter.locale = Locale(identifier: "zh_CN")
formatter.timeZone = calendar.timeZone
formatter.dateFormat = "yyyy-MM-dd HH:mm"
return formatter.string(from: date)
}
}
private extension String {
/// 去除首尾空白后的非空文本。
var nonEmptyValue: String? {
let value = trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
}
@@ -0,0 +1,359 @@
//
// OfflineCollectionMockService.swift
// suixinkan
//
import Foundation
/// 线下收款本地 Mock 服务,负责演示数据、持久化、逾期流转与幂等补缴。
final class OfflineCollectionMockService: @unchecked Sendable {
/// App 内共享的线下收款 Mock 数据源。
static let shared = OfflineCollectionMockService()
/// 用于本地持久化的数据快照。
private struct Snapshot: Codable, Equatable {
var records: [OfflineCollectionRecord]
var batches: [OfflineSettlementBatch]
}
private let defaults: UserDefaults
private let calendar: Calendar
private let nowProvider: @Sendable () -> Date
private let requestDelayNanoseconds: UInt64
private let lock = NSLock()
private var shouldFailNextCreation = false
private var shouldFailNextSettlement = false
/// 创建 Mock 服务,测试可注入独立 UserDefaults、时间与请求延迟。
init(
defaults: UserDefaults = .standard,
calendar: Calendar = .current,
nowProvider: @escaping @Sendable () -> Date = Date.init,
requestDelayNanoseconds: UInt64 = 450_000_000
) {
self.defaults = defaults
self.calendar = calendar
self.nowProvider = nowProvider
self.requestDelayNanoseconds = requestDelayNanoseconds
}
/// 当前设备时区下的今日营业日。
var todayBusinessDate: String {
OfflineCollectionDate.businessDate(for: nowProvider(), calendar: calendar)
}
/// 获取某营业日汇总。
func getDailySummary(date: String, context: OfflineCollectionContext) -> OfflineDailySummary {
lock.withLock {
var snapshot = loadSnapshotLocked(context: context)
refreshOverdueLocked(snapshot: &snapshot, context: context)
let records = matchingRecords(snapshot.records, date: date, context: context)
return OfflineDailySummary.make(businessDate: date, records: records)
}
}
/// 获取某营业日明细,按登记时间倒序返回。
func getDailyRecords(date: String, context: OfflineCollectionContext) -> [OfflineCollectionRecord] {
lock.withLock {
var snapshot = loadSnapshotLocked(context: context)
refreshOverdueLocked(snapshot: &snapshot, context: context)
return matchingRecords(snapshot.records, date: date, context: context)
.sorted { $0.registeredAt > $1.registeredAt }
}
}
/// 获取某营业日的成功补缴流水。
func getSettlementBatches(date: String, context: OfflineCollectionContext) -> [OfflineSettlementBatch] {
lock.withLock {
let snapshot = loadSnapshotLocked(context: context)
return snapshot.batches
.filter { batch in
batch.businessDate == date
&& batch.collectorId == context.collectorId
&& batch.storeId == context.storeId
&& batch.scenicId == context.scenicId
}
.sorted { $0.paidAt > $1.paidAt }
}
}
/// 获取当前用户与店铺的历史逾期营业日,按日期升序方便先处理最早欠款。
func getOverdueSummaries(context: OfflineCollectionContext) -> [OfflineDailySummary] {
lock.withLock {
var snapshot = loadSnapshotLocked(context: context)
refreshOverdueLocked(snapshot: &snapshot, context: context)
let overdueDates: Set<String> = Set(snapshot.records.compactMap { record -> String? in
guard matches(record, context: context), record.status == .overdue else { return nil }
return record.businessDate
})
return overdueDates.sorted().map { date in
OfflineDailySummary.make(
businessDate: date,
records: matchingRecords(snapshot.records, date: date, context: context)
)
}
}
}
/// 获取可查看的营业日,始终包含今日。
func getAvailableBusinessDates(context: OfflineCollectionContext) -> [String] {
lock.withLock {
var snapshot = loadSnapshotLocked(context: context)
refreshOverdueLocked(snapshot: &snapshot, context: context)
var dates = Set(snapshot.records.filter { matches($0, context: context) }.map(\.businessDate))
dates.insert(todayBusinessDate)
return dates.sorted(by: >)
}
}
/// 新建一条待补缴线下收款记录,不触发任何订单流程。
func createOfflineCollection(
amountFen: Int,
paymentMethod: OfflineCollectionPaymentMethod,
context: OfflineCollectionContext
) async throws -> OfflineCollectionRegistrationReceipt {
guard amountFen > 0, amountFen <= OfflineCollectionMoney.maximumFen else {
throw OfflineCollectionError.invalidAmount
}
await simulateRequestDelay()
return try lock.withLock {
if shouldFailNextCreation {
shouldFailNextCreation = false
throw OfflineCollectionError.createFailed
}
var snapshot = loadSnapshotLocked(context: context)
refreshOverdueLocked(snapshot: &snapshot, context: context)
let now = nowProvider()
let businessDate = OfflineCollectionDate.businessDate(for: now, calendar: calendar)
let sequence = snapshot.records.filter { $0.businessDate == businessDate }.count + 1
let record = OfflineCollectionRecord(
id: String(format: "OCR%@%04d", businessDate.replacingOccurrences(of: "-", with: ""), sequence),
collectorId: context.collectorId,
collectorName: context.collectorName,
storeId: context.storeId,
storeName: context.storeName,
scenicId: context.scenicId,
scenicName: context.scenicName,
amountFen: amountFen,
paymentMethod: paymentMethod,
registeredAt: now,
receivedAt: now,
businessDate: businessDate,
status: .pending,
settlementBatchId: nil,
settledAt: nil
)
snapshot.records.append(record)
try saveSnapshotLocked(snapshot, context: context)
let summary = OfflineDailySummary.make(
businessDate: businessDate,
records: matchingRecords(snapshot.records, date: businessDate, context: context)
)
return OfflineCollectionRegistrationReceipt(record: record, summary: summary)
}
}
/// 按确认时冻结的记录快照执行全额补缴,同一请求标识只生成一条流水。
func settleDailyCollections(
request: OfflineSettlementRequest,
context: OfflineCollectionContext
) async throws -> OfflineSettlementBatch {
await simulateRequestDelay()
return try lock.withLock {
var snapshot = loadSnapshotLocked(context: context)
refreshOverdueLocked(snapshot: &snapshot, context: context)
if let existing = snapshot.batches.first(where: { $0.clientRequestId == request.clientRequestId }) {
return existing
}
if shouldFailNextSettlement {
shouldFailNextSettlement = false
throw OfflineCollectionError.settlementFailed
}
let requestedIds = Set(request.recordIds)
guard !requestedIds.isEmpty, requestedIds.count == request.recordIds.count else {
throw OfflineCollectionError.noPendingRecords
}
let pendingRecords = snapshot.records.filter { record in
requestedIds.contains(record.id)
&& matches(record, context: context)
&& record.businessDate == request.businessDate
&& (record.status == .pending || record.status == .overdue)
}
guard pendingRecords.count == request.recordIds.count else {
throw OfflineCollectionError.recordsChanged
}
let calculatedAmount = pendingRecords.reduce(0) { $0 + $1.amountFen }
guard calculatedAmount == request.amountFen else {
throw OfflineCollectionError.summaryMismatch
}
let now = nowProvider()
let batchSequence = snapshot.batches.filter { $0.businessDate == request.businessDate }.count + 1
let batchId = String(
format: "OCS%@%04d",
request.businessDate.replacingOccurrences(of: "-", with: ""),
batchSequence
)
let batch = OfflineSettlementBatch(
id: batchId,
businessDate: request.businessDate,
collectorId: context.collectorId,
collectorName: context.collectorName,
storeId: context.storeId,
storeName: context.storeName,
scenicId: context.scenicId,
scenicName: context.scenicName,
recordIds: request.recordIds,
recordCount: request.recordIds.count,
amountFen: request.amountFen,
paidAt: now,
payerId: context.collectorId,
payerName: context.collectorName,
clientRequestId: request.clientRequestId
)
// 流水与记录在同一份本地快照内一次性更新,避免出现金额已减但流水缺失。
snapshot.batches.append(batch)
for index in snapshot.records.indices where requestedIds.contains(snapshot.records[index].id) {
snapshot.records[index].status = .settled
snapshot.records[index].settlementBatchId = batch.id
snapshot.records[index].settledAt = now
}
try saveSnapshotLocked(snapshot, context: context)
return batch
}
}
/// 开发环境中使下一次登记失败,用于验收输入保留和重试。
func simulateNextCreationFailure() {
lock.withLock { shouldFailNextCreation = true }
}
/// 开发环境中使下一次补缴失败,失败时不改变记录和流水。
func simulateNextSettlementFailure() {
lock.withLock { shouldFailNextSettlement = true }
}
/// 将当前上下文恢复为文档约定的默认演示数据。
func resetOfflineCollectionMockData(context: OfflineCollectionContext) {
lock.withLock {
let snapshot = makeDefaultSnapshot(context: context)
try? saveSnapshotLocked(snapshot, context: context)
shouldFailNextCreation = false
shouldFailNextSettlement = false
}
}
private func matchingRecords(
_ records: [OfflineCollectionRecord],
date: String,
context: OfflineCollectionContext
) -> [OfflineCollectionRecord] {
records.filter { $0.businessDate == date && matches($0, context: context) }
}
private func matches(_ record: OfflineCollectionRecord, context: OfflineCollectionContext) -> Bool {
record.collectorId == context.collectorId
&& record.storeId == context.storeId
&& record.scenicId == context.scenicId
}
private func refreshOverdueLocked(snapshot: inout Snapshot, context: OfflineCollectionContext) {
let today = todayBusinessDate
var changed = false
for index in snapshot.records.indices where matches(snapshot.records[index], context: context) {
guard snapshot.records[index].status != .settled else { continue }
let expected: OfflineCollectionStatus = snapshot.records[index].businessDate < today ? .overdue : .pending
if snapshot.records[index].status != expected {
snapshot.records[index].status = expected
changed = true
}
}
if changed {
try? saveSnapshotLocked(snapshot, context: context)
}
}
private func loadSnapshotLocked(context: OfflineCollectionContext) -> Snapshot {
let key = storageKey(context: context)
guard let data = defaults.data(forKey: key) else {
let snapshot = makeDefaultSnapshot(context: context)
try? saveSnapshotLocked(snapshot, context: context)
return snapshot
}
do {
return try JSONDecoder().decode(Snapshot.self, from: data)
} catch {
#if DEBUG
print("线下收款 Mock 数据损坏,已恢复默认数据:\(error)")
#endif
let snapshot = makeDefaultSnapshot(context: context)
try? saveSnapshotLocked(snapshot, context: context)
return snapshot
}
}
private func saveSnapshotLocked(_ snapshot: Snapshot, context: OfflineCollectionContext) throws {
do {
defaults.set(try JSONEncoder().encode(snapshot), forKey: storageKey(context: context))
} catch {
throw OfflineCollectionError.persistenceFailed
}
}
private func storageKey(context: OfflineCollectionContext) -> String {
"offline_collection_mock_v1_\(context.storageScope)"
}
private func makeDefaultSnapshot(context: OfflineCollectionContext) -> Snapshot {
let todayDate = nowProvider()
let today = OfflineCollectionDate.businessDate(for: todayDate, calendar: calendar)
let previousDate = calendar.date(byAdding: .day, value: -1, to: todayDate) ?? todayDate
let previousBusinessDate = OfflineCollectionDate.businessDate(for: previousDate, calendar: calendar)
let samples: [(String, Int, OfflineCollectionPaymentMethod, Date, OfflineCollectionStatus)] = [
("OCR\(today.replacingOccurrences(of: "-", with: ""))0001", 29_900, .wechat, time(on: todayDate, hour: 10, minute: 21), .pending),
("OCR\(today.replacingOccurrences(of: "-", with: ""))0002", 39_900, .alipay, time(on: todayDate, hour: 14, minute: 16), .pending),
("OCR\(today.replacingOccurrences(of: "-", with: ""))0003", 19_900, .wechat, time(on: todayDate, hour: 17, minute: 42), .pending),
("OCR\(previousBusinessDate.replacingOccurrences(of: "-", with: ""))0001", 30_000, .cash, time(on: previousDate, hour: 16, minute: 10), .overdue),
("OCR\(previousBusinessDate.replacingOccurrences(of: "-", with: ""))0002", 20_000, .wechat, time(on: previousDate, hour: 19, minute: 32), .overdue),
]
let records = samples.map { sample in
let businessDate = sample.3 < calendar.startOfDay(for: todayDate) ? previousBusinessDate : today
return OfflineCollectionRecord(
id: sample.0,
collectorId: context.collectorId,
collectorName: context.collectorName,
storeId: context.storeId,
storeName: context.storeName,
scenicId: context.scenicId,
scenicName: context.scenicName,
amountFen: sample.1,
paymentMethod: sample.2,
registeredAt: sample.3,
receivedAt: sample.3,
businessDate: businessDate,
status: sample.4,
settlementBatchId: nil,
settledAt: nil
)
}
return Snapshot(records: records, batches: [])
}
private func time(on date: Date, hour: Int, minute: Int) -> Date {
calendar.date(bySettingHour: hour, minute: minute, second: 0, of: date) ?? date
}
private func simulateRequestDelay() async {
guard requestDelayNanoseconds > 0 else { return }
try? await Task.sleep(nanoseconds: requestDelayNanoseconds)
}
}
@@ -0,0 +1,248 @@
//
// OfflineCollectionViewModels.swift
// suixinkan
//
import Foundation
/// 收款首页线下收款区域的 ViewModel。
final class OfflineCollectionHomeViewModel {
private(set) var todaySummary: OfflineDailySummary
private(set) var overdueSummaries: [OfflineDailySummary] = []
let context: OfflineCollectionContext
let service: OfflineCollectionMockService
var onStateChange: (() -> Void)?
/// 使用当前账号上下文创建首页 ViewModel。
init(
context: OfflineCollectionContext = .current(),
service: OfflineCollectionMockService = .shared
) {
self.context = context
self.service = service
todaySummary = OfflineDailySummary.make(businessDate: service.todayBusinessDate, records: [])
load()
}
/// 当前今日营业日。
var todayBusinessDate: String { service.todayBusinessDate }
/// 最早一个需要处理的逾期营业日。
var earliestOverdueBusinessDate: String? { overdueSummaries.first?.businessDate }
/// 历史逾期记录的合计笔数。
var overdueRecordCount: Int { overdueSummaries.reduce(0) { $0 + $1.pendingCount } }
/// 历史逾期记录的合计待补缴金额。
var overdueAmountFen: Int { overdueSummaries.reduce(0) { $0 + $1.pendingAmountFen } }
/// 从本地 Mock 服务重新读取今日和逾期汇总。
func load() {
todaySummary = service.getDailySummary(date: todayBusinessDate, context: context)
overdueSummaries = service.getOverdueSummaries(context: context)
onStateChange?()
}
}
/// 线下收款登记页 ViewModel,管理表单校验、提交防重和成功反馈。
final class OfflineCollectionRegistrationViewModel {
private(set) var amountText = ""
private(set) var paymentMethod: OfflineCollectionPaymentMethod? = .wechat
private(set) var isSubmitting = false
private(set) var receipt: OfflineCollectionRegistrationReceipt?
let context: OfflineCollectionContext
let service: OfflineCollectionMockService
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var onRegistrationSuccess: ((OfflineCollectionRegistrationReceipt) -> Void)?
/// 使用指定收款上下文和 Mock 服务创建登记 ViewModel。
init(
context: OfflineCollectionContext = .current(),
service: OfflineCollectionMockService = .shared
) {
self.context = context
self.service = service
}
/// 当前金额和收款方式是否允许提交。
var canSubmit: Bool {
!isSubmitting && OfflineCollectionMoney.parseFen(amountText) != nil && paymentMethod != nil
}
/// 更新金额输入,仅接受数字、单个小数点和最多两位小数。
func updateAmount(_ value: String) {
guard OfflineCollectionMoney.acceptsEditingText(value) else { return }
amountText = value
receipt = nil
onStateChange?()
}
/// 选择线下收款方式。
func selectPaymentMethod(_ method: OfflineCollectionPaymentMethod) {
paymentMethod = method
receipt = nil
onStateChange?()
}
/// 提交一笔线下收款登记,提交中的重复调用会被忽略。
func submit() async {
guard !isSubmitting else { return }
guard let amountFen = OfflineCollectionMoney.parseFen(amountText) else {
onShowMessage?(OfflineCollectionError.invalidAmount.localizedDescription)
return
}
guard let paymentMethod else {
onShowMessage?("请选择收款方式")
return
}
isSubmitting = true
onStateChange?()
defer {
isSubmitting = false
onStateChange?()
}
do {
let result = try await service.createOfflineCollection(
amountFen: amountFen,
paymentMethod: paymentMethod,
context: context
)
receipt = result
onRegistrationSuccess?(result)
} catch {
onShowMessage?(error.localizedDescription)
}
}
/// 登记成功后开始下一笔,清空金额并保留本次收款方式。
func startAnotherRegistration() {
amountText = ""
receipt = nil
onStateChange?()
}
}
/// 日清页的补缴处理状态。
enum OfflineSettlementPresentationState: Sendable, Equatable {
case idle
case processing
case success(OfflineSettlementBatch)
case failed(String)
}
/// 线下收款日清页 ViewModel,负责单营业日汇总、快照冻结和全额补缴。
final class OfflineCollectionDailyViewModel {
private(set) var businessDate: String
private(set) var summary: OfflineDailySummary
private(set) var records: [OfflineCollectionRecord] = []
private(set) var batches: [OfflineSettlementBatch] = []
private(set) var availableBusinessDates: [String] = []
private(set) var settlementState: OfflineSettlementPresentationState = .idle
private(set) var preparedRequest: OfflineSettlementRequest?
let context: OfflineCollectionContext
let service: OfflineCollectionMockService
var onStateChange: (() -> Void)?
/// 使用指定营业日、收款上下文和 Mock 服务创建日清 ViewModel。
init(
businessDate: String,
context: OfflineCollectionContext = .current(),
service: OfflineCollectionMockService = .shared
) {
self.businessDate = businessDate
self.context = context
self.service = service
summary = OfflineDailySummary.make(businessDate: businessDate, records: [])
load()
}
/// 当前查看的是否为今日营业日。
var isToday: Bool { businessDate == service.todayBusinessDate }
/// 当前是否可以发起补缴。
var canSettle: Bool {
summary.pendingAmountFen > 0 && summary.pendingCount > 0 && settlementState != .processing
}
/// 从本地 Mock 服务加载当前营业日的全部数据。
func load() {
records = service.getDailyRecords(date: businessDate, context: context)
summary = service.getDailySummary(date: businessDate, context: context)
batches = service.getSettlementBatches(date: businessDate, context: context)
availableBusinessDates = service.getAvailableBusinessDates(context: context)
onStateChange?()
}
/// 切换至已存在数据或今日的营业日。
func selectBusinessDate(_ date: String) {
guard availableBusinessDates.contains(date), settlementState != .processing else { return }
businessDate = date
preparedRequest = nil
settlementState = .idle
load()
}
/// 按页面当前待补缴记录冻结一份不可编辑的补缴快照。
func prepareSettlement() throws -> OfflineSettlementRequest {
guard canSettle else { throw OfflineCollectionError.noPendingRecords }
let pendingRecords = records.filter { $0.status == .pending || $0.status == .overdue }
let amountFen = pendingRecords.reduce(0) { $0 + $1.amountFen }
guard pendingRecords.count == summary.pendingCount, amountFen == summary.pendingAmountFen else {
throw OfflineCollectionError.summaryMismatch
}
let request = OfflineSettlementRequest(
businessDate: businessDate,
recordIds: pendingRecords.map(\.id),
amountFen: amountFen,
clientRequestId: "LOCAL-\(UUID().uuidString.uppercased())"
)
preparedRequest = request
return request
}
/// 取消未提交的补缴确认快照。
func cancelPreparedSettlement() {
guard settlementState != .processing else { return }
preparedRequest = nil
}
/// 提交已冻结的补缴快照,失败后保留快照以便原样重试。
func confirmSettlement() async {
guard settlementState != .processing else { return }
guard let request = preparedRequest else {
settlementState = .failed(OfflineCollectionError.noPendingRecords.localizedDescription)
onStateChange?()
return
}
settlementState = .processing
onStateChange?()
do {
let batch = try await service.settleDailyCollections(request: request, context: context)
preparedRequest = nil
settlementState = .success(batch)
load()
} catch {
settlementState = .failed(error.localizedDescription)
load()
}
}
/// 将成功或失败反馈恢复为可操作状态。
func clearSettlementFeedback() {
guard settlementState != .processing else { return }
settlementState = .idle
onStateChange?()
}
/// 开发环境下将下一次 Mock 补缴设为失败。
func simulateNextSettlementFailure() {
service.simulateNextSettlementFailure()
}
}
@@ -0,0 +1,122 @@
//
// TravelAlbumAutoRetouchModels.swift
// suixinkan
//
import Foundation
/// 自动修图第一级方式,页面上始终只展示“不修图”和“AI 修图”两个选项。
enum TravelAlbumRetouchMode: String, CaseIterable, Hashable, Sendable {
case disabled
case aiRetouch
/// 修图方式的用户可见名称。
var title: String {
switch self {
case .disabled:
return "不修图"
case .aiRetouch:
return "AI 修图"
}
}
}
/// 相册自动 AI 修图配置,用于在新建相册与照片上传页之间传递模板选择。
struct TravelAlbumAutoRetouchConfiguration: Codable, Equatable, Sendable {
let isEnabled: Bool
let templateID: String?
/// 默认不启用自动修图。
static let disabled = TravelAlbumAutoRetouchConfiguration(isEnabled: false, templateID: nil)
/// 根据已选模板创建启用状态配置。
static func enabled(templateID: String) -> TravelAlbumAutoRetouchConfiguration {
TravelAlbumAutoRetouchConfiguration(isEnabled: true, templateID: templateID)
}
/// 当前配置对应的修图模板。
var template: TravelAlbumEditPreset? {
guard isEnabled, let templateID else { return nil }
return TravelAlbumEditPreset.autoRetouchOptions.first { $0.id == templateID }
}
/// 是否可以作为完整的自动修图配置提交。
var isValid: Bool {
!isEnabled || template != nil
}
/// 上传页紧凑设置项的展示文案。
var uploadOptionTitle: String {
isEnabled ? TravelAlbumRetouchMode.aiRetouch.title : TravelAlbumRetouchMode.disabled.title
}
}
extension TravelAlbumEditPreset {
/// 自动修图可选模板,不包含手动修图中的“还原为原图”。
static var autoRetouchOptions: [TravelAlbumEditPreset] {
defaultOptions.filter { $0.effect != .original }
}
}
/// 单张 OTG 照片的自动 AI 修图状态,与原有上传状态分开计算。
enum TravelAlbumAutoRetouchState: String, Codable, Equatable, Sendable {
case none = "NONE"
case processing = "PROCESSING"
case completed = "COMPLETED"
case failed = "FAILED"
}
/// 相册自动修图配置读写接口,便于 ViewModel 与单元测试注入。
protocol TravelAlbumAutoRetouchConfigurationStoring: AnyObject {
/// 读取指定服务端相册的配置。
func configuration(albumID: Int) -> TravelAlbumAutoRetouchConfiguration
/// 保存指定服务端相册的配置。
func save(_ configuration: TravelAlbumAutoRetouchConfiguration, albumID: Int)
/// 删除指定相册的本地配置。
func remove(albumID: Int)
}
/// 使用 UserDefaults 按服务端相册 ID 持久化自动 AI 修图配置。
final class TravelAlbumAutoRetouchConfigurationStore: TravelAlbumAutoRetouchConfigurationStoring {
static let shared = TravelAlbumAutoRetouchConfigurationStore()
private let userDefaults: UserDefaults
private let keyPrefix: String
/// 创建配置存储;测试可传入独立 UserDefaults suite。
init(
userDefaults: UserDefaults = .standard,
keyPrefix: String = "travelAlbum.autoRetouch.album"
) {
self.userDefaults = userDefaults
self.keyPrefix = keyPrefix
}
func configuration(albumID: Int) -> TravelAlbumAutoRetouchConfiguration {
guard albumID > 0,
let data = userDefaults.data(forKey: key(albumID: albumID)),
let configuration = try? JSONDecoder().decode(TravelAlbumAutoRetouchConfiguration.self, from: data),
configuration.isValid else {
return .disabled
}
return configuration
}
func save(_ configuration: TravelAlbumAutoRetouchConfiguration, albumID: Int) {
guard albumID > 0 else { return }
let normalized = configuration.isValid ? configuration : .disabled
guard let data = try? JSONEncoder().encode(normalized) else { return }
userDefaults.set(data, forKey: key(albumID: albumID))
}
func remove(albumID: Int) {
guard albumID > 0 else { return }
userDefaults.removeObject(forKey: key(albumID: albumID))
}
private func key(albumID: Int) -> String {
"\(keyPrefix).\(albumID)"
}
}
@@ -19,6 +19,8 @@ struct TravelAlbumOTGPhotoItem: Hashable, Sendable {
var errorMessage: String?
let localPath: String
var remoteUrl: String
var autoRetouchState: TravelAlbumAutoRetouchState
var autoRetouchTemplateId: String?
/// 是否未上传完成。
var isNotUploaded: Bool {
@@ -346,7 +348,8 @@ enum TravelAlbumOTGPhotoFormatMatcher {
extension TravelAlbumOTGPhotoRecord {
/// 转为页面展示项。
func toPhotoItem(storage: TravelAlbumOTGPhotoStore, albumId: Int) -> TravelAlbumOTGPhotoItem {
let previewPath = [thumbnailPath, localPath, remoteUrl].first { path in
let preferredRetouchedPath = autoRetouchState == .completed ? retouchedPath : ""
let previewPath = [preferredRetouchedPath, thumbnailPath, localPath, remoteUrl].first { path in
guard !path.isEmpty else { return false }
if path.hasPrefix("http") { return true }
return storage.fileExists(relativePath: path, albumId: albumId)
@@ -371,7 +374,9 @@ extension TravelAlbumOTGPhotoRecord {
progress: progress,
errorMessage: errorMessage,
localPath: localPath,
remoteUrl: remoteUrl
remoteUrl: remoteUrl,
autoRetouchState: autoRetouchState,
autoRetouchTemplateId: autoRetouchTemplateId
)
}
}
@@ -56,6 +56,10 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
let albumId: Int
let userId: String
var remoteUrl: String
var materialId: Int?
var autoRetouchState: TravelAlbumAutoRetouchState
var autoRetouchTemplateId: String?
var retouchedPath: String
var updatedAt: Int64
/// 创建 OTG 本地照片记录。
@@ -74,6 +78,10 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
albumId: Int,
userId: String,
remoteUrl: String = "",
materialId: Int? = nil,
autoRetouchState: TravelAlbumAutoRetouchState = .none,
autoRetouchTemplateId: String? = nil,
retouchedPath: String = "",
updatedAt: Int64 = Int64(Date().timeIntervalSince1970 * 1000)
) {
self.id = id
@@ -90,12 +98,17 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
self.albumId = albumId
self.userId = userId
self.remoteUrl = remoteUrl
self.materialId = materialId
self.autoRetouchState = autoRetouchState
self.autoRetouchTemplateId = autoRetouchTemplateId
self.retouchedPath = retouchedPath
self.updatedAt = updatedAt
}
private enum CodingKeys: String, CodingKey {
case id, sourceId, clientPhotoId, fileName, localPath, thumbnailPath, capturedAt
case fileSizeBytes, status, progress, errorMessage, albumId, userId, remoteUrl, updatedAt
case fileSizeBytes, status, progress, errorMessage, albumId, userId, remoteUrl, materialId
case autoRetouchState, autoRetouchTemplateId, retouchedPath, updatedAt
}
/// 解码本地索引;旧版本缺少 `clientPhotoId` 时先保留为空,由 Store 一次性迁移并回写。
@@ -115,16 +128,30 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
albumId = try container.decode(Int.self, forKey: .albumId)
userId = try container.decode(String.self, forKey: .userId)
remoteUrl = try container.decodeIfPresent(String.self, forKey: .remoteUrl) ?? ""
materialId = try container.decodeIfPresent(Int.self, forKey: .materialId)
autoRetouchState = try container.decodeIfPresent(
TravelAlbumAutoRetouchState.self,
forKey: .autoRetouchState
) ?? .none
autoRetouchTemplateId = try container.decodeIfPresent(String.self, forKey: .autoRetouchTemplateId)
retouchedPath = try container.decodeIfPresent(String.self, forKey: .retouchedPath) ?? ""
updatedAt = try container.decodeIfPresent(Int64.self, forKey: .updatedAt) ?? 0
}
/// 把中断中的传输恢复为待上传,避免重进页面卡在上传中。
func normalizedAfterInterruptedTransfer() -> TravelAlbumOTGPhotoRecord {
guard status == .transferring || status == .uploading else { return self }
let interruptedUpload = status == .transferring || status == .uploading
let interruptedRetouch = autoRetouchState == .processing
guard interruptedUpload || interruptedRetouch else { return self }
var copy = self
copy.status = .pending
copy.progress = 0
copy.errorMessage = nil
if interruptedUpload {
copy.status = copy.materialId == nil ? .pending : .uploaded
copy.progress = copy.materialId == nil ? 0 : 100
copy.errorMessage = nil
}
if interruptedRetouch {
copy.autoRetouchState = copy.materialId == nil ? .none : .failed
}
copy.updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
return copy
}
@@ -242,7 +269,7 @@ final class TravelAlbumOTGPhotoStore {
?? TravelAlbumClientPhotoID.make()
return copy.normalizedAfterInterruptedTransfer()
}
if migrated.map(\.clientPhotoId) != scoped.map(\.clientPhotoId) {
if migrated != scoped {
save(migrated, albumId: albumId)
}
return migrated
@@ -333,6 +360,22 @@ final class TravelAlbumOTGPhotoStore {
return url
}
/// 写入自动 AI 修图结果,返回当前相册预览目录中的文件 URL。
func writeRetouchedImage(_ data: Data, filename: String, albumId: Int) throws -> URL {
let directory = try previewsDirectory(albumId: albumId)
let sanitized = PTPHelper.sanitizeFilename(filename)
let stem = (sanitized as NSString).deletingPathExtension
let preferredName = "\(stem)_retouched.jpg"
var candidate = directory.appendingPathComponent(preferredName)
var counter = 1
while fileManager.fileExists(atPath: candidate.path), counter < 10_000 {
candidate = directory.appendingPathComponent("\(stem)_retouched_\(counter).jpg")
counter += 1
}
try data.write(to: candidate, options: .atomic)
return candidate
}
/// 返回写入索引用的相对路径。
func relativePath(for url: URL, albumId: Int) -> String {
let path = url.standardizedFileURL.path
@@ -382,6 +425,9 @@ final class TravelAlbumOTGPhotoStore {
if let thumbnailURL = absoluteURL(for: record.thumbnailPath, albumId: albumId) {
resolved.thumbnailPath = thumbnailURL.path
}
if let retouchedURL = absoluteURL(for: record.retouchedPath, albumId: albumId) {
resolved.retouchedPath = retouchedURL.path
}
return resolved
}
@@ -433,7 +479,7 @@ final class TravelAlbumOTGPhotoStore {
}
private func deleteFiles(_ record: TravelAlbumOTGPhotoRecord) {
[record.localPath, record.thumbnailPath].forEach { path in
[record.localPath, record.thumbnailPath, record.retouchedPath].forEach { path in
guard !path.isEmpty else { return }
guard let url = absoluteURL(for: path, albumId: record.albumId) else { return }
try? fileManager.removeItem(at: url)
@@ -463,6 +509,11 @@ final class TravelAlbumOTGPhotoStore {
? relativePath(for: URL(fileURLWithPath: record.thumbnailPath), albumId: albumId)
: record.thumbnailPath
}
if !record.retouchedPath.isEmpty {
normalized.retouchedPath = record.retouchedPath.hasPrefix("/")
? relativePath(for: URL(fileURLWithPath: record.retouchedPath), albumId: albumId)
: record.retouchedPath
}
return normalized
}
@@ -90,6 +90,18 @@ final class TravelAlbumAIEditResultStore {
notifyChange(materialID: materialID)
}
/// 使用已编码图片数据保存自动修图结果,供无 UIKit 依赖的 ViewModel 调用。
@discardableResult
func completeFromImageData(
materialID: Int,
presetID: String,
editedImageData: Data
) -> Bool {
guard let image = UIImage(data: editedImageData) else { return false }
complete(materialID: materialID, presetID: presetID, editedImage: image)
return true
}
/// 修图失败时恢复按钮可用状态,并保留之前成功的结果。
func failProcessing(materialID: Int) {
guard var record = recordsByMaterialID[materialID] else { return }
@@ -0,0 +1,59 @@
//
// TravelAlbumAutoRetouchProcessor.swift
// suixinkan
//
import Foundation
import UIKit
/// 自动 AI 修图处理接口,把本地原图与已选模板转换为可持久化的结果数据。
@MainActor
protocol TravelAlbumAutoRetouchProcessing {
/// 按指定模板处理一张本地照片。
func process(sourceURL: URL, preset: TravelAlbumEditPreset) async throws -> Data
}
/// 使用项目现有 Core Image 效果生成自动修图演示结果。
@MainActor
final class TravelAlbumAutoRetouchProcessor: TravelAlbumAutoRetouchProcessing {
private let processingDelayNanoseconds: UInt64
/// 创建本地修图处理器,默认保留短暂处理时间用于展示“修图中”状态。
init(processingDelayNanoseconds: UInt64 = 900_000_000) {
self.processingDelayNanoseconds = processingDelayNanoseconds
}
func process(sourceURL: URL, preset: TravelAlbumEditPreset) async throws -> Data {
let sourceData = try Data(contentsOf: sourceURL)
guard let sourceImage = UIImage(data: sourceData) else {
throw TravelAlbumAutoRetouchError.invalidSourceImage
}
if processingDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: processingDelayNanoseconds)
}
try Task.checkCancellation()
let editedImage = TravelAlbumAIEditImageProcessor.render(effect: preset.effect, source: sourceImage)
guard let resultData = editedImage.jpegData(compressionQuality: 0.92) else {
throw TravelAlbumAutoRetouchError.resultEncodingFailed
}
return resultData
}
}
/// 本地自动 AI 修图过程的可展示错误。
enum TravelAlbumAutoRetouchError: LocalizedError, Equatable {
case invalidSourceImage
case resultEncodingFailed
case invalidImageData
var errorDescription: String? {
switch self {
case .invalidSourceImage:
return "原图无法读取,修图失败"
case .resultEncodingFailed:
return "修图结果生成失败"
case .invalidImageData:
return "修图结果无法读取"
}
}
}
@@ -42,13 +42,16 @@ final class TravelAlbumEntryViewModel {
private let currentScenicIdProvider: () -> Int
private let dateProvider: () -> Date
private let autoRetouchConfigurationStore: any TravelAlbumAutoRetouchConfigurationStoring
init(
currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId },
dateProvider: @escaping () -> Date = Date.init
dateProvider: @escaping () -> Date = Date.init,
autoRetouchConfigurationStore: any TravelAlbumAutoRetouchConfigurationStoring = TravelAlbumAutoRetouchConfigurationStore.shared
) {
self.currentScenicIdProvider = currentScenicIdProvider
self.dateProvider = dateProvider
self.autoRetouchConfigurationStore = autoRetouchConfigurationStore
}
/// 重新拉取相册列表。
@@ -110,6 +113,7 @@ final class TravelAlbumEntryViewModel {
freeCount: String,
singlePrice: String,
packagePrice: String,
autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled,
order: TravelAlbumAvailableOrder?,
api: any TravelAlbumServing
) async {
@@ -132,6 +136,10 @@ final class TravelAlbumEntryViewModel {
onShowMessage?("请输入有效的单张照片价格")
return
}
if !autoRetouchConfiguration.isValid {
onShowMessage?("请选择修图模板")
return
}
let albumName: String
switch mode {
@@ -174,6 +182,7 @@ final class TravelAlbumEntryViewModel {
do {
let response = try await api.create(request)
autoRetouchConfigurationStore.save(autoRetouchConfiguration, albumID: response.id)
isCreateSheetVisible = false
onShowMessage?("任务创建成功")
onCreatedAlbum?(response)
@@ -63,7 +63,7 @@ final class WiredCameraTransferViewModel {
private(set) var sonyMTPHint: String?
private(set) var isContentCatalogReady = false
let retouchOption = "不修图"
private(set) var autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration
private(set) var photoFormatOption: TravelAlbumOTGPhotoFormatOption = .jpg
private(set) var transferMode: TravelAlbumOTGTransferMode = .liveUpload {
didSet { notifyStateChanged() }
@@ -80,6 +80,9 @@ final class WiredCameraTransferViewModel {
private let api: any TravelAlbumServing
private let appStore: AppStore
private let userDefaults: UserDefaults
private let autoRetouchConfigurationStore: any TravelAlbumAutoRetouchConfigurationStoring
private let autoRetouchProcessor: any TravelAlbumAutoRetouchProcessing
private let aiEditResultStore: TravelAlbumAIEditResultStore
private var currentDriver: CameraDriver?
private var persistedRecordsById: [String: TravelAlbumOTGPhotoRecord] = [:]
@@ -100,7 +103,10 @@ final class WiredCameraTransferViewModel {
uploader: (any TravelAlbumOTGUploading)? = nil,
api: (any TravelAlbumServing)? = nil,
appStore: AppStore = .shared,
userDefaults: UserDefaults = .standard
userDefaults: UserDefaults = .standard,
autoRetouchConfigurationStore: any TravelAlbumAutoRetouchConfigurationStoring = TravelAlbumAutoRetouchConfigurationStore.shared,
autoRetouchProcessor: (any TravelAlbumAutoRetouchProcessing)? = nil,
aiEditResultStore: TravelAlbumAIEditResultStore? = nil
) {
self.albumId = albumId
self.albumTitle = albumTitle.isEmpty ? "有线传输" : albumTitle
@@ -113,6 +119,10 @@ final class WiredCameraTransferViewModel {
self.api = api ?? NetworkServices.shared.travelAlbumAPI
self.appStore = appStore
self.userDefaults = userDefaults
self.autoRetouchConfigurationStore = autoRetouchConfigurationStore
self.autoRetouchProcessor = autoRetouchProcessor ?? TravelAlbumAutoRetouchProcessor()
self.aiEditResultStore = aiEditResultStore ?? .shared
self.autoRetouchConfiguration = autoRetouchConfigurationStore.configuration(albumID: albumId)
// 从相册管理选择模式进入时,以本次选择为准;其他旧入口继续沿用上次记录。
self.transferMode = initialTransferMode ?? Self.persistedTransferMode(in: userDefaults)
if let initialTransferMode {
@@ -218,6 +228,11 @@ final class WiredCameraTransferViewModel {
transferMode.title
}
/// 自动 AI 修图设置项展示文案。
var retouchOption: String {
autoRetouchConfiguration.uploadOptionTitle
}
/// 指定上传弹窗选项。
var specifyUploadOptions: [TravelAlbumOTGSpecifyUploadOption] {
TravelAlbumOTGSpecifyUploadOption.allCases
@@ -306,6 +321,15 @@ final class WiredCameraTransferViewModel {
userDefaults.set(mode.rawValue, forKey: Self.transferModeDefaultsKey)
}
/// 更新当前相册的自动 AI 修图配置,只影响尚未开始的上传任务。
func updateAutoRetouchConfiguration(_ configuration: TravelAlbumAutoRetouchConfiguration) {
let normalized = configuration.isValid ? configuration : .disabled
guard normalized != autoRetouchConfiguration else { return }
autoRetouchConfiguration = normalized
autoRetouchConfigurationStore.save(normalized, albumID: albumId)
notifyStateChanged()
}
/// 切换上传格式。
func selectPhotoFormat(_ option: TravelAlbumOTGPhotoFormatOption) {
guard photoFormatOption != option else { return }
@@ -467,8 +491,31 @@ final class WiredCameraTransferViewModel {
startUploadByIds([photoId], emptyMessage: "本地文件不存在,无法重传")
}
/// 使用该照片上次失败时的模板重试自动修图,不重复上传原图。
func retryAutoRetouch(photoId: String) {
guard let record = persistedRecordsById[photoId],
record.autoRetouchState == .failed,
let materialId = record.materialId,
let templateID = record.autoRetouchTemplateId,
let preset = TravelAlbumEditPreset.autoRetouchOptions.first(where: { $0.id == templateID }) else {
showMessage("暂无可重试的修图任务")
return
}
Task {
await processAutoRetouch(
photoId: photoId,
sourceRecord: record,
materialId: materialId,
preset: preset
)
}
}
/// 删除单张本地照片。
func deletePhoto(photoId: String) {
if let materialId = persistedRecordsById[photoId]?.materialId {
aiEditResultStore.remove(materialID: materialId)
}
storage.remove(albumId: albumId, photoId: photoId)
persistedRecordsById.removeValue(forKey: photoId)
selectedPhotoIds.remove(photoId)
@@ -478,6 +525,9 @@ final class WiredCameraTransferViewModel {
/// 清空当前相册本地 OTG 缓存。
func clearLocalAlbumCache() {
persistedRecordsById.values.compactMap(\.materialId).forEach { materialId in
aiEditResultStore.remove(materialID: materialId)
}
storage.clearAlbum(albumId: albumId)
persistedRecordsById = [:]
photos = []
@@ -492,9 +542,27 @@ final class WiredCameraTransferViewModel {
private func loadPersistedPhotos() {
let records = storage.load(albumId: albumId)
persistedRecordsById = Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0) })
restorePersistedAutoRetouchResults(records)
applyMergedPhotos()
}
private func restorePersistedAutoRetouchResults(_ records: [TravelAlbumOTGPhotoRecord]) {
records.forEach { record in
guard record.autoRetouchState == .completed,
let materialId = record.materialId,
let templateID = record.autoRetouchTemplateId,
let resultURL = storage.absoluteURL(for: record.retouchedPath, albumId: albumId),
let resultData = try? Data(contentsOf: resultURL) else {
return
}
_ = aiEditResultStore.completeFromImageData(
materialID: materialId,
presetID: templateID,
editedImageData: resultData
)
}
}
private func syncServerUploadStatuses() {
serverStatusSyncTask?.cancel()
guard albumId > 0 else { return }
@@ -593,6 +661,8 @@ final class WiredCameraTransferViewModel {
}
private func uploadPhoto(id: String) async {
// 每张照片开始上传时固定一次配置,避免修图过程中切换模板导致结果不一致。
let retouchConfiguration = autoRetouchConfiguration
do {
let record = try await localRecordForUpload(id: id)
updateRecord(id: id, status: .uploading, progress: max(record.progress, 1), error: nil)
@@ -602,13 +672,89 @@ final class WiredCameraTransferViewModel {
) { [weak self] progress in
self?.updateRecord(id: id, status: .uploading, progress: progress, error: nil)
}
updateRecord(id: id, status: .uploaded, progress: 100, error: nil, remoteUrl: material.fileUrl)
// 上传期间用户可能删除照片,删除后不再回写本地任务或发起修图。
guard persistedRecordsById[id] != nil else { return }
updateRecord(
id: id,
status: retouchConfiguration.template == nil ? .uploaded : .uploading,
progress: 100,
error: nil,
remoteUrl: material.fileUrl,
materialId: material.id
)
guard let preset = retouchConfiguration.template else { return }
await processAutoRetouch(
photoId: id,
sourceRecord: record,
materialId: material.id,
preset: preset
)
} catch {
updateRecord(id: id, status: .failed, progress: 0, error: error.localizedDescription)
showMessage(error.localizedDescription)
}
}
/// 对已完成原图上传的照片执行本地演示修图,修图失败不回退上传成功状态。
private func processAutoRetouch(
photoId: String,
sourceRecord: TravelAlbumOTGPhotoRecord,
materialId: Int,
preset: TravelAlbumEditPreset
) async {
let resolvedRecord = storage.recordWithResolvedFilePaths(sourceRecord, albumId: albumId)
let sourceURL = URL(fileURLWithPath: resolvedRecord.localPath)
updateAutoRetouchRecord(
id: photoId,
state: .processing,
templateId: preset.id,
materialId: materialId,
status: .uploading,
error: nil
)
aiEditResultStore.startProcessing(materialIDs: [materialId])
do {
let resultData = try await autoRetouchProcessor.process(sourceURL: sourceURL, preset: preset)
guard persistedRecordsById[photoId] != nil else {
aiEditResultStore.remove(materialID: materialId)
return
}
let resultURL = try storage.writeRetouchedImage(
resultData,
filename: sourceRecord.fileName,
albumId: albumId
)
guard aiEditResultStore.completeFromImageData(
materialID: materialId,
presetID: preset.id,
editedImageData: resultData
) else {
throw TravelAlbumAutoRetouchError.invalidImageData
}
updateAutoRetouchRecord(
id: photoId,
state: .completed,
templateId: preset.id,
materialId: materialId,
status: .uploaded,
error: nil,
retouchedPath: storage.relativePath(for: resultURL, albumId: albumId)
)
} catch {
aiEditResultStore.failProcessing(materialID: materialId)
updateAutoRetouchRecord(
id: photoId,
state: .failed,
templateId: preset.id,
materialId: materialId,
status: .uploaded,
error: error.localizedDescription
)
showMessage("修图失败,可点击更多重试")
}
}
private func localRecordForUpload(id: String) async throws -> TravelAlbumOTGPhotoRecord {
if let record = persistedRecordsById[id],
!record.localPath.isEmpty,
@@ -623,7 +769,8 @@ final class WiredCameraTransferViewModel {
status: TravelAlbumOTGUploadStatus,
progress: Int,
error: String?,
remoteUrl: String? = nil
remoteUrl: String? = nil,
materialId: Int? = nil
) {
var record = persistedRecordsById[id]
if record == nil, let item = photos.first(where: { $0.id == id }) {
@@ -642,7 +789,10 @@ final class WiredCameraTransferViewModel {
errorMessage: error,
albumId: albumId,
userId: appStore.session.userId,
remoteUrl: remoteUrl ?? item.remoteUrl
remoteUrl: remoteUrl ?? item.remoteUrl,
materialId: materialId,
autoRetouchState: item.autoRetouchState,
autoRetouchTemplateId: item.autoRetouchTemplateId
)
}
guard var record else { return }
@@ -652,6 +802,35 @@ final class WiredCameraTransferViewModel {
if let remoteUrl {
record.remoteUrl = remoteUrl
}
if let materialId {
record.materialId = materialId
}
record.updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
persistedRecordsById[id] = record
storage.upsert(record, albumId: albumId)
applyMergedPhotos()
}
/// 单独更新修图阶段,保留已完成的原图上传信息。
private func updateAutoRetouchRecord(
id: String,
state: TravelAlbumAutoRetouchState,
templateId: String,
materialId: Int,
status: TravelAlbumOTGUploadStatus,
error: String?,
retouchedPath: String? = nil
) {
guard var record = persistedRecordsById[id] else { return }
record.status = status
record.progress = 100
record.errorMessage = error
record.materialId = materialId
record.autoRetouchState = state
record.autoRetouchTemplateId = templateId
if let retouchedPath {
record.retouchedPath = retouchedPath
}
record.updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
persistedRecordsById[id] = record
storage.upsert(record, albumId: albumId)