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:
wangdl 2026-06-26 20:08:41 +08:00
parent 993a68759f
commit e2511855fe
8 changed files with 695 additions and 0 deletions

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

View File

@ -35,6 +35,9 @@ enum Route: Hashable {
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?)
// Membership
case membership
// Profile
case notificationList
case settings
@ -68,6 +71,7 @@ extension Route {
case .goalSetting: "GoalSettingDetailView"
case .methodPreference: "MethodPreferenceView"
case .feedbackForm: "FeedbackFormView"
case .membership: "MembershipView"
case .editProfile: "EditProfilePage"
case .importReview: "ImportReviewPage"
case .quizList: "QuizListView"
@ -102,6 +106,7 @@ extension Route {
AnyView(LearningSessionView(taskTitle: title, taskType: type, taskColor: Color(hex: colorHex)))
case .studyHome: AnyView(StudyHomeView(selectedTab: .constant("study")))
case .membership: AnyView(MembershipView())
case .notificationList: AnyView(NotificationListView())
case .settings: AnyView(SettingsView())
case .goalSetting: AnyView(GoalSettingDetailView())

View File

@ -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 {
var reviewReminder: Bool?
var newFeatures: Bool?

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

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

View File

@ -29,6 +29,11 @@ struct ProfileView: View {
.accessibilityLabel("设置")
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 4)
profileCard
// M-MEMBER-01-10:
NavigationLink(value: Route.membership) {
ZXProfileMenuRow(icon: "crown", title: "会员中心", desc: "查看和管理你的会员订阅")
}.foregroundColor(.primary)
ZXProfileDivider()
VStack(spacing: 0) {
NavigationLink(value: Route.goalSetting) {
ZXProfileMenuRow(icon: "target", title: "学习目标设置", desc: "调整你的学习目标")

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

View 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: "用户输入"), "用户输入")
}
}