Files
suixinkan_uikit/suixinkan/Features/LocationReport/Services/LocationReportService.swift
T
lujiuyinandCursor 005349f8e6 模块化 AppStore 并完善素材管理与个人空间设置。
将会话、权限、位置、收款与排队存储拆分为独立模块,同步更新各 ViewModel 与单元测试,并补充素材管理 UI 与个人空间设置交互。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 11:01:08 +08:00

212 lines
7.1 KiB
Swift

//
// LocationReportService.swift
// suixinkan
//
import CoreLocation
import Foundation
/// 位置上报成功结果。
struct LocationReportSuccessResult: Equatable {
let reportTimeText: String
let locationInfoText: String
let shouldShowSuccessDialog: Bool
}
/// 位置上报共享业务逻辑,对齐 Android `HomeViewModel.reportLocation`。
final class LocationReportService {
static let operationIntervalMillis: Int64 = 30_000
static let manualReportDesiredAccuracy: CLLocationAccuracy = kCLLocationAccuracyHundredMeters
private(set) var lastOnlineStatusSwitchTime: Int64 = 0
private(set) var lastLocationReportTime: Int64 = 0
private(set) var isReporting = false
private let appStore: AppStore
private let locationStateStore: HomeLocationStateStore
private let locationProvider: any LocationProviding
init(
appStore: AppStore = .shared,
locationStateStore: HomeLocationStateStore,
locationProvider: any LocationProviding = LocationProvider.shared
) {
self.appStore = appStore
self.locationStateStore = locationStateStore
self.locationProvider = locationProvider
}
/// 切换在线/离线并触发位置上报。
func switchOnlineStatus(api: HomeAPI) async throws -> LocationReportSuccessResult? {
let now = currentTimestampMillis()
if now - lastOnlineStatusSwitchTime < Self.operationIntervalMillis {
let remaining = Int((Self.operationIntervalMillis - (now - lastOnlineStatusSwitchTime)) / 1000)
throw LocationReportServiceError.tooFrequent(remainingSeconds: remaining)
}
guard appStore.session.currentScenicId > 0 else {
throw LocationReportServiceError.missingScenic
}
let goingOnline = !locationStateStore.isOnline
if goingOnline {
_ = try await locationProvider.requestSnapshot()
locationStateStore.updateOnlineStatus(true)
lastOnlineStatusSwitchTime = now
return try await reportWithCoordinates(
api: api,
latitude: nil,
longitude: nil,
address: nil,
type: 1,
showSuccessToast: true
)
}
locationStateStore.updateOnlineStatus(false)
lastOnlineStatusSwitchTime = now
_ = try await reportWithCoordinates(
api: api,
latitude: nil,
longitude: nil,
address: nil,
type: 3,
showSuccessToast: false
)
return nil
}
/// 手动 GPS 上报。
func manualReport(api: HomeAPI) async throws -> LocationReportSuccessResult {
let now = currentTimestampMillis()
if now - lastLocationReportTime < Self.operationIntervalMillis {
let remaining = Int((Self.operationIntervalMillis - (now - lastLocationReportTime)) / 1000)
throw LocationReportServiceError.tooFrequent(remainingSeconds: remaining)
}
return try await reportWithCoordinates(
api: api,
latitude: nil,
longitude: nil,
address: nil,
type: 1,
showSuccessToast: true,
desiredAccuracy: Self.manualReportDesiredAccuracy
)
}
/// 指定坐标上报(含标记上报 type=2)。
func reportWithCoordinates(
api: HomeAPI,
latitude: Double?,
longitude: Double?,
address: String?,
type: Int,
showSuccessToast: Bool = true,
desiredAccuracy: CLLocationAccuracy = kCLLocationAccuracyBest
) async throws -> LocationReportSuccessResult {
if type != 3 {
let now = currentTimestampMillis()
if now - lastLocationReportTime < Self.operationIntervalMillis {
let remaining = Int((Self.operationIntervalMillis - (now - lastLocationReportTime)) / 1000)
throw LocationReportServiceError.tooFrequent(remainingSeconds: remaining)
}
}
guard appStore.session.currentScenicId > 0 else {
throw LocationReportServiceError.missingScenic
}
let staffId = appStore.session.userId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !staffId.isEmpty else {
throw LocationReportServiceError.missingStaffId
}
isReporting = true
defer { isReporting = false }
let snapshot: HomeLocationSnapshot
if let latitude, let longitude {
let resolvedAddress: String
if let address, !address.isEmpty {
resolvedAddress = address
} else {
resolvedAddress = await locationProvider.reverseGeocode(
latitude: latitude,
longitude: longitude
)
}
snapshot = HomeLocationSnapshot(
latitude: latitude,
longitude: longitude,
address: resolvedAddress
)
} else {
snapshot = try await locationProvider.requestSnapshot(desiredAccuracy: desiredAccuracy)
}
let locationText = snapshot.address.isEmpty
? String(format: "%.5f, %.5f", snapshot.latitude, snapshot.longitude)
: snapshot.address
_ = try await api.reportLocation(
staffId: staffId,
latitude: snapshot.latitude,
longitude: snapshot.longitude,
address: snapshot.address,
type: type,
scenicId: String(appStore.session.currentScenicId)
)
lastLocationReportTime = currentTimestampMillis()
if type != 3 {
locationStateStore.startLocationReport()
let reportTime = formattedReportTime()
if showSuccessToast {
// Toast handled by caller via onShowMessage if needed
}
return LocationReportSuccessResult(
reportTimeText: reportTime,
locationInfoText: locationText,
shouldShowSuccessDialog: true
)
}
return LocationReportSuccessResult(
reportTimeText: "",
locationInfoText: locationText,
shouldShowSuccessDialog: false
)
}
private func formattedReportTime() -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "HH:mm"
return formatter.string(from: Date())
}
private func currentTimestampMillis() -> Int64 {
Int64(Date().timeIntervalSince1970 * 1000)
}
}
/// 位置上报业务错误。
enum LocationReportServiceError: LocalizedError, Equatable {
case tooFrequent(remainingSeconds: Int)
case missingScenic
case missingStaffId
case locating
var errorDescription: String? {
switch self {
case .tooFrequent(let remaining):
"操作过于频繁,请\(remaining)秒后再试"
case .missingScenic:
"请先选择景区"
case .missingStaffId:
"获取用户ID失败"
case .locating:
"正在定位中,请稍候"
}
}
}