迁移合作订单完整能力,并统一 AppRoleCode 角色权限与首页菜单展示。
新增 CooperationOrder 模块(双 Tab 列表、获客员绑定、主 Tab 扫码)、订单来源/带客单绑定及配套 API 测试;同步引入 roleCode 权限匹配与相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
100
suixinkan/Core/Models/AppRoleCode.swift
Normal file
100
suixinkan/Core/Models/AppRoleCode.swift
Normal file
@ -0,0 +1,100 @@
|
||||
//
|
||||
// AppRoleCode.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// App 业务角色枚举,与后端 `app_role_code` / `role_code` 字符串一一对应。
|
||||
enum AppRoleCode: String, CaseIterable, Hashable {
|
||||
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 isStoreAdmin: Bool { self == .storeAdmin }
|
||||
var isScenicAdmin: Bool { self == .scenicAdmin }
|
||||
var isPhotographer: Bool { self == .photographer }
|
||||
|
||||
/// 从后端 role_code 字符串解析角色。
|
||||
static func fromCode(_ code: String?) -> AppRoleCode? {
|
||||
guard let trimmed = code?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!trimmed.isEmpty else { return nil }
|
||||
return AppRoleCode(rawValue: trimmed)
|
||||
}
|
||||
|
||||
/// 从旧版 numeric 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_code 缺失时的兜底。
|
||||
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) }
|
||||
}
|
||||
|
||||
/// 首页隐藏顶部在线/计时/位置/快捷操作的角色(原 46/47/52/53/54)。
|
||||
static let homeMinimalTopRoles: Set<AppRoleCode> = [
|
||||
.storeAdmin,
|
||||
.editor,
|
||||
.scenicCS,
|
||||
.scenicAdmin,
|
||||
.scenicOperation,
|
||||
]
|
||||
}
|
||||
76
suixinkan/Core/Models/RolePermissionMatching.swift
Normal file
76
suixinkan/Core/Models/RolePermissionMatching.swift
Normal file
@ -0,0 +1,76 @@
|
||||
//
|
||||
// RolePermissionMatching.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 角色权限匹配工具,按 role_code 优先、legacy id、角色名顺序解析当前角色。
|
||||
enum RolePermissionMatching {
|
||||
/// 解析当前 App 角色,用于快照迁移和 session 补全。
|
||||
static func resolveRoleCode(
|
||||
roleCode: String?,
|
||||
roleId: Int?,
|
||||
roleName: String?,
|
||||
permissions: [RolePermissionResponse] = []
|
||||
) -> AppRoleCode? {
|
||||
if let code = AppRoleCode.fromCode(roleCode) { return code }
|
||||
if let legacyId = roleId, legacyId > 0, let code = AppRoleCode.fromLegacyRoleId(legacyId) {
|
||||
return code
|
||||
}
|
||||
if let code = AppRoleCode.fromRoleName(roleName) { return code }
|
||||
return inferFromPermissionList(roleId: roleId, roleName: roleName, permissions: permissions)
|
||||
}
|
||||
|
||||
/// 在角色权限列表中匹配当前角色,优先 role_code。
|
||||
static func match(
|
||||
in permissions: [RolePermissionResponse],
|
||||
roleCode: String?,
|
||||
roleId: Int?,
|
||||
roleName: String?
|
||||
) -> RolePermissionResponse? {
|
||||
if let code = AppRoleCode.fromCode(roleCode) {
|
||||
if let matched = permissions.first(where: { AppRoleCode.fromCode($0.role.roleCode) == code }) {
|
||||
return matched
|
||||
}
|
||||
}
|
||||
if let roleId, roleId > 0,
|
||||
let matched = permissions.first(where: { $0.role.id == roleId }) {
|
||||
return matched
|
||||
}
|
||||
let trimmedName = roleName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedName.isEmpty {
|
||||
if let matched = permissions.first(where: { item in
|
||||
let name = item.role.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return !name.isEmpty && (name.contains(trimmedName) || trimmedName.contains(name))
|
||||
}) {
|
||||
return matched
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 从权限列表推断角色 code。
|
||||
private static func inferFromPermissionList(
|
||||
roleId: Int?,
|
||||
roleName: String?,
|
||||
permissions: [RolePermissionResponse]
|
||||
) -> AppRoleCode? {
|
||||
guard !permissions.isEmpty else { return nil }
|
||||
if let roleId, roleId > 0,
|
||||
let item = permissions.first(where: { $0.role.id == roleId }) {
|
||||
return AppRoleCode.fromCode(item.role.roleCode) ?? AppRoleCode.fromRoleName(item.role.name)
|
||||
}
|
||||
let trimmedName = roleName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedName.isEmpty,
|
||||
let item = permissions.first(where: { item in
|
||||
let name = item.role.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return !name.isEmpty && (name.contains(trimmedName) || trimmedName.contains(name))
|
||||
}) {
|
||||
return AppRoleCode.fromCode(item.role.roleCode) ?? AppRoleCode.fromRoleName(item.role.name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -13,6 +13,8 @@ struct AccountSnapshot: Codable, Equatable {
|
||||
var profile: AccountProfile?
|
||||
var accountType: String?
|
||||
var businessUserId: Int?
|
||||
var currentRoleCode: String?
|
||||
/// 旧版快照字段,仅用于解码与一次性迁移。
|
||||
var currentRoleId: Int?
|
||||
var scenicScopes: [BusinessScope]
|
||||
var storeScopes: [BusinessScope]
|
||||
@ -24,6 +26,7 @@ struct AccountSnapshot: Codable, Equatable {
|
||||
profile: AccountProfile? = nil,
|
||||
accountType: String? = nil,
|
||||
businessUserId: Int? = nil,
|
||||
currentRoleCode: String? = nil,
|
||||
currentRoleId: Int? = nil,
|
||||
scenicScopes: [BusinessScope] = [],
|
||||
storeScopes: [BusinessScope] = [],
|
||||
@ -33,12 +36,25 @@ struct AccountSnapshot: Codable, Equatable {
|
||||
self.profile = profile
|
||||
self.accountType = accountType
|
||||
self.businessUserId = businessUserId
|
||||
self.currentRoleCode = currentRoleCode
|
||||
self.currentRoleId = currentRoleId
|
||||
self.scenicScopes = scenicScopes
|
||||
self.storeScopes = storeScopes
|
||||
self.currentScenicId = currentScenicId
|
||||
self.currentStoreId = currentStoreId
|
||||
}
|
||||
|
||||
/// 若快照仅有 legacy role id,则迁移为 role_code 并清除 id。
|
||||
mutating func migrateLegacyRoleIfNeeded() {
|
||||
guard AppRoleCode.fromCode(currentRoleCode) == nil,
|
||||
let legacyId = currentRoleId,
|
||||
legacyId > 0,
|
||||
let migrated = AppRoleCode.fromLegacyRoleId(legacyId) else {
|
||||
return
|
||||
}
|
||||
currentRoleCode = migrated.rawValue
|
||||
currentRoleId = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 账号快照存储服务,使用 UserDefaults 保存非敏感登录上下文。
|
||||
@ -67,11 +83,17 @@ final class AccountSnapshotStore {
|
||||
defaults.set(data, forKey: key)
|
||||
}
|
||||
|
||||
/// 读取账号快照,解码失败时清空损坏数据。
|
||||
/// 读取账号快照,解码失败时清空损坏数据;读取后自动尝试 legacy 角色迁移。
|
||||
func load() -> AccountSnapshot? {
|
||||
guard let data = defaults.data(forKey: key) else { return nil }
|
||||
do {
|
||||
return try decoder.decode(AccountSnapshot.self, from: data)
|
||||
var snapshot = try decoder.decode(AccountSnapshot.self, from: data)
|
||||
let beforeCode = snapshot.currentRoleCode
|
||||
snapshot.migrateLegacyRoleIfNeeded()
|
||||
if snapshot.currentRoleCode != beforeCode {
|
||||
save(snapshot)
|
||||
}
|
||||
return snapshot
|
||||
} catch {
|
||||
clear()
|
||||
return nil
|
||||
@ -87,7 +109,7 @@ final class AccountSnapshotStore {
|
||||
@MainActor
|
||||
func saveCurrentSelection(
|
||||
accountContext: AccountContext,
|
||||
currentRoleId: Int?
|
||||
currentRoleCode: String?
|
||||
) {
|
||||
let existing = load()
|
||||
save(
|
||||
@ -95,7 +117,7 @@ final class AccountSnapshotStore {
|
||||
profile: accountContext.profile ?? existing?.profile,
|
||||
accountType: existing?.accountType,
|
||||
businessUserId: existing?.businessUserId,
|
||||
currentRoleId: currentRoleId ?? existing?.currentRoleId,
|
||||
currentRoleCode: currentRoleCode ?? existing?.currentRoleCode,
|
||||
scenicScopes: accountContext.scenicScopes,
|
||||
storeScopes: accountContext.storeScopes,
|
||||
currentScenicId: accountContext.currentScenic?.id,
|
||||
|
||||
107
suixinkan/Core/Utils/SaleUserQrParser.swift
Normal file
107
suixinkan/Core/Utils/SaleUserQrParser.swift
Normal file
@ -0,0 +1,107 @@
|
||||
//
|
||||
// SaleUserQrParser.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 获客员绑定二维码解析器,从扫码内容提取 sale_user_id。
|
||||
enum SaleUserQrParser {
|
||||
/// 标准获客员绑定二维码 method,与后端约定一致。
|
||||
static let methodSaleUserBind = "sale_user_bind"
|
||||
|
||||
/// 从获客员二维码内容解析 sale_user_id。
|
||||
/// 标准格式:`{"method":"sale_user_bind","sale_user_id":2}`
|
||||
/// 兼容:纯数字、URL query、无 method 的历史 JSON。
|
||||
static func parseSaleUserId(_ content: String) -> Int? {
|
||||
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return nil }
|
||||
|
||||
if trimmed.hasPrefix("{") {
|
||||
if let value = parseFromJSON(trimmed) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
if let value = Int(trimmed), value > 0 {
|
||||
return value
|
||||
}
|
||||
|
||||
if trimmed.contains("://") || trimmed.contains("?") {
|
||||
return parseFromURL(trimmed)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 判断扫码内容是否为获客员绑定专用二维码。
|
||||
static func isSaleUserBindQR(_ content: String) -> Bool {
|
||||
guard let object = jsonObject(from: content) else { return false }
|
||||
let method = (object["method"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return method == methodSaleUserBind
|
||||
}
|
||||
|
||||
private static func parseFromJSON(_ json: String) -> Int? {
|
||||
guard let object = jsonObject(from: json) else { return nil }
|
||||
return parseFromJSONObject(object)
|
||||
}
|
||||
|
||||
private static func jsonObject(from json: String) -> [String: Any]? {
|
||||
guard let data = json.data(using: .utf8),
|
||||
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
else { return nil }
|
||||
return object
|
||||
}
|
||||
|
||||
private static func parseFromJSONObject(_ object: [String: Any]) -> Int? {
|
||||
let method = (object["method"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if method == methodSaleUserBind {
|
||||
return readIntValue(object["sale_user_id"])
|
||||
}
|
||||
return readSaleUserId(object)
|
||||
}
|
||||
|
||||
private static func readSaleUserId(_ object: [String: Any]) -> Int? {
|
||||
let keys = ["sale_user_id", "saleUserId", "user_id", "id"]
|
||||
for key in keys {
|
||||
if let value = readIntValue(object[key]) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func readIntValue(_ value: Any?) -> Int? {
|
||||
switch value {
|
||||
case let intValue as Int where intValue > 0:
|
||||
return intValue
|
||||
case let number as NSNumber where number.intValue > 0:
|
||||
return number.intValue
|
||||
case let stringValue as String:
|
||||
let trimmed = stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let intValue = Int(trimmed), intValue > 0 {
|
||||
return intValue
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseFromURL(_ urlString: String) -> Int? {
|
||||
guard let components = URLComponents(string: urlString) else { return nil }
|
||||
let keys = ["sale_user_id", "saleUserId", "user_id", "id"]
|
||||
for key in keys {
|
||||
if let value = components.queryItems?.first(where: { $0.name == key })?.value,
|
||||
let intValue = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||
intValue > 0 {
|
||||
return intValue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user