Files
hfb_sys/backend/internal/modules/dispute/arbitration.go
T
yml2213 98772867a7 统一结账押金字段为押金赔付扣除
号主修正去掉无效的其他押金扣款,仅保留押金赔付扣除;
前后端与账单文案统一,修正时 other 与 deduct 对齐。
2026-07-17 17:17:29 +08:00

365 lines
12 KiB
Go

package dispute
import (
"context"
"errors"
"fmt"
"log"
"time"
"hfb_sys/backend/internal/listingstatus"
"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 := 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 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
}
}