迁移合作订单完整能力,并统一 AppRoleCode 角色权限与首页菜单展示。

新增 CooperationOrder 模块(双 Tab 列表、获客员绑定、主 Tab 扫码)、订单来源/带客单绑定及配套 API 测试;同步引入 roleCode 权限匹配与相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-29 13:33:00 +08:00
parent d310d26293
commit 1970572edd
66 changed files with 4247 additions and 201 deletions

View 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,
]
}

View 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
}
}

View File

@ -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,

View 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
}
}