对接
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const mockURL = "http://127.0.0.1:18081"
|
||||
const mockAppID = "local-acceptance-app"
|
||||
|
||||
type mockKeys struct {
|
||||
MerchantPrivate string `json:"merchantPrivate"`
|
||||
PlatformPrivate string `json:"platformPrivate"`
|
||||
PlatformPublic string `json:"platformPublic"`
|
||||
}
|
||||
|
||||
type mockOrder struct {
|
||||
Number string `json:"number"`
|
||||
Amount string `json:"amount"`
|
||||
Paid bool `json:"paid"`
|
||||
}
|
||||
|
||||
type mockPayments struct {
|
||||
keys *mockKeys
|
||||
key *rsa.PrivateKey
|
||||
mu sync.Mutex
|
||||
orders map[string]mockOrder
|
||||
}
|
||||
|
||||
func newRSAKey() (*rsa.PrivateKey, string, string) {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
must(err)
|
||||
private := string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
|
||||
public, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
|
||||
must(err)
|
||||
return key, private, string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: public}))
|
||||
}
|
||||
|
||||
func loadMockKeys() (*mockKeys, error) {
|
||||
raw, err := os.ReadFile(filepath.Join(outputDir, "mock-payment-keys.json"))
|
||||
if err == nil {
|
||||
var keys mockKeys
|
||||
err = json.Unmarshal(raw, &keys)
|
||||
return &keys, err
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
_, merchant, _ := newRSAKey()
|
||||
_, private, public := newRSAKey()
|
||||
keys := &mockKeys{MerchantPrivate: merchant, PlatformPrivate: private, PlatformPublic: public}
|
||||
return keys, saveJSON("mock-payment-keys.json", keys)
|
||||
}
|
||||
|
||||
func parsePrivate(value string) *rsa.PrivateKey {
|
||||
block, _ := pem.Decode([]byte(value))
|
||||
check(block != nil, "invalid local test private key")
|
||||
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
must(err)
|
||||
return key
|
||||
}
|
||||
|
||||
func rsa2Sign(key *rsa.PrivateKey, raw []byte) string {
|
||||
digest := sha256.Sum256(raw)
|
||||
signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
|
||||
must(err)
|
||||
return base64.StdEncoding.EncodeToString(signature)
|
||||
}
|
||||
|
||||
func signingFields(values url.Values, includeSignType bool) string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
if key != "sign" && (includeSignType || key != "sign_type") && values.Get(key) != "" {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
parts = append(parts, key+"="+values.Get(key))
|
||||
}
|
||||
return strings.Join(parts, "&")
|
||||
}
|
||||
|
||||
func serveMockPayments() error {
|
||||
keys, err := loadMockKeys()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m := &mockPayments{keys: keys, key: parsePrivate(keys.PlatformPrivate), orders: map[string]mockOrder{}}
|
||||
if raw, readErr := os.ReadFile(filepath.Join(outputDir, "mock-payment-orders.json")); readErr == nil {
|
||||
must(json.Unmarshal(raw, &m.orders))
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, "local-only-mock") })
|
||||
mux.HandleFunc("/gateway", m.gateway)
|
||||
mux.HandleFunc("/confirm", m.confirm)
|
||||
log.Printf("Local-only signed payment mock listening at %s", mockURL)
|
||||
server := &http.Server{Addr: "127.0.0.1:18081", Handler: mux, ReadHeaderTimeout: 5 * time.Second}
|
||||
return server.ListenAndServe()
|
||||
}
|
||||
|
||||
func (m *mockPayments) gateway(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
merchant := parsePrivate(m.keys.MerchantPrivate)
|
||||
signature, err := base64.StdEncoding.DecodeString(r.Form.Get("sign"))
|
||||
digest := sha256.Sum256([]byte(signingFields(r.Form, true)))
|
||||
if err != nil || rsa.VerifyPKCS1v15(&merchant.PublicKey, crypto.SHA256, digest[:], signature) != nil || r.Form.Get("app_id") != mockAppID {
|
||||
http.Error(w, "invalid local merchant signature", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
var content map[string]any
|
||||
if err = json.Unmarshal([]byte(r.Form.Get("biz_content")), &content); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
number, _ := content["out_trade_no"].(string)
|
||||
method := r.Form.Get("method")
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var response map[string]any
|
||||
switch method {
|
||||
case "alipay.trade.precreate":
|
||||
amount, _ := content["total_amount"].(string)
|
||||
m.orders[number] = mockOrder{Number: number, Amount: amount}
|
||||
must(saveJSON("mock-payment-orders.json", m.orders))
|
||||
response = map[string]any{
|
||||
"code": "10000", "msg": "Success", "out_trade_no": number,
|
||||
"qr_code": mockURL + "/confirm?orderNo=" + url.QueryEscape(number),
|
||||
}
|
||||
case "alipay.trade.query":
|
||||
order, found := m.orders[number]
|
||||
if !found {
|
||||
http.Error(w, "unknown local test order", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
status := "WAIT_BUYER_PAY"
|
||||
if order.Paid {
|
||||
status = "TRADE_SUCCESS"
|
||||
}
|
||||
response = map[string]any{
|
||||
"code": "10000", "msg": "Success", "trade_no": "LOCAL-" + number, "out_trade_no": number,
|
||||
"trade_status": status, "total_amount": order.Amount, "buyer_pay_amount": order.Amount,
|
||||
"receipt_amount": order.Amount, "trans_currency": "CNY",
|
||||
}
|
||||
default:
|
||||
http.Error(w, "method not implemented by local-only mock", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
raw, err := json.Marshal(response)
|
||||
must(err)
|
||||
envelope := map[string]any{strings.ReplaceAll(method, ".", "_") + "_response": json.RawMessage(raw), "sign": rsa2Sign(m.key, raw)}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
must(json.NewEncoder(w).Encode(envelope))
|
||||
}
|
||||
|
||||
func (m *mockPayments) confirm(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "local test confirmation requires POST", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
must(r.ParseForm())
|
||||
number := r.Form.Get("orderNo")
|
||||
m.mu.Lock()
|
||||
order, found := m.orders[number]
|
||||
if !found {
|
||||
m.mu.Unlock()
|
||||
http.Error(w, "unknown local test order", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
order.Paid = true
|
||||
m.orders[number] = order
|
||||
must(saveJSON("mock-payment-orders.json", m.orders))
|
||||
m.mu.Unlock()
|
||||
fields := url.Values{
|
||||
"app_id": {mockAppID}, "trade_no": {"LOCAL-" + number}, "out_trade_no": {number},
|
||||
"trade_status": {"TRADE_SUCCESS"}, "total_amount": {order.Amount}, "sign_type": {"RSA2"},
|
||||
}
|
||||
fields.Set("sign", rsa2Sign(m.key, []byte(signingFields(fields, false))))
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
response, err := client.PostForm(baseURL+"/payment/callback/alipay", fields)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
w.WriteHeader(response.StatusCode)
|
||||
_, _ = io.Copy(w, response.Body)
|
||||
}
|
||||
|
||||
func configureMockPayment(admin *apiClient) error {
|
||||
keys, err := loadMockKeys()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response, err := http.Get(mockURL + "/health")
|
||||
if err != nil {
|
||||
return fmt.Errorf("start the local mock-payment command before verification: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("local payment mock unavailable")
|
||||
}
|
||||
return admin.call("PUT", "/integration/configs/payment/alipay", map[string]any{
|
||||
"enabled": true,
|
||||
"config": map[string]any{
|
||||
"app_id": mockAppID, "private_key": keys.MerchantPrivate, "public_key": keys.PlatformPublic,
|
||||
"environment": "sandbox", "sign_type": "RSA2", "method": "alipay.trade.precreate", "gateway_url": mockURL + "/gateway",
|
||||
"notify_url": baseURL + "/payment/callback/alipay",
|
||||
},
|
||||
}, nil)
|
||||
}
|
||||
Reference in New Issue
Block a user