From d2fb5c11b86b270f2c781d972b3e4d1ef51bf8fe Mon Sep 17 00:00:00 2001
From: Yvan <8574526@qq,com>
Date: Fri, 21 Aug 2026 00:28:22 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=BB=93=E6=9E=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
pkg/httpx/response.go | 43 +++++++++++++++
pkg/paymentkit/README.md | 5 ++
pkg/paymentkit/amount.go | 97 +++++++++++++++++++++++++++++++++
pkg/paymentkit/amount_test.go | 25 +++++++++
pkg/paymentkit/callback.go | 91 +++++++++++++++++++++++++++++++
pkg/paymentkit/callback_test.go | 37 +++++++++++++
pkg/paymentkit/json.go | 66 ++++++++++++++++++++++
pkg/paymentkit/json_test.go | 16 ++++++
pkg/paymentkit/signing.go | 58 ++++++++++++++++++++
pkg/paymentkit/signing_test.go | 12 ++++
pkg/paymentkit/status.go | 63 +++++++++++++++++++++
pkg/paymentkit/xml.go | 60 ++++++++++++++++++++
pkg/protoutil/json.go | 80 +++++++++++++++++++++++++++
13 files changed, 653 insertions(+)
create mode 100644 pkg/httpx/response.go
create mode 100644 pkg/paymentkit/README.md
create mode 100644 pkg/paymentkit/amount.go
create mode 100644 pkg/paymentkit/amount_test.go
create mode 100644 pkg/paymentkit/callback.go
create mode 100644 pkg/paymentkit/callback_test.go
create mode 100644 pkg/paymentkit/json.go
create mode 100644 pkg/paymentkit/json_test.go
create mode 100644 pkg/paymentkit/signing.go
create mode 100644 pkg/paymentkit/signing_test.go
create mode 100644 pkg/paymentkit/status.go
create mode 100644 pkg/paymentkit/xml.go
create mode 100644 pkg/protoutil/json.go
diff --git a/pkg/httpx/response.go b/pkg/httpx/response.go
new file mode 100644
index 0000000..a4d4351
--- /dev/null
+++ b/pkg/httpx/response.go
@@ -0,0 +1,43 @@
+// Package httpx contains the stable JSON response contract shared by HTTP
+// modules. It intentionally keeps the existing Gin adapter so callers can
+// migrate without changing their handler flow.
+package httpx
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+const (
+ CodeSuccess = 0
+ CodeError = 7
+ CodePasswordChangeRequired = 10001
+)
+
+type Response struct {
+ Code int `json:"code"`
+ Data any `json:"data"`
+ Msg string `json:"msg"`
+}
+
+type PageResult struct {
+ List any `json:"list"`
+ Total int64 `json:"total"`
+ Page int `json:"page"`
+ PageSize int `json:"pageSize"`
+}
+
+func Write(c *gin.Context, code int, data any, message string) {
+ c.JSON(http.StatusOK, Response{Code: code, Data: data, Msg: message})
+}
+
+func OK(c *gin.Context) { Write(c, CodeSuccess, gin.H{}, "操作成功") }
+
+func OKWithData(c *gin.Context, data any) { Write(c, CodeSuccess, data, "成功") }
+
+func Fail(c *gin.Context, message string) { Write(c, CodeError, gin.H{}, message) }
+
+func NoAuth(c *gin.Context, message string) {
+ c.AbortWithStatusJSON(http.StatusUnauthorized, Response{Code: CodeError, Data: nil, Msg: message})
+}
diff --git a/pkg/paymentkit/README.md b/pkg/paymentkit/README.md
new file mode 100644
index 0000000..130aa2c
--- /dev/null
+++ b/pkg/paymentkit/README.md
@@ -0,0 +1,5 @@
+# Payment Utilities
+
+Stateless payment protocol helpers live here: amount conversion, status
+normalization, JSON/XML extraction, and request signing. Provider adapters and
+their SDK clients remain under `app/system/internal/integration/payment`.
diff --git a/pkg/paymentkit/amount.go b/pkg/paymentkit/amount.go
new file mode 100644
index 0000000..180fe74
--- /dev/null
+++ b/pkg/paymentkit/amount.go
@@ -0,0 +1,97 @@
+package paymentutil
+
+import (
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+)
+
+// ParseIntegerAmount parses an integer amount expressed in the smallest
+// currency unit. Floating point is intentionally not involved.
+func ParseIntegerAmount(value string) (int64, error) {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return 0, errors.New("支付金额为空")
+ }
+ return strconv.ParseInt(value, 10, 64)
+}
+
+// ParseDecimalAmount converts a major-unit decimal string to an integer amount
+// using a power-of-ten scale, without using floating point.
+func ParseDecimalAmount(value string, scale int64) (int64, error) {
+ value = strings.TrimSpace(value)
+ if value == "" || scale <= 0 {
+ return 0, errors.New("支付金额或换算比例无效")
+ }
+ if strings.HasPrefix(value, "-") {
+ return 0, errors.New("支付金额不能为负数")
+ }
+ parts := strings.Split(value, ".")
+ if len(parts) > 2 || parts[0] == "" {
+ return 0, fmt.Errorf("支付金额格式错误: %s", value)
+ }
+ digits, err := scaleDigits(scale)
+ if err != nil {
+ return 0, err
+ }
+ whole, err := strconv.ParseInt(parts[0], 10, 64)
+ if err != nil {
+ return 0, err
+ }
+ fraction := ""
+ if len(parts) == 2 {
+ fraction = parts[1]
+ }
+ if len(fraction) > digits {
+ return 0, fmt.Errorf("支付金额精度超过 %d 位", digits)
+ }
+ fraction += strings.Repeat("0", digits-len(fraction))
+ fractionValue := int64(0)
+ if fraction != "" {
+ fractionValue, err = strconv.ParseInt(fraction, 10, 64)
+ if err != nil {
+ return 0, err
+ }
+ }
+ if whole > (int64(^uint64(0)>>1)-fractionValue)/scale {
+ return 0, errors.New("支付金额超出范围")
+ }
+ return whole*scale + fractionValue, nil
+}
+
+func FormatDecimalAmount(amount, scale int64) (string, error) {
+ if amount <= 0 || scale <= 0 {
+ return "", errors.New("支付金额或换算比例无效")
+ }
+ digits, err := scaleDigits(scale)
+ if err != nil {
+ return "", err
+ }
+ if digits == 0 {
+ return strconv.FormatInt(amount, 10), nil
+ }
+ return fmt.Sprintf("%d.%0*d", amount/scale, digits, amount%scale), nil
+}
+
+func scaleDigits(scale int64) (int, error) {
+ digits := 0
+ for n := scale; n > 1 && n%10 == 0; n /= 10 {
+ digits++
+ }
+ if scale <= 0 || scale != pow10(digits) {
+ return 0, errors.New("支付金额换算比例必须是 10 的幂")
+ }
+ return digits, nil
+}
+
+func pow10(digits int) int64 {
+ value := int64(1)
+ for i := 0; i < digits; i++ {
+ if value > (int64(^uint64(0)>>1))/10 {
+ return 0
+ }
+ value *= 10
+ }
+ return value
+}
diff --git a/pkg/paymentkit/amount_test.go b/pkg/paymentkit/amount_test.go
new file mode 100644
index 0000000..1d82d33
--- /dev/null
+++ b/pkg/paymentkit/amount_test.go
@@ -0,0 +1,25 @@
+package paymentutil
+
+import "testing"
+
+func TestDecimalAmountUsesIntegerMath(t *testing.T) {
+ got, err := ParseDecimalAmount("12.34", 100)
+ if err != nil || got != 1234 {
+ t.Fatalf("ParseDecimalAmount = %d, %v", got, err)
+ }
+ formatted, err := FormatDecimalAmount(got, 100)
+ if err != nil || formatted != "12.34" {
+ t.Fatalf("FormatDecimalAmount = %q, %v", formatted, err)
+ }
+}
+
+func TestDecimalAmountRejectsUnsafeInput(t *testing.T) {
+ for _, value := range []string{"-1.00", "1.001", "1.2.3"} {
+ if _, err := ParseDecimalAmount(value, 100); err == nil {
+ t.Fatalf("ParseDecimalAmount(%q) accepted unsafe input", value)
+ }
+ }
+ if _, err := ParseDecimalAmount("1", 3); err == nil {
+ t.Fatal("non power-of-ten scale accepted")
+ }
+}
diff --git a/pkg/paymentkit/callback.go b/pkg/paymentkit/callback.go
new file mode 100644
index 0000000..3fac0d7
--- /dev/null
+++ b/pkg/paymentkit/callback.go
@@ -0,0 +1,91 @@
+package paymentutil
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+)
+
+const (
+ ProviderAlipay = "alipay"
+ ProviderAlipayV3 = "alipay-v3"
+ ProviderWechatV2 = "wechat-v2"
+ ProviderWechatV3 = "wechat-v3"
+ ProviderApple = "apple-iap"
+)
+
+// CallbackAck is the provider-facing HTTP acknowledgement returned after a
+// payment notification is processed.
+type CallbackAck struct {
+ StatusCode int
+ ContentType string
+ Body []byte
+}
+
+// CallbackError carries a safe provider acknowledgement alongside the cause
+// that should be logged or returned to the application layer.
+type CallbackError struct {
+ Cause error
+ Ack CallbackAck
+}
+
+func (e *CallbackError) Error() string {
+ if e == nil || e.Cause == nil {
+ return "支付回调处理失败"
+ }
+ return e.Cause.Error()
+}
+
+func (e *CallbackError) Unwrap() error {
+ if e == nil {
+ return nil
+ }
+ return e.Cause
+}
+
+func CallbackFailure(err error, fallback CallbackAck) CallbackAck {
+ var callbackErr *CallbackError
+ if errors.As(err, &callbackErr) && callbackErr.Ack.StatusCode != 0 {
+ return callbackErr.Ack
+ }
+ return fallback
+}
+
+// DefaultCallbackAck returns the conventional acknowledgement for providers
+// whose callback wire formats are stable across modules.
+func DefaultCallbackAck(provider string, success bool) CallbackAck {
+ status := 200
+ if !success {
+ status = 500
+ }
+ switch provider {
+ case ProviderAlipay, ProviderAlipayV3:
+ body := "success"
+ if !success {
+ body = "failure"
+ }
+ return CallbackAck{StatusCode: status, ContentType: "text/plain; charset=utf-8", Body: []byte(body)}
+ case ProviderWechatV2:
+ code, message := "SUCCESS", "OK"
+ if !success {
+ code, message = "FAIL", "FAIL"
+ }
+ body := fmt.Sprintf("", code, message)
+ return CallbackAck{StatusCode: status, ContentType: "application/xml; charset=utf-8", Body: []byte(body)}
+ case ProviderWechatV3:
+ code, message := "SUCCESS", "成功"
+ if !success {
+ code, message = "FAIL", "失败"
+ }
+ body, _ := json.Marshal(map[string]string{"code": code, "message": message})
+ return CallbackAck{StatusCode: status, ContentType: "application/json; charset=utf-8", Body: body}
+ case ProviderApple:
+ return CallbackAck{StatusCode: status}
+ default:
+ body := "success"
+ if !success {
+ body = "failure"
+ }
+ return CallbackAck{StatusCode: status, ContentType: "text/plain; charset=utf-8", Body: []byte(body)}
+ }
+}
diff --git a/pkg/paymentkit/callback_test.go b/pkg/paymentkit/callback_test.go
new file mode 100644
index 0000000..06e697a
--- /dev/null
+++ b/pkg/paymentkit/callback_test.go
@@ -0,0 +1,37 @@
+package paymentutil
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestDefaultCallbackAck(t *testing.T) {
+ cases := []struct {
+ provider string
+ success bool
+ status int
+ contentType string
+ body string
+ }{
+ {ProviderAlipay, true, 200, "text/plain; charset=utf-8", "success"},
+ {ProviderWechatV2, false, 500, "application/xml; charset=utf-8", ""},
+ {ProviderWechatV3, true, 200, "application/json; charset=utf-8", `{"code":"SUCCESS","message":"成功"}`},
+ {ProviderApple, true, 200, "", ""},
+ }
+ for _, tc := range cases {
+ ack := DefaultCallbackAck(tc.provider, tc.success)
+ if ack.StatusCode != tc.status || ack.ContentType != tc.contentType || string(ack.Body) != tc.body {
+ t.Fatalf("%s ack = %#v", tc.provider, ack)
+ }
+ }
+}
+
+func TestCallbackFailureUsesProviderAck(t *testing.T) {
+ fallback := DefaultCallbackAck(ProviderAlipay, false)
+ want := DefaultCallbackAck(ProviderWechatV3, false)
+ err := &CallbackError{Cause: errors.New("provider rejected"), Ack: want}
+ got := CallbackFailure(err, fallback)
+ if got.StatusCode != want.StatusCode || string(got.Body) != string(want.Body) {
+ t.Fatalf("ack = %#v, want %#v", got, want)
+ }
+}
diff --git a/pkg/paymentkit/json.go b/pkg/paymentkit/json.go
new file mode 100644
index 0000000..c31d6bc
--- /dev/null
+++ b/pkg/paymentkit/json.go
@@ -0,0 +1,66 @@
+package paymentutil
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+)
+
+func JSONObject(raw []byte) map[string]any {
+ var value any
+ if json.Unmarshal(raw, &value) != nil {
+ return nil
+ }
+ object, _ := value.(map[string]any)
+ return object
+}
+
+func NestedString(value any, keys ...string) string {
+ found := nestedValue(value, keys...)
+ if found == nil {
+ return ""
+ }
+ return strings.TrimSpace(fmt.Sprint(found))
+}
+
+func StringAtPath(value any, path string) string {
+ found := ValueAtPath(value, path)
+ if found == nil {
+ return ""
+ }
+ return strings.TrimSpace(fmt.Sprint(found))
+}
+
+func ValueAtPath(value any, path string) any {
+ path = strings.TrimSpace(path)
+ if path == "" {
+ return nil
+ }
+ current := value
+ for _, part := range strings.Split(path, ".") {
+ object, ok := current.(map[string]any)
+ if !ok {
+ return nil
+ }
+ current = object[part]
+ }
+ return current
+}
+
+func nestedValue(value any, keys ...string) any {
+ object, ok := value.(map[string]any)
+ if !ok {
+ return nil
+ }
+ for _, key := range keys {
+ if found, exists := object[key]; exists && found != nil && fmt.Sprint(found) != "" {
+ return found
+ }
+ }
+ for _, nestedKey := range []string{"data", "result", "order", "transaction", "response"} {
+ if found := nestedValue(object[nestedKey], keys...); found != nil {
+ return found
+ }
+ }
+ return nil
+}
diff --git a/pkg/paymentkit/json_test.go b/pkg/paymentkit/json_test.go
new file mode 100644
index 0000000..34f46e2
--- /dev/null
+++ b/pkg/paymentkit/json_test.go
@@ -0,0 +1,16 @@
+package paymentutil
+
+import "testing"
+
+func TestNestedAndPathValues(t *testing.T) {
+ object := JSONObject([]byte(`{"data":{"status":"SUCCESS","amount":100}}`))
+ if got := NestedString(object, "status"); got != "SUCCESS" {
+ t.Fatalf("NestedString = %q", got)
+ }
+ if got := StringAtPath(object, "data.amount"); got != "100" {
+ t.Fatalf("StringAtPath = %q", got)
+ }
+ if got := StringAtPath(object, "data.missing"); got != "" {
+ t.Fatalf("missing path = %q", got)
+ }
+}
diff --git a/pkg/paymentkit/signing.go b/pkg/paymentkit/signing.go
new file mode 100644
index 0000000..7ba4b15
--- /dev/null
+++ b/pkg/paymentkit/signing.go
@@ -0,0 +1,58 @@
+package paymentutil
+
+import (
+ "crypto/hmac"
+ "crypto/md5"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "net/url"
+ "sort"
+ "strings"
+)
+
+func WechatV2Sign(values map[string]string, key, signType string) string {
+ keys := make([]string, 0, len(values))
+ for k, v := range values {
+ if k != "sign" && v != "" {
+ keys = append(keys, k)
+ }
+ }
+ sort.Strings(keys)
+ parts := make([]string, 0, len(keys))
+ for _, k := range keys {
+ parts = append(parts, k+"="+values[k])
+ }
+ raw := strings.Join(parts, "&") + "&key=" + key
+ if strings.EqualFold(signType, "HMAC-SHA256") {
+ return HMACSHA256Hex([]byte(raw), key, true)
+ }
+ sum := md5.Sum([]byte(raw))
+ return strings.ToUpper(hex.EncodeToString(sum[:]))
+}
+
+func MD5Canonical(values map[string]any, secret string) string {
+ keys := make([]string, 0, len(values))
+ for key, value := range values {
+ if key != "sign" && value != nil && fmt.Sprint(value) != "" {
+ keys = append(keys, key)
+ }
+ }
+ sort.Strings(keys)
+ query := url.Values{}
+ for _, key := range keys {
+ query.Set(key, fmt.Sprint(values[key]))
+ }
+ sum := md5.Sum([]byte(query.Encode() + "&key=" + secret))
+ return strings.ToUpper(hex.EncodeToString(sum[:]))
+}
+
+func HMACSHA256Hex(raw []byte, secret string, upper bool) string {
+ h := hmac.New(sha256.New, []byte(secret))
+ _, _ = h.Write(raw)
+ result := hex.EncodeToString(h.Sum(nil))
+ if upper {
+ return strings.ToUpper(result)
+ }
+ return result
+}
diff --git a/pkg/paymentkit/signing_test.go b/pkg/paymentkit/signing_test.go
new file mode 100644
index 0000000..a85e011
--- /dev/null
+++ b/pkg/paymentkit/signing_test.go
@@ -0,0 +1,12 @@
+package paymentutil
+
+import "testing"
+
+func TestWechatV2SignIsStableAndExcludesSign(t *testing.T) {
+ values := map[string]string{"b": "2", "a": "1", "sign": "old"}
+ first := WechatV2Sign(values, "secret", "MD5")
+ values["sign"] = "different"
+ if second := WechatV2Sign(values, "secret", "MD5"); first != second {
+ t.Fatalf("signature changed when sign field changed: %q != %q", first, second)
+ }
+}
diff --git a/pkg/paymentkit/status.go b/pkg/paymentkit/status.go
new file mode 100644
index 0000000..12b6030
--- /dev/null
+++ b/pkg/paymentkit/status.go
@@ -0,0 +1,63 @@
+package paymentutil
+
+import (
+ "fmt"
+ "strings"
+)
+
+func NormalizeStatus(value, fallback string) string {
+ switch strings.ToUpper(strings.TrimSpace(value)) {
+ case "SUCCESS", "PAID", "TRADE_SUCCESS", "TRADE_FINISHED", "COMPLETED", "PAY_SUCCESS":
+ return "success"
+ case "WAIT_BUYER_PAY", "USERPAYING", "NOTPAY", "PROCESSING", "PENDING", "CREATED", "ACCEPT", "PAYING":
+ return "pending"
+ case "CLOSED", "TRADE_CLOSED", "CANCELLED", "CANCELED", "REVOKED", "REFUND", "REFUNDED", "FAILED", "FAIL", "PAYERROR":
+ return "failed"
+ default:
+ return fallback
+ }
+}
+
+func ConfiguredInt64(values map[string]any, key string, fallback int64) int64 {
+ value, ok := values[key]
+ if !ok {
+ return fallback
+ }
+ parsed, err := ParseIntegerAmount(strings.TrimSpace(toString(value)))
+ if err != nil || parsed <= 0 {
+ return fallback
+ }
+ return parsed
+}
+
+func ConfiguredValues(values map[string]any, key string) []string {
+ result := []string{}
+ for _, item := range strings.Split(toString(values[key]), ",") {
+ item = strings.ToUpper(strings.TrimSpace(item))
+ if item != "" {
+ result = append(result, item)
+ }
+ }
+ return result
+}
+
+func ContainsFold(values []string, value string) bool {
+ value = strings.ToUpper(strings.TrimSpace(value))
+ for _, item := range values {
+ if item == value {
+ return true
+ }
+ }
+ return false
+}
+
+func toString(value any) string {
+ if value == nil {
+ return ""
+ }
+ return strings.TrimSpace(strings.ReplaceAll(strings.TrimSpace(stringify(value)), "", ""))
+}
+
+func stringify(value any) string {
+ return fmt.Sprint(value)
+}
diff --git a/pkg/paymentkit/xml.go b/pkg/paymentkit/xml.go
new file mode 100644
index 0000000..6c46733
--- /dev/null
+++ b/pkg/paymentkit/xml.go
@@ -0,0 +1,60 @@
+package paymentutil
+
+import (
+ "bytes"
+ "encoding/xml"
+ "io"
+ "sort"
+ "strings"
+)
+
+type xmlValue struct {
+ XMLName xml.Name
+ Value string `xml:",chardata"`
+}
+
+func XMLValues(raw []byte) (map[string]string, error) {
+ decoder := xml.NewDecoder(bytes.NewReader(raw))
+ result := map[string]string{}
+ for {
+ token, err := decoder.Token()
+ if err == io.EOF {
+ return result, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ start, ok := token.(xml.StartElement)
+ if !ok || start.Name.Local == "xml" {
+ continue
+ }
+ var value xmlValue
+ if err = decoder.DecodeElement(&value, &start); err != nil {
+ return nil, err
+ }
+ if value.Value != "" {
+ result[start.Name.Local] = value.Value
+ }
+ }
+}
+
+func XMLEncode(values map[string]string) []byte {
+ var b strings.Builder
+ b.WriteString("")
+ keys := make([]string, 0, len(values))
+ for key := range values {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ for _, key := range keys {
+ b.WriteString("<")
+ b.WriteString(key)
+ b.WriteString(">")
+ b.WriteString(key)
+ b.WriteString(">")
+ }
+ b.WriteString("")
+ return []byte(b.String())
+}
diff --git a/pkg/protoutil/json.go b/pkg/protoutil/json.go
new file mode 100644
index 0000000..dd0725d
--- /dev/null
+++ b/pkg/protoutil/json.go
@@ -0,0 +1,80 @@
+// Package protoutil contains protobuf helpers that are independent of any
+// application module.
+package protoutil
+
+import (
+ "encoding/json"
+ "strings"
+
+ "google.golang.org/protobuf/encoding/protojson"
+ "google.golang.org/protobuf/proto"
+)
+
+// MergeProtoJSON applies a JSON object as a partial protobuf update while
+// retaining fields omitted by the caller.
+func MergeProtoJSON(target proto.Message, patch json.RawMessage, options protojson.UnmarshalOptions) error {
+ currentRaw, err := protojson.MarshalOptions{UseProtoNames: false}.Marshal(target)
+ if err != nil {
+ return err
+ }
+ var current map[string]any
+ if err = json.Unmarshal(currentRaw, ¤t); err != nil {
+ return err
+ }
+ var incoming map[string]any
+ if err = json.Unmarshal(patch, &incoming); err != nil {
+ return err
+ }
+ incoming = normalizeJSONKeys(incoming).(map[string]any)
+ mergeJSONObjects(current, incoming)
+ merged, err := json.Marshal(current)
+ if err != nil {
+ return err
+ }
+ return options.Unmarshal(merged, target)
+}
+
+func normalizeJSONKeys(value any) any {
+ switch item := value.(type) {
+ case map[string]any:
+ result := make(map[string]any, len(item))
+ for key, nested := range item {
+ result[snakeToLowerCamel(key)] = normalizeJSONKeys(nested)
+ }
+ return result
+ case []any:
+ result := make([]any, len(item))
+ for index, nested := range item {
+ result[index] = normalizeJSONKeys(nested)
+ }
+ return result
+ default:
+ return value
+ }
+}
+
+func snakeToLowerCamel(value string) string {
+ if !strings.Contains(value, "_") {
+ return value
+ }
+ parts := strings.Split(value, "_")
+ result := parts[0]
+ for _, part := range parts[1:] {
+ if part != "" {
+ result += strings.ToUpper(part[:1]) + part[1:]
+ }
+ }
+ return result
+}
+
+func mergeJSONObjects(target, patch map[string]any) {
+ for key, value := range patch {
+ if incoming, ok := value.(map[string]any); ok {
+ if existing, exists := target[key].(map[string]any); exists {
+ mergeJSONObjects(existing, incoming)
+ continue
+ }
+ }
+ target[key] = value
+ }
+}