浏览代码

google ios 登录
ios iap

baxi 5 天之前
父节点
当前提交
6fb1d06a36

+ 3 - 0
assets/scripts/platform/FaceBook/FBNativeImpl.ts

@@ -29,6 +29,7 @@ interface NativeResponse<T = Record<string, never>> {
 interface LoginData {
     accessToken?: string;
     userId?: string;
+    name?: string;
     authenticationToken?: string;
     nonce?: string;
 }
@@ -96,6 +97,8 @@ export class FBNativeImpl extends FacebookImpl {
 
             return {
                 accessToken,
+                userId: response.data?.userId,
+                name: response.data?.name,
                 ...(authenticationToken ? {
                     authenticationToken,
                     nonce: response.data?.nonce,

+ 3 - 0
assets/scripts/platform/FaceBook/FacebookImpl.ts

@@ -1,5 +1,8 @@
 export interface IFBLoginRes {
     accessToken: string;
+    /** SDK 返回的用户资料,仅用于展示;服务端仍需验证登录凭证。 */
+    userId?: string;
+    name?: string;
     /** iOS Limited Login JWT; never use as a Graph API access token. */
     authenticationToken?: string;
     tokenType?: "access" | "authentication";

+ 3 - 8
assets/scripts/platform/Google/Google.ts

@@ -1,17 +1,12 @@
 import { ANDROID, IOS } from "cc/env";
 import { GoogleImpl } from "./GoogleImpl";
-import { GoogleAndroidImpl } from "./GoogleAndroidImpl";
-import { GoogleIOSImpl } from "./GoogleIOSImpl";
+import { GoogleNativeImpl } from "./GoogleNativeImpl";
 import { GoogleH5Impl } from "./GoogleH5Impl";
 
 
 function getGoogleImpl(): GoogleImpl {
-    if (ANDROID) {
-        return new GoogleAndroidImpl();
-    }
-
-    if (IOS) {
-        return new GoogleIOSImpl();
+    if (ANDROID || IOS) {
+        return new GoogleNativeImpl();
     }
 
     return new GoogleH5Impl();

+ 0 - 15
assets/scripts/platform/Google/GoogleIOSImpl.ts

@@ -1,15 +0,0 @@
-import { GoogleImpl, IGoogleLoginRes } from "./GoogleImpl";
-
-export class GoogleIOSImpl extends GoogleImpl {
-    init() {
-        console.log("[Google IOS] Method not implemented.");
-    }
-
-    protected onLogin(): Promise<IGoogleLoginRes> {
-        throw new Error("[Google IOS] Method not implemented.");
-    }
-
-    logOut(): Promise<boolean> {
-        throw new Error("[Google IOS] Method not implemented.");
-    }
-}

+ 3 - 0
assets/scripts/platform/Google/GoogleImpl.ts

@@ -2,6 +2,9 @@ import { Node } from "cc";
 
 export interface IGoogleLoginRes {
     accessToken: string;
+    userId?: string;
+    name?: string;
+    email?: string;
     isCancel: boolean;
     status?: string;
     reason?: string;

+ 7 - 2
assets/scripts/platform/Google/GoogleAndroidImpl.ts → assets/scripts/platform/Google/GoogleNativeImpl.ts

@@ -21,6 +21,7 @@ interface NativeResponse<T = Record<string, never>> {
 }
 
 interface GoogleLoginData {
+    userId?: string;
     idToken?: string;
     email?: string;
     displayName?: string;
@@ -36,7 +37,7 @@ interface PendingRequest {
     timer: ReturnType<typeof setTimeout>;
 }
 
-export class GoogleAndroidImpl extends GoogleImpl {
+export class GoogleNativeImpl extends GoogleImpl {
     private _requestSeed = 0;
     private _pendingRequests = new Map<string, PendingRequest>();
     private _initPromise: Promise<boolean> | null = null;
@@ -59,7 +60,7 @@ export class GoogleAndroidImpl extends GoogleImpl {
 
     protected async onLogin(): Promise<IGoogleLoginRes> {
         if (!(await this.ensureInitialized())) {
-            return this.loginFailure("Google Android 登录尚未初始化。");
+            return this.loginFailure("Google 原生登录尚未初始化,请检查 Client ID 配置。");
         }
 
         try {
@@ -83,6 +84,9 @@ export class GoogleAndroidImpl extends GoogleImpl {
             return {
                 // 与 H5 保持一致:accessToken 字段承载需要交给服务端验证的 Google ID Token。
                 accessToken: idToken,
+                userId: response.data?.userId,
+                name: response.data?.displayName,
+                email: response.data?.email,
                 isCancel: false,
                 status: "connected",
             };
@@ -175,3 +179,4 @@ export class GoogleAndroidImpl extends GoogleImpl {
         return error instanceof Error ? error.message : String(error);
     }
 }
+

+ 1 - 1
assets/scripts/platform/Google/GoogleAndroidImpl.ts.meta → assets/scripts/platform/Google/GoogleNativeImpl.ts.meta

@@ -2,7 +2,7 @@
   "ver": "4.0.24",
   "importer": "typescript",
   "imported": true,
-  "uuid": "abc9bb18-9534-4f77-a0a0-b2643f1025a9",
+  "uuid": "b9cfd43c-32d4-4e77-9a9e-647174b43db2",
   "files": [],
   "subMetas": {},
   "userData": {}

+ 9 - 0
assets/scripts/platform/IAP.meta

@@ -0,0 +1,9 @@
+{
+  "ver": "1.2.0",
+  "importer": "directory",
+  "imported": true,
+  "uuid": "6ff77610-dba9-4629-a87f-963229d97aa1",
+  "files": [],
+  "subMetas": {},
+  "userData": {}
+}

+ 3 - 0
assets/scripts/platform/IAP/GoogleIAP.ts

@@ -0,0 +1,3 @@
+export class GoogleIAP {
+
+}

+ 1 - 1
assets/scripts/platform/Google/GoogleIOSImpl.ts.meta → assets/scripts/platform/IAP/GoogleIAP.ts.meta

@@ -2,7 +2,7 @@
   "ver": "4.0.24",
   "importer": "typescript",
   "imported": true,
-  "uuid": "83576cdb-390d-4cbb-873e-7a615fda3950",
+  "uuid": "c1bfe2d9-49f0-4729-9669-2ceed4f2d221",
   "files": [],
   "subMetas": {},
   "userData": {}

+ 17 - 0
assets/scripts/platform/IAP/IAP.ts

@@ -0,0 +1,17 @@
+
+import { IOS } from 'cc/env';
+import { IAPImpl, IAPProductsResult, IAPResult } from './IAPImpl';
+import { IOSIAP } from './IOSIAP';
+
+class UnsupportedIAP extends IAPImpl {
+    private unsupported(): never { throw new Error('当前平台尚未实现内购。'); }
+    async init(_ids: string[]): Promise<IAPProductsResult> { return this.unsupported(); }
+    async purchase(_id: string): Promise<IAPResult> { return this.unsupported(); }
+    async restore(): Promise<IAPResult[]> { return this.unsupported(); }
+    async getPendingTransactions(): Promise<IAPResult[]> { return this.unsupported(); }
+    async finishTransaction(_id: string): Promise<void> { this.unsupported(); }
+    onTransaction(_listener: (transaction: IAPResult) => void): () => void { return () => {}; }
+}
+const iap: IAPImpl = IOS ? new IOSIAP() : new UnsupportedIAP();
+export default iap;
+export * from './IAPImpl';

+ 9 - 0
assets/scripts/platform/IAP/IAP.ts.meta

@@ -0,0 +1,9 @@
+{
+  "ver": "4.0.24",
+  "importer": "typescript",
+  "imported": true,
+  "uuid": "f52afd8d-8145-4c55-8c66-fc2f6259e1b2",
+  "files": [],
+  "subMetas": {},
+  "userData": {}
+}

+ 31 - 0
assets/scripts/platform/IAP/IAPImpl.ts

@@ -0,0 +1,31 @@
+export interface IAPProduct {
+    productId: string;
+    title: string;
+    description: string;
+    price: string;
+    localizedPrice: string;
+    currencyCode: string;
+}
+export interface IAPResult {
+    status: 'purchased' | 'restored' | 'cancelled' | 'deferred';
+    productId: string;
+    transactionId?: string;
+    originalTransactionId?: string;
+    /** Base64 App Store receipt; send to your backend for validation. */
+    receipt?: string;
+}
+export interface IAPProductsResult {
+    products: IAPProduct[];
+    invalidProductIds: string[];
+}
+
+export abstract class IAPImpl {
+    abstract init(products: string[]): Promise<IAPProductsResult>;
+
+    abstract purchase(product: string): Promise<IAPResult>;
+    abstract restore(): Promise<IAPResult[]>;
+    abstract getPendingTransactions(): Promise<IAPResult[]>;
+    /** Only call after verification and idempotent delivery of the purchase. */
+    abstract finishTransaction(transactionId: string): Promise<void>;
+    abstract onTransaction(listener: (transaction: IAPResult) => void): () => void;
+}

+ 9 - 0
assets/scripts/platform/IAP/IAPImpl.ts.meta

@@ -0,0 +1,9 @@
+{
+  "ver": "4.0.24",
+  "importer": "typescript",
+  "imported": true,
+  "uuid": "a9143ee8-fae2-4bdb-8b35-97f112bbe9ed",
+  "files": [],
+  "subMetas": {},
+  "userData": {}
+}

+ 61 - 0
assets/scripts/platform/IAP/IOSIAP.ts

@@ -0,0 +1,61 @@
+import { native } from 'cc';
+import { IAPImpl, IAPProductsResult, IAPResult } from './IAPImpl';
+
+export class IOSIAP extends IAPImpl {
+    private seed = 0;
+    private listening = false;
+    private pending = new Map<string, { resolve: (data: any) => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout> }>();
+    private listeners = new Set<(transaction: IAPResult) => void>();
+
+    private listen(): void {
+        if (this.listening) return;
+        native.jsbBridgeWrapper.addNativeEventListener('iap.response', payload => {
+            let response: any;
+            try { response = JSON.parse(payload); } catch { return; }
+            if (response.method === 'transaction') {
+                for (const listener of this.listeners) {
+                    try { listener(response.data); } catch (error) { console.error('[IAP] Transaction listener failed', error); }
+                }
+                return;
+            }
+            const pending = this.pending.get(response.requestId);
+            if (!pending) return;
+            clearTimeout(pending.timer);
+            this.pending.delete(response.requestId);
+            if (response.success) pending.resolve(response.data);
+            else pending.reject(new Error(response.error || 'IAP request failed'));
+        });
+        this.listening = true;
+    }
+    private request<T>(method: string, params: object = {}): Promise<T> {
+        this.listen();
+        const requestId = `${Date.now()}-${++this.seed}`;
+        return new Promise<T>((resolve, reject) => {
+            const timer = setTimeout(() => {
+                this.pending.delete(requestId);
+                reject(new Error('IAP 请求超时;不代表支付失败,请查询未完成交易,勿直接重复购买。'));
+            }, 180000);
+            this.pending.set(requestId, { resolve, reject, timer });
+            try { native.jsbBridgeWrapper.dispatchEventToNative('iap.request', JSON.stringify({ requestId, method, params })); }
+            catch (error) { clearTimeout(timer); this.pending.delete(requestId); reject(error); }
+        });
+    }
+    init(productIds: string[]): Promise<IAPProductsResult> {
+        if (!productIds.length || productIds.some(id => typeof id !== 'string' || !id.trim())) {
+            return Promise.reject(new Error('商品 ID 不能为空。'));
+        }
+        return this.request('init', { productIds: [...new Set(productIds)] });
+    }
+    purchase(productId: string): Promise<IAPResult> { return this.request('purchase', { productId }); }
+    restore(): Promise<IAPResult[]> { return this.request('restore'); }
+    getPendingTransactions(): Promise<IAPResult[]> { return this.request('pending'); }
+    async finishTransaction(transactionId: string): Promise<void> {
+        if (!transactionId) throw new Error('交易 ID 不能为空。');
+        await this.request('finish', { transactionId });
+    }
+    onTransaction(listener: (transaction: IAPResult) => void): () => void {
+        this.listen();
+        this.listeners.add(listener);
+        return () => { this.listeners.delete(listener); };
+    }
+}

+ 9 - 0
assets/scripts/platform/IAP/IOSIAP.ts.meta

@@ -0,0 +1,9 @@
+{
+  "ver": "4.0.24",
+  "importer": "typescript",
+  "imported": true,
+  "uuid": "cbff4285-e9e7-4dca-833d-62f5be39eb79",
+  "files": [],
+  "subMetas": {},
+  "userData": {}
+}

+ 5 - 0
native/engine/ios/AppDelegate.mm

@@ -33,6 +33,8 @@
 #import "platform/ios/AppDelegateBridge.h"
 #import "service/SDKWrapper.h"
 #import "service/FBWrapper.h"
+#import "service/GoogleWrapper.h"
+#import "service/IAPWrapper.h"
 
 @implementation AppDelegate
 @synthesize window;
@@ -43,6 +45,8 @@
 
 - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
     [[FBWrapper shared] application:application didFinishLaunchingWithOptions:launchOptions];
+    [[GoogleWrapper shared] registerBridge];
+    [[IAPWrapper shared] start];
     [[SDKWrapper shared] application:application didFinishLaunchingWithOptions:launchOptions];
     appDelegateBridge = [[AppDelegateBridge alloc] init];
     
@@ -72,6 +76,7 @@
 }
 
 - (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey, id> *)options {
+    if ([[GoogleWrapper shared] handleURL:url]) return YES;
     return [[FBWrapper shared] application:application openURL:url options:options];
 }
 

+ 5 - 0
native/engine/ios/CMakeLists.txt

@@ -18,6 +18,11 @@ set(EXECUTABLE_NAME ${APP_NAME}-mobile)
 cc_ios_before_target(${EXECUTABLE_NAME})
 add_executable(${EXECUTABLE_NAME} ${CC_ALL_SOURCES})
 cc_ios_after_target(${EXECUTABLE_NAME})
+target_sources(${EXECUTABLE_NAME} PRIVATE ${CC_PROJECT_DIR}/service/IAPWrapper.mm)
+set_source_files_properties(${CC_PROJECT_DIR}/service/IAPWrapper.mm PROPERTIES COMPILE_FLAGS "-fobjc-arc")
+target_link_libraries(${EXECUTABLE_NAME} "-framework StoreKit")
+target_sources(${EXECUTABLE_NAME} PRIVATE ${CC_PROJECT_DIR}/service/GoogleWrapper.mm)
+set_source_files_properties(${CC_PROJECT_DIR}/service/GoogleWrapper.mm PROPERTIES COMPILE_FLAGS "-fobjc-arc")
 
 target_sources(${EXECUTABLE_NAME} PRIVATE ${CC_PROJECT_DIR}/service/FBWrapper.mm)
 set_source_files_properties(${CC_PROJECT_DIR}/service/FBWrapper.mm PROPERTIES COMPILE_FLAGS "-fobjc-arc")

+ 18 - 0
native/engine/ios/GOOGLE.md

@@ -0,0 +1,18 @@
+# Google iOS 登录
+
+1. 在 Google Cloud 同一项目创建 **iOS 类型** OAuth Client,Bundle ID 与 Xcode 最终应用一致。
+2. 在本目录 Info.plist 替换 GIDClientID 和 google URL Scheme 两处 YOUR_IOS_CLIENT_ID 占位符。
+   例如 Client ID 为 123-abc.apps.googleusercontent.com,Scheme 为 com.googleusercontent.apps.123-abc。
+3. 服务端 Client ID 使用现有 AppConfig.GoogleID(Web 类型),由 TS init 传入,不需要重复填写 GIDServerClientID。
+4. Creator 重新构建 iOS 后,在 Mac 的 native/engine/ios 执行 pod install,打开生成的 xcworkspace 编译。
+
+GoogleIOSImpl 复用 GoogleNativeImpl 的请求协议。AppKernel 已调用 google.init()。
+await AppKernel.google.login() 返回 accessToken(兼容旧接口,实际为 Google ID Token)、userId、name、email、isCancel、status。
+await AppKernel.google.logOut() 清除本机 Google SDK 会话,不撤销授权,也不自动清理游戏服务器会话。
+
+后端必须验证 Google ID Token 的签名、issuer、audience、过期时间,以验证后的 sub 识别用户;不得信任客户端资料作为认证依据。
+Google 登录不依赖 ATT。系统授权弹窗尚未关闭时请稍后重试。
+测试:成功登录、用户取消、断网、重复点击、登出再登录、Facebook URL 回调不受影响。
+Windows 无法验证 CocoaPods 安装和 Xcode 原生编译,需要 Mac 真机测试。
+
+官方文档:https://developers.google.com/identity/sign-in/ios/start-integrating

+ 39 - 0
native/engine/ios/IAP.md

@@ -0,0 +1,39 @@
+# iOS 内购
+
+使用 StoreKit 1 兼容项目 iOS 12 最低版本(这些 API 在新 SDK 中已弃用,未来提升最低版本后可迁移 StoreKit 2)。无需额外 CocoaPods。
+在 App Store Connect 创建与 Bundle ID 对应的商品,使用准确商品 ID;完成付费协议等配置。
+Creator 重新构建 iOS,Xcode 启用 In-App Purchase capability,用 StoreKit Configuration 或 Sandbox 账号测试。
+
+```ts
+import iap from '@scripts/platform/IAP/IAP';
+
+const unsubscribe = iap.onTransaction(transaction => {
+    // purchased/restored: 交给业务的验证发货队列。
+    // deferred: 家长审批等,不发货、不 finish。
+});
+const catalog = await iap.init(['your.product.id']);
+// catalog.products 包含本地化价格;invalidProductIds 需要检查后台配置。
+const pending = await iap.getPendingTransactions(); // 启动补单,即使此前事件早于 TS 启动
+const result = await iap.purchase('your.product.id');
+const restored = await iap.restore(); // 用户点击“恢复购买”时执行
+// 服务端验证成功且幂等发货完成后才调用:
+// await iap.finishTransaction(verifiedTransactionId);
+// unsubscribe();
+```
+
+上述调用展示各 API,实际购买与恢复由不同按钮触发,不要在启动时自动购买/恢复。
+purchase、restore 和 onTransaction 可重复报告同一交易,统一队列按 transactionId 去重,后台必须幂等。
+恢复非消耗型商品或订阅权益时,后台还需结合 originalTransactionId、退款/撤销和订阅到期状态,不能仅看到 restored 就永久解锁。
+恢复不用于找回已消耗的消耗型商品。这里只提供客户端支付能力,不包含后台验单或发货。
+
+receipt 为 Base64 App Store 收据。必须在服务端验证真实性、Bundle ID、商品、交易、环境及当前权益,并绑定游戏账号。
+不应直接信任客户端 status;交易 ID 也不是单独的身份验证凭证。购买前应确认游戏账号,切换账号时防止错绑。
+如果 receipt 为空,不发货也不 finish:保留交易,后台可使用已验证的 App Store Server API 查询流程;本实现未提供收据刷新接口。
+finishTransaction 必须使用当前 transactionId,不是 originalTransactionId。
+
+超时仅表示客户端等待结束,不表示 Apple 取消交易。继续观察事件/查询 pending,禁止直接重复付款。
+原生暂时限制同一时间一笔购买,存在队列交易时先处理或等待;deferred 必须等用户审批,不能 finish。
+恢复失败通过 Promise rejection 返回。成功恢复但没有可恢复商品返回空数组。
+
+测试:正常购买、取消、支付限制、无效商品、断网、审批延迟、杀进程补单、重复点击、恢复空列表、恢复已购、验单失败不 finish、发货成功后 finish。
+Windows 上只能检查 TS/源码;必须在 Mac 编译并在 Sandbox/StoreKit 测试支付链路。

+ 11 - 0
native/engine/ios/Info.plist

@@ -2,6 +2,9 @@
 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 <plist version="1.0">
 <dict>
+	<!-- Replace with the iOS OAuth client, not the Web client. -->
+	<key>GIDClientID</key>
+	<string>984811369314-7j1ps0kfv5ql2474jr5ac8ka822t6s2k.apps.googleusercontent.com</string>
 	<key>NSUserTrackingUsageDescription</key>
 	<string>经您允许,我们将使用设备标识符衡量广告效果并提供个性化广告。</string>
 	<key>FacebookAppID</key>
@@ -12,6 +15,14 @@
 	<false/>
 	<key>CFBundleURLTypes</key>
 	<array>
+		<dict>
+			<key>CFBundleURLName</key>
+			<string>google</string>
+			<key>CFBundleURLSchemes</key>
+			<array>
+				<string>com.googleusercontent.apps.984811369314-7j1ps0kfv5ql2474jr5ac8ka822t6s2k</string>
+			</array>
+		</dict>
 		<dict>
 			<key>CFBundleURLName</key>
 			<string>facebook</string>

+ 2 - 1
native/engine/ios/Podfile

@@ -21,4 +21,5 @@ target ENV.fetch('COCOS_IOS_TARGET', 'game_2c-mobile') do
   pod 'FBSDKCoreKit', '18.1.1'
   pod 'FBSDKLoginKit', '18.1.1'
   pod 'FBSDKShareKit', '18.1.1'
-end
+  pod 'GoogleSignIn', '9.0.0'
+end

+ 14 - 3
native/engine/ios/service/FBWrapper.mm

@@ -143,8 +143,16 @@
             }
             NSString *accessToken = result.token.tokenString;
             NSString *authenticationToken = result.authenticationToken.tokenString;
+            FBSDKProfile *profile = FBSDKProfile.currentProfile;
+            NSString *userID = result.token.userID.length ? result.token.userID : profile.userID;
+            // Do not attach a cached profile's name to a different token's user.
+            NSString *name = [profile.userID isEqualToString:userID] ? profile.name : nil;
+            NSDictionary *userInfo = @{@"userId": userID ?: @"", @"name": name ?: @""};
             if (accessToken.length) {
-                [self reply:request data:@{@"accessToken": accessToken, @"tokenType": @"access"} cancelled:NO error:nil];
+                NSMutableDictionary *data = [userInfo mutableCopy];
+                data[@"accessToken"] = accessToken;
+                data[@"tokenType"] = @"access";
+                [self reply:request data:data cancelled:NO error:nil];
             } else if (authenticationToken.length) {
                 // The standard entry point generates its own nonce. Return the
                 // SDK token's nonce, never an unrelated locally generated value.
@@ -153,8 +161,11 @@
                     [self reply:request data:nil cancelled:NO error:@"Facebook 身份令牌缺少 nonce。"];
                     return;
                 }
-                [self reply:request data:@{@"authenticationToken": authenticationToken,
-                    @"tokenType": @"authentication", @"nonce": nonce} cancelled:NO error:nil];
+                NSMutableDictionary *data = [userInfo mutableCopy];
+                data[@"authenticationToken"] = authenticationToken;
+                data[@"tokenType"] = @"authentication";
+                data[@"nonce"] = nonce;
+                [self reply:request data:data cancelled:NO error:nil];
             } else {
                 [self reply:request data:nil cancelled:NO error:@"Facebook 登录未返回有效令牌。"];
             }

+ 6 - 0
native/engine/ios/service/GoogleWrapper.h

@@ -0,0 +1,6 @@
+#import <Foundation/Foundation.h>
+@interface GoogleWrapper : NSObject
++ (instancetype)shared;
+- (void)registerBridge;
+- (BOOL)handleURL:(NSURL *)url;
+@end

+ 113 - 0
native/engine/ios/service/GoogleWrapper.mm

@@ -0,0 +1,113 @@
+#import "GoogleWrapper.h"
+#import <UIKit/UIKit.h>
+#import <GoogleSignIn/GoogleSignIn.h>
+#import "platform/apple/JsbBridgeWrapper.h"
+#include "application/ApplicationManager.h"
+#include "application/BaseApplication.h"
+#include "base/Scheduler.h"
+#include "engine/BaseEngine.h"
+
+@interface GoogleWrapper ()
+@property(nonatomic) BOOL initialized;
+@property(nonatomic) BOOL loginPending;
+@end
+@implementation GoogleWrapper
++ (instancetype)shared {
+    static GoogleWrapper *instance;
+    static dispatch_once_t once;
+    dispatch_once(&once, ^{ instance = [GoogleWrapper new]; });
+    return instance;
+}
+- (void)registerBridge {
+    // Must be capture-free: Creator 3.8.8's MRC bridge releases this block.
+    static OnScriptEventListener const listener = ^(NSString *payload) {
+        dispatch_async(dispatch_get_main_queue(), ^{ [[GoogleWrapper shared] handlePayload:payload]; });
+    };
+    [[JsbBridgeWrapper sharedInstance] addScriptEventListener:@"google.request" listener:listener];
+}
+- (BOOL)handleURL:(NSURL *)url { return [GIDSignIn.sharedInstance handleURL:url]; }
+- (void)reply:(NSDictionary *)request data:(NSDictionary *)data cancelled:(BOOL)cancelled error:(NSString *)error {
+    NSMutableDictionary *response = [@{@"requestId": request[@"requestId"], @"method": request[@"method"],
+        @"success": @(!error), @"cancelled": @(cancelled)} mutableCopy];
+    if (data) response[@"data"] = data;
+    if (error) response[@"error"] = @{@"code": @"GOOGLE_IOS_ERROR", @"message": error};
+    NSData *json = [NSJSONSerialization dataWithJSONObject:response options:0 error:nil];
+    NSString *payload = [[NSString alloc] initWithData:json encoding:NSUTF8StringEncoding];
+    auto app = CC_CURRENT_APPLICATION();
+    if (!app || !app->getEngine() || !payload) return;
+    app->getEngine()->getScheduler()->performFunctionInCocosThread([payload]() {
+        [[JsbBridgeWrapper sharedInstance] dispatchEventToScript:@"google.response" arg:payload];
+    });
+}
+- (void)handlePayload:(NSString *)payload {
+    id decoded = [NSJSONSerialization JSONObjectWithData:[payload dataUsingEncoding:NSUTF8StringEncoding] options:0 error:nil];
+    if (![decoded isKindOfClass:NSDictionary.class]) return;
+    NSDictionary *request = decoded;
+    if (![request[@"requestId"] isKindOfClass:NSString.class] || ![request[@"method"] isKindOfClass:NSString.class]) return;
+    NSString *method = request[@"method"];
+    @try {
+        if (self.loginPending) {
+            [self reply:request data:nil cancelled:NO error:@"Google 登录仍在进行中。"];
+        } else if ([method isEqualToString:@"init"]) {
+            NSDictionary *params = [request[@"params"] isKindOfClass:NSDictionary.class] ? request[@"params"] : @{};
+            NSString *clientID = [NSBundle.mainBundle objectForInfoDictionaryKey:@"GIDClientID"];
+            NSString *serverID = params[@"webClientId"];
+            if (![clientID isKindOfClass:NSString.class] || ![clientID hasSuffix:@".apps.googleusercontent.com"] ||
+                [clientID containsString:@"YOUR_"] || ![serverID isKindOfClass:NSString.class] ||
+                ![serverID hasSuffix:@".apps.googleusercontent.com"]) {
+                [self reply:request data:nil cancelled:NO error:@"请配置 Info.plist 的 GIDClientID(iOS)及 AppConfig.GoogleID(Web)。"];
+                return;
+            }
+            NSArray *parts = [[clientID componentsSeparatedByString:@"."] reverseObjectEnumerator].allObjects;
+            NSString *scheme = [parts componentsJoinedByString:@"."];
+            BOOL found = NO;
+            for (NSDictionary *type in [NSBundle.mainBundle objectForInfoDictionaryKey:@"CFBundleURLTypes"]) {
+                if ([type[@"CFBundleURLSchemes"] containsObject:scheme]) found = YES;
+            }
+            if (!found) {
+                [self reply:request data:nil cancelled:NO error:@"Info.plist 缺少 Google iOS Client ID 对应的反向 URL Scheme。"];
+                return;
+            }
+            GIDSignIn.sharedInstance.configuration = [[GIDConfiguration alloc] initWithClientID:clientID serverClientID:serverID];
+            self.initialized = YES;
+            [self reply:request data:@{} cancelled:NO error:nil];
+        } else if (!self.initialized) {
+            [self reply:request data:nil cancelled:NO error:@"Google SDK 未初始化。"];
+        } else if ([method isEqualToString:@"logout"]) {
+            [GIDSignIn.sharedInstance signOut];
+            [self reply:request data:@{} cancelled:NO error:nil];
+        } else if ([method isEqualToString:@"login"]) {
+            UIViewController *presenter = UIApplication.sharedApplication.delegate.window.rootViewController;
+            while (presenter.presentedViewController) presenter = presenter.presentedViewController;
+            if (!presenter || UIApplication.sharedApplication.applicationState != UIApplicationStateActive) {
+                [self reply:request data:nil cancelled:NO error:@"请在游戏前台且系统授权弹窗关闭后重试 Google 登录。"];
+                return;
+            }
+            self.loginPending = YES;
+            [GIDSignIn.sharedInstance signInWithPresentingViewController:presenter completion:^(GIDSignInResult *result, NSError *error) {
+                dispatch_async(dispatch_get_main_queue(), ^{
+                    self.loginPending = NO;
+                    BOOL cancelled = [error.domain isEqualToString:kGIDSignInErrorDomain] && error.code == kGIDSignInErrorCodeCanceled;
+                    if (error) {
+                        [self reply:request data:nil cancelled:cancelled error:cancelled ? nil : error.localizedDescription];
+                        return;
+                    }
+                    GIDGoogleUser *user = result.user;
+                    if (!user.idToken.tokenString.length) {
+                        [self reply:request data:nil cancelled:NO error:@"Google 未返回 ID Token。"];
+                        return;
+                    }
+                    [self reply:request data:@{@"idToken": user.idToken.tokenString,
+                        @"userId": user.userID ?: @"", @"displayName": user.profile.name ?: @"",
+                        @"email": user.profile.email ?: @""} cancelled:NO error:nil];
+                });
+            }];
+        } else {
+            [self reply:request data:nil cancelled:NO error:@"不支持的 Google 方法。"];
+        }
+    } @catch (NSException *exception) {
+        if ([method isEqualToString:@"login"]) self.loginPending = NO;
+        [self reply:request data:nil cancelled:NO error:exception.reason ?: @"Google SDK 调用失败。"];
+    }
+}
+@end

+ 5 - 0
native/engine/ios/service/IAPWrapper.h

@@ -0,0 +1,5 @@
+#import <Foundation/Foundation.h>
+@interface IAPWrapper : NSObject
++ (instancetype)shared;
+- (void)start;
+@end

+ 175 - 0
native/engine/ios/service/IAPWrapper.mm

@@ -0,0 +1,175 @@
+#import "IAPWrapper.h"
+#import <StoreKit/StoreKit.h>
+#import "platform/apple/JsbBridgeWrapper.h"
+#include "application/ApplicationManager.h"
+#include "application/BaseApplication.h"
+#include "base/Scheduler.h"
+#include "engine/BaseEngine.h"
+
+@interface IAPWrapper () <SKProductsRequestDelegate, SKPaymentTransactionObserver>
+@property(nonatomic, strong) NSMutableDictionary<NSString *, SKProduct *> *products;
+@property(nonatomic, strong) SKProductsRequest *productsRequest;
+@property(nonatomic, strong) NSDictionary *initRequest;
+@property(nonatomic, strong) NSDictionary *purchaseRequest;
+@property(nonatomic, strong) NSDictionary *restoreRequest;
+@property(nonatomic, strong) NSMutableArray *restored;
+@end
+
+@implementation IAPWrapper
++ (instancetype)shared {
+    static IAPWrapper *instance;
+    static dispatch_once_t once;
+    dispatch_once(&once, ^{ instance = [IAPWrapper new]; instance.products = [NSMutableDictionary new]; });
+    return instance;
+}
+- (void)start {
+    static dispatch_once_t once;
+    dispatch_once(&once, ^{
+        // Global, capture-free listener survives Creator 3.8.8 MRC ownership.
+        static OnScriptEventListener const listener = ^(NSString *payload) {
+            dispatch_async(dispatch_get_main_queue(), ^{ [[IAPWrapper shared] handle:payload]; });
+        };
+        [[JsbBridgeWrapper sharedInstance] addScriptEventListener:@"iap.request" listener:listener];
+        [SKPaymentQueue.defaultQueue addTransactionObserver:self];
+    });
+}
+- (void)reply:(NSDictionary *)request data:(id)data error:(NSString *)error {
+    if (!request) return;
+    NSMutableDictionary *response = [@{@"requestId": request[@"requestId"] ?: @"",
+        @"method": request[@"method"] ?: @"", @"success": @(!error)} mutableCopy];
+    if (data) response[@"data"] = data;
+    if (error) response[@"error"] = error;
+    NSData *json = [NSJSONSerialization dataWithJSONObject:response options:0 error:nil];
+    NSString *payload = [[NSString alloc] initWithData:json encoding:NSUTF8StringEncoding];
+    auto app = CC_CURRENT_APPLICATION();
+    if (!app || !app->getEngine() || !payload) return;
+    app->getEngine()->getScheduler()->performFunctionInCocosThread([payload]() {
+        [[JsbBridgeWrapper sharedInstance] dispatchEventToScript:@"iap.response" arg:payload];
+    });
+}
+- (NSDictionary *)dataFor:(SKPaymentTransaction *)transaction {
+    NSURL *url = NSBundle.mainBundle.appStoreReceiptURL;
+    NSData *receipt = url ? [NSData dataWithContentsOfURL:url] : nil;
+    NSString *status = transaction.transactionState == SKPaymentTransactionStateRestored ? @"restored" :
+        transaction.transactionState == SKPaymentTransactionStateDeferred ? @"deferred" : @"purchased";
+    return @{@"status": status, @"productId": transaction.payment.productIdentifier ?: @"",
+        @"transactionId": transaction.transactionIdentifier ?: @"",
+        @"originalTransactionId": transaction.originalTransaction.transactionIdentifier ?: transaction.transactionIdentifier ?: @"",
+        @"receipt": [receipt base64EncodedStringWithOptions:0] ?: @""};
+}
+- (NSArray *)pending {
+    NSMutableArray *items = [NSMutableArray new];
+    for (SKPaymentTransaction *t in SKPaymentQueue.defaultQueue.transactions) {
+        if (t.transactionState == SKPaymentTransactionStatePurchased || t.transactionState == SKPaymentTransactionStateRestored ||
+            t.transactionState == SKPaymentTransactionStateDeferred) [items addObject:[self dataFor:t]];
+    }
+    return items;
+}
+- (void)handle:(NSString *)payload {
+    id decoded = [NSJSONSerialization JSONObjectWithData:[payload dataUsingEncoding:NSUTF8StringEncoding] options:0 error:nil];
+    if (![decoded isKindOfClass:NSDictionary.class]) return;
+    NSDictionary *r = decoded;
+    if (![r[@"requestId"] isKindOfClass:NSString.class] || ![r[@"method"] isKindOfClass:NSString.class]) return;
+    NSString *method = r[@"method"];
+    NSDictionary *p = [r[@"params"] isKindOfClass:NSDictionary.class] ? r[@"params"] : @{};
+    if ([method isEqualToString:@"init"]) {
+        if (self.productsRequest) { [self reply:r data:nil error:@"正在查询商品,请稍后重试。"]; return; }
+        NSArray *ids = p[@"productIds"];
+        if (![ids isKindOfClass:NSArray.class] || !ids.count) { [self reply:r data:nil error:@"商品列表不能为空。"]; return; }
+        for (id value in ids) if (![value isKindOfClass:NSString.class] || ![value length]) {
+            [self reply:r data:nil error:@"商品 ID 必须为非空字符串。"]; return;
+        }
+        self.initRequest = r;
+        self.productsRequest = [[SKProductsRequest alloc] initWithProductIdentifiers:[NSSet setWithArray:ids]];
+        self.productsRequest.delegate = self;
+        [self.productsRequest start];
+    } else if ([method isEqualToString:@"purchase"]) {
+        if (self.purchaseRequest || self.restoreRequest || SKPaymentQueue.defaultQueue.transactions.count) {
+            [self reply:r data:nil error:@"存在进行中或未完成交易,请先处理未完成交易。"]; return;
+        }
+        NSString *productID = p[@"productId"];
+        SKProduct *product = [productID isKindOfClass:NSString.class] ? self.products[productID] : nil;
+        if (!product || ![SKPaymentQueue canMakePayments]) {
+            [self reply:r data:nil error:@"商品未初始化或此设备不允许内购。"]; return;
+        }
+        self.purchaseRequest = r;
+        [SKPaymentQueue.defaultQueue addPayment:[SKPayment paymentWithProduct:product]];
+    } else if ([method isEqualToString:@"restore"]) {
+        if (self.restoreRequest || self.purchaseRequest) { [self reply:r data:nil error:@"已有购买或恢复操作进行中。"]; return; }
+        self.restoreRequest = r;
+        self.restored = [NSMutableArray new];
+        [SKPaymentQueue.defaultQueue restoreCompletedTransactions];
+    } else if ([method isEqualToString:@"pending"]) {
+        [self reply:r data:[self pending] error:nil];
+    } else if ([method isEqualToString:@"finish"]) {
+        NSString *identifier = p[@"transactionId"];
+        if (![identifier isKindOfClass:NSString.class] || !identifier.length) { [self reply:r data:nil error:@"无效的交易 ID。"]; return; }
+        for (SKPaymentTransaction *t in SKPaymentQueue.defaultQueue.transactions) {
+            if ([t.transactionIdentifier isEqualToString:identifier] &&
+                (t.transactionState == SKPaymentTransactionStatePurchased || t.transactionState == SKPaymentTransactionStateRestored)) {
+                [SKPaymentQueue.defaultQueue finishTransaction:t];
+                [self reply:r data:@{} error:nil]; return;
+            }
+        }
+        [self reply:r data:nil error:@"未找到可完成的交易,请查询未完成交易。"];
+    } else { [self reply:r data:nil error:@"不支持的内购方法。"]; }
+}
+- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response {
+    dispatch_async(dispatch_get_main_queue(), ^{
+        if (request != self.productsRequest) return;
+        NSMutableArray *items = [NSMutableArray new];
+        for (SKProduct *product in response.products) {
+            self.products[product.productIdentifier] = product;
+            NSNumberFormatter *formatter = [NSNumberFormatter new];
+            formatter.numberStyle = NSNumberFormatterCurrencyStyle;
+            formatter.locale = product.priceLocale;
+            [items addObject:@{@"productId": product.productIdentifier, @"title": product.localizedTitle ?: @"",
+                @"description": product.localizedDescription ?: @"", @"price": product.price.stringValue,
+                @"localizedPrice": [formatter stringFromNumber:product.price] ?: @"", @"currencyCode": formatter.currencyCode ?: @""}];
+        }
+        [self reply:self.initRequest data:@{@"products": items, @"invalidProductIds": response.invalidProductIdentifiers} error:nil];
+        self.initRequest = nil; self.productsRequest = nil;
+    });
+}
+- (void)request:(SKRequest *)request didFailWithError:(NSError *)error {
+    dispatch_async(dispatch_get_main_queue(), ^{
+        if (request != self.productsRequest) return;
+        [self reply:self.initRequest data:nil error:error.localizedDescription];
+        self.initRequest = nil; self.productsRequest = nil;
+    });
+}
+- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray<SKPaymentTransaction *> *)transactions {
+    dispatch_async(dispatch_get_main_queue(), ^{
+        for (SKPaymentTransaction *t in transactions) {
+            if (t.transactionState == SKPaymentTransactionStatePurchasing) continue;
+            BOOL matches = [self.purchaseRequest[@"params"][@"productId"] isEqualToString:t.payment.productIdentifier];
+            if (t.transactionState == SKPaymentTransactionStateFailed) {
+                BOOL cancelled = t.error.code == SKErrorPaymentCancelled && [t.error.domain isEqualToString:SKErrorDomain];
+                NSDictionary *data = @{@"status": @"cancelled", @"productId": t.payment.productIdentifier ?: @""};
+                if (matches) { [self reply:self.purchaseRequest data:cancelled ? data : nil error:cancelled ? nil : t.error.localizedDescription ?: @"支付失败。"]; self.purchaseRequest = nil; }
+                [queue finishTransaction:t];
+                continue;
+            }
+            NSDictionary *data = [self dataFor:t];
+            if (t.transactionState == SKPaymentTransactionStateRestored && self.restoreRequest) [self.restored addObject:data];
+            if (matches && t.transactionState != SKPaymentTransactionStateRestored) {
+                [self reply:self.purchaseRequest data:data error:nil]; self.purchaseRequest = nil;
+            }
+            // Successful transactions remain in StoreKit until backend delivery is acknowledged.
+            [self reply:@{@"method": @"transaction"} data:data error:nil];
+        }
+    });
+}
+- (void)paymentQueueRestoreCompletedTransactionsFinished:(SKPaymentQueue *)queue {
+    dispatch_async(dispatch_get_main_queue(), ^{
+        [self reply:self.restoreRequest data:self.restored ?: @[] error:nil];
+        self.restoreRequest = nil; self.restored = nil;
+    });
+}
+- (void)paymentQueue:(SKPaymentQueue *)queue restoreCompletedTransactionsFailedWithError:(NSError *)error {
+    dispatch_async(dispatch_get_main_queue(), ^{
+        [self reply:self.restoreRequest data:nil error:error.localizedDescription];
+        self.restoreRequest = nil; self.restored = nil;
+    });
+}
+@end