新增线下收款记录和 ai 修图优化
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user