fix(mobile): use product IDs for Apple IAP verification

This commit is contained in:
DIYgod 2026-07-30 19:27:59 +08:00
parent dd2c933936
commit a27db571b3
3 changed files with 142 additions and 15 deletions

View File

@ -14,6 +14,12 @@ import { proxyEnv } from "@/src/lib/proxy-env"
import { queryClient } from "@/src/lib/query-client"
import { toast } from "@/src/lib/toast"
import {
buildAppleVerificationRequest,
isKnownAppleSubscriptionPurchase,
selectSignedTransactionInfo,
} from "./apple-iap-purchase"
const billingSubscriptionQueryKey = ["billingSubscription"]
type BillingSubscriptionResponse = {
@ -123,16 +129,23 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => {
const verifyPurchase = useCallback(
async (purchase: Purchase) => {
const productId = purchase.id
const jwsRepresentation =
purchase.purchaseToken ||
(await getTransactionJwsIOS(productId).catch(() => null)) ||
(await validateReceipt({ apple: { sku: productId } })
.then((result) => ("jwsRepresentation" in result ? result.jwsRepresentation : undefined))
.catch(() => {}))
const productId = purchase.productId
let signedTransactionInfo = selectSignedTransactionInfo(purchase.purchaseToken)
if (!jwsRepresentation) {
throw new Error(t("subscription.actions.upgrade_error"))
if (!signedTransactionInfo) {
signedTransactionInfo = selectSignedTransactionInfo(
await getTransactionJwsIOS(productId).catch(() => null),
)
}
if (!signedTransactionInfo) {
signedTransactionInfo = selectSignedTransactionInfo(
await validateReceipt({ apple: { sku: productId } })
.then((result) =>
"jwsRepresentation" in result ? result.jwsRepresentation : undefined,
)
.catch(() => undefined),
)
}
const response = await followClient.request<{
@ -140,23 +153,21 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => {
data: BillingSubscriptionResponse
}>("/billing/apple/verify", {
method: "POST",
body: {
signedTransactionInfo: jwsRepresentation,
},
body: buildAppleVerificationRequest(purchase, signedTransactionInfo),
})
if (response.code !== 0) {
throw new Error("Failed to verify Apple subscription")
}
},
[t, validateReceipt],
[validateReceipt],
)
useEffect(() => {
if (
Platform.OS !== "ios" ||
!currentPurchase ||
!knownSubscriptionIds.has(currentPurchase.id)
!isKnownAppleSubscriptionPurchase(currentPurchase, knownSubscriptionIds)
) {
return
}
@ -280,7 +291,7 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => {
await new Promise((resolve) => setTimeout(resolve, 300))
const restoredPurchases = availablePurchasesRef.current.filter((purchase) =>
knownSubscriptionIds.has(purchase.id),
isKnownAppleSubscriptionPurchase(purchase, knownSubscriptionIds),
)
if (restoredPurchases.length === 0) {

View File

@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest"
import {
buildAppleVerificationRequest,
isCompactJws,
isKnownAppleSubscriptionPurchase,
selectSignedTransactionInfo,
} from "./apple-iap-purchase"
describe("Apple IAP purchase identifiers", () => {
it("matches subscriptions by product ID instead of transaction ID", () => {
const knownSubscriptionIds = new Set(["is.follow.basic.monthly"])
const purchase = {
id: "2000001234567890",
productId: "is.follow.basic.monthly",
}
expect(isKnownAppleSubscriptionPurchase(purchase, knownSubscriptionIds)).toBe(true)
})
it("builds verification hints from transaction identifiers", () => {
const request = buildAppleVerificationRequest(
{
id: "2000001234567890",
originalTransactionIdentifierIOS: "2000001000000000",
productId: "is.follow.basic.monthly",
transactionId: "2000001234567890",
},
"header.payload.signature",
)
expect(request).toEqual({
originalTransactionId: "2000001000000000",
signedTransactionInfo: "header.payload.signature",
transactionId: "2000001234567890",
})
})
it("falls back to the purchase ID when transactionId is absent", () => {
expect(
buildAppleVerificationRequest({
id: "2000001234567890",
productId: "is.follow.basic.monthly",
}),
).toEqual({
originalTransactionId: undefined,
signedTransactionInfo: undefined,
transactionId: "2000001234567890",
})
})
it("does not submit a transaction ID as signed transaction info", () => {
expect(isCompactJws("2000001234567890")).toBe(false)
expect(selectSignedTransactionInfo("2000001234567890", null, "header.payload.signature")).toBe(
"header.payload.signature",
)
expect(
buildAppleVerificationRequest(
{
id: "2000001234567890",
productId: "is.follow.basic.monthly",
purchaseToken: "2000001234567890",
},
"2000001234567890",
),
).toEqual({
originalTransactionId: undefined,
signedTransactionInfo: undefined,
transactionId: "2000001234567890",
})
})
})

View File

@ -0,0 +1,44 @@
export type ApplePurchaseIdentity = {
id: string
originalTransactionIdentifierIOS?: string | null
productId: string
purchaseToken?: string | null
transactionId?: string | null
}
export type AppleVerificationRequest = {
originalTransactionId?: string
signedTransactionInfo?: string
transactionId?: string
}
const compactJwsSegmentPattern = /^[\w-]+$/
export const isCompactJws = (value?: string | null): value is string => {
if (!value) {
return false
}
const segments = value.split(".")
return (
segments.length === 3 &&
segments.every((segment) => segment.length > 0 && compactJwsSegmentPattern.test(segment))
)
}
export const selectSignedTransactionInfo = (...candidates: Array<string | null | undefined>) =>
candidates.find(isCompactJws)
export const isKnownAppleSubscriptionPurchase = (
purchase: Pick<ApplePurchaseIdentity, "productId">,
knownSubscriptionIds: ReadonlySet<string>,
) => knownSubscriptionIds.has(purchase.productId)
export const buildAppleVerificationRequest = (
purchase: ApplePurchaseIdentity,
signedTransactionInfo?: string | null,
): AppleVerificationRequest => ({
originalTransactionId: purchase.originalTransactionIdentifierIOS || undefined,
signedTransactionInfo: isCompactJws(signedTransactionInfo) ? signedTransactionInfo : undefined,
transactionId: purchase.transactionId || purchase.id || undefined,
})