新增 xunhupay 官方包,支付 RPC 与宿主只保留订单和验签边界;官方支付包同时带上各系统 provider,避免把 Linux ELF 当成本机入口。 Co-authored-by: Cursor <cursoragent@cursor.com>
500 lines
15 KiB
Go
500 lines
15 KiB
Go
package paymentplugins
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/md5"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
defaultXunHuPayGateway = "https://api.xunhupay.com"
|
|
xunhupayAPIVersion = "1.1"
|
|
xunhupayPluginName = "yingce-xunhupay"
|
|
xunhupayPayPath = "/payment/do.html"
|
|
xunhupayQueryPath = "/payment/query.html"
|
|
xunhupayQRLifetime = 5 * time.Minute
|
|
)
|
|
|
|
var xunhupayGatewayHosts = map[string]struct{}{
|
|
"api.xunhupay.com": {},
|
|
"api.dpweixin.com": {},
|
|
"api.diypc.com.cn": {},
|
|
}
|
|
|
|
type XunHuPayProvider struct {
|
|
client *http.Client
|
|
now func() time.Time
|
|
baseURL string
|
|
nonce func() string
|
|
}
|
|
|
|
func NewXunHuPayProvider(client *http.Client) *XunHuPayProvider {
|
|
if client == nil {
|
|
client = http.DefaultClient
|
|
}
|
|
return &XunHuPayProvider{client: client, now: time.Now, nonce: randomNonce}
|
|
}
|
|
|
|
func (p *XunHuPayProvider) Descriptor() Descriptor {
|
|
return Descriptor{
|
|
ID: ProviderXunHuPay, PluginID: PluginXunHuPay, PluginVersion: "1.0.0",
|
|
Name: "虎皮椒聚合支付", Icon: "assets/icon.svg", CheckoutMode: "qr_code",
|
|
IdentityFields: []string{"appId"},
|
|
NotificationSuccess: NotificationResponse{Status: 200, ContentType: "text/plain; charset=utf-8", Body: "success"},
|
|
NotificationFailure: NotificationResponse{Status: 400, ContentType: "text/plain; charset=utf-8", Body: "failure"},
|
|
}
|
|
}
|
|
|
|
func (p *XunHuPayProvider) ValidateConfig(config Config) error {
|
|
if strings.TrimSpace(config["appId"]) == "" {
|
|
return errors.New("虎皮椒配置缺少 appId")
|
|
}
|
|
if strings.TrimSpace(config["appSecret"]) == "" {
|
|
return errors.New("虎皮椒配置缺少 appSecret")
|
|
}
|
|
mode := xunhupayCheckoutMode(config)
|
|
if mode != "qr_code" && mode != "redirect" {
|
|
return errors.New("虎皮椒 checkoutMode 必须是 qr_code 或 redirect")
|
|
}
|
|
if _, err := xunhupayGatewayOrigin(config); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *XunHuPayProvider) CreateOrder(ctx context.Context, config Config, request CreateRequest) (Checkout, error) {
|
|
if err := p.ValidateConfig(config); err != nil {
|
|
return Checkout{}, err
|
|
}
|
|
if request.AmountFen <= 0 || request.Currency != "CNY" || request.MerchantOrderNo == "" || request.NotifyURL == "" {
|
|
return Checkout{}, errors.New("虎皮椒下单参数无效")
|
|
}
|
|
params := map[string]string{
|
|
"version": xunhupayAPIVersion,
|
|
"appid": strings.TrimSpace(config["appId"]),
|
|
"trade_order_id": request.MerchantOrderNo,
|
|
"total_fee": formatFen(request.AmountFen),
|
|
"title": xunhupayTitle(request.Description),
|
|
"time": strconv.FormatInt(p.now().Unix(), 10),
|
|
"notify_url": request.NotifyURL,
|
|
"plugins": xunhupayPluginName,
|
|
"nonce_str": p.nonce(),
|
|
}
|
|
if request.ReturnURL != "" {
|
|
params["return_url"] = request.ReturnURL
|
|
params["callback_url"] = request.ReturnURL
|
|
}
|
|
var response xunhupayPayResponse
|
|
if err := p.call(ctx, config, xunhupayPayPath, params, &response); err != nil {
|
|
return Checkout{}, err
|
|
}
|
|
mode := xunhupayCheckoutMode(config)
|
|
// url_qrcode 是网关已经画好的二维码图片地址,钱包按图片展示,不能再编码成新二维码。
|
|
value := strings.TrimSpace(response.URLQrcode)
|
|
if mode == "redirect" {
|
|
value = strings.TrimSpace(response.URL)
|
|
}
|
|
if value == "" {
|
|
if mode == "redirect" {
|
|
return Checkout{}, errors.New("虎皮椒下单未返回支付跳转地址")
|
|
}
|
|
return Checkout{}, errors.New("虎皮椒下单未返回支付二维码")
|
|
}
|
|
expiresAt := request.ExpiresAt
|
|
if mode == "qr_code" {
|
|
qrExpires := p.now().Add(xunhupayQRLifetime)
|
|
if expiresAt.IsZero() || qrExpires.Before(expiresAt) {
|
|
expiresAt = qrExpires
|
|
}
|
|
}
|
|
return Checkout{Mode: mode, Value: value, ExpiresAt: expiresAt}, nil
|
|
}
|
|
|
|
func (p *XunHuPayProvider) QueryOrder(ctx context.Context, config Config, request QueryRequest) (Result, error) {
|
|
if err := p.ValidateConfig(config); err != nil {
|
|
return Result{}, err
|
|
}
|
|
if strings.TrimSpace(request.MerchantOrderNo) == "" {
|
|
return Result{}, errors.New("虎皮椒查单缺少商户订单号")
|
|
}
|
|
order, err := p.query(ctx, config, request.MerchantOrderNo)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
return xunhupayResult(request.MerchantOrderNo, order), nil
|
|
}
|
|
|
|
func (p *XunHuPayProvider) CloseOrder(ctx context.Context, config Config, request CloseRequest) (Result, error) {
|
|
if err := p.ValidateConfig(config); err != nil {
|
|
return Result{}, err
|
|
}
|
|
order, err := p.query(ctx, config, request.MerchantOrderNo)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
result := xunhupayResult(request.MerchantOrderNo, order)
|
|
if result.Paid {
|
|
return result, nil
|
|
}
|
|
result.Closed = true
|
|
if result.ProviderStatus == "" || result.ProviderStatus == "WP" {
|
|
result.ProviderStatus = "CD"
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (p *XunHuPayProvider) VerifyNotification(_ context.Context, config Config, _ http.Header, rawBody []byte) (Notification, error) {
|
|
if err := p.ValidateConfig(config); err != nil {
|
|
return Notification{}, err
|
|
}
|
|
values, err := parseXunHuPayNotification(rawBody)
|
|
if err != nil {
|
|
return Notification{}, err
|
|
}
|
|
if !xunhupayHashEqual(values, config["appSecret"], values["hash"]) {
|
|
return Notification{}, errors.New("虎皮椒异步通知签名无效")
|
|
}
|
|
if strings.TrimSpace(values["appid"]) != strings.TrimSpace(config["appId"]) {
|
|
return Notification{}, errors.New("虎皮椒异步通知商户身份不匹配")
|
|
}
|
|
status := strings.TrimSpace(values["status"])
|
|
merchantOrderNo := firstNonEmpty(values["trade_order_id"], values["out_trade_order"])
|
|
if merchantOrderNo == "" {
|
|
return Notification{}, errors.New("虎皮椒异步通知缺少商户订单号")
|
|
}
|
|
amount, err := parseYuanToFen(firstNonEmpty(values["total_fee"], values["total_amount"]))
|
|
if err != nil {
|
|
return Notification{}, errors.New("虎皮椒异步通知金额无效")
|
|
}
|
|
paidAt := parseXunHuPayTime(values["time"])
|
|
eventID := firstNonEmpty(values["transaction_id"], values["open_order_id"], merchantOrderNo) + ":" + status + ":" + values["time"]
|
|
return Notification{
|
|
EventID: eventID,
|
|
Result: Result{
|
|
MerchantOrderNo: merchantOrderNo,
|
|
ProviderTradeNo: firstNonEmpty(values["transaction_id"], values["open_order_id"]),
|
|
ProviderStatus: status,
|
|
AmountFen: amount,
|
|
Currency: "CNY",
|
|
Paid: status == "OD",
|
|
Closed: status == "CD",
|
|
PaidAt: paidAt,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (p *XunHuPayProvider) DownloadTradeBill(context.Context, Config, time.Time) ([]BillRecord, error) {
|
|
return nil, fmt.Errorf("%w: 虎皮椒未提供交易账单下载接口", ErrTradeBillNotFound)
|
|
}
|
|
|
|
func (p *XunHuPayProvider) query(ctx context.Context, config Config, merchantOrderNo string) (xunhupayOrderData, error) {
|
|
params := map[string]string{
|
|
"appid": strings.TrimSpace(config["appId"]),
|
|
"out_trade_order": merchantOrderNo,
|
|
"time": strconv.FormatInt(p.now().Unix(), 10),
|
|
"nonce_str": p.nonce(),
|
|
}
|
|
var envelope xunhupayQueryResponse
|
|
if err := p.call(ctx, config, xunhupayQueryPath, params, &envelope); err != nil {
|
|
return xunhupayOrderData{}, err
|
|
}
|
|
if strings.TrimSpace(envelope.Data.Status) == "" {
|
|
return xunhupayOrderData{}, errors.New("虎皮椒查单未返回订单状态")
|
|
}
|
|
return envelope.Data, nil
|
|
}
|
|
|
|
func (p *XunHuPayProvider) call(ctx context.Context, config Config, path string, params map[string]string, output any) error {
|
|
params["hash"] = xunhupayHash(params, config["appSecret"])
|
|
payload, err := json.Marshal(params)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, p.endpoint(config, path), bytes.NewReader(payload))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
request.Header.Set("Content-Type", "application/json; charset=utf-8")
|
|
response, err := p.client.Do(request)
|
|
if err != nil {
|
|
return &ProviderError{Code: "xunhupay_transport_error", Message: "虎皮椒网络请求失败", Temporary: true, Cause: err}
|
|
}
|
|
defer response.Body.Close()
|
|
body, err := io.ReadAll(io.LimitReader(response.Body, 2<<20))
|
|
if err != nil {
|
|
return &ProviderError{Code: "xunhupay_response_read_error", Message: "读取虎皮椒响应失败", Temporary: true, Cause: err}
|
|
}
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
return &ProviderError{Code: "xunhupay_http_error", Message: "虎皮椒接口返回失败", Temporary: response.StatusCode >= 500}
|
|
}
|
|
var envelope xunhupayEnvelope
|
|
if err := json.Unmarshal(body, &envelope); err != nil {
|
|
return fmt.Errorf("解析虎皮椒响应:%w", err)
|
|
}
|
|
if envelope.Hash != "" {
|
|
values, err := jsonObjectStrings(body)
|
|
if err != nil {
|
|
return fmt.Errorf("解析虎皮椒响应签名字段:%w", err)
|
|
}
|
|
if !xunhupayHashEqual(values, config["appSecret"], envelope.Hash) {
|
|
return errors.New("虎皮椒响应签名无效")
|
|
}
|
|
}
|
|
if envelope.ErrCode != 0 {
|
|
message := strings.TrimSpace(envelope.ErrMsg)
|
|
if message == "" {
|
|
message = "虎皮椒业务请求失败"
|
|
}
|
|
if isXunHuPayOrderMissing(message) {
|
|
return fmt.Errorf("%w: %s", ErrOrderNotFound, message)
|
|
}
|
|
return &ProviderError{Code: strconv.Itoa(int(envelope.ErrCode)), Message: message}
|
|
}
|
|
if output == nil {
|
|
return nil
|
|
}
|
|
return json.Unmarshal(body, output)
|
|
}
|
|
|
|
func (p *XunHuPayProvider) endpoint(config Config, path string) string {
|
|
if strings.TrimSpace(p.baseURL) != "" {
|
|
return strings.TrimRight(p.baseURL, "/") + path
|
|
}
|
|
origin, err := xunhupayGatewayOrigin(config)
|
|
if err != nil {
|
|
return defaultXunHuPayGateway + path
|
|
}
|
|
return origin + path
|
|
}
|
|
|
|
type xunhupayEnvelope struct {
|
|
ErrCode xunhupayCode `json:"errcode"`
|
|
ErrMsg string `json:"errmsg"`
|
|
Hash string `json:"hash"`
|
|
}
|
|
|
|
type xunhupayCode int
|
|
|
|
func (code *xunhupayCode) UnmarshalJSON(raw []byte) error {
|
|
raw = bytes.TrimSpace(raw)
|
|
if len(raw) == 0 || string(raw) == "null" {
|
|
*code = 0
|
|
return nil
|
|
}
|
|
if raw[0] == '"' {
|
|
var text string
|
|
if err := json.Unmarshal(raw, &text); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(text) == "" {
|
|
*code = 0
|
|
return nil
|
|
}
|
|
parsed, err := strconv.Atoi(strings.TrimSpace(text))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
*code = xunhupayCode(parsed)
|
|
return nil
|
|
}
|
|
var value int
|
|
if err := json.Unmarshal(raw, &value); err != nil {
|
|
return err
|
|
}
|
|
*code = xunhupayCode(value)
|
|
return nil
|
|
}
|
|
|
|
type xunhupayPayResponse struct {
|
|
xunhupayEnvelope
|
|
URLQrcode string `json:"url_qrcode"`
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
type xunhupayQueryResponse struct {
|
|
xunhupayEnvelope
|
|
Data xunhupayOrderData `json:"data"`
|
|
}
|
|
|
|
type xunhupayOrderData struct {
|
|
Status string `json:"status"`
|
|
OpenOrderID string `json:"open_order_id"`
|
|
TransactionID string `json:"transaction_id"`
|
|
TotalAmount string `json:"total_amount"`
|
|
TotalFee string `json:"total_fee"`
|
|
OutTradeOrder string `json:"out_trade_order"`
|
|
PaidDate string `json:"paid_date"`
|
|
}
|
|
|
|
func xunhupayResult(merchantOrderNo string, order xunhupayOrderData) Result {
|
|
amount, _ := parseYuanToFen(firstNonEmpty(order.TotalFee, order.TotalAmount))
|
|
status := strings.TrimSpace(order.Status)
|
|
return Result{
|
|
MerchantOrderNo: firstNonEmpty(order.OutTradeOrder, merchantOrderNo),
|
|
ProviderTradeNo: firstNonEmpty(order.TransactionID, order.OpenOrderID),
|
|
ProviderStatus: status,
|
|
AmountFen: amount,
|
|
Currency: "CNY",
|
|
Paid: status == "OD",
|
|
Closed: status == "CD",
|
|
PaidAt: parseXunHuPayTime(order.PaidDate),
|
|
}
|
|
}
|
|
|
|
func xunhupayGatewayOrigin(config Config) (string, error) {
|
|
raw := strings.TrimSpace(config["gateway"])
|
|
if raw == "" {
|
|
raw = defaultXunHuPayGateway
|
|
}
|
|
parsed, err := url.Parse(raw)
|
|
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" || parsed.RawQuery != "" || (parsed.Port() != "" && parsed.Port() != "443") {
|
|
return "", errors.New("虎皮椒网关必须是官方 HTTPS 地址")
|
|
}
|
|
host := strings.ToLower(parsed.Hostname())
|
|
if _, ok := xunhupayGatewayHosts[host]; !ok {
|
|
return "", errors.New("虎皮椒网关仅支持官方生产或备用域名")
|
|
}
|
|
if parsed.Path != "" && parsed.Path != "/" && !strings.HasPrefix(parsed.Path, "/payment/") {
|
|
return "", errors.New("虎皮椒网关路径无效")
|
|
}
|
|
return "https://" + parsed.Hostname(), nil
|
|
}
|
|
|
|
func xunhupayCheckoutMode(config Config) string {
|
|
mode := strings.TrimSpace(config["checkoutMode"])
|
|
if mode == "" {
|
|
return "qr_code"
|
|
}
|
|
return mode
|
|
}
|
|
|
|
func xunhupayTitle(value string) string {
|
|
title := strings.ReplaceAll(truncateUTF8(value, 120), "%", "")
|
|
if title == "" {
|
|
return "积分充值"
|
|
}
|
|
return title
|
|
}
|
|
|
|
func xunhupayHash(params map[string]string, secret string) string {
|
|
keys := make([]string, 0, len(params))
|
|
for key, value := range params {
|
|
if key == "hash" || value == "" {
|
|
continue
|
|
}
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
parts := make([]string, 0, len(keys))
|
|
for _, key := range keys {
|
|
parts = append(parts, key+"="+params[key])
|
|
}
|
|
sum := md5.Sum([]byte(strings.Join(parts, "&") + secret))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func xunhupayHashEqual(params map[string]string, secret, actual string) bool {
|
|
expected := xunhupayHash(params, secret)
|
|
return actual != "" && strings.EqualFold(expected, strings.TrimSpace(actual))
|
|
}
|
|
|
|
func parseXunHuPayNotification(rawBody []byte) (map[string]string, error) {
|
|
trimmed := bytes.TrimSpace(rawBody)
|
|
if len(trimmed) == 0 {
|
|
return nil, errors.New("虎皮椒异步通知为空")
|
|
}
|
|
if trimmed[0] == '{' {
|
|
values, err := jsonObjectStrings(trimmed)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("解析虎皮椒异步通知:%w", err)
|
|
}
|
|
return values, nil
|
|
}
|
|
form, err := url.ParseQuery(string(trimmed))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("解析虎皮椒异步通知:%w", err)
|
|
}
|
|
values := make(map[string]string, len(form))
|
|
for key, items := range form {
|
|
if len(items) > 0 {
|
|
values[key] = items[0]
|
|
}
|
|
}
|
|
return values, nil
|
|
}
|
|
|
|
func jsonObjectStrings(raw []byte) (map[string]string, error) {
|
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
|
decoder.UseNumber()
|
|
var object map[string]any
|
|
if err := decoder.Decode(&object); err != nil {
|
|
return nil, err
|
|
}
|
|
values := make(map[string]string, len(object))
|
|
for key, value := range object {
|
|
if key == "" || value == nil {
|
|
continue
|
|
}
|
|
switch typed := value.(type) {
|
|
case string:
|
|
values[key] = typed
|
|
case json.Number:
|
|
values[key] = typed.String()
|
|
case bool:
|
|
values[key] = strconv.FormatBool(typed)
|
|
}
|
|
}
|
|
return values, nil
|
|
}
|
|
|
|
func parseXunHuPayTime(value string) time.Time {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return time.Time{}
|
|
}
|
|
if unix, err := strconv.ParseInt(value, 10, 64); err == nil && unix > 0 {
|
|
return time.Unix(unix, 0)
|
|
}
|
|
if parsed, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.FixedZone("CST", 8*60*60)); err == nil {
|
|
return parsed
|
|
}
|
|
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
|
|
return parsed
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
func isXunHuPayOrderMissing(message string) bool {
|
|
lower := strings.ToLower(message)
|
|
return strings.Contains(lower, "not exist") || strings.Contains(lower, "not found") || strings.Contains(message, "不存在")
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func randomNonce() string {
|
|
var raw [16]byte
|
|
if _, err := rand.Read(raw[:]); err != nil {
|
|
return strconv.FormatInt(time.Now().UnixNano(), 16)
|
|
}
|
|
return hex.EncodeToString(raw[:])
|
|
}
|