使用 Cloud Code 实现购买
实现 Cloud Code 模块作为您的后端解决方案来实现购买。
阅读时间6 分钟最后更新于 20 天前
如果使用 D2C 支付提供商,可以使用 Cloud Code 模块实现购买。 Cloud Code 会验证 Webhook 购买事件并返回成功响应。您需要编写一个模块来实现以下内容:
- 基于 Stock Keeping Unit (SKU) 授予商品授权并在数据库中更新玩家的背包,例如 Cloud Save。
- 调用订单 API 将订单标记为已完成。
有关订单 API 的信息,请参阅事件类型文档。
部署该模块
有关如何部署模块的信息,请参阅 Cloud Code 入门指南。在 IAP Dashboard(IAP 后台)中设置 Cloud Code 模块
在 Unity Dashboard(Unity 后台)中将项目配置为从 Cloud Code 模块接收购买信息:- 在 Unity Dashboard 中,选择 IAP > Payment Providers。
- 在 Entitlement Delivery Method 下,选择 Edit。
- 选择 Cloud Code 模块选项,然后从下拉选单中选择模块和终端。
- 选择 Save configuration(保存配置)。
使用日志
当 Cloud Code 模块处理交易时,您可以在 Cloud Code Dashboard 中查看日志。如需了解更多信息,请参阅 Cloud Code Logging 文档。Cloud Code 示例模块
请参阅以下处理购买事件 Webhook 的示例模块:using System;using System.Collections.Generic;using System.Net.Http;using System.Net.Http.Headers;using System.Text;using System.Text.Json;using System.Text.Json.Serialization;using System.Threading;using System.Threading.Tasks;using Microsoft.Extensions.DependencyInjection;using Microsoft.Extensions.Logging;using Unity.Services.CloudCode.Core;using Unity.Services.CloudCode.Apis;using Unity.Services.CloudSave.Model;namespace Fullfillment;// Handles IAP purchase webhooks: grants product entitlements via Cloud Save and marks orders as fulfilled.public class IapFulfillmentHandler{ private readonly ILogger<IapFulfillmentHandler> m_Logger; private static readonly HttpClient s_HttpClient = new() { Timeout = TimeSpan.FromSeconds(10) }; private const string k_IapTransactionVerifierBaseUrl = "https://iap.services.api.unity.com/v1"; // Accepts and stores the logger used for diagnostics. public IapFulfillmentHandler(ILogger<IapFulfillmentHandler> logger) { m_Logger = logger; } // Webhook entry point: grants entitlements from line items via Cloud Save, then marks the order as fulfilled and returns a response. [CloudCodeFunction("ProcessPurchaseFulfillment")] public async Task<WebhookResponse> ProcessPurchaseFulfillment( IExecutionContext 上下文、 IGameApiClient gameApiClient, Guid ID、 字符串版本, string eventType、 DateTime 时间、 string projectId, string environmentId、 string dataType、 WebhookOrderData data) { try { { m_Logger.LogInformation( "处理购买履行 - EventID: {EventID}、EventType: {EventType}、Time: {Time}、" + "ProjectID:{ProjectID}、EnvironmentId:{EnvironmentId}、PlayerId:{PlayerId}、" + "PaymentProvider: {PaymentProvider}、PaymentProviderResourceId: {PaymentProviderResourceId}、" + "OrderId: {OrderId}、LineItems: {LineItems}、CustomReferenceId: {CustomReferenceId}", id.ToString(), eventType, time.ToString(), projectId environmentId data.PlayerId, data.PaymentProvider, data.PaymentProviderResourceId, data.Id, JsonSerializer.Serialize(data.LineItems), data.CustomReferenceId); List<string> rights = await GrantProductEntitlements( context, gameApiClient, data.PlayerId, data.LineItems); m_Logger.LogInformation( "已成功处理订单 {OrderId} 的购买履行,使用“ + "{Entitlements}", data.Id, rights); try { { m_Logger.LogInformation("Calling fulfillment API for order {OrderId}", data.Id); bool fulfillmentSuccess = await FulfillOrderAsync( data.Id, projectId environmentId context.ServiceToken ?? string.Empty); if (fulfillmentSuccess) { m_Logger.LogInformation("Successfully marked order {OrderId} as fulfilled", data.Id); } else { m_Logger.LogWarning( "未能将订单 {OrderId} 标记为已完成,但授权为" + "granted", data.Id); } } catch(异常 ex) { m_Logger.LogError(例如,"为订单 {OrderId} 调用履行 API 时出错:{Error}", data.Id, ex.Message); } return new WebhookResponse { Status = WebhookResponseCodes.WebhookStatusOK }; } catch(异常 ex) { m_Logger.LogError( "订单 {OrderId} 的执行过程中出现意外错误:{Error}", data.Id, ex.Message); return new WebhookResponse { Status = WebhookResponseCodes.WebhookStatusError, Code = WebhookResponseCodes.WebhookErrorDeclined, Description = ex.Message }; } } // Loads the player inventory, applies each line item by SKU (gems, pass, storage), saves inventory, and returns the list of granted entitlement descriptions. private async Task<List<string>> GrantProductEntitlements( IExecutionContext 上下文、 IGameApiClient gameApiClient, string playerId, List<WebhookLineItem> lineItems) { if (lineItems == null || lineItems.Count == 0) { m_Logger.LogWarning("GrantProductEntitlements called with null or empty line items list"); return new List<string>(); } List<string> rights = new List<string>(); try { { m_Logger.LogInformation("Getting player inventory for player {PlayerId}", playerId); PlayerInventory currentInventory = await GetPlayerInventory( context, gameApiClient, playerId); m_Logger.LogInformation("Retrieved inventory: {Inventory}", JsonSerializer.Serialize(currentInventory)); foreach(lineItems 中的 WebhookLineItem 项) { string sku = item.Sku; m_Logger.LogInformation("Processing line item:SKU={SKU}、ProductType={ProductType}", sku, item.ProductType); switch (sku) { case "com.unity.iap.test.adventure.pass.not": int void = 30; currentInventory.AdventurePass = DateTime.UtcNow.AddDays(days); entitlements.Add($"{days} Days Adventure Pass"); break; case "com.unity.iap.test.30.gems": int gems30 = 30; currentInventory.Gems += gems30; entitlements.Add($"{gems30} Gems"); break; case "com.unity.iap.test.premium.storage": currentInventory.PremiumStorage = true; entitlements.Add($"PremiumStorage = true"); break; Default m_Logger.LogWarning("Unknown SKU {SKU} in fulfillment request", sku); break; } } m_Logger.LogInformation("Updated inventory: inventory: {Inventory}", JsonSerializer.Serialize(currentInventory)); m_Logger.LogInformation("Saving player inventory"); await SavePlayerInventory(context, gameApiClient, playerId, currentInventory); m_Logger.LogInformation("Successfully saved player inventory"); } catch(异常 ex) { m_Logger.LogError("Error in GrantProductEntitlements: {Error}", ex.Message); throw; } return entitlements; } // Fetches the "player_inventory" key from Cloud Save for the player and deserializes it; returns a new empty inventory if missing or on error. private async Task<PlayerInventory> GetPlayerInventory( IExecutionContext 上下文、 IGameApiClient gameApiClient, string playerId) { try { { var result = await gameApiClient.CloudSaveData.GetItemsAsync( context context.ServiceToken!, context.ProjectId!, PlayerId new List<string> { "player_inventory" }); if (result.Data.Results.Count <= 0) { m_Logger.LogInformation("No existing inventory found, creating new inventory"); return new PlayerInventory(); } var resultItem = result.Data.Results[0]; if (resultItem.Value == null) { m_Logger.LogWarning("Inventory value is null, creating new inventory"); return new PlayerInventory(); } string inventoryJson; try { { inventoryJson = resultItem.Value.ToString(); if (string.IsNullOrWhiteSpace(inventoryJson)) { m_Logger.LogWarning("Inventory JSON is empty, creating new inventory"); return new PlayerInventory(); } } catch(异常 ex) { m_Logger.LogError("Failed to convert inventory value to string: {Error}", ex.Message); return new PlayerInventory(); } try { { PlayerInventory inventory = JsonSerializer.Deserialize<PlayerInventory>( inventoryJson); return inventory ?? new PlayerInventory(); } catch(异常 ex) { m_Logger.LogError("无法反序列化背包 JSON:{Error}。JSON: {Json}", ex.Message, inventoryJson); return new PlayerInventory(); } } catch(异常 ex) { m_Logger.LogError("Unexpected error while getting inventory: {Error}", ex.Message); return new PlayerInventory(); } } // Serializes the inventory to JSON and writes it to Cloud Save under the "player_inventory" key for the player. private async Task SavePlayerInventory( IExecutionContext 上下文、 IGameApiClient gameApiClient, string playerId, PlayerInventory inventory) { try { { if (inventory == null) { m_Logger.LogWarning("Attempted to save null inventory, skipping"); return; } string inventoryJson; try { { inventoryJson = JsonSerializer.Serialize(inventory); } catch(异常 ex) { m_Logger.LogError("Failed to serialize inventory: {Error}", ex.Message); throw; } await gameApiClient.CloudSaveData.SetItemAsync( context context.ServiceToken!, context.ProjectId!, PlayerId new SetItemBody("player_inventory", inventoryJson)); m_Logger.LogInformation("Successfully saved inventory for player {PlayerId}", playerId); } catch(异常 ex) { m_Logger.LogError("Failed to save inventory: {Error}", ex.Message); throw; } } // Sends a PATCH request to the IAP orders API to set the order status to fulfilled; returns true on success. private async Task<bool> FulfillOrderAsync( string orderId、 string projectId, string environmentId、 string serviceToken) { if (string.IsNullOrEmpty(orderId)) { m_Logger.LogWarning("Cannot fulfill order - order ID is null or empty"); return false; } if (string.IsNullOrEmpty(projectId)) { m_Logger.LogWarning("Cannot fulfill order - project ID is null or empty"); return false; } if (string.IsNullOrEmpty(environmentId)) { m_Logger.LogWarning("Cannot fulfill order - environment ID is null or empty"); return false; } if (string.IsNullOrEmpty(serviceToken)) { m_Logger.LogWarning("Cannot fulfill order - service token is null or empty"); return false; } try { { string url = $"{k_IapTransactionVerifierBaseUrl}/projects/{projectId}/environments/" + $"{environmentId}/orders/{orderId}"; var requestBody = new { status = "fulfilled" }; string jsonContent = JsonSerializer.Serialize(requestBody); StringContent content = new StringContent( jsonContent, Encoding.UTF8, "application/json"); HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Patch, url) { Content = content }; request.Headers.Authorization = new AuthenticationHeaderValue( "Bearer", serviceToken); m_Logger.LogInformation("Sending fulfillment request to {Url} for order {OrderId}", url, orderId); HttpResponseMessage response = await s_HttpClient.SendAsync(request); if (response.IsSuccessStatusCode) { m_Logger.LogInformation("订单 {OrderId} 的 Fulfillment API 调用成功。Status = {StatusCode}", orderId, response.StatusCode); return true; } string responseBody = await response.Content.ReadAsStringAsync(); m_Logger.LogWarning( "订单 {OrderId} 的履行 API 调用失败。Status:{StatusCode}、" + "Response: {ResponseBody}", orderId, response.StatusCode, responseBody); return false; } catch(异常 ex) { m_Logger.LogError(ex, "Unexpected error while fulfilling order {OrderId}: {Error}", orderId, ex.Message); return false; } }}// Player inventory stored in Cloud Save (gems, adventure pass, premium storage, and similar).public class PlayerInventory{ public int Gems { get; set; } public int XpBooster { get; set; } public DateTime?AdventurePass { get; set; } public bool PremiumStorage { get; set; }}// Order payload sent by the IAP webhook for a purchase event.public class WebhookOrderData{ [JsonPropertyName("id")] public string Id { get; set; } = string.Empty; [JsonPropertyName("playerId")] public string PlayerId { get; set; } = string.Empty; [JsonPropertyName("paymentProvider")] public string PaymentProvider { get; set; } = string.Empty; [JsonPropertyName("paymentProviderResourceId")] public string?PaymentProviderResourceId { get; set; } [JsonPropertyName("url")] public string Url { get; set; } = string.Empty; [JsonPropertyName("lineItems")] public List<WebhookLineItem> LineItems { get; set; } = new(); [JsonPropertyName("currency")] public string Currency { get; set; } = string.Empty; [JsonPropertyName("amounts")] public WebhookAmounts?Amounts { get; set; } [JsonPropertyName("status")] public string Status { get; set; } = string.Empty; [JsonPropertyName("customReferenceId")] public string?CustomReferenceId { get; set; } [JsonPropertyName("metadata")] public Dictionary<string, string>?Metadata { get; set; } [JsonPropertyName("createdAt")] public DateTime CreatedAt { get; set; } [JsonPropertyName("updatedAt")] public DateTime UpdatedAt { get; set; } [JsonPropertyName("paidAt")] public DateTime?PaidAt { get; set; } [JsonPropertyName("fulfilledAt")] public DateTime?FullfilledAt { get; set; }}// A single purchasable item in an order (SKU, product type, and price).public class WebhookLineItem{ [JsonPropertyName("sku")] public string Sku { get; set; } = string.Empty; [JsonPropertyName("productType")] public string ProductType { get; set; } = string.Empty; [JsonPropertyName("price")] public WebhookMoney Price { get; set; } = null!;}// Total and refunded amounts for an order, in micros.public class WebhookAmounts{ [JsonPropertyName("totalMicros")] public long TotalMicros { get; set; } [JsonPropertyName("refundedMicros")] public long RefundedMicros { get; set; }}// A monetary amount and currency (amount in micros).public class WebhookMoney{ [JsonPropertyName("amountMicros")] public long AmountMicros { get; set; } [JsonPropertyName("currency")] public string Currency { get; set; } = string.Empty;}// Status and error code constants for IAP webhook responses.public static class WebhookResponseCodes{ public const string WebhookStatusOK = "ok"; public const string WebhookStatusError = "error"; public const string WebhookErrorDeclined = "declined";}// Response returned by the IAP fulfillment webhook to the IAP service.public class WebhookResponse{ public string Status { get; set; } = string.Empty; public string?Code { get; set; } public string?Description { get; set; }}// Registers Cloud Code module dependencies (for example, the game API client).public class ModuleConfig :ICloudCodeSetup{ // Registers the game API client as a singleton for re-use by Cloud Code functions. public void Setup(ICloudCodeConfig config) { config.Dependencies.AddSingleton(GameApiClient.Create()); }}