优化结构

This commit is contained in:
Yvan 2026-08-21 00:28:22 +08:00
parent 13f4589a18
commit d2fb5c11b8
13 changed files with 653 additions and 0 deletions

43
pkg/httpx/response.go Normal file
View File

@ -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})
}

5
pkg/paymentkit/README.md Normal file
View File

@ -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`.

97
pkg/paymentkit/amount.go Normal file
View File

@ -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
}

View File

@ -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")
}
}

View File

@ -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("<xml><return_code><![CDATA[%s]]></return_code><return_msg><![CDATA[%s]]></return_msg></xml>", 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)}
}
}

View File

@ -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", "<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[FAIL]]></return_msg></xml>"},
{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)
}
}

66
pkg/paymentkit/json.go Normal file
View File

@ -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
}

View File

@ -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)
}
}

58
pkg/paymentkit/signing.go Normal file
View File

@ -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
}

View File

@ -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)
}
}

63
pkg/paymentkit/status.go Normal file
View File

@ -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)), "<nil>", ""))
}
func stringify(value any) string {
return fmt.Sprint(value)
}

60
pkg/paymentkit/xml.go Normal file
View File

@ -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("<xml>")
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("><![CDATA[")
b.WriteString(values[key])
b.WriteString("]]></")
b.WriteString(key)
b.WriteString(">")
}
b.WriteString("</xml>")
return []byte(b.String())
}

80
pkg/protoutil/json.go Normal file
View File

@ -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, &current); 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
}
}