589 lines
16 KiB
Go
589 lines
16 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"crypto/md5"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"jnc-server/app/main/api/internal/config"
|
||
"jnc-server/app/main/model"
|
||
"net/http"
|
||
"net/url"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/zeromicro/go-zero/core/logx"
|
||
)
|
||
|
||
// EasyPayService 易支付服务
|
||
type EasyPayService struct {
|
||
config config.EasyPayConfig
|
||
client *http.Client
|
||
orderModel model.OrderModel // 订单模型,用于次数轮询模式查询用户订单数量
|
||
}
|
||
|
||
// getSelectedCID 获取当前应该使用的渠道ID
|
||
// ctx: 上下文,用于次数轮询模式时访问Redis
|
||
// userID: 用户ID,用于次数轮询模式时记录用户使用的渠道
|
||
func (e *EasyPayService) getSelectedCID(ctx context.Context, userID string) string {
|
||
// 如果没有配置 CID,返回空字符串
|
||
if len(e.config.CIDs) == 0 {
|
||
return ""
|
||
}
|
||
|
||
// 如果只有一个渠道,直接返回
|
||
if len(e.config.CIDs) == 1 {
|
||
return e.config.CIDs[0]
|
||
}
|
||
|
||
// 根据轮询模式选择策略
|
||
rotateMode := e.config.RotateMode
|
||
if rotateMode == "" {
|
||
rotateMode = "day" // 默认天数轮询
|
||
}
|
||
|
||
switch rotateMode {
|
||
case "count":
|
||
// 次数轮询模式:按用户订单次数轮询
|
||
return e.selectCIDByCount(ctx, userID)
|
||
case "day":
|
||
// 天数轮询模式:按时间轮询
|
||
return e.selectCIDByRotation()
|
||
default:
|
||
// 默认使用天数轮询
|
||
logx.Infof("未知的轮询模式: %s,使用默认天数轮询", rotateMode)
|
||
return e.selectCIDByRotation()
|
||
}
|
||
}
|
||
|
||
// selectCIDByRotation 按时间轮询策略选择CID
|
||
func (e *EasyPayService) selectCIDByRotation() string {
|
||
if len(e.config.CIDs) == 0 {
|
||
return ""
|
||
}
|
||
|
||
// 如果只有一个,直接返回
|
||
if len(e.config.CIDs) == 1 {
|
||
return e.config.CIDs[0]
|
||
}
|
||
|
||
// 获取轮询天数,默认3天
|
||
rotateDays := e.config.RotateDays
|
||
if rotateDays <= 0 {
|
||
rotateDays = 3
|
||
}
|
||
|
||
// 计算从某个基准日期(比如2020-01-01)开始的天数
|
||
baseDate := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
||
now := time.Now()
|
||
daysSinceBase := int(now.Sub(baseDate).Hours() / 24)
|
||
|
||
// 按轮询天数计算当前应该使用的索引
|
||
rotationIndex := (daysSinceBase / rotateDays) % len(e.config.CIDs)
|
||
|
||
selectedCID := e.config.CIDs[rotationIndex]
|
||
logx.Infof("易支付渠道天数轮询选择: 总渠道数=%d, 轮询天数=%d, 当前索引=%d, 选择渠道=%s",
|
||
len(e.config.CIDs), rotateDays, rotationIndex, selectedCID)
|
||
|
||
return selectedCID
|
||
}
|
||
|
||
// selectCIDByCount 按次数轮询策略选择CID(针对用户)
|
||
func (e *EasyPayService) selectCIDByCount(ctx context.Context, userID string) string {
|
||
if len(e.config.CIDs) == 0 {
|
||
return ""
|
||
}
|
||
|
||
// 如果只有一个,直接返回
|
||
if len(e.config.CIDs) == 1 {
|
||
return e.config.CIDs[0]
|
||
}
|
||
|
||
// 如果没有用户ID,回退到天数轮询
|
||
if userID == "" {
|
||
logx.Infof("次数轮询模式但用户ID为空,回退到天数轮询")
|
||
return e.selectCIDByRotation()
|
||
}
|
||
|
||
// 查询该用户的易支付订单数量
|
||
orderCount := int64(0)
|
||
if e.orderModel != nil {
|
||
builder := e.orderModel.SelectBuilder().
|
||
Where("user_id = ?", userID).
|
||
Where("payment_platform = ?", "easypay_alipay")
|
||
count, err := e.orderModel.FindCount(ctx, builder, "id")
|
||
if err != nil {
|
||
logx.Errorf("查询用户易支付订单数量失败: %v,使用索引0", err)
|
||
} else {
|
||
orderCount = count
|
||
}
|
||
}
|
||
|
||
// 根据订单数量计算应该使用的渠道索引(订单数量从0开始,所以第0个订单用索引0,第1个订单用索引1,以此类推)
|
||
channelIndex := int(orderCount) % len(e.config.CIDs)
|
||
selectedCID := e.config.CIDs[channelIndex]
|
||
|
||
logx.Infof("易支付渠道次数轮询选择: 用户ID=%s, 总渠道数=%d, 用户订单数=%d, 选择索引=%d, 选择渠道=%s",
|
||
userID, len(e.config.CIDs), orderCount, channelIndex, selectedCID)
|
||
|
||
return selectedCID
|
||
}
|
||
|
||
// NewEasyPayService 创建易支付服务实例
|
||
func NewEasyPayService(c config.Config, orderModel model.OrderModel) *EasyPayService {
|
||
return &EasyPayService{
|
||
config: c.EasyPay,
|
||
client: &http.Client{
|
||
Timeout: 30 * time.Second,
|
||
},
|
||
orderModel: orderModel,
|
||
}
|
||
}
|
||
|
||
// EasyPayOrderResponse API接口支付响应
|
||
type EasyPayOrderResponse struct {
|
||
Code interface{} `json:"code"` // 可能是 int 或 string
|
||
Msg string `json:"msg"`
|
||
TradeNo string `json:"trade_no,omitempty"`
|
||
OId string `json:"O_id,omitempty"`
|
||
PayUrl string `json:"payurl,omitempty"`
|
||
Qrcode string `json:"qrcode,omitempty"`
|
||
Img string `json:"img,omitempty"`
|
||
}
|
||
|
||
// GetCodeInt 获取 code 的 int 值
|
||
func (r *EasyPayOrderResponse) GetCodeInt() int {
|
||
switch v := r.Code.(type) {
|
||
case int:
|
||
return v
|
||
case float64:
|
||
return int(v)
|
||
case string:
|
||
if v == "1" || v == "error" {
|
||
if v == "1" {
|
||
return 1
|
||
}
|
||
return 0
|
||
}
|
||
return 0
|
||
default:
|
||
return 0
|
||
}
|
||
}
|
||
|
||
// EasyPayQueryResponse 查询订单响应
|
||
type EasyPayQueryResponse struct {
|
||
Code interface{} `json:"code"` // 可能是 int 或 string
|
||
Msg string `json:"msg"`
|
||
TradeNo string `json:"trade_no,omitempty"`
|
||
OutTradeNo string `json:"out_trade_no,omitempty"`
|
||
Type string `json:"type,omitempty"`
|
||
Pid string `json:"pid,omitempty"`
|
||
Addtime string `json:"addtime,omitempty"`
|
||
Endtime string `json:"endtime,omitempty"`
|
||
Name string `json:"name,omitempty"`
|
||
Money string `json:"money,omitempty"`
|
||
Status interface{} `json:"status,omitempty"` // 可能是 int 或 string
|
||
Param string `json:"param,omitempty"`
|
||
Buyer string `json:"buyer,omitempty"`
|
||
}
|
||
|
||
// GetCodeInt 获取 code 的 int 值
|
||
func (r *EasyPayQueryResponse) GetCodeInt() int {
|
||
switch v := r.Code.(type) {
|
||
case int:
|
||
return v
|
||
case float64:
|
||
return int(v)
|
||
case string:
|
||
if v == "1" {
|
||
return 1
|
||
}
|
||
return 0
|
||
default:
|
||
return 0
|
||
}
|
||
}
|
||
|
||
// GetStatusInt 获取 status 的 int 值
|
||
func (r *EasyPayQueryResponse) GetStatusInt() int {
|
||
switch v := r.Status.(type) {
|
||
case int:
|
||
return v
|
||
case float64:
|
||
return int(v)
|
||
case string:
|
||
if v == "1" {
|
||
return 1
|
||
}
|
||
return 0
|
||
default:
|
||
return 0
|
||
}
|
||
}
|
||
|
||
// EasyPayRefundResponse 退款响应
|
||
type EasyPayRefundResponse struct {
|
||
Code interface{} `json:"code"` // 可能是 int 或 string
|
||
Msg string `json:"msg"`
|
||
}
|
||
|
||
// GetCodeInt 获取 code 的 int 值
|
||
func (r *EasyPayRefundResponse) GetCodeInt() int {
|
||
switch v := r.Code.(type) {
|
||
case int:
|
||
return v
|
||
case float64:
|
||
return int(v)
|
||
case string:
|
||
if v == "1" {
|
||
return 1
|
||
}
|
||
return 0
|
||
default:
|
||
return 0
|
||
}
|
||
}
|
||
|
||
// EasyPayNotification 支付回调通知
|
||
type EasyPayNotification struct {
|
||
Pid string
|
||
Name string
|
||
Money string
|
||
OutTradeNo string
|
||
TradeNo string
|
||
Param string
|
||
TradeStatus string
|
||
Type string
|
||
Sign string
|
||
SignType string
|
||
}
|
||
|
||
// generateSign 生成MD5签名
|
||
func (e *EasyPayService) generateSign(params map[string]string) string {
|
||
// 排除 sign、sign_type 和空值
|
||
filteredParams := make(map[string]string)
|
||
for k, v := range params {
|
||
if k != "sign" && k != "sign_type" && v != "" {
|
||
filteredParams[k] = v
|
||
}
|
||
}
|
||
|
||
// 按参数名ASCII码从小到大排序
|
||
keys := make([]string, 0, len(filteredParams))
|
||
for k := range filteredParams {
|
||
keys = append(keys, k)
|
||
}
|
||
sort.Strings(keys)
|
||
|
||
// 拼接成URL键值对格式
|
||
var parts []string
|
||
for _, k := range keys {
|
||
parts = append(parts, fmt.Sprintf("%s=%s", k, filteredParams[k]))
|
||
}
|
||
queryString := strings.Join(parts, "&")
|
||
|
||
// 拼接商户密钥并MD5加密
|
||
signString := queryString + e.config.PKEY
|
||
hash := md5.Sum([]byte(signString))
|
||
return fmt.Sprintf("%x", hash) // 转为小写
|
||
}
|
||
|
||
// verifySign 验证签名
|
||
func (e *EasyPayService) verifySign(params map[string]string, sign string) bool {
|
||
calculatedSign := e.generateSign(params)
|
||
return strings.EqualFold(calculatedSign, sign)
|
||
}
|
||
|
||
// CreateEasyPayH5Order 创建易支付H5订单(页面跳转方式)
|
||
func (e *EasyPayService) CreateEasyPayH5Order(ctx context.Context, amount float64, subject string, outTradeNo string, userID string) (string, error) {
|
||
// 格式化金额,保留两位小数
|
||
moneyStr := fmt.Sprintf("%.2f", amount)
|
||
|
||
params := map[string]string{
|
||
"name": subject,
|
||
"money": moneyStr,
|
||
"type": "alipay",
|
||
"out_trade_no": outTradeNo,
|
||
"notify_url": e.config.NotifyUrl,
|
||
"pid": e.config.PID,
|
||
"return_url": e.config.ReturnUrl,
|
||
"sign_type": "MD5",
|
||
}
|
||
// 获取并添加渠道ID
|
||
if cid := e.getSelectedCID(ctx, userID); cid != "" {
|
||
params["cid"] = cid
|
||
}
|
||
|
||
// 生成签名
|
||
sign := e.generateSign(params)
|
||
params["sign"] = sign
|
||
|
||
// 构建支付URL
|
||
baseURL := strings.TrimSuffix(e.config.ApiURL, "/")
|
||
payURL := fmt.Sprintf("%s/submit.php", baseURL)
|
||
|
||
// 构建查询字符串
|
||
values := url.Values{}
|
||
for k, v := range params {
|
||
values.Set(k, v)
|
||
}
|
||
|
||
return fmt.Sprintf("%s?%s", payURL, values.Encode()), nil
|
||
}
|
||
|
||
// CreateEasyPayAppOrder 创建易支付APP订单(API方式)
|
||
func (e *EasyPayService) CreateEasyPayAppOrder(ctx context.Context, amount float64, subject string, outTradeNo string, clientIP string, userID string) (string, error) {
|
||
// 格式化金额,保留两位小数
|
||
moneyStr := fmt.Sprintf("%.2f", amount)
|
||
|
||
params := map[string]string{
|
||
"pid": e.config.PID,
|
||
"type": "alipay",
|
||
"out_trade_no": outTradeNo,
|
||
"notify_url": e.config.NotifyUrl,
|
||
"name": subject,
|
||
"money": moneyStr,
|
||
"clientip": clientIP,
|
||
"device": "pc",
|
||
"sign_type": "MD5",
|
||
}
|
||
// 获取并添加渠道ID
|
||
if cid := e.getSelectedCID(ctx, userID); cid != "" {
|
||
params["cid"] = cid
|
||
}
|
||
|
||
// 生成签名
|
||
sign := e.generateSign(params)
|
||
params["sign"] = sign
|
||
|
||
// 构建请求URL
|
||
baseURL := strings.TrimSuffix(e.config.ApiURL, "/")
|
||
apiURL := fmt.Sprintf("%s/mapi.php", baseURL)
|
||
|
||
// 构建form-data请求
|
||
values := url.Values{}
|
||
for k, v := range params {
|
||
values.Set(k, v)
|
||
}
|
||
|
||
// 发送POST请求
|
||
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, strings.NewReader(values.Encode()))
|
||
if err != nil {
|
||
return "", fmt.Errorf("创建请求失败: %v", err)
|
||
}
|
||
|
||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
|
||
resp, err := e.client.Do(req)
|
||
if err != nil {
|
||
return "", fmt.Errorf("请求失败: %v", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return "", fmt.Errorf("读取响应失败: %v", err)
|
||
}
|
||
|
||
var orderResp EasyPayOrderResponse
|
||
if err := json.Unmarshal(body, &orderResp); err != nil {
|
||
return "", fmt.Errorf("解析响应失败: %v, 响应内容: %s", err, string(body))
|
||
}
|
||
|
||
if orderResp.GetCodeInt() != 1 {
|
||
return "", fmt.Errorf("创建订单失败: %s", orderResp.Msg)
|
||
}
|
||
|
||
// 优先返回支付URL,如果没有则返回二维码
|
||
if orderResp.PayUrl != "" {
|
||
return orderResp.PayUrl, nil
|
||
}
|
||
if orderResp.Qrcode != "" {
|
||
return orderResp.Qrcode, nil
|
||
}
|
||
if orderResp.Img != "" {
|
||
return orderResp.Img, nil
|
||
}
|
||
|
||
return "", fmt.Errorf("未获取到支付链接")
|
||
}
|
||
|
||
// CreateEasyPayOrderResult 易支付订单创建结果
|
||
type CreateEasyPayOrderResult struct {
|
||
PayURL string // 支付URL
|
||
CID string // 使用的渠道ID
|
||
}
|
||
|
||
// CreateEasyPayOrder 根据平台类型创建易支付订单
|
||
func (e *EasyPayService) CreateEasyPayOrder(ctx context.Context, amount float64, subject string, outTradeNo string, userID string) (*CreateEasyPayOrderResult, error) {
|
||
// 获取选中的渠道ID
|
||
selectedCID := e.getSelectedCID(ctx, userID)
|
||
|
||
// 根据 ctx 中的 platform 判断平台
|
||
platform, platformOk := ctx.Value("platform").(string)
|
||
if !platformOk {
|
||
return nil, fmt.Errorf("无效的支付平台")
|
||
}
|
||
|
||
var payURL string
|
||
var err error
|
||
switch platform {
|
||
case model.PlatformApp:
|
||
// APP平台使用API方式
|
||
clientIP := ""
|
||
if ip, ok := ctx.Value("client_ip").(string); ok {
|
||
clientIP = ip
|
||
}
|
||
payURL, err = e.CreateEasyPayAppOrder(ctx, amount, subject, outTradeNo, clientIP, userID)
|
||
case model.PlatformH5:
|
||
// H5平台使用页面跳转方式
|
||
payURL, err = e.CreateEasyPayH5Order(ctx, amount, subject, outTradeNo, userID)
|
||
default:
|
||
return nil, fmt.Errorf("不支持的支付平台: %s", platform)
|
||
}
|
||
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return &CreateEasyPayOrderResult{
|
||
PayURL: payURL,
|
||
CID: selectedCID,
|
||
}, nil
|
||
}
|
||
|
||
// HandleEasyPayNotification 处理易支付回调通知
|
||
func (e *EasyPayService) HandleEasyPayNotification(r *http.Request) (*EasyPayNotification, error) {
|
||
// 解析GET参数
|
||
params := make(map[string]string)
|
||
for k, v := range r.URL.Query() {
|
||
if len(v) > 0 {
|
||
params[k] = v[0]
|
||
}
|
||
}
|
||
|
||
// 获取签名
|
||
sign, ok := params["sign"]
|
||
if !ok {
|
||
return nil, fmt.Errorf("缺少签名参数")
|
||
}
|
||
|
||
// 验证签名
|
||
if !e.verifySign(params, sign) {
|
||
return nil, fmt.Errorf("签名验证失败")
|
||
}
|
||
|
||
// 构建通知对象
|
||
notification := &EasyPayNotification{
|
||
Pid: params["pid"],
|
||
Name: params["name"],
|
||
Money: params["money"],
|
||
OutTradeNo: params["out_trade_no"],
|
||
TradeNo: params["trade_no"],
|
||
Param: params["param"],
|
||
TradeStatus: params["trade_status"],
|
||
Type: params["type"],
|
||
Sign: sign,
|
||
SignType: params["sign_type"],
|
||
}
|
||
|
||
return notification, nil
|
||
}
|
||
|
||
// QueryOrderStatus 查询订单状态
|
||
func (e *EasyPayService) QueryOrderStatus(ctx context.Context, outTradeNo string) (*EasyPayQueryResponse, error) {
|
||
// 构建查询URL
|
||
baseURL := strings.TrimSuffix(e.config.ApiURL, "/")
|
||
queryURL := fmt.Sprintf("%s/api.php?act=order&pid=%s&key=%s&out_trade_no=%s",
|
||
baseURL, e.config.PID, e.config.PKEY, url.QueryEscape(outTradeNo))
|
||
|
||
req, err := http.NewRequestWithContext(ctx, "GET", queryURL, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("创建请求失败: %v", err)
|
||
}
|
||
|
||
resp, err := e.client.Do(req)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("请求失败: %v", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取响应失败: %v", err)
|
||
}
|
||
|
||
var queryResp EasyPayQueryResponse
|
||
if err := json.Unmarshal(body, &queryResp); err != nil {
|
||
return nil, fmt.Errorf("解析响应失败: %v, 响应内容: %s", err, string(body))
|
||
}
|
||
|
||
if queryResp.GetCodeInt() != 1 {
|
||
return nil, fmt.Errorf("查询订单失败: %s", queryResp.Msg)
|
||
}
|
||
|
||
return &queryResp, nil
|
||
}
|
||
|
||
// Refund 申请退款
|
||
func (e *EasyPayService) Refund(ctx context.Context, outTradeNo string, refundAmount float64) error {
|
||
// 格式化金额,保留两位小数
|
||
moneyStr := fmt.Sprintf("%.2f", refundAmount)
|
||
|
||
params := map[string]string{
|
||
"pid": e.config.PID,
|
||
"key": e.config.PKEY,
|
||
"out_trade_no": outTradeNo,
|
||
"money": moneyStr,
|
||
}
|
||
|
||
// 构建请求URL
|
||
baseURL := strings.TrimSuffix(e.config.ApiURL, "/")
|
||
refundURL := fmt.Sprintf("%s/api.php?act=refund", baseURL)
|
||
|
||
// 构建form-data请求
|
||
values := url.Values{}
|
||
for k, v := range params {
|
||
values.Set(k, v)
|
||
}
|
||
|
||
// 发送POST请求
|
||
req, err := http.NewRequestWithContext(ctx, "POST", refundURL, strings.NewReader(values.Encode()))
|
||
if err != nil {
|
||
return fmt.Errorf("创建请求失败: %v", err)
|
||
}
|
||
|
||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
|
||
resp, err := e.client.Do(req)
|
||
if err != nil {
|
||
return fmt.Errorf("请求失败: %v", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return fmt.Errorf("读取响应失败: %v", err)
|
||
}
|
||
|
||
var refundResp EasyPayRefundResponse
|
||
if err := json.Unmarshal(body, &refundResp); err != nil {
|
||
return fmt.Errorf("解析响应失败: %v, 响应内容: %s", err, string(body))
|
||
}
|
||
|
||
if refundResp.GetCodeInt() != 1 {
|
||
return fmt.Errorf("退款失败: %s", refundResp.Msg)
|
||
}
|
||
|
||
logx.Infof("易支付退款成功,订单号: %s, 退款金额: %s", outTradeNo, moneyStr)
|
||
return nil
|
||
}
|
||
|
||
// IsPaymentSuccess 判断支付是否成功
|
||
func (e *EasyPayService) IsPaymentSuccess(notification *EasyPayNotification) bool {
|
||
return notification.TradeStatus == "TRADE_SUCCESS"
|
||
}
|