feat(membership): add StoreKit subscription and membership center
- StoreKitManager: purchase, Transaction.updates, restore, finish - MembershipView: plan cards, purchase section, usage grid, compliance - MembershipService: API client for /membership/* endpoints - MembershipModels: Codable DTOs for plans/grants/transactions - Route.membership + ProfileView entry point - Products.storekit: Sandbox test products (monthly/yearly) - Remove hardcoded price fallback (displayPrice nil → 价格暂不可用) - Add MembershipModelsTests (12 tests) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
993a68759f
commit
e2511855fe
68
AIStudyApp/AIStudyApp/Core/Models/MembershipModels.swift
Normal file
68
AIStudyApp/AIStudyApp/Core/Models/MembershipModels.swift
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
//
|
||||||
|
// MembershipModels.swift — M-MEMBER-01-09
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - API Responses
|
||||||
|
|
||||||
|
struct MembershipPlanResponse: Codable {
|
||||||
|
let plans: [PlanItem]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PlanItem: Codable, Identifiable {
|
||||||
|
var id: String { planCode }
|
||||||
|
let planCode: String
|
||||||
|
let name: String
|
||||||
|
let entitlements: Entitlements?
|
||||||
|
let priceMonthly: Int
|
||||||
|
let priceYearly: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Entitlements: Codable {
|
||||||
|
let maxKnowledgeBases: Int
|
||||||
|
let maxStorageBytes: Int
|
||||||
|
let maxFileSizeBytes: Int
|
||||||
|
let monthlyOcrPages: Int
|
||||||
|
let monthlyVisionPages: Int
|
||||||
|
let monthlyChatCount: Int
|
||||||
|
let monthlyAiAnalysisCount: Int
|
||||||
|
let monthlyRecallCount: Int
|
||||||
|
let monthlyCardGenCount: Int
|
||||||
|
let monthlyQuizCount: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MyMembershipResponse: Codable {
|
||||||
|
let effectivePlan: String
|
||||||
|
let grants: [GrantSummary]
|
||||||
|
let appAccountToken: String?
|
||||||
|
let entitlements: Entitlements?
|
||||||
|
let usageSummary: UsageSummary?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GrantSummary: Codable, Identifiable {
|
||||||
|
var id: String { "\(source)-\(planCode)" }
|
||||||
|
let source: String
|
||||||
|
let status: String
|
||||||
|
let planCode: String
|
||||||
|
let planName: String
|
||||||
|
let startedAt: String
|
||||||
|
let expiresAt: String?
|
||||||
|
let autoRenewStatus: String?
|
||||||
|
let environment: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UsageSummary: Codable {
|
||||||
|
let used: Int?
|
||||||
|
let remaining: Int?
|
||||||
|
let nextRefresh: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TransactionSubmitBody: Codable {
|
||||||
|
let signedTransactionInfo: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TransactionSubmitResponse: Codable {
|
||||||
|
let transactionId: String
|
||||||
|
let status: String
|
||||||
|
}
|
||||||
@ -35,6 +35,9 @@ enum Route: Hashable {
|
|||||||
case materialReader(materialId: String, filePath: String, materialType: MaterialType, knowledgeBaseId: String? = nil, title: String = "")
|
case materialReader(materialId: String, filePath: String, materialType: MaterialType, knowledgeBaseId: String? = nil, title: String = "")
|
||||||
case materialDetail(knowledgeBaseId: String, fileName: String, fileType: MaterialType, fileSize: UInt64, filePath: String, uploadDate: String?)
|
case materialDetail(knowledgeBaseId: String, fileName: String, fileType: MaterialType, fileSize: UInt64, filePath: String, uploadDate: String?)
|
||||||
|
|
||||||
|
// Membership
|
||||||
|
case membership
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
case notificationList
|
case notificationList
|
||||||
case settings
|
case settings
|
||||||
@ -68,6 +71,7 @@ extension Route {
|
|||||||
case .goalSetting: "GoalSettingDetailView"
|
case .goalSetting: "GoalSettingDetailView"
|
||||||
case .methodPreference: "MethodPreferenceView"
|
case .methodPreference: "MethodPreferenceView"
|
||||||
case .feedbackForm: "FeedbackFormView"
|
case .feedbackForm: "FeedbackFormView"
|
||||||
|
case .membership: "MembershipView"
|
||||||
case .editProfile: "EditProfilePage"
|
case .editProfile: "EditProfilePage"
|
||||||
case .importReview: "ImportReviewPage"
|
case .importReview: "ImportReviewPage"
|
||||||
case .quizList: "QuizListView"
|
case .quizList: "QuizListView"
|
||||||
@ -102,6 +106,7 @@ extension Route {
|
|||||||
AnyView(LearningSessionView(taskTitle: title, taskType: type, taskColor: Color(hex: colorHex)))
|
AnyView(LearningSessionView(taskTitle: title, taskType: type, taskColor: Color(hex: colorHex)))
|
||||||
case .studyHome: AnyView(StudyHomeView(selectedTab: .constant("study")))
|
case .studyHome: AnyView(StudyHomeView(selectedTab: .constant("study")))
|
||||||
|
|
||||||
|
case .membership: AnyView(MembershipView())
|
||||||
case .notificationList: AnyView(NotificationListView())
|
case .notificationList: AnyView(NotificationListView())
|
||||||
case .settings: AnyView(SettingsView())
|
case .settings: AnyView(SettingsView())
|
||||||
case .goalSetting: AnyView(GoalSettingDetailView())
|
case .goalSetting: AnyView(GoalSettingDetailView())
|
||||||
|
|||||||
@ -632,6 +632,34 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Membership (M-MEMBER-01-09)
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class MembershipService {
|
||||||
|
static let shared = MembershipService()
|
||||||
|
private let client = APIClient.shared
|
||||||
|
|
||||||
|
func getPlans() async throws -> MembershipPlanResponse {
|
||||||
|
try await client.request("/membership/plans")
|
||||||
|
}
|
||||||
|
|
||||||
|
func myMembership() async throws -> MyMembershipResponse {
|
||||||
|
try await client.request("/membership/me")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUsage() async throws -> UsageSummary {
|
||||||
|
try await client.request("/membership/usage")
|
||||||
|
}
|
||||||
|
|
||||||
|
func submitTransaction(signedTransactionInfo: String) async throws -> TransactionSubmitResponse {
|
||||||
|
try await client.request(
|
||||||
|
"/membership/apple/transactions",
|
||||||
|
method: "POST",
|
||||||
|
body: ["signedTransactionInfo": signedTransactionInfo] as [String: String]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct NotificationPreferences: Codable {
|
struct NotificationPreferences: Codable {
|
||||||
var reviewReminder: Bool?
|
var reviewReminder: Bool?
|
||||||
var newFeatures: Bool?
|
var newFeatures: Bool?
|
||||||
|
|||||||
152
AIStudyApp/AIStudyApp/Core/Services/StoreKitManager.swift
Normal file
152
AIStudyApp/AIStudyApp/Core/Services/StoreKitManager.swift
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
import StoreKit
|
||||||
|
import Combine
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// M-MEMBER-01-09: StoreKit 2 核心管理器
|
||||||
|
@MainActor
|
||||||
|
final class StoreKitManager: ObservableObject {
|
||||||
|
static let shared = StoreKitManager()
|
||||||
|
|
||||||
|
@Published var products: [StoreKit.Product] = []
|
||||||
|
@Published var isLoadingProducts = false
|
||||||
|
@Published var purchaseState: PurchaseState = .idle
|
||||||
|
@Published var currentEntitlementActive = false
|
||||||
|
|
||||||
|
enum PurchaseState: Equatable {
|
||||||
|
case idle
|
||||||
|
case loading
|
||||||
|
case success(transactionId: String)
|
||||||
|
case userCancelled
|
||||||
|
case pending
|
||||||
|
case failed(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
private let productIds = [
|
||||||
|
"cloud.longde.aistudyapp.premium.monthly",
|
||||||
|
"cloud.longde.aistudyapp.premium.yearly",
|
||||||
|
]
|
||||||
|
|
||||||
|
private var updatesTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
func loadProducts() async {
|
||||||
|
isLoadingProducts = true
|
||||||
|
defer { isLoadingProducts = false }
|
||||||
|
do {
|
||||||
|
products = try await StoreKit.Product.products(for: productIds)
|
||||||
|
.sorted { $0.price < $1.price }
|
||||||
|
} catch {
|
||||||
|
products = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Purchase
|
||||||
|
|
||||||
|
func purchase(_ product: StoreKit.Product, appAccountToken: UUID) async {
|
||||||
|
purchaseState = .loading
|
||||||
|
|
||||||
|
let result: Product.PurchaseResult
|
||||||
|
do {
|
||||||
|
result = try await product.purchase(options: [
|
||||||
|
.appAccountToken(appAccountToken),
|
||||||
|
])
|
||||||
|
} catch {
|
||||||
|
purchaseState = .failed(error.localizedDescription)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success(let verification):
|
||||||
|
await handleVerifiedTransaction(verification)
|
||||||
|
|
||||||
|
case .userCancelled:
|
||||||
|
purchaseState = .userCancelled
|
||||||
|
|
||||||
|
case .pending:
|
||||||
|
purchaseState = .pending
|
||||||
|
|
||||||
|
@unknown default:
|
||||||
|
purchaseState = .failed("Unknown purchase result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Transaction Handling
|
||||||
|
|
||||||
|
private func handleVerifiedTransaction(_ verification: StoreKit.VerificationResult<StoreKit.Transaction>) async {
|
||||||
|
// B1: 从 VerificationResult 提取 JWS(非 Transaction.jsonRepresentation)
|
||||||
|
let jws = verification.jwsRepresentation
|
||||||
|
|
||||||
|
switch verification {
|
||||||
|
case .verified(let transaction):
|
||||||
|
let success = await submitToBackend(jws: jws)
|
||||||
|
if success {
|
||||||
|
currentEntitlementActive = true
|
||||||
|
purchaseState = .success(transactionId: String(transaction.id))
|
||||||
|
} else {
|
||||||
|
purchaseState = .failed("Backend verification failed")
|
||||||
|
}
|
||||||
|
await transaction.finish()
|
||||||
|
|
||||||
|
case .unverified:
|
||||||
|
purchaseState = .failed("Transaction unverified")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func submitToBackend(jws: String) async -> Bool {
|
||||||
|
do {
|
||||||
|
let _: TransactionSubmitResponse = try await APIClient.shared.request(
|
||||||
|
"/membership/apple/transactions",
|
||||||
|
method: "POST",
|
||||||
|
body: ["signedTransactionInfo": jws]
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Transaction Listener
|
||||||
|
|
||||||
|
func startListening() {
|
||||||
|
updatesTask = Task.detached {
|
||||||
|
for await verification in Transaction.updates {
|
||||||
|
await self.handleVerifiedTransaction(verification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopListening() {
|
||||||
|
updatesTask?.cancel()
|
||||||
|
updatesTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Restore Purchases
|
||||||
|
|
||||||
|
func restorePurchases() async -> Bool {
|
||||||
|
var restored = false
|
||||||
|
do {
|
||||||
|
try await AppStore.sync()
|
||||||
|
for await verification in Transaction.currentEntitlements {
|
||||||
|
// B1: 从 VerificationResult 提取 JWS
|
||||||
|
let success = await submitToBackend(jws: verification.jwsRepresentation)
|
||||||
|
if case .verified(let tx) = verification {
|
||||||
|
if success { restored = true; currentEntitlementActive = true }
|
||||||
|
await tx.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return restored
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cleanup
|
||||||
|
|
||||||
|
func handleLogout() {
|
||||||
|
stopListening()
|
||||||
|
currentEntitlementActive = false
|
||||||
|
purchaseState = .idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
323
AIStudyApp/AIStudyApp/Features/Membership/MembershipView.swift
Normal file
323
AIStudyApp/AIStudyApp/Features/Membership/MembershipView.swift
Normal file
@ -0,0 +1,323 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
import StoreKit
|
||||||
|
|
||||||
|
// MARK: - MembershipView (M-MEMBER-01-10)
|
||||||
|
|
||||||
|
struct MembershipView: View {
|
||||||
|
@StateObject private var store = MembershipStore()
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.zxBg0.ignoresSafeArea()
|
||||||
|
|
||||||
|
switch store.state {
|
||||||
|
case .loading:
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ProgressView().tint(Color.zxPurple)
|
||||||
|
Text("加载中…").font(.system(size: 14)).foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .loaded(let membership, let plans):
|
||||||
|
loadedView(membership: membership, plans: plans)
|
||||||
|
|
||||||
|
case .error(let msg):
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "wifi.slash").font(.system(size: 36)).foregroundColor(Color.zxF03)
|
||||||
|
Text(msg).font(.system(size: 14)).foregroundColor(Color.zxF04)
|
||||||
|
Button("重试") { Task { await store.load() } }
|
||||||
|
.font(.system(size: 14, weight: .medium)).foregroundColor(Color.zxPurple)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("会员中心").navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar(.hidden, for: .tabBar)
|
||||||
|
.task { await store.load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Loaded State
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func loadedView(membership: MyMembershipResponse, plans: MembershipPlanResponse) -> some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
// Current plan card
|
||||||
|
currentPlanCard(membership)
|
||||||
|
|
||||||
|
// Purchase section (only if not premium)
|
||||||
|
if membership.effectivePlan != "premium" {
|
||||||
|
purchaseSection(plans)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
if let entitlements = membership.entitlements {
|
||||||
|
usageSection(entitlements: entitlements)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
actionsSection(membership)
|
||||||
|
|
||||||
|
// Compliance
|
||||||
|
complianceSection
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100)
|
||||||
|
}
|
||||||
|
.scrollIndicators(.hidden)
|
||||||
|
.alert(store.alertTitle, isPresented: $store.showAlert) {
|
||||||
|
Button("确定", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text(store.alertMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Current Plan
|
||||||
|
|
||||||
|
private func currentPlanCard(_ m: MyMembershipResponse) -> some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: m.effectivePlan == "premium" ? "crown.fill" : "person.fill")
|
||||||
|
.font(.system(size: 24)).foregroundColor(m.effectivePlan == "premium" ? Color.zxAmber : Color.zxF03)
|
||||||
|
Text(m.effectivePlan == "premium" ? "Premium 会员" : "免费用户")
|
||||||
|
.font(.system(size: 18, weight: .bold)).foregroundColor(Color.zxF0)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
if let expires = m.grants.first?.expiresAt, m.effectivePlan == "premium" {
|
||||||
|
HStack {
|
||||||
|
Text("到期时间:\(formatDate(expires))").font(.system(size: 12)).foregroundColor(Color.zxF04)
|
||||||
|
Spacer()
|
||||||
|
if m.grants.first?.autoRenewStatus == "off" {
|
||||||
|
Text("续订已关闭").font(.system(size: 11)).foregroundColor(Color.zxCoral)
|
||||||
|
.padding(.horizontal, 8).padding(.vertical, 3)
|
||||||
|
.background(Color.zxCoral.opacity(0.1)).clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let grant = m.grants.first {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Label("来源:\(sourceLabel(grant.source))", systemImage: "checkmark.shield")
|
||||||
|
.font(.system(size: 11)).foregroundColor(Color.zxF04)
|
||||||
|
if let env = grant.environment, env == "Sandbox" {
|
||||||
|
Text("沙盒").font(.system(size: 10)).foregroundColor(Color.zxAmber)
|
||||||
|
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||||
|
.background(Color.zxAmber.opacity(0.15)).clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Purchase
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func purchaseSection(_ plans: MembershipPlanResponse) -> some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text("升级 Premium").font(.system(size: 16, weight: .bold)).foregroundColor(Color.zxF0)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
ForEach(plans.plans) { plan in
|
||||||
|
Button {
|
||||||
|
Task { await store.purchase(plan: plan) }
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(plan.name).font(.system(size: 15, weight: .semibold)).foregroundColor(Color.zxF0)
|
||||||
|
Text(plan.planCode.contains("yearly") ? "每年自动续订,更优惠" : "每月自动续订")
|
||||||
|
.font(.system(size: 11)).foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if store.purchasingPlanId == plan.planCode {
|
||||||
|
ProgressView().tint(Color.zxPurple)
|
||||||
|
} else if let price = store.displayPrice(for: plan.planCode) {
|
||||||
|
Text(price)
|
||||||
|
.font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxPurple)
|
||||||
|
} else {
|
||||||
|
Text("价格暂不可用")
|
||||||
|
.font(.system(size: 12)).foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16).background(Color.zxFill004)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxPurple.opacity(0.15), lineWidth: 1))
|
||||||
|
}
|
||||||
|
.disabled(store.purchasingPlanId != nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Usage
|
||||||
|
|
||||||
|
private func usageSection(entitlements: Entitlements) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("额度").font(.system(size: 16, weight: .bold)).foregroundColor(Color.zxF0)
|
||||||
|
|
||||||
|
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
|
||||||
|
QuotaItem(label: "知识库", value: "\(entitlements.maxKnowledgeBases)")
|
||||||
|
QuotaItem(label: "AI 对话/月", value: entitlements.monthlyChatCount > 0 ? "\(entitlements.monthlyChatCount)" : "无限")
|
||||||
|
QuotaItem(label: "OCR/月", value: "\(entitlements.monthlyOcrPages) 页")
|
||||||
|
QuotaItem(label: "测验/天", value: "\(entitlements.monthlyQuizCount)")
|
||||||
|
QuotaItem(label: "存储", value: formatBytes(entitlements.maxStorageBytes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func actionsSection(_ m: MyMembershipResponse) -> some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Button { Task { await store.restore() } } label: {
|
||||||
|
HStack {
|
||||||
|
Text("恢复购买").font(.system(size: 14)).foregroundColor(Color.zxPurple)
|
||||||
|
Spacer()
|
||||||
|
if store.isRestoring { ProgressView().tint(Color.zxPurple) }
|
||||||
|
}.padding(16).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.effectivePlan == "premium" {
|
||||||
|
Button { store.openManageSubscription() } label: {
|
||||||
|
HStack {
|
||||||
|
Text("管理订阅").font(.system(size: 14)).foregroundColor(Color.zxF04)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "arrow.up.forward").font(.system(size: 12)).foregroundColor(Color.zxF03)
|
||||||
|
}.padding(16).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Compliance
|
||||||
|
|
||||||
|
private var complianceSection: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Link("隐私政策", destination: URL(string: "https://longde.cloud/privacy")!)
|
||||||
|
.font(.system(size: 13)).foregroundColor(Color.zxF04)
|
||||||
|
Link("用户协议", destination: URL(string: "https://longde.cloud/terms")!)
|
||||||
|
.font(.system(size: 13)).foregroundColor(Color.zxF04)
|
||||||
|
Text("自动续订说明:订阅将在到期前 24 小时内自动续订,可随时在系统设置中取消。")
|
||||||
|
.font(.system(size: 11)).foregroundColor(Color.zxF04).multilineTextAlignment(.center)
|
||||||
|
Link("联系支持", destination: URL(string: "mailto:support@longde.cloud")!)
|
||||||
|
.font(.system(size: 13)).foregroundColor(Color.zxPurple)
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func formatDate(_ iso: String) -> String {
|
||||||
|
let f = ISO8601DateFormatter()
|
||||||
|
f.formatOptions = [.withFullDate]
|
||||||
|
return f.date(from: iso)?.formatted(date: .abbreviated, time: .omitted) ?? iso.prefix(10).description
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatBytes(_ b: Int) -> String {
|
||||||
|
if b >= 1_073_741_824 { return String(format: "%.0f GB", Double(b) / 1_073_741_824) }
|
||||||
|
if b >= 1_048_576 { return String(format: "%.0f MB", Double(b) / 1_048_576) }
|
||||||
|
return "\(b) B"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sourceLabel(_ s: String) -> String {
|
||||||
|
switch s { case "apple": return "Apple"; case "admin": return "管理员"; case "trial": return "试用"; default: return s }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Quota Item
|
||||||
|
|
||||||
|
struct QuotaItem: View {
|
||||||
|
let label: String
|
||||||
|
let value: String
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text(value).font(.system(size: 16, weight: .bold)).foregroundColor(Color.zxF0)
|
||||||
|
Text(label).font(.system(size: 11)).foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity).padding(.vertical, 12)
|
||||||
|
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - MembershipStore
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class MembershipStore: ObservableObject {
|
||||||
|
@Published var state: State = .loading
|
||||||
|
@Published var purchasingPlanId: String? = nil
|
||||||
|
@Published var isRestoring = false
|
||||||
|
@Published var showAlert = false
|
||||||
|
var alertTitle = ""; var alertMessage = ""
|
||||||
|
|
||||||
|
enum State {
|
||||||
|
case loading
|
||||||
|
case loaded(membership: MyMembershipResponse, plans: MembershipPlanResponse)
|
||||||
|
case error(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
func load() async {
|
||||||
|
state = .loading
|
||||||
|
do {
|
||||||
|
async let membership = MembershipService.shared.myMembership()
|
||||||
|
async let plans = MembershipService.shared.getPlans()
|
||||||
|
state = try await .loaded(membership: membership, plans: plans)
|
||||||
|
} catch {
|
||||||
|
state = .error("加载失败:\(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func purchase(plan: PlanItem) async {
|
||||||
|
purchasingPlanId = plan.planCode
|
||||||
|
guard let tokenStr = try? await MembershipService.shared.myMembership().appAccountToken,
|
||||||
|
let uuid = UUID(uuidString: tokenStr) else {
|
||||||
|
show("无法获取账号令牌")
|
||||||
|
purchasingPlanId = nil; return
|
||||||
|
}
|
||||||
|
|
||||||
|
let pm = StoreKitManager.shared
|
||||||
|
await pm.loadProducts()
|
||||||
|
guard let product = pm.products.first(where: {
|
||||||
|
$0.id == (plan.planCode.contains("yearly")
|
||||||
|
? "cloud.longde.aistudyapp.premium.yearly"
|
||||||
|
: "cloud.longde.aistudyapp.premium.monthly")
|
||||||
|
}) else {
|
||||||
|
show("商品不可用"); purchasingPlanId = nil; return
|
||||||
|
}
|
||||||
|
|
||||||
|
await pm.purchase(product, appAccountToken: uuid)
|
||||||
|
|
||||||
|
switch pm.purchaseState {
|
||||||
|
case .success: await load()
|
||||||
|
case .userCancelled: break
|
||||||
|
case .pending: show("购买处理中,请稍候")
|
||||||
|
case .failed(let msg): show("购买失败:\(msg)")
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
purchasingPlanId = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func restore() async {
|
||||||
|
isRestoring = true
|
||||||
|
let restored = await StoreKitManager.shared.restorePurchases()
|
||||||
|
if restored { await load() } else { show("未找到可恢复的订阅") }
|
||||||
|
isRestoring = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 StoreKit Product 获取本地化价格。未加载时返回 nil。
|
||||||
|
func displayPrice(for planCode: String) -> String? {
|
||||||
|
let productId = planCode.contains("yearly")
|
||||||
|
? "cloud.longde.aistudyapp.premium.yearly"
|
||||||
|
: "cloud.longde.aistudyapp.premium.monthly"
|
||||||
|
return StoreKitManager.shared.products.first(where: { $0.id == productId })?.displayPrice
|
||||||
|
}
|
||||||
|
|
||||||
|
/// N2: 使用系统 API 打开订阅管理
|
||||||
|
func openManageSubscription() {
|
||||||
|
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
|
||||||
|
Task { try? await AppStore.showManageSubscriptions(in: scene) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func show(_ msg: String) {
|
||||||
|
alertTitle = "提示"; alertMessage = msg; showAlert = true
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -29,6 +29,11 @@ struct ProfileView: View {
|
|||||||
.accessibilityLabel("设置")
|
.accessibilityLabel("设置")
|
||||||
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 4)
|
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 4)
|
||||||
profileCard
|
profileCard
|
||||||
|
// M-MEMBER-01-10: 会员中心入口
|
||||||
|
NavigationLink(value: Route.membership) {
|
||||||
|
ZXProfileMenuRow(icon: "crown", title: "会员中心", desc: "查看和管理你的会员订阅")
|
||||||
|
}.foregroundColor(.primary)
|
||||||
|
ZXProfileDivider()
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
NavigationLink(value: Route.goalSetting) {
|
NavigationLink(value: Route.goalSetting) {
|
||||||
ZXProfileMenuRow(icon: "target", title: "学习目标设置", desc: "调整你的学习目标")
|
ZXProfileMenuRow(icon: "target", title: "学习目标设置", desc: "调整你的学习目标")
|
||||||
|
|||||||
53
AIStudyApp/AIStudyApp/Products.storekit
Normal file
53
AIStudyApp/AIStudyApp/Products.storekit
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"identifier" : "F6394B01",
|
||||||
|
"nonRenewingSubscriptions" : [],
|
||||||
|
"products" : [
|
||||||
|
{
|
||||||
|
"displayName" : "Premium Monthly",
|
||||||
|
"familyDisplayName" : "Premium",
|
||||||
|
"productType" : "AutoRenewableSubscription",
|
||||||
|
"referenceName" : "Premium Monthly",
|
||||||
|
"subscriptionGroupID" : "premium",
|
||||||
|
"subscriptionGroupDisplayName" : "Premium",
|
||||||
|
"productID" : "cloud.longde.aistudyapp.premium.monthly",
|
||||||
|
"price" : 28,
|
||||||
|
"subscriptionPeriod" : "P1M",
|
||||||
|
"localizations" : [
|
||||||
|
{
|
||||||
|
"displayName" : "Premium 月付",
|
||||||
|
"description" : "每月自动续订",
|
||||||
|
"locale" : "zh_CN"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"displayName" : "Premium Yearly",
|
||||||
|
"familyDisplayName" : "Premium",
|
||||||
|
"productType" : "AutoRenewableSubscription",
|
||||||
|
"referenceName" : "Premium Yearly",
|
||||||
|
"subscriptionGroupID" : "premium",
|
||||||
|
"subscriptionGroupDisplayName" : "Premium",
|
||||||
|
"productID" : "cloud.longde.aistudyapp.premium.yearly",
|
||||||
|
"price" : 198,
|
||||||
|
"subscriptionPeriod" : "P1Y",
|
||||||
|
"localizations" : [
|
||||||
|
{
|
||||||
|
"displayName" : "Premium 年付",
|
||||||
|
"description" : "每年自动续订,省 41%",
|
||||||
|
"locale" : "zh_CN"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings" : {
|
||||||
|
"failTransactionsEnabled" : false,
|
||||||
|
"locale" : "zh_CN",
|
||||||
|
"name" : "本地测试",
|
||||||
|
"storefront" : "CHN",
|
||||||
|
"timeRate" : 1
|
||||||
|
},
|
||||||
|
"version" : {
|
||||||
|
"major" : 3,
|
||||||
|
"minor" : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
61
AIStudyApp/AIStudyAppTests/MembershipModelsTests.swift
Normal file
61
AIStudyApp/AIStudyAppTests/MembershipModelsTests.swift
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import AIStudyApp
|
||||||
|
|
||||||
|
final class MembershipModelsTests: XCTestCase {
|
||||||
|
|
||||||
|
func testPlanItemDecoding() throws {
|
||||||
|
let json = #"{"planCode":"premium","name":"Premium","entitlements":{"maxKnowledgeBases":10,"maxStorageBytes":10737418240,"maxFileSizeBytes":52428800,"monthlyOcrPages":50,"monthlyVisionPages":30,"monthlyChatCount":300,"monthlyAiAnalysisCount":0,"monthlyRecallCount":0,"monthlyCardGenCount":0,"monthlyQuizCount":0},"priceMonthly":2800,"priceYearly":19800}"#
|
||||||
|
let data = json.data(using: .utf8)!
|
||||||
|
let plan = try JSONDecoder().decode(PlanItem.self, from: data)
|
||||||
|
XCTAssertEqual(plan.planCode, "premium")
|
||||||
|
XCTAssertEqual(plan.entitlements?.maxKnowledgeBases, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMyMembershipDecoding() throws {
|
||||||
|
let json = #"{"effectivePlan":"premium","grants":[{"source":"apple","status":"active","planCode":"premium","planName":"Premium","startedAt":"2026-01-01T00:00:00Z","expiresAt":null,"autoRenewStatus":"on","environment":"Production"}],"appAccountToken":"uuid","entitlements":null,"usageSummary":null}"#
|
||||||
|
let data = json.data(using: .utf8)!
|
||||||
|
let m = try JSONDecoder().decode(MyMembershipResponse.self, from: data)
|
||||||
|
XCTAssertEqual(m.effectivePlan, "premium")
|
||||||
|
XCTAssertEqual(m.grants.first?.source, "apple")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTransactionSubmitBodyEncoding() throws {
|
||||||
|
let body = TransactionSubmitBody(signedTransactionInfo: "test-jws")
|
||||||
|
let data = try JSONEncoder().encode(body)
|
||||||
|
let dict = try JSONSerialization.jsonObject(with: data) as? [String: String]
|
||||||
|
XCTAssertEqual(dict?["signedTransactionInfo"], "test-jws")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMembershipPlanResponseDecoding() throws {
|
||||||
|
let json = #"{"plans":[]}"#
|
||||||
|
let data = json.data(using: .utf8)!
|
||||||
|
let r = try JSONDecoder().decode(MembershipPlanResponse.self, from: data)
|
||||||
|
XCTAssertTrue(r.plans.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTransactionSubmitResponseDecoding() throws {
|
||||||
|
let json = #"{"transactionId":"tx-1","status":"created"}"#
|
||||||
|
let data = json.data(using: .utf8)!
|
||||||
|
let r = try JSONDecoder().decode(TransactionSubmitResponse.self, from: data)
|
||||||
|
XCTAssertEqual(r.transactionId, "tx-1")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testQuizQuestionTypeChoice() { XCTAssertEqual(QuizQuestionType.from(rawValue: "choice"), .choice) }
|
||||||
|
func testQuizQuestionTypeFill() { XCTAssertEqual(QuizQuestionType.from(rawValue: "fill"), .fill) }
|
||||||
|
func testQuizQuestionTypeJudge() { XCTAssertEqual(QuizQuestionType.from(rawValue: "judge"), .judge) }
|
||||||
|
func testQuizQuestionTypeUnknown() { XCTAssertNil(QuizQuestionType.from(rawValue: "essay")) }
|
||||||
|
|
||||||
|
func testJudgeDisplayAnswer() {
|
||||||
|
XCTAssertEqual(QuizQuestionType.judge.displayAnswer(raw: "true"), "正确")
|
||||||
|
XCTAssertEqual(QuizQuestionType.judge.displayAnswer(raw: "false"), "错误")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testChoiceDisplayAnswer() {
|
||||||
|
XCTAssertEqual(QuizQuestionType.choice.displayAnswer(raw: "0", options: ["氧气","氮气"]), "氧气")
|
||||||
|
XCTAssertEqual(QuizQuestionType.choice.displayAnswer(raw: "1", options: ["氧气","氮气"]), "氮气")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFillDisplayAnswer() {
|
||||||
|
XCTAssertEqual(QuizQuestionType.fill.displayAnswer(raw: "用户输入"), "用户输入")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user