Files
hfb_sys/backend/internal/modules/dispute/repository.go
T

608 lines
18 KiB
Go

package dispute
import (
"encoding/json"
"errors"
"fmt"
"math"
"time"
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/notification"
"hfb_sys/backend/internal/modules/wallet"
"hfb_sys/backend/pkg/money"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Repository struct {
db *gorm.DB
refundFunc RefundFunc
}
// RefundFunc 由 payment 模块注入,避免 dispute 与 payment 形成循环依赖。
type RefundFunc func(orderID uint64, refundAmountCent int64, bizType string, remark string) (status string, err error)
type refundAction struct {
OrderID uint64
RefundAmountCent int64
BizType string
Remark string
}
func NewRepository(db *gorm.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) SetRefundFunc(fn RefundFunc) {
r.refundFunc = fn
}
func (r *Repository) Create(userID uint64, orderID uint64, req CreateRequest) (*DisputeDTO, error) {
var createdID uint64
err := r.db.Transaction(func(tx *gorm.DB) error {
var order model.RentalOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
return err
}
if order.RenterID != userID && order.OwnerID != userID {
return ErrPermissionDenied
}
if order.Status == "completed" || order.Status == "cancelled" || order.Status == "closed" {
return ErrInvalidDispute
}
isCheckoutDispute := order.Status == "pending_checkout_confirm" || order.Status == "pending_checkout_accept"
var count int64
if err := tx.Model(&model.Dispute{}).
Where("order_id = ? AND status IN ?", order.ID, []string{"open", "processing"}).
Count(&count).Error; err != nil {
return err
}
if count > 0 {
return ErrDisputeExists
}
targetID := order.OwnerID
if userID == order.OwnerID {
targetID = order.RenterID
}
evidence, err := marshalEvidence(req.EvidenceURLS)
if err != nil {
return err
}
row := model.Dispute{
OrderID: order.ID,
InitiatorID: userID,
TargetUserID: targetID,
Type: disputeType(req.Type, isCheckoutDispute),
Status: "open",
Description: req.Description,
EvidenceURLS: evidence,
}
if err := tx.Create(&row).Error; err != nil {
return err
}
if isCheckoutDispute {
now := time.Now()
order.Status = "checkout_disputing"
order.HandoffStatus = "checkout_disputed"
order.SettlementStatus = "disputed"
updates := map[string]any{
"status": "disputed",
"updated_at": now,
}
if userID == order.RenterID {
updates["renter_rejected_at"] = now
}
if err := tx.Model(&model.OrderCheckout{}).
Where("order_id = ? AND status IN ?", order.ID, []string{"submitted", "countered"}).
Updates(updates).Error; err != nil {
return err
}
} else {
order.Status = "disputing"
}
if err := tx.Save(&order).Error; err != nil {
return err
}
disputeID := row.ID
title := "订单进入申诉"
content := "对方已发起申诉,请等待客服仲裁或补充沟通记录。"
if isCheckoutDispute {
title = "订单进入结账争议"
content = "对方已发起结账争议,请等待客服仲裁或补充结账证据。"
}
if err := notification.Append(tx,
notification.Entry{
UserID: targetID,
Type: "dispute",
Title: title,
Content: content,
BizType: "dispute",
BizID: &disputeID,
},
notification.Entry{
UserID: userID,
Type: "dispute",
Title: "申诉已提交",
Content: "申诉已进入待处理状态,客服仲裁后会通知双方。",
BizType: "dispute",
BizID: &disputeID,
},
); err != nil {
return err
}
createdID = row.ID
return nil
})
if err != nil {
return nil, err
}
return r.FindForUser(userID, createdID)
}
func (r *Repository) ListForUser(userID uint64, page, pageSize int) (*PaginatedResult, error) {
conditions := r.db.Model(&model.Dispute{}).Where("initiator_id = ? OR target_user_id = ?", userID, userID)
var total int64
if err := conditions.Count(&total).Error; err != nil {
return nil, err
}
offset := (page - 1) * pageSize
var rows []disputeRow
err := r.baseQuery().
Where("d.initiator_id = ? OR d.target_user_id = ?", userID, userID).
Order("d.id DESC").
Offset(offset).Limit(pageSize).
Scan(&rows).Error
if err != nil {
return nil, err
}
return &PaginatedResult{Items: toDTOs(rows), Total: total, Page: page, PageSize: pageSize}, nil
}
func (r *Repository) FindForUser(userID uint64, id uint64) (*DisputeDTO, error) {
var row disputeRow
if err := r.baseQuery().
Where("d.id = ? AND (d.initiator_id = ? OR d.target_user_id = ?)", id, userID, userID).
First(&row).Error; err != nil {
return nil, err
}
dto := row.toDTO()
return &dto, nil
}
func (r *Repository) ListAdmin(page, pageSize int) (*PaginatedResult, error) {
var total int64
if err := r.db.Model(&model.Dispute{}).Count(&total).Error; err != nil {
return nil, err
}
offset := (page - 1) * pageSize
var rows []disputeRow
err := r.baseQuery().Order("d.id DESC").Offset(offset).Limit(pageSize).Scan(&rows).Error
if err != nil {
return nil, err
}
return &PaginatedResult{Items: toDTOs(rows), Total: total, Page: page, PageSize: pageSize}, nil
}
func (r *Repository) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest, meta AuditMeta) (*DisputeDTO, error) {
var refund *refundAction
err := r.db.Transaction(func(tx *gorm.DB) error {
var row model.Dispute
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, id).Error; err != nil {
return err
}
if row.Status != "open" && row.Status != "processing" {
return ErrDisputeCannotHandle
}
var order model.RentalOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, row.OrderID).Error; err != nil {
return err
}
var listing model.RentalListing
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, order.ListingID).Error; err != nil {
return err
}
var account model.GameAccount
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, order.AccountID).Error; err != nil {
return err
}
beforeOrderStatus := order.Status
beforeHandoffStatus := order.HandoffStatus
beforeSettlementStatus := order.SettlementStatus
beforeListingStatus := listing.Status
beforeAccountStatus := account.Status
frozenBalance, err := renterFrozenBalance(tx, order.RenterID)
if err != nil {
return err
}
settlement, err := buildArbitrationSettlement(order, req, frozenBalance)
if err != nil {
return err
}
now := time.Now()
row.Status = "resolved"
row.ArbitrationResult = req.Result
row.ArbitrationRemark = req.Remark
row.HandledBy = &adminID
row.HandledAt = &now
order.Status = arbitrateOrderStatus(req.Result)
order.SettlementStatus = "arbitrated"
order.SettledAt = &now
order.OwnerSettledAt = &now
if order.HandoffStatus != "cancelled" && order.HandoffStatus != "returned" {
order.HandoffStatus = "arbitrated"
}
if req.Result == "order_close" {
listing.Status = "offline"
account.Status = "offline"
} else if req.Result == "mark_abnormal" {
listing.Status = "abnormal"
account.Status = "abnormal"
} else {
listing.Status = "published"
account.Status = "published"
}
if err := wallet.AppendEntries(tx, settlement.Entries...); err != nil {
return err
}
if settlement.RenterRefundAmount > 0 {
refundCent := int64(math.Round(settlement.RenterRefundAmount * 100))
action, err := r.prepareRefund(&order, refundCent, "arbitration_refund", "仲裁退款原路退还")
if err != nil {
return err
}
refund = action
}
if err := tx.Save(&row).Error; err != nil {
return err
}
if err := tx.Save(&order).Error; err != nil {
return err
}
if err := tx.Save(&listing).Error; err != nil {
return err
}
if err := tx.Save(&account).Error; err != nil {
return err
}
handoffRecord := model.HandoffRecord{
OrderID: order.ID,
FromUserID: adminID,
ToUserID: order.RenterID,
Type: "admin_arbitration",
Content: buildArbitrationHandoffContent(req, settlement),
}
if err := tx.Create(&handoffRecord).Error; err != nil {
return err
}
disputeID := row.ID
if err := appendAuditLog(tx, adminID, "dispute.arbitrate", "dispute", row.ID, meta, map[string]any{
"dispute_id": row.ID,
"order_id": order.ID,
"order_no": order.OrderNo,
"result": req.Result,
"remark": req.Remark,
"input_amount": req.Amount,
"renter_refund_amount": settlement.RenterRefundAmount,
"owner_income_amount": settlement.OwnerIncomeAmount,
"deposit_deduct_amount": settlement.DepositDeductAmount,
"before_order_status": beforeOrderStatus,
"after_order_status": order.Status,
"before_handoff_status": beforeHandoffStatus,
"after_handoff_status": order.HandoffStatus,
"before_settlement_status": beforeSettlementStatus,
"after_settlement_status": order.SettlementStatus,
"before_listing_status": beforeListingStatus,
"after_listing_status": listing.Status,
"before_account_status": beforeAccountStatus,
"after_account_status": account.Status,
}); err != nil {
return err
}
if err := notification.Append(tx,
notification.Entry{
UserID: order.RenterID,
Type: "arbitration",
Title: "申诉仲裁已完成",
Content: "客服已给出仲裁结果,请在订单和申诉记录中查看处理说明。",
BizType: "dispute",
BizID: &disputeID,
},
notification.Entry{
UserID: order.OwnerID,
Type: "arbitration",
Title: "申诉仲裁已完成",
Content: "客服已给出仲裁结果,请在订单和申诉记录中查看处理说明。",
BizType: "dispute",
BizID: &disputeID,
},
); err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
r.startRefundBestEffort(refund)
var row disputeRow
if err := r.baseQuery().Where("d.id = ?", id).First(&row).Error; err != nil {
return nil, err
}
dto := row.toDTO()
return &dto, nil
}
type arbitrationSettlement struct {
Entries []wallet.Entry
RenterRefundAmount float64
OwnerIncomeAmount float64
DepositDeductAmount float64
}
func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest, renterFrozenBalance float64) (arbitrationSettlement, error) {
rentAmount := float64(order.RentAmountCent) / 100
if rentAmount <= 0 {
rentAmount = order.RentAmount
}
depositAmount := float64(order.DepositAmountCent) / 100
if depositAmount <= 0 {
depositAmount = order.DepositAmount
}
ownerRentAmount := float64(order.OwnerRentAmountCent) / 100
if ownerRentAmount <= 0 || ownerRentAmount > rentAmount {
ownerRentAmount = rentAmount
}
total := roundMoney(rentAmount + depositAmount)
ownerRentAmount = roundMoney(ownerRentAmount)
settlement := arbitrationSettlement{}
orderID := order.ID
releaseFrozenAmount := minMoney(total, roundMoney(renterFrozenBalance))
if releaseFrozenAmount > 0 {
settlement.Entries = append(settlement.Entries, wallet.Entry{
UserID: order.RenterID,
OrderID: &orderID,
Direction: "out",
AmountCent: int64(math.Round(releaseFrozenAmount * 100)),
BalanceType: "frozen",
BizType: "arbitration_release_frozen",
BizNo: order.OrderNo,
Remark: "仲裁释放冻结金额",
})
}
addRenterRefund := func(amount float64, remark string) {
if amount <= 0 {
return
}
settlement.RenterRefundAmount += amount
}
addOwnerIncome := func(amount float64, remark string) {
if amount <= 0 {
return
}
settlement.OwnerIncomeAmount += amount
settlement.Entries = append(settlement.Entries, wallet.Entry{
UserID: order.OwnerID,
OrderID: &orderID,
Direction: "in",
AmountCent: int64(math.Round(amount * 100)),
BalanceType: "available",
BizType: "arbitration_owner_income",
BizNo: order.OrderNo,
Remark: remark,
})
}
switch req.Result {
case "full_refund":
addRenterRefund(total, "仲裁全额退款")
case "partial_refund":
req.Amount = roundMoney(req.Amount)
if req.Amount <= 0 || req.Amount > total {
return settlement, ErrInvalidDispute
}
addRenterRefund(req.Amount, "仲裁部分退款")
addOwnerIncome(minMoney(total-req.Amount, ownerRentAmount+depositAmount), "仲裁剩余金额结算给号主")
case "release_deposit":
addOwnerIncome(ownerRentAmount, "仲裁确认订单金额结算给号主")
addRenterRefund(depositAmount, "仲裁释放押金给租客")
case "deduct_deposit", "compensate_owner":
deductAmount := roundMoney(req.Amount)
if deductAmount <= 0 {
deductAmount = depositAmount
}
if deductAmount > depositAmount {
return settlement, ErrInvalidDispute
}
settlement.DepositDeductAmount = deductAmount
addOwnerIncome(ownerRentAmount+deductAmount, "仲裁订单金额及押金赔付结算给号主")
addRenterRefund(depositAmount-deductAmount, "仲裁退回剩余押金给租客")
case "order_close":
// Only release frozen funds. No available-balance settlement happens in development mode.
case "mark_abnormal":
// 标记异常只释放冻结账务,后续由客服继续线下复核。
default:
return settlement, ErrInvalidDispute
}
return settlement, nil
}
func (r *Repository) prepareRefund(order *model.RentalOrder, amountCent int64, bizType string, remark string) (*refundAction, error) {
if amountCent <= 0 {
return nil, nil
}
if r.refundFunc == nil {
return nil, ErrDependencyUnavailable
}
order.RefundStatus = "pending"
order.RefundAmountCent = amountCent
order.RefundedAt = nil
return &refundAction{
OrderID: order.ID,
RefundAmountCent: amountCent,
BizType: bizType,
Remark: remark,
}, nil
}
func (r *Repository) startRefundBestEffort(action *refundAction) {
if action == nil || r.refundFunc == nil {
return
}
_, _ = r.refundFunc(action.OrderID, action.RefundAmountCent, action.BizType, action.Remark)
}
func renterFrozenBalance(tx *gorm.DB, renterID uint64) (float64, error) {
var account model.WalletAccount
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("user_id = ?", renterID).
First(&account).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, nil
}
if err != nil {
return 0, err
}
return account.FrozenBalance, nil
}
// minMoney 返回较小金额(角精度)
func minMoney(a float64, b float64) float64 {
return money.Min(a, b)
}
// roundMoney 使用统一的角精度(0.1元)
func roundMoney(value float64) float64 {
return money.Round(value)
}
func (r *Repository) baseQuery() *gorm.DB {
return r.db.Table("disputes AS d").
Select("d.*, o.order_no, l.listing_no, a.title").
Joins("JOIN rental_orders AS o ON o.id = d.order_id").
Joins("JOIN rental_listings AS l ON l.id = o.listing_id").
Joins("JOIN game_accounts AS a ON a.id = o.account_id")
}
type disputeRow struct {
model.Dispute
OrderNo string
ListingNo string
Title string
}
func (row disputeRow) toDTO() DisputeDTO {
return DisputeDTO{
ID: row.ID,
OrderID: row.OrderID,
OrderNo: row.OrderNo,
ListingNo: row.ListingNo,
Title: row.Title,
InitiatorID: row.InitiatorID,
TargetUserID: row.TargetUserID,
Type: row.Type,
Status: row.Status,
Description: row.Description,
EvidenceURLS: row.EvidenceURLS,
ArbitrationResult: row.ArbitrationResult,
ArbitrationRemark: row.ArbitrationRemark,
HandledBy: row.HandledBy,
HandledAt: row.HandledAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
}
func toDTOs(rows []disputeRow) []DisputeDTO {
items := make([]DisputeDTO, 0, len(rows))
for _, row := range rows {
items = append(items, row.toDTO())
}
return items
}
func marshalEvidence(urls []string) (datatypes.JSON, error) {
if len(urls) == 0 {
return datatypes.JSON([]byte("[]")), nil
}
raw, err := json.Marshal(urls)
return datatypes.JSON(raw), err
}
func arbitrateOrderStatus(result string) string {
switch result {
case "full_refund", "partial_refund", "order_close":
return "closed"
case "mark_abnormal":
return "abnormal"
default:
return "completed"
}
}
func buildArbitrationHandoffContent(req ArbitrateRequest, settlement arbitrationSettlement) string {
content := fmt.Sprintf("客服仲裁结果:%s", arbitrationResultLabel(req.Result))
if req.Remark != "" {
content += "\n处理说明:" + req.Remark
}
if settlement.RenterRefundAmount > 0 {
content += fmt.Sprintf("\n退款给租客:¥%.2f", settlement.RenterRefundAmount)
}
if settlement.OwnerIncomeAmount > 0 {
content += fmt.Sprintf("\n结算给号主:¥%.2f", settlement.OwnerIncomeAmount)
}
if settlement.DepositDeductAmount > 0 {
content += fmt.Sprintf("\n押金扣除:¥%.2f", settlement.DepositDeductAmount)
}
return content
}
func arbitrationResultLabel(result string) string {
switch result {
case "full_refund":
return "全额退款"
case "partial_refund":
return "部分退款"
case "deduct_deposit":
return "扣押金"
case "release_deposit":
return "释放押金"
case "compensate_owner":
return "赔付号主"
case "order_close":
return "关闭订单"
case "mark_abnormal":
return "标记异常"
default:
return result
}
}
func disputeType(input string, isCheckoutDispute bool) string {
if isCheckoutDispute {
return "checkout_dispute"
}
return input
}
func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizType string, bizID uint64, meta AuditMeta, detail map[string]any) error {
return auditlog.Append(tx, auditlog.Entry{
ActorType: "admin",
ActorID: actorID,
Action: action,
BizType: bizType,
BizID: &bizID,
Meta: meta,
Detail: detail,
})
}
func IsNotFound(err error) bool {
return errors.Is(err, gorm.ErrRecordNotFound)
}