压缩历史至 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
+26
View File
@@ -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 团队"
)
+449
View File
@@ -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())
}
+199
View File
@@ -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
}