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, 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 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" { coinConsumedM, linkedCheckout, err := arbitrationCoinConsumedM(tx, row, req) if err != nil { return err } pureCoin := ordermodule.CalculateActualPureCoinAmounts(order, coinConsumedM) order.ActualCoinConsumedM = pureCoin.ConsumedM order.ActualPureCoinAmountCent = pureCoin.AmountCent order.ActualPureCoinDiscountCent = pureCoin.DiscountCent if linkedCheckout != nil { linkedCheckout.PureCoinAmountCent = pureCoin.AmountCent linkedCheckout.PureCoinDiscountCent = pureCoin.DiscountCent linkedCheckout.PureCoinPayableCent = pureCoin.PayableCent if err := tx.Save(linkedCheckout).Error; err != nil { return err } } if err := rentergrowth.AwardOrderCompleted(tx, &order, pureCoin.AmountCent, 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, "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 arbitrationCoinConsumedM(tx *gorm.DB, row model.Dispute, req ArbitrateRequest) (float64, *model.OrderCheckout, error) { var checkout *model.OrderCheckout if row.CheckoutID != nil && *row.CheckoutID > 0 { var saved model.OrderCheckout if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&saved, *row.CheckoutID).Error; err != nil { return 0, nil, err } checkout = &saved } if req.ActualCoinConsumedM != nil { return *req.ActualCoinConsumedM, checkout, nil } if checkout != nil { return checkout.CoinConsumedM, checkout, nil } return 0, nil, ErrInvalidDispute } 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 } 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 := arbitrationSettlement{} orderID := order.ID releaseFrozenAmountCent := money.MinCent(totalCent, 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: "仲裁释放冻结金额", }) } addRenterRefund := func(amountCent int64, remark string) { if amountCent <= 0 { return } settlement.RenterRefundAmountCent += amountCent } addOwnerIncome := func(amountCent int64, remark string) { if amountCent <= 0 { return } 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, }) } 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) } 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 } }