压缩历史至 95081aff

This commit is contained in:
ryan
2026-06-08 20:34:27 +08:00
commit 8a782525de
435 changed files with 71146 additions and 0 deletions
+29
View File
@@ -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"
)
+30
View File
@@ -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字节"
)
+161
View File
@@ -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()
}
}
+761
View File
@@ -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())
}
+142
View File
@@ -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
}
+278
View File
@@ -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
}