Add order tail flows and migrate queue, message, settlement, and audit modules.
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>
This commit is contained in:
73
suixinkan/Core/Queue/ScenicQueueAnnouncementState.swift
Normal file
73
suixinkan/Core/Queue/ScenicQueueAnnouncementState.swift
Normal file
@ -0,0 +1,73 @@
|
||||
//
|
||||
// ScenicQueueAnnouncementState.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 排队语音播报内容。
|
||||
struct ScenicQueueAnnouncement: Equatable {
|
||||
enum Kind: Equatable {
|
||||
case newTickets(count: Int)
|
||||
case calledTicket(id: Int64)
|
||||
}
|
||||
|
||||
let kind: Kind
|
||||
let text: String
|
||||
}
|
||||
|
||||
/// 排队播报去重状态,避免同一批队列反复播报。
|
||||
struct ScenicQueueAnnouncementState {
|
||||
private var knownTicketIds = Set<Int64>()
|
||||
private var calledTicketIds = Set<Int64>()
|
||||
|
||||
/// 根据最新统计和列表计算下一条需要播报的内容。
|
||||
mutating func nextAnnouncement(
|
||||
stats: ScenicQueueStatsData,
|
||||
tickets: [ScenicQueueTicket],
|
||||
customCallText: String?
|
||||
) -> ScenicQueueAnnouncement? {
|
||||
let currentIds = Set(tickets.map(\.id))
|
||||
let newTickets = tickets.filter { !knownTicketIds.contains($0.id) }
|
||||
knownTicketIds = currentIds
|
||||
|
||||
if let ticket = tickets.first(where: { ($0.isCalled == 1 || $0.statusText.contains("已叫号")) && !calledTicketIds.contains($0.id) }) {
|
||||
calledTicketIds.insert(ticket.id)
|
||||
return ScenicQueueAnnouncement(kind: .calledTicket(id: ticket.id), text: Self.callText(for: ticket, customText: customCallText))
|
||||
}
|
||||
|
||||
guard !newTickets.isEmpty else { return nil }
|
||||
let text = "排队提醒,当前新增\(newTickets.count)位游客,在排人数\(stats.queueCount)人。"
|
||||
return ScenicQueueAnnouncement(kind: .newTickets(count: newTickets.count), text: text)
|
||||
}
|
||||
|
||||
/// 重置播报去重状态。
|
||||
mutating func reset() {
|
||||
knownTicketIds.removeAll()
|
||||
calledTicketIds.removeAll()
|
||||
}
|
||||
|
||||
/// 生成叫号播报文案。
|
||||
static func callText(for ticket: ScenicQueueTicket, customText: String?) -> String {
|
||||
callText(forQueueCode: ticket.queueCode, customText: customText)
|
||||
}
|
||||
|
||||
/// 生成叫号播报文案。
|
||||
static func callText(forQueueCode queueCode: String, customText: String?) -> String {
|
||||
let custom = customText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !custom.isEmpty, custom != "A0001" {
|
||||
return custom.replacingOccurrences(of: "{number}", with: queueCode)
|
||||
}
|
||||
let code = queueCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "当前游客" : ttsLabel(for: queueCode)
|
||||
return "请\(code)到拍摄点拍摄。"
|
||||
}
|
||||
|
||||
/// 将排队号拆成更适合语音播报的文本。
|
||||
static func ttsLabel(for queueCode: String) -> String {
|
||||
let trimmed = queueCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return "当前游客" }
|
||||
return trimmed.map(String.init).joined(separator: " ")
|
||||
}
|
||||
}
|
||||
289
suixinkan/Core/Queue/ScenicQueueRuntime.swift
Normal file
289
suixinkan/Core/Queue/ScenicQueueRuntime.swift
Normal file
@ -0,0 +1,289 @@
|
||||
//
|
||||
// ScenicQueueRuntime.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Observation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 排队运行时,页面外按开关短轮询当前打卡点并语音播报队列变化。
|
||||
final class ScenicQueueRuntime {
|
||||
private(set) var isMonitoring = false
|
||||
private(set) var lastPollText = "--"
|
||||
private(set) var lastSpokenText = ""
|
||||
private(set) var lastQueueCount = 0
|
||||
private(set) var lastError: String?
|
||||
|
||||
@ObservationIgnored private let speaker = ScenicQueueSpeechService()
|
||||
@ObservationIgnored private weak var api: (any ScenicQueueServing)?
|
||||
@ObservationIgnored private var userId: String?
|
||||
@ObservationIgnored private var scenicId: Int?
|
||||
@ObservationIgnored private var scenePhase: ScenePhase = .active
|
||||
@ObservationIgnored private var pollTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var backgroundTaskId: UIBackgroundTaskIdentifier = .invalid
|
||||
@ObservationIgnored private var announcementState = ScenicQueueAnnouncementState()
|
||||
@ObservationIgnored private var lastScenicId: Int?
|
||||
@ObservationIgnored private var lastSpotId: Int?
|
||||
@ObservationIgnored private var suspendedByQueueScreen = false
|
||||
|
||||
/// 语音播报是否开启。
|
||||
var voiceEnabled: Bool {
|
||||
get {
|
||||
if UserDefaults.standard.object(forKey: ScenicQueueLocalSettings.ttsEnabledKey) == nil { return true }
|
||||
return UserDefaults.standard.bool(forKey: ScenicQueueLocalSettings.ttsEnabledKey)
|
||||
}
|
||||
set { UserDefaults.standard.set(newValue, forKey: ScenicQueueLocalSettings.ttsEnabledKey) }
|
||||
}
|
||||
|
||||
/// 后台短时轮询是否开启。
|
||||
var backgroundPollingEnabled: Bool {
|
||||
get {
|
||||
if UserDefaults.standard.object(forKey: ScenicQueueLocalSettings.backgroundPollEnabledKey) == nil { return false }
|
||||
return UserDefaults.standard.bool(forKey: ScenicQueueLocalSettings.backgroundPollEnabledKey)
|
||||
}
|
||||
set { UserDefaults.standard.set(newValue, forKey: ScenicQueueLocalSettings.backgroundPollEnabledKey) }
|
||||
}
|
||||
|
||||
/// 更新运行时上下文。
|
||||
func update(
|
||||
api: any ScenicQueueServing,
|
||||
userId: String?,
|
||||
scenicId: Int?,
|
||||
scenePhase: ScenePhase
|
||||
) {
|
||||
self.api = api
|
||||
self.userId = userId
|
||||
self.scenicId = scenicId
|
||||
self.scenePhase = scenePhase
|
||||
|
||||
guard !suspendedByQueueScreen else {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
guard let scenicId else {
|
||||
stop()
|
||||
resetSnapshot()
|
||||
return
|
||||
}
|
||||
|
||||
let spotId = selectedSpotId()
|
||||
guard spotId > 0 else {
|
||||
stop()
|
||||
lastError = "排队监听未启动:请先在排队设置里选择打卡点"
|
||||
return
|
||||
}
|
||||
|
||||
if lastScenicId != scenicId || lastSpotId != spotId {
|
||||
resetSnapshot()
|
||||
lastScenicId = scenicId
|
||||
lastSpotId = spotId
|
||||
}
|
||||
|
||||
if scenePhase == .background, !backgroundPollingEnabled {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
startIfNeeded()
|
||||
}
|
||||
|
||||
/// 页面自身接管实时监听时暂停运行时轮询。
|
||||
func setSuspendedByQueueScreen(_ suspended: Bool) {
|
||||
guard suspendedByQueueScreen != suspended else { return }
|
||||
suspendedByQueueScreen = suspended
|
||||
if suspended {
|
||||
stop()
|
||||
} else if let api {
|
||||
update(api: api, userId: userId, scenicId: scenicId, scenePhase: scenePhase)
|
||||
}
|
||||
}
|
||||
|
||||
/// 停止运行时轮询和播报。
|
||||
func stop() {
|
||||
pollTask?.cancel()
|
||||
pollTask = nil
|
||||
isMonitoring = false
|
||||
speaker.stop()
|
||||
endBackgroundTask()
|
||||
}
|
||||
|
||||
/// 立即轮询一次。
|
||||
func pollNow() {
|
||||
guard pollTask != nil else { return }
|
||||
Task { await pollOnce() }
|
||||
}
|
||||
|
||||
private func startIfNeeded() {
|
||||
guard pollTask == nil else { return }
|
||||
isMonitoring = true
|
||||
if scenePhase == .background {
|
||||
beginBackgroundTask()
|
||||
}
|
||||
pollTask = Task { [weak self] in
|
||||
await self?.runLoop()
|
||||
}
|
||||
}
|
||||
|
||||
private func runLoop() async {
|
||||
while !Task.isCancelled {
|
||||
await pollOnce()
|
||||
let seconds = pollIntervalSeconds()
|
||||
try? await Task.sleep(nanoseconds: UInt64(seconds) * 1_000_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
private func pollOnce() async {
|
||||
guard let api, let scenicId else { return }
|
||||
let spotId = selectedSpotId()
|
||||
guard spotId > 0 else { return }
|
||||
|
||||
if scenePhase == .background {
|
||||
beginBackgroundTask()
|
||||
}
|
||||
|
||||
do {
|
||||
if lastScenicId != scenicId || lastSpotId != spotId {
|
||||
resetSnapshot()
|
||||
lastScenicId = scenicId
|
||||
lastSpotId = spotId
|
||||
}
|
||||
async let statsData = api.scenicQueueStats(scenicId: scenicId, scenicSpotId: spotId)
|
||||
async let homeData = api.scenicQueueHome(
|
||||
scenicId: scenicId,
|
||||
scenicSpotId: spotId,
|
||||
type: QueueListType.queueing.rawValue,
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
)
|
||||
let (stats, home) = try await (statsData, homeData)
|
||||
handle(stats: stats, tickets: home.list?.list ?? [])
|
||||
lastError = nil
|
||||
} catch {
|
||||
lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(stats: ScenicQueueStatsData, tickets: [ScenicQueueTicket]) {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HH:mm:ss"
|
||||
lastPollText = formatter.string(from: Date())
|
||||
lastQueueCount = stats.queueCount
|
||||
|
||||
guard voiceEnabled else { return }
|
||||
let customText = ScenicQueueSettingsStore.customTtsText(userId: userId, scenicId: scenicId, spotId: lastSpotId)
|
||||
if let announcement = announcementState.nextAnnouncement(stats: stats, tickets: tickets, customCallText: customText) {
|
||||
lastSpokenText = announcement.text
|
||||
speaker.speak(announcement.text)
|
||||
}
|
||||
}
|
||||
|
||||
private func pollIntervalSeconds() -> UInt64 {
|
||||
switch scenePhase {
|
||||
case .active:
|
||||
return 12
|
||||
case .inactive:
|
||||
return 20
|
||||
case .background:
|
||||
return 30
|
||||
@unknown default:
|
||||
return 20
|
||||
}
|
||||
}
|
||||
|
||||
private func selectedSpotId() -> Int {
|
||||
ScenicQueueSettingsStore.selectedSpotId(userId: userId, scenicId: scenicId)
|
||||
?? UserDefaults.standard.integer(forKey: ScenicQueueLocalSettings.selectedSpotIdKey)
|
||||
}
|
||||
|
||||
private func resetSnapshot() {
|
||||
announcementState.reset()
|
||||
lastQueueCount = 0
|
||||
lastPollText = "--"
|
||||
lastSpokenText = ""
|
||||
}
|
||||
|
||||
private func beginBackgroundTask() {
|
||||
guard backgroundTaskId == .invalid else { return }
|
||||
backgroundTaskId = UIApplication.shared.beginBackgroundTask(withName: "scenic.queue.poll") { [weak self] in
|
||||
Task { @MainActor in
|
||||
self?.endBackgroundTask()
|
||||
self?.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func endBackgroundTask() {
|
||||
guard backgroundTaskId != .invalid else { return }
|
||||
UIApplication.shared.endBackgroundTask(backgroundTaskId)
|
||||
backgroundTaskId = .invalid
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 排队语音播报服务。
|
||||
final class ScenicQueueSpeechService: NSObject, AVSpeechSynthesizerDelegate {
|
||||
private let synthesizer = AVSpeechSynthesizer()
|
||||
private var pendingContinuations: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
synthesizer.delegate = self
|
||||
}
|
||||
|
||||
/// 播放语音文本。
|
||||
func speak(_ text: String) {
|
||||
let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalized.isEmpty else { return }
|
||||
enqueue(normalized, continuation: nil, replacePending: true)
|
||||
}
|
||||
|
||||
/// 播放语音并等待结束,供测试和设置页试听使用。
|
||||
func speakAndWait(_ text: String) async {
|
||||
let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalized.isEmpty else { return }
|
||||
await withCheckedContinuation { continuation in
|
||||
enqueue(normalized, continuation: continuation, replacePending: false)
|
||||
}
|
||||
}
|
||||
|
||||
/// 停止语音播报。
|
||||
func stop() {
|
||||
synthesizer.stopSpeaking(at: .immediate)
|
||||
resumePending()
|
||||
}
|
||||
|
||||
private func enqueue(_ normalized: String, continuation: CheckedContinuation<Void, Never>?, replacePending: Bool) {
|
||||
if replacePending {
|
||||
synthesizer.stopSpeaking(at: .immediate)
|
||||
resumePending()
|
||||
}
|
||||
if let continuation {
|
||||
pendingContinuations.append(continuation)
|
||||
}
|
||||
let utterance = AVSpeechUtterance(string: normalized)
|
||||
utterance.voice = AVSpeechSynthesisVoice(language: "zh-CN")
|
||||
utterance.rate = 0.48
|
||||
synthesizer.speak(utterance)
|
||||
}
|
||||
|
||||
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
||||
Task { @MainActor in self.resumePending() }
|
||||
}
|
||||
|
||||
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
|
||||
Task { @MainActor in self.resumePending() }
|
||||
}
|
||||
|
||||
private func resumePending() {
|
||||
let continuations = pendingContinuations
|
||||
pendingContinuations.removeAll()
|
||||
continuations.forEach { $0.resume() }
|
||||
}
|
||||
}
|
||||
166
suixinkan/Core/Queue/ScenicQueueSocketClient.swift
Normal file
166
suixinkan/Core/Queue/ScenicQueueSocketClient.swift
Normal file
@ -0,0 +1,166 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user