mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-11 01:36:37 +08:00
压缩历史至 95081aff
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
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 dispute
|
||||
|
||||
const (
|
||||
OrderNotFoundForDispute = "订单不存在"
|
||||
DisputeNotFound = "争议不存在"
|
||||
NotOrderMerchant = "您不是该订单的商家"
|
||||
ReasonRequiredForRefusal = "拒绝退款时必须提供理由"
|
||||
DisputeTimeWindowExpired = "订单已交易完成,超过争议时间窗口,无法发起争议"
|
||||
DuplicateDispute = "无法重复发起争议,如仍有疑问请联系商家或LINUX DO Credit 团队"
|
||||
)
|
||||
@@ -0,0 +1,449 @@
|
||||
/*
|
||||
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 dispute
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/linux-do/credit/internal/apps/oauth"
|
||||
"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"
|
||||
)
|
||||
|
||||
// ListDisputesRequest 查询争议列表请求
|
||||
type ListDisputesRequest struct {
|
||||
Page int `json:"page" form:"page" binding:"min=1"`
|
||||
PageSize int `json:"page_size" form:"page_size" binding:"min=1,max=100"`
|
||||
Status string `json:"status" form:"status" binding:"omitempty,oneof=disputing refund closed"`
|
||||
DisputeID *uint64 `json:"dispute_id,string" form:"dispute_id" binding:"omitempty"`
|
||||
}
|
||||
|
||||
// ListDisputesResponse 查询争议列表响应
|
||||
type ListDisputesResponse struct {
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Disputes []struct {
|
||||
model.Dispute
|
||||
OrderName string `json:"order_name"`
|
||||
PayeeUsername string `json:"payee_username"`
|
||||
Amount decimal.Decimal `json:"amount"`
|
||||
} `json:"disputes"`
|
||||
}
|
||||
|
||||
// ListDisputes 查询当前用户作为发起者的争议订单
|
||||
// @Tags order
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body ListDisputesRequest false "request body"
|
||||
// @Success 200 {object} util.ResponseAny
|
||||
// @Router /api/v1/order/disputes [post]
|
||||
func ListDisputes(c *gin.Context) {
|
||||
var req ListDisputesRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
user, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
|
||||
|
||||
baseQuery := db.DB(c.Request.Context()).Model(&model.Dispute{}).
|
||||
Select("disputes.*, orders.order_name, payee_user.username as payee_username, orders.amount, initiator_user.username as initiator_username, handler_user.username as handler_username").
|
||||
Joins("JOIN orders ON disputes.order_id = orders.id").
|
||||
Joins("JOIN users as payee_user ON orders.payee_user_id = payee_user.id").
|
||||
Joins("JOIN users as initiator_user ON disputes.initiator_user_id = initiator_user.id").
|
||||
Joins("LEFT JOIN users as handler_user ON disputes.handler_user_id = handler_user.id").
|
||||
Where("disputes.initiator_user_id = ?", user.ID)
|
||||
|
||||
if req.Status != "" {
|
||||
baseQuery = baseQuery.Where("disputes.status = ?", model.DisputeStatus(req.Status))
|
||||
}
|
||||
if req.DisputeID != nil {
|
||||
baseQuery = baseQuery.Where("disputes.id = ?", req.DisputeID)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := baseQuery.Count(&total).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response := &ListDisputesResponse{
|
||||
Total: total,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
}
|
||||
|
||||
offset := (req.Page - 1) * req.PageSize
|
||||
if err := baseQuery.Order("disputes.created_at DESC").Offset(offset).Limit(req.PageSize).Find(&response.Disputes).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, util.OK(response))
|
||||
}
|
||||
|
||||
// ListMerchantDisputes 查询当前用户作为商家的争议订单
|
||||
// @Tags order
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body ListDisputesRequest false "request body"
|
||||
// @Success 200 {object} util.ResponseAny
|
||||
// @Router /api/v1/order/disputes/merchant [post]
|
||||
func ListMerchantDisputes(c *gin.Context) {
|
||||
var req ListDisputesRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
user, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
|
||||
|
||||
baseQuery := db.DB(c.Request.Context()).Model(&model.Dispute{}).
|
||||
Select("disputes.*, orders.order_name, payee_user.username as payee_username, orders.amount, initiator_user.username as initiator_username, handler_user.username as handler_username").
|
||||
Joins("JOIN orders ON disputes.order_id = orders.id").
|
||||
Joins("JOIN users as payee_user ON orders.payee_user_id = payee_user.id").
|
||||
Joins("JOIN users as initiator_user ON disputes.initiator_user_id = initiator_user.id").
|
||||
Joins("LEFT JOIN users as handler_user ON disputes.handler_user_id = handler_user.id").
|
||||
Where("orders.payee_user_id = ?", user.ID)
|
||||
|
||||
if req.Status != "" {
|
||||
baseQuery = baseQuery.Where("disputes.status = ?", model.DisputeStatus(req.Status))
|
||||
}
|
||||
if req.DisputeID != nil {
|
||||
baseQuery = baseQuery.Where("disputes.id = ?", req.DisputeID)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := baseQuery.Count(&total).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response := &ListDisputesResponse{
|
||||
Total: total,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
}
|
||||
|
||||
offset := (req.Page - 1) * req.PageSize
|
||||
if err := baseQuery.Order("disputes.created_at DESC").Offset(offset).Limit(req.PageSize).Find(&response.Disputes).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, util.OK(response))
|
||||
}
|
||||
|
||||
// CreateDisputeRequest 发起争议请求
|
||||
type CreateDisputeRequest struct {
|
||||
OrderID uint64 `json:"order_id,string" binding:"required"`
|
||||
Reason string `json:"reason" binding:"required,max=100"`
|
||||
}
|
||||
|
||||
// CreateDispute 发起争议
|
||||
// @Tags order
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body CreateDisputeRequest true "request body"
|
||||
// @Success 200 {object} util.ResponseAny
|
||||
// @Router /api/v1/order/dispute [post]
|
||||
func CreateDispute(c *gin.Context) {
|
||||
var req CreateDisputeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
user, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
|
||||
|
||||
// 获取争议时间窗口配置(小时)
|
||||
disputeTimeHours, errKey := model.GetIntByKey(c.Request.Context(), model.ConfigKeyDisputeTimeWindowHours)
|
||||
if errKey != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(errKey.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
dispute := model.Dispute{
|
||||
OrderID: req.OrderID,
|
||||
InitiatorUserID: user.ID,
|
||||
Reason: req.Reason,
|
||||
Status: model.DisputeStatusDisputing,
|
||||
}
|
||||
|
||||
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 payer_user_id = ? AND status = ? AND type IN ?", req.OrderID, user.ID, model.OrderStatusSuccess, []model.OrderType{model.OrderTypePayment, model.OrderTypeOnline}).
|
||||
First(&order).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New(OrderNotFoundForDispute)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查是否在争议时间窗口内
|
||||
// 订单支付时间 + 争议时间窗口 <= 当前时间,则无法发起争议
|
||||
disputeDeadline := order.TradeTime.Add(time.Duration(disputeTimeHours) * time.Hour)
|
||||
if time.Now().After(disputeDeadline) {
|
||||
return errors.New(DisputeTimeWindowExpired)
|
||||
}
|
||||
|
||||
if err := tx.Create(&dispute).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 更新订单状态为争议中
|
||||
if err := tx.Model(&order).Update("status", model.OrderStatusDisputing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
); err != nil {
|
||||
errMsg := err.Error()
|
||||
if errMsg == OrderNotFoundForDispute {
|
||||
c.JSON(http.StatusNotFound, util.Err(OrderNotFoundForDispute))
|
||||
} else if errMsg == DisputeTimeWindowExpired {
|
||||
c.JSON(http.StatusBadRequest, util.Err(DisputeTimeWindowExpired))
|
||||
} else if strings.Contains(errMsg, "SQLSTATE 23505") {
|
||||
c.JSON(http.StatusBadRequest, util.Err(DuplicateDispute))
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(errMsg))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, util.OK(dispute))
|
||||
}
|
||||
|
||||
// RefundReviewRequest 退款审核请求
|
||||
type RefundReviewRequest struct {
|
||||
DisputeID uint64 `json:"dispute_id,string" binding:"required"`
|
||||
Status string `json:"status" binding:"required,oneof=refund closed"`
|
||||
Reason string `json:"reason" binding:"omitempty,max=100"`
|
||||
}
|
||||
|
||||
// RefundReview 退款审核(同意/拒绝)
|
||||
// @Tags order
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body RefundReviewRequest true "request body"
|
||||
// @Success 200 {object} util.ResponseAny
|
||||
// @Router /api/v1/order/refund-review [post]
|
||||
func RefundReview(c *gin.Context) {
|
||||
var req RefundReviewRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
status := model.DisputeStatus(req.Status)
|
||||
|
||||
if status == model.DisputeStatusClosed && req.Reason == "" {
|
||||
c.JSON(http.StatusBadRequest, util.Err(ReasonRequiredForRefusal))
|
||||
return
|
||||
}
|
||||
|
||||
merchantUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
|
||||
|
||||
if err := db.DB(c.Request.Context()).Transaction(
|
||||
func(tx *gorm.DB) error {
|
||||
var dispute model.Dispute
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).
|
||||
Where("id = ? AND status = ?", req.DisputeID, model.DisputeStatusDisputing).
|
||||
First(&dispute).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New(DisputeNotFound)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var order model.Order
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).
|
||||
Where("id = ? AND payee_user_id = ? AND status = ? AND type IN ?", dispute.OrderID, merchantUser.ID, model.OrderStatusDisputing, []model.OrderType{model.OrderTypePayment, model.OrderTypeOnline}).
|
||||
First(&order).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New(NotOrderMerchant)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if status == model.DisputeStatusRefund {
|
||||
var payerUser model.User
|
||||
if err := payerUser.GetByID(tx, order.PayerUserID); 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.Dispute{}).
|
||||
Where("id = ?", dispute.ID).
|
||||
Updates(map[string]interface{}{
|
||||
"status": model.DisputeStatusRefund,
|
||||
"handler_user_id": merchantUser.ID,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Model(&model.Order{}).
|
||||
Where("id = ?", order.ID).
|
||||
Update("status", model.OrderStatusRefund).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else if status == model.DisputeStatusClosed {
|
||||
updateData := map[string]interface{}{
|
||||
"status": model.DisputeStatusClosed,
|
||||
"handler_user_id": merchantUser.ID,
|
||||
"reason": dispute.Reason + " [服务方拒绝理由: " + req.Reason + "]",
|
||||
}
|
||||
|
||||
if err := tx.Model(&model.Dispute{}).
|
||||
Where("id = ?", dispute.ID).
|
||||
Updates(updateData).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Model(&model.Order{}).
|
||||
Where("id = ?", order.ID).
|
||||
Update("status", model.OrderStatusRefused).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
); err != nil {
|
||||
errMsg := err.Error()
|
||||
if errMsg == DisputeNotFound {
|
||||
c.JSON(http.StatusNotFound, util.Err(DisputeNotFound))
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(errMsg))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, util.OKNil())
|
||||
}
|
||||
|
||||
// CloseDisputeRequest 关闭争议请求
|
||||
type CloseDisputeRequest struct {
|
||||
DisputeID uint64 `json:"dispute_id,string" binding:"required"`
|
||||
}
|
||||
|
||||
// CloseDispute 用户主动关闭争议(只能由发起者关闭)
|
||||
// @Tags order
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body CloseDisputeRequest true "request body"
|
||||
// @Success 200 {object} util.ResponseAny
|
||||
// @Router /api/v1/order/dispute/close [post]
|
||||
func CloseDispute(c *gin.Context) {
|
||||
var req CloseDisputeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
user, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
|
||||
|
||||
if err := db.DB(c.Request.Context()).Transaction(
|
||||
func(tx *gorm.DB) error {
|
||||
var dispute model.Dispute
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).
|
||||
Where("id = ? AND initiator_user_id = ? AND status = ?", req.DisputeID, user.ID, model.DisputeStatusDisputing).
|
||||
First(&dispute).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New(DisputeNotFound)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var order model.Order
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).
|
||||
Where("id = ? AND status = ? AND type IN ?", dispute.OrderID, model.OrderStatusDisputing, []model.OrderType{model.OrderTypePayment, model.OrderTypeOnline}).
|
||||
First(&order).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New(OrderNotFoundForDispute)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Model(&model.Dispute{}).
|
||||
Where("id = ?", dispute.ID).
|
||||
Updates(map[string]interface{}{
|
||||
"status": model.DisputeStatusClosed,
|
||||
"handler_user_id": user.ID,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Model(&model.Order{}).
|
||||
Where("id = ?", order.ID).
|
||||
Update("status", model.OrderStatusSuccess).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
); err != nil {
|
||||
errMsg := err.Error()
|
||||
if errMsg == DisputeNotFound {
|
||||
c.JSON(http.StatusNotFound, util.Err(DisputeNotFound))
|
||||
} else if errMsg == OrderNotFoundForDispute {
|
||||
c.JSON(http.StatusNotFound, util.Err(OrderNotFoundForDispute))
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(errMsg))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, util.OKNil())
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
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 dispute
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"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/task"
|
||||
"github.com/linux-do/credit/internal/task/scheduler"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// HandleAutoRefundExpiredDisputes 处理所有过期争议的批量任务
|
||||
func HandleAutoRefundExpiredDisputes(ctx context.Context, t *asynq.Task) error {
|
||||
// 获取争议时间窗口配置(小时)
|
||||
disputeTimeHours, errGet := model.GetIntByKey(ctx, model.ConfigKeyDisputeTimeWindowHours)
|
||||
if errGet != nil {
|
||||
logger.ErrorF(ctx, "获取争议时间窗口配置失败: %v", errGet)
|
||||
return errGet
|
||||
}
|
||||
|
||||
pageSize := 1000
|
||||
lastID := uint64(0)
|
||||
currentDelay := 0 * time.Second
|
||||
|
||||
// 计算过期时间阈值:created_at < deadline 的争议需要自动退款
|
||||
deadline := time.Now().Add(-time.Duration(disputeTimeHours) * time.Hour)
|
||||
|
||||
for {
|
||||
var disputes []model.Dispute
|
||||
if err := db.DB(ctx).
|
||||
Where("id > ? AND status = ? AND created_at < ?",
|
||||
lastID, model.DisputeStatusDisputing, deadline).
|
||||
Order("id ASC").
|
||||
Limit(pageSize).
|
||||
Find(&disputes).Error; err != nil {
|
||||
logger.ErrorF(ctx, "查询过期争议失败: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// 没有更多争议,退出循环
|
||||
if len(disputes) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for _, dispute := range disputes {
|
||||
currentDelay += time.Duration(config.Config.Scheduler.DisputeAutoRefundDispatchIntervalSeconds) * time.Second
|
||||
|
||||
payload, _ := json.Marshal(map[string]interface{}{
|
||||
"dispute_id": dispute.ID,
|
||||
})
|
||||
|
||||
if _, errTask := scheduler.AsynqClient.Enqueue(
|
||||
asynq.NewTask(task.AutoRefundSingleDisputeTask, payload),
|
||||
asynq.ProcessIn(currentDelay),
|
||||
asynq.MaxRetry(5),
|
||||
); errTask != nil {
|
||||
logger.ErrorF(ctx, "下发争议[ID:%d]自动退款任务失败: %v", dispute.ID, errTask)
|
||||
return errTask
|
||||
} else {
|
||||
logger.InfoF(ctx, "下发争议[ID:%d]自动退款任务成功", dispute.ID)
|
||||
}
|
||||
}
|
||||
|
||||
lastID = disputes[len(disputes)-1].ID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleAutoRefundSingleDispute 处理单个争议的自动退款任务
|
||||
func HandleAutoRefundSingleDispute(ctx context.Context, t *asynq.Task) error {
|
||||
// 解析任务参数
|
||||
var payload struct {
|
||||
DisputeID uint64 `json:"dispute_id"`
|
||||
}
|
||||
if err := json.Unmarshal(t.Payload(), &payload); err != nil {
|
||||
return fmt.Errorf("解析任务参数失败: %w", err)
|
||||
}
|
||||
|
||||
if err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var dispute model.Dispute
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).
|
||||
Where("id = ? AND status = ?", payload.DisputeID, model.DisputeStatusDisputing).
|
||||
First(&dispute).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
logger.InfoF(ctx, "争议[ID:%d]已被处理或不存在,跳过", payload.DisputeID)
|
||||
return nil // 已处理,不算错误
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var order model.Order
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).
|
||||
Where(
|
||||
"id = ? AND status = ? AND type IN ?",
|
||||
dispute.OrderID,
|
||||
model.OrderStatusDisputing,
|
||||
[]model.OrderType{model.OrderTypePayment, model.OrderTypeOnline},
|
||||
).
|
||||
First(&order).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
logger.ErrorF(ctx, "争议[ID:%d]关联订单[ID:%d]不存在或状态异常", payload.DisputeID, dispute.OrderID)
|
||||
return nil // 订单状态异常,跳过
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 获取付款方和收款方用户
|
||||
var payerUser, payeeUser model.User
|
||||
if err := payerUser.GetByID(tx, order.PayerUserID); err != nil {
|
||||
return fmt.Errorf("查询付款方用户失败: %w", err)
|
||||
}
|
||||
if err := payeeUser.GetByID(tx, order.PayeeUserID); err != nil {
|
||||
return fmt.Errorf("查询收款方用户失败: %w", err)
|
||||
}
|
||||
|
||||
// 获取商家的支付配置
|
||||
var merchantPayConfig model.UserPayConfig
|
||||
if err := merchantPayConfig.GetByPayScore(tx, payeeUser.PayScore); err != nil {
|
||||
return fmt.Errorf("查询商家支付配置失败: %w", err)
|
||||
}
|
||||
|
||||
// 计算商家积分减少:订单金额 × 商家的 score_rate
|
||||
merchantScoreDecrease := order.Amount.Mul(merchantPayConfig.ScoreRate).Round(0).IntPart()
|
||||
|
||||
// 商家(收款方)退款:扣除可用余额、总收款和积分
|
||||
if err := tx.Model(&model.User{}).
|
||||
Where("id = ?", payeeUser.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 fmt.Errorf("商家退款失败: %w", 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 fmt.Errorf("付款方退款失败: %w", err)
|
||||
}
|
||||
|
||||
// 更新争议状态为已退款,handler_user_id 设为 0(系统自动处理)
|
||||
if err := tx.Model(&model.Dispute{}).
|
||||
Where("id = ?", dispute.ID).
|
||||
Updates(map[string]interface{}{
|
||||
"status": model.DisputeStatusRefund,
|
||||
"handler_user_id": 0,
|
||||
}).Error; err != nil {
|
||||
return fmt.Errorf("更新争议状态失败: %w", err)
|
||||
}
|
||||
|
||||
// 更新订单状态为已退款
|
||||
if err := tx.Model(&model.Order{}).
|
||||
Where("id = ?", order.ID).
|
||||
Update("status", model.OrderStatusRefund).Error; err != nil {
|
||||
return fmt.Errorf("更新订单状态失败: %w", err)
|
||||
}
|
||||
|
||||
logger.InfoF(ctx, "自动退款成功: 争议[ID:%d] 订单[ID:%d] 金额[%s] 付款方[%s] 商家[%s]",
|
||||
dispute.ID, order.ID, order.Amount.String(), payerUser.Username, payeeUser.Username)
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
logger.ErrorF(ctx, "处理争议[ID:%d]自动退款失败: %v", payload.DisputeID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user