뒤끝펑션 트랜잭션 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번 항목)를 확인하여 주시면 감사하겠습니다.

제가 문의 드린 부분은 영수증 검증이 아니라 TransactionWriteV2 함수 호출 시 bad bad,signature가 반환되는 문제입니다. 안드로이드 영수증 검증 부분은 오류가 있는 걸 확인 후 이미 수정했습니다.

아니면 말씀해 주신 말 뜻이 뒤끝 서버 DB에 영수증 같이 긴 문자열이 insert 되면 오류가 발생한다는 것인가요?
하지만 ios 경우에는 영수증도 잘 저장되고 아무 에러가 발생하지 않습니다.

혹은 아래와 비슷한 에러일까요?

좋아요 1

뒤끝 펑션으로 영수증을 전달하는 코드는 아래와 같습니다.

public class IAPManager : MonoBehaviour, IDetailedStoreListener
{
        // 상품 구입 요청
        public async UniTask<bool> PurchaseProduct(string prodcutId)
        {
            Product product = m_controller.products.WithID(prodcutId);

            if (product != null)
            {
                m_purchaseCompletionSource = new UniTaskCompletionSource<bool>();

                m_controller.InitiatePurchase(product);

                return await m_purchaseCompletionSource.Task;
            }
            else
            {
                // 없는 아이템은 구입 거부
                return false;
            }
        }

        // 결제 완료
        public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs purchaseEvent)
        {
            ProcessReceipt(purchaseEvent, () =>
            {
                m_controller.ConfirmPendingPurchase(purchaseEvent.purchasedProduct);
            });

            return PurchaseProcessingResult.Pending;   // 영수증 검증 완료 전까지 아이템 지급 보류
        }

        // 영수증 검증 및 아이템 지급 요청
        private async void ProcessReceipt(PurchaseEventArgs purchaseEvent, Action callback)
        {
                var responseJsonData = await NetAPI.Instance.PaidAsset.ProcessReceiptAsync(purchaseEvent.purchasedProduct.receipt, purchaseEvent.purchasedProduct.definition.id);
                ...
        }
}
// 구매 내역 처리 (영수증 검증 + 구매 아이템 지급)
public async UniTask<NetResponseJsonData> ProcessReceiptAsync(string receipt, string productId)
{
        var tcs = new UniTaskCompletionSource<NetResponseJsonData>();

        Param param = new Param();
        param.Add("receipt", receipt);
        param.Add("productId", productId);

        SendQueue.Enqueue(Backend.BFunc.InvokeFunction, "Shop", param, callback =>
        {
                ...
        }
}
좋아요 1

답변이 오래 지연되어 죄송합니다.

bad signature 오류가 발생하는 이유는
구글 영수증의 json데이터가 key에 해당하는 value값이 다시 json형식으로 된 문자열로 설정되어 있어 이를 정상적으로 해석하지 못한 상태로 시그니처를 생성하기 때문으로 확인됩니다.

베이스 SDK 최신 버전에서는 signature방식을 변경하여 복합적 json 형식으로 된 문자열도 정상적으로 처리할 수 있지만 뒤끝펑션은 5.9.6 버전을 기반으로 하고 있어 현재와 같이 에러가 발생합니다.

펑션을 통해 영수증 내용을 저장하셔야 한다면 원본 receipt에서 데이터를 추출하여 custom하게 가공한 후, 하나의 json데이터로 구성해 저장하시는 것을 추천드립니다.

아래의 예시 코드 참고해 주시면 감사하겠습니다.

// 원본 receipt를 json object로 변환.
var receiptOriginJObj = JsonMapper.ToObject(receipt);   

// 원본의 Payload key에 해당하는 문자열 값을 json object로 변환.            
var payloadJObj = JsonMapper.ToObject(receiptOriginJObj["Payload"].ToString());
// Payload에 포함되어 있는 key에 대한 value를 별도의 json object에 추가.
var receiptPayloadJObj = new JsonData();            
receiptPayloadJObj["json"] = JsonMapper.ToObject(payloadJObj["json"].ToString());
receiptPayloadJObj["signature"] = payloadJObj["signature"];

// receipt 데이터를 custom json 구조에 맞게 구성.
var receiptCustomJObj = new JsonData();
receiptCustomJObj["Payload"] = receiptPayloadJObj;
receiptCustomJObj["Store"] = receiptOriginJObj["Store"];
receiptCustomJObj["TransactionID"] = receiptOriginJObj["TransactionID"];

// 트랜잭션을 사용한 구매내역 기록
var transactionList = new List<TransactionValue>();            
var logParam = new Param
{
    { "ItemId", itemId },
    { "Receipt", receiptCustomJObj.ToJson() }
};
transactionList.Add(TransactionValue.SetInsert("table_name", logParam));
var bro = Backend.GameData.TransactionWriteV2(transactionList);
if (!bro.IsSuccess())
{
    Console.WriteLine(bro);
}
좋아요 1