Files
ttstd-infinite-canvas/backend/payment-plugins/xunhupay.go
ddCat bb2a79d365 feat(payment): 虎皮椒聚合支付 - 官方插件与跨平台可执行文件
新增 xunhupay 官方包,支付 RPC 与宿主只保留订单和验签边界;官方支付包同时带上各系统 provider,避免把 Linux ELF 当成本机入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 23:32:11 +08:00

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[:])
}