初始化傻瓜比价正式版 iOS 客户端

对标安卓正式版、对接 app-server(8770)的 SwiftUI 客户端。iOS 无障碍受限、无比价核心,做自成一体:美团 CPS 导购 + 福利(签到/任务/金币)+ 查看(比价记录/省钱战绩)。
功能完整 M0-M5:脚手架、短信登录(JWT+Keychain+401 自动刷新)、首页券 feed 导购、比价记录+省钱战绩、福利、我的(资料/反馈/设置/注销)。
单 target、0 三方依赖、XcodeGen 生成工程;Debug/Release 双 plist 分流 ATS。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 00:54:11 +08:00
commit 98b7600797
60 changed files with 3112 additions and 0 deletions
+192
View File
@@ -0,0 +1,192 @@
import Foundation
extension Notification.Name {
/// /SessionStore UI
static let authDidExpire = Notification.Name("authDidExpire")
}
/// multipart
struct MultipartFile {
let name: String // (="file"="images")
let filename: String
let mimeType: String
let data: Data
}
/// REST Bearer401 ( AuthInterceptor + RefreshAuthenticator)
/// snake_case camelCase , DTO camelCase CodingKeys
final class APIClient: @unchecked Sendable {
static let shared = APIClient()
private let baseURL: URL
private let session: URLSession
let decoder: JSONDecoder
let encoder: JSONEncoder
init(baseURL: URL = AppConfig.apiBaseURL, session: URLSession = .shared) {
self.baseURL = baseURL
self.session = session
let d = JSONDecoder()
d.keyDecodingStrategy = .convertFromSnakeCase
self.decoder = d
let e = JSONEncoder()
e.keyEncodingStrategy = .convertToSnakeCase
self.encoder = e
}
// MARK: 便
func get<R: Decodable>(_ path: String, query: [URLQueryItem]? = nil, auth: Bool = true) async throws -> R {
try await send(path: path, method: "GET", query: query, body: EmptyBody?.none, auth: auth)
}
func post<B: Encodable, R: Decodable>(_ path: String, body: B, auth: Bool = true) async throws -> R {
try await send(path: path, method: "POST", query: nil, body: body, auth: auth)
}
func post<R: Decodable>(_ path: String, auth: Bool = true) async throws -> R {
try await send(path: path, method: "POST", query: nil, body: EmptyBody?.none, auth: auth)
}
func patch<B: Encodable, R: Decodable>(_ path: String, body: B, auth: Bool = true) async throws -> R {
try await send(path: path, method: "PATCH", query: nil, body: body, auth: auth)
}
func delete<R: Decodable>(_ path: String, auth: Bool = true) async throws -> R {
try await send(path: path, method: "DELETE", query: nil, body: EmptyBody?.none, auth: auth)
}
/// multipart/form-data ()401
func postMultipart<R: Decodable>(
_ path: String,
fields: [String: String] = [:],
files: [MultipartFile] = [],
auth: Bool = true,
isRetry: Bool = false
) async throws -> R {
let cleanPath = path.hasPrefix("/") ? String(path.dropFirst()) : path
var req = URLRequest(url: baseURL.appending(path: cleanPath))
req.httpMethod = "POST"
req.timeoutInterval = 30
let boundary = "----shagua-\(UUID().uuidString)"
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
req.httpBody = Self.buildMultipart(boundary: boundary, fields: fields, files: files)
if auth, let token = await AuthTokens.shared.accessToken() {
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
let (data, resp) = try await performData(req)
guard let http = resp as? HTTPURLResponse else { throw APIError.invalidResponse }
if http.statusCode == 401, auth {
if !isRetry, await AuthTokens.shared.refresh() {
return try await postMultipart(path, fields: fields, files: files, auth: auth, isRetry: true)
}
NotificationCenter.default.post(name: .authDidExpire, object: nil)
throw APIError.unauthorized
}
guard (200..<300).contains(http.statusCode) else {
throw APIError.http(status: http.statusCode, detail: Self.parseDetail(data))
}
do { return try decoder.decode(R.self, from: data) }
catch { throw APIError.decoding(error) }
}
// MARK:
private struct EmptyBody: Encodable {}
private func send<B: Encodable, R: Decodable>(
path: String,
method: String,
query: [URLQueryItem]?,
body: B?,
auth: Bool,
isRetry: Bool = false
) async throws -> R {
let cleanPath = path.hasPrefix("/") ? String(path.dropFirst()) : path
guard var comps = URLComponents(
url: baseURL.appending(path: cleanPath),
resolvingAgainstBaseURL: false
) else { throw APIError.invalidResponse }
if let query, !query.isEmpty { comps.queryItems = query }
guard let url = comps.url else { throw APIError.invalidResponse }
var req = URLRequest(url: url)
req.httpMethod = method
req.timeoutInterval = 30
if let body {
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
do { req.httpBody = try encoder.encode(body) }
catch { throw APIError.decoding(error) }
}
if auth, let token = await AuthTokens.shared.accessToken() {
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
let (data, resp) = try await performData(req)
guard let http = resp as? HTTPURLResponse else { throw APIError.invalidResponse }
if http.statusCode == 401, auth {
if !isRetry, await AuthTokens.shared.refresh() {
return try await send(
path: path, method: method, query: query,
body: body, auth: auth, isRetry: true
)
}
NotificationCenter.default.post(name: .authDidExpire, object: nil)
throw APIError.unauthorized
}
guard (200..<300).contains(http.statusCode) else {
throw APIError.http(status: http.statusCode, detail: Self.parseDetail(data))
}
do { return try decoder.decode(R.self, from: data) }
catch { throw APIError.decoding(error) }
}
/// , AuthTokens ( send /,)
func rawRefresh(refreshToken: String) async throws -> TokenPairDTO {
var req = URLRequest(url: baseURL.appending(path: "api/v1/auth/refresh"))
req.httpMethod = "POST"
req.timeoutInterval = 20
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try encoder.encode(RefreshRequest(refreshToken: refreshToken))
let (data, resp) = try await performData(req)
guard let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw APIError.http(status: (resp as? HTTPURLResponse)?.statusCode ?? -1, detail: nil)
}
return try decoder.decode(TokenPairDTO.self, from: data)
}
// MARK: helpers
private func performData(_ req: URLRequest) async throws -> (Data, URLResponse) {
do { return try await session.data(for: req) }
catch { throw APIError.network(error) }
}
private static func buildMultipart(boundary: String, fields: [String: String], files: [MultipartFile]) -> Data {
var body = Data()
let crlf = "\r\n"
for (key, value) in fields {
body.append("--\(boundary)\(crlf)".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"\(key)\"\(crlf)\(crlf)".data(using: .utf8)!)
body.append("\(value)\(crlf)".data(using: .utf8)!)
}
for file in files {
body.append("--\(boundary)\(crlf)".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"\(file.name)\"; filename=\"\(file.filename)\"\(crlf)".data(using: .utf8)!)
body.append("Content-Type: \(file.mimeType)\(crlf)\(crlf)".data(using: .utf8)!)
body.append(file.data)
body.append(crlf.data(using: .utf8)!)
}
body.append("--\(boundary)--\(crlf)".data(using: .utf8)!)
return body
}
/// FastAPI {"detail": "..."};422 detail , nil()
private static func parseDetail(_ data: Data) -> String? {
struct E: Decodable { let detail: String? }
return (try? JSONDecoder().decode(E.self, from: data))?.detail
}
}
+36
View File
@@ -0,0 +1,36 @@
import Foundation
enum APIError: Error, LocalizedError {
case invalidResponse // HTTPURLResponse / URL
case http(status: Int, detail: String?)
case unauthorized // 401
case decoding(Error)
case network(Error)
var errorDescription: String? {
switch self {
case .invalidResponse:
return "服务器响应异常"
case .http(let status, let detail):
// detail();/ detail
if let detail, detail.contains(where: { !$0.isASCII }) {
return detail
}
switch status {
case 400: return "请求有误,请重试"
case 401: return "登录已过期,请重新登录"
case 403: return "账号状态异常,请联系客服"
case 404: return "内容不存在"
case 429: return "操作太频繁,请稍后再试"
case 500...: return "服务器繁忙,请稍后重试"
default: return "请求失败(\(status))"
}
case .unauthorized:
return "登录已过期,请重新登录"
case .decoding:
return "数据解析失败"
case .network(let e):
return "网络异常:\(e.localizedDescription)"
}
}
}
+27
View File
@@ -0,0 +1,27 @@
import Foundation
/// debug /( SHAGUA_API_BASE_URL ,),
/// release 线 BuildConfig.BASE_URL debug/release
enum AppConfig {
static let apiBaseURL: URL = {
#if DEBUG
if let raw = ProcessInfo.processInfo.environment["SHAGUA_API_BASE_URL"],
let url = URL(string: raw) {
return url
}
// / IP( 127.0.0.1 ,)
// WiFi IP , `ipconfig getifaddr en0` ()
return URL(string: "http://192.168.0.119:8770")!
#else
return URL(string: "https://app-api.shaguabijia.com")!
#endif
}()
/// (/media/...) URL,/
static func mediaURL(_ path: String?) -> URL? {
guard let path, !path.isEmpty else { return nil }
if path.hasPrefix("http") { return URL(string: path) }
let sep = path.hasPrefix("/") ? "" : "/"
return URL(string: apiBaseURL.absoluteString + sep + path)
}
}
+26
View File
@@ -0,0 +1,26 @@
import Foundation
/// , app/api/v1/auth.pyjverify ()
struct AuthService {
private var api: APIClient { .shared }
/// (SMS_MOCK ,60s )
func smsSend(phone: String) async throws -> SmsSendResponse {
try await api.post("api/v1/auth/sms/send", body: SmsSendRequest(phone: phone), auth: false)
}
/// (mock 6 ) token + user
func smsLogin(phone: String, code: String) async throws -> TokenWithUserDTO {
try await api.post("api/v1/auth/sms/login", body: SmsLoginRequest(phone: phone, code: code), auth: false)
}
///
func me() async throws -> UserDTO {
try await api.get("api/v1/auth/me")
}
/// 退(, token)
func logout() async throws -> LogoutResponse {
try await api.post("api/v1/auth/logout")
}
}
+12
View File
@@ -0,0 +1,12 @@
import Foundation
/// , app/api/v1/compare_record.py()iOS ,()
struct CompareService {
private var api: APIClient { .shared }
func records(limit: Int = 20, cursor: Int? = nil) async throws -> ComparisonRecordPageDTO {
var query = [URLQueryItem(name: "limit", value: String(limit))]
if let cursor { query.append(URLQueryItem(name: "cursor", value: String(cursor))) }
return try await api.get("api/v1/compare/records", query: query)
}
}
+66
View File
@@ -0,0 +1,66 @@
import Foundation
// ===== =====
struct SmsSendRequest: Codable {
let phone: String
}
struct SmsLoginRequest: Codable {
let phone: String
let code: String
}
struct RefreshRequest: Codable {
let refreshToken: String // refresh_token
}
struct ProfileUpdateRequest: Codable {
let nickname: String
}
// ===== =====
struct UserDTO: Codable, Identifiable, Hashable {
let id: Int
let phone: String
var nickname: String?
var avatarUrl: String?
let registerChannel: String
let status: String
let createdAt: String
let lastLoginAt: String
}
struct TokenPairDTO: Codable {
let accessToken: String
let refreshToken: String
let tokenType: String
let expiresIn: Int
let refreshExpiresIn: Int
}
struct TokenWithUserDTO: Codable {
let accessToken: String
let refreshToken: String
let tokenType: String
let expiresIn: Int
let refreshExpiresIn: Int
let user: UserDTO
}
struct SmsSendResponse: Codable {
let sent: Bool
let mock: Bool
let cooldownSec: Int
}
struct LogoutResponse: Codable {
let ok: Bool
}
struct FeedbackResultDTO: Codable {
let id: Int
let status: String
let createdAt: String?
}
+6
View File
@@ -0,0 +1,6 @@
import Foundation
/// {"ok": true}
struct OkResponse: Codable {
let ok: Bool
}
+91
View File
@@ -0,0 +1,91 @@
import Foundation
// () schemas/compare_record.py
// : *_cents (Int); price / couponSaved (Double)
// raw JSON , optional
struct ComparisonResultItemDTO: Codable, Hashable {
let platformId: String?
let platformName: String?
let `package`: String? // ,;Swift ,
let price: Double? // ;/ null
let isSource: Bool?
let rank: Int?
let couponSaved: Double? // ();/ null
}
struct ComparisonItemDTO: Codable, Hashable {
let name: String
var qty: Int = 1
let specs: [String]?
enum CodingKeys: String, CodingKey { case name, qty, specs }
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
name = try c.decode(String.self, forKey: .name)
qty = (try? c.decode(Int.self, forKey: .qty)) ?? 1
specs = try? c.decode([String].self, forKey: .specs)
}
}
struct ComparisonRecordDTO: Codable, Identifiable, Hashable {
let id: Int
let businessType: String
let traceId: String
let sourcePlatformId: String?
let sourcePlatformName: String?
let sourcePackage: String?
let sourcePriceCents: Int?
let bestPlatformId: String?
let bestPlatformName: String?
let bestPriceCents: Int?
let savedAmountCents: Int?
let isSourceBest: Bool?
let storeName: String?
let totalDishCount: Int?
let skippedDishCount: Int?
let status: String
let information: String?
var items: [ComparisonItemDTO] = []
var comparisonResults: [ComparisonResultItemDTO] = []
var skippedDishNames: [String] = []
let createdAt: String
enum CodingKeys: String, CodingKey {
case id, businessType, traceId, sourcePlatformId, sourcePlatformName, sourcePackage
case sourcePriceCents, bestPlatformId, bestPlatformName, bestPriceCents, savedAmountCents
case isSourceBest, storeName, totalDishCount, skippedDishCount, status, information
case items, comparisonResults, skippedDishNames, createdAt
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(Int.self, forKey: .id)
businessType = (try? c.decode(String.self, forKey: .businessType)) ?? "food"
traceId = (try? c.decode(String.self, forKey: .traceId)) ?? ""
sourcePlatformId = try? c.decode(String.self, forKey: .sourcePlatformId)
sourcePlatformName = try? c.decode(String.self, forKey: .sourcePlatformName)
sourcePackage = try? c.decode(String.self, forKey: .sourcePackage)
sourcePriceCents = try? c.decode(Int.self, forKey: .sourcePriceCents)
bestPlatformId = try? c.decode(String.self, forKey: .bestPlatformId)
bestPlatformName = try? c.decode(String.self, forKey: .bestPlatformName)
bestPriceCents = try? c.decode(Int.self, forKey: .bestPriceCents)
savedAmountCents = try? c.decode(Int.self, forKey: .savedAmountCents)
isSourceBest = try? c.decode(Bool.self, forKey: .isSourceBest)
storeName = try? c.decode(String.self, forKey: .storeName)
totalDishCount = try? c.decode(Int.self, forKey: .totalDishCount)
skippedDishCount = try? c.decode(Int.self, forKey: .skippedDishCount)
status = (try? c.decode(String.self, forKey: .status)) ?? "unknown"
information = try? c.decode(String.self, forKey: .information)
items = (try? c.decode([ComparisonItemDTO].self, forKey: .items)) ?? []
comparisonResults = (try? c.decode([ComparisonResultItemDTO].self, forKey: .comparisonResults)) ?? []
skippedDishNames = (try? c.decode([String].self, forKey: .skippedDishNames)) ?? []
createdAt = (try? c.decode(String.self, forKey: .createdAt)) ?? ""
}
}
struct ComparisonRecordPageDTO: Codable {
let items: [ComparisonRecordDTO]
let nextCursor: Int?
}
+66
View File
@@ -0,0 +1,66 @@
import Foundation
// CPS (),
struct FeedRequest: Codable {
let longitude: Double
let latitude: Double
var page: Int = 1
var pageSize: Int = 20
}
struct FeedResponse: Codable {
let items: [CouponCard]
let hasNext: Bool
let page: Int
}
struct CouponCard: Codable, Identifiable, Hashable {
var id: String { productViewSign }
let productViewSign: String
let platform: Int
let bizLine: Int?
let name: String
let headImageUrl: String
let brandName: String?
let brandLogoUrl: String?
let sellPrice: String
let originalPrice: String
let discountAmount: String
let commissionRate: String
let commissionAmount: String?
let saleVolume: String?
let poiName: String?
let distanceText: String?
let distanceMeters: Double?
let availablePoiNum: Int?
let couponNum: Int?
let validDays: Int?
let priceLabel: String?
let rankLabel: String?
let ratingLabel: String?
}
struct ReferralLinkRequest: Codable {
let productViewSign: String
var platform: Int = 1
var bizLine: Int?
var linkTypeList: [Int]?
}
struct ReferralLinkResponse: Codable {
let link: String
var linkMap: [String: String] = [:]
enum CodingKeys: String, CodingKey {
case link
case linkMap // convertFromSnakeCase link_map linkMap
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
link = try c.decode(String.self, forKey: .link)
linkMap = (try? c.decode([String: String].self, forKey: .linkMap)) ?? [:]
}
}
+137
View File
@@ -0,0 +1,137 @@
import Foundation
// (///):=,=""""
// / 广 / , Withdraw / Ad / Exchange / Cash DTO
// ===== (: + )=====
struct CoinAccountDTO: Codable {
let coinBalance: Int
let cashBalanceCents: Int
let totalCoinEarned: Int
}
struct CoinTransactionDTO: Codable, Identifiable, Hashable {
let id: Int
let amount: Int
let balanceAfter: Int
let bizType: String
let refId: String?
let remark: String?
let createdAt: String
}
struct CoinTransactionPageDTO: Codable {
let items: [CoinTransactionDTO]
let nextCursor: Int?
}
// ===== =====
struct SigninStepDTO: Codable, Hashable {
let day: Int
let coin: Int
let status: String // claimed / today / locked
}
struct SigninStatusDTO: Codable {
let todaySigned: Bool
let consecutiveDays: Int
let todayCycleDay: Int
let todayCoin: Int
let canClaim: Bool
let steps: [SigninStepDTO]
}
struct SigninResultDTO: Codable {
let coinAwarded: Int
let cycleDay: Int
let streak: Int
let coinBalance: Int
}
// ===== =====
struct TaskDTO: Codable, Identifiable, Hashable {
var id: String { taskKey }
let taskKey: String
let coin: Int
let claimed: Bool
}
struct TaskListDTO: Codable {
let items: [TaskDTO]
}
struct TaskClaimResultDTO: Codable {
let taskKey: String
let coinAwarded: Int
let coinBalance: Int
}
// ===== ()=====
struct MilestoneStateDTO: Codable, Hashable {
let milestone: Int
let coin: Int
let state: String // claimed / active / locked
}
struct MilestoneStatusDTO: Codable {
let successCount: Int
let claimableCount: Int
let milestones: [MilestoneStateDTO]
}
// ===== (//)=====
struct SavingsSummaryDTO: Codable {
let totalSavedCents: Int
let orderCount: Int
let avgSavedCents: Int
}
struct SavingsBattleDTO: Codable {
let weekSavedCents: Int
let beatPercent: Int
let streakDays: Int
}
struct SavingsRecordDTO: Codable, Identifiable, Hashable {
let id: Int
let orderAmountCents: Int
let savedAmountCents: Int
let platform: String?
let title: String?
let shopName: String?
var dishes: [String] = []
let payChannel: String?
let sourcePlatformName: String?
let originalPriceCents: Int?
let createdAt: String
enum CodingKeys: String, CodingKey {
case id, orderAmountCents, savedAmountCents, platform, title
case shopName, dishes, payChannel, sourcePlatformName, originalPriceCents, createdAt
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(Int.self, forKey: .id)
orderAmountCents = try c.decode(Int.self, forKey: .orderAmountCents)
savedAmountCents = try c.decode(Int.self, forKey: .savedAmountCents)
platform = try? c.decode(String.self, forKey: .platform)
title = try? c.decode(String.self, forKey: .title)
shopName = try? c.decode(String.self, forKey: .shopName)
dishes = (try? c.decode([String].self, forKey: .dishes)) ?? []
payChannel = try? c.decode(String.self, forKey: .payChannel)
sourcePlatformName = try? c.decode(String.self, forKey: .sourcePlatformName)
originalPriceCents = try? c.decode(Int.self, forKey: .originalPriceCents)
createdAt = try c.decode(String.self, forKey: .createdAt)
}
}
struct SavingsRecordPageDTO: Codable {
let items: [SavingsRecordDTO]
let nextCursor: Int?
}
+17
View File
@@ -0,0 +1,17 @@
import Foundation
/// , app/api/v1/feedback.py(multipart:content/contact + images )
struct FeedbackService {
private var api: APIClient { .shared }
func submit(content: String, contact: String, images: [Data]) async throws -> FeedbackResultDTO {
let files = images.enumerated().map { index, data in
MultipartFile(name: "images", filename: "img\(index).jpg", mimeType: "image/jpeg", data: data)
}
return try await api.postMultipart(
"api/v1/feedback",
fields: ["content": content, "contact": contact],
files: files
)
}
}
+29
View File
@@ -0,0 +1,29 @@
import Foundation
/// CPS , app/api/v1/meituan.py()
struct MeituanService {
private var api: APIClient { .shared }
/// (2 + 1 , 3 )
func feed(longitude: Double, latitude: Double, page: Int) async throws -> FeedResponse {
try await api.post(
"api/v1/meituan/feed",
body: FeedRequest(longitude: longitude, latitude: latitude, page: page),
auth: false
)
}
/// 广( CPS sid )linkTypeList=[1,3]:1=H5 3=deeplink
func referralLink(productViewSign: String, platform: Int, bizLine: Int?) async throws -> ReferralLinkResponse {
try await api.post(
"api/v1/meituan/referral-link",
body: ReferralLinkRequest(
productViewSign: productViewSign,
platform: platform,
bizLine: bizLine,
linkTypeList: [1, 3]
),
auth: false
)
}
}
+20
View File
@@ -0,0 +1,20 @@
import Foundation
/// (//), app/api/v1/savings.py()
struct SavingsService {
private var api: APIClient { .shared }
func summary() async throws -> SavingsSummaryDTO {
try await api.get("api/v1/savings/summary")
}
func battle() async throws -> SavingsBattleDTO {
try await api.get("api/v1/savings/battle")
}
func records(limit: Int = 20, cursor: Int? = nil) async throws -> SavingsRecordPageDTO {
var query = [URLQueryItem(name: "limit", value: String(limit))]
if let cursor { query.append(URLQueryItem(name: "cursor", value: String(cursor))) }
return try await api.get("api/v1/savings/records", query: query)
}
}
+24
View File
@@ -0,0 +1,24 @@
import Foundation
/// , app/api/v1/user.py()
struct UserService {
private var api: APIClient { .shared }
/// , user
func updateNickname(_ nickname: String) async throws -> UserDTO {
try await api.patch("api/v1/user/profile", body: ProfileUpdateRequest(nickname: nickname))
}
/// ( file), user( avatar_url)
func uploadAvatar(jpeg: Data) async throws -> UserDTO {
try await api.postMultipart(
"api/v1/user/avatar",
files: [MultipartFile(name: "file", filename: "avatar.jpg", mimeType: "image/jpeg", data: jpeg)]
)
}
/// ( + )
func deleteAccount() async throws -> OkResponse {
try await api.delete("api/v1/user")
}
}
+17
View File
@@ -0,0 +1,17 @@
import Foundation
/// (: + ) wallet.py
/// "": exchange/withdraw/bind-wechat
struct WalletService {
private var api: APIClient { .shared }
func account() async throws -> CoinAccountDTO {
try await api.get("api/v1/wallet/account")
}
func coinTransactions(limit: Int = 20, cursor: Int? = nil) async throws -> CoinTransactionPageDTO {
var query = [URLQueryItem(name: "limit", value: String(limit))]
if let cursor { query.append(URLQueryItem(name: "cursor", value: String(cursor))) }
return try await api.get("api/v1/wallet/coin-transactions", query: query)
}
}
+24
View File
@@ -0,0 +1,24 @@
import Foundation
/// + , signin.py / tasks.py()
struct WelfareService {
private var api: APIClient { .shared }
func signinStatus() async throws -> SigninStatusDTO {
try await api.get("api/v1/signin/status")
}
func doSignin() async throws -> SigninResultDTO {
try await api.post("api/v1/signin")
}
func tasks() async throws -> TaskListDTO {
try await api.get("api/v1/tasks")
}
/// task_key URL , body
func claimTask(taskKey: String) async throws -> TaskClaimResultDTO {
let key = taskKey.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? taskKey
return try await api.post("api/v1/tasks/\(key)/claim")
}
}