拆分申诉 Repository 文件职责

This commit is contained in:
yml2213
2026-06-10 14:53:02 +08:00
parent 14d9601ef2
commit f75174b1cf
6 changed files with 600 additions and 556 deletions
@@ -0,0 +1,341 @@
package dispute
import (
"context"
"errors"
"fmt"
"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
}
if settlement.RenterRefundAmountCent > 0 {
action, err := r.prepareRefund(&order, settlement.RenterRefundAmountCent, "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
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, "仲裁全额退款")
case "partial_refund":
if req.AmountCent <= 0 || req.AmountCent > totalCent {
return settlement, ErrInvalidDispute
}
addRenterRefund(req.AmountCent, "仲裁部分退款")
addOwnerIncome(money.MinCent(totalCent-req.AmountCent, ownerRentAmountCent+depositAmountCent), "仲裁剩余金额结算给号主")
case "release_deposit":
addOwnerIncome(ownerRentAmountCent, "仲裁确认订单金额结算给号主")
addRenterRefund(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, "仲裁退回剩余押金给租客")
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(ctx context.Context, action *refundAction) {
if action == nil || r.refundFunc == nil {
return
}
_, _ = r.refundFunc(ctx, action.OrderID, action.RefundAmountCent, action.BizType, action.Remark)
}
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
}
}
+19
View File
@@ -0,0 +1,19 @@
package dispute
import (
"hfb_sys/backend/internal/auditlog"
"gorm.io/gorm"
)
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,
})
}
@@ -0,0 +1,122 @@
package dispute
import (
"context"
"time"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/notification"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func (r *Repository) Create(ctx context.Context, userID uint64, orderID uint64, req CreateRequest) (*DisputeDTO, error) {
var createdID uint64
err := r.db.WithContext(ctx).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(ctx, userID, createdID)
}
func disputeType(input string, isCheckoutDispute bool) string {
if isCheckoutDispute {
return "checkout_dispute"
}
return input
}
@@ -0,0 +1,54 @@
package dispute
import (
"encoding/json"
"hfb_sys/backend/internal/model"
"gorm.io/datatypes"
)
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
}
+62
View File
@@ -0,0 +1,62 @@
package dispute
import (
"context"
"hfb_sys/backend/internal/model"
"gorm.io/gorm"
)
func (r *Repository) ListForUser(ctx context.Context, userID uint64, page, pageSize int) (*PaginatedResult, error) {
db := r.db.WithContext(ctx)
conditions := 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(ctx).
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(ctx context.Context, userID uint64, id uint64) (*DisputeDTO, error) {
var row disputeRow
if err := r.baseQuery(ctx).
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(ctx context.Context, page, pageSize int) (*PaginatedResult, error) {
var total int64
if err := r.db.WithContext(ctx).Model(&model.Dispute{}).Count(&total).Error; err != nil {
return nil, err
}
offset := (page - 1) * pageSize
var rows []disputeRow
err := r.baseQuery(ctx).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) baseQuery(ctx context.Context) *gorm.DB {
return r.db.WithContext(ctx).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")
}
+2 -556
View File
@@ -2,20 +2,9 @@ package dispute
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt"
"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"
"gorm.io/gorm/clause"
) )
type Repository struct { type Repository struct {
@@ -23,6 +12,8 @@ type Repository struct {
refundFunc RefundFunc refundFunc RefundFunc
} }
// RefundFunc 由 payment 模块注入,避免 dispute 与 payment 形成循环依赖。
// RefundFunc 由 payment 模块注入,避免 dispute 与 payment 形成循环依赖。 // RefundFunc 由 payment 模块注入,避免 dispute 与 payment 形成循环依赖。
type RefundFunc func(ctx context.Context, orderID uint64, refundAmountCent int64, bizType string, remark string) (status string, err error) type RefundFunc func(ctx context.Context, orderID uint64, refundAmountCent int64, bizType string, remark string) (status string, err error)
@@ -41,551 +32,6 @@ func (r *Repository) SetRefundFunc(fn RefundFunc) {
r.refundFunc = fn r.refundFunc = fn
} }
func (r *Repository) Create(ctx context.Context, userID uint64, orderID uint64, req CreateRequest) (*DisputeDTO, error) {
var createdID uint64
err := r.db.WithContext(ctx).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(ctx, userID, createdID)
}
func (r *Repository) ListForUser(ctx context.Context, userID uint64, page, pageSize int) (*PaginatedResult, error) {
db := r.db.WithContext(ctx)
conditions := 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(ctx).
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(ctx context.Context, userID uint64, id uint64) (*DisputeDTO, error) {
var row disputeRow
if err := r.baseQuery(ctx).
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(ctx context.Context, page, pageSize int) (*PaginatedResult, error) {
var total int64
if err := r.db.WithContext(ctx).Model(&model.Dispute{}).Count(&total).Error; err != nil {
return nil, err
}
offset := (page - 1) * pageSize
var rows []disputeRow
err := r.baseQuery(ctx).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(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
}
if settlement.RenterRefundAmountCent > 0 {
action, err := r.prepareRefund(&order, settlement.RenterRefundAmountCent, "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
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, "仲裁全额退款")
case "partial_refund":
if req.AmountCent <= 0 || req.AmountCent > totalCent {
return settlement, ErrInvalidDispute
}
addRenterRefund(req.AmountCent, "仲裁部分退款")
addOwnerIncome(money.MinCent(totalCent-req.AmountCent, ownerRentAmountCent+depositAmountCent), "仲裁剩余金额结算给号主")
case "release_deposit":
addOwnerIncome(ownerRentAmountCent, "仲裁确认订单金额结算给号主")
addRenterRefund(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, "仲裁退回剩余押金给租客")
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(ctx context.Context, action *refundAction) {
if action == nil || r.refundFunc == nil {
return
}
_, _ = r.refundFunc(ctx, action.OrderID, action.RefundAmountCent, action.BizType, action.Remark)
}
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 (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
return r.db.WithContext(ctx).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.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
}
}
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 { func IsNotFound(err error) bool {
return errors.Is(err, gorm.ErrRecordNotFound) return errors.Is(err, gorm.ErrRecordNotFound)
} }