mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-07 16:16:37 +08:00
压缩历史至 95081aff
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
Copyright 2025 linux.do
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package payment
|
||||
|
||||
const (
|
||||
APIKeyObjKey = "payment_api_key_obj"
|
||||
CreateOrderRequestKey = "payment_create_order_request"
|
||||
)
|
||||
|
||||
const (
|
||||
// OrderMerchantIDCacheKeyFormat Redis key 格式,用于存储订单号对应的商户ID
|
||||
OrderMerchantIDCacheKeyFormat = "payment:order:%s"
|
||||
// OrderExpireKeyFormat Redis key 格式,用于订单过期监听,key中包含订单ID
|
||||
OrderExpireKeyFormat = "payment:order:expire:%d"
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
Copyright 2025 linux.do
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package payment
|
||||
|
||||
const (
|
||||
OrderNotFound = "订单不存在或已完成"
|
||||
OrderStatusInvalid = "订单状态不允许支付"
|
||||
OrderExpired = "订单已过期"
|
||||
MerchantInfoNotFound = "商户信息不存在"
|
||||
RecipientNotFound = "收款人不存在"
|
||||
OrderNoFormatError = "订单号格式错误"
|
||||
CannotTransferToSelf = "不能转账给自己"
|
||||
PayConfigNotFound = "支付配置不存在"
|
||||
InvalidPublicKeyFormat = "公钥格式错误"
|
||||
InvalidPublicKeyLength = "公钥长度必须为32字节"
|
||||
)
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
Copyright 2025 linux.do
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package payment
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/linux-do/credit/internal/common"
|
||||
"github.com/linux-do/credit/internal/db"
|
||||
"github.com/linux-do/credit/internal/model"
|
||||
"github.com/linux-do/credit/internal/util"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// CreateOrderRequest 商户创建订单统一请求
|
||||
type CreateOrderRequest struct {
|
||||
OrderName string `json:"order_name" binding:"required,max=64"`
|
||||
MerchantOrderNo *string `json:"merchant_order_no" binding:"omitempty,min=1,max=64"`
|
||||
Amount decimal.Decimal `json:"amount" binding:"required"`
|
||||
Remark string `json:"remark" binding:"max=100"`
|
||||
PaymentType string `json:"payment_type"`
|
||||
NotifyURL string `json:"notify_url" binding:"omitempty,max=100,url"`
|
||||
ReturnURL string `json:"return_url" binding:"omitempty,max=100,url"`
|
||||
}
|
||||
|
||||
// EPayRequest 易支付请求
|
||||
type EPayRequest struct {
|
||||
ClientID string `form:"pid" binding:"required"`
|
||||
OrderName string `form:"name" binding:"required,max=64"`
|
||||
MerchantOrderNo *string `form:"out_trade_no" binding:"required,min=1,max=64"`
|
||||
Amount decimal.Decimal `form:"money" binding:"required"`
|
||||
NotifyURL string `form:"notify_url" binding:"omitempty,max=100,url"`
|
||||
ReturnURL string `form:"return_url" binding:"omitempty,max=100,url"`
|
||||
Device string `form:"device"`
|
||||
Sign string `form:"sign" binding:"required"`
|
||||
PayType string `form:"type" binding:"required"`
|
||||
SignType string `form:"sign_type"`
|
||||
}
|
||||
|
||||
// LDCPayRequest LDC支付请求
|
||||
type LDCPayRequest struct {
|
||||
ClientID string `form:"client_id" binding:"required"`
|
||||
OrderName string `form:"order_name" binding:"required,max=64"`
|
||||
MerchantOrderNo *string `form:"out_trade_no" binding:"required,min=1,max=64"`
|
||||
Amount decimal.Decimal `form:"money" binding:"required"`
|
||||
NotifyURL string `form:"notify_url" binding:"omitempty,max=100,url"`
|
||||
ReturnURL string `form:"return_url" binding:"omitempty,max=100,url"`
|
||||
PayType string `form:"type" binding:"required"`
|
||||
Sign string `form:"sign" binding:"required"`
|
||||
}
|
||||
|
||||
// NewCreateOrderRequest 从支付请求创建通用订单请求
|
||||
func NewCreateOrderRequest(orderName string, merchantOrderNo *string, amount decimal.Decimal, payType string, notifyURL string, returnURL string) *CreateOrderRequest {
|
||||
return &CreateOrderRequest{
|
||||
OrderName: orderName,
|
||||
MerchantOrderNo: merchantOrderNo,
|
||||
Amount: amount,
|
||||
PaymentType: payType,
|
||||
NotifyURL: notifyURL,
|
||||
ReturnURL: returnURL,
|
||||
}
|
||||
}
|
||||
|
||||
// RequireMerchantAuth 验证商户 ClientID/ClientSecret(Basic Auth)
|
||||
func RequireMerchantAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Authorization: Basic base64(ClientID:ClientSecret)
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err("缺少认证信息"))
|
||||
return
|
||||
}
|
||||
|
||||
// 解析 Basic Auth
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Basic" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err("认证格式错误"))
|
||||
return
|
||||
}
|
||||
|
||||
// 解码 base64
|
||||
decoded, err := base64.StdEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err("认证信息解码失败"))
|
||||
return
|
||||
}
|
||||
|
||||
// 解析 ClientID:ClientSecret
|
||||
credentials := strings.SplitN(string(decoded), ":", 2)
|
||||
if len(credentials) != 2 {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err("认证信息格式错误"))
|
||||
return
|
||||
}
|
||||
|
||||
clientID := credentials[0]
|
||||
clientSecret := credentials[1]
|
||||
|
||||
var apiKey model.MerchantAPIKey
|
||||
if err := db.DB(c.Request.Context()).
|
||||
Where("client_secret = ? AND client_id = ?", clientSecret, clientID).
|
||||
First(&apiKey).Error; err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err("认证失败"))
|
||||
return
|
||||
}
|
||||
|
||||
util.SetToContext(c, APIKeyObjKey, &apiKey)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireSignatureAuth 验证签名
|
||||
func RequireSignatureAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
PayType := c.Request.FormValue("type")
|
||||
|
||||
var apiKey model.MerchantAPIKey
|
||||
var createOrderReq *CreateOrderRequest
|
||||
var err error
|
||||
|
||||
switch PayType {
|
||||
case common.PayTypeLDCPay:
|
||||
createOrderReq, err = VerifySignatureEd25519(c, &apiKey)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
case common.PayTypeEPay:
|
||||
createOrderReq, err = VerifySignatureMD5(c, &apiKey)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
default:
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, util.Err("不支持的请求类型"))
|
||||
return
|
||||
}
|
||||
|
||||
util.SetToContext(c, CreateOrderRequestKey, createOrderReq)
|
||||
util.SetToContext(c, APIKeyObjKey, &apiKey)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,761 @@
|
||||
/*
|
||||
Copyright 2025 linux.do
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package payment
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/linux-do/credit/internal/apps/oauth"
|
||||
"github.com/linux-do/credit/internal/common"
|
||||
"github.com/linux-do/credit/internal/config"
|
||||
"github.com/linux-do/credit/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/linux-do/credit/internal/db"
|
||||
"github.com/linux-do/credit/internal/model"
|
||||
"github.com/linux-do/credit/internal/util"
|
||||
"github.com/shopspring/decimal"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// PayOrderRequest 用户支付订单请求
|
||||
type PayOrderRequest struct {
|
||||
OrderNo string `json:"order_no" binding:"required"`
|
||||
PayKey string `json:"pay_key" binding:"required,max=6"`
|
||||
}
|
||||
|
||||
// GetOrderRequest 查询订单请求
|
||||
type GetOrderRequest struct {
|
||||
OrderNo string `form:"order_no" json:"order_no" binding:"required"`
|
||||
}
|
||||
|
||||
// MerchantInfo 商户信息
|
||||
type MerchantInfo struct {
|
||||
AppName string `json:"app_name"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
}
|
||||
|
||||
// GetOrderResponse 查询订单响应
|
||||
type GetOrderResponse struct {
|
||||
Order *model.Order `json:"order"`
|
||||
FeeRate decimal.Decimal `json:"fee_rate"`
|
||||
Merchant MerchantInfo `json:"merchant"`
|
||||
}
|
||||
|
||||
// TransferRequest 转账请求
|
||||
type TransferRequest struct {
|
||||
RecipientID uint64 `json:"recipient_id,string" binding:"required"`
|
||||
RecipientUsername string `json:"recipient_username" binding:"required"`
|
||||
Amount decimal.Decimal `json:"amount" binding:"required"`
|
||||
PayKey string `json:"pay_key" binding:"required,max=6"`
|
||||
Remark string `json:"remark" binding:"max=100"`
|
||||
}
|
||||
|
||||
// QueryOrderRequest 商户查询订单请求
|
||||
type QueryOrderRequest struct {
|
||||
Act string `form:"act" json:"act"`
|
||||
ClientID string `form:"pid" json:"pid" binding:"required"`
|
||||
ClientSecret string `form:"key" json:"key" binding:"required"`
|
||||
MerchantOrderNo *string `form:"out_trade_no" json:"out_trade_no" binding:"required,min=1,max=64"`
|
||||
}
|
||||
|
||||
// RefundOrderRequest 商户退款请求
|
||||
type RefundOrderRequest struct {
|
||||
ClientID string `form:"pid" json:"pid" binding:"required"`
|
||||
ClientSecret string `form:"key" json:"key" binding:"required"`
|
||||
MerchantOrderNo string `form:"out_trade_no" json:"out_trade_no"`
|
||||
TradeNo uint64 `form:"trade_no" json:"trade_no" binding:"required"`
|
||||
Amount decimal.Decimal `form:"money" json:"money" binding:"required"`
|
||||
}
|
||||
|
||||
// CreateMerchantOrder 商户创建订单接口
|
||||
// @Tags payment
|
||||
// @Accept x-www-form-urlencoded
|
||||
// @Produce json
|
||||
// @Param request body CreateOrderRequest true "request body"
|
||||
// @Success 200 {object} util.ResponseAny
|
||||
// @Router /pay/submit.php [post]
|
||||
// @Router /pay/submit.php [get]
|
||||
func CreateMerchantOrder(c *gin.Context) {
|
||||
req, _ := util.GetFromContext[*CreateOrderRequest](c, CreateOrderRequestKey)
|
||||
apiKey, _ := util.GetFromContext[*model.MerchantAPIKey](c, APIKeyObjKey)
|
||||
|
||||
// 获取商户用户信息
|
||||
var merchantUser model.User
|
||||
if err := db.DB(c.Request.Context()).Where("id = ? AND is_active = ?", apiKey.UserID, true).First(&merchantUser).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(MerchantInfoNotFound))
|
||||
return
|
||||
}
|
||||
|
||||
// 获取商家订单过期时间(分钟)
|
||||
expireMinutes, errGet := model.GetIntByKey(c.Request.Context(), model.ConfigKeyMerchantOrderExpireMinutes)
|
||||
if errGet != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(errGet.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
var payURL string
|
||||
|
||||
if err := db.DB(c.Request.Context()).Transaction(
|
||||
func(tx *gorm.DB) error {
|
||||
// 创建订单
|
||||
order := model.Order{
|
||||
OrderName: req.OrderName,
|
||||
ClientID: apiKey.ClientID,
|
||||
MerchantOrderNo: req.MerchantOrderNo,
|
||||
PayeeUserID: merchantUser.ID,
|
||||
Amount: req.Amount,
|
||||
Status: model.OrderStatusPending,
|
||||
Type: model.OrderTypePayment,
|
||||
Remark: req.Remark,
|
||||
PaymentType: req.PaymentType,
|
||||
RedirectURI: req.ReturnURL,
|
||||
NotifyURL: req.NotifyURL,
|
||||
ExpiresAt: time.Now().Add(time.Duration(expireMinutes) * time.Minute),
|
||||
}
|
||||
if err := tx.Create(&order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
encryptString, err := util.Encrypt(merchantUser.SignKey, strconv.FormatUint(order.ID, 10))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
merchantIDStr := strconv.FormatUint(merchantUser.ID, 10)
|
||||
if errSet := db.Redis.Set(c.Request.Context(), db.PrefixedKey(fmt.Sprintf(OrderMerchantIDCacheKeyFormat, encryptString)), merchantIDStr, time.Duration(expireMinutes)*time.Minute).Err(); errSet != nil {
|
||||
return fmt.Errorf("failed to set redis key: %w", errSet)
|
||||
}
|
||||
|
||||
expireKey := db.PrefixedKey(fmt.Sprintf(OrderExpireKeyFormat, order.ID))
|
||||
if errSet := db.Redis.Set(c.Request.Context(), expireKey, order.ID, time.Duration(expireMinutes)*time.Minute).Err(); errSet != nil {
|
||||
return fmt.Errorf("failed to set order expire key: %w", errSet)
|
||||
}
|
||||
|
||||
payURL = fmt.Sprintf("%s?order_no=%s", config.Config.App.FrontendPayURL, url.QueryEscape(encryptString))
|
||||
return nil
|
||||
},
|
||||
); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, payURL)
|
||||
}
|
||||
|
||||
// QueryMerchantOrderResponse 查询订单响应
|
||||
type QueryMerchantOrderResponse struct {
|
||||
Code int `json:"code" example:"1"`
|
||||
Msg string `json:"msg" example:"查询订单号成功!"`
|
||||
TradeNo string `json:"trade_no" example:"123456"`
|
||||
OutTradeNo string `json:"out_trade_no" example:"M202312080001"`
|
||||
Type string `json:"type" example:"epay"`
|
||||
Pid string `json:"pid" example:"1001"`
|
||||
AddTime string `json:"addtime" example:"2023-12-08 12:00:00"`
|
||||
EndTime string `json:"endtime" example:"2023-12-08 12:05:00"`
|
||||
Name string `json:"name" example:"商品名称"`
|
||||
Money string `json:"money" example:"10.00"`
|
||||
Status int `json:"status" example:"1"`
|
||||
}
|
||||
|
||||
// QueryMerchantOrder 商户主动查询订单状态接口
|
||||
// @Tags payment
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request query QueryOrderRequest true "查询参数"
|
||||
// @Success 200 {object} QueryMerchantOrderResponse
|
||||
// @Router /api.php [get]
|
||||
func QueryMerchantOrder(c *gin.Context) {
|
||||
var req QueryOrderRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": -1, "msg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var apiKey model.MerchantAPIKey
|
||||
if err := db.DB(c.Request.Context()).Where("client_id = ? AND client_secret = ?", req.ClientID, req.ClientSecret).First(&apiKey).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": -1, "msg": MerchantInfoNotFound})
|
||||
return
|
||||
}
|
||||
|
||||
var order model.Order
|
||||
if err := db.DB(c.Request.Context()).Where("client_id = ? AND merchant_order_no = ?", req.ClientID, req.MerchantOrderNo).First(&order).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": -1, "msg": OrderNotFound})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": -1, "msg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
statusInt := 0
|
||||
if order.Status == model.OrderStatusSuccess {
|
||||
statusInt = 1
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 1,
|
||||
"msg": "查询订单号成功!",
|
||||
"trade_no": strconv.FormatUint(order.ID, 10),
|
||||
"out_trade_no": order.MerchantOrderNo,
|
||||
"type": order.PaymentType,
|
||||
"pid": order.ClientID,
|
||||
"addtime": order.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
"endtime": order.TradeTime.Format("2006-01-02 15:04:05"),
|
||||
"name": order.OrderName,
|
||||
"money": order.Amount.Truncate(2).StringFixed(2),
|
||||
"status": statusInt,
|
||||
})
|
||||
}
|
||||
|
||||
// RefundMerchantOrderResponse 退款响应
|
||||
type RefundMerchantOrderResponse struct {
|
||||
Code int `json:"code" example:"1"`
|
||||
Msg string `json:"msg" example:"退款成功"`
|
||||
}
|
||||
|
||||
// RefundMerchantOrder 商户退款接口
|
||||
// @Tags payment
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body RefundOrderRequest true "退款请求"
|
||||
// @Success 200 {object} RefundMerchantOrderResponse
|
||||
// @Router /api.php [post]
|
||||
func RefundMerchantOrder(c *gin.Context) {
|
||||
var req RefundOrderRequest
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": -1, "msg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := util.ValidateAmount(req.Amount); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": -1, "msg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var apiKey model.MerchantAPIKey
|
||||
if err := db.DB(c.Request.Context()).Where("client_id = ? AND client_secret = ?", req.ClientID, req.ClientSecret).First(&apiKey).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": -1, "msg": MerchantInfoNotFound})
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
|
||||
var order model.Order
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id = ? AND client_id = ? AND status = ? AND amount = ? AND type IN ?", req.TradeNo, req.ClientID, model.OrderStatusSuccess, req.Amount, []model.OrderType{model.OrderTypePayment, model.OrderTypeOnline}).
|
||||
First(&order).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New(OrderNotFound)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var payerUser model.User
|
||||
if err := payerUser.GetByID(tx, order.PayerUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var merchantUser model.User
|
||||
if err := tx.Where("id = ? AND is_active = ?", apiKey.UserID, true).First(&merchantUser).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var merchantPayConfig model.UserPayConfig
|
||||
if err := merchantPayConfig.GetByPayScore(tx, merchantUser.PayScore); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
merchantScoreDecrease := order.Amount.Mul(merchantPayConfig.ScoreRate).Round(0).IntPart()
|
||||
if err := tx.Model(&model.User{}).
|
||||
Where("id = ?", merchantUser.ID).
|
||||
UpdateColumns(map[string]interface{}{
|
||||
"available_balance": gorm.Expr("available_balance - ?", order.Amount),
|
||||
"total_receive": gorm.Expr("total_receive - ?", order.Amount),
|
||||
"pay_score": gorm.Expr("pay_score - ?", merchantScoreDecrease),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Model(&model.User{}).
|
||||
Where("id = ?", payerUser.ID).
|
||||
UpdateColumns(map[string]interface{}{
|
||||
"available_balance": gorm.Expr("available_balance + ?", order.Amount),
|
||||
"total_payment": gorm.Expr("total_payment - ?", order.Amount),
|
||||
"pay_score": gorm.Expr("pay_score - ?", order.Amount.Round(0).IntPart()),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Model(&model.Order{}).
|
||||
Where("id = ?", order.ID).
|
||||
Update("status", model.OrderStatusRefund).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"code": -1, "msg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 1,
|
||||
"msg": "退款成功",
|
||||
})
|
||||
}
|
||||
|
||||
// MerchantDistributeRequest 商户分发请求
|
||||
type MerchantDistributeRequest struct {
|
||||
RecipientID uint64 `json:"user_id" binding:"required"`
|
||||
RecipientUsername string `json:"username" binding:"required"`
|
||||
Amount decimal.Decimal `json:"amount" binding:"required"`
|
||||
MerchantOrderNo *string `json:"out_trade_no" binding:"omitempty,min=1,max=64"`
|
||||
Remark string `json:"remark" binding:"max=100"`
|
||||
}
|
||||
|
||||
// MerchantDistribute 商户分发接口(商户向用户分发)
|
||||
// @Tags payment
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param Authorization header string true "Basic Auth (base64(client_id:client_secret))"
|
||||
// @Param request body MerchantDistributeRequest true "分发请求"
|
||||
// @Success 200 {object} util.ResponseAny
|
||||
// @Router /pay/distribute [post]
|
||||
func MerchantDistribute(c *gin.Context) {
|
||||
var req MerchantDistributeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if err := util.ValidateAmount(req.Amount); err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
apiKey, _ := util.GetFromContext[*model.MerchantAPIKey](c, APIKeyObjKey)
|
||||
|
||||
var orderID uint64
|
||||
|
||||
if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
|
||||
// 验证收款人是否存在且用户名匹配
|
||||
var recipient model.User
|
||||
if err := tx.Where("id = ? AND username = ?", req.RecipientID, req.RecipientUsername).First(&recipient).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New(RecipientNotFound)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 获取商户用户信息
|
||||
var merchantUser model.User
|
||||
if err := tx.Where("id = ? AND is_active = ?", apiKey.UserID, true).
|
||||
First(&merchantUser).Error; err != nil {
|
||||
return errors.New(MerchantInfoNotFound)
|
||||
}
|
||||
|
||||
// 不能分发给自己
|
||||
if recipient.ID == merchantUser.ID {
|
||||
return errors.New(CannotTransferToSelf)
|
||||
}
|
||||
|
||||
// 获取商户支付配置
|
||||
var merchantPayConfig model.UserPayConfig
|
||||
if err := merchantPayConfig.GetByPayScore(tx, merchantUser.PayScore); err != nil {
|
||||
return errors.New(PayConfigNotFound)
|
||||
}
|
||||
|
||||
if err := service.CheckDailyLimit(tx, merchantUser.ID, req.Amount, merchantPayConfig.DailyLimit); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, recipientAmount, distributePercent := service.CalculateFee(req.Amount, merchantPayConfig.DistributeRate)
|
||||
merchantScore := req.Amount.Mul(merchantPayConfig.ScoreRate).Round(0).IntPart()
|
||||
|
||||
order := model.Order{
|
||||
OrderName: "商户分发",
|
||||
ClientID: apiKey.ClientID,
|
||||
MerchantOrderNo: req.MerchantOrderNo,
|
||||
PayerUserID: merchantUser.ID,
|
||||
PayeeUserID: recipient.ID,
|
||||
Amount: req.Amount,
|
||||
Status: model.OrderStatusSuccess,
|
||||
Type: model.OrderTypeDistribute,
|
||||
Remark: req.Remark,
|
||||
TradeTime: time.Now(),
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
distributeRemark := fmt.Sprintf("[系统]: 分发费率%d%%", distributePercent)
|
||||
if order.Remark != "" {
|
||||
order.Remark = order.Remark + " " + distributeRemark
|
||||
} else {
|
||||
order.Remark = distributeRemark
|
||||
}
|
||||
|
||||
if err := tx.Create(&order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
orderID = order.ID
|
||||
|
||||
// 扣减商户余额,同时增加平台分数
|
||||
if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
|
||||
UserID: merchantUser.ID,
|
||||
Amount: req.Amount,
|
||||
Operation: service.BalanceDeduct,
|
||||
ScoreChange: merchantScore,
|
||||
TotalField: "total_payment",
|
||||
CheckBalance: true,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 增加收款人余额(按分发费率计算后的金额)
|
||||
if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
|
||||
UserID: recipient.ID,
|
||||
Amount: recipientAmount,
|
||||
Operation: service.BalanceAdd,
|
||||
TotalField: "total_receive",
|
||||
CheckBalance: false,
|
||||
AsyncTransfer: true,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 创建异步到账任务
|
||||
orderTransfer := model.OrderTransfer{
|
||||
OrderID: orderID,
|
||||
PayeeUserID: recipient.ID,
|
||||
Amount: recipientAmount,
|
||||
Status: model.OrderTransferStatusPending,
|
||||
TransferAt: model.GetRandomSettleAt(c.Request.Context()),
|
||||
}
|
||||
if err := tx.Create(&orderTransfer).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, util.OK(gin.H{
|
||||
"trade_no": strconv.FormatUint(orderID, 10),
|
||||
"out_trade_no": req.MerchantOrderNo,
|
||||
}))
|
||||
}
|
||||
|
||||
// GetPaymentPageDetails 查询支付订单信息接口(用于收银台页面)
|
||||
// @Tags payment
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param order_no query string true "订单号"
|
||||
// @Success 200 {object} util.ResponseAny
|
||||
// @Router /api/v1/merchant/payment/order [get]
|
||||
func GetPaymentPageDetails(c *gin.Context) {
|
||||
var req GetOrderRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
orderCtx, errCtx := ParseOrderNo(c, req.OrderNo)
|
||||
if HandleParseOrderNoError(c, errCtx) {
|
||||
return
|
||||
}
|
||||
|
||||
var order model.Order
|
||||
if err := db.DB(c.Request.Context()).
|
||||
Select("orders.*, payee_user.username as payee_username").
|
||||
Joins("LEFT JOIN users as payee_user ON orders.payee_user_id = payee_user.id").
|
||||
Where("orders.id = ? AND orders.status = ?", orderCtx.OrderID, model.OrderStatusPending).
|
||||
First(&order).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, util.Err(OrderNotFound))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
order.PayerUsername = orderCtx.CurrentUser.Username
|
||||
|
||||
var merchant model.MerchantAPIKey
|
||||
if err := db.DB(c.Request.Context()).
|
||||
Where("client_id = ?", order.ClientID).
|
||||
First(&merchant).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, util.Err(MerchantInfoNotFound))
|
||||
return
|
||||
}
|
||||
|
||||
redirectURI := cmp.Or(order.RedirectURI, merchant.RedirectURI)
|
||||
|
||||
c.JSON(http.StatusOK, util.OK(GetOrderResponse{
|
||||
Order: &order,
|
||||
FeeRate: orderCtx.MerchantPayConfig.FeeRate,
|
||||
Merchant: MerchantInfo{
|
||||
AppName: merchant.AppName,
|
||||
RedirectURI: redirectURI,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
// PayMerchantOrder 用户支付订单接口
|
||||
// @Tags payment
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body PayOrderRequest true "支付订单请求"
|
||||
// @Success 200 {object} util.ResponseAny
|
||||
// @Router /api/v1/merchant/payment [post]
|
||||
func PayMerchantOrder(c *gin.Context) {
|
||||
var req PayOrderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
orderCtx, errCtx := ParseOrderNo(c, req.OrderNo)
|
||||
if HandleParseOrderNoError(c, errCtx) {
|
||||
return
|
||||
}
|
||||
|
||||
if !orderCtx.CurrentUser.VerifyPayKey(req.PayKey) {
|
||||
c.JSON(http.StatusBadRequest, util.Err(common.PayKeyIncorrect))
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.DB(c.Request.Context()).Transaction(
|
||||
func(tx *gorm.DB) error {
|
||||
var order model.Order
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).
|
||||
Where("id = ? AND status = ?", orderCtx.OrderID, model.OrderStatusPending).
|
||||
First(&order).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New(OrderNotFound)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查订单是否过期
|
||||
if order.ExpiresAt.Before(time.Now()) {
|
||||
return errors.New(OrderExpired)
|
||||
}
|
||||
|
||||
isTestMode := orderCtx.MerchantAPIKey.TestMode
|
||||
|
||||
// 非测试模式:检查每日限额
|
||||
if !isTestMode {
|
||||
if err := service.CheckDailyLimit(tx, orderCtx.CurrentUser.ID, order.Amount, orderCtx.PayerPayConfig.DailyLimit); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 计算手续费
|
||||
_, merchantAmount, feePercent := service.CalculateFee(order.Amount, orderCtx.MerchantPayConfig.FeeRate)
|
||||
|
||||
// 更新订单状态
|
||||
order.Status = model.OrderStatusSuccess
|
||||
order.PayerUserID = orderCtx.CurrentUser.ID
|
||||
order.TradeTime = time.Now()
|
||||
|
||||
if isTestMode {
|
||||
order.Type = model.OrderTypeTest
|
||||
order.Remark = common.TestModeOrderRemark
|
||||
} else {
|
||||
feeRemark := fmt.Sprintf("[系统]: 收取商家%d%%手续费", feePercent)
|
||||
if order.Remark != "" {
|
||||
order.Remark = order.Remark + " " + feeRemark
|
||||
} else {
|
||||
order.Remark = feeRemark
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Save(&order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 非测试模式:扣减用户余额和增加商户余额
|
||||
if !isTestMode {
|
||||
// 扣用户
|
||||
if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
|
||||
UserID: orderCtx.CurrentUser.ID,
|
||||
Amount: order.Amount,
|
||||
Operation: service.BalanceDeduct,
|
||||
ScoreChange: order.Amount.Round(0).IntPart(),
|
||||
TotalField: "total_payment",
|
||||
CheckBalance: true,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 加给商家
|
||||
merchantScoreIncrease := order.Amount.Mul(orderCtx.MerchantPayConfig.ScoreRate).Round(0).IntPart()
|
||||
if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
|
||||
UserID: orderCtx.MerchantUser.ID,
|
||||
Amount: merchantAmount,
|
||||
Operation: service.BalanceAdd,
|
||||
ScoreChange: merchantScoreIncrease,
|
||||
TotalField: "total_receive",
|
||||
CheckBalance: false,
|
||||
AsyncTransfer: true,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 异步到账任务
|
||||
orderTransfer := model.OrderTransfer{
|
||||
OrderID: order.ID,
|
||||
PayeeUserID: order.PayeeUserID,
|
||||
Amount: merchantAmount,
|
||||
Status: model.OrderTransferStatusPending,
|
||||
TransferAt: model.GetRandomSettleAt(c.Request.Context()),
|
||||
}
|
||||
if err := tx.Create(&orderTransfer).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
expireKey := db.PrefixedKey(fmt.Sprintf(OrderExpireKeyFormat, order.ID))
|
||||
if err := db.Redis.Del(c.Request.Context(), expireKey).Err(); err != nil {
|
||||
log.Printf("[Payment] 删除订单过期key失败: order_id=%d, error=%v", order.ID, err)
|
||||
}
|
||||
|
||||
return service.EnqueueMerchantNotify(order.ID, order.ClientID)
|
||||
},
|
||||
); err != nil {
|
||||
errMsg := err.Error()
|
||||
switch errMsg {
|
||||
case common.InsufficientBalance, OrderExpired, common.DailyLimitExceeded:
|
||||
c.JSON(http.StatusBadRequest, util.Err(errMsg))
|
||||
case OrderNotFound:
|
||||
c.JSON(http.StatusNotFound, util.Err(errMsg))
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, util.Err(errMsg))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, util.OKNil())
|
||||
}
|
||||
|
||||
// Transfer 用户转账接口
|
||||
// @Tags payment
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body TransferRequest true "转账请求"
|
||||
// @Success 200 {object} util.ResponseAny
|
||||
// @Router /api/v1/payment/transfer [post]
|
||||
func Transfer(c *gin.Context) {
|
||||
var req TransferRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if err := util.ValidateAmount(req.Amount); err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
|
||||
|
||||
if !currentUser.VerifyPayKey(req.PayKey) {
|
||||
c.JSON(http.StatusBadRequest, util.Err(common.PayKeyIncorrect))
|
||||
return
|
||||
}
|
||||
|
||||
if currentUser.ID == req.RecipientID && currentUser.Username == req.RecipientUsername {
|
||||
c.JSON(http.StatusBadRequest, util.Err(CannotTransferToSelf))
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.DB(c.Request.Context()).Transaction(
|
||||
func(tx *gorm.DB) error {
|
||||
// 验证收款人是否存在且用户名匹配
|
||||
var recipient model.User
|
||||
if err := tx.Where("id = ? AND username = ?", req.RecipientID, req.RecipientUsername).First(&recipient).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New(RecipientNotFound)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 获取转账人支付配置
|
||||
var payerPayConfig model.UserPayConfig
|
||||
if err := payerPayConfig.GetByPayScore(tx, currentUser.PayScore); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := service.CheckDailyLimit(tx, currentUser.ID, req.Amount, payerPayConfig.DailyLimit); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 创建转账订单
|
||||
order := model.Order{
|
||||
OrderName: "转账",
|
||||
PayerUserID: currentUser.ID,
|
||||
PayeeUserID: recipient.ID,
|
||||
Amount: req.Amount,
|
||||
Status: model.OrderStatusSuccess,
|
||||
Type: model.OrderTypeTransfer,
|
||||
Remark: req.Remark,
|
||||
TradeTime: time.Now(),
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
if err := tx.Create(&order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 扣减付款人余额
|
||||
if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
|
||||
UserID: currentUser.ID,
|
||||
Amount: req.Amount,
|
||||
Operation: service.BalanceDeduct,
|
||||
TotalField: "total_transfer",
|
||||
CheckBalance: true,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 增加收款人余额
|
||||
if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
|
||||
UserID: recipient.ID,
|
||||
Amount: req.Amount,
|
||||
Operation: service.BalanceAdd,
|
||||
TotalField: "total_receive",
|
||||
CheckBalance: false,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
); err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, util.OKNil())
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
Copyright 2025 linux.do
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package payment
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/linux-do/credit/internal/common"
|
||||
"github.com/linux-do/credit/internal/config"
|
||||
"github.com/linux-do/credit/internal/db"
|
||||
"github.com/linux-do/credit/internal/logger"
|
||||
"github.com/linux-do/credit/internal/model"
|
||||
"github.com/linux-do/credit/internal/util"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// HandleMerchantPaymentNotify 处理商户支付回调任务
|
||||
func HandleMerchantPaymentNotify(ctx context.Context, t *asynq.Task) error {
|
||||
// 解析任务参数
|
||||
var payload struct {
|
||||
OrderID uint64 `json:"order_id"`
|
||||
ClientID string `json:"client_id"`
|
||||
}
|
||||
if err := json.Unmarshal(t.Payload(), &payload); err != nil {
|
||||
logger.ErrorF(ctx, "解析商户回调任务参数失败: %v", err)
|
||||
return fmt.Errorf("解析任务参数失败: %w", err)
|
||||
}
|
||||
|
||||
// 查询订单信息
|
||||
var order model.Order
|
||||
if err := db.DB(ctx).Where("id = ? AND status = ?", payload.OrderID, model.OrderStatusSuccess).First(&order).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
logger.ErrorF(ctx, "订单[ID:%d]不存在,跳过回调", payload.OrderID)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("查询订单失败: %w", err)
|
||||
}
|
||||
|
||||
// 查询商户API Key信息
|
||||
var apiKey model.MerchantAPIKey
|
||||
if err := apiKey.GetByClientID(db.DB(ctx), payload.ClientID); err != nil {
|
||||
logger.ErrorF(ctx, "查询商户[ClientID:%s]失败: %v", payload.ClientID, err)
|
||||
return fmt.Errorf("查询商户信息失败: %w", err)
|
||||
}
|
||||
|
||||
// 回调 URL
|
||||
callbackURL := cmp.Or(order.NotifyURL, apiKey.NotifyURL)
|
||||
|
||||
// 判断是否需要回调
|
||||
if config.Config.App.IsProduction() && util.IsLocalhost(callbackURL) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 构建回调参数
|
||||
callbackParams := map[string]string{
|
||||
"pid": payload.ClientID,
|
||||
"trade_no": strconv.FormatUint(order.ID, 10),
|
||||
"out_trade_no": util.DerefString(order.MerchantOrderNo),
|
||||
"type": common.PayTypeEPay,
|
||||
"name": order.OrderName,
|
||||
"money": order.Amount.Truncate(2).StringFixed(2),
|
||||
"trade_status": "TRADE_SUCCESS",
|
||||
}
|
||||
callbackParams["sign"] = GenerateSignature(callbackParams, apiKey.ClientSecret, true)
|
||||
|
||||
// 回调
|
||||
if err := sendCallbackRequest(ctx, callbackURL, callbackParams); err != nil {
|
||||
retried, _ := asynq.GetRetryCount(ctx)
|
||||
logger.ErrorF(ctx, "商户回调失败: 订单[ID:%d] 重试次数[%d] 错误: %v",
|
||||
payload.OrderID, retried+1, err)
|
||||
return err
|
||||
}
|
||||
|
||||
logger.InfoF(ctx, "商户回调成功: 订单[ID:%d] ClientID[%s]", payload.OrderID, payload.ClientID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendCallbackRequest 发送HTTP回调请求
|
||||
func sendCallbackRequest(ctx context.Context, callbackURL string, params map[string]string) error {
|
||||
vals := url.Values{}
|
||||
for k, v := range params {
|
||||
vals.Add(k, v)
|
||||
}
|
||||
|
||||
// 拼接URL
|
||||
separator := "?"
|
||||
if strings.Contains(callbackURL, "?") {
|
||||
separator = "&"
|
||||
}
|
||||
targetURL := callbackURL + separator + vals.Encode()
|
||||
|
||||
headers := map[string]string{
|
||||
"User-Agent": "LinuxDo-Credit/1.0",
|
||||
}
|
||||
|
||||
resp, err := util.Request(ctx, http.MethodGet, targetURL, nil, headers, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("回调返回异常状态码: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取响应失败: %w", err)
|
||||
}
|
||||
|
||||
responseText := strings.TrimSpace(strings.ToLower(string(respBody)))
|
||||
if responseText != "success" {
|
||||
return fmt.Errorf("回调返回非成功响应: %s", string(respBody))
|
||||
}
|
||||
|
||||
logger.InfoF(ctx, "商户回调请求成功: URL[%s] 响应[%s]", callbackURL, string(respBody))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
/*
|
||||
Copyright 2025 linux.do
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package payment
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/linux-do/credit/internal/apps/oauth"
|
||||
"github.com/linux-do/credit/internal/common"
|
||||
"github.com/linux-do/credit/internal/db"
|
||||
"github.com/linux-do/credit/internal/model"
|
||||
"github.com/linux-do/credit/internal/service"
|
||||
"github.com/linux-do/credit/internal/util"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// HandleParseOrderNoError 处理 ParseOrderNo 返回的错误,返回对应的 HTTP 响应
|
||||
func HandleParseOrderNoError(c *gin.Context, err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
errMsg := err.Error()
|
||||
switch errMsg {
|
||||
case OrderNotFound:
|
||||
c.JSON(http.StatusNotFound, util.Err(errMsg))
|
||||
case MerchantInfoNotFound, PayConfigNotFound:
|
||||
c.JSON(http.StatusInternalServerError, util.Err(errMsg))
|
||||
case common.CannotPaySelf, common.TestModeCannotProcessOrder, OrderNoFormatError:
|
||||
c.JSON(http.StatusBadRequest, util.Err(errMsg))
|
||||
case common.UnAuthorized:
|
||||
c.JSON(http.StatusUnauthorized, util.Err(errMsg))
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, util.Err(errMsg))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// OrderContext 订单上下文信息
|
||||
type OrderContext struct {
|
||||
OrderID uint64
|
||||
MerchantUser *model.User
|
||||
CurrentUser *model.User
|
||||
PayerPayConfig *model.UserPayConfig
|
||||
MerchantPayConfig *model.UserPayConfig
|
||||
MerchantAPIKey *model.MerchantAPIKey
|
||||
}
|
||||
|
||||
// ParseOrderNo 解析订单号,获取订单上下文信息
|
||||
func ParseOrderNo(c *gin.Context, orderNo string) (*OrderContext, error) {
|
||||
merchantIDStr, errGet := db.Redis.Get(c.Request.Context(), db.PrefixedKey(fmt.Sprintf(OrderMerchantIDCacheKeyFormat, orderNo))).Result()
|
||||
if errGet != nil {
|
||||
if errors.Is(errGet, redis.Nil) {
|
||||
return nil, errors.New(OrderNotFound)
|
||||
}
|
||||
return nil, errGet
|
||||
}
|
||||
|
||||
merchantID, errParse := strconv.ParseUint(merchantIDStr, 10, 64)
|
||||
if errParse != nil {
|
||||
return nil, errors.New(OrderNoFormatError)
|
||||
}
|
||||
|
||||
// 获取商户用户信息
|
||||
var merchantUser model.User
|
||||
if err := db.DB(c.Request.Context()).Where("id = ? AND is_active = ?", merchantID, true).First(&merchantUser).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New(MerchantInfoNotFound)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
|
||||
|
||||
orderNoStr, errDecrypt := util.Decrypt(merchantUser.SignKey, orderNo)
|
||||
if errDecrypt != nil {
|
||||
return nil, errors.New(OrderNoFormatError)
|
||||
}
|
||||
|
||||
orderID, errParse := strconv.ParseUint(orderNoStr, 10, 64)
|
||||
if errParse != nil {
|
||||
return nil, errors.New(OrderNoFormatError)
|
||||
}
|
||||
|
||||
var apiKey model.MerchantAPIKey
|
||||
if err := db.DB(c.Request.Context()).
|
||||
Where("client_id = (SELECT client_id FROM orders WHERE id = ?)", orderID).
|
||||
First(&apiKey).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New(OrderNotFound)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 验证测试模式下的支付权限
|
||||
if err := service.ValidateTestModePayment(currentUser.ID, merchantUser.ID, apiKey.TestMode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx := &OrderContext{
|
||||
OrderID: orderID,
|
||||
MerchantUser: &merchantUser,
|
||||
CurrentUser: currentUser,
|
||||
MerchantAPIKey: &apiKey,
|
||||
}
|
||||
|
||||
// 获取付款用户的支付配置(用于限额检查)
|
||||
var payerPayConfig model.UserPayConfig
|
||||
if err := payerPayConfig.GetByPayScore(db.DB(c.Request.Context()), currentUser.PayScore); err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New(PayConfigNotFound)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
ctx.PayerPayConfig = &payerPayConfig
|
||||
|
||||
// 获取商家的支付配置(用于手续费倍率)
|
||||
var merchantPayConfig model.UserPayConfig
|
||||
if err := merchantPayConfig.GetByPayScore(db.DB(c.Request.Context()), merchantUser.PayScore); err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New(PayConfigNotFound)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
ctx.MerchantPayConfig = &merchantPayConfig
|
||||
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
// GenerateSignature 生成签名
|
||||
func GenerateSignature(params map[string]string, secret string, isMD5 bool) string {
|
||||
// 按key排序
|
||||
keys := make([]string, 0, len(params))
|
||||
for k := range params {
|
||||
if k == "sign" || k == "sign_type" {
|
||||
continue
|
||||
}
|
||||
// 空值不参与签名
|
||||
if params[k] == "" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
|
||||
sort.Strings(keys)
|
||||
|
||||
// 拼接签名字符串
|
||||
var builder strings.Builder
|
||||
builder.Grow(256)
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
builder.WriteByte('&')
|
||||
}
|
||||
builder.WriteString(k)
|
||||
builder.WriteByte('=')
|
||||
builder.WriteString(params[k])
|
||||
}
|
||||
builder.WriteString(secret)
|
||||
|
||||
if isMD5 {
|
||||
// MD5加密
|
||||
hash := md5.Sum([]byte(builder.String()))
|
||||
return fmt.Sprintf("%x", hash)
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// VerifySignatureMD5 验证MD5签名
|
||||
func VerifySignatureMD5(c *gin.Context, apiKey *model.MerchantAPIKey) (*CreateOrderRequest, error) {
|
||||
var req EPayRequest
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 验证金额
|
||||
if err := util.ValidateAmount(req.Amount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := apiKey.GetByClientID(db.DB(c.Request.Context()), req.ClientID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 构建签名参数
|
||||
params := map[string]string{
|
||||
"pid": req.ClientID,
|
||||
"type": req.PayType,
|
||||
"out_trade_no": util.DerefString(req.MerchantOrderNo),
|
||||
"notify_url": req.NotifyURL,
|
||||
"return_url": req.ReturnURL,
|
||||
"name": req.OrderName,
|
||||
"device": req.Device,
|
||||
}
|
||||
|
||||
params["money"] = req.Amount.Truncate(2).StringFixed(2)
|
||||
expectedSignFixed := GenerateSignature(params, apiKey.ClientSecret, true)
|
||||
|
||||
params["money"] = req.Amount.Truncate(2).String()
|
||||
expectedSignTrimmed := GenerateSignature(params, apiKey.ClientSecret, true)
|
||||
|
||||
matchFixed := subtle.ConstantTimeCompare([]byte(strings.ToLower(expectedSignFixed)), []byte(strings.ToLower(req.Sign))) == 1
|
||||
matchTrimmed := subtle.ConstantTimeCompare([]byte(strings.ToLower(expectedSignTrimmed)), []byte(strings.ToLower(req.Sign))) == 1
|
||||
|
||||
if !matchFixed && !matchTrimmed {
|
||||
return nil, errors.New("签名验证失败")
|
||||
}
|
||||
|
||||
return NewCreateOrderRequest(req.OrderName, req.MerchantOrderNo, req.Amount, req.PayType, req.NotifyURL, req.ReturnURL), nil
|
||||
}
|
||||
|
||||
// VerifySignatureEd25519 验证 Ed25519 签名
|
||||
func VerifySignatureEd25519(c *gin.Context, apiKey *model.MerchantAPIKey) (*CreateOrderRequest, error) {
|
||||
var req LDCPayRequest
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 验证金额
|
||||
if err := util.ValidateAmount(req.Amount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := apiKey.GetByClientID(db.DB(c.Request.Context()), req.ClientID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(apiKey.PublicKey) == 0 {
|
||||
return nil, errors.New("商户未配置公钥")
|
||||
}
|
||||
|
||||
signatureBytes, err := util.Base64Decode(req.Sign)
|
||||
if err != nil {
|
||||
return nil, errors.New("签名格式错误")
|
||||
}
|
||||
|
||||
// 构建签名参数
|
||||
params := map[string]string{
|
||||
"client_id": req.ClientID,
|
||||
"type": req.PayType,
|
||||
"out_trade_no": util.DerefString(req.MerchantOrderNo),
|
||||
"order_name": req.OrderName,
|
||||
"notify_url": req.NotifyURL,
|
||||
"return_url": req.ReturnURL,
|
||||
"money": req.Amount.Truncate(2).StringFixed(2),
|
||||
}
|
||||
|
||||
signatureParam := GenerateSignature(params, apiKey.ClientSecret, false)
|
||||
validTrimmed := util.Ed25519Verify(apiKey.PublicKey, []byte(signatureParam), signatureBytes)
|
||||
|
||||
if !validTrimmed {
|
||||
return nil, errors.New("签名验证失败")
|
||||
}
|
||||
|
||||
return NewCreateOrderRequest(req.OrderName, req.MerchantOrderNo, req.Amount, req.PayType, req.NotifyURL, req.ReturnURL), nil
|
||||
}
|
||||
Reference in New Issue
Block a user