Initial commit
This commit is contained in:
86
suixinkan/Core/Storage/AccountSnapshotStore.swift
Normal file
86
suixinkan/Core/Storage/AccountSnapshotStore.swift
Normal file
@ -0,0 +1,86 @@
|
||||
//
|
||||
// AccountSnapshotStore.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/20.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// 账号缓存快照实体,保存可重建、非敏感的账号展示和业务上下文。
|
||||
struct AccountSnapshot: Codable, Equatable {
|
||||
var profile: AccountProfile?
|
||||
var accountType: String?
|
||||
var businessUserId: Int?
|
||||
var currentRoleId: Int?
|
||||
var scenicScopes: [BusinessScope]
|
||||
var storeScopes: [BusinessScope]
|
||||
var currentScenicId: Int?
|
||||
var currentStoreId: Int?
|
||||
|
||||
/// 创建账号缓存快照,默认没有业务作用域。
|
||||
init(
|
||||
profile: AccountProfile? = nil,
|
||||
accountType: String? = nil,
|
||||
businessUserId: Int? = nil,
|
||||
currentRoleId: Int? = nil,
|
||||
scenicScopes: [BusinessScope] = [],
|
||||
storeScopes: [BusinessScope] = [],
|
||||
currentScenicId: Int? = nil,
|
||||
currentStoreId: Int? = nil
|
||||
) {
|
||||
self.profile = profile
|
||||
self.accountType = accountType
|
||||
self.businessUserId = businessUserId
|
||||
self.currentRoleId = currentRoleId
|
||||
self.scenicScopes = scenicScopes
|
||||
self.storeScopes = storeScopes
|
||||
self.currentScenicId = currentScenicId
|
||||
self.currentStoreId = currentStoreId
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
/// 账号快照存储服务,使用 UserDefaults 保存非敏感登录上下文。
|
||||
final class AccountSnapshotStore {
|
||||
@ObservationIgnored private let defaults: UserDefaults
|
||||
@ObservationIgnored private let key: String
|
||||
@ObservationIgnored private let encoder: JSONEncoder
|
||||
@ObservationIgnored private let decoder: JSONDecoder
|
||||
|
||||
/// 初始化账号快照存储服务,并允许测试注入独立的 UserDefaults。
|
||||
init(
|
||||
defaults: UserDefaults = .standard,
|
||||
key: String = "suixinkan.account.snapshot.v1",
|
||||
encoder: JSONEncoder = JSONEncoder(),
|
||||
decoder: JSONDecoder = JSONDecoder()
|
||||
) {
|
||||
self.defaults = defaults
|
||||
self.key = key
|
||||
self.encoder = encoder
|
||||
self.decoder = decoder
|
||||
}
|
||||
|
||||
/// 保存账号快照,编码失败时保持原缓存不变。
|
||||
func save(_ snapshot: AccountSnapshot) {
|
||||
guard let data = try? encoder.encode(snapshot) else { return }
|
||||
defaults.set(data, forKey: key)
|
||||
}
|
||||
|
||||
/// 读取账号快照,解码失败时清空损坏数据。
|
||||
func load() -> AccountSnapshot? {
|
||||
guard let data = defaults.data(forKey: key) else { return nil }
|
||||
do {
|
||||
return try decoder.decode(AccountSnapshot.self, from: data)
|
||||
} catch {
|
||||
clear()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空账号快照缓存。
|
||||
func clear() {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
46
suixinkan/Core/Storage/AppPreferencesStore.swift
Normal file
46
suixinkan/Core/Storage/AppPreferencesStore.swift
Normal file
@ -0,0 +1,46 @@
|
||||
//
|
||||
// AppPreferencesStore.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/20.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
/// App 偏好存储服务,保存上次手机号和协议状态等非敏感设置。
|
||||
final class AppPreferencesStore {
|
||||
@ObservationIgnored private let defaults: UserDefaults
|
||||
@ObservationIgnored private let lastLoginUsernameKey = "suixinkan.preferences.last_login_username"
|
||||
@ObservationIgnored private let privacyAgreementAcceptedKey = "suixinkan.preferences.privacy_agreement_accepted"
|
||||
|
||||
/// 初始化偏好存储服务,并允许测试注入独立的 UserDefaults。
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
/// 保存上次成功登录的手机号。
|
||||
func saveLastLoginUsername(_ username: String) {
|
||||
let value = username.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !value.isEmpty else { return }
|
||||
defaults.set(value, forKey: lastLoginUsernameKey)
|
||||
}
|
||||
|
||||
/// 读取上次成功登录的手机号。
|
||||
func loadLastLoginUsername() -> String? {
|
||||
let value = defaults.string(forKey: lastLoginUsernameKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
|
||||
/// 保存用户是否已经同意登录页协议。
|
||||
func savePrivacyAgreementAccepted(_ accepted: Bool) {
|
||||
defaults.set(accepted, forKey: privacyAgreementAcceptedKey)
|
||||
}
|
||||
|
||||
/// 读取用户是否已经同意登录页协议。
|
||||
func loadPrivacyAgreementAccepted() -> Bool {
|
||||
defaults.bool(forKey: privacyAgreementAcceptedKey)
|
||||
}
|
||||
}
|
||||
102
suixinkan/Core/Storage/SessionTokenStore.swift
Normal file
102
suixinkan/Core/Storage/SessionTokenStore.swift
Normal file
@ -0,0 +1,102 @@
|
||||
//
|
||||
// SessionTokenStore.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/20.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Security
|
||||
|
||||
/// 登录 token 存储错误实体,表示 Keychain 读写失败的具体状态。
|
||||
enum SessionTokenStoreError: LocalizedError {
|
||||
case unexpectedStatus(OSStatus)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case let .unexpectedStatus(status):
|
||||
"登录凭证存储失败(\(status))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
/// 正式登录 token 存储服务,封装 Keychain 读写并避免业务层接触安全 API。
|
||||
final class SessionTokenStore {
|
||||
@ObservationIgnored private let service: String
|
||||
@ObservationIgnored private let account: String
|
||||
|
||||
/// 初始化 token 存储服务,默认按 App Bundle 隔离 Keychain 项。
|
||||
init(
|
||||
service: String = Bundle.main.bundleIdentifier ?? "com.yuanzhixiang.suixinkan",
|
||||
account: String = "session.token"
|
||||
) {
|
||||
self.service = service
|
||||
self.account = account
|
||||
}
|
||||
|
||||
/// 保存正式 token,空 token 会被视为清空凭证。
|
||||
func save(_ token: String) throws {
|
||||
let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedToken.isEmpty else {
|
||||
try clear()
|
||||
return
|
||||
}
|
||||
|
||||
let data = Data(trimmedToken.utf8)
|
||||
let query = baseQuery()
|
||||
let updateAttributes: [String: Any] = [kSecValueData as String: data]
|
||||
|
||||
let updateStatus = SecItemUpdate(query as CFDictionary, updateAttributes as CFDictionary)
|
||||
if updateStatus == errSecSuccess {
|
||||
return
|
||||
}
|
||||
guard updateStatus == errSecItemNotFound else {
|
||||
throw SessionTokenStoreError.unexpectedStatus(updateStatus)
|
||||
}
|
||||
|
||||
var addQuery = query
|
||||
updateAttributes.forEach { addQuery[$0.key] = $0.value }
|
||||
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else {
|
||||
throw SessionTokenStoreError.unexpectedStatus(addStatus)
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取本地正式 token,读取失败或无值时返回 nil。
|
||||
func load() -> String? {
|
||||
var query = baseQuery()
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
guard status == errSecSuccess,
|
||||
let data = result as? Data,
|
||||
let token = String(data: data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!token.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
/// 清空本地正式 token。
|
||||
func clear() throws {
|
||||
let status = SecItemDelete(baseQuery() as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw SessionTokenStoreError.unexpectedStatus(status)
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造当前 App 使用的 Keychain 查询条件。
|
||||
private func baseQuery() -> [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user