뒤끝펑션 트랜잭션 TransactionWriteV2 중 bad signature

  • 뒤끝 SDK 버전 : SDK 5.11.7 버전 & 뒤끝 펑션 [2025-02-06] 버전
  • 프로젝트명 : golem
  • 스테이터스 코드 : 401
  • 에러 코드 : BadUnauthorizedException
  • 에러 메시지 : bad bad,signature,잘못된,signature,입니다,잘못된 bad,signature,잘못된,signature,입니다

TransactionWriteV2 를 통해 두개의 테이블에 동시에 insert 요청을 했습니다.
뒤끝펑션 디버그 환경이나 pc에서는 문제 없이 잘 동작했습니다.
하지만 안드로이드 빌드 환경에서는 TransactionWriteV2 함수에서 401 에러가 발생하며 정상적으로 작동하지 않았습니다.
아래는 코드입니다.

        // 아이템을 지급하고 기존 데이터에 합산
        private BackendReturnObject GrantAllRewards(JsonData playerPaidAsset, ChartItem purchasedItem, out string resultItems, Param purchasesLog)
        {
            // 서버 전송용
            Param paidAssetParam = new Param();

            // TransactionWriteV2 파라미터 객체 생성
            List<TransactionValue> transactionList = new List<TransactionValue>();

            // 유료 재화 테이블 삽입
            if (playerPaidAsset == null)
            {
                JsonData newPaidItems = new JsonData();
                newPaidItems = CreateNewItemData(purchasedItem);
                paidAssetParam.Add(ItemsKey, newPaidItems.ToJson());
                resultItems = newPaidItems.ToJson();

                // paidAssetParam에 대한 Insert 작업을 트랜잭션에 추가
                transactionList.Add(TransactionValue.SetInsert(PlayerPaidAssetTableName, paidAssetParam));
            }
            // 유료 재화 테이블 업데이트
            else
            {
                JsonData playerPaidItems = GetPlayerPaidItems(playerPaidAsset); // asset 에서 items 뽑아오기
                UpdateExistingItemData(playerPaidItems, purchasedItem);  // 현재 items에서 구매한 아이템 추가
                paidAssetParam.Add(ItemsKey, playerPaidItems.ToJson());  // 서버에 전송을 위해 param에 추가
                resultItems = playerPaidItems.ToJson();   // 변경된 아이템 반환을 위해 asset에 반영

                // paidAssetParam에 대한 Update 작업을 트랜잭션에 추가
                transactionList.Add(TransactionValue.SetUpdate(PlayerPaidAssetTableName, new Where(), paidAssetParam));
            }

            // 구매 기록 남기기
            transactionList.Add(TransactionValue.SetInsert(PlayerPurchasesTableName, purchasesLog));

            // 두 작업(유료 아이템 지급/업데이트와 구매 내역 추가)을 하나의 트랜잭션으로 실행합니다.
            return Backend.GameData.TransactionWriteV2(transactionList);
        }

혹시 전체 코드가 필요하시면 아래 코드를 참고해주세요.

전체 코드
using Amazon.Lambda.Core;
using BackEnd;
using LitJson;
using System;
using System.Collections.Generic;
using System.IO;

[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.Json.JsonSerializer))]

namespace BackendFunction
{
    public class BFunc
    {
        private const string PlayerPaidAssetTableName = "PlayerPaidAsset";
        private const string PlayerPurchasesTableName = "PlayerPurchaseHistory";
        private const string ItemsKey = "Items";
        private const string PerksKey = "Perks";
        private const string IsSuccessKey = "IsSuccess";
        private const string ErrorKey = "Error";

        public Stream Function(Stream stream, ILambdaContext context)
        {
            var result = new Param();
            result.Add(IsSuccessKey, false);

            if (!InitializeBackend(ref stream, result))
            {
                return ReturnErrorObject(result, "Backend initialization failed");
            }

            if (!ValidateRequiredKeys(result))
            {
                return ReturnErrorObject(result, "Missing required keys: productId, receipt");
            }
            string productId = Backend.Content["productId"].ToString();
            string receipt = Backend.Content["receipt"].ToString();

            // 영수증 검증
            if (!ValidateReceipt(receipt, result, out string errorLog))
            {
                return ReturnErrorObject(result, errorLog);
            }

            var purchasedItem = GetChartItem(productId);
            if (purchasedItem == null)
            {
                return ReturnErrorObject(result, "Invalid productId");
            }

            var playerPaidAsset = GetPlayerPaidAsset();

            Param purchasesLog = new Param
            {
                { "ItemId", purchasedItem.itemId },
                { "Receipt", receipt }
            };

            var rewardResult = GrantAllRewards(playerPaidAsset, purchasedItem, out string resultItems, purchasesLog);
            if (rewardResult == null || !rewardResult.IsSuccess())
            {
                return ReturnErrorObject(result, "Failed to grant reward: " + rewardResult.GetMessage());
            }

            result[IsSuccessKey] = true;
            result.Add(ItemsKey, resultItems);
            result.Add("Purchases", purchasesLog);

            return Backend.StringToStream(result.GetJson());
        }

        private bool InitializeBackend(ref Stream stream, Param result)
        {
            try
            {
                Backend.Initialize(ref stream);
                return true;
            }
            catch (Exception e)
            {
                ReturnErrorObject(result, "Initialize failed: " + e.ToString());
                return false;
            }
        }

        private bool ValidateRequiredKeys(Param result)
        {
            return Backend.HasKey("productId") && Backend.HasKey("receipt");
        }

        private bool ValidateReceipt(string receipt, Param result, out string errorLog)
        {
            errorLog = "";
            try
            {
                var receiptJson = JsonMapper.ToObject(receipt);
                var payload = receiptJson["Payload"];
                var store = receiptJson["Store"];

                if (payload == null || store == null)
                {
                    errorLog = "Invalid receipt structure";
                    return false;
                }

                if (store.ToString().Equals("GooglePlay", StringComparison.OrdinalIgnoreCase))
                {
                    bool isValid = GoogleReceipt(payload, out errorLog);
                    return isValid;
                }
                else if (store.ToString().Equals("fake", StringComparison.OrdinalIgnoreCase))
                {
                    return true;
                }
                return true;
            }
            catch (Exception ex)
            {
                errorLog = "Unexpected error occurred: " + ex.Message;
                return false;
            }
        }

        private bool AppleReceipt(JsonData payload)
        {
            return false;
        }

        private bool GoogleReceipt(JsonData payload, out string validationError)
        {
            validationError = "";
            try
            {
                var json = JsonMapper.ToObject(JsonMapper.ToObject(payload.ToString())["json"].ToString());
                if (json == null)
                {
                    validationError = "json is null";
                    return false;
                }

                string purchaseToken = json["purchaseToken"].ToString();
                string productId = json["productId"].ToString();

                var validation = Backend.Receipt.IsValidateGooglePurchase(productId, purchaseToken, "receiptDescription");
                if (!validation.IsSuccess())
                {
                    validationError = validation.GetMessage();
                    return false;
                }

                return true;
            }
            catch (Exception ex)
            {
                validationError = "Google receipt validation failed: " + ex.Message;
                return false;
            }
        }

        // 아이템을 지급하고 기존 데이터에 합산
        private BackendReturnObject GrantAllRewards(JsonData playerPaidAsset, ChartItem purchasedItem, out string resultItems, Param purchasesLog)
        {
            // 서버 전송용
            Param paidAssetParam = new Param();

            // TransactionWriteV2 파라미터 객체 생성
            List<TransactionValue> transactionList = new List<TransactionValue>();

            // 유료 재화 테이블 삽입
            if (playerPaidAsset == null)
            {
                JsonData newPaidItems = new JsonData();
                newPaidItems = CreateNewItemData(purchasedItem);
                paidAssetParam.Add(ItemsKey, newPaidItems.ToJson());
                resultItems = newPaidItems.ToJson();

                // paidAssetParam에 대한 Insert 작업을 트랜잭션에 추가
                transactionList.Add(TransactionValue.SetInsert(PlayerPaidAssetTableName, paidAssetParam));
            }
            // 유료 재화 테이블 업데이트
            else
            {
                JsonData playerPaidItems = GetPlayerPaidItems(playerPaidAsset); // asset 에서 items 뽑아오기
                UpdateExistingItemData(playerPaidItems, purchasedItem);  // 현재 items에서 구매한 아이템 추가
                paidAssetParam.Add(ItemsKey, playerPaidItems.ToJson());  // 서버에 전송을 위해 param에 추가
                resultItems = playerPaidItems.ToJson();   // 변경된 아이템 반환을 위해 asset에 반영

                // paidAssetParam에 대한 Update 작업을 트랜잭션에 추가
                transactionList.Add(TransactionValue.SetUpdate(PlayerPaidAssetTableName, new Where(), paidAssetParam));
            }

            // 구매 기록 남기기
            transactionList.Add(TransactionValue.SetInsert(PlayerPurchasesTableName, purchasesLog));

            // 두 작업(유료 아이템 지급/업데이트와 구매 내역 추가)을 하나의 트랜잭션으로 실행합니다.
            return Backend.GameData.TransactionWriteV2(transactionList);
        }


        // 새로운 아이템 데이터 생성
        private JsonData CreateNewItemData(ChartItem item)
        {
            JsonData itemsArray = new JsonData();
            for (int i = 0; i < item.itemIds.Length && i < item.itemCounts.Length; i++)
            {
                if (item.itemIds[i] != 0 && item.itemCounts[i] > 0)
                {
                    JsonData newItem = new JsonData();
                    newItem["id"] = item.itemIds[i];
                    newItem["count"] = item.itemCounts[i];
                    itemsArray.Add(newItem);
                }
            }
            return itemsArray;
        }

        // 기존 아이템 데이터 업데이트
        private void UpdateExistingItemData(JsonData playerPaidItems, ChartItem item)
        {
            for (int i = 0; i < item.itemIds.Length && i < item.itemCounts.Length; i++)
            {
                if (item.itemIds[i] != 0 && item.itemCounts[i] > 0)
                {
                    UpdateOrAddItem(playerPaidItems, item.itemIds[i], item.itemCounts[i]);
                }
            }
        }

        // 아이템 추가
        private void UpdateOrAddItem(JsonData items, int id, int count)
        {
            foreach (JsonData item in items)
            {
                if (int.TryParse(item["id"].ToString(), out int itemId))
                {
                    if (itemId == id)
                    {
                        if (int.TryParse(item["count"].ToString(), out int currentCount))
                        {
                            item["count"] = currentCount + count;
                        }
                        return;
                    }
                }
            }
            JsonData newItem = new JsonData { ["id"] = id, ["count"] = count };
            items.Add(newItem);
        }

        private JsonData GetPlayerPaidItems(JsonData asset)
        {
            if (asset == null || !asset.ContainsKey(ItemsKey))
            {
                Console.WriteLine("playerPaidAsset에 Items 키가 없거나 null입니다.");
                return new JsonData(); // 빈 JsonData 반환
            }

            JsonData items = JsonMapper.ToObject(asset[ItemsKey].ToString());

            if (!items.IsArray)
            {
                Console.WriteLine("Items 데이터가 배열 형식이 아닙니다.");
                items = new JsonData();
                items.SetJsonType(JsonType.Array);
            }

            return items;
        }

        private JsonData GetPlayerPaidAsset()
        {
            BackendReturnObject returnObject = Backend.GameData.GetMyData(PlayerPaidAssetTableName, new Where(), 1);
            if (returnObject.IsSuccess() && returnObject.GetFlattenJSON()["rows"].Count > 0)
            {
                return returnObject.GetFlattenJSON()["rows"][0];
            }
            return null;
        }

        private ChartItem GetChartItem(string productId)
        {
            string selectedProbabilityFileId = "163901";
            var bro = Backend.Chart.GetChartContents(selectedProbabilityFileId);
            if (!bro.IsSuccess()) return null;

            foreach (LitJson.JsonData data in bro.FlattenRows())
            {
                if (data["productId"].ToString() == productId)
                {
                    ChartItem item = new ChartItem();
                    item.SetData(data);
                    return item;
                }
            }
            return null;
        }

        static Stream ReturnErrorObject(Param result, string err)
        {
            result.Add(ErrorKey, err);
            return Backend.StringToStream(result.GetJson());
        }
    }

    // 뒤끝의 기본 제공 차트를 이용하면 만든 아이템입니다.  
    public class ChartItem
    {
        public int itemId;
        public string productId;
        public string image;
        public string category;
        public string name;
        public string desc;
        public string subdesc;
        public string costtype;
        public float priceUS;
        public float priceKR;
        public int shopstock;
        public int restocktime;
        public int[] itemIds = new int[8];
        public int[] itemCounts = new int[8];

        public void SetData(JsonData jsonData)
        {
            foreach (var field in GetType().GetFields())
            {
                if (jsonData.ContainsKey(field.Name))
                {
                    if (field.FieldType == typeof(int))
                        field.SetValue(this, int.Parse(jsonData[field.Name].ToString()));
                    else if (field.FieldType == typeof(float))
                        field.SetValue(this, float.Parse(jsonData[field.Name].ToString()));
                    else
                        field.SetValue(this, jsonData[field.Name].ToString());
                }
            }

            // item & itemcount 배열 처리
            for (int i = 0; i < 8; i++)
            {
                string itemKey = $"item{i + 1}";
                string valueKey = $"itemcount{i + 1}";

                if (jsonData.ContainsKey(itemKey))
                {
                    itemIds[i] = int.TryParse(jsonData[itemKey].ToString(), out int val) ? val : 0;
                }

                if (jsonData.ContainsKey(valueKey))
                {
                    itemCounts[i] = int.TryParse(jsonData[valueKey].ToString(), out int val) ? val : 0;
                }
            }
        }

        public override string ToString()
        {
            return $"itemId : {itemId}\n" +
                   $"name : {name}\n" +
                   $"desc : {desc}\n" +
                   $"priceUS : {priceUS}, priceKR : {priceKR}\n" +
                   $"shopstock : {shopstock}, restocktime : {restocktime}\n" +
                   $"items : {string.Join(", ", itemIds)}\n" +
                   $"values : {string.Join(", ", itemCounts)}";
        }
    }
}

안녕하세요 개발자님,
관련 내용 상세히 확인하여 안내드릴 수 있도록 하겠습니다.
다소 시간이 소요될 수 있는 점 양해 부탁드립니다.

해당 요청에 실패하였을 때, Receipt로 전달되는 문자열이 문제의 원인을 예상되며,
펑션 호출을 위해 영수증 정보를 전달한 데이터부터 문제가 있는 것으로 예상됩니다.

실제 결제 후, 펑션 호출을 할 때, 어떻게 펑션으로 데이터를 전달하는 지 코드 확인이 필요합니다.
관련 코드 정보도 공유하여 주시면 감사하겠습니다.

추가 안내드립니다.

뒤끝펑션에서 안드로이드의 영수증 검증을 이용하는 방식은 기본 영수증 검증 호출 방식과는 상이하게 제공되고 있습니다.
일반 호출에서는 receipt 정보를 그대로 넣는 방식으로 제공되나,
펑션은 내부에서 token을 직접 추출하여 넣어주는 방식으로 제공됩니다.

관련하여서는 아래 개발자 문서(7번 항목)를 확인하여 주시면 감사하겠습니다.