初始化傻瓜比价正式版 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:
@@ -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 客户端。自动注入 Bearer、401 自动刷新重放一次(对应安卓 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
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
/// 登录相关接口,对齐后端 app/api/v1/auth.py。jverify 一键登录首版不做(只短信)。
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
/// 通用响应。后端不少接口返 {"ok": true}。
|
||||
struct OkResponse: Codable {
|
||||
let ok: Bool
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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)) ?? [:]
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user