iPhone 应用如何按每封邮件收取 .50 USD
结论
可以把”发送一封邮件”设计成一个消耗型 App 内购买项目(Consumable In-App Purchase)。用户每购买一次,就会获得 1 个发送额度;邮件成功提交后,再消耗这个额度。
需要注意两点:
- StoreKit 不能直接从银行卡任意扣取
$0.50。你需要在 App Store Connect 中创建消耗型商品,并选择 Apple 提供的价格点。是否有正好0.50 USD的价格点,以当前 App Store Connect 中显示的选项为准。 - 如果邮件通过 iPhone 的系统邮件编辑器发送,应用很难可靠地确认邮件是否真正送达。更稳妥的做法是由自己的服务器发送邮件,并在服务端核销发送额度。
如果收费对应数字功能,通常应优先使用 App 内购买。审核要求和可用价格点可能发生变化,因此上线前要核对最新的 App Review Guidelines 和 App Store Connect 配置说明。
推荐的业务流程
业务流程可以这样设计:
-
在 App Store Connect 中创建消耗型商品,例如:
Product ID: com.example.app.email_credit Type: Consumable Display Name: One Email Credit -
用户点击”发送邮件”。
-
App 检查用户是否还有可用额度。
-
如果没有额度,通过 StoreKit 购买一个消耗型商品。
-
交易验证成功后,为用户增加 1 个额度。
-
将邮件交给服务器发送。
-
服务器接受发送请求后,通过原子操作扣除 1 个额度。
-
客户端调用
transaction.finish()完成 StoreKit 交易。
不要只用 UserDefaults 在本地保存余额。卸载应用、修改本地数据、并发请求或网络中断,都可能导致额度丢失或被重复使用。
StoreKit 2 购买示例
下面是一个简化的 Swift 示例,用于演示商品加载和购买流程。生产环境还需要加入服务端交易验证和防重复发放机制。
import StoreKit
@MainActor
final class EmailCreditStore: ObservableObject {
private let productID = "com.example.app.email_credit"
@Published private(set) var product: Product?
@Published private(set) var isPurchasing = false
func loadProduct() async throws {
let products = try await Product.products(for: [productID])
guard let product = products.first else {
throw PurchaseError.productNotFound
}
self.product = product
}
func purchaseOneCredit() async throws {
guard let product else {
throw PurchaseError.productNotLoaded
}
isPurchasing = true
defer { isPurchasing = false }
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
/*
将 transaction.id、transaction.originalID、
transaction.productID 等信息发送到服务器验证。
服务器确认该 transaction.id 尚未处理后,
再为用户增加一个邮件额度。
*/
try await grantCreditOnServer(for: transaction)
// 只有在服务器成功记录交易后再结束交易
await transaction.finish()
case .pending:
throw PurchaseError.pending
case .userCancelled:
throw PurchaseError.cancelled
@unknown default:
throw PurchaseError.unknownResult
}
}
private func checkVerified<T>(
_ result: VerificationResult<T>
) throws -> T {
switch result {
case .verified(let value):
return value
case .unverified:
throw PurchaseError.failedVerification
}
}
private func grantCreditOnServer(
for transaction: StoreKit.Transaction
) async throws {
// 示例占位:
// 通过 HTTPS 将交易标识发送给你的服务器。
// 服务器应验证交易并使用 transaction.id 做幂等处理。
}
}
enum PurchaseError: Error {
case productNotFound
case productNotLoaded
case failedVerification
case pending
case cancelled
case unknownResult
}
界面应显示 StoreKit 返回的本地化价格,不要写死 $0.50:
if let product = store.product {
Text("购买 1 次发送额度:\(product.displayPrice)")
}
不同国家或地区使用的货币、税费和价格可能不同,因此应向用户显示 displayPrice。
监听未完成的交易
应用启动后还应监听交易更新。这样即使支付延期、应用被终止或网络中断,也能继续处理已经付款但尚未发放额度的交易。
import StoreKit
func observeTransactions() -> Task<Void, Never> {
Task.detached {
for await result in Transaction.updates {
do {
let transaction: Transaction
switch result {
case .verified(let verifiedTransaction):
transaction = verifiedTransaction
case .unverified:
continue
}
// 将交易提交到服务器。
// 服务器通过 transaction.id 保证同一笔交易只发放一次。
try await submitTransactionToServer(transaction)
await transaction.finish()
} catch {
// 暂时不要 finish,让系统之后继续提供该交易。
}
}
}
}
func submitTransactionToServer(
_ transaction: Transaction
) async throws {
// 实现 HTTPS 请求和服务端验证
}
邮件发送方案
方案一:由服务器发送,推荐
客户端把收件人、主题和正文提交给服务器,服务器负责:
- 验证登录用户。
- 检查可用额度。
- 校验收件人、正文长度和发送频率。
- 在数据库事务中扣除一个额度,同时创建发送任务。
- 调用邮件服务商发送邮件。
- 保存发送状态和审计记录。
扣除额度和创建发送任务都必须具备幂等性。例如,每次请求都携带唯一的 requestId,避免用户重复点击或客户端重试时被扣费两次。
{
"requestId": "4BB7D869-1E30-41A2-97EC-A17EF93405B8",
"to": "recipient@example.com",
"subject": "Hello",
"body": "Message body"
}
服务端要保证同一个 requestId 最多只能创建一次发送任务。
产品还需要提前定义”发送成功”的含义。通常,这只能表示邮件服务商已经接受投递请求,并不代表收件人一定收到或阅读了邮件。如果发生永久退信,是否返还额度也要在产品规则中写清楚。
方案二:使用系统邮件编辑器
如果必须让用户通过系统邮件账户发送,可以使用 MFMailComposeViewController:
import MessageUI
import UIKit
final class MailViewController: UIViewController,
MFMailComposeViewControllerDelegate {
func presentMailComposer() {
guard MFMailComposeViewController.canSendMail() else {
// 当前设备没有配置可用的邮件账户
return
}
let composer = MFMailComposeViewController()
composer.mailComposeDelegate = self
composer.setToRecipients(["recipient@example.com"])
composer.setSubject("Subject")
composer.setMessageBody("Message body", isHTML: false)
present(composer, animated: true)
}
func mailComposeController(
_ controller: MFMailComposeViewController,
didFinishWith result: MFMailComposeResult,
error: Error?
) {
controller.dismiss(animated: true)
switch result {
case .sent:
// 可以认为用户已将邮件提交给系统邮件组件,
// 但这不能证明邮件最终送达。
break
case .saved:
break
case .cancelled:
break
case .failed:
break
@unknown default:
break
}
}
}
这种方式有几个明显的限制:
- 用户可能取消发送,因此不能在打开编辑器时直接扣除额度。
.sent只表示邮件已提交给系统邮件组件,不代表邮件已经送达。- 应用无法控制用户之后通过其他邮件客户端发送邮件的行为。
- 如果用户付款后取消邮件,需要决定是保留额度还是退款。通常,保留额度供下次使用更合理。
因此,更合适的处理方式是先购买额度,在实际提交发送时再消耗额度,不要把一次购买与一次邮件编辑器展示直接绑定。
价格和收入并不相同
即使商店中有 0.50 USD 的价格点,开发者实际获得的收入通常也不是 $0.50,因为其中可能涉及:
- Apple 的佣金;
- 当地税费;
- 不同地区的价格和汇率;
- 退款、撤销和家庭成员付款审批;
- App Store 的结算规则。
产品描述应写成”用户支付一个商店价格点,购买一次发送额度”,不要把 $0.50 当成每次发送的固定净收入。
上线前需要处理的问题
- 使用服务端交易验证,并通过
transaction.id防止重复发放额度。 - 在服务器确认交易前,不要调用
transaction.finish()。 - 邮件发送请求必须使用登录鉴权、HTTPS、限流和幂等键。
- 明确邮件发送失败、退信或取消时是否返还额度。
- 在购买按钮附近说明商品内容,并显示 StoreKit 提供的实际价格。
- 提供隐私政策,说明如何处理收件人地址、邮件正文和发送记录。
- 加入反垃圾邮件机制,防止应用被用于批量发送未经同意的邮件。
- 在沙盒环境和 TestFlight 中测试购买取消、付款待处理、断网、重复回调和应用被强制终止等情况。
- 提交审核前检查最新的 App Review Guidelines、StoreKit 文档 和 App Store Server API。
备注:内容仅供参考。
