Implement profile tab with Android-aligned flows and OSS upload.
Add personal info page, account switch, real-name auth, withdrawal settings, session cache extensions, AlibabaCloudOSS SPM, and unit tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -5,14 +5,15 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 登录会话辅助工具,负责写入本地 Token 并广播登录成功通知。
|
||||
/// 登录会话辅助工具,负责写入本地 Token、账号上下文并广播登录成功通知。
|
||||
enum AuthSessionHelper {
|
||||
|
||||
/// 完成登录,保存 Token 与用户偏好,并通知 SceneDelegate 切换根页面。
|
||||
/// 完成登录,保存 Token、账号上下文与用户偏好,并通知 SceneDelegate 切换根页面。
|
||||
static func completeLogin(
|
||||
with response: V9AuthResponse,
|
||||
username: String,
|
||||
privacyAgreementAccepted: Bool
|
||||
privacyAgreementAccepted: Bool,
|
||||
selectedAccount: AccountSwitchAccount
|
||||
) {
|
||||
let token = response.token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !token.isEmpty else { return }
|
||||
@ -20,7 +21,98 @@ enum AuthSessionHelper {
|
||||
AppStore.shared.saveToken(token)
|
||||
AppStore.shared.lastLoginUsername = username
|
||||
AppStore.shared.privacyAgreementAccepted = privacyAgreementAccepted
|
||||
applyAccountSession(from: selectedAccount, in: response)
|
||||
|
||||
NotificationCenter.default.post(name: NotificationName.userDidLogin, object: nil)
|
||||
}
|
||||
|
||||
/// 账号切换完成后更新会话,不触发根页面重建。
|
||||
static func applyAccountSwitch(
|
||||
with response: V9AuthResponse,
|
||||
account: AccountSwitchAccount
|
||||
) {
|
||||
let token = response.token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !token.isEmpty else { return }
|
||||
|
||||
AppStore.shared.saveToken(token)
|
||||
applyAccountSession(from: account, in: response)
|
||||
NotificationCenter.default.post(name: NotificationName.accountDidSwitch, object: nil)
|
||||
}
|
||||
|
||||
/// 将 v9 响应中的角色与账号展示信息写入 AppStore。
|
||||
static func applyAccountSession(from account: AccountSwitchAccount, in response: V9AuthResponse) {
|
||||
AppStore.shared.applyAccount(account)
|
||||
|
||||
if let scenicUser = response.scenicUsers.first(where: { matches($0, account: account) }) {
|
||||
applyScenicUser(scenicUser)
|
||||
return
|
||||
}
|
||||
if let storeUser = response.storeUsers.first(where: { matches($0, account: account) }) {
|
||||
applyStoreUser(storeUser)
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 v9 响应中解析当前账号实体,优先 `isCurrent`。
|
||||
static func resolveCurrentAccount(from response: V9AuthResponse) -> AccountSwitchAccount? {
|
||||
let accounts = response.accounts.filter { $0.businessUserId > 0 }
|
||||
if let current = accounts.first(where: \.isCurrent) {
|
||||
return current
|
||||
}
|
||||
return accounts.first
|
||||
}
|
||||
|
||||
private static func applyScenicUser(_ user: V9ScenicUser) {
|
||||
AppStore.shared.userId = String(user.businessUserId)
|
||||
AppStore.shared.accountType = V9ScenicUser.accountTypeValue
|
||||
AppStore.shared.accountDisplayName = firstNonEmpty(
|
||||
user.scenicName, user.nickname, user.realName, user.username
|
||||
)
|
||||
AppStore.shared.userName = firstNonEmpty(user.nickname, user.username, user.realName)
|
||||
AppStore.shared.realName = firstNonEmpty(user.realName, user.nickname, user.username)
|
||||
AppStore.shared.phone = user.phone
|
||||
AppStore.shared.currentScenicId = user.scenicId
|
||||
AppStore.shared.currentScenicName = user.scenicName
|
||||
AppStore.shared.currentStoreId = 0
|
||||
if !user.appRoleCode.isEmpty {
|
||||
AppStore.shared.roleCode = user.appRoleCode
|
||||
}
|
||||
if !user.appRoleName.isEmpty {
|
||||
AppStore.shared.roleName = user.appRoleName
|
||||
}
|
||||
}
|
||||
|
||||
private static func applyStoreUser(_ user: V9StoreUser) {
|
||||
AppStore.shared.userId = String(user.businessUserId)
|
||||
AppStore.shared.accountType = V9StoreUser.accountTypeValue
|
||||
AppStore.shared.accountDisplayName = firstNonEmpty(
|
||||
user.storeName, user.scenicName, user.realName, user.userName, user.username
|
||||
)
|
||||
AppStore.shared.userName = firstNonEmpty(user.userName, user.username, user.realName)
|
||||
AppStore.shared.realName = firstNonEmpty(user.realName, user.userName, user.username)
|
||||
AppStore.shared.phone = user.phone
|
||||
AppStore.shared.avatar = user.avatar
|
||||
AppStore.shared.currentScenicId = user.scenicId
|
||||
AppStore.shared.currentScenicName = user.scenicName
|
||||
AppStore.shared.currentStoreId = user.storeId
|
||||
if !user.appRoleCode.isEmpty {
|
||||
AppStore.shared.roleCode = user.appRoleCode
|
||||
}
|
||||
if !user.appRoleName.isEmpty {
|
||||
AppStore.shared.roleName = user.appRoleName
|
||||
}
|
||||
}
|
||||
|
||||
private static func matches(_ user: V9ScenicUser, account: AccountSwitchAccount) -> Bool {
|
||||
user.businessUserId == account.businessUserId
|
||||
|| account.id == "\(V9ScenicUser.accountTypeValue)_\(user.businessUserId)"
|
||||
}
|
||||
|
||||
private static func matches(_ user: V9StoreUser, account: AccountSwitchAccount) -> Bool {
|
||||
user.businessUserId == account.businessUserId
|
||||
|| account.id == "\(V9StoreUser.accountTypeValue)_\(user.businessUserId)"
|
||||
}
|
||||
|
||||
private static func firstNonEmpty(_ values: String...) -> String {
|
||||
values.first { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
88
suixinkan/Features/Auth/Models/AppRoleCode.swift
Normal file
88
suixinkan/Features/Auth/Models/AppRoleCode.swift
Normal file
@ -0,0 +1,88 @@
|
||||
//
|
||||
// AppRoleCode.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 业务角色编码,与 Android `AppRoleCode` 对齐。
|
||||
enum AppRoleCode: String, CaseIterable, Equatable {
|
||||
case driver = "driver"
|
||||
case floristBuilder = "florist_builder"
|
||||
case clerk = "clerk"
|
||||
case liveStream = "live_stream"
|
||||
case frontDesk = "front_desk"
|
||||
case makeupArtist = "makeup_artist"
|
||||
case photographerAssistant = "photographer_assistant"
|
||||
case assistantManager = "assistant_manager"
|
||||
case scenicOperation = "scenic_operation"
|
||||
case scenicAdmin = "scenic_admin"
|
||||
case scenicCS = "scenic_cs"
|
||||
case editor = "editor"
|
||||
case storeAdmin = "store_admin"
|
||||
case photographer = "photographer"
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .driver: "司机"
|
||||
case .floristBuilder: "搭建花艺师"
|
||||
case .clerk: "店员"
|
||||
case .liveStream: "直播"
|
||||
case .frontDesk: "前台"
|
||||
case .makeupArtist: "化妆师"
|
||||
case .photographerAssistant: "摄影师助理"
|
||||
case .assistantManager: "副店长"
|
||||
case .scenicOperation: "景区运营"
|
||||
case .scenicAdmin: "景区管理员"
|
||||
case .scenicCS: "飞手"
|
||||
case .editor: "剪辑师"
|
||||
case .storeAdmin: "店铺管理员"
|
||||
case .photographer: "摄影师"
|
||||
}
|
||||
}
|
||||
|
||||
var isPhotographer: Bool {
|
||||
self == .photographer
|
||||
}
|
||||
|
||||
/// 从后端 role_code 解析角色。
|
||||
static func fromCode(_ code: String?) -> AppRoleCode? {
|
||||
let normalized = code?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !normalized.isEmpty else { return nil }
|
||||
return AppRoleCode(rawValue: normalized)
|
||||
}
|
||||
|
||||
/// 从旧版 role_id 推断角色。
|
||||
static func fromLegacyRoleId(_ roleId: Int) -> AppRoleCode? {
|
||||
switch roleId {
|
||||
case 41: .photographer
|
||||
case 46: .storeAdmin
|
||||
case 47: .editor
|
||||
case 52: .scenicCS
|
||||
case 53: .scenicAdmin
|
||||
case 54: .scenicOperation
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 role_name 文本推断角色,避免「摄影师助理」误判为摄影师。
|
||||
static func fromRoleName(_ roleName: String?) -> AppRoleCode? {
|
||||
let name = roleName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !name.isEmpty else { return nil }
|
||||
if name.contains("摄影师助理") { return .photographerAssistant }
|
||||
if name.contains("副店长") { return .assistantManager }
|
||||
if name.contains("搭建花艺") || name.contains("花艺师") { return .floristBuilder }
|
||||
if name.contains("店铺管理") || name.contains("店长") { return .storeAdmin }
|
||||
if name.contains("景区管理") { return .scenicAdmin }
|
||||
if name.contains("景区运营") { return .scenicOperation }
|
||||
if name.contains("景区客服") || name.contains("飞手") { return .scenicCS }
|
||||
if name.contains("剪辑") { return .editor }
|
||||
if name.contains("化妆师") { return .makeupArtist }
|
||||
if name.contains("摄影师") { return .photographer }
|
||||
if name.contains("司机") { return .driver }
|
||||
if name.contains("店员") { return .clerk }
|
||||
if name.contains("直播") { return .liveStream }
|
||||
if name.contains("前台") { return .frontDesk }
|
||||
return AppRoleCode.allCases.first { name.contains($0.displayName) }
|
||||
}
|
||||
}
|
||||
@ -55,7 +55,7 @@ enum LoginValidationError: Equatable {
|
||||
|
||||
/// 登录结果实体,区分已完成登录和需要用户选择账号两种情况。
|
||||
enum LoginResolution {
|
||||
case completed(V9AuthResponse)
|
||||
case completed(V9AuthResponse, AccountSwitchAccount)
|
||||
case needsAccountSelection(AccountSelectionPayload)
|
||||
}
|
||||
|
||||
@ -201,6 +201,8 @@ struct V9ScenicUser: Decodable, Equatable {
|
||||
let phone: String
|
||||
let scenicId: Int
|
||||
let scenicName: String
|
||||
let appRoleCode: String
|
||||
let appRoleName: String
|
||||
let isCurrent: Bool
|
||||
|
||||
var businessUserId: Int {
|
||||
@ -241,6 +243,8 @@ struct V9ScenicUser: Decodable, Equatable {
|
||||
case phone
|
||||
case scenicId = "scenic_id"
|
||||
case scenicName = "scenic_name"
|
||||
case appRoleCode = "app_role_code"
|
||||
case appRoleName = "app_role_name"
|
||||
case isCurrent = "is_current"
|
||||
}
|
||||
|
||||
@ -257,6 +261,8 @@ struct V9ScenicUser: Decodable, Equatable {
|
||||
phone = try container.decodeLossyString(forKey: .phone)
|
||||
scenicId = try container.decodeLossyInt(forKey: .scenicId) ?? 0
|
||||
scenicName = try container.decodeLossyString(forKey: .scenicName)
|
||||
appRoleCode = try container.decodeLossyString(forKey: .appRoleCode)
|
||||
appRoleName = try container.decodeLossyString(forKey: .appRoleName)
|
||||
isCurrent = try container.decodeLossyBool(forKey: .isCurrent) ?? false
|
||||
}
|
||||
}
|
||||
@ -278,6 +284,8 @@ struct V9StoreUser: Decodable, Equatable {
|
||||
let scenicName: String
|
||||
let storeId: Int
|
||||
let storeName: String
|
||||
let appRoleCode: String
|
||||
let appRoleName: String
|
||||
let isCurrent: Bool
|
||||
|
||||
var businessUserId: Int {
|
||||
@ -320,6 +328,8 @@ struct V9StoreUser: Decodable, Equatable {
|
||||
case scenicName = "scenic_name"
|
||||
case storeId = "store_id"
|
||||
case storeName = "store_name"
|
||||
case appRoleCode = "app_role_code"
|
||||
case appRoleName = "app_role_name"
|
||||
case isCurrent = "is_current"
|
||||
}
|
||||
|
||||
@ -338,6 +348,8 @@ struct V9StoreUser: Decodable, Equatable {
|
||||
scenicName = try container.decodeLossyString(forKey: .scenicName)
|
||||
storeId = try container.decodeLossyInt(forKey: .storeId) ?? 0
|
||||
storeName = try container.decodeLossyString(forKey: .storeName)
|
||||
appRoleCode = try container.decodeLossyString(forKey: .appRoleCode)
|
||||
appRoleName = try container.decodeLossyString(forKey: .appRoleName)
|
||||
isCurrent = try container.decodeLossyBool(forKey: .isCurrent) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
125
suixinkan/Features/Profile/API/ProfileAPI.swift
Normal file
125
suixinkan/Features/Profile/API/ProfileAPI.swift
Normal file
@ -0,0 +1,125 @@
|
||||
//
|
||||
// ProfileAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 个人信息 API,封装“我的”页面需要的用户资料和资料更新接口。
|
||||
final class ProfileAPI {
|
||||
private let client: APIClient
|
||||
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 拉取当前登录用户的基础资料。
|
||||
func userInfo() async throws -> UserInfoResponse {
|
||||
try await client.send(
|
||||
APIRequest(method: .get, path: "/api/yf-handset-app/userinfo")
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取当前登录账号可切换的景区账号和门店账号。
|
||||
func switchableAccounts() async throws -> V9AuthResponse {
|
||||
try await client.send(
|
||||
APIRequest(method: .get, path: "/api/app/v9/accounts")
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取当前登录用户的实名认证状态。
|
||||
func realNameInfo() async throws -> RealNameInfoResponse {
|
||||
try await client.send(
|
||||
APIRequest(method: .get, path: "/api/yf-handset-app/photog/real-name/info")
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取当前登录用户的银行卡信息。
|
||||
func bankCardInfo() async throws -> BankCardInfoResponse {
|
||||
try await client.send(
|
||||
APIRequest(method: .get, path: "/api/yf-handset-app/photog/wallet/bank-card/info")
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取开户银行列表。
|
||||
func bankList() async throws -> BankListResponse {
|
||||
try await client.send(
|
||||
APIRequest(method: .get, path: "/api/yf-handset-app/photog/wallet/bank-list")
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取省市区列表。
|
||||
func areas() async throws -> [AreasResponse] {
|
||||
try await client.send(
|
||||
APIRequest(method: .get, path: "/api/app/config/areas")
|
||||
)
|
||||
}
|
||||
|
||||
/// 更新用户昵称、密码或头像地址。
|
||||
func updateUserInfo(nickname: String? = nil, password: String? = nil, avatar: String? = nil) async throws {
|
||||
let request = UpdateInfoRequest(nickname: nickname, password: password, avatar: avatar)
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/userinfo-update",
|
||||
body: request
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 单独更新用户头像 URL,供 OSS 上传完成后回写服务端。
|
||||
func updateUserAvatarURL(_ fileURL: String) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/userinfo-update-avatar-url",
|
||||
queryItems: [URLQueryItem(name: "file_url", value: fileURL)]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 发送实名认证短信验证码。
|
||||
func realNameSmsVerifyCode() async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/real-name/sms-verify-code",
|
||||
body: EmptyPayload()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 提交实名认证资料。
|
||||
func realNameSubmit(_ request: RealNameAuthRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/real-name/submit",
|
||||
body: request
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 发送银行卡绑定短信验证码。
|
||||
func bankCardSmsVerifyCode() async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/wallet/bank-card/sms-verify-code",
|
||||
body: EmptyPayload()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 提交或更新银行卡信息。
|
||||
func updateBankCard(_ request: UpdateBankInfoRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/wallet/bank-card/update",
|
||||
body: request
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
353
suixinkan/Features/Profile/Models/ProfileModels.swift
Normal file
353
suixinkan/Features/Profile/Models/ProfileModels.swift
Normal file
@ -0,0 +1,353 @@
|
||||
//
|
||||
// ProfileModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 用户资料响应实体,表示“我的”页面展示的账号基础信息。
|
||||
struct UserInfoResponse: Decodable, Equatable {
|
||||
let avatar: String
|
||||
let realName: String
|
||||
let phone: String
|
||||
let nickname: String
|
||||
let roleName: String
|
||||
let status: Int
|
||||
let statusName: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case avatar
|
||||
case realName = "real_name"
|
||||
case phone
|
||||
case nickname
|
||||
case roleName = "role_name"
|
||||
case status
|
||||
case statusName = "status_name"
|
||||
}
|
||||
|
||||
init(
|
||||
avatar: String = "",
|
||||
realName: String = "",
|
||||
phone: String = "",
|
||||
nickname: String = "",
|
||||
roleName: String = "",
|
||||
status: Int = 0,
|
||||
statusName: String = ""
|
||||
) {
|
||||
self.avatar = avatar
|
||||
self.realName = realName
|
||||
self.phone = phone
|
||||
self.nickname = nickname
|
||||
self.roleName = roleName
|
||||
self.status = status
|
||||
self.statusName = statusName
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
avatar = try container.decodeLossyString(forKey: .avatar)
|
||||
realName = try container.decodeLossyString(forKey: .realName)
|
||||
phone = try container.decodeLossyString(forKey: .phone)
|
||||
nickname = try container.decodeLossyString(forKey: .nickname)
|
||||
roleName = try container.decodeLossyString(forKey: .roleName)
|
||||
status = try container.decodeLossyInt(forKey: .status) ?? 0
|
||||
statusName = try container.decodeLossyString(forKey: .statusName)
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户资料更新请求实体,表示昵称、密码和头像修改参数。
|
||||
struct UpdateInfoRequest: Encodable {
|
||||
let nickname: String?
|
||||
let password: String?
|
||||
let avatar: String?
|
||||
}
|
||||
|
||||
/// 实名认证响应实体,包裹当前用户的认证详情。
|
||||
struct RealNameInfoResponse: Decodable, Equatable {
|
||||
let realNameInfo: RealNameInfo?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case realNameInfo = "real_name_info"
|
||||
}
|
||||
}
|
||||
|
||||
/// 实名认证信息实体,表示当前用户实名审核状态和失败原因。
|
||||
struct RealNameInfo: Decodable, Equatable {
|
||||
let realName: String
|
||||
let idCardNo: String
|
||||
let auditStatus: Int
|
||||
let auditStatusText: String?
|
||||
let rejectReason: String?
|
||||
let startDate: String?
|
||||
let endDate: String?
|
||||
let isLongValid: Bool
|
||||
let frontUrl: String?
|
||||
let backUrl: String?
|
||||
let auditorId: Int?
|
||||
let auditor: RealNameAuditor?
|
||||
let auditAt: String?
|
||||
|
||||
var verified: Bool {
|
||||
auditStatus == 2
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case realName = "real_name"
|
||||
case idCardNo = "id_card_no"
|
||||
case auditStatus = "audit_status"
|
||||
case auditStatusText = "audit_status_text"
|
||||
case rejectReason = "reject_reason"
|
||||
case startDate = "start_date"
|
||||
case endDate = "end_date"
|
||||
case isLongValid = "is_long_valid"
|
||||
case frontUrl = "front_url"
|
||||
case backUrl = "back_url"
|
||||
case auditorId = "auditor_id"
|
||||
case auditor
|
||||
case auditAt = "audit_at"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
realName = try container.decodeLossyString(forKey: .realName)
|
||||
idCardNo = try container.decodeLossyString(forKey: .idCardNo)
|
||||
auditStatus = try container.decodeLossyInt(forKey: .auditStatus) ?? 0
|
||||
auditStatusText = try container.decodeIfPresent(String.self, forKey: .auditStatusText)
|
||||
rejectReason = try container.decodeIfPresent(String.self, forKey: .rejectReason)
|
||||
startDate = try container.decodeIfPresent(String.self, forKey: .startDate)
|
||||
endDate = try container.decodeIfPresent(String.self, forKey: .endDate)
|
||||
isLongValid = (try container.decodeLossyInt(forKey: .isLongValid) ?? 0) == 1
|
||||
frontUrl = try container.decodeIfPresent(String.self, forKey: .frontUrl)
|
||||
backUrl = try container.decodeIfPresent(String.self, forKey: .backUrl)
|
||||
auditorId = try container.decodeLossyInt(forKey: .auditorId)
|
||||
auditor = try container.decodeIfPresent(RealNameAuditor.self, forKey: .auditor)
|
||||
auditAt = try container.decodeIfPresent(String.self, forKey: .auditAt)
|
||||
}
|
||||
}
|
||||
|
||||
/// 实名认证审核人实体,表示后台审核人的基础展示信息。
|
||||
struct RealNameAuditor: Decodable, Equatable {
|
||||
let id: Int
|
||||
let name: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
name = try container.decodeLossyString(forKey: .name)
|
||||
}
|
||||
}
|
||||
|
||||
/// 实名认证提交请求实体,表示用户填写的姓名、证件号、短信和证件图片。
|
||||
struct RealNameAuthRequest: Encodable, Equatable {
|
||||
let realName: String
|
||||
let idCardNo: String
|
||||
let smsVerifyCode: String
|
||||
let startDate: String
|
||||
let endDate: String
|
||||
let isLongValid: Int
|
||||
let frontUrl: String
|
||||
let backUrl: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case realName = "real_name"
|
||||
case idCardNo = "id_card_no"
|
||||
case smsVerifyCode = "sms_verify_code"
|
||||
case startDate = "start_date"
|
||||
case endDate = "end_date"
|
||||
case isLongValid = "is_long_valid"
|
||||
case frontUrl = "front_url"
|
||||
case backUrl = "back_url"
|
||||
}
|
||||
}
|
||||
|
||||
/// 银行卡信息响应实体。
|
||||
struct BankCardInfoResponse: Decodable, Equatable {
|
||||
let bankCard: BankCardInfo?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case bankCard = "bank_card"
|
||||
}
|
||||
}
|
||||
|
||||
/// 银行卡信息实体,表示提现设置与审核状态。
|
||||
struct BankCardInfo: Decodable, Equatable {
|
||||
let realName: String
|
||||
let bankName: String
|
||||
let branchName: String
|
||||
let cardNumber: String
|
||||
let frontUrl: String?
|
||||
let backUrl: String?
|
||||
let provinceCode: String?
|
||||
let cityCode: String?
|
||||
let rejectReason: String
|
||||
let auditStatus: Int
|
||||
let auditStatusLabel: String
|
||||
let auditAt: String?
|
||||
let auditorId: Int?
|
||||
let auditor: RealNameAuditor?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case realName = "real_name"
|
||||
case bankName = "bank_name"
|
||||
case branchName = "branch_name"
|
||||
case cardNumber = "card_number"
|
||||
case frontUrl = "front_url"
|
||||
case backUrl = "back_url"
|
||||
case provinceCode = "province_code"
|
||||
case cityCode = "city_code"
|
||||
case rejectReason = "reject_reason"
|
||||
case auditStatus = "audit_status"
|
||||
case auditStatusLabel = "audit_status_label"
|
||||
case auditAt = "audit_at"
|
||||
case auditorId = "auditor_id"
|
||||
case auditor
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
realName = try container.decodeLossyString(forKey: .realName)
|
||||
bankName = try container.decodeLossyString(forKey: .bankName)
|
||||
branchName = try container.decodeLossyString(forKey: .branchName)
|
||||
cardNumber = try container.decodeLossyString(forKey: .cardNumber)
|
||||
frontUrl = try container.decodeIfPresent(String.self, forKey: .frontUrl)
|
||||
backUrl = try container.decodeIfPresent(String.self, forKey: .backUrl)
|
||||
provinceCode = try container.decodeIfPresent(String.self, forKey: .provinceCode)
|
||||
cityCode = try container.decodeIfPresent(String.self, forKey: .cityCode)
|
||||
rejectReason = try container.decodeLossyString(forKey: .rejectReason)
|
||||
auditStatus = try container.decodeLossyInt(forKey: .auditStatus) ?? 0
|
||||
auditStatusLabel = try container.decodeLossyString(forKey: .auditStatusLabel)
|
||||
auditAt = try container.decodeIfPresent(String.self, forKey: .auditAt)
|
||||
auditorId = try container.decodeLossyInt(forKey: .auditorId)
|
||||
auditor = try container.decodeIfPresent(RealNameAuditor.self, forKey: .auditor)
|
||||
}
|
||||
}
|
||||
|
||||
/// 银行列表响应实体。
|
||||
struct BankListResponse: Decodable, Equatable {
|
||||
let banks: [String]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case banks
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
banks = try container.decodeIfPresent([String].self, forKey: .banks) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 省市区响应实体。
|
||||
struct AreasResponse: Decodable, Equatable, Identifiable {
|
||||
let id: Int
|
||||
let code: String
|
||||
let name: String
|
||||
let children: [AreasResponse]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case code
|
||||
case name
|
||||
case children
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeLossyInt(forKey: .id)
|
||||
?? container.decodeLossyInt(forKey: .code)
|
||||
?? 0
|
||||
let decodedCode = try container.decodeLossyString(forKey: .code)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
code = decodedCode.isEmpty ? "\(id)" : decodedCode
|
||||
name = try container.decodeLossyString(forKey: .name)
|
||||
children = try container.decodeIfPresent([AreasResponse].self, forKey: .children) ?? []
|
||||
}
|
||||
|
||||
init(id: Int, code: String, name: String, children: [AreasResponse] = []) {
|
||||
self.id = id
|
||||
self.code = code
|
||||
self.name = name
|
||||
self.children = children
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新银行卡信息请求实体。
|
||||
struct UpdateBankInfoRequest: Encodable, Equatable {
|
||||
let realName: String
|
||||
let cardNumber: String
|
||||
let bankName: String
|
||||
let branchName: String
|
||||
let frontUrl: String
|
||||
let backUrl: String
|
||||
let provinceCode: String
|
||||
let cityCode: String
|
||||
let smsVerifyCode: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case realName = "real_name"
|
||||
case cardNumber = "card_number"
|
||||
case bankName = "bank_name"
|
||||
case branchName = "branch_name"
|
||||
case frontUrl = "front_url"
|
||||
case backUrl = "back_url"
|
||||
case provinceCode = "province_code"
|
||||
case cityCode = "city_code"
|
||||
case smsVerifyCode = "sms_verify_code"
|
||||
}
|
||||
}
|
||||
|
||||
/// 个人信息页校验错误实体。
|
||||
enum ProfileValidationError: LocalizedError, Equatable {
|
||||
case emptyNickname
|
||||
case shortPassword
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .emptyNickname:
|
||||
"请输入昵称"
|
||||
case .shortPassword:
|
||||
"密码至少 6 位"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
func decodeLossyString(forKey key: Key) throws -> String {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||
return value ? "true" : "false"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let intValue = Int(text) {
|
||||
return intValue
|
||||
}
|
||||
if let doubleValue = Double(text) {
|
||||
return Int(doubleValue)
|
||||
}
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
//
|
||||
// AccountSwitchViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 账号切换 ViewModel,管理可切换账号列表、选择状态和提交状态。
|
||||
final class AccountSwitchViewModel {
|
||||
private(set) var accounts: [AccountSwitchAccount] = []
|
||||
private(set) var selectedAccountId: String?
|
||||
private(set) var loading = false
|
||||
private(set) var switching = false
|
||||
private var didLoad = false
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
|
||||
var selectedAccount: AccountSwitchAccount? {
|
||||
accounts.first { $0.id == selectedAccountId }
|
||||
}
|
||||
|
||||
func load(api: ProfileAPI, force: Bool = false, currentAccountId: String? = nil) async throws {
|
||||
guard force || !didLoad else { return }
|
||||
didLoad = true
|
||||
loading = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
loading = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
let response = try await api.switchableAccounts()
|
||||
accounts = response.accounts.filter { $0.businessUserId > 0 }
|
||||
selectedAccountId = accounts.first(where: { $0.isCurrent || isCurrent($0, currentAccountId: currentAccountId) })?.id
|
||||
?? accounts.first?.id
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func select(_ account: AccountSwitchAccount) {
|
||||
selectedAccountId = account.id
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func switchAccount(_ account: AccountSwitchAccount, api: AuthAPI) async throws -> V9AuthResponse {
|
||||
guard !switching else { throw AccountSwitchError.submitting }
|
||||
guard account.businessUserId > 0 else { throw AccountSwitchError.invalidAccount }
|
||||
|
||||
switching = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
switching = false
|
||||
notifyStateChange()
|
||||
}
|
||||
return try await api.setUser(account.toSetUserRequest())
|
||||
}
|
||||
|
||||
func isCurrent(_ account: AccountSwitchAccount, currentAccountId: String?) -> Bool {
|
||||
guard let currentAccountId else { return false }
|
||||
return account.id == currentAccountId || String(account.businessUserId) == currentAccountId
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
|
||||
enum AccountSwitchError: LocalizedError, Equatable {
|
||||
case submitting
|
||||
case invalidAccount
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .submitting: "账号正在切换,请稍候"
|
||||
case .invalidAccount: "账号信息异常,请重新选择账号"
|
||||
}
|
||||
}
|
||||
}
|
||||
249
suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift
Normal file
249
suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift
Normal file
@ -0,0 +1,249 @@
|
||||
//
|
||||
// ProfileViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 个人信息页 ViewModel,负责资料加载、编辑状态和提交更新。
|
||||
final class ProfileViewModel {
|
||||
|
||||
private(set) var userInfo: UserInfoResponse?
|
||||
private(set) var realNameInfo: RealNameInfo?
|
||||
private(set) var bankCardInfo: BankCardInfo?
|
||||
private(set) var isLoading = false
|
||||
private(set) var isSaving = false
|
||||
private(set) var isEditingProfile = false
|
||||
private(set) var editingNickname = ""
|
||||
private(set) var pendingAvatarData: Data?
|
||||
private(set) var pendingAvatarFileName: String?
|
||||
private(set) var avatarUploadProgress: Int?
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
|
||||
var displayNickname: String {
|
||||
nonEmpty(userInfo?.nickname) ?? "未设置昵称"
|
||||
}
|
||||
|
||||
var displayRealName: String {
|
||||
nonEmpty(userInfo?.realName) ?? "--"
|
||||
}
|
||||
|
||||
var displayPhone: String {
|
||||
nonEmpty(userInfo?.phone) ?? AppStore.shared.phone.nonEmpty ?? "--"
|
||||
}
|
||||
|
||||
var displayAvatarURL: String {
|
||||
nonEmpty(userInfo?.avatar) ?? AppStore.shared.avatar.nonEmpty ?? ""
|
||||
}
|
||||
|
||||
var displayUID: String {
|
||||
let uid = AppStore.shared.userId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return uid.isEmpty ? "--" : uid
|
||||
}
|
||||
|
||||
var accountDisplayName: String {
|
||||
let name = AppStore.shared.accountDisplayName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return name.isEmpty ? "--" : name
|
||||
}
|
||||
|
||||
var currentScenicName: String {
|
||||
let name = AppStore.shared.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return name.isEmpty ? "--" : name
|
||||
}
|
||||
|
||||
var accountStatusText: String {
|
||||
nonEmpty(userInfo?.statusName) ?? "--"
|
||||
}
|
||||
|
||||
var showPhotographerFields: Bool {
|
||||
if AppStore.shared.isPhotographerRole {
|
||||
return true
|
||||
}
|
||||
if let roleName = nonEmpty(userInfo?.roleName) {
|
||||
return AppRoleCode.fromRoleName(roleName)?.isPhotographer == true || roleName == "摄影师"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var realNameStatusText: String {
|
||||
guard let realNameInfo else { return "点击去实名认证" }
|
||||
switch realNameInfo.auditStatus {
|
||||
case 2: return "已实名认证"
|
||||
case 3: return "审核不通过"
|
||||
default: return "审核中"
|
||||
}
|
||||
}
|
||||
|
||||
var bankCardDisplayText: String {
|
||||
guard let bankCardInfo else { return "点击绑定银行卡" }
|
||||
if bankCardInfo.auditStatus == 2 {
|
||||
let masked = Self.maskCardNumber(bankCardInfo.cardNumber)
|
||||
return "\(bankCardInfo.bankName) \(masked)"
|
||||
}
|
||||
let label = bankCardInfo.auditStatusLabel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return label.isEmpty ? "审核中" : label
|
||||
}
|
||||
|
||||
func updateEditingNickname(_ value: String) {
|
||||
editingNickname = value
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 重新加载用户资料,摄影师额外拉取实名与银行卡信息。
|
||||
func reload(api: ProfileAPI) async throws {
|
||||
guard !isLoading else { return }
|
||||
isLoading = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isLoading = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
let info = try await api.userInfo()
|
||||
userInfo = info
|
||||
AppStore.shared.applyUserInfo(info)
|
||||
if !info.roleName.isEmpty {
|
||||
AppStore.shared.roleName = info.roleName
|
||||
}
|
||||
|
||||
if showPhotographerFields {
|
||||
async let realName = api.realNameInfo()
|
||||
async let bankCard = api.bankCardInfo()
|
||||
let extra = try await (realName, bankCard)
|
||||
realNameInfo = extra.0.realNameInfo
|
||||
bankCardInfo = extra.1.bankCard
|
||||
} else {
|
||||
realNameInfo = nil
|
||||
bankCardInfo = nil
|
||||
}
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func beginEditing() {
|
||||
editingNickname = displayNickname == "未设置昵称" ? "" : displayNickname
|
||||
isEditingProfile = true
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func cancelEditing() {
|
||||
editingNickname = ""
|
||||
isEditingProfile = false
|
||||
clearPendingAvatar()
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func prepareAvatarImage(data: Data, timestamp: TimeInterval = Date().timeIntervalSince1970) throws {
|
||||
let processed = try AvatarImageProcessor.process(data: data, timestamp: timestamp)
|
||||
pendingAvatarData = processed.data
|
||||
pendingAvatarFileName = processed.fileName
|
||||
if !isEditingProfile {
|
||||
beginEditing()
|
||||
} else {
|
||||
notifyStateChange()
|
||||
}
|
||||
}
|
||||
|
||||
func saveProfile(api: ProfileAPI, uploader: any OSSUploadServing, scenicId: Int) async throws {
|
||||
let nextNickname = editingNickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !nextNickname.isEmpty else {
|
||||
throw ProfileValidationError.emptyNickname
|
||||
}
|
||||
|
||||
let nicknameChanged = nextNickname != displayNickname
|
||||
let avatarChanged = pendingAvatarData != nil
|
||||
guard nicknameChanged || avatarChanged else {
|
||||
cancelEditing()
|
||||
return
|
||||
}
|
||||
|
||||
guard !isSaving else { return }
|
||||
isSaving = true
|
||||
avatarUploadProgress = avatarChanged ? 1 : nil
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isSaving = false
|
||||
avatarUploadProgress = nil
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
let uploadedAvatarURL = try await uploadPendingAvatarIfNeeded(uploader: uploader, scenicId: scenicId)
|
||||
if let uploadedAvatarURL {
|
||||
try await api.updateUserAvatarURL(uploadedAvatarURL)
|
||||
}
|
||||
if nicknameChanged {
|
||||
try await api.updateUserInfo(nickname: nextNickname)
|
||||
}
|
||||
|
||||
let previous = userInfo ?? UserInfoResponse()
|
||||
userInfo = UserInfoResponse(
|
||||
avatar: uploadedAvatarURL ?? previous.avatar,
|
||||
realName: previous.realName,
|
||||
phone: previous.phone,
|
||||
nickname: nextNickname,
|
||||
roleName: previous.roleName,
|
||||
status: previous.status,
|
||||
statusName: previous.statusName
|
||||
)
|
||||
AppStore.shared.userName = nextNickname
|
||||
if let uploadedAvatarURL {
|
||||
AppStore.shared.avatar = uploadedAvatarURL
|
||||
}
|
||||
clearPendingAvatar()
|
||||
cancelEditing()
|
||||
}
|
||||
|
||||
func updatePassword(_ password: String, api: ProfileAPI) async throws {
|
||||
let nextPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard nextPassword.count >= 6 else {
|
||||
throw ProfileValidationError.shortPassword
|
||||
}
|
||||
|
||||
guard !isSaving else { return }
|
||||
isSaving = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isSaving = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
try await api.updateUserInfo(password: nextPassword)
|
||||
}
|
||||
|
||||
static func maskCardNumber(_ cardNumber: String) -> String {
|
||||
let digits = cardNumber.filter(\.isNumber)
|
||||
guard digits.count >= 4 else { return cardNumber }
|
||||
return "**** **** **** \(digits.suffix(4))"
|
||||
}
|
||||
|
||||
private func uploadPendingAvatarIfNeeded(uploader: any OSSUploadServing, scenicId: Int) async throws -> String? {
|
||||
guard let pendingAvatarData else { return nil }
|
||||
let fileName = pendingAvatarFileName ?? "avatar_\(Int(Date().timeIntervalSince1970)).jpg"
|
||||
return try await uploader.uploadUserAvatar(data: pendingAvatarData, fileName: fileName, scenicId: scenicId) { [weak self] progress in
|
||||
self?.avatarUploadProgress = progress
|
||||
self?.notifyStateChange()
|
||||
}
|
||||
}
|
||||
|
||||
private func clearPendingAvatar() {
|
||||
pendingAvatarData = nil
|
||||
pendingAvatarFileName = nil
|
||||
avatarUploadProgress = nil
|
||||
}
|
||||
|
||||
private func nonEmpty(_ value: String?) -> String? {
|
||||
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var nonEmpty: String? {
|
||||
let text = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,237 @@
|
||||
//
|
||||
// RealNameAuthViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 实名认证 ViewModel,管理认证资料表单、审核状态、短信验证码和提交状态。
|
||||
final class RealNameAuthViewModel {
|
||||
private(set) var info: RealNameInfo?
|
||||
private(set) var realName = ""
|
||||
private(set) var idCardNo = ""
|
||||
private(set) var smsCode = ""
|
||||
private(set) var startDate = Date()
|
||||
private(set) var endDate = Calendar.current.date(byAdding: .year, value: 10, to: Date()) ?? Date()
|
||||
private(set) var isLongValid = false
|
||||
private(set) var frontUrl = ""
|
||||
private(set) var backUrl = ""
|
||||
private(set) var pendingFrontImageData: Data?
|
||||
private(set) var pendingBackImageData: Data?
|
||||
private(set) var loading = false
|
||||
private(set) var sendingCode = false
|
||||
private(set) var submitting = false
|
||||
private(set) var statusMessage: String?
|
||||
|
||||
private var pendingFrontFileName: String?
|
||||
private var pendingBackFileName: String?
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
|
||||
func updateRealName(_ value: String) {
|
||||
realName = value
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func updateIdCardNo(_ value: String) {
|
||||
idCardNo = value
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func updateSmsCode(_ value: String) {
|
||||
smsCode = value
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func updateLongValid(_ value: Bool) {
|
||||
isLongValid = value
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func updateStartDate(_ value: Date) {
|
||||
startDate = value
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func updateEndDate(_ value: Date) {
|
||||
endDate = value
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func load(api: ProfileAPI) async throws {
|
||||
guard !loading else { return }
|
||||
loading = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
loading = false
|
||||
notifyStateChange()
|
||||
}
|
||||
let response = try await api.realNameInfo()
|
||||
apply(response.realNameInfo)
|
||||
}
|
||||
|
||||
func sendCode(api: ProfileAPI) async throws {
|
||||
guard !sendingCode else { return }
|
||||
sendingCode = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
sendingCode = false
|
||||
notifyStateChange()
|
||||
}
|
||||
try await api.realNameSmsVerifyCode()
|
||||
statusMessage = "验证码已发送"
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func prepareIdentityImage(data: Data, side: RealNameImageSide, timestamp: TimeInterval = Date().timeIntervalSince1970) throws {
|
||||
let processed = try RealNameImageProcessor.process(data: data, side: side, timestamp: timestamp)
|
||||
switch side {
|
||||
case .front:
|
||||
pendingFrontImageData = processed.data
|
||||
pendingFrontFileName = processed.fileName
|
||||
frontUrl = ""
|
||||
case .back:
|
||||
pendingBackImageData = processed.data
|
||||
pendingBackFileName = processed.fileName
|
||||
backUrl = ""
|
||||
}
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func submit(api: ProfileAPI, uploader: any OSSUploadServing, scenicId: Int) async throws {
|
||||
if info?.verified == true {
|
||||
statusMessage = "已完成实名认证"
|
||||
notifyStateChange()
|
||||
return
|
||||
}
|
||||
if let validationMessage {
|
||||
throw RealNameValidationError.message(validationMessage)
|
||||
}
|
||||
|
||||
submitting = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
submitting = false
|
||||
notifyStateChange()
|
||||
}
|
||||
try await uploadPendingIdentityImages(uploader: uploader, scenicId: scenicId)
|
||||
try await api.realNameSubmit(makeRequest())
|
||||
statusMessage = "已提交审核"
|
||||
notifyStateChange()
|
||||
try await load(api: api)
|
||||
}
|
||||
|
||||
var validationMessage: String? {
|
||||
if realName.trimmed.isEmpty { return "请输入真实姓名" }
|
||||
if idCardNo.trimmed.isEmpty { return "请输入身份证号" }
|
||||
if !Self.isValidMainlandIDCardNumber(idCardNo) { return "请输入有效的身份证号" }
|
||||
if info?.verified != true {
|
||||
if smsCode.trimmed.isEmpty { return "请输入短信验证码" }
|
||||
if frontUrl.trimmed.isEmpty && pendingFrontImageData == nil { return "请选择身份证人像面图片" }
|
||||
if backUrl.trimmed.isEmpty && pendingBackImageData == nil { return "请选择身份证国徽面图片" }
|
||||
}
|
||||
if !isLongValid && endDate < startDate { return "证件结束日期不能早于起始日期" }
|
||||
return nil
|
||||
}
|
||||
|
||||
nonisolated static func normalizedIDCardNumber(_ value: String) -> String {
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||
}
|
||||
|
||||
nonisolated static func isValidMainlandIDCardNumber(_ value: String) -> Bool {
|
||||
let number = normalizedIDCardNumber(value)
|
||||
guard number.count == 18 else { return false }
|
||||
let chars = Array(number)
|
||||
guard chars.prefix(17).allSatisfy(\.isNumber) else { return false }
|
||||
guard chars[17].isNumber || chars[17] == "X" else { return false }
|
||||
let weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
|
||||
let checkCodes = Array("10X98765432")
|
||||
let sum = zip(chars.prefix(17), weights).reduce(0) { partial, pair in
|
||||
partial + (pair.0.wholeNumberValue ?? 0) * pair.1
|
||||
}
|
||||
return chars[17] == checkCodes[sum % 11]
|
||||
}
|
||||
|
||||
private func apply(_ info: RealNameInfo?) {
|
||||
self.info = info
|
||||
guard let info else {
|
||||
notifyStateChange()
|
||||
return
|
||||
}
|
||||
realName = info.realName
|
||||
idCardNo = info.idCardNo
|
||||
frontUrl = info.frontUrl ?? ""
|
||||
backUrl = info.backUrl ?? ""
|
||||
pendingFrontImageData = nil
|
||||
pendingBackImageData = nil
|
||||
isLongValid = info.isLongValid
|
||||
startDate = Self.date(from: info.startDate) ?? startDate
|
||||
endDate = Self.date(from: info.endDate) ?? endDate
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
private func makeRequest() -> RealNameAuthRequest {
|
||||
RealNameAuthRequest(
|
||||
realName: realName.trimmed,
|
||||
idCardNo: Self.normalizedIDCardNumber(idCardNo),
|
||||
smsVerifyCode: smsCode.trimmed,
|
||||
startDate: Self.dateFormatter.string(from: startDate),
|
||||
endDate: isLongValid ? "" : Self.dateFormatter.string(from: endDate),
|
||||
isLongValid: isLongValid ? 1 : 0,
|
||||
frontUrl: frontUrl.trimmed,
|
||||
backUrl: backUrl.trimmed
|
||||
)
|
||||
}
|
||||
|
||||
private static let dateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = Calendar(identifier: .gregorian)
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private static func date(from value: String?) -> Date? {
|
||||
guard let value, !value.isEmpty else { return nil }
|
||||
return dateFormatter.date(from: value)
|
||||
}
|
||||
|
||||
private func uploadPendingIdentityImages(uploader: any OSSUploadServing, scenicId: Int) async throws {
|
||||
if let pendingFrontImageData {
|
||||
frontUrl = try await uploader.uploadRealNameImage(
|
||||
data: pendingFrontImageData,
|
||||
fileName: pendingFrontFileName ?? "real_name_front.jpg",
|
||||
scenicId: scenicId
|
||||
) { _ in }
|
||||
self.pendingFrontImageData = nil
|
||||
}
|
||||
if let pendingBackImageData {
|
||||
backUrl = try await uploader.uploadRealNameImage(
|
||||
data: pendingBackImageData,
|
||||
fileName: pendingBackFileName ?? "real_name_back.jpg",
|
||||
scenicId: scenicId
|
||||
) { _ in }
|
||||
self.pendingBackImageData = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
|
||||
enum RealNameValidationError: LocalizedError, Equatable {
|
||||
case message(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .message(let message): message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var trimmed: String {
|
||||
trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,219 @@
|
||||
//
|
||||
// WithdrawalSettingsViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 提现设置 ViewModel,管理银行卡绑定两步表单与提交状态。
|
||||
final class WithdrawalSettingsViewModel {
|
||||
private(set) var currentStep = 1
|
||||
private(set) var holderName = ""
|
||||
private(set) var bankNo = ""
|
||||
private(set) var bankCardFrontURL = ""
|
||||
private(set) var bankCardBackURL = ""
|
||||
private(set) var bankName = ""
|
||||
private(set) var provinceCode = ""
|
||||
private(set) var provinceName = ""
|
||||
private(set) var cityCode = ""
|
||||
private(set) var cityName = ""
|
||||
private(set) var branchName = ""
|
||||
private(set) var verificationCode = ""
|
||||
private(set) var isAgree = false
|
||||
private(set) var banks: [String] = []
|
||||
private(set) var areas: [AreasResponse] = []
|
||||
private(set) var loading = false
|
||||
private(set) var sendingCode = false
|
||||
private(set) var submitting = false
|
||||
|
||||
private(set) var pendingFrontImageData: Data?
|
||||
private(set) var pendingBackImageData: Data?
|
||||
private var pendingFrontFileName: String?
|
||||
private var pendingBackFileName: String?
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
|
||||
var canGoNextStep: Bool {
|
||||
currentStep == 1
|
||||
&& !holderName.trimmed.isEmpty
|
||||
&& (12 ... 25).contains(bankNo.filter(\.isNumber).count)
|
||||
&& !bankCardFrontURL.isEmpty
|
||||
&& !bankCardBackURL.isEmpty
|
||||
&& isAgree
|
||||
}
|
||||
|
||||
var canSubmit: Bool {
|
||||
currentStep == 2
|
||||
&& !holderName.trimmed.isEmpty
|
||||
&& (12 ... 25).contains(bankNo.filter(\.isNumber).count)
|
||||
&& !bankName.trimmed.isEmpty
|
||||
&& !provinceCode.isEmpty
|
||||
&& !cityCode.isEmpty
|
||||
&& !branchName.trimmed.isEmpty
|
||||
&& !verificationCode.trimmed.isEmpty
|
||||
&& isAgree
|
||||
&& !submitting
|
||||
}
|
||||
|
||||
func updateHolderName(_ value: String) {
|
||||
holderName = value
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func updateBankNo(_ value: String) {
|
||||
bankNo = value
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func updateBankName(_ value: String) {
|
||||
bankName = value
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func updateBranchName(_ value: String) {
|
||||
branchName = value
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func updateVerificationCode(_ value: String) {
|
||||
verificationCode = value
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func updateAgree(_ value: Bool) {
|
||||
isAgree = value
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func selectProvince(_ area: AreasResponse) {
|
||||
provinceCode = area.code
|
||||
provinceName = area.name
|
||||
cityCode = ""
|
||||
cityName = ""
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func selectCity(_ area: AreasResponse) {
|
||||
cityCode = area.code
|
||||
cityName = area.name
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func goNextStep() {
|
||||
guard canGoNextStep else { return }
|
||||
currentStep = 2
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func prepareBankCardImage(data: Data, side: BankCardImageSide) throws {
|
||||
let processed = try BankCardImageProcessor.process(data: data, side: side)
|
||||
switch side {
|
||||
case .front:
|
||||
pendingFrontImageData = processed.data
|
||||
pendingFrontFileName = processed.fileName
|
||||
bankCardFrontURL = "pending"
|
||||
case .back:
|
||||
pendingBackImageData = processed.data
|
||||
pendingBackFileName = processed.fileName
|
||||
bankCardBackURL = "pending"
|
||||
}
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func load(api: ProfileAPI) async throws {
|
||||
guard !loading else { return }
|
||||
loading = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
loading = false
|
||||
notifyStateChange()
|
||||
}
|
||||
async let bankList = api.bankList()
|
||||
async let areaList = api.areas()
|
||||
let result = try await (bankList, areaList)
|
||||
banks = result.0.banks
|
||||
areas = result.1
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func sendCode(api: ProfileAPI) async throws {
|
||||
guard !sendingCode else { return }
|
||||
sendingCode = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
sendingCode = false
|
||||
notifyStateChange()
|
||||
}
|
||||
try await api.bankCardSmsVerifyCode()
|
||||
}
|
||||
|
||||
func submit(api: ProfileAPI, uploader: any OSSUploadServing, scenicId: Int) async throws {
|
||||
guard canSubmit else {
|
||||
throw WithdrawalValidationError.message("请完善银行卡信息")
|
||||
}
|
||||
submitting = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
submitting = false
|
||||
notifyStateChange()
|
||||
}
|
||||
try await uploadPendingImages(uploader: uploader, scenicId: scenicId)
|
||||
let request = UpdateBankInfoRequest(
|
||||
realName: holderName.trimmed,
|
||||
cardNumber: bankNo.filter(\.isNumber),
|
||||
bankName: bankName.trimmed,
|
||||
branchName: branchName.trimmed,
|
||||
frontUrl: bankCardFrontURL,
|
||||
backUrl: bankCardBackURL,
|
||||
provinceCode: provinceCode,
|
||||
cityCode: cityCode,
|
||||
smsVerifyCode: verificationCode.trimmed
|
||||
)
|
||||
try await api.updateBankCard(request)
|
||||
}
|
||||
|
||||
var provinceOptions: [AreasResponse] { areas }
|
||||
|
||||
var cityOptions: [AreasResponse] {
|
||||
areas.first { $0.code == provinceCode || $0.name == provinceName }?.children ?? []
|
||||
}
|
||||
|
||||
private func uploadPendingImages(uploader: any OSSUploadServing, scenicId: Int) async throws {
|
||||
if let pendingFrontImageData {
|
||||
bankCardFrontURL = try await uploader.uploadBankCardImage(
|
||||
data: pendingFrontImageData,
|
||||
fileName: pendingFrontFileName ?? "bank_card_front.jpg",
|
||||
scenicId: scenicId
|
||||
) { _ in }
|
||||
self.pendingFrontImageData = nil
|
||||
}
|
||||
if let pendingBackImageData {
|
||||
bankCardBackURL = try await uploader.uploadBankCardImage(
|
||||
data: pendingBackImageData,
|
||||
fileName: pendingBackFileName ?? "bank_card_back.jpg",
|
||||
scenicId: scenicId
|
||||
) { _ in }
|
||||
self.pendingBackImageData = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
|
||||
enum WithdrawalValidationError: LocalizedError, Equatable {
|
||||
case message(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .message(let message): message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var trimmed: String {
|
||||
trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user