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) { total := roundMoney(order.RentAmount + order.DepositAmount) ownerRentAmount := roundMoney(order.OwnerRentAmount) if ownerRentAmount <= 0 || ownerRentAmount > order.RentAmount { ownerRentAmount = roundMoney(order.RentAmount) } 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", Amount: releaseFrozenAmount, 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", Amount: amount, 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+order.DepositAmount), "仲裁剩余金额结算给号主") case "release_deposit": addOwnerIncome(ownerRentAmount, "仲裁确认订单金额结算给号主") addRenterRefund(order.DepositAmount, "仲裁释放押金给租客") case "deduct_deposit", "compensate_owner": deductAmount := roundMoney(req.Amount) if deductAmount <= 0 { deductAmount = order.DepositAmount } if deductAmount > order.DepositAmount { return settlement, ErrInvalidDispute } settlement.DepositDeductAmount = deductAmount addOwnerIncome(ownerRentAmount+deductAmount, "仲裁订单金额及押金赔付结算给号主") addRenterRefund(order.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) }