wangdl e2511855fe 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>
2026-06-26 20:08:41 +08:00

324 lines
13 KiB
Swift

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
}
}