import Foundation final class FeishuService { private let baseURL = "https://open.feishu.cn/open-apis" private let session: URLSession private let decoder: JSONDecoder init(session: URLSession = .shared) { self.session = session let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase self.decoder = decoder } func fetchBugs( config: AppConfig, assigneeName: String, accessToken: String, userOpenId: String? = nil ) async throws -> [Bug] { let records = try await fetchRecordsFiltered( config: config, assigneeName: assigneeName, accessToken: accessToken, userOpenId: userOpenId ) return records .map { BugMapper.map($0, config: config) } .filter { bug in bug.assignee.localizedCaseInsensitiveContains(assigneeName) } } func fetchRecordCount( appToken: String, tableId: String, accessToken: String ) async throws -> Int { let page = try await fetchPage( appToken: appToken, tableId: tableId, pageToken: nil, pageSize: 1, accessToken: accessToken ) return page.total ?? page.items.count } // MARK: - Filtered fetch (3-tier fallback) /// Fetch records using a 3-tier strategy: /// 1. Search endpoint with open_id filter (Person-type fields, requires OAuth) /// 2. List endpoint with CurrentValue filter (text-type fields) /// 3. Unfiltered list (current behavior — fetch all, filter client-side) private func fetchRecordsFiltered( config: AppConfig, assigneeName: String, accessToken: String, userOpenId: String? ) async throws -> [RecordItem] { // Tier 1: search endpoint with open_id (Person fields) if let openId = userOpenId, !openId.isEmpty { do { let records = try await searchRecords( config: config, accessToken: accessToken, assigneeField: config.fieldMappings.assigneeField, openId: openId ) if !records.isEmpty { BuggerLog.debug("FeishuService: tier 1 (search by open_id) returned \(records.count) records") return records } BuggerLog.debug("FeishuService: tier 1 returned 0 records, falling back") } catch { BuggerLog.debug("FeishuService: tier 1 failed (\(error.localizedDescription)), falling back") } } // Tier 2: list endpoint with CurrentValue filter (text fields) let filterStr = "CurrentValue.[\(config.fieldMappings.assigneeField)]=\"\(assigneeName)\"" do { let records = try await listAllRecords( config: config, accessToken: accessToken, filter: filterStr ) if !records.isEmpty { BuggerLog.debug("FeishuService: tier 2 (list with filter) returned \(records.count) records") return records } BuggerLog.debug("FeishuService: tier 2 returned 0 records, falling back") } catch { BuggerLog.debug("FeishuService: tier 2 failed (\(error.localizedDescription)), falling back") } // Tier 3: unfiltered list (original behavior) BuggerLog.debug("FeishuService: tier 3 (unfiltered list)") return try await listAllRecords( config: config, accessToken: accessToken, filter: nil ) } // MARK: - List endpoint (GET /records) private func listAllRecords( config: AppConfig, accessToken: String, filter: String? ) async throws -> [RecordItem] { var allRecords: [RecordItem] = [] var pageToken: String? var pages = 0 let maxPages = 50 repeat { pages += 1 guard pages <= maxPages else { throw FeishuError.paginationLimitExceeded } let page = try await fetchPage( appToken: config.appToken, tableId: config.tableId, pageToken: pageToken, accessToken: accessToken, filter: filter ) allRecords.append(contentsOf: page.items) pageToken = page.hasMore ? page.pageToken : nil } while pageToken != nil return allRecords } private func fetchPage( appToken: String, tableId: String, pageToken: String?, pageSize: Int = 500, accessToken: String, filter: String? = nil ) async throws -> RecordListData { 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)) } if let filter, !filter.isEmpty { queryItems.append(URLQueryItem(name: "filter", value: filter)) } components.queryItems = queryItems 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 let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FeishuError.networkError(nil) } let rawBody = String(data: data, encoding: .utf8) ?? "" BuggerLog.debug("fetchPage: HTTP \(httpResponse.statusCode) body=\(String(rawBody.prefix(500)))") if httpResponse.statusCode == 401 { throw FeishuError.unauthorized } let apiResponse: FeishuAPIResponse do { apiResponse = try decoder.decode(FeishuAPIResponse.self, from: data) } catch { BuggerLog.error("fetchPage: decode FAILED: \(error)") BuggerLog.error("fetchPage: raw body: \(String(rawBody.prefix(1000)))") throw FeishuError.decodingError(error, rawBody: String(rawBody.prefix(200))) } guard apiResponse.code == 0, let pageData = apiResponse.data else { throw FeishuError.apiError(code: apiResponse.code, message: apiResponse.msg) } return pageData } // MARK: - Search endpoint (POST /records/search) private func searchRecords( config: AppConfig, accessToken: String, assigneeField: String, openId: String ) async throws -> [RecordItem] { var allRecords: [RecordItem] = [] var pageToken: String? var pages = 0 let maxPages = 50 repeat { pages += 1 guard pages <= maxPages else { throw FeishuError.paginationLimitExceeded } let page = try await searchPage( appToken: config.appToken, tableId: config.tableId, pageToken: pageToken, accessToken: accessToken, assigneeField: assigneeField, openId: openId ) allRecords.append(contentsOf: page.items) pageToken = page.hasMore ? page.pageToken : nil } while pageToken != nil return allRecords } private func searchPage( appToken: String, tableId: String, pageToken: String?, accessToken: String, assigneeField: String, openId: String ) async throws -> RecordListData { guard !appToken.isEmpty, !tableId.isEmpty else { throw FeishuError.invalidConfiguration("App token and table ID must not be empty.") } let urlString = "\(baseURL)/bitable/v1/apps/\(appToken)/tables/\(tableId)/records/search" guard let url = URL(string: urlString) else { throw FeishuError.invalidConfiguration("Could not build search request URL.") } var body: [String: Any] = [ "page_size": 500, "filter": [ "conjunction": "and", "conditions": [ [ "field_name": assigneeField, "operator": "is", "value": [openId], ] ] ] ] if let pageToken, !pageToken.isEmpty { body["page_token"] = pageToken } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") request.timeoutInterval = 30 request.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FeishuError.networkError(nil) } let rawBody = String(data: data, encoding: .utf8) ?? "" BuggerLog.debug("searchPage: HTTP \(httpResponse.statusCode) body=\(String(rawBody.prefix(500)))") if httpResponse.statusCode == 401 { throw FeishuError.unauthorized } let apiResponse: FeishuAPIResponse do { apiResponse = try decoder.decode(FeishuAPIResponse.self, from: data) } catch { BuggerLog.error("searchPage: decode FAILED: \(error)") throw FeishuError.decodingError(error, rawBody: String(rawBody.prefix(200))) } guard apiResponse.code == 0, let pageData = apiResponse.data else { throw FeishuError.apiError(code: apiResponse.code, message: apiResponse.msg) } return pageData } }