233 lines
7.2 KiB
Go
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)
|
|
}
|