Files
OpenFlare/internal/apps/merchant/link/routers.go
T
2026-06-08 20:34:27 +08:00

442 lines
14 KiB
Go

/*
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 link
import (
"errors"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/apps/merchant"
"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/shopspring/decimal"
"gorm.io/gorm"
)
// PayByLinkRequest 通过支付链接支付请求
type PayByLinkRequest struct {
Token string `json:"token" binding:"required"`
PayKey string `json:"pay_key" binding:"required,max=6"`
Remark string `json:"remark" binding:"max=100"`
}
// PaymentLinkRequest 创建支付链接请求
type PaymentLinkRequest struct {
Amount decimal.Decimal `json:"amount" binding:"required"`
ProductName string `json:"product_name" binding:"required,max=30"`
Remark string `json:"remark" binding:"max=100"`
TotalLimit *uint `json:"total_limit" binding:"omitempty,min=1"`
UserLimit *uint `json:"user_limit" binding:"omitempty,min=1"`
}
// CreatePaymentLink 创建支付链接
// @Tags merchant
// @Accept json
// @Produce json
// @Param id path uint64 true "API Key ID"
// @Param request body PaymentLinkRequest true "创建支付链接请求"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/merchant/api-keys/{id}/payment-links [post]
func CreatePaymentLink(c *gin.Context) {
var req PaymentLinkRequest
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, merchant.APIKeyObjKey)
paymentLink := model.MerchantPaymentLink{
MerchantAPIKeyID: apiKey.ID,
Token: util.GenerateUniqueIDSimple(),
Amount: req.Amount,
ProductName: req.ProductName,
Remark: req.Remark,
TotalLimit: req.TotalLimit,
UserLimit: req.UserLimit,
}
if err := db.DB(c.Request.Context()).Create(&paymentLink).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(paymentLink))
}
// PaymentLinkDetail 支付链接详情
type PaymentLinkDetail struct {
ID uint64 `json:"id,string"`
Token string `json:"token"`
Amount decimal.Decimal `json:"amount"`
ProductName string `json:"product_name"`
Remark string `json:"remark"`
TotalLimit *uint `json:"total_limit"`
UserLimit *uint `json:"user_limit"`
CreatedAt time.Time `json:"created_at"`
AppName string `json:"app_name"`
RedirectURI string `json:"redirect_uri"`
}
// ListPaymentLinks 获取支付链接列表
// @Tags merchant
// @Produce json
// @Param id path uint64 true "API Key ID"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/merchant/api-keys/{id}/payment-links [get]
func ListPaymentLinks(c *gin.Context) {
apiKey, _ := util.GetFromContext[*model.MerchantAPIKey](c, merchant.APIKeyObjKey)
var paymentLinks []PaymentLinkDetail
if err := db.DB(c.Request.Context()).
Table("merchant_payment_links").
Select("merchant_payment_links.id, merchant_payment_links.token, merchant_payment_links.amount, merchant_payment_links.product_name, merchant_payment_links.remark, merchant_payment_links.total_limit, merchant_payment_links.user_limit, merchant_payment_links.created_at, merchant_api_keys.app_name, merchant_api_keys.redirect_uri").
Joins("JOIN merchant_api_keys ON merchant_api_keys.id = merchant_payment_links.merchant_api_key_id").
Where("merchant_payment_links.merchant_api_key_id = ? AND merchant_payment_links.deleted_at IS NULL", apiKey.ID).
Order("merchant_payment_links.created_at DESC").
Find(&paymentLinks).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(paymentLinks))
}
// GetPaymentLinkByToken 通过 Token 查询支付链接信息
// @Tags merchant
// @Produce json
// @Param token path string true "支付链接 Token"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/merchant/payment-links/{token} [get]
func GetPaymentLinkByToken(c *gin.Context) {
var paymentLink PaymentLinkDetail
if err := db.DB(c.Request.Context()).
Table("merchant_payment_links").
Select("merchant_payment_links.id, merchant_payment_links.token, merchant_payment_links.amount, merchant_payment_links.product_name, merchant_payment_links.remark, merchant_payment_links.created_at, merchant_api_keys.app_name, merchant_api_keys.redirect_uri").
Joins("JOIN merchant_api_keys ON merchant_api_keys.id = merchant_payment_links.merchant_api_key_id").
Where("merchant_payment_links.token = ? AND merchant_payment_links.deleted_at IS NULL", c.Param("token")).
First(&paymentLink).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, util.Err(PaymentLinkNotFound))
} else {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
}
return
}
c.JSON(http.StatusOK, util.OK(paymentLink))
}
// DeletePaymentLink 删除支付链接
// @Tags merchant
// @Produce json
// @Param id path uint64 true "API Key ID"
// @Param linkId path uint64 true "Payment Link ID"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/merchant/api-keys/{id}/payment-links/{linkId} [delete]
func DeletePaymentLink(c *gin.Context) {
apiKey, _ := util.GetFromContext[*model.MerchantAPIKey](c, merchant.APIKeyObjKey)
linkID := c.Param("linkId")
result := db.DB(c.Request.Context()).
Where("id = ? AND merchant_api_key_id = ?", linkID, apiKey.ID).
Delete(&model.MerchantPaymentLink{})
if result.Error != nil {
c.JSON(http.StatusInternalServerError, util.Err(result.Error.Error()))
return
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, util.Err(PaymentLinkNotFound))
return
}
c.JSON(http.StatusOK, util.OKNil())
}
// UpdatePaymentLink 更新支付链接
// @Tags merchant
// @Accept json
// @Produce json
// @Param id path uint64 true "API Key ID"
// @Param linkId path uint64 true "Payment Link ID"
// @Param request body PaymentLinkRequest true "更新支付链接请求"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/merchant/api-keys/{id}/payment-links/{linkId} [put]
func UpdatePaymentLink(c *gin.Context) {
var req PaymentLinkRequest
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, merchant.APIKeyObjKey)
linkID := c.Param("linkId")
result := db.DB(c.Request.Context()).
Model(&model.MerchantPaymentLink{}).
Where("id = ? AND merchant_api_key_id = ?", linkID, apiKey.ID).
Updates(map[string]interface{}{
"amount": req.Amount,
"product_name": req.ProductName,
"remark": req.Remark,
"total_limit": req.TotalLimit,
"user_limit": req.UserLimit,
})
if result.Error != nil {
c.JSON(http.StatusInternalServerError, util.Err(result.Error.Error()))
return
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, util.Err(PaymentLinkNotFound))
return
}
c.JSON(http.StatusOK, util.OKNil())
}
// PayByLink 通过支付链接支付
// @Tags merchant
// @Accept json
// @Produce json
// @Param request body PayByLinkRequest true "支付请求"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/merchant/payment-links/pay [post]
func PayByLink(c *gin.Context) {
var req PayByLinkRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
var paymentLink model.MerchantPaymentLink
if err := paymentLink.GetByToken(db.DB(c.Request.Context()), req.Token); err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, util.Err(PaymentLinkNotFound))
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.AvailableBalance.LessThan(paymentLink.Amount) {
c.JSON(http.StatusBadRequest, util.Err(common.InsufficientBalance))
return
}
// 查询商户 API Key
var merchantAPIKey model.MerchantAPIKey
if err := merchantAPIKey.GetByID(db.DB(c.Request.Context()), paymentLink.MerchantAPIKeyID); err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
// 查询商户用户
var merchantUser model.User
if err := merchantUser.GetByID(db.DB(c.Request.Context()), merchantAPIKey.UserID); err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
// 验证测试模式下的支付权限
if err := service.ValidateTestModePayment(currentUser.ID, merchantUser.ID, merchantAPIKey.TestMode); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
// 获取商户的支付配置
var merchantPayConfig model.UserPayConfig
if err := merchantPayConfig.GetByPayScore(db.DB(c.Request.Context()), merchantUser.PayScore); err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
// 获取付款方的支付配置
var payerPayConfig model.UserPayConfig
if err := payerPayConfig.GetByPayScore(db.DB(c.Request.Context()), currentUser.PayScore); err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
isTestMode := merchantAPIKey.TestMode
if err := db.DB(c.Request.Context()).Transaction(
func(tx *gorm.DB) error {
// 非测试模式
if !isTestMode {
if paymentLink.TotalLimit != nil || paymentLink.UserLimit != nil {
if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", paymentLink.ID).Error; err != nil {
return err
}
}
// 检查总付款次数限制
if paymentLink.TotalLimit != nil {
var totalCount int64
if err := tx.Table("orders").
Where("payment_link_id = ? AND status = ?", paymentLink.ID, model.OrderStatusSuccess).
Count(&totalCount).Error; err != nil {
return err
}
if totalCount >= int64(*paymentLink.TotalLimit) {
return errors.New(PaymentLinkTotalLimitExceeded)
}
}
// 检查单用户付款次数限制
if paymentLink.UserLimit != nil {
var userCount int64
if err := tx.Table("orders").
Where("payment_link_id = ? AND status = ? AND payer_user_id = ?",
paymentLink.ID, model.OrderStatusSuccess, currentUser.ID).
Count(&userCount).Error; err != nil {
return err
}
if userCount >= int64(*paymentLink.UserLimit) {
return errors.New(PaymentLinkUserLimitExceeded)
}
}
// 检查每日限额
if err := service.CheckDailyLimit(tx, currentUser.ID, paymentLink.Amount, payerPayConfig.DailyLimit); err != nil {
return err
}
}
// 计算手续费
_, merchantAmount, feePercent := service.CalculateFee(paymentLink.Amount, merchantPayConfig.FeeRate)
var remark string
var orderType model.OrderType
var paymentLinkID *uint64
if isTestMode {
remark = common.TestModeOrderRemark
orderType = model.OrderTypeTest
} else {
feeRemark := fmt.Sprintf("[系统]: 收取商家%d%%手续费", feePercent)
if req.Remark != "" {
remark = req.Remark + " " + feeRemark
} else {
remark = feeRemark
}
orderType = model.OrderTypeOnline
paymentLinkID = &paymentLink.ID
}
// 创建订单
order := model.Order{
OrderName: paymentLink.ProductName,
PayerUserID: currentUser.ID,
PayeeUserID: merchantUser.ID,
ClientID: merchantAPIKey.ClientID,
Amount: paymentLink.Amount,
Status: model.OrderStatusSuccess,
Type: orderType,
Remark: remark,
PaymentLinkID: paymentLinkID,
TradeTime: time.Now(),
ExpiresAt: time.Now(),
}
if err := tx.Create(&order).Error; err != nil {
return err
}
// 非测试模式:扣减用户余额和增加商户余额
if !isTestMode {
// 扣用户
if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
UserID: currentUser.ID,
Amount: paymentLink.Amount,
Operation: service.BalanceDeduct,
ScoreChange: paymentLink.Amount.Round(0).IntPart(),
TotalField: "total_payment",
CheckBalance: true,
}); err != nil {
return err
}
// 加商家
merchantScoreIncrease := paymentLink.Amount.Mul(merchantPayConfig.ScoreRate).Round(0).IntPart()
if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
UserID: 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: merchantUser.ID,
Amount: merchantAmount,
Status: model.OrderTransferStatusPending,
TransferAt: model.GetRandomSettleAt(c.Request.Context()),
}
if err := tx.Create(&orderTransfer).Error; err != nil {
return err
}
}
return service.EnqueueMerchantNotify(order.ID, merchantAPIKey.ClientID)
},
); err != nil {
errMsg := err.Error()
switch errMsg {
case common.InsufficientBalance, common.DailyLimitExceeded,
PaymentLinkTotalLimitExceeded, PaymentLinkUserLimitExceeded:
c.JSON(http.StatusBadRequest, util.Err(errMsg))
default:
c.JSON(http.StatusInternalServerError, util.Err(errMsg))
}
return
}
c.JSON(http.StatusOK, util.OKNil())
}