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("") + 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 + } +}