Initial commit

This commit is contained in:
2026-06-22 11:28:01 +08:00
commit ace9c94359
84 changed files with 8899 additions and 0 deletions

View File

@ -0,0 +1,78 @@
//
// AccountContextAPI.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
import Observation
@MainActor
/// 便
protocol AccountContextServing {
///
func rolePermissions() async throws -> [RolePermissionResponse]
/// 访
func scenicListAll() async throws -> ScenicListAllResponse
/// 访
func storeAll() async throws -> ListPayload<StoreItem>
///
func scenicSpotListAll(scenicId: Int) async throws -> ListPayload<ScenicSpotItem>
}
@MainActor
@Observable
/// API
final class AccountContextAPI: AccountContextServing {
@ObservationIgnored private let client: APIClient
/// API
init(client: APIClient) {
self.client = client
}
///
func rolePermissions() async throws -> [RolePermissionResponse] {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/role-permission"
)
)
}
/// 访
func scenicListAll() async throws -> ScenicListAllResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/scenic/list-all"
)
)
}
/// 访
func storeAll() async throws -> ListPayload<StoreItem> {
try await client.send(
APIRequest(
method: .get,
path: "/api/app/store/all"
)
)
}
///
func scenicSpotListAll(scenicId: Int) async throws -> ListPayload<ScenicSpotItem> {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/scenic-spot/list-all",
queryItems: [URLQueryItem(name: "scenic_id", value: "\(scenicId)")]
)
)
}
}

View File

@ -0,0 +1,36 @@
# Account 模块业务逻辑
## 模块职责
Account 模块负责同步账号进入业务页前必须具备的上下文数据,包括角色权限、景区、门店和当前景区下的景点/打卡点。
该模块只做读取和上下文装配,不负责登录 token 存储,也不负责页面导航。
## 数据边界
- 角色权限:由 `/api/yf-handset-app/role-permission` 返回,写入 `PermissionContext`
- 景区列表:由 `/api/yf-handset-app/photog/scenic/list-all` 返回,用于账号业务作用域。
- 门店列表:由 `/api/app/store/all` 返回,并通过 `scenic_id` 关联景区。
- 景点/打卡点:由 `/api/yf-handset-app/photog/scenic-spot/list-all` 按当前景区懒加载。
## 加载流程
登录成功或冷启动恢复时,`AccountContextLoader` 会并行请求用户资料和角色权限。权限成功后继续读取景区和门店:
1. 用户资料刷新 `AccountContext.profile`
2. 角色权限刷新 `PermissionContext`,并计算当前角色的权限 URI 集合。
3. 景区列表失败时,从角色权限里的景区数据去重兜底。
4. 门店列表失败时不阻断登录,只写入空门店列表。
5. 当前景区确定后,`ScenicSpotContext` 再按景区 ID 拉取景点/打卡点。
## 切换规则
- 切换角色时,当前景区优先保留在新角色可访问景区中,否则选择新角色第一个景区。
- 切换景区时,当前门店优先保留同景区门店,否则选择该景区第一个门店。
- 切换景区后,景点/打卡点列表会重新加载。
## 缓存规则
`AccountSnapshotStore` 只保存非敏感上下文快照,包括当前角色 ID、当前景区 ID、当前门店 ID 和基础账号展示信息。
景点/打卡点列表只保存在内存中,不长期落盘。后续业务需要离线缓存时,需要按账号类型、业务账号 ID 和景区 ID 做隔离。

View File

@ -0,0 +1,355 @@
//
// AccountContextModels.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
/// 访
struct RolePermissionResponse: Decodable, Equatable {
let role: RoleInfo
let scenic: [ScenicInfo]
}
///
struct RoleInfo: Decodable, Equatable, Identifiable {
let id: Int
let name: String
let notes: String?
let permission: [PermissionItem]
/// JSON
enum CodingKeys: String, CodingKey {
case id
case name
case notes
case permission
}
///
init(id: Int, name: String, notes: String? = nil, permission: [PermissionItem] = []) {
self.id = id
self.name = name
self.notes = notes
self.permission = permission
}
/// ID
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)
notes = try container.decodeIfPresent(String.self, forKey: .notes)
permission = try container.decodeIfPresent([PermissionItem].self, forKey: .permission) ?? []
}
}
///
struct PermissionItem: Decodable, Equatable, Identifiable {
let id: Int
let pid: Int
let name: String
let uri: String
let iconSrc: String?
let children: [PermissionItem]
/// JSON
enum CodingKeys: String, CodingKey {
case id
case pid
case name
case uri
case iconSrc = "icon_src"
case children
}
///
init(id: Int, pid: Int = 0, name: String, uri: String = "", iconSrc: String? = nil, children: [PermissionItem] = []) {
self.id = id
self.pid = pid
self.name = name
self.uri = uri
self.iconSrc = iconSrc
self.children = children
}
/// ID children
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id) ?? 0
pid = try container.decodeLossyInt(forKey: .pid) ?? 0
name = try container.decodeLossyString(forKey: .name)
uri = try container.decodeLossyString(forKey: .uri)
iconSrc = try container.decodeIfPresent(String.self, forKey: .iconSrc)
children = try container.decodeIfPresent([PermissionItem].self, forKey: .children) ?? []
}
}
/// 访
struct ScenicInfo: Decodable, Equatable, Identifiable {
let id: Int
let name: String
let status: Int?
let location: ScenicLocationInfo?
let coverImg: String?
/// JSON
enum CodingKeys: String, CodingKey {
case id
case name
case status
case location
case coverImg = "cover_img"
}
///
init(id: Int, name: String, status: Int? = nil, location: ScenicLocationInfo? = nil, coverImg: String? = nil) {
self.id = id
self.name = name
self.status = status
self.location = location
self.coverImg = coverImg
}
/// location JSON
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)
status = try container.decodeLossyInt(forKey: .status)
coverImg = try container.decodeIfPresent(String.self, forKey: .coverImg)
if let value = try? container.decodeIfPresent(ScenicLocationInfo.self, forKey: .location) {
location = value
} else if let rawValue = try? container.decodeIfPresent(String.self, forKey: .location),
let data = rawValue.data(using: .utf8) {
location = try? JSONDecoder().decode(ScenicLocationInfo.self, from: data)
} else {
location = nil
}
}
}
///
struct ScenicLocationInfo: Decodable, Equatable {
let lng: Double
let lat: Double
let address: String
/// JSON
enum CodingKeys: String, CodingKey {
case lng
case lat
case address
}
///
init(lng: Double, lat: Double, address: String) {
self.lng = lng
self.lat = lat
self.address = address
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
lng = try container.decodeLossyDouble(forKey: .lng) ?? 0
lat = try container.decodeLossyDouble(forKey: .lat) ?? 0
address = try container.decodeLossyString(forKey: .address)
}
}
///
struct ScenicListAllResponse: Decodable, Equatable {
let total: Int
let list: [ScenicListItem]
/// JSON
enum CodingKeys: String, CodingKey {
case total
case list
}
///
init(total: Int, list: [ScenicListItem]) {
self.total = total
self.list = list
}
/// total
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
total = try container.decodeLossyInt(forKey: .total) ?? 0
list = try container.decodeIfPresent([ScenicListItem].self, forKey: .list) ?? []
}
}
///
struct ScenicListItem: Decodable, Equatable, Identifiable {
let id: Int
let name: String
/// JSON
enum CodingKeys: String, CodingKey {
case id
case name
}
///
init(id: Int, name: String) {
self.id = id
self.name = name
}
/// ID
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 StoreItem: Decodable, Equatable, Identifiable {
let id: Int
let scenicId: Int
let name: String
let address: String
let status: Int
let statusText: String
let tel: String?
let logo: String?
/// JSON
enum CodingKeys: String, CodingKey {
case id
case scenicId = "scenic_id"
case name
case address
case status
case statusText = "status_text"
case tel
case logo
}
///
init(
id: Int,
scenicId: Int,
name: String,
address: String = "",
status: Int = 0,
statusText: String = "",
tel: String? = nil,
logo: String? = nil
) {
self.id = id
self.scenicId = scenicId
self.name = name
self.address = address
self.status = status
self.statusText = statusText
self.tel = tel
self.logo = logo
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id) ?? 0
scenicId = try container.decodeLossyInt(forKey: .scenicId) ?? 0
name = try container.decodeLossyString(forKey: .name)
address = try container.decodeLossyString(forKey: .address)
status = try container.decodeLossyInt(forKey: .status) ?? 0
statusText = try container.decodeLossyString(forKey: .statusText)
tel = try container.decodeIfPresent(String.self, forKey: .tel)
logo = try container.decodeIfPresent(String.self, forKey: .logo)
}
}
///
struct ScenicSpotItem: Decodable, Equatable, Identifiable {
let id: Int
let name: String
let status: Int
let statusLabel: String
/// JSON
enum CodingKeys: String, CodingKey {
case id
case name
case status
case statusLabel = "status_label"
}
///
init(id: Int, name: String, status: Int = 0, statusLabel: String = "") {
self.id = id
self.name = name
self.status = status
self.statusLabel = statusLabel
}
///
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)
status = try container.decodeLossyInt(forKey: .status) ?? 0
statusLabel = try container.decodeLossyString(forKey: .statusLabel)
}
}
private extension KeyedDecodingContainer {
/// String Bool
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 ""
}
/// IntDouble
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
}
/// DoubleInt
func decodeLossyDouble(forKey key: Key) throws -> Double? {
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return Double(value)
}
if let value = try? decodeIfPresent(String.self, forKey: key) {
return Double(value.trimmingCharacters(in: .whitespacesAndNewlines))
}
return nil
}
}