fix: 增强配置容错与启动稳定性,优化错误状态交互

This commit is contained in:
tigerenwork 2026-07-05 20:25:06 +08:00
parent 7047c51a07
commit 55d8b50f1d
9 changed files with 103 additions and 22 deletions

View File

@ -12,6 +12,10 @@ struct AppConfig: Codable, Equatable {
var showFloatingWidget: Bool = false
var launchAtLogin: Bool = true
var feishuBaseDomain: String = "xorbitlab.feishu.cn"
/// When false (the default), Bugger waits for the next scheduled poll
/// instead of fetching immediately on launch. Avoids a crash-on-start
/// loop if the table fetch is failing.
var refreshOnStart: Bool = false
struct FieldMappings: Codable, Equatable {
var titleField: String = "Title"
@ -49,4 +53,28 @@ struct AppConfig: Codable, Equatable {
var isConfigured: Bool {
!appToken.isEmpty && !tableId.isEmpty
}
// Explicit keys + tolerant decoding so a config persisted before a new
// field was added (missing key) doesn't fail to decode and wipe settings.
enum CodingKeys: String, CodingKey {
case appToken, tableId, assigneeName, fieldMappings
case pollIntervalSeconds, dailyRefreshTimes
case showFloatingWidget, launchAtLogin, feishuBaseDomain, refreshOnStart
}
init() {}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
appToken = try c.decodeIfPresent(String.self, forKey: .appToken) ?? ""
tableId = try c.decodeIfPresent(String.self, forKey: .tableId) ?? ""
assigneeName = try c.decodeIfPresent(String.self, forKey: .assigneeName) ?? ""
fieldMappings = try c.decodeIfPresent(FieldMappings.self, forKey: .fieldMappings) ?? FieldMappings()
pollIntervalSeconds = try c.decodeIfPresent(Int.self, forKey: .pollIntervalSeconds) ?? 300
dailyRefreshTimes = try c.decodeIfPresent([String].self, forKey: .dailyRefreshTimes) ?? []
showFloatingWidget = try c.decodeIfPresent(Bool.self, forKey: .showFloatingWidget) ?? false
launchAtLogin = try c.decodeIfPresent(Bool.self, forKey: .launchAtLogin) ?? true
feishuBaseDomain = try c.decodeIfPresent(String.self, forKey: .feishuBaseDomain) ?? "xorbitlab.feishu.cn"
refreshOnStart = try c.decodeIfPresent(Bool.self, forKey: .refreshOnStart) ?? false
}
}

View File

@ -10,7 +10,7 @@ struct Bug: Identifiable, Equatable, Hashable {
let customer: String?
let createdAt: Date
let updatedAt: Date
let feishuURL: URL
let feishuURL: URL?
var age: TimeInterval {
Date().timeIntervalSince(createdAt)

View File

@ -7,6 +7,8 @@ enum FeishuError: Error, LocalizedError {
case decodingError(Error, rawBody: String? = nil)
case notConfigured
case missingCredentials
case invalidConfiguration(String)
case paginationLimitExceeded
var errorDescription: String? {
switch self {
@ -25,6 +27,10 @@ enum FeishuError: Error, LocalizedError {
return "Feishu table not configured. Open Settings."
case .missingCredentials:
return "Feishu app credentials are missing. Set FEISHU_APP_ID and FEISHU_APP_SECRET."
case .invalidConfiguration(let detail):
return "Invalid Feishu configuration: \(detail)"
case .paginationLimitExceeded:
return "Feishu table has too many records to load in one pass."
}
}
}

View File

@ -19,8 +19,16 @@ final class FeishuService {
) async throws -> [Bug] {
var allRecords: [RecordItem] = []
var pageToken: String?
var pages = 0
// 25,000 records at 500/page guard against a runaway cursor that
// would otherwise loop forever accumulating memory.
let maxPages = 50
repeat {
pages += 1
guard pages <= maxPages else {
throw FeishuError.paginationLimitExceeded
}
let page = try await fetchPage(
appToken: config.appToken,
tableId: config.tableId,
@ -60,16 +68,25 @@ final class FeishuService {
pageSize: Int = 500,
accessToken: String
) async throws -> RecordListData {
var components = URLComponents(
guard !appToken.isEmpty, !tableId.isEmpty else {
throw FeishuError.invalidConfiguration("App token and table ID must not be empty.")
}
guard var components = URLComponents(
string: "\(baseURL)/bitable/v1/apps/\(appToken)/tables/\(tableId)/records"
)!
) else {
throw FeishuError.invalidConfiguration("App token or table ID contains invalid characters.")
}
var queryItems = [URLQueryItem(name: "page_size", value: String(pageSize))]
if let pageToken {
queryItems.append(URLQueryItem(name: "page_token", value: pageToken))
}
components.queryItems = queryItems
var request = URLRequest(url: components.url!)
guard let url = components.url else {
throw FeishuError.invalidConfiguration("Could not build records request URL.")
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
request.timeoutInterval = 30

View File

@ -56,10 +56,11 @@ final class NotificationService: NSObject, UNUserNotificationCenterDelegate {
return
}
content.userInfo = [
"bugId": change.bug.id,
"feishuURL": change.bug.feishuURL.absoluteString
]
var userInfo: [String: Any] = ["bugId": change.bug.id]
if let url = change.bug.feishuURL {
userInfo["feishuURL"] = url.absoluteString
}
content.userInfo = userInfo
let request = UNNotificationRequest(
identifier: "bugger-\(change.bug.id)-\(Date().timeIntervalSince1970)",

View File

@ -24,14 +24,16 @@ final class PollerService {
tokenManager.isAuthenticated else {
return
}
start(interval: TimeInterval(config.pollIntervalSeconds))
start(interval: TimeInterval(config.pollIntervalSeconds), fetchImmediately: config.refreshOnStart)
}
func start(interval: TimeInterval) {
func start(interval: TimeInterval, fetchImmediately: Bool = true) {
guard !isRunning else { return }
isRunning = true
Task { await performFetch() }
if fetchImmediately {
Task { await performFetch() }
}
if interval < 0 {
// Daily schedule mode: check every 60s, fire at configured times

View File

@ -6,12 +6,26 @@ enum FeishuURLBuilder {
appToken: String,
tableId: String,
recordId: String
) -> URL {
// Feishu Bitable record deep link: uses query parameters
URL(string: "https://\(baseDomain)/base/\(appToken)?table=\(tableId)&record=\(recordId)")!
) -> URL? {
// Feishu Bitable record deep link: uses query parameters.
// Returns nil for malformed inputs (e.g. config containing spaces or
// slashes) instead of force-unwrapping and crashing.
var components = URLComponents()
components.scheme = "https"
components.host = baseDomain
components.path = "/base/\(appToken)"
components.queryItems = [
URLQueryItem(name: "table", value: tableId),
URLQueryItem(name: "record", value: recordId)
]
return components.url
}
static func tableURL(baseDomain: String, appToken: String) -> URL {
URL(string: "https://\(baseDomain)/base/\(appToken)")!
static func tableURL(baseDomain: String, appToken: String) -> URL? {
var components = URLComponents()
components.scheme = "https"
components.host = baseDomain
components.path = "/base/\(appToken)"
return components.url
}
}

View File

@ -104,16 +104,17 @@ struct BugListPopover: View {
}
private func openInFeishu(_ bug: Bug) {
NSWorkspace.shared.open(bug.feishuURL)
guard let url = bug.feishuURL else { return }
NSWorkspace.shared.open(url)
AppDelegate.shared?.closePopover()
}
private func openFeishuTable() {
guard let config = AppStateService.shared.config else { return }
let url = FeishuURLBuilder.tableURL(
baseDomain: config.feishuBaseDomain,
appToken: config.appToken
)
guard let config = AppStateService.shared.config,
let url = FeishuURLBuilder.tableURL(
baseDomain: config.feishuBaseDomain,
appToken: config.appToken
) else { return }
NSWorkspace.shared.open(url)
}
@ -165,6 +166,11 @@ struct ErrorStateView: View {
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button("Retry") {
Task { await PollerService.shared.fetchNow() }
}
.buttonStyle(.bordered)
.padding(.top, 4)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()

View File

@ -86,6 +86,13 @@ struct SettingsView: View {
.buttonStyle(.plain)
}
}
VStack(alignment: .leading, spacing: 4) {
Toggle("Refresh on launch", isOn: $config.refreshOnStart)
Text("Fetch bugs immediately when Bugger starts. Off = wait for the next scheduled poll.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Section("Display") {