Files
kra-new/tmp/live_acceptance/payment_mock.go
T
2026-09-23 17:42:55 +08:00

233 lines
7.2 KiB
Go

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