Files
ttstd-infinite-canvas/backend/payment-plugins/epay.go

489 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package paymentplugins
import (
"context"
"crypto/md5"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
)
const (
defaultEpayGateway = "https://pay.admincloudai.com"
epayPluginVersion = "1.0.8"
epayQRLifetime = 10 * time.Minute
maxEpayResponse = 2 << 20
)
type EpayProvider struct {
client *http.Client
now func() time.Time
}
func NewEpayProvider(client *http.Client) *EpayProvider {
if client == nil {
client = http.DefaultClient
}
return &EpayProvider{client: client, now: time.Now}
}
func (p *EpayProvider) Descriptor() Descriptor {
return Descriptor{
ID: ProviderEpay, PluginID: PluginEpay, PluginVersion: epayPluginVersion,
Name: "易支付", Icon: "assets/icon.svg", CheckoutMode: "redirect",
IdentityFields: []string{"pid"},
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 *EpayProvider) ValidateConfig(config Config) error {
if strings.TrimSpace(config["pid"]) == "" {
return errors.New("易支付配置缺少商户号 pid")
}
version := epayVersion(config)
if version != "0" && version != "1" {
return errors.New("易支付 version 必须是 0 或 1")
}
if version == "0" && strings.TrimSpace(config["key"]) == "" {
return errors.New("易支付 V1 配置缺少商户密钥 key")
}
if version == "1" {
if strings.TrimSpace(config["privateKey"]) == "" || strings.TrimSpace(config["platformPublicKey"]) == "" {
return errors.New("易支付 V2 配置缺少 privateKey 或 platformPublicKey")
}
if _, err := parseRSAPrivateKey(config["privateKey"]); err != nil {
return fmt.Errorf("易支付商户私钥无效: %w", err)
}
if _, err := parseRSAPublicKey(config["platformPublicKey"]); err != nil {
return fmt.Errorf("易支付平台公钥无效: %w", err)
}
}
if _, err := epayGateway(config); err != nil {
return err
}
return nil
}
func (p *EpayProvider) 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" || strings.TrimSpace(request.MerchantOrderNo) == "" || strings.TrimSpace(request.NotifyURL) == "" {
return Checkout{}, errors.New("易支付下单参数无效")
}
params := map[string]string{
"pid": strings.TrimSpace(config["pid"]),
"name": epayTitle(request.Description, request.MerchantOrderNo),
"type": epayPayType(config),
"money": formatEpayAmount(request.AmountFen),
"out_trade_no": request.MerchantOrderNo,
"notify_url": request.NotifyURL,
"return_url": request.ReturnURL,
"sitename": request.MerchantOrderNo,
// CreateRequest 不带买家 IP;mapi 仍要求 clientip,占位即可。
"clientip": "0.0.0.0",
"device": epayDevice(config),
}
version := epayVersion(config)
path := "/mapi.php"
successCode := "1"
if version == "1" {
params["method"] = "jump"
params["timestamp"] = strconv.FormatInt(p.now().Unix(), 10)
params["sign"] = epayRSASign(params, config["privateKey"])
params["sign_type"] = "RSA"
path = "/api/pay/create"
successCode = "0"
} else {
params["sign"] = epayMD5Sign(params, config["key"])
params["sign_type"] = "MD5"
}
response, status, err := p.postForm(ctx, config, path, params)
if err != nil {
return Checkout{}, err
}
if response["code"] != successCode {
message := strings.TrimSpace(epayString(response["msg"]))
if message == "" {
message = fmt.Sprintf("易支付返回失败状态 code=%s", response["code"])
}
return Checkout{}, &ProviderError{Code: "epay_order_create_failed", Message: message, Temporary: status >= 500}
}
mode := epayCheckoutMode(config)
var value string
if version == "1" {
// method=jump 只会返回跳转地址
value = epayString(response["pay_info"])
mode = "redirect"
} else {
// payurl、qrcode、urlscheme 三者只会返回其一
qrcode := epayString(response["qrcode"])
if mode == "qr_code" && qrcode != "" {
value = qrcode
} else {
// urlscheme 是小程序跳转链接,前端直接跳;没有本地收银台时二维码链接也直接跳过去
value = firstNonEmpty(epayString(response["payurl"]), epayString(response["urlscheme"]), qrcode)
}
}
if !isEpayURL(value) {
return Checkout{}, &ProviderError{Code: "epay_invalid_checkout", Message: "易支付未返回有效的支付地址"}
}
expiresAt := request.ExpiresAt
if expiresAt.IsZero() {
expiresAt = p.now().Add(epayQRLifetime)
}
return Checkout{Mode: mode, Value: value, ExpiresAt: expiresAt}, nil
}
func (p *EpayProvider) 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("易支付查单缺少商户订单号")
}
if epayVersion(config) == "1" {
return Result{}, &ProviderError{Code: "epay_query_unsupported", Message: "易支付 V2 查单接口未在当前协议中实现"}
}
base, _ := epayGateway(config)
query := url.Values{}
query.Set("act", "order")
query.Set("pid", strings.TrimSpace(config["pid"]))
query.Set("key", strings.TrimSpace(config["key"]))
query.Set("out_trade_no", request.MerchantOrderNo)
endpoint := strings.TrimRight(base, "/") + "/api.php?" + query.Encode()
response, status, err := p.getJSON(ctx, endpoint)
if err != nil {
return Result{}, err
}
if response["code"] != "1" {
message := strings.TrimSpace(epayString(response["msg"]))
if message == "" {
message = "易支付订单不存在或查询失败"
}
return Result{}, &ProviderError{Code: "epay_query_failed", Message: message, Temporary: status >= 500}
}
return epayResult(response, request.MerchantOrderNo), nil
}
func (p *EpayProvider) CloseOrder(ctx context.Context, config Config, request CloseRequest) (Result, error) {
if err := p.ValidateConfig(config); err != nil {
return Result{}, err
}
if strings.TrimSpace(request.MerchantOrderNo) == "" {
return Result{}, errors.New("易支付关单缺少商户订单号")
}
if epayVersion(config) == "0" {
result, err := p.QueryOrder(ctx, config, QueryRequest{MerchantOrderNo: request.MerchantOrderNo})
if err != nil {
return Result{}, err
}
if result.Paid {
return result, nil
}
result.Closed = true
result.ProviderStatus = "TRADE_CLOSED"
return result, nil
}
// V2 没有通用远程关单接口,只关闭宿主本地订单。
return Result{MerchantOrderNo: request.MerchantOrderNo, ProviderStatus: "TRADE_CLOSED", Currency: "CNY", Closed: true}, nil
}
func (p *EpayProvider) VerifyNotification(_ context.Context, config Config, _ http.Header, rawBody []byte) (Notification, error) {
if err := p.ValidateConfig(config); err != nil {
return Notification{}, err
}
params, err := parseNotificationValues(rawBody)
if err != nil {
return Notification{}, fmt.Errorf("解析易支付异步通知失败: %w", err)
}
values := make(map[string]string, len(params))
for key, items := range params {
if len(items) > 0 {
values[key] = items[0]
}
}
if values["pid"] != strings.TrimSpace(config["pid"]) {
return Notification{}, errors.New("易支付异步通知商户号不匹配")
}
if strings.TrimSpace(values["out_trade_no"]) == "" || strings.TrimSpace(values["money"]) == "" {
return Notification{}, errors.New("易支付异步通知缺少订单号或金额")
}
if epayVersion(config) == "1" {
signType := strings.ToUpper(strings.TrimSpace(values["sign_type"]))
if signType != "" && signType != "RSA" && signType != "RSA2" {
return Notification{}, errors.New("易支付异步通知签名类型无效")
}
publicKey, err := parseRSAPublicKey(config["platformPublicKey"])
if err != nil {
return Notification{}, err
}
if err := rsaSHA256Verify(publicKey, []byte(epayCanonical(values)), values["sign"]); err != nil {
return Notification{}, errors.New("易支付异步通知 RSA 签名无效")
}
} else {
signType := strings.ToUpper(strings.TrimSpace(values["sign_type"]))
if signType != "" && signType != "MD5" {
return Notification{}, errors.New("易支付异步通知签名类型无效")
}
// 定时安全比较,且只吃字符串(挡掉 sign 传数组/缺失);与其它支付插件一致
if !epaySafetyEquals(values["sign"], epayMD5Sign(values, config["key"])) {
return Notification{}, errors.New("易支付异步通知 MD5 签名无效")
}
}
amount, err := parseYuanToFen(values["money"])
if err != nil {
return Notification{}, fmt.Errorf("易支付异步通知金额无效: %w", err)
}
status := strings.TrimSpace(values["trade_status"])
paid := status == "TRADE_SUCCESS"
providerTradeNo := firstNonEmpty(values["trade_no"], values["api_trade_no"])
eventID := firstNonEmpty(values["notify_id"], providerTradeNo, values["out_trade_no"]+":"+status)
return Notification{
EventID: eventID,
Result: Result{
MerchantOrderNo: values["out_trade_no"], ProviderTradeNo: providerTradeNo,
ProviderStatus: status, AmountFen: amount, Currency: "CNY",
Paid: paid, Closed: status == "TRADE_CLOSED", PaidAt: parseEpayTime(firstNonEmpty(values["endtime"], values["gmt_payment"])),
},
}, nil
}
func (p *EpayProvider) DownloadTradeBill(context.Context, Config, time.Time) ([]BillRecord, error) {
return nil, fmt.Errorf("%w: 易支付未提供统一账单下载接口", ErrTradeBillNotFound)
}
type epayResponse map[string]string
func (p *EpayProvider) postForm(ctx context.Context, config Config, path string, values map[string]string) (epayResponse, int, error) {
base, err := epayGateway(config)
if err != nil {
return nil, 0, err
}
form := url.Values{}
for key, value := range values {
form.Set(key, value)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(base, "/")+path, strings.NewReader(form.Encode()))
if err != nil {
return nil, 0, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// 网关卡住不能一直等;超时由注入的 http.Client 控制(RPC 入口约 25s)
resp, err := p.client.Do(req)
if err != nil {
return nil, 0, &ProviderError{Code: "epay_transport_error", Message: "易支付网络请求失败", Temporary: true, Cause: err}
}
defer resp.Body.Close()
return epayJSONResponse(resp)
}
func (p *EpayProvider) getJSON(ctx context.Context, endpoint string) (epayResponse, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, 0, err
}
resp, err := p.client.Do(req)
if err != nil {
return nil, 0, &ProviderError{Code: "epay_transport_error", Message: "易支付网络请求失败", Temporary: true, Cause: err}
}
defer resp.Body.Close()
return epayJSONResponse(resp)
}
func epayJSONResponse(resp *http.Response) (epayResponse, int, error) {
body, err := io.ReadAll(io.LimitReader(resp.Body, maxEpayResponse+1))
if err != nil {
return nil, resp.StatusCode, &ProviderError{Code: "epay_response_read_error", Message: "读取易支付响应失败", Temporary: true, Cause: err}
}
if len(body) > maxEpayResponse {
return nil, resp.StatusCode, &ProviderError{Code: "epay_response_too_large", Message: "易支付响应过大"}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, resp.StatusCode, &ProviderError{Code: "epay_http_error", Message: fmt.Sprintf("易支付 HTTP 状态异常: %d", resp.StatusCode), Temporary: resp.StatusCode >= 500}
}
var raw map[string]any
// 有的网关输出带 BOM
if err := json.Unmarshal([]byte(strings.TrimPrefix(string(body), "\xEF\xBB\xBF")), &raw); err != nil {
return nil, resp.StatusCode, &ProviderError{Code: "epay_response_parse_error", Message: "易支付响应不是有效 JSON", Temporary: true, Cause: err}
}
result := make(epayResponse, len(raw))
for key, value := range raw {
result[key] = epayString(value)
}
return result, resp.StatusCode, nil
}
func epayGateway(config Config) (string, error) {
gateway := strings.TrimSpace(config["gateway"])
if gateway == "" {
gateway = defaultEpayGateway
}
parsed, err := url.Parse(gateway)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return "", errors.New("易支付网关必须是有效的 HTTP(S) 根地址")
}
return strings.TrimRight(gateway, "/"), nil
}
func epayVersion(config Config) string {
version := strings.TrimSpace(config["version"])
if version == "" {
return "0"
}
return version
}
func epayCheckoutMode(config Config) string {
if strings.TrimSpace(config["checkoutMode"]) == "qr_code" {
return "qr_code"
}
return "redirect"
}
func epayPayType(config Config) string {
payType := strings.TrimSpace(config["payType"])
if payType == "" {
return "alipay"
}
return payType
}
// mapi 是服务端下单,网关看不到买家的浏览器,只能靠 device 决定给什么支付页。
// 微信/QQ/支付宝里打开的要报对,不然拿到的是 App 内打不开的 H5 或二维码。
// 宿主 CreateRequest 不带 UA,可用配置 device 覆盖;留空默认 pc。
func epayDevice(config Config) string {
if device := strings.TrimSpace(config["device"]); device != "" {
return device
}
return "pc"
}
func epayTitle(description, orderNo string) string {
title := strings.TrimSpace(description)
if title == "" {
title = "商品订单号:" + orderNo
}
return strings.ReplaceAll(title, "%", "")
}
func formatEpayAmount(amountFen int64) string {
return strconv.FormatInt(amountFen/100, 10) + "." + fmt.Sprintf("%02d", amountFen%100)
}
func epayMD5Sign(values map[string]string, key string) string {
return epayMD5Hex(epayCanonical(values) + key)
}
func epayCanonical(values map[string]string) string {
keys := make([]string, 0, len(values))
for key, value := range values {
if key == "sign" || key == "sign_type" || value == "" {
continue
}
keys = append(keys, key)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
parts = append(parts, key+"="+values[key])
}
return strings.Join(parts, "&")
}
func epayMD5Hex(value string) string {
hash := md5.Sum([]byte(value))
return hex.EncodeToString(hash[:])
}
func epayRSASign(values map[string]string, privateKey string) string {
key, err := parseRSAPrivateKey(privateKey)
if err != nil {
return ""
}
signature, err := rsaSHA256Sign(key, []byte(epayCanonical(values)))
if err != nil {
return ""
}
return signature
}
func epaySafetyEquals(actual, expected string) bool {
return actual != "" && len(actual) == len(expected) && subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) == 1
}
func epayResult(response epayResponse, fallbackOrderNo string) Result {
status := epayString(response["status"])
if status == "" && response["trade_status"] != "" {
status = response["trade_status"]
}
amount, _ := parseYuanToFen(firstNonEmpty(epayString(response["money"]), epayString(response["total_amount"])))
return Result{
MerchantOrderNo: firstNonEmpty(epayString(response["out_trade_no"]), fallbackOrderNo),
ProviderTradeNo: firstNonEmpty(epayString(response["trade_no"]), epayString(response["api_trade_no"])),
ProviderStatus: status, AmountFen: amount, Currency: "CNY",
Paid: status == "1" || status == "TRADE_SUCCESS", Closed: status == "TRADE_CLOSED",
PaidAt: parseEpayTime(firstNonEmpty(epayString(response["endtime"]), epayString(response["gmt_payment"]))),
}
}
func epayString(value any) string {
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed)
case json.Number:
return typed.String()
case float64:
return strconv.FormatFloat(typed, 'f', -1, 64)
case int:
return strconv.Itoa(typed)
default:
return ""
}
}
// 只认带协议头的地址(http(s),或 weixin:// 这类唤起 App 的),挡掉 javascript: 之类
func isEpayURL(value string) bool {
value = strings.TrimSpace(value)
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme == "" {
return false
}
scheme := strings.ToLower(parsed.Scheme)
if scheme == "javascript" || scheme == "data" || scheme == "vbscript" {
return false
}
if scheme == "http" || scheme == "https" {
return parsed.Host != ""
}
return strings.Contains(value, "://")
}
func parseEpayTime(value string) time.Time {
value = strings.TrimSpace(value)
for _, layout := range []string{"2006-01-02 15:04:05", time.RFC3339} {
if parsed, err := time.ParseInLocation(layout, value, time.Local); err == nil {
return parsed
}
}
return time.Time{}
}