125 lines
4.3 KiB
Swift
125 lines
4.3 KiB
Swift
import Foundation
|
|
|
|
/// Periodically verifies that Bugger's local bug list matches the record IDs
|
|
/// the notification server (bugger-feishu) believes are assigned to the
|
|
/// current user. If there's a mismatch — meaning a push was missed — this
|
|
/// service triggers `PollerService.fetchNow()` to recalibrate.
|
|
///
|
|
/// Disabled by default. Enabled via the "Enable calibration check" toggle
|
|
/// in Advanced settings. Only active when `feishuAppBaseURL` is configured,
|
|
/// since the calibration endpoint lives on the same server as the SSE stream.
|
|
final class CalibrationService {
|
|
static let shared = CalibrationService()
|
|
|
|
private var timer: Timer?
|
|
private let interval: TimeInterval = 300
|
|
private var isRunning = false
|
|
private let session = URLSession.shared
|
|
|
|
private init() {}
|
|
|
|
// MARK: - Public
|
|
|
|
func start() {
|
|
guard !isRunning else { return }
|
|
guard let config = AppStateService.shared.config,
|
|
config.calibrationEnabled,
|
|
!config.feishuAppBaseURL.isEmpty else { return }
|
|
isRunning = true
|
|
|
|
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
|
Task { await self?.calibrate() }
|
|
}
|
|
timer?.tolerance = 30
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 60) { [weak self] in
|
|
Task { await self?.calibrate() }
|
|
}
|
|
|
|
BuggerLog.info("CalibrationService: started (interval=\(Int(interval))s)")
|
|
}
|
|
|
|
func stop() {
|
|
timer?.invalidate()
|
|
timer = nil
|
|
isRunning = false
|
|
}
|
|
|
|
func restart() {
|
|
stop()
|
|
start()
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private func calibrate() async {
|
|
guard let config = AppStateService.shared.config,
|
|
!config.feishuAppBaseURL.isEmpty,
|
|
!config.assigneeName.isEmpty,
|
|
!config.fieldMappings.assigneeField.isEmpty else { return }
|
|
|
|
guard let url = buildURL(config: config) else { return }
|
|
|
|
do {
|
|
let (data, response) = try await session.data(from: url)
|
|
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
|
BuggerLog.error("CalibrationService: non-200 response (\((response as? HTTPURLResponse)?.statusCode ?? -1))")
|
|
return
|
|
}
|
|
|
|
let result = try JSONDecoder().decode(OwnedRecordsResponse.self, from: data)
|
|
|
|
guard result.cacheStatus != "cold" else {
|
|
BuggerLog.info("CalibrationService: server cache cold, skipping")
|
|
return
|
|
}
|
|
|
|
let serverIDs = Set(result.recordIds)
|
|
let localIDs = Set(BugStore.shared.bugs.map(\.id))
|
|
|
|
guard !localIDs.isEmpty else {
|
|
BuggerLog.info("CalibrationService: no local bugs yet, skipping")
|
|
return
|
|
}
|
|
|
|
if serverIDs != localIDs {
|
|
let added = serverIDs.subtracting(localIDs)
|
|
let removed = localIDs.subtracting(serverIDs)
|
|
BuggerLog.info("CalibrationService: mismatch (server=\(serverIDs.count), local=\(localIDs.count), +\(added.count) -\(removed.count)), triggering fetchNow")
|
|
await PollerService.shared.fetchNow()
|
|
} else {
|
|
BuggerLog.debug("CalibrationService: in sync (\(serverIDs.count) records)")
|
|
}
|
|
} catch {
|
|
BuggerLog.error("CalibrationService: error — \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
private func buildURL(config: AppConfig) -> URL? {
|
|
let base = config.feishuAppBaseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
guard var comps = URLComponents(string: "\(base)/api/v1/bitable/owned_records") else {
|
|
return nil
|
|
}
|
|
comps.queryItems = [
|
|
URLQueryItem(name: "file_token", value: config.appToken),
|
|
URLQueryItem(name: "assignee_field", value: config.fieldMappings.assigneeField),
|
|
URLQueryItem(name: "assignee_name", value: config.assigneeName),
|
|
]
|
|
return comps.url
|
|
}
|
|
}
|
|
|
|
// MARK: - Response model
|
|
|
|
struct OwnedRecordsResponse: Decodable {
|
|
let recordIds: [String]
|
|
let cacheStatus: String
|
|
let count: Int
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case recordIds = "record_ids"
|
|
case cacheStatus = "cache_status"
|
|
case count
|
|
}
|
|
}
|