支付相关
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
package osskit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
)
|
||||
|
||||
// ComposeStreams concatenates objects into destination while calculating the
|
||||
// MD5 of the exact bytes written. Provider-specific storage clients stay out
|
||||
// of this package; callbacks keep this helper independent from biz and SDKs.
|
||||
func ComposeStreams(
|
||||
ctx context.Context,
|
||||
names []string,
|
||||
open func(context.Context, string) (io.ReadCloser, error),
|
||||
put func(context.Context, string, io.Reader) error,
|
||||
remove func(context.Context, string) error,
|
||||
destination string,
|
||||
) (string, error) {
|
||||
reader, writer := io.Pipe()
|
||||
hash := md5.New()
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
defer writer.Close()
|
||||
for _, name := range names {
|
||||
if err := ctx.Err(); err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
file, err := open(ctx, name)
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
_, copyErr := io.Copy(io.MultiWriter(writer, hash), file)
|
||||
_ = file.Close()
|
||||
if copyErr != nil {
|
||||
errCh <- copyErr
|
||||
return
|
||||
}
|
||||
}
|
||||
errCh <- nil
|
||||
}()
|
||||
putErr := put(ctx, destination, reader)
|
||||
composeErr := <-errCh
|
||||
if putErr != nil {
|
||||
_ = remove(ctx, destination)
|
||||
return "", putErr
|
||||
}
|
||||
if composeErr != nil {
|
||||
_ = remove(ctx, destination)
|
||||
return "", composeErr
|
||||
}
|
||||
return hex.EncodeToString(hash.Sum(nil)), nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package osskit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestComposeStreams(t *testing.T) {
|
||||
objects := map[string]string{"a": "hello ", "b": "world"}
|
||||
var stored string
|
||||
hash, err := ComposeStreams(context.Background(), []string{"a", "b"}, func(_ context.Context, name string) (io.ReadCloser, error) {
|
||||
value, ok := objects[name]
|
||||
if !ok {
|
||||
return nil, errors.New("missing object")
|
||||
}
|
||||
return io.NopCloser(strings.NewReader(value)), nil
|
||||
}, func(_ context.Context, _ string, reader io.Reader) error {
|
||||
value, err := io.ReadAll(reader)
|
||||
stored = string(value)
|
||||
return err
|
||||
}, func(context.Context, string) error { return nil }, "out")
|
||||
if err != nil || stored != "hello world" {
|
||||
t.Fatalf("compose = %q, %v", stored, err)
|
||||
}
|
||||
want := md5.Sum([]byte(stored))
|
||||
if hash != hex.EncodeToString(want[:]) {
|
||||
t.Fatalf("hash = %q", hash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeStreamsRemovesPartialDestination(t *testing.T) {
|
||||
removed := false
|
||||
_, err := ComposeStreams(context.Background(), []string{"missing"}, func(context.Context, string) (io.ReadCloser, error) {
|
||||
return nil, errors.New("open failed")
|
||||
}, func(_ context.Context, _ string, reader io.Reader) error {
|
||||
_, _ = io.ReadAll(reader)
|
||||
return nil
|
||||
}, func(context.Context, string) error {
|
||||
removed = true
|
||||
return nil
|
||||
}, "out")
|
||||
if err == nil || !removed {
|
||||
t.Fatalf("compose error = %v, removed = %v", err, removed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package paymentkit
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package paymentkit
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package paymentkit
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package paymentkit
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package paymentkit
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package paymentkit
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package paymentkit
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package paymentkit
|
||||
|
||||
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())
|
||||
}
|
||||
Reference in New Issue
Block a user