lemoba/mobile-monetization 问题修复 & 功能扩展

解决BUG、新增功能、兼容多环境部署,快速响应你的开发需求

邮箱:yvsm@zunyunkeji.com | QQ:316430983 | 微信:yvsm316

lemoba/mobile-monetization

Composer 安装命令:

composer require lemoba/mobile-monetization

包简介

Laravel package for Apple/Google login verification, mobile IAP validation, Unity LevelPlay rewarded ad callbacks, and Firebase Cloud Messaging.

README 文档

README

Laravel 扩展包,用于移动端短剧/内容应用常见的后端验证能力:

  • iOS Sign in with Apple 登录 token 验证
  • Android Google 登录 ID token 验证
  • iOS App Store / Android Google Play 内购验证
  • Unity LevelPlay 激励广告 S2S 回调验签
  • Firebase Cloud Messaging iOS / Android 消息推送

本包只做「可信验证、签名校验、接口封装、结果归一化、推送发送」,不创建数据表,不写数据库,不给用户加金币,不开通 VIP,不解锁视频。订单幂等、金币流水、会员权益、短剧解锁、推送 token 保存等业务逻辑全部由调用方完成。

安装

通过 Composer 安装:

composer require lemoba/mobile-monetization

发布配置:

php artisan vendor:publish --tag=mobile-monetization-config

环境变量

MOBILE_MONETIZATION_CACHE_STORE=redis
MOBILE_MONETIZATION_CACHE_PREFIX=mobile_monetization
MOBILE_MONETIZATION_JWKS_TTL=3600
MOBILE_MONETIZATION_OAUTH_TOKEN_TTL=3300

APPLE_BUNDLE_ID=com.example.app
APPLE_TEAM_ID=YOUR_APPLE_TEAM_ID
APPLE_ISSUER_ID=YOUR_APP_STORE_CONNECT_ISSUER_ID
APPLE_CLIENT_ID=com.example.app
APPLE_KEY_ID=ABC123DEFG
APPLE_PRIVATE_KEY_PATH=/secure/AuthKey_ABC123DEFG.p8
APPLE_PROMOTIONAL_OFFER_KEY_ID=PROMO12345
APPLE_PROMOTIONAL_OFFER_PRIVATE_KEY_PATH=/secure/SubscriptionKey_PROMO12345.p8
APPLE_IAP_ENVIRONMENT=production

GOOGLE_ANDROID_CLIENT_IDS=android-oauth-client-id.apps.googleusercontent.com
GOOGLE_PLAY_PACKAGE_NAME=com.example.app
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH=/secure/google-play-service-account.json

MOBILE_COIN_PRODUCT_IDS=coins_60,coins_300,coins_980
MOBILE_VIP_WEEK_PRODUCT_ID=vip_week
MOBILE_VIP_MONTH_PRODUCT_ID=vip_month
MOBILE_VIP_YEAR_PRODUCT_ID=vip_year

LEVELPLAY_SECRET=your_levelplay_secret

FCM_ANDROID_PROJECT_ID=android-firebase-project-id
FCM_ANDROID_SERVICE_ACCOUNT_JSON_PATH=/secure/firebase-android-service-account.json
FCM_IOS_PROJECT_ID=ios-firebase-project-id
FCM_IOS_SERVICE_ACCOUNT_JSON_PATH=/secure/firebase-ios-service-account.json
FCM_DEFAULT_PLATFORM=android
FCM_TIMEOUT=15

配置文件会按职责发布到主项目:

config/mobile-monetization.php  # Redis cache store、cache key 前缀、TTL
config/mobile-auth.php          # Apple / Google 登录
config/mobile-payments.php      # App Store / Google Play 支付
config/mobile-ads.php           # LevelPlay 广告
config/mobile-push.php          # FCM 推送,Android/iOS 两套 Firebase 文件

路由

本包不注册任何默认路由,由调用方在主项目中自行定义路由和控制器。示例:

use Illuminate\Support\Facades\Route;
use Lemoba\MobileMonetization\Facades\MobileMonetization;

Route::post('/auth/apple', function () {
    $data = request()->validate([
        'identity_token' => ['required', 'string'],
        'nonce' => ['nullable', 'string'],
    ]);

    return MobileMonetization::verifyAppleIdentityToken(
        $data['identity_token'],
        $data['nonce'] ?? null
    );
});

缓存

JWKS、公钥集合、Google Play OAuth token、App Store Server API bearer token、FCM OAuth token 都会走 Laravel Cache,并默认使用 Redis:

// config/mobile-monetization.php
'cache' => [
    'store' => env('MOBILE_MONETIZATION_CACHE_STORE', 'redis'),
    'key_prefix' => env('MOBILE_MONETIZATION_CACHE_PREFIX', 'mobile_monetization'),
    'jwks_ttl' => 3600,
    'oauth_token_ttl' => 3300,
],

调用方可以改 store 使用任意 Laravel cache store,但生产环境建议 Redis。

登录验证

Apple:

use Lemoba\MobileMonetization\Facades\MobileMonetization;

$identity = MobileMonetization::verifyAppleIdentityToken($identityToken, $nonce);

Google:

use Lemoba\MobileMonetization\Facades\MobileMonetization;

$identity = MobileMonetization::verifyGoogleIdToken($idToken, $nonce);

返回字段包含:

[
    'provider' => 'apple',
    'provider_user_id' => '...',
    'email' => '...',
    'email_verified' => true,
    'claims' => [],
]

调用方应该用 provider + provider_user_id 去绑定或创建自己的用户。

provider_user_id 来自 Apple / Google ID token 的 sub 字段。本包会强制校验 sub,如果 token 中没有 sub 会直接抛出异常,不会返回空的 provider_user_id

支付验证

iOS App Store:

use Lemoba\MobileMonetization\Facades\MobileMonetization;

$purchase = MobileMonetization::verifyAppleTransactionId($transactionId);

// 或者客户端已拿到 signedTransactionInfo:
$purchase = MobileMonetization::verifyAppleSignedTransaction($signedTransactionInfo);

iOS App Store 订阅优惠签名:

use Lemoba\MobileMonetization\Facades\MobileMonetization;

// StoreKit 1 / StoreKit 2 Product.SubscriptionOffer.Signature 使用。
$offer = MobileMonetization::applePromotionalOfferSignature(
    productIdentifier: 'vip_month',
    subscriptionOfferId: 'intro_month_50',
    appAccountToken: (string) $user->id, // 如果客户端传 UUID,这里传同一个 UUID。
);

// 返回:
// [
//     'keyIdentifier' => 'PROMO12345',
//     'nonce' => '47f8a4b5-5957-4d56-bf0f-c7416f33c701',
//     'timestamp' => 1714567890123,
//     'signature' => 'base64-encoded-signature',
// ]

applePromotionalOfferSignature() 参数:

MobileMonetization::applePromotionalOfferSignature(
    string $productIdentifier,
    string $subscriptionOfferId,
    string $appAccountToken = '',
    ?string $nonce = null,
    ?int $timestamp = null,
);
  • productIdentifier:App Store Connect 中的订阅商品 ID,例如 vip_month
  • subscriptionOfferId:App Store Connect 中配置的 promotional offer identifier,例如 intro_month_50
  • appAccountToken:与客户端购买时传入的 app account token 保持一致;如果客户端不传,可以留空。
  • nonce:可选,不传时服务端自动生成 UUID。
  • timestamp:可选,毫秒时间戳,不传时服务端自动生成。

客户端按 Apple StoreKit API 使用返回字段即可:

[
    'identifier' => 'intro_month_50',
    'keyIdentifier' => $offer['keyIdentifier'],
    'nonce' => $offer['nonce'],
    'signature' => $offer['signature'],
    'timestamp' => $offer['timestamp'],
]

如配置了 APPLE_PROMOTIONAL_OFFER_KEY_ID / APPLE_PROMOTIONAL_OFFER_PRIVATE_KEY_PATH,会优先使用订阅优惠专用密钥;否则回退到 APPLE_KEY_ID / APPLE_PRIVATE_KEY_PATH

新版 StoreKit 2 promotional offer compact JWS:

use Lemoba\MobileMonetization\Facades\MobileMonetization;

$compactJws = MobileMonetization::applePromotionalOfferJws(
    productId: 'vip_month',
    offerIdentifier: 'intro_month_50',
    transactionId: $originalTransactionId, // 可选。
);

applePromotionalOfferJws() 参数:

MobileMonetization::applePromotionalOfferJws(
    string $productId,
    string $offerIdentifier,
    ?string $transactionId = null,
    ?string $nonce = null,
);
  • productId:App Store Connect 中的订阅商品 ID。
  • offerIdentifier:App Store Connect 中配置的 promotional offer identifier。
  • transactionId:可选,通常传原始订阅交易 ID。
  • nonce:可选,不传时服务端自动生成 UUID。

Android Google Play 一次性消耗商品:

一次性消耗商品适合金币、钻石、体力、道具包等可以反复购买的商品。服务端必须先请求 Google Play Developer API 验证 purchaseToken,验单成功后还要调用 Google 的 consume 接口;否则 Google Play 侧该购买 token 未被消费,同一个商品可能无法再次购买。

推荐使用 verifyAndConsumeGoogleProduct() 一次完成“验单 + 消费”:

use Lemoba\MobileMonetization\Facades\MobileMonetization;

$purchase = MobileMonetization::verifyAndConsumeGoogleProduct(
    productId: 'coins_100',
    purchaseToken: $purchaseToken,
);

if ($purchase->valid) {
    // 1. 用 transaction_id 做唯一索引或幂等锁。
    // 2. 根据 product_id 查自己的金币配置。
    // 3. 写订单、写金币流水、增加余额。
}

verifyAndConsumeGoogleProduct() 的行为:

  • 先调用 Google Play Developer API 验证一次性商品 purchaseToken
  • 只有 Google 返回已购买状态时,才会继续调用 consumeGoogleProduct()
  • 返回 VerifiedPurchase,调用方仍然要按 transaction_id 做业务幂等。
  • consume 只代表释放 Google Play 侧的再次购买能力,不代表你的业务已经发货;发货仍然由调用方自己完成。

如果业务需要把“验单、入库、发货、消费”拆成自己的事务流程,也可以手动调用:

$purchase = MobileMonetization::verifyGoogleProduct(
    productId: 'coins_100',
    purchaseToken: $purchaseToken,
);

if ($purchase->valid) {
    // 调用方完成自己的幂等入库 / 发货流程。

    MobileMonetization::consumeGoogleProduct(
        productId: 'coins_100',
        purchaseToken: $purchaseToken,
    );
}

一次性商品注意事项:

  • 消耗型商品必须 consume,不需要订阅那种续期状态维护。
  • consumeGoogleProduct() 只用于一次性商品,不用于订阅。
  • 不要只信任客户端返回结果;服务端必须使用 purchaseToken 请求 Google 接口验单。
  • 发货前要用 transaction_id 或订单号建唯一索引,避免客户端重复请求导致重复发货。
  • 如果使用手动流程,建议只有在本地订单和发货幂等逻辑已经处理完成后,再调用 consume。

Android Google Play VIP 周/月/年订阅:

订阅适合 VIP 周卡、月卡、年卡等周期性权益。订阅不是消耗品,不需要也不能调用 consumeGoogleProduct()。服务端要做的是验证 purchaseToken,读取 Google 返回的订阅状态和到期时间,然后更新自己系统里的会员权益。

$purchase = MobileMonetization::verifyGoogleSubscription(
    subscriptionId: 'vip_month',
    purchaseToken: $purchaseToken,
);

if ($purchase->active()) {
    // 1. 用 original_transaction_id 或 transaction_id 做幂等。
    // 2. 用 expires_at_ms 更新自己的会员到期时间。
    // 3. 保存 raw 字段,方便排查 Google Play 返回的原始状态。
}

订阅返回的 VerifiedPurchase 里,常用字段包括:

  • product_id:Google Play Console 中的订阅商品 ID。
  • transaction_id:Google 返回的最新订单 ID;没有时回退到 purchaseToken
  • original_transaction_id:关联购买 token 或最新订单 ID;可用于串联同一订阅链路。
  • valid:当前订阅是否处于可用状态。
  • active()valid 为真,且未过期时返回真。
  • expires_at_ms:当前订阅周期到期时间,毫秒时间戳。
  • raw:Google Play Developer API 的完整响应。

订阅注意事项:

  • 订阅不调用 consumeGoogleProduct()
  • 订阅权益应按 expires_at_ms 更新,不要只保存“已购买”布尔值。
  • 订阅可能续期、宽限期、过期、取消、换档,建议保留 raw 方便后续排查。
  • 当前包提供订阅验单和优惠校验;如果业务要求服务端 acknowledge 订阅,需要使用 Google 订阅专用 acknowledge 接口,不能使用 acknowledgeGoogleProduct()

Android Google Play 订阅优惠:

Google Play 订阅优惠和 Apple promotional offer 不一样。Apple 需要服务端生成签名;Google Play 订阅优惠不需要服务端签名,服务端也拿不到一个需要签名后返回给客户端的优惠参数。

Google Play 订阅优惠流程:

  1. 客户端通过 Play Billing 查询 ProductDetails
  2. 客户端从 ProductDetails.SubscriptionOfferDetails 中选择一个优惠。
  3. 客户端使用该优惠的 offerToken 发起购买。
  4. 购买成功后,客户端把 Google 返回的 purchaseToken 发给服务端。
  5. 服务端调用 verifyGoogleSubscriptionOffer() 验证该购买确实来自期望的 basePlanId / offerId

服务端示例:

$offer = MobileMonetization::verifyGoogleSubscriptionOffer(
    subscriptionId: 'vip_month',
    purchaseToken: $purchaseToken,
    expectedBasePlanId: 'monthly',
    expectedOfferId: 'intro_month_50',
);

if ($offer['purchase']->active()) {
    // 确认 base_plan_id / offer_id 匹配后,更新会员权益。
    $offer['base_plan_id']; // monthly
    $offer['offer_id'];     // intro_month_50
    $offer['offer_tags'];   // Google Play Console 配置的标签
}

verifyGoogleSubscriptionOffer() 返回:

[
    'purchase' => $purchase,
    'base_plan_id' => 'monthly',
    'offer_id' => 'intro_month_50',
    'offer_tags' => [],
    'pricing_phase' => null,
    'raw_offer_details' => [],
]

如果传入了 expectedBasePlanIdexpectedOfferId,服务端会与 Google 返回的 offerDetails 对比;不一致会抛出异常,不应该给用户发放该优惠权益。

统一返回对象:

$purchase->toArray();

关键字段:

[
    'platform' => 'ios|android',
    'product_id' => 'coins_60',
    'transaction_id' => '...',
    'original_transaction_id' => '...',
    'type' => 'consumable|subscription',
    'valid' => true,
    'active' => true,
    'consumable' => true,
    'purchased_at_ms' => 1710000000000,
    'expires_at_ms' => null,
    'raw' => [],
]

建议调用方业务处理:

if ($purchase->valid && $purchase->consumable) {
    // 1. 用 transaction_id 建唯一索引或幂等锁。
    // 2. 根据 product_id 查自己的金币配置。
    // 3. 写订单、写金币流水、增加余额。
}

if ($purchase->active() && $purchase->type === 'subscription') {
    // 1. 用 original_transaction_id 关联订阅。
    // 2. 用 expires_at_ms 更新 VIP 到期时间。
}

LevelPlay 激励广告

在 LevelPlay 后台配置 S2S Rewarded Video Callback URL:

https://your-domain.com/your-levelplay-callback

本包不提供默认 HTTP 控制器,也不注册默认路由。调用方需要在主项目中自行创建回调入口,调用本包完成验签,并保存 event_id 做唯一幂等。

业务控制器示例:

use Illuminate\Http\Request;
use Lemoba\MobileMonetization\Facades\MobileMonetization;

public function reward(Request $request)
{
    $reward = MobileMonetization::verifyLevelPlayRewardCallback($request);

    // 调用方业务逻辑:
    // 1. 用 event_id 做唯一幂等,event_id 对应 LevelPlay eventId。
    // 2. 用 user_id/app_user_id 或 dynamic_user_id 映射自己的用户。
    // 3. 用 order_id 关联前端传入的自定义订单号(如果配置了 customParameters)。
    // 4. 根据 reward_amount 或自己的广告奖励配置给金币。
    // 5. 写金币流水、余额变化、任务记录等。

    return response(MobileMonetization::levelPlayOkResponse($reward['event_id']), 200)
        ->header('Content-Type', 'text/plain');
}

本地测试如果不方便生成 LevelPlay 签名,可以传入第二个参数 true 开启 dev 模式,跳过 LEVELPLAY_SECRETsignature 校验:

$reward = MobileMonetization::verifyLevelPlayRewardCallback($request, dev: true);

verifyRewardCallback() 返回:

[
    'event_id' => '...',
    'user_id' => '...',
    'app_user_id' => '...',
    'dynamic_user_id' => '...',
    'reward_item' => 'coins',
    'reward_amount' => 10,
    'rewards' => '10',
    'country' => 'SG',
    'publisher_sub_id' => '0',
    'custom_parameters' => [
        'order_id' => 'ORD-20260429-001',
    ],
    'order_id' => 'ORD-20260429-001',
    'ad_unit' => '...',
    'placement' => '...',
    'network' => '...',
    'timestamp' => 1710000000,
    'raw' => [],
]

Firebase Cloud Messaging 推送

由于 Android 和 iOS 不在同一个 Firebase 后台,本包在 config/mobile-push.php 中分别配置两套 service account:

'fcm' => [
    'android' => [
        'project_id' => env('FCM_ANDROID_PROJECT_ID'),
        'service_account_json_path' => env('FCM_ANDROID_SERVICE_ACCOUNT_JSON_PATH'),
    ],
    'ios' => [
        'project_id' => env('FCM_IOS_PROJECT_ID'),
        'service_account_json_path' => env('FCM_IOS_SERVICE_ACCOUNT_JSON_PATH'),
    ],
],

发送到单个设备 token:

use Lemoba\MobileMonetization\Facades\MobileMonetization;

$message = MobileMonetization::sendFcmToToken(
    platform: 'ios',
    token: $deviceToken,
    title: 'VIP 到期提醒',
    body: '你的会员即将到期',
    data: [
        'type' => 'vip_expiring',
        'user_id' => (string) $userId,
    ],
    options: [
        'apns' => [
            'payload' => [
                'aps' => [
                    'sound' => 'default',
                ],
            ],
        ],
    ]
);

$message->toArray();

发送到 Android:

$message = MobileMonetization::sendFcmToToken(
    platform: 'android',
    token: $deviceToken,
    title: '金币到账',
    body: '看广告奖励已发放',
    data: [
        'type' => 'coins_granted',
        'amount' => '10',
    ],
    options: [
        'android' => [
            'priority' => 'HIGH',
        ],
    ]
);

发送到 topic:

$message = MobileMonetization::sendFcmToTopic(
    platform: 'android',
    topic: 'vip_users',
    title: '新剧上线',
    body: '会员可抢先观看'
);

data 会统一转成字符串值,符合 FCM HTTP v1 对 data payload 的要求。FCM OAuth token 会按平台和 service account 缓存在 Redis 中。

数据库说明

本包没有迁移文件,也不会调用 DB、Model 或 Schema。推荐调用方自行维护这些业务表或存储:

  • 用户第三方登录绑定表
  • 充值订单表
  • 内购交易幂等表
  • 金币钱包表
  • 金币流水表
  • VIP 订阅表
  • LevelPlay 广告事件表
  • 短剧视频解锁表
  • 设备推送 token 表

统计信息

  • 总下载量: 182
  • 月度下载量: 0
  • 日度下载量: 0
  • 收藏数: 1
  • 点击次数: 1
  • 依赖项目数: 0
  • 推荐数: 0

GitHub 信息

  • Stars: 1
  • Watchers: 0
  • Forks: 0
  • 开发语言: PHP

其他信息

  • 授权协议: MIT
  • 更新时间: 2026-04-30

承接程序开发

PHP开发

VUE

Vue开发

前端开发

小程序开发

公众号开发

系统定制

数据库设计

云部署

网站建设

安全加固