package dispute import ( "context" "errors" "fmt" "math" "time" "hfb_sys/backend/internal/listingstatus" "hfb_sys/backend/internal/logging" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/adminnotification" "hfb_sys/backend/internal/modules/notification" ordermodule "hfb_sys/backend/internal/modules/order" "hfb_sys/backend/internal/modules/rentergrowth" "hfb_sys/backend/internal/modules/wallet" "hfb_sys/backend/pkg/money" "go.uber.org/zap" "gorm.io/gorm" "gorm.io/gorm/clause" ) func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, req ArbitrateRequest, meta AuditMeta) (*DisputeDTO, error) { var refund *refundAction err := r.db.WithContext(ctx).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, actualSettlement, linkedCheckout, depositDeductAmountCent, err := buildArbitrationDecision(tx, row, 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 if isPlatformManagedOrder(order) { order.OwnerSettledAt = nil applyPlatformManagedSettlement(&order, &settlement) } else { order.OwnerSettledAt = &now } if order.HandoffStatus != "cancelled" && order.HandoffStatus != "returned" { order.HandoffStatus = "arbitrated" } if req.Result == "mark_abnormal" { listing.Status = "abnormal" listing.InTransaction = false listing.PublishedAt = nil account.Status = "abnormal" } else { // 仲裁会终结当前订单,账号统一归档下架,避免已结案账号重新出现在公开列表。 listing.Status = "offline" listing.InTransaction = false listing.PublishedAt = nil account.Status = "offline" } if err := listingstatus.AppendTransition( tx, &listing, beforeListingStatus, listingstatus.SourceDispute, listingstatus.ActorAdmin, adminID, "仲裁结案", ); err != nil { return err } if err := wallet.AppendEntries(tx, settlement.Entries...); err != nil { return err } // 押金暂扣:若订单被暂扣,则把本次退款中的押金部分挂起不退,仅退还剩余部分。 refundAmountCent := order.ApplyDepositHold(settlement.RenterRefundAmountCent, settlement.RenterDepositRefundCent) if refundAmountCent > 0 { action, err := r.prepareRefund(&order, refundAmountCent, "arbitration_refund", "仲裁退款原路退还") if err != nil { return err } refund = action } if err := tx.Save(&row).Error; err != nil { return err } if order.Status == "completed" { if actualSettlement == nil { return ErrInvalidDispute } applyArbitrationActualSettlement(&order, linkedCheckout, *actualSettlement, depositDeductAmountCent) if linkedCheckout != nil { if err := tx.Save(linkedCheckout).Error; err != nil { return err } } if err := rentergrowth.AwardOrderCompleted(tx, &order, actualSettlement.PureCoinAmountCent, rentergrowth.SourceOrderCompleted); 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_cent": req.AmountCent, "renter_refund_amount_cent": settlement.RenterRefundAmountCent, "owner_income_amount_cent": settlement.OwnerIncomeAmountCent, "deposit_deduct_amount_cent": settlement.DepositDeductAmountCent, "actual_coin_consumed_m": order.ActualCoinConsumedM, "actual_pure_coin_cent": order.ActualPureCoinAmountCent, "actual_extra_item_cent": order.ActualExtraItemAmountCent, "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 } renterNotification := notification.Entry{ UserID: order.RenterID, Type: "arbitration", Title: "申诉仲裁已完成", Content: "客服已给出仲裁结果,请在订单和申诉记录中查看处理说明。", BizType: "dispute", BizID: &disputeID, } if isPlatformManagedOrder(order) { if err := notification.Append(tx, renterNotification); err != nil { return err } } else { if err := notification.Append(tx, renterNotification, 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(ctx, refund) var row disputeRow if err := r.baseQuery(ctx).Where("d.id = ?", id).First(&row).Error; err != nil { return nil, err } dto := row.toDTO() return &dto, nil } func buildArbitrationDecision(tx *gorm.DB, row model.Dispute, order model.RentalOrder, req ArbitrateRequest, renterFrozenBalanceCent int64) (arbitrationSettlement, *ordermodule.ActualCheckoutSettlement, *model.OrderCheckout, int64, error) { if arbitrationUsesActualCheckout(req.Result) { return buildActualCheckoutArbitrationSettlement(tx, row, order, req, renterFrozenBalanceCent) } settlement, err := buildArbitrationSettlement(order, req, renterFrozenBalanceCent) return settlement, nil, nil, 0, err } func arbitrationUsesActualCheckout(result string) bool { switch result { case "actual_settlement", "release_deposit", "deduct_deposit", "compensate_owner": return true default: return false } } func buildActualCheckoutArbitrationSettlement(tx *gorm.DB, row model.Dispute, order model.RentalOrder, req ArbitrateRequest, renterFrozenBalanceCent int64) (arbitrationSettlement, *ordermodule.ActualCheckoutSettlement, *model.OrderCheckout, int64, error) { settlement := newArbitrationSettlement(order, renterFrozenBalanceCent) linkedCheckout, err := lockArbitrationCheckout(tx, row) if err != nil { return settlement, nil, nil, 0, err } coinConsumedM, err := arbitrationCoinConsumedM(linkedCheckout, req) if err != nil { return settlement, nil, linkedCheckout, 0, err } consumableAmountCent, err := arbitrationConsumableAmountCent(order, linkedCheckout, req) if err != nil { return settlement, nil, linkedCheckout, 0, err } depositDeductAmountCent, err := arbitrationDepositDeductAmountCent(order, req) if err != nil { return settlement, nil, linkedCheckout, 0, err } actualSettlement := ordermodule.CalculateActualCheckoutSettlement(order, consumableAmountCent, coinConsumedM, depositDeductAmountCent) if actualSettlement.ShortfallCent > 0 { return settlement, nil, linkedCheckout, 0, ErrInvalidDispute } if actualSettlement.OwnerIncomeCent > 0 { settlement.addOwnerIncome(order, actualSettlement.OwnerIncomeCent, "仲裁按实际消耗结算给号主") } if actualSettlement.RenterRefundCent > 0 { settlement.RenterRefundAmountCent = actualSettlement.RenterRefundCent settlement.RenterDepositRefundCent = actualSettlement.DepositRefundCent } settlement.DepositDeductAmountCent = depositDeductAmountCent + actualSettlement.OvershootAmountCent settlement.ActualExtraItemAmountCent = actualSettlement.ExtraItemAmountCent return settlement, &actualSettlement, linkedCheckout, depositDeductAmountCent, nil } func lockArbitrationCheckout(tx *gorm.DB, row model.Dispute) (*model.OrderCheckout, error) { if row.CheckoutID == nil || *row.CheckoutID == 0 { return nil, nil } var checkout model.OrderCheckout if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&checkout, *row.CheckoutID).Error; err != nil { return nil, err } return &checkout, nil } func arbitrationCoinConsumedM(checkout *model.OrderCheckout, req ArbitrateRequest) (float64, error) { if req.ActualCoinConsumedM != nil { return *req.ActualCoinConsumedM, nil } if checkout != nil { return checkout.CoinConsumedM, nil } return 0, ErrInvalidDispute } func arbitrationConsumableAmountCent(order model.RentalOrder, checkout *model.OrderCheckout, req ArbitrateRequest) (int64, error) { if req.ActualConsumableAmountCent != nil { return *req.ActualConsumableAmountCent, nil } if checkout != nil { return checkout.ConsumableAmountCent, nil } if order.ExtraItemOriginalAmountCent <= 0 { return 0, nil } return 0, ErrInvalidDispute } func arbitrationDepositDeductAmountCent(order model.RentalOrder, req ArbitrateRequest) (int64, error) { switch req.Result { case "actual_settlement": if req.AmountCent <= 0 { return 0, nil } if req.AmountCent > order.DepositAmountCent { return 0, ErrInvalidDispute } return req.AmountCent, nil case "release_deposit": return 0, nil case "deduct_deposit", "compensate_owner": deductAmountCent := req.AmountCent if deductAmountCent <= 0 { deductAmountCent = order.DepositAmountCent } if deductAmountCent > order.DepositAmountCent { return 0, ErrInvalidDispute } return deductAmountCent, nil default: return 0, ErrInvalidDispute } } func applyArbitrationActualSettlement(order *model.RentalOrder, checkout *model.OrderCheckout, settlement ordermodule.ActualCheckoutSettlement, depositDeductAmountCent int64) { order.ActualCoinConsumedM = settlement.CoinConsumedM order.ActualPureCoinAmountCent = settlement.PureCoinAmountCent order.ActualPureCoinDiscountCent = settlement.PureCoinDiscountCent order.ActualExtraItemAmountCent = settlement.ExtraItemAmountCent if checkout == nil { return } checkout.RentAmountCent = settlement.ActualRentAmountCent checkout.OwnerRentAmountCent = settlement.OwnerRentIncomeCent checkout.PlatformFeeCent = settlement.PlatformFeeCent checkout.PureCoinAmountCent = settlement.PureCoinAmountCent checkout.PureCoinDiscountCent = settlement.PureCoinDiscountCent checkout.PureCoinPayableCent = settlement.PureCoinPayableCent checkout.ConsumableAmountCent = settlement.ExtraItemAmountCent checkout.CoinConsumedM = settlement.CoinConsumedM checkout.DepositDeductAmountCent = depositDeductAmountCent checkout.OtherAmountCent = depositDeductAmountCent checkout.RenterRefundAmountCent = settlement.RenterRefundCent checkout.OwnerIncomeAmountCent = settlement.OwnerIncomeCent checkout.ShortfallCent = settlement.ShortfallCent checkout.OvershootAmountCent = settlement.OvershootAmountCent } func isPlatformManagedOrder(order model.RentalOrder) bool { return order.SettlementMode == "platform_managed" || order.HandoffMode == "platform" } func appendPlatformManagedAdminNotification(tx *gorm.DB, order model.RentalOrder, typ string, title string, content string) error { if order.ManagedAdminID == nil || *order.ManagedAdminID == 0 || title == "" { return nil } return adminnotification.Append(tx, adminnotification.Entry{ AdminUserID: *order.ManagedAdminID, Type: typ, Title: title, Content: content, }) } func applyPlatformManagedSettlement(order *model.RentalOrder, settlement *arbitrationSettlement) { if order == nil || settlement == nil { return } filtered := settlement.Entries[:0] for _, entry := range settlement.Entries { if entry.UserID == order.OwnerID && entry.Direction == "in" && entry.BizType == "arbitration_owner_income" { continue } filtered = append(filtered, entry) } settlement.Entries = filtered order.OfflineSettlementAmountCent = settlement.OwnerIncomeAmountCent if settlement.OwnerIncomeAmountCent > 0 { order.OfflineSettlementStatus = "pending" } else { order.OfflineSettlementStatus = "none" } } type arbitrationSettlement struct { Entries []wallet.Entry RenterRefundAmountCent int64 RenterDepositRefundCent int64 OwnerIncomeAmountCent int64 DepositDeductAmountCent int64 ActualExtraItemAmountCent int64 } func newArbitrationSettlement(order model.RentalOrder, renterFrozenBalanceCent int64) arbitrationSettlement { settlement := arbitrationSettlement{} orderID := order.ID releaseFrozenAmountCent := money.MinCent(order.RentAmountCent+order.DepositAmountCent, renterFrozenBalanceCent) if releaseFrozenAmountCent > 0 { settlement.Entries = append(settlement.Entries, wallet.Entry{ UserID: order.RenterID, OrderID: &orderID, Direction: "out", AmountCent: releaseFrozenAmountCent, BalanceType: "frozen", BizType: "arbitration_release_frozen", BizNo: order.OrderNo, Remark: "仲裁释放冻结金额", }) } return settlement } func (settlement *arbitrationSettlement) addOwnerIncome(order model.RentalOrder, amountCent int64, remark string) { if settlement == nil || amountCent <= 0 { return } orderID := order.ID settlement.OwnerIncomeAmountCent += amountCent settlement.Entries = append(settlement.Entries, wallet.Entry{ UserID: order.OwnerID, OrderID: &orderID, Direction: "in", AmountCent: amountCent, BalanceType: "available", BizType: "arbitration_owner_income", BizNo: order.OrderNo, Remark: remark, }) } func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest, renterFrozenBalanceCent int64) (arbitrationSettlement, error) { rentAmountCent := order.RentAmountCent depositAmountCent := order.DepositAmountCent ownerRentAmountCent := order.OwnerRentAmountCent if ownerRentAmountCent <= 0 || ownerRentAmountCent > rentAmountCent { ownerRentAmountCent = rentAmountCent } totalCent := rentAmountCent + depositAmountCent settlement := newArbitrationSettlement(order, renterFrozenBalanceCent) addRenterRefund := func(amountCent int64, remark string) { if amountCent <= 0 { return } settlement.RenterRefundAmountCent += amountCent } addOwnerIncome := func(amountCent int64, remark string) { if amountCent <= 0 { return } settlement.addOwnerIncome(order, amountCent, remark) } switch req.Result { case "full_refund": addRenterRefund(totalCent, "仲裁全额退款") settlement.RenterDepositRefundCent = depositAmountCent case "partial_refund": if req.AmountCent <= 0 || req.AmountCent > totalCent { return settlement, ErrInvalidDispute } addRenterRefund(req.AmountCent, "仲裁部分退款") // 部分退款为合并金额,无法精确拆分租金/押金,按押金优先归类以便暂扣。 settlement.RenterDepositRefundCent = money.MinCent(req.AmountCent, depositAmountCent) // 号主仅拿「未退租金中的号主份额 + 未退押金」,平台加价按未退租金比例预留,避免整笔剩余进号主。 addOwnerIncome( partialRefundOwnerIncomeCent(rentAmountCent, ownerRentAmountCent, depositAmountCent, req.AmountCent), "仲裁剩余金额结算给号主", ) case "release_deposit": addOwnerIncome(ownerRentAmountCent, "仲裁确认订单金额结算给号主") addRenterRefund(depositAmountCent, "仲裁释放押金给租客") settlement.RenterDepositRefundCent = depositAmountCent case "deduct_deposit", "compensate_owner": deductAmountCent := req.AmountCent if deductAmountCent <= 0 { deductAmountCent = depositAmountCent } if deductAmountCent > depositAmountCent { return settlement, ErrInvalidDispute } settlement.DepositDeductAmountCent = deductAmountCent addOwnerIncome(ownerRentAmountCent+deductAmountCent, "仲裁订单金额及押金赔付结算给号主") addRenterRefund(depositAmountCent-deductAmountCent, "仲裁退回剩余押金给租客") settlement.RenterDepositRefundCent = depositAmountCent - deductAmountCent 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 } // partialRefundOwnerIncomeCent 计算部分退款后号主应得。 // 退款优先冲押金,其余冲租金;未退租金按预收结构拆分: // 平台费 = 租客租金 − 号主租金,并按 未退租金/预收租金 比例预留,剩余才归号主。 // 这样可避免「剩余金额整笔给号主」把平台加价分给用户。 func partialRefundOwnerIncomeCent(rentAmountCent, ownerRentAmountCent, depositAmountCent, renterRefundCent int64) int64 { if renterRefundCent < 0 { renterRefundCent = 0 } totalCent := rentAmountCent + depositAmountCent if renterRefundCent > totalCent { renterRefundCent = totalCent } if ownerRentAmountCent < 0 { ownerRentAmountCent = 0 } if ownerRentAmountCent > rentAmountCent { ownerRentAmountCent = rentAmountCent } // 退款优先视为押金退还,其余视为租金退还(与 RenterDepositRefundCent 归类一致) depositRefunded := money.MinCent(renterRefundCent, depositAmountCent) rentRefunded := renterRefundCent - depositRefunded rentLeft := rentAmountCent - rentRefunded if rentLeft < 0 { rentLeft = 0 } depositLeft := depositAmountCent - depositRefunded platformFeeCent := rentAmountCent - ownerRentAmountCent if platformFeeCent < 0 { platformFeeCent = 0 } // 按未退租金占比预留平台费 platformKeepCent := int64(0) if rentAmountCent > 0 && rentLeft > 0 && platformFeeCent > 0 { platformKeepCent = int64(math.Round(float64(platformFeeCent) * float64(rentLeft) / float64(rentAmountCent))) platformKeepCent = money.MinCent(platformKeepCent, rentLeft) platformKeepCent = money.MinCent(platformKeepCent, platformFeeCent) } ownerFromRent := rentLeft - platformKeepCent if ownerFromRent > ownerRentAmountCent { ownerFromRent = ownerRentAmountCent } if ownerFromRent < 0 { ownerFromRent = 0 } ownerIncome := ownerFromRent + depositLeft remaining := totalCent - renterRefundCent if ownerIncome > remaining { ownerIncome = remaining } if ownerIncome < 0 { return 0 } return ownerIncome } func (r *Repository) prepareRefund(order *model.RentalOrder, amountCent int64, bizType string, remark string) (*refundAction, error) { if amountCent <= 0 { return nil, nil } if r.refundStarter == 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(ctx context.Context, action *refundAction) { if action == nil || r.refundStarter == nil { return } if _, err := r.refundStarter.StartRefund(ctx, action.OrderID, action.RefundAmountCent, action.BizType, action.Remark); err != nil { logging.FromContext(ctx).Error("争议退款提交失败", zap.String("module", "dispute"), zap.Uint64("order_id", action.OrderID), zap.String("biz_type", action.BizType), zap.Int64("amount_cent", action.RefundAmountCent), zap.Error(err), ) } } func renterFrozenBalance(tx *gorm.DB, renterID uint64) (int64, 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.FrozenBalanceCent, nil } 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.RenterRefundAmountCent > 0 { content += "\n退款给租客:" + money.FormatWithSymbol(settlement.RenterRefundAmountCent) } if settlement.OwnerIncomeAmountCent > 0 { content += "\n结算给号主:" + money.FormatWithSymbol(settlement.OwnerIncomeAmountCent) } if settlement.DepositDeductAmountCent > 0 { content += "\n押金赔付扣除:" + money.FormatWithSymbol(settlement.DepositDeductAmountCent) } if settlement.ActualExtraItemAmountCent > 0 { content += "\n额外物品消耗:" + money.FormatWithSymbol(settlement.ActualExtraItemAmountCent) } return content } func arbitrationResultLabel(result string) string { switch result { case "actual_settlement": return "按实际消耗结算" 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 } }