简介:面向iOS开发者的Swift内购支付工具示例工程,基于StoreKit框架实现App内购买(IAP)完整链路,覆盖消耗品、非消耗品、订阅三类产品,适合需要为应用接入内购功能的移动端开发者查阅与复用。资源压缩包大小为66KB,共31个文件,类型上以9个Swift源文件为核心,附带5个plist配置、3个Objective-C头文件与对应实现、2个storyboard界面文件,以及Xcode工程配置、entitlements权限文件等,结构紧凑,便于直接打开工程对照核心代码。示例通过InAppPurchaseManager封装产品请求与交易监听,完整演示了从SKProductsRequest拉取产品信息、SKPaymentQueue添加支付请求、paymentQueue(updatedTransactions:)处理交易状态,到恢复购买和服务器收据验证的流程;同时包含产品ID配置、观察者注册、交易队列刷新、失败与退款处理等实现细节。Info.plist中的App Transport Security配置与entitlements相关设置也已备好,可减少实际集成时的踩坑成本。已有1701人学习下载,对正准备接入内购的中级iOS开发者尤其有参考价值。
1. 苹果内购支付工具在 Swift 里的真实定位:不是“接个 SDK”那么简单
不少 Swift 团队把苹果内购当成一个“接上就能跑”的支付工具,实际上它更像一套需要自建状态机的业务系统。我在过去两年里前后帮三个团队处理过内购相关的问题:一个因为收据校验放在了客户端被刷了上千笔假订单,一个因为没处理 Transaction 延迟到账导致用户永久性丢单,还有一个在审核前夜才发现“恢复购买”按钮在 iOS 15 的弹窗逻辑下根本走不通。这篇文章不打算给你贴一份官方文档的翻译,而是把我在 Swift 里落地苹果内购支付工具时反复用到的流程、配置、参数和踩坑记录整理成一份能直接照着做的方案。适合已经写好 App、准备接入或正在重构内购模块的 iOS 工程师,也适合想搞清楚 StoreKit 1/2 差异再做技术选型的技术负责人。
2. StoreKit 2 与 StoreKit 1:选型前先搞懂两代 API 的差异和迁移代价
WWDC 之后,苹果把 StoreKit 2 补到了 iOS 15 及以上,官方演示几乎全是 Swift 原生 async/await 写法。但很多存量 App 还背着 StoreKit 1 的 SKPaymentQueue 老代码,新建项目的人也容易被两套 API 搞混。选型直接决定接下来几个月的开发量:新工程建议直接用 StoreKit 2;老工程则要评估迁移时对交易监听、收据校验和订阅续期判断这三块的重写成本。我见过有人在 iOS 15 的 target 上强行混用两代,Transaction.updates和SKPaymentTransactionObserver同时挂,一次购买触发两套回调,业务侧重复解锁,用户在会员页被扣了两遍权益。
2.1 两代框架的能力对比:Transaction、Product、Entitlement 的差异
先把能力对比放在前面,后面所有代码和踩坑都基于这张表。
| 维度 | StoreKit 1 | StoreKit 2 |
|---|---|---|
| 最低系统版本 | iOS 6 | iOS 15 |
| 商品查询 | SKProductsRequestDelegate 回调 | Product.products(for:) async/await |
| 发起购买 | SKPaymentQueue.add(_:) | Product.purchase() |
| 交易监听 | SKPaymentTransactionObserver | Transaction.updates |
| 恢复购买 | restoreCompletedTransactions | AppStore.sync() |
| 交易凭证 | 本地收据文件 | Transaction.jwsRepresentation |
| 校验结果 | 无内置校验 | VerificationResult 校验 |
两代最本质的差异是数据模型。StoreKit 1 把一次购买拆成 SKPayment、SKPaymentQueue、SKPaymentTransaction 三个类,状态散落在队列和代理回调里;StoreKit 2 直接用 Product 和 Transaction 两个值类型表达,Transaction 自带signedDate、expirationDate、revocationDate、productID、purchaseDate,订阅续期和撤销都走同一条更新流下发。这对支付工具层意义很大:订阅续期会产生一笔新的 Transaction,退款或撤销会下发一笔revocationDate不为空的 Transaction,如果你只按“purchased 就发货”的逻辑写,订阅场景一定会出问题。我见过不止一个团队在测试订阅时发现“用户退款后权益没收回”,追到根因就是把revocationDate忽略掉了。
2.2 从 StoreKit 1 迁移到 StoreKit 2 的代码路径与关键改动点
迁移不是把方法名替换一遍,核心是把“代理回调 + 手动队列”改成“异步序列”的思维。常见做法是先抽一个IAPService接口,把对外的buy(productID:)、restore()、observeTransactions()抽象出来,内部再决定用哪一代实现。下面这段映射是迁移时最常遇到的三个改动点。
// StoreKit 1:商品查询走代理,结果要等 didReceive 回调 let request = SKProductsRequest(productIdentifiers: productIDs) request.delegate = self request.start() // StoreKit 2:一行拿到结果,失败直接抛错 let products = try await Product.products(for: productIDs)productIDs是你在 App Store Connect 里建好的产品标识符集合,类型是Set<String>。这里有两个容易踩的细节:同一个产品 ID 重复放进集合,苹果会忽略请求;产品 ID 里如果混入了空格或大写字母,返回的数组会是空数组而不是报错,所以guard let products.first是必须写的防御逻辑。
交易监听是迁移里最容易翻车的部分。StoreKit 1 要求在 AppDelegate 里挂一个常驻观察者,调SKPaymentQueue.default().add(observer);StoreKit 2 把监听改成Transaction.updates这个异步序列,在每个更新到达时处理。关键是updates必须在 App 启动早期就开始消费,否则购买完成后的 transaction 会一直留在队列里,下次启动才补发。
// StoreKit 2:在启动阶段开启交易监听 func startObserveTransactions() async { for await update in Transaction.updates { // update 可能是购买成功、续期、撤销、退款 let transaction = try checkVerified(update) await handle(transaction) await transaction.finish() } }checkVerified做的是VerificationResult解包:苹果返回.unverified时不能继续发货,.verified才能进入业务处理。这个函数在下面第 3.2 节会给出完整实现。恢复购买在 StoreKit 2 里变成了AppStore.sync(),调用后同样从Transaction.updates或Transaction.currentEntitlements里读结果,而不是像 StoreKit 1 那样单独走restoreCompletedTransactions的代理方法。
3. 用 Swift 实现沙盒内购的最小可运行流程:从配置到收据校验
3.1 在 App Store Connect 配置内购商品的六个步骤与关键参数
很多人先写代码再去建商品,等你调Product.products(for:)时发现返回空数组,才意识到是商品状态或协议问题。顺序应该是先把 App Store Connect 里的配置做对,再开 Xcode。我一般按下面六步走:
- 在 App Store Connect 选择对应 App 的“功能 -> App 内购买项目”,点加号。
- 选择商品类型。消耗型、非消耗型、自动续订订阅、非续订订阅四选一。
- 填写“引用名称”(团队内部识别用)和产品 ID。
- 设置价格与价格梯度。订阅还要选订阅组和结算周期。
- 填本地化信息:显示名称和描述。这一项为空或和 App 实际功能不符,审核会被拒。
- 提交审核信息里的截图和备注,然后保存。沙盒环境下商品不需要真正过审也能测,但状态必须是“准备提交”或“已批准”。
关键参数表:
| 参数 | 取值建议 | 坑点 |
|---|---|---|
| 产品 ID | com.公司名.App名.商品名,全小写 | 创建后不可修改,删除也删不掉,只能停用 |
| 商品类型 | 消耗型用于金币/道具;非消耗型用于解锁功能;自动续订用于会员 | 类型创建后不可修改 |
| 价格点 | 选择苹果提供的价格点 | 调价不用提交新版本 |
| 订阅组 | 同一个 App 的多个订阅归入同组 | 组内价格梯度需一致,否则被拒 |
| 本地化语言 | 至少填英文 | 只有中文会要求补材料 |
这里提醒一句:非续订订阅是最容易被误用的类型。它不会自动续期,也不能跨设备恢复,需要自己处理多设备同步。如果做的是会员服务,优先考虑自动续订订阅,否则设备切换后会面临“用户明明付过费,新设备却查不到交易”的投诉。
3.2 沙盒购买流程的 Swift 代码:从商品查询到 Transaction 结束
下面是一段能在 iOS 15+ 编译运行的 Swift 购买函数,完整处理购买结果的三大分支。建议放在一个IAPService类里,而不是直接在 ViewModel 里写。
import StoreKit enum IAPError: Error { case productNotFound(String) case failedVerification case unknown } func purchase(_ productID: String) async throws { // 1. 拉取商品信息 let products = try await Product.products(for: [productID]) guard let product = products.first else { throw IAPError.productNotFound(productID) } // 2. 发起购买,等待系统弹窗和用户操作 let result = try await product.purchase() switch result { case .success(let verification): // 这里的 verification 类型是 VerificationResult<Transaction> let transaction = try checkVerified(verification) // 先发货(一般是调用服务端接口解锁),再 finish await deliver(transaction) await transaction.finish() case .userCancelled: // 用户点了取消,不做任何处理 return case .pending: // 家长同意 / 余额不足 / 账号待验证 // 此时没有 transaction,等 Transaction.updates 后续到达 return @unknown default: throw IAPError.unknown } } private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T { switch result { case .verified(let safe): return safe case .unverified: throw IAPError.failedVerification } }逻辑说明:Product.products(for:)只接受Set<String>,重复 ID 和非法字符都会导致请求失败,靠返回空数组而不是抛错来表现,所以一定要先guard let products.first。product.purchase()是异步挂起函数,在调用期间用户会看到系统购买弹窗,调用方的 UI 状态要提前切到 loading。result的三个分支必须分别处理:.success说明支付完成;.userCancelled什么都不用做;.pending是最容易忽略的,它表示交易不能立即完成,比如家庭共享需要家长批准,这一单没有同步返回 transaction,真正的交易会在之后通过Transaction.updates到达。如果只处理.success,这类订单会丢失,用户那边表现为“钱扣了但权益没到账”。
参数说明:productID对应 App Store Connect 里的产品 ID,必须是字符串。checkVerified是泛型函数,只对VerificationResult做解包,不做业务校验,真正的业务校验要放到第 3.3 节的服务端逻辑里。
3.3 收据校验的取舍:本地验证为什么只能当兜底
上面的代码可以让你在沙盒里弹窗、付款、解锁,但它离“支付工具”还差一个关键环节:验证这个 transaction 不是伪造的。StoreKit 2 的VerificationResult只能证明 JWS 签名完整、没被篡改,不能证明这笔交易在苹果服务器上真实存在。绕过它的常见做法是直接构造一个假的Transaction对象,由于VerificationResult解包后拿到的类型是值类型,攻击者可以在内存里 hook 掉返回结果。因此生产环境必须做服务端校验。
客户端在购买成功后把transaction.jwsRepresentation发给自己的服务器,服务器用苹果的根证书验证签名,并拿着 transaction 的originalTransactionID和productID配合 App Store 服务端 API 二次确认。本地校验只建议在弱网兜底场景用,比如服务端暂时不可达时,先用本地凭证临时解锁,等网络恢复后再确认。
// 获取 JWS 收据并发送给服务端的示意 guard case .success(let verification) = result, case .verified(let transaction) = verification else { return } var request = URLRequest(url: URL(string: "https://your.api/iap/verify")!) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") let body: [String: Any] = [ "jws": transaction.jwsRepresentation, "productID": transaction.productID, "transactionID": transaction.id ] request.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, response) = try await URLSession.shared.data(for: request) // 服务端返回 verified: true 再解锁内容这段代码里jwsRepresentation是 StoreKit 2 最核心的收据载体,一个 JWS 字符串,包含 transaction 的完整信息、签名和证书链。注意transactionID和originalTransactionID是两回事:前者是每一笔交易的唯一 ID,后者是这个用户在这个产品上的首笔交易 ID。订阅续期时前者变化、后者不变。服务端查重时用originalTransactionID做唯一键,不要用transactionID判断“用户是否已购买”,否则续期一次就会多放行一次。
4. 苹果内购支付工具避坑手册:5 个从 Sandbox 到审核的翻车记录
这一章不按 API 顺序走,按实际高频问题排,每条都是“现象 -> 原因 -> 解决”,你对照自己的项目排查就行。
4.1 沙盒账号登录状态混乱导致无法弹窗:现象与解法
现象:在 Xcode 里跑真机,点击购买按钮没有任何反应,控制台也没有错误,系统弹窗根本不出现。
原因:最常见的是设备 App Store 的登录状态和目标沙盒账号不一致,或者上一个测试交易还挂在队列里没有被 finish。TestFlight 和 Xcode 沙盒环境对账号状态要求很苛刻,有时候连 Xcode 都没办法替你切换沙盒账号。
解决:先到系统设置里退出 App Store 登录,再在 App 内触发购买,此时系统会弹出沙盒账号登录框,用 App Store Connect 里创建的测试账号登录。如果还是无弹窗,检查代码里有没有未 finish 的 transaction,把所有 pending 的 transaction 调finish();再不行就换一台干净的真机,模拟器在内购弹窗上表现不稳定,不适合做最终验证。这套操作相当玄学,但确实能解决九成以上的无弹窗问题。另外,沙盒账号只能通过 App Store Connect 后台创建,不要用真实 Apple ID 测试,否则会被苹果封掉这个账号的测试资格。
4.2 订阅续期与撤销 Transaction 重复发放:状态机没有覆盖 revocation
现象:订阅产品在用户续期或退款后,服务器重复发货,后台日志显示同样的originalTransactionID被处理了多次。
原因:StoreKit 2 中订阅会产生多个 Transaction:续期一笔、撤销一笔。撤销时会下发revocationDate不为空的 Transaction,服务端如果没读取这个字段,就会再次执行发货逻辑。
解决:在handle(transaction)里先判断revocationDate。
func handle(_ transaction: Transaction) async throws { if let revocationDate = transaction.revocationDate { // 撤销/退款:收回权益,不要发货 await service.revokeEntitlement(transaction.originalTransactionID) } else { // 正常购买或续期:发货 await service.grantEntitlement( originalTransactionID: transaction.originalTransactionID, productID: transaction.productID ) } }判断必须放在发货之前。revocationDate是Date?,为空表示正常交易。还要注意Transaction.currentEntitlements也会返回已经撤销的 Transaction,过滤逻辑要和这里保持一致,建议把这段函数抽成公共方法,Transaction.updates和currentEntitlements两个入口复用,避免两处逻辑不一致。
4.3 客户端收据校验被绕过:伪造票据和假订单
现象:后台出现大量同一用户短时间内“购买”同一非消耗品的记录,但苹果开发者后台看不到对应订单。
原因:代码里用VerificationResult通过就当成功,而VerificationResult只验证本地 JWS 的签名链。攻击者可以构造一个签名有效的假 transaction,或直接把别人换取的 JWS 拿来重放。
解决:唯一可靠做法是让服务端调 App Store 的status接口核对交易状态。客户端拿到 JWS 后先做设备端完整性校验,再把 JWS 和originalTransactionID发给服务端,服务端用transactionID调苹果 API,确认productID、purchaseDate、expirationDate与客户端上报一致才算真支付。不要信任任何客户端传来的布尔字段,业务上只认服务端的结论。这里补充一个血泪经验:JWS 里的时间字段要用苹果服务器时间,不要拿客户端本地时间做比对,因为攻击者可以改系统时间绕过“未过期”判断。
4.4 审核被拒:隐藏的恢复购买按钮与缺失的引导
现象:App 提审后收到 Guideline 3.1.1 拒审,理由是“无法在 App 内找到恢复购买的功能入口”。
原因:非消耗品和自动续订订阅必须提供恢复购买入口,且不能藏太深。很多团队把“恢复购买”和“联系客服”挤在设置页底部,审核员找不到。
解决:在购买页、会员页和设置页至少放一个显眼的“恢复购买”按钮,点击后调用AppStore.sync()。
// 恢复购买:sync 后等待 Transaction.updates 或 currentEntitlements func restorePurchases() async throws { try await AppStore.sync() // 手动检查当前权益 for await entitlement in Transaction.currentEntitlements { if case .verified(let transaction) = entitlement { await service.grantEntitlement(transaction) } } }注意:AppStore.sync()不能让用户输入 Apple ID 密码。如果用户在当前设备上没有购买记录,系统弹窗会提示“已恢复”,你不能把它当作错误处理。另外,恢复购买成功后要回到购买页刷新按钮状态,否则用户以为没成功会反复点,导致队列里塞满 sync 请求。
4.5 多设备同步与延迟到账:Transaction 到达顺序不可控
现象:用户在同一 Apple ID 的另一台设备上购买,当前设备冷启动后没有解锁。
原因:StoreKit 的 Transaction 更新并不是实时推送,Transaction.updates只负责“到达本设备”的交易。新设备启动时,如果 App 没有主动检查Transaction.currentEntitlements或调AppStore.sync(),就不知道这个账号在其他设备买过什么。
解决:App 启动时同时做两件事:第一,检查Transaction.currentEntitlements并恢复该 Apple ID 的已有权益;第二,订阅产品要监听Transaction.updates里的续期事件。注意这两套逻辑要幂等,服务端按originalTransactionID去重,客户端重复调用grantEntitlement不会重复解锁。延迟到账的极端情况是用户在飞行模式下完成支付,transaction 会留在队列里等网络恢复,如果你看到“钱扣了但没发货”,先查队列里有没有未 finish 的 transaction。
5. 打造自己的内购支付工具层:封装、队列与状态机的设计
5.1 为什么需要自己的内购支付工具层:把 StoreKit 关进业务门外
直接在 SwiftUI 的 ViewModel 里写product.purchase()很快,但后面维护会越来越难受。内购涉及启动时监听、购买中状态、购买后服务端校验、恢复购买四件事,每个页面都要处理这三个时序,就会产生大量重复代码。我一般会在工程里单独建一个IAPService或IAPStore层,把 StoreKit 全部关在里面,业务层只看到几个干净的方法:
protocol IAPServicing { func purchase(_ productID: String) async throws func restore() async throws var currentEntitlements: AsyncStream<[String]> { get } }这个抽象带来的直接好处是:如果你要切到 StoreKit 1、或者接一个自己造的支付网关做灰度,业务层完全不用动。坏处是不要把协议设计得太细,StoreKit 2 的异步序列和验证结果一旦被协议吃掉太多类型,迁移时协议本身会变成绊脚石。我的习惯是协议里只暴露业务含义的方法,比如purchase、restore,不暴露Transaction、VerificationResult这些 StoreKit 类型,方便以后替换实现。
5.2 用状态机管理购买过程:idle、purchasing、pending、failed 的迁移规则
购买不是一个瞬时操作,服务端校验、网络请求都可能让流程中断几分钟。我见过团队用布尔变量同时记“正在购买”和“购买失败”,两个标志位组合出四态,结果漏了一种就翻车。更稳妥的是在工具层定义一个状态机。
enum PurchaseState: Equatable { case idle case purchasing(productID: String) case pending(productID: String) case purchased(productID: String, transactionID: String) case failed(productID: String, error: Error) case restoring }状态迁移规则如下:
- 发起购买时,从
.idle到.purchasing。 - 收到
.userCancelled,回到.idle。 - 收到
.pending,进入.pending,等待Transaction.updates推送真实交易。 - 收到
.success且服务端校验通过,进入.purchased。 - 校验失败或抛错,进入
.failed,并允许用户重试。 - 恢复购买开始到结束,走
.restoring,结束后回到.idle并刷新权益。
在IAPStore里的实现片段:
@MainActor final class IAPStore: ObservableObject { @Published private(set) var state: PurchaseState = .idle func buy(_ productID: String) async { state = .purchasing(productID: productID) do { let result = try await doPurchase(productID) switch result { case .pending: state = .pending(productID: productID) case .purchased(let transactionID): state = .purchased(productID: productID, transactionID: transactionID) case .cancelled: state = .idle } } catch { state = .failed(productID: productID, error: error) } } }这段代码里@MainActor是 Swift 并发下的 UI 安全边界,state是@Published,让 SwiftUI 直接订阅界面变化。.pending状态必须能被界面展示,比如显示“等待确认中”,不要把它当错误弹红色提示。.failed状态要带上 productID,因为用户可能在两个商品之间快速切换,如果没有 productID,界面不知道失败的是哪一单。
5.3 服务端二次校验的 Swift 实现:把 JWS 收据交给自己的后端
工具层里校验逻辑最容易写散。我的做法是在IAPService内维护一个ReceiptValidator,专门负责把 JWS 发往服务端并返回校验结论。下面是一个最小结构:
struct ReceiptValidationRequest: Encodable { let jws: String let transactionID: String let originalTransactionID: String } struct ReceiptValidationResponse: Decodable { let isValid: Bool let productID: String? let expirationDate: Date? } final class ReceiptValidator { let endpoint: URL init(endpoint: URL) { self.endpoint = endpoint } func validate(_ transaction: Transaction) async throws -> ReceiptValidationResponse { let request = ReceiptValidationRequest( jws: transaction.jwsRepresentation, transactionID: transaction.id, originalTransactionID: transaction.originalTransactionID ) var urlRequest = URLRequest(url: endpoint) urlRequest.httpMethod = "POST" urlRequest.httpBody = try JSONEncoder().encode(request) urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") let (data, _) = try await URLSession.shared.data(for: urlRequest) return try JSONDecoder().decode(ReceiptValidationResponse.self, from: data) } }服务端要做的事是:先用苹果的 JWKS 公钥验证 JWS 签名,再用originalTransactionID调 App Store 服务端 API 查询交易状态,最后比对productID与会话登录的用户是否一致。注意一个安全细节:不要把整个 JWS 原样存进服务端日志,只存transactionID和originalTransactionID的哈希,否则重放攻击时日志本身就是泄露源。ReceiptValidationResponse里的expirationDate是给订阅用的,非消耗品可以忽略,但最好统一在响应里带上,方便后续加订阅产品不换模型。
6. 用自动化测试撑住内购工具:沙盒账号矩阵与 Transaction 模拟
手动在沙盒上点购买容易漏场景,Xcode 14 之后内置的 StoreKit Test 框架能在本地模拟商品、订阅续期、退款、撤销,不依赖真实网络和沙盒账号。
6.1 用 StoreKit Test 配置文件做本地模拟:不依赖沙盒环境的自动化测试
在 Xcode scheme 里打开 StoreKit Configuration,选择一个.storekit文件,运行时 StoreKit 会使用本地配置替代 App Store。.storekit文件可以手动添加并编辑,也可以通过 Xcode 的 StoreKit Test 面板生成。常用配置项包括:商品列表、价格、订阅组、续期时长、撤销/退款的时间点。我用它做了两个测试场景:本地购买成功且服务端 mock 返回有效,用户取消后状态回到 idle。
func testPurchaseAndGrantEntitlement() async throws { let store = IAPStore() try await store.buy("com.example.premium") XCTAssertEqual(store.state, .purchased(productID: "com.example.premium")) }这个测试要求 scheme 里启用了 StoreKit Test,并且 mock 了ReceiptValidator,否则会真的发起网络请求。跑通后,续期、撤销、退款这几个场景都可以在本地模拟出来,不需要去 App Store Connect 反复创建沙盒账号。
6.2 上线前的内购回归清单:十个必须手点的场景
自动化测试补不到服务端交互和审核体验,因此上线前仍要手动过一遍清单:
| 场景 | 操作 | 预期 |
|---|---|---|
| 首次购买非消耗品 | 沙盒账号点击购买 | 解锁权益,二次点击不重复扣费 |
| 重复购买非消耗品 | 再次点击购买 | 弹窗提示“已购买”或直接恢复 |
| 购买取消 | 弹窗点击取消 | 状态回 idle,无日志异常 |
| 订阅首次购买 | 沙盒短周期订阅 | 能拿到 expirationDate |
| 订阅续期 | 使用本地模拟触发续期 | 第二次 Transaction 不重复发货 |
| 撤销/退款 | 本地模拟退款 | 权益被收回 |
| 恢复购买 | 点恢复,换设备 | 权益恢复且不重复 |
| 弱网 | 开飞行模式购买 | 无崩溃,出现等待状态 |
| 服务端校验失败 | mock 后端返回 500 | 客户端提示稍后重试,状态 failed |
| 审核路径 | 从设置页进入会员 | 能看到“恢复购买”按钮 |
最后两条必须手动。我自己的习惯是每次提审前把十条完整跑一遍,哪怕只改了一行 UI 代码也跑,因为内购工具层的状态机一旦被 UI 改动干扰,出错时定位成本极高。希望这些配置、代码和踩坑记录能帮到你。
本文还有配套的精品资源,点击获取