package dispute import ( "context" "errors" "fmt" "log" "time" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/notification" "hfb_sys/backend/internal/modules/wallet" "hfb_sys/backend/pkg/money" "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 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 := 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 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, "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(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 } 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(money.MinCent(totalCent-req.AmountCent, ownerRentAmountCent+depositAmountCent), "仲裁剩余金额结算给号主") 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 } 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 { log.Printf("[dispute] start refund failed order_id=%d biz_type=%s amount_cent=%d err=%v", action.OrderID, action.BizType, action.RefundAmountCent, 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 } }