### Major Changes: - **Rust Migration**: Move api-rs to root directory, rename binary to 'aicard' - **MCP Integration**: Add card tools to ai.gpt MCP server (get_user_cards, draw_card, get_draw_status) - **Daily Limit System**: Implement 2-day interval card drawing limits in API and iOS - **iOS Enhancements**: Add DrawStatusView, backup functionality, and limit integration ### Technical Details: - ai.gpt MCP now has 20 tools including 3 card-related tools - ServiceClient enhanced with missing card API methods - iOS app includes daily limit UI and atproto OAuth backup features - Database migration for last_draw_date field - Complete feature parity between web and iOS implementations 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
243 lines
8.9 KiB
Swift
243 lines
8.9 KiB
Swift
import Foundation
|
||
import Combine
|
||
|
||
enum APIError: Error {
|
||
case invalidURL
|
||
case noData
|
||
case decodingError
|
||
case networkError(String)
|
||
case unauthorized
|
||
}
|
||
|
||
// MCP Server response format
|
||
struct MCPResponse<T: Decodable>: Decodable {
|
||
let data: T?
|
||
let error: String?
|
||
}
|
||
|
||
class APIClient {
|
||
static let shared = APIClient()
|
||
|
||
#if DEBUG
|
||
private let baseURL = "http://localhost:8000/api/v1" // ai.card direct access
|
||
private let aiGptBaseURL = "http://localhost:8001" // ai.gpt MCP server (optional)
|
||
#else
|
||
private let baseURL = "https://api.card.syui.ai/api/v1" // ai.card direct access
|
||
private let aiGptBaseURL = "https://ai.gpt.syui.ai" // ai.gpt MCP server (optional)
|
||
#endif
|
||
|
||
private var cancellables = Set<AnyCancellable>()
|
||
|
||
private init() {}
|
||
|
||
private var authToken: String? {
|
||
get { UserDefaults.standard.string(forKey: "authToken") }
|
||
set { UserDefaults.standard.set(newValue, forKey: "authToken") }
|
||
}
|
||
|
||
// ai.gpt MCP経由でのリクエスト(推奨)
|
||
private func mcpRequest<T: Decodable>(_ endpoint: String,
|
||
parameters: [String: Any] = [:]) -> AnyPublisher<T, APIError> {
|
||
guard let url = URL(string: "\(aiGptBaseURL)\(endpoint)") else {
|
||
return Fail(error: APIError.invalidURL).eraseToAnyPublisher()
|
||
}
|
||
|
||
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||
components?.queryItems = parameters.map { URLQueryItem(name: $0.key, value: "\($0.value)") }
|
||
|
||
guard let finalURL = components?.url else {
|
||
return Fail(error: APIError.invalidURL).eraseToAnyPublisher()
|
||
}
|
||
|
||
var request = URLRequest(url: finalURL)
|
||
request.httpMethod = "GET"
|
||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||
|
||
return URLSession.shared.dataTaskPublisher(for: request)
|
||
.tryMap { data, response in
|
||
guard let httpResponse = response as? HTTPURLResponse else {
|
||
throw APIError.networkError("Invalid response")
|
||
}
|
||
|
||
if !(200...299).contains(httpResponse.statusCode) {
|
||
throw APIError.networkError("MCP Server error: \(httpResponse.statusCode)")
|
||
}
|
||
|
||
return data
|
||
}
|
||
.decode(type: MCPResponse<T>.self, decoder: JSONDecoder())
|
||
.tryMap { mcpResponse in
|
||
if let data = mcpResponse.data {
|
||
return data
|
||
} else if let error = mcpResponse.error {
|
||
throw APIError.networkError(error)
|
||
} else {
|
||
throw APIError.networkError("Invalid MCP response")
|
||
}
|
||
}
|
||
.mapError { error in
|
||
if error is DecodingError {
|
||
return APIError.decodingError
|
||
} else if let apiError = error as? APIError {
|
||
return apiError
|
||
} else {
|
||
return APIError.networkError(error.localizedDescription)
|
||
}
|
||
}
|
||
.eraseToAnyPublisher()
|
||
}
|
||
|
||
// ai.card直接リクエスト(メイン)
|
||
private func request<T: Decodable>(_ endpoint: String,
|
||
method: String = "GET",
|
||
body: Data? = nil,
|
||
authenticated: Bool = true) -> AnyPublisher<T, APIError> {
|
||
guard let url = URL(string: "\(baseURL)\(endpoint)") else {
|
||
return Fail(error: APIError.invalidURL).eraseToAnyPublisher()
|
||
}
|
||
|
||
var request = URLRequest(url: url)
|
||
request.httpMethod = method
|
||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||
|
||
if authenticated, let token = authToken {
|
||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||
}
|
||
|
||
if let body = body {
|
||
request.httpBody = body
|
||
}
|
||
|
||
return URLSession.shared.dataTaskPublisher(for: request)
|
||
.tryMap { data, response in
|
||
guard let httpResponse = response as? HTTPURLResponse else {
|
||
throw APIError.networkError("Invalid response")
|
||
}
|
||
|
||
if httpResponse.statusCode == 401 {
|
||
throw APIError.unauthorized
|
||
}
|
||
|
||
if !(200...299).contains(httpResponse.statusCode) {
|
||
throw APIError.networkError("Status code: \(httpResponse.statusCode)")
|
||
}
|
||
|
||
return data
|
||
}
|
||
.decode(type: T.self, decoder: JSONDecoder())
|
||
.mapError { error in
|
||
if error is DecodingError {
|
||
return APIError.decodingError
|
||
} else if let apiError = error as? APIError {
|
||
return apiError
|
||
} else {
|
||
return APIError.networkError(error.localizedDescription)
|
||
}
|
||
}
|
||
.eraseToAnyPublisher()
|
||
}
|
||
|
||
// MARK: - Auth
|
||
|
||
func login(identifier: String, password: String) -> AnyPublisher<LoginResponse, APIError> {
|
||
let loginRequest = LoginRequest(identifier: identifier, password: password)
|
||
guard let body = try? JSONEncoder().encode(loginRequest) else {
|
||
return Fail(error: APIError.decodingError).eraseToAnyPublisher()
|
||
}
|
||
|
||
return request("/auth/login", method: "POST", body: body, authenticated: false)
|
||
.handleEvents(receiveOutput: { [weak self] (response: LoginResponse) in
|
||
self?.authToken = response.accessToken
|
||
})
|
||
.eraseToAnyPublisher()
|
||
}
|
||
|
||
func logout() -> AnyPublisher<Void, APIError> {
|
||
request("/auth/logout", method: "POST")
|
||
.map { (_: [String: String]) in () }
|
||
.handleEvents(receiveCompletion: { [weak self] _ in
|
||
self?.authToken = nil
|
||
})
|
||
.eraseToAnyPublisher()
|
||
}
|
||
|
||
func verify() -> AnyPublisher<User, APIError> {
|
||
request("/auth/verify")
|
||
}
|
||
|
||
// MARK: - Cards (ai.card直接アクセス)
|
||
|
||
func drawCard(userDid: String, isPaid: Bool = false) -> AnyPublisher<CardDrawResult, APIError> {
|
||
let body = try? JSONEncoder().encode([
|
||
"user_did": userDid,
|
||
"is_paid": isPaid
|
||
])
|
||
|
||
return request("/cards/draw", method: "POST", body: body)
|
||
}
|
||
|
||
func getUserCards(userDid: String) -> AnyPublisher<[Card], APIError> {
|
||
return request("/cards/user/\(userDid)")
|
||
}
|
||
|
||
func getCardDetails(cardId: Int) -> AnyPublisher<Card, APIError> {
|
||
return request("/cards/\(cardId)")
|
||
}
|
||
|
||
func getGachaStats() -> AnyPublisher<GachaStats, APIError> {
|
||
return request("/cards/stats")
|
||
}
|
||
|
||
func getUniqueCards() -> AnyPublisher<[[String: Any]], APIError> {
|
||
return request("/cards/unique")
|
||
}
|
||
|
||
func getSystemStatus() -> AnyPublisher<[String: Any], APIError> {
|
||
return request("/health")
|
||
}
|
||
|
||
// MARK: - Daily Draw Limit
|
||
|
||
func getDrawStatus(userDid: String) -> AnyPublisher<DrawStatus, APIError> {
|
||
return request("/cards/draw-status/\(userDid)")
|
||
}
|
||
}
|
||
|
||
// MARK: - AI Enhanced API (Optional ai.gpt integration)
|
||
|
||
extension APIClient {
|
||
|
||
func analyzeCollection(userDid: String) -> AnyPublisher<CollectionAnalysis, APIError> {
|
||
let parameters: [String: Any] = [
|
||
"did": userDid
|
||
]
|
||
|
||
return mcpRequest("/card_analyze_collection", parameters: parameters)
|
||
.catch { error -> AnyPublisher<CollectionAnalysis, APIError> in
|
||
// AI機能が利用できない場合のエラー
|
||
return Fail(error: APIError.networkError("AI分析機能を利用するにはai.gptサーバーが必要です")).eraseToAnyPublisher()
|
||
}
|
||
.eraseToAnyPublisher()
|
||
}
|
||
|
||
func getEnhancedStats() -> AnyPublisher<GachaStats, APIError> {
|
||
return mcpRequest("/card_get_gacha_stats", parameters: [:])
|
||
.catch { [weak self] error -> AnyPublisher<GachaStats, APIError> in
|
||
// AI機能が利用できない場合は基本統計にフォールバック
|
||
print("AI統計が利用できません、基本統計に切り替えます: \(error)")
|
||
return self?.getGachaStats() ?? Fail(error: error).eraseToAnyPublisher()
|
||
}
|
||
.eraseToAnyPublisher()
|
||
}
|
||
|
||
func isAIAvailable() -> AnyPublisher<Bool, Never> {
|
||
guard let url = URL(string: "\(aiGptBaseURL)/health") else {
|
||
return Just(false).eraseToAnyPublisher()
|
||
}
|
||
|
||
return URLSession.shared.dataTaskPublisher(for: url)
|
||
.map { _ in true }
|
||
.catch { _ in Just(false) }
|
||
.eraseToAnyPublisher()
|
||
}
|
||
} |