Wire deposit orders, historical shooting, and multi-travel uploads into Orders, and replace home placeholders for queue management, message center, scenic settlement, and withdrawal audit. Co-authored-by: Cursor <cursoragent@cursor.com>
167 lines
5.6 KiB
Swift
167 lines
5.6 KiB
Swift
//
|
||
// ScenicQueueSocketClient.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/25.
|
||
//
|
||
|
||
import Foundation
|
||
|
||
/// 排队 WebSocket 消息。
|
||
struct ScenicQueueSocketMessage: Equatable {
|
||
static let queueUpdatedAction = "scenic_spot_queue_updated"
|
||
static let ticketCalledAction = "scenic_queue_ticket_called"
|
||
|
||
let code: Int
|
||
let data: ScenicQueueSocketData?
|
||
|
||
var isScenicQueueEvent: Bool {
|
||
let action = data?.action ?? ""
|
||
return action == Self.queueUpdatedAction || action == Self.ticketCalledAction
|
||
}
|
||
}
|
||
|
||
/// 排队 WebSocket data 块。
|
||
struct ScenicQueueSocketData: Equatable {
|
||
let action: String
|
||
let params: ScenicQueueSocketParams?
|
||
}
|
||
|
||
/// 排队 WebSocket 参数。
|
||
struct ScenicQueueSocketParams: Equatable {
|
||
let scenicSpotId: Int64?
|
||
let recordId: Int64?
|
||
let operatorUid: Int64?
|
||
let eventId: String?
|
||
}
|
||
|
||
@MainActor
|
||
/// 排队 WebSocket 客户端,负责订阅排队更新和叫号事件。
|
||
final class ScenicQueueSocketClient {
|
||
private var webSocketTask: URLSessionWebSocketTask?
|
||
private var receiveTask: Task<Void, Never>?
|
||
|
||
/// 连接 WebSocket 并订阅指定打卡点的排队事件。
|
||
func connect(
|
||
socketToken: String,
|
||
scenicSpotId: Int,
|
||
onMessage: @escaping @MainActor (ScenicQueueSocketMessage) -> Void
|
||
) {
|
||
let token = socketToken.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !token.isEmpty else { return }
|
||
disconnect(reason: "Reconnect before scenic queue socket connect")
|
||
|
||
let task = URLSession.shared.webSocketTask(with: APIEnvironment.current.webSocketURL)
|
||
webSocketTask = task
|
||
task.resume()
|
||
|
||
receiveTask = Task { [weak self] in
|
||
await self?.receiveLoop(task: task, onMessage: onMessage)
|
||
}
|
||
|
||
Task {
|
||
for payload in Self.subscriptionPayloads(scenicSpotId: scenicSpotId) {
|
||
try? await task.send(.string(payload))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 断开 WebSocket。
|
||
func disconnect(reason: String = "Scenic queue page stopped") {
|
||
receiveTask?.cancel()
|
||
receiveTask = nil
|
||
webSocketTask?.cancel(with: .normalClosure, reason: reason.data(using: .utf8))
|
||
webSocketTask = nil
|
||
}
|
||
|
||
private func receiveLoop(
|
||
task: URLSessionWebSocketTask,
|
||
onMessage: @escaping @MainActor (ScenicQueueSocketMessage) -> Void
|
||
) async {
|
||
while !Task.isCancelled {
|
||
do {
|
||
let message = try await task.receive()
|
||
let text: String?
|
||
switch message {
|
||
case .string(let value):
|
||
text = value
|
||
case .data(let data):
|
||
text = String(data: data, encoding: .utf8)
|
||
@unknown default:
|
||
text = nil
|
||
}
|
||
guard let text, let parsed = Self.parseMessage(text), parsed.isScenicQueueEvent else { continue }
|
||
onMessage(parsed)
|
||
} catch {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 构造订阅 payload,type 与 Android 保持一致。
|
||
static func subscriptionPayloads(scenicSpotId: Int) -> [String] {
|
||
[
|
||
#"{"type":306,"params":{"scenic_spot_id":\#(scenicSpotId)}}"#,
|
||
#"{"type":307,"params":{"scenic_spot_id":\#(scenicSpotId)}}"#
|
||
]
|
||
}
|
||
|
||
/// 解析排队 WebSocket 原始消息。
|
||
static func parseMessage(_ raw: String) -> ScenicQueueSocketMessage? {
|
||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard trimmed.hasPrefix("{"), let data = trimmed.data(using: .utf8) else { return nil }
|
||
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
||
|
||
let code = intValue(root["code"]) ?? 0
|
||
var socketData: ScenicQueueSocketData?
|
||
if let dataObject = root["data"] as? [String: Any] {
|
||
let action = stringValue(dataObject["action"])
|
||
var params: ScenicQueueSocketParams?
|
||
if let paramsObject = dataObject["params"] as? [String: Any] {
|
||
params = ScenicQueueSocketParams(
|
||
scenicSpotId: int64Value(paramsObject["scenic_spot_id"]),
|
||
recordId: int64Value(paramsObject["record_id"]),
|
||
operatorUid: int64Value(paramsObject["operator_uid"]),
|
||
eventId: stringValue(paramsObject["event_id"]).trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
|
||
)
|
||
}
|
||
socketData = ScenicQueueSocketData(action: action, params: params)
|
||
}
|
||
return ScenicQueueSocketMessage(code: code, data: socketData)
|
||
}
|
||
|
||
private static func stringValue(_ value: Any?) -> String {
|
||
switch value {
|
||
case let value as String:
|
||
return value
|
||
case let value as NSNumber:
|
||
return value.stringValue
|
||
case let value?:
|
||
return "\(value)"
|
||
case nil:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
private static func intValue(_ value: Any?) -> Int? {
|
||
if let value = value as? Int { return value }
|
||
if let value = value as? NSNumber { return value.intValue }
|
||
if let value = value as? String { return Int(value) }
|
||
return nil
|
||
}
|
||
|
||
private static func int64Value(_ value: Any?) -> Int64? {
|
||
if let value = value as? Int64 { return value }
|
||
if let value = value as? Int { return Int64(value) }
|
||
if let value = value as? NSNumber { return value.int64Value }
|
||
if let value = value as? String { return Int64(value) }
|
||
return nil
|
||
}
|
||
}
|
||
|
||
private extension String {
|
||
var nilIfEmpty: String? {
|
||
isEmpty ? nil : self
|
||
}
|
||
}
|