269 lines
9.4 KiB
Go
269 lines
9.4 KiB
Go
package payment
|
|
|
|
// RPCProvider is the host side of the yingce.payment/v1 process ABI. The
|
|
// process is shipped inside a validated plugin package and receives exactly
|
|
// one JSON request on stdin, returning one JSON response on stdout. The host
|
|
// still owns the order, credential storage, network policy and crediting
|
|
// transaction; the process only translates a payment provider protocol.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"debug/elf"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
pluginRPCVersion = "yingce.payment/v1"
|
|
pluginRPCMaxOutput = 2 << 20
|
|
pluginRPCTimeout = 30 * time.Second
|
|
)
|
|
|
|
type RPCProvider struct {
|
|
descriptor Descriptor
|
|
command string
|
|
dir string
|
|
}
|
|
|
|
func NewRPCProvider(descriptor Descriptor, packageDir, entry string) (*RPCProvider, error) {
|
|
if strings.TrimSpace(descriptor.ID) == "" || strings.TrimSpace(descriptor.PluginID) == "" {
|
|
return nil, errors.New("payment plugin descriptor requires id and plugin id")
|
|
}
|
|
if filepath.IsAbs(entry) || path.Clean(entry) != entry || !strings.HasPrefix(entry, "backend/") {
|
|
return nil, errors.New("payment plugin entry must be relative to backend/")
|
|
}
|
|
if strings.TrimSpace(packageDir) == "" {
|
|
return nil, errors.New("payment plugin package directory is empty")
|
|
}
|
|
return &RPCProvider{descriptor: descriptor, command: filepath.Join(packageDir, filepath.FromSlash(entry)), dir: packageDir}, nil
|
|
}
|
|
|
|
func (p *RPCProvider) Descriptor() Descriptor { return p.descriptor }
|
|
|
|
type rpcRequest struct {
|
|
Version string `json:"version"`
|
|
Operation string `json:"operation"`
|
|
Config Config `json:"config,omitempty"`
|
|
Request any `json:"request,omitempty"`
|
|
Headers map[string][]string `json:"headers,omitempty"`
|
|
Body string `json:"bodyBase64,omitempty"`
|
|
BillDate string `json:"billDate,omitempty"`
|
|
}
|
|
|
|
type rpcResponse struct {
|
|
OK bool `json:"ok"`
|
|
Error string `json:"error,omitempty"`
|
|
Code string `json:"code,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
Data json.RawMessage `json:"data,omitempty"`
|
|
}
|
|
|
|
func (p *RPCProvider) call(ctx context.Context, request rpcRequest, output any) error {
|
|
if p == nil || strings.TrimSpace(p.command) == "" {
|
|
return errors.New("payment plugin runtime is unavailable")
|
|
}
|
|
info, err := os.Stat(p.command)
|
|
if err != nil {
|
|
return p.startError(err)
|
|
}
|
|
if info.IsDir() {
|
|
return p.startError(fmt.Errorf("payment plugin executable is a directory: %w", syscall.EACCES))
|
|
}
|
|
if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 {
|
|
return p.startError(fmt.Errorf("payment plugin executable has no execute bit: %w", syscall.EACCES))
|
|
}
|
|
if err := validatePaymentExecutablePlatform(p.command); err != nil {
|
|
return p.startError(err)
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, pluginRPCTimeout)
|
|
defer cancel()
|
|
payload, err := json.Marshal(request)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cmd := exec.CommandContext(ctx, p.command)
|
|
cmd.Dir = p.dir
|
|
cmd.Env = []string{"PATH=/usr/bin:/bin", "HOME=/nonexistent"}
|
|
cmd.Stdin = strings.NewReader(string(payload) + "\n")
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
stderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
return p.startError(err)
|
|
}
|
|
data, readErr := io.ReadAll(io.LimitReader(stdout, pluginRPCMaxOutput+1))
|
|
_, _ = io.ReadAll(io.LimitReader(stderr, 64<<10))
|
|
waitErr := cmd.Wait()
|
|
if readErr != nil {
|
|
return &ProviderError{Code: "plugin_read_failed", Message: "读取支付插件响应失败", Temporary: true, Cause: readErr}
|
|
}
|
|
if len(data) > pluginRPCMaxOutput {
|
|
return errors.New("支付插件响应超过安全限制")
|
|
}
|
|
if ctx.Err() != nil {
|
|
return &ProviderError{Code: "plugin_timeout", Message: "支付插件执行超时", Temporary: true, Cause: ctx.Err()}
|
|
}
|
|
if waitErr != nil {
|
|
return &ProviderError{Code: "plugin_process_failed", Message: "支付插件进程异常退出", Temporary: true, Cause: waitErr}
|
|
}
|
|
var response rpcResponse
|
|
if err := json.Unmarshal(data, &response); err != nil {
|
|
return &ProviderError{Code: "plugin_invalid_response", Message: "支付插件返回了无效响应", Temporary: true, Cause: err}
|
|
}
|
|
if !response.OK {
|
|
message := response.Message
|
|
if message == "" {
|
|
message = response.Error
|
|
}
|
|
if message == "" {
|
|
message = "支付插件返回失败"
|
|
}
|
|
return &ProviderError{Code: response.Code, Message: message}
|
|
}
|
|
if output == nil || len(response.Data) == 0 {
|
|
return nil
|
|
}
|
|
if err := json.Unmarshal(response.Data, output); err != nil {
|
|
return fmt.Errorf("解析支付插件数据失败:%w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *RPCProvider) startError(cause error) error {
|
|
providerErr := classifyPaymentPluginStartError(cause)
|
|
log.Printf(
|
|
"payment plugin executable failed: provider=%q plugin=%q host=%s/%s command=%q code=%s error=%v",
|
|
p.descriptor.ID,
|
|
p.descriptor.PluginID,
|
|
runtime.GOOS,
|
|
runtime.GOARCH,
|
|
p.command,
|
|
providerErr.Code,
|
|
cause,
|
|
)
|
|
return providerErr
|
|
}
|
|
|
|
func classifyPaymentPluginStartError(cause error) *ProviderError {
|
|
switch {
|
|
case errors.Is(cause, os.ErrNotExist), errors.Is(cause, syscall.ENOENT), errors.Is(cause, exec.ErrNotFound):
|
|
return &ProviderError{Code: "plugin_executable_missing", Message: "支付插件可执行文件或其运行时加载器不可用", Cause: cause}
|
|
case errors.Is(cause, os.ErrPermission), errors.Is(cause, syscall.EACCES):
|
|
return &ProviderError{Code: "plugin_permission_denied", Message: "支付插件没有执行权限", Cause: cause}
|
|
case errors.Is(cause, syscall.ENOEXEC):
|
|
return &ProviderError{Code: "plugin_exec_format_error", Message: "支付插件可执行文件与当前操作系统或 CPU 架构不兼容", Cause: cause}
|
|
default:
|
|
return &ProviderError{Code: "plugin_start_failed", Message: "支付插件启动失败", Temporary: true, Cause: cause}
|
|
}
|
|
}
|
|
|
|
func validatePaymentExecutablePlatform(command string) error {
|
|
if runtime.GOOS != "linux" {
|
|
return nil
|
|
}
|
|
file, err := os.Open(command)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
|
|
var magic [4]byte
|
|
if _, err := io.ReadFull(file, magic[:]); err != nil {
|
|
return fmt.Errorf("payment plugin executable header is incomplete: %w", syscall.ENOEXEC)
|
|
}
|
|
if bytes.Equal(magic[:2], []byte{'M', 'Z'}) {
|
|
return fmt.Errorf("payment plugin executable is Windows PE on linux/%s: %w", runtime.GOARCH, syscall.ENOEXEC)
|
|
}
|
|
if isMachOMagic(magic) {
|
|
return fmt.Errorf("payment plugin executable is Mach-O on linux/%s: %w", runtime.GOARCH, syscall.ENOEXEC)
|
|
}
|
|
if !bytes.Equal(magic[:], []byte{0x7f, 'E', 'L', 'F'}) {
|
|
// Scripts and other kernel-supported executable formats are left to
|
|
// cmd.Start. Unknown binary data will be classified from ENOEXEC there.
|
|
return nil
|
|
}
|
|
|
|
binary, err := elf.NewFile(file)
|
|
if err != nil {
|
|
return fmt.Errorf("payment plugin ELF header is invalid: %w", syscall.ENOEXEC)
|
|
}
|
|
defer binary.Close()
|
|
expected, ok := expectedPaymentELFMachine(runtime.GOARCH)
|
|
if binary.Class != elf.ELFCLASS64 || (ok && binary.Machine != expected) {
|
|
return fmt.Errorf("payment plugin ELF architecture mismatch: host=linux/%s class=%s machine=%s: %w", runtime.GOARCH, binary.Class, binary.Machine, syscall.ENOEXEC)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isMachOMagic(magic [4]byte) bool {
|
|
return magic == [4]byte{0xfe, 0xed, 0xfa, 0xce} ||
|
|
magic == [4]byte{0xce, 0xfa, 0xed, 0xfe} ||
|
|
magic == [4]byte{0xfe, 0xed, 0xfa, 0xcf} ||
|
|
magic == [4]byte{0xcf, 0xfa, 0xed, 0xfe}
|
|
}
|
|
|
|
func expectedPaymentELFMachine(arch string) (elf.Machine, bool) {
|
|
switch arch {
|
|
case "amd64":
|
|
return elf.EM_X86_64, true
|
|
case "arm64":
|
|
return elf.EM_AARCH64, true
|
|
default:
|
|
return elf.EM_NONE, false
|
|
}
|
|
}
|
|
|
|
func (p *RPCProvider) ValidateConfig(config Config) error {
|
|
return p.call(context.Background(), rpcRequest{Version: pluginRPCVersion, Operation: "validate_config", Config: config}, nil)
|
|
}
|
|
|
|
func (p *RPCProvider) CreateOrder(ctx context.Context, config Config, request CreateRequest) (Checkout, error) {
|
|
var result Checkout
|
|
err := p.call(ctx, rpcRequest{Version: pluginRPCVersion, Operation: "create_order", Config: config, Request: request}, &result)
|
|
return result, err
|
|
}
|
|
|
|
func (p *RPCProvider) QueryOrder(ctx context.Context, config Config, request QueryRequest) (Result, error) {
|
|
var result Result
|
|
err := p.call(ctx, rpcRequest{Version: pluginRPCVersion, Operation: "query_order", Config: config, Request: request}, &result)
|
|
return result, err
|
|
}
|
|
|
|
func (p *RPCProvider) CloseOrder(ctx context.Context, config Config, request CloseRequest) (Result, error) {
|
|
var result Result
|
|
err := p.call(ctx, rpcRequest{Version: pluginRPCVersion, Operation: "close_order", Config: config, Request: request}, &result)
|
|
return result, err
|
|
}
|
|
|
|
func (p *RPCProvider) VerifyNotification(ctx context.Context, config Config, headers http.Header, rawBody []byte) (Notification, error) {
|
|
var result Notification
|
|
headerValues := map[string][]string(headers)
|
|
err := p.call(ctx, rpcRequest{Version: pluginRPCVersion, Operation: "verify_notification", Config: config, Headers: headerValues, Body: base64.StdEncoding.EncodeToString(rawBody)}, &result)
|
|
return result, err
|
|
}
|
|
|
|
func (p *RPCProvider) DownloadTradeBill(ctx context.Context, config Config, billDate time.Time) ([]BillRecord, error) {
|
|
var result []BillRecord
|
|
err := p.call(ctx, rpcRequest{Version: pluginRPCVersion, Operation: "download_trade_bill", Config: config, BillDate: billDate.Format("2006-01-02")}, &result)
|
|
return result, err
|
|
}
|