From f75174b1cf6e19bbdbbd18ceb13f1b04ba08406c Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 10 Jun 2026 14:53:02 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=86=E7=94=B3=E8=AF=89=20Reposito?= =?UTF-8?q?ry=20=E6=96=87=E4=BB=B6=E8=81=8C=E8=B4=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/modules/dispute/arbitration.go | 341 +++++++++++ backend/internal/modules/dispute/audit.go | 19 + backend/internal/modules/dispute/mutation.go | 122 ++++ backend/internal/modules/dispute/presenter.go | 54 ++ backend/internal/modules/dispute/query.go | 62 ++ .../internal/modules/dispute/repository.go | 558 +----------------- 6 files changed, 600 insertions(+), 556 deletions(-) create mode 100644 backend/internal/modules/dispute/arbitration.go create mode 100644 backend/internal/modules/dispute/audit.go create mode 100644 backend/internal/modules/dispute/mutation.go create mode 100644 backend/internal/modules/dispute/presenter.go create mode 100644 backend/internal/modules/dispute/query.go diff --git a/backend/internal/modules/dispute/arbitration.go b/backend/internal/modules/dispute/arbitration.go new file mode 100644 index 0000000..8047241 --- /dev/null +++ b/backend/internal/modules/dispute/arbitration.go @@ -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 + } +} diff --git a/backend/internal/modules/dispute/audit.go b/backend/internal/modules/dispute/audit.go new file mode 100644 index 0000000..ef862f5 --- /dev/null +++ b/backend/internal/modules/dispute/audit.go @@ -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, + }) +} diff --git a/backend/internal/modules/dispute/mutation.go b/backend/internal/modules/dispute/mutation.go new file mode 100644 index 0000000..ebe42cc --- /dev/null +++ b/backend/internal/modules/dispute/mutation.go @@ -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 +} diff --git a/backend/internal/modules/dispute/presenter.go b/backend/internal/modules/dispute/presenter.go new file mode 100644 index 0000000..d52a8f4 --- /dev/null +++ b/backend/internal/modules/dispute/presenter.go @@ -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 +} diff --git a/backend/internal/modules/dispute/query.go b/backend/internal/modules/dispute/query.go new file mode 100644 index 0000000..7b0fc6d --- /dev/null +++ b/backend/internal/modules/dispute/query.go @@ -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") +} diff --git a/backend/internal/modules/dispute/repository.go b/backend/internal/modules/dispute/repository.go index 7340ccc..b9319e3 100644 --- a/backend/internal/modules/dispute/repository.go +++ b/backend/internal/modules/dispute/repository.go @@ -2,20 +2,9 @@ package dispute import ( "context" - "encoding/json" "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/clause" ) type Repository struct { @@ -23,6 +12,8 @@ type Repository struct { refundFunc RefundFunc } +// 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) @@ -41,551 +32,6 @@ func (r *Repository) SetRefundFunc(fn RefundFunc) { 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 { return errors.Is(err, gorm.ErrRecordNotFound) }