Cloud Code를 사용하여 구매 수행
구매를 수행하기 위해 백엔드 솔루션으로 Cloud Code 모듈을 구현합니다.
읽는 시간 4분최근 업데이트: 10일 전
D2C 결제 제공업체를 사용하는 경우 구매를 수행하기 위해 Cloud Code 모듈을 사용할 수 있습니다. Cloud Code는 웹후크 구매 이벤트를 검증하고 성공적인 응답을 반환합니다. 다음을 구현하기 위한 모듈을 작성해야 합니다.
- SKU(Stock Keeping Unit)를 기반으로 Cloud Save와 같은 데이터베이스에서 상품 권한을 부여하고 플레이어 인벤토리를 업데이트합니다.
- 주문 API 호출하여 순서를 완료된 것으로 표시합니다.
주문 API에 대한 자세한 내용은 이벤트 유형 기술 자료를 참고하십시오.
모듈 배포
모듈을 배포하는 방법에 대한 자세한 내용은 Cloud Code 시작하기 가이드를 참고하십시오.IAP 대시보드에서 Cloud Code 모듈 설정
Unity Dashboard의 Cloud Code 모듈에서 구매 정보를 수신하도록 프로젝트를 구성합니다.- Unity Dashboard에서 IAP > Payment Providers를 선택합니다.
- 자격 제공 방법에서 편집을 선택합니다.
- Cloud Code 모듈 옵션을 선택한 다음 드롭다운에서 모듈과 엔드포인트를 선택합니다.
- Save configuration을 선택합니다.
로그 사용
Cloud Code 모듈이 거래를 처리할 때 Cloud Code 대시보드에서 로그를 볼 수 있습니다. 자세한 내용은 Cloud Code 로깅 기술 자료를 참고하십시오.Cloud Code 예제 모듈
다음 예시 모듈을 참고하여 구매 이벤트 웹후크를 처리합니다.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;네임스페이스 이행;// 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, 가이드 ID, 문자열 버전, 문자열 eventType, DateTime 시간, string projectId, string environmentId, 문자열 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> entitlements = await GrantProductEntitlements( context, gameApiClient, data.PlayerId, data.LineItems); m_Logger.LogInformation( "성공된 주문 {OrderId}의 구매 이행 " + "{Entitlements}", data.Id, entitlements); 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(예외) { m_Logger.LogError(예: "OrderId}에 대한 API 호출 오류: {Error}", data.Id, ex.Message); } return new WebhookResponse { Status = WebhookResponseCodes.WebhookStatusOK }; } catch(예외) { 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, 목록<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> entitlements = 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 항목) { 문자열 sku = item.Sku; m_Logger.LogInformation("라인 항목 처리: SKU={SKU}, ProductType={ProductType}", sku, item.ProductType); switch (sku) { case "com.unity.iap.test.adventure.pass.not": int days = 30; currentInventory.AdventurePass = DateTime.UtcNow.AddDays(days); entitlements.Add($"{days} Days Adventure Pass"); 중지; case "com.unity.iap.test.30.gems": int gems30 = 30; currentInventory.Gems += gems30; entitlements.Add($"{gems30} Gems"); 중지; case "com.unity.iap.test.premium.storage": currentInventory.PremiumStorage = true; entitlements.Add($"PremiumStorage = true"); 중지; Default m_Logger.LogWarning("Unknown SKU {SKU} in fulfillment request", sku); 중지; } } m_Logger.LogInformation("업데이트된 인벤토리: 인벤토리: {인벤토리}", JsonSerializer.Serialize(currentInventory)); m_Logger.LogInformation("Saving player inventory"); await SavePlayerInventory(context, gameApiClient, playerId, currentInventory); m_Logger.LogInformation("Successfully saved player inventory"); } catch(예외) { m_Logger.LogError("Error in GrantProductEntitlements: {Error}", ex.Message); 던지기; } 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. 비공개 비동기 작업<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(예외) { 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(예외) { m_Logger.LogError("인벤토리 JSON 역직렬화 실패: {Error}. JSON: {Json}", 예.Message, inventoryJson); return new PlayerInventory(); } } catch(예외) { 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. 비공개 비동기 작업 SavePlayerInventory( IExecutionContext 컨텍스트, IGameApiClient gameApiClient, string playerId, PlayerInventory 인벤토리) { try { { if (inventory == null) { m_Logger.LogWarning("Attempted to save null inventory, skipping"); 반환; } string inventoryJson; try { { inventoryJson = JsonSerializer.Serialize(inventory); } catch(예외) { m_Logger.LogError("Failed to serialize inventory: {Error}", ex.Message); 던지기; } 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(예외) { m_Logger.LogError("Failed to save inventory: {Error}", ex.Message); 던지기; } } // Sends a PATCH request to the IAP orders API to set the order status to fulfilled; returns true on success. 비공개 비동기 작업<bool> FulfillOrderAsync( string orderId, string projectId, string environmentId, 문자열 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("{Url}에 주문 {OrderId}에 대한 이행 요청 전송", url, orderId); HttpResponseMessage response = await s_HttpClient.SendAsync(request); if (response.IsSuccessStatusCode) { m_Logger.LogInformation("Fulfillment API 호출 성공적으로 주문 {OrderId}. 상태: {StatusCode}", orderId, response.StatusCode); return true; } string responseBody = await response.Content.ReadAsStringAsync(); m_Logger.LogWarning( 주문 {OrderId}에 대한 API 호출 실패 상태: {StatusCode}, " + "응답: {ResponseBody}", orderId, response.StatusCode, responseBody); return false; } catch(예외) { 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? 양 { get; set; } [JsonPropertyName("status")] public string Status { get; set; } = string.Empty; [JsonPropertyName("customReferenceId")] public string? CustomReferenceId { get; set; } [JsonPropertyName("metadata")] public Dictionary<string, string>? 메타데이터 { 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? FulfilledAt { 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? 코드 { get; set; } public string? 설명 { 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()); }}