修复仲裁退款并新增免押额度

This commit is contained in:
yml
2026-06-08 21:00:39 +08:00
parent 830cfcf33d
commit 0b419f5084
22 changed files with 559 additions and 72 deletions
+20 -13
View File
@@ -3,24 +3,31 @@ package adminuser
import "time"
type UserDTO struct {
ID uint64 `json:"id"`
Phone string `json:"phone"`
Nickname string `json:"nickname"`
RealnameStatus string `json:"realname_status"`
RiskStatus string `json:"risk_status"`
CreditScore int `json:"credit_score"`
Status string `json:"status"`
OrderCount int64 `json:"order_count"`
ListingCount int64 `json:"listing_count"`
DisputeCount int64 `json:"dispute_count"`
LastLoginAt *time.Time `json:"last_login_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint64 `json:"id"`
Phone string `json:"phone"`
Nickname string `json:"nickname"`
RealnameStatus string `json:"realname_status"`
RiskStatus string `json:"risk_status"`
CreditScore int `json:"credit_score"`
DepositFreeQuota float64 `json:"deposit_free_quota"`
DepositFreeUsed float64 `json:"deposit_free_used"`
DepositFreeRemaining float64 `json:"deposit_free_remaining"`
Status string `json:"status"`
OrderCount int64 `json:"order_count"`
ListingCount int64 `json:"listing_count"`
DisputeCount int64 `json:"dispute_count"`
LastLoginAt *time.Time `json:"last_login_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type FreezeRequest struct {
Reason string `json:"reason"`
}
type DepositFreeQuotaRequest struct {
Amount float64 `json:"amount"`
}
type PaginatedResult struct {
Items interface{} `json:"items"`
Total int64 `json:"total"`
@@ -82,6 +82,29 @@ func (h *Handler) Unfreeze(c *gin.Context) {
response.OK(c, item)
}
func (h *Handler) SetDepositFreeQuota(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
userID, ok := parseID(c)
if !ok {
return
}
var req DepositFreeQuotaRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "免押额度不正确")
return
}
item, err := h.service.SetDepositFreeQuota(adminID, userID, req, auditMeta(c))
if err != nil {
writeAdminUserError(c, err)
return
}
response.OK(c, item)
}
func currentAdminID(c *gin.Context) (uint64, bool) {
value, ok := c.Get(middleware.ContextAdminID)
if !ok {
@@ -5,6 +5,7 @@ import (
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/pkg/money"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -31,10 +32,12 @@ func (r *Repository) List(page, pageSize int) (*PaginatedResult, error) {
Select(`u.*,
COALESCE(o.order_count, 0) AS order_count,
COALESCE(l.listing_count, 0) AS listing_count,
COALESCE(d.dispute_count, 0) AS dispute_count`).
COALESCE(d.dispute_count, 0) AS dispute_count,
COALESCE(df.deposit_free_used, 0) AS deposit_free_used`).
Joins("LEFT JOIN (SELECT user_id, COUNT(*) AS order_count FROM (SELECT renter_id AS user_id FROM rental_orders UNION ALL SELECT owner_id AS user_id FROM rental_orders) AS order_users GROUP BY user_id) AS o ON o.user_id = u.id").
Joins("LEFT JOIN (SELECT owner_id AS user_id, COUNT(*) AS listing_count FROM rental_listings GROUP BY owner_id) AS l ON l.user_id = u.id").
Joins("LEFT JOIN (SELECT user_id, COUNT(*) AS dispute_count FROM (SELECT initiator_id AS user_id FROM disputes UNION ALL SELECT target_user_id AS user_id FROM disputes) AS dispute_users GROUP BY user_id) AS d ON d.user_id = u.id").
Joins("LEFT JOIN (SELECT renter_id AS user_id, SUM(deposit_waived_amount) AS deposit_free_used FROM rental_orders WHERE status NOT IN ('completed', 'cancelled', 'closed') GROUP BY renter_id) AS df ON df.user_id = u.id").
Order("u.id DESC").
Offset(offset).Limit(pageSize).
Scan(&rows).Error
@@ -56,6 +59,33 @@ func (r *Repository) Unfreeze(adminID uint64, userID uint64, meta AuditMeta) (*U
return r.updateStatus(adminID, userID, "active", "normal", "admin_user.unfreeze", "", meta)
}
func (r *Repository) SetDepositFreeQuota(adminID uint64, userID uint64, req DepositFreeQuotaRequest, meta AuditMeta) (*UserDTO, error) {
amount := roundMoney(req.Amount)
if amount < 0 {
return nil, ErrInvalidUser
}
err := r.db.Transaction(func(tx *gorm.DB) error {
var user model.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil {
return err
}
beforeAmount := user.DepositFreeQuota
user.DepositFreeQuota = amount
if err := tx.Save(&user).Error; err != nil {
return err
}
return appendAuditLog(tx, adminID, "admin_user.set_deposit_free_quota", user.ID, meta, map[string]any{
"user_id": user.ID,
"before_amount": beforeAmount,
"after_amount": amount,
})
})
if err != nil {
return nil, err
}
return r.Find(userID)
}
func (r *Repository) updateStatus(adminID uint64, userID uint64, status string, riskStatus string, action string, reason string, meta AuditMeta) (*UserDTO, error) {
err := r.db.Transaction(func(tx *gorm.DB) error {
var user model.User
@@ -90,10 +120,12 @@ func (r *Repository) Find(userID uint64) (*UserDTO, error) {
Select(`u.*,
COALESCE(o.order_count, 0) AS order_count,
COALESCE(l.listing_count, 0) AS listing_count,
COALESCE(d.dispute_count, 0) AS dispute_count`).
COALESCE(d.dispute_count, 0) AS dispute_count,
COALESCE(df.deposit_free_used, 0) AS deposit_free_used`).
Joins("LEFT JOIN (SELECT user_id, COUNT(*) AS order_count FROM (SELECT renter_id AS user_id FROM rental_orders UNION ALL SELECT owner_id AS user_id FROM rental_orders) AS order_users GROUP BY user_id) AS o ON o.user_id = u.id").
Joins("LEFT JOIN (SELECT owner_id AS user_id, COUNT(*) AS listing_count FROM rental_listings GROUP BY owner_id) AS l ON l.user_id = u.id").
Joins("LEFT JOIN (SELECT user_id, COUNT(*) AS dispute_count FROM (SELECT initiator_id AS user_id FROM disputes UNION ALL SELECT target_user_id AS user_id FROM disputes) AS dispute_users GROUP BY user_id) AS d ON d.user_id = u.id").
Joins("LEFT JOIN (SELECT renter_id AS user_id, SUM(deposit_waived_amount) AS deposit_free_used FROM rental_orders WHERE status NOT IN ('completed', 'cancelled', 'closed') GROUP BY renter_id) AS df ON df.user_id = u.id").
Where("u.id = ?", userID).
First(&row).Error
if err != nil {
@@ -105,27 +137,39 @@ func (r *Repository) Find(userID uint64) (*UserDTO, error) {
type userRow struct {
model.User
OrderCount int64
ListingCount int64
DisputeCount int64
OrderCount int64
ListingCount int64
DisputeCount int64
DepositFreeUsed float64
}
func (row userRow) toDTO() UserDTO {
return UserDTO{
ID: row.ID,
Phone: row.Phone,
Nickname: row.Nickname,
RealnameStatus: row.RealnameStatus,
RiskStatus: row.RiskStatus,
CreditScore: row.CreditScore,
Status: row.Status,
OrderCount: row.OrderCount,
ListingCount: row.ListingCount,
DisputeCount: row.DisputeCount,
LastLoginAt: row.LastLoginAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
remaining := roundMoney(row.DepositFreeQuota - row.DepositFreeUsed)
if remaining < 0 {
remaining = 0
}
return UserDTO{
ID: row.ID,
Phone: row.Phone,
Nickname: row.Nickname,
RealnameStatus: row.RealnameStatus,
RiskStatus: row.RiskStatus,
CreditScore: row.CreditScore,
DepositFreeQuota: row.DepositFreeQuota,
DepositFreeUsed: roundMoney(row.DepositFreeUsed),
DepositFreeRemaining: remaining,
Status: row.Status,
OrderCount: row.OrderCount,
ListingCount: row.ListingCount,
DisputeCount: row.DisputeCount,
LastLoginAt: row.LastLoginAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
}
func roundMoney(value float64) float64 {
return money.Round(value)
}
func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizID uint64, meta AuditMeta, detail map[string]any) error {
@@ -41,3 +41,13 @@ func (s *Service) Unfreeze(adminID uint64, userID uint64, meta AuditMeta) (*User
}
return s.repo.Unfreeze(adminID, userID, meta)
}
func (s *Service) SetDepositFreeQuota(adminID uint64, userID uint64, req DepositFreeQuotaRequest, meta AuditMeta) (*UserDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if userID == 0 || req.Amount < 0 {
return nil, ErrInvalidUser
}
return s.repo.SetDepositFreeQuota(adminID, userID, req, meta)
}
+76 -16
View File
@@ -3,6 +3,7 @@ package dispute
import (
"encoding/json"
"errors"
"math"
"time"
"hfb_sys/backend/internal/auditlog"
@@ -17,13 +18,28 @@ import (
)
type Repository struct {
db *gorm.DB
db *gorm.DB
refundFunc RefundFunc
}
// RefundFunc 由 payment 模块注入,避免 dispute 与 payment 形成循环依赖。
type RefundFunc func(orderID uint64, refundAmountCent int64, bizType string, remark string) (status string, err error)
type refundAction struct {
OrderID uint64
RefundAmountCent int64
BizType string
Remark string
}
func NewRepository(db *gorm.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) SetRefundFunc(fn RefundFunc) {
r.refundFunc = fn
}
func (r *Repository) Create(userID uint64, orderID uint64, req CreateRequest) (*DisputeDTO, error) {
var createdID uint64
err := r.db.Transaction(func(tx *gorm.DB) error {
@@ -172,6 +188,7 @@ func (r *Repository) ListAdmin(page, pageSize int) (*PaginatedResult, error) {
}
func (r *Repository) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest, meta AuditMeta) (*DisputeDTO, error) {
var refund *refundAction
err := r.db.Transaction(func(tx *gorm.DB) error {
var row model.Dispute
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, id).Error; err != nil {
@@ -197,7 +214,11 @@ func (r *Repository) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest,
beforeSettlementStatus := order.SettlementStatus
beforeListingStatus := listing.Status
beforeAccountStatus := account.Status
settlement, err := buildArbitrationSettlement(order, req)
frozenBalance, err := renterFrozenBalance(tx, order.RenterID)
if err != nil {
return err
}
settlement, err := buildArbitrationSettlement(order, req, frozenBalance)
if err != nil {
return err
}
@@ -227,6 +248,14 @@ func (r *Repository) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest,
if err := wallet.AppendEntries(tx, settlement.Entries...); err != nil {
return err
}
if settlement.RenterRefundAmount > 0 {
refundCent := int64(math.Round(settlement.RenterRefundAmount * 100))
action, err := r.prepareRefund(&order, refundCent, "arbitration_refund", "仲裁退款原路退还")
if err != nil {
return err
}
refund = action
}
if err := tx.Save(&row).Error; err != nil {
return err
}
@@ -288,6 +317,7 @@ func (r *Repository) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest,
if err != nil {
return nil, err
}
r.startRefundBestEffort(refund)
var row disputeRow
if err := r.baseQuery().Where("d.id = ?", id).First(&row).Error; err != nil {
return nil, err
@@ -303,7 +333,7 @@ type arbitrationSettlement struct {
DepositDeductAmount float64
}
func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest) (arbitrationSettlement, error) {
func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest, renterFrozenBalance float64) (arbitrationSettlement, error) {
total := roundMoney(order.RentAmount + order.DepositAmount)
ownerRentAmount := roundMoney(order.OwnerRentAmount)
if ownerRentAmount <= 0 || ownerRentAmount > order.RentAmount {
@@ -311,16 +341,17 @@ func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest) (
}
settlement := arbitrationSettlement{}
orderID := order.ID
if total > 0 {
releaseFrozenAmount := minMoney(total, roundMoney(renterFrozenBalance))
if releaseFrozenAmount > 0 {
settlement.Entries = append(settlement.Entries, wallet.Entry{
UserID: order.RenterID,
OrderID: &orderID,
Direction: "out",
Amount: total,
Amount: releaseFrozenAmount,
BalanceType: "frozen",
BizType: "arbitration_release_frozen",
BizNo: order.OrderNo,
Remark: "仲裁释放开发态模拟冻结金额",
Remark: "仲裁释放冻结金额",
})
}
@@ -329,16 +360,6 @@ func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest) (
return
}
settlement.RenterRefundAmount += amount
settlement.Entries = append(settlement.Entries, wallet.Entry{
UserID: order.RenterID,
OrderID: &orderID,
Direction: "in",
Amount: amount,
BalanceType: "available",
BizType: "arbitration_renter_refund",
BizNo: order.OrderNo,
Remark: remark,
})
}
addOwnerIncome := func(amount float64, remark string) {
if amount <= 0 {
@@ -391,6 +412,45 @@ func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest) (
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(action *refundAction) {
if action == nil || r.refundFunc == nil {
return
}
_, _ = r.refundFunc(action.OrderID, action.RefundAmountCent, action.BizType, action.Remark)
}
func renterFrozenBalance(tx *gorm.DB, renterID uint64) (float64, 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.FrozenBalance, nil
}
// minMoney 返回较小金额(角精度)
func minMoney(a float64, b float64) float64 {
return money.Min(a, b)
@@ -0,0 +1,78 @@
package dispute
import (
"testing"
"hfb_sys/backend/internal/model"
)
func TestBuildArbitrationSettlementSkipsFrozenReleaseWhenNoFrozenBalance(t *testing.T) {
order := model.RentalOrder{
ID: 11,
OrderNo: "RO202606080001",
RenterID: 101,
OwnerID: 202,
RentAmount: 200,
OwnerRentAmount: 180,
DepositAmount: 100,
}
settlement, err := buildArbitrationSettlement(order, ArbitrateRequest{
Result: "release_deposit",
Remark: "测试裁决",
}, 0)
if err != nil {
t.Fatalf("buildArbitrationSettlement() error = %v", err)
}
for _, entry := range settlement.Entries {
if entry.BizType == "arbitration_release_frozen" {
t.Fatalf("不应在无冻结余额时生成解冻流水: %+v", entry)
}
}
if settlement.OwnerIncomeAmount != 180 {
t.Fatalf("OwnerIncomeAmount = %.1f, want 180.0", settlement.OwnerIncomeAmount)
}
if settlement.RenterRefundAmount != 100 {
t.Fatalf("RenterRefundAmount = %.1f, want 100.0", settlement.RenterRefundAmount)
}
for _, entry := range settlement.Entries {
if entry.UserID == order.RenterID && entry.BizType == "arbitration_renter_refund" {
t.Fatalf("外部支付订单不应生成租客钱包退款流水: %+v", entry)
}
}
}
func TestBuildArbitrationSettlementLimitsFrozenReleaseToExistingBalance(t *testing.T) {
order := model.RentalOrder{
ID: 12,
OrderNo: "RO202606080002",
RenterID: 101,
OwnerID: 202,
RentAmount: 200,
OwnerRentAmount: 180,
DepositAmount: 100,
}
settlement, err := buildArbitrationSettlement(order, ArbitrateRequest{
Result: "order_close",
Remark: "测试裁决",
}, 120)
if err != nil {
t.Fatalf("buildArbitrationSettlement() error = %v", err)
}
releaseEntryCount := 0
for _, entry := range settlement.Entries {
if entry.BizType != "arbitration_release_frozen" {
continue
}
releaseEntryCount++
if entry.Amount != 120 {
t.Fatalf("release frozen amount = %.1f, want 120.0", entry.Amount)
}
}
if releaseEntryCount != 1 {
t.Fatalf("release frozen entry count = %d, want 1", releaseEntryCount)
}
}
+2
View File
@@ -28,6 +28,8 @@ type OrderDTO struct {
RentAmount *float64 `json:"rent_amount,omitempty"`
OwnerRentAmount *float64 `json:"owner_rent_amount,omitempty"`
DepositAmount float64 `json:"deposit_amount"`
DepositOriginalAmount float64 `json:"deposit_original_amount"`
DepositWaivedAmount float64 `json:"deposit_waived_amount"`
PlatformFee *float64 `json:"platform_fee,omitempty"`
AccountSnapshot datatypes.JSON `json:"account_snapshot"`
Status string `json:"status"`
+54 -1
View File
@@ -173,6 +173,11 @@ func (r *Repository) Create(renterID uint64, req CreateRequest) (*OrderDTO, erro
}
rentHours := internalOrderHours
pricing := buildOrderPricing(listing, account)
depositOriginalAmount := roundMoney(listing.DepositAmount)
paidDepositAmount, waivedDepositAmount, err := r.depositAmountsForOrder(tx, renterID, depositOriginalAmount)
if err != nil {
return err
}
order := model.RentalOrder{
OrderNo: orderNo,
ListingID: listing.ID,
@@ -182,7 +187,9 @@ func (r *Repository) Create(renterID uint64, req CreateRequest) (*OrderDTO, erro
EstimatedDurationHours: rentHours,
RentAmount: pricing.RentAmount,
OwnerRentAmount: pricing.OwnerRentAmount,
DepositAmount: listing.DepositAmount,
DepositAmount: paidDepositAmount,
DepositOriginalAmount: depositOriginalAmount,
DepositWaivedAmount: waivedDepositAmount,
PlatformFee: pricing.PlatformFee,
AccountSnapshot: snapshot,
Status: "pending_payment",
@@ -218,6 +225,43 @@ func (r *Repository) Create(renterID uint64, req CreateRequest) (*OrderDTO, erro
return r.FindForUser(renterID, createdID)
}
func (r *Repository) depositAmountsForOrder(tx *gorm.DB, renterID uint64, originalDeposit float64) (float64, float64, error) {
originalDeposit = roundMoney(originalDeposit)
if originalDeposit <= 0 {
return 0, 0, nil
}
var user model.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, renterID).Error; err != nil {
return 0, 0, err
}
used, err := activeDepositFreeUsed(tx, renterID)
if err != nil {
return 0, 0, err
}
paidDeposit, waivedDeposit := calculateDepositWaiver(originalDeposit, user.DepositFreeQuota, used)
return paidDeposit, waivedDeposit, nil
}
func activeDepositFreeUsed(tx *gorm.DB, renterID uint64) (float64, error) {
var used float64
err := tx.Model(&model.RentalOrder{}).
Where("renter_id = ? AND status NOT IN ?",
renterID,
[]string{"completed", "cancelled", "closed"},
).
Select("COALESCE(SUM(deposit_waived_amount), 0)").
Scan(&used).Error
return roundMoney(used), err
}
func calculateDepositWaiver(originalDeposit float64, quota float64, used float64) (float64, float64) {
originalDeposit = roundMoney(originalDeposit)
remaining := maxMoney(roundMoney(quota)-roundMoney(used), 0)
waived := minMoney(originalDeposit, remaining)
paid := maxMoney(originalDeposit-waived, 0)
return roundMoney(paid), roundMoney(waived)
}
// Pay 保留旧接口兼容,但真实付款必须走 payment 模块的渠道支付入口。
func (r *Repository) Pay(userID uint64, orderID uint64) error {
return ErrChannelPaymentRequired
@@ -1424,6 +1468,8 @@ func (row orderRow) toAdminDTO() OrderDTO {
RentAmount: &rentAmount,
OwnerRentAmount: &ownerRentAmount,
DepositAmount: row.DepositAmount,
DepositOriginalAmount: effectiveDepositOriginalAmount(row.RentalOrder),
DepositWaivedAmount: row.DepositWaivedAmount,
PlatformFee: &platformFee,
AccountSnapshot: row.AccountSnapshot,
Status: row.Status,
@@ -1454,6 +1500,13 @@ func toHandoffDTO(record model.HandoffRecord) HandoffRecordDTO {
}
}
func effectiveDepositOriginalAmount(order model.RentalOrder) float64 {
if order.DepositOriginalAmount > 0 {
return order.DepositOriginalAmount
}
return order.DepositAmount
}
func toCheckoutAdminDTO(checkout model.OrderCheckout) CheckoutDTO {
rentAmount := checkout.RentAmount
ownerRentAmount := checkout.OwnerRentAmount
@@ -123,6 +123,23 @@ func TestCalculateCheckoutSettlementAddsDepositCompensation(t *testing.T) {
}
}
func TestCalculateDepositWaiverUsesSharedRemainingQuota(t *testing.T) {
paid, waived := calculateDepositWaiver(500, 300, 0)
if paid != 200 || waived != 300 {
t.Fatalf("paid = %.1f, waived = %.1f, want 200.0, 300.0", paid, waived)
}
paid, waived = calculateDepositWaiver(500, 300, 200)
if paid != 400 || waived != 100 {
t.Fatalf("paid = %.1f, waived = %.1f, want 400.0, 100.0", paid, waived)
}
paid, waived = calculateDepositWaiver(500, 300, 300)
if paid != 500 || waived != 0 {
t.Fatalf("paid = %.1f, waived = %.1f, want 500.0, 0.0", paid, waived)
}
}
func TestArchiveListingAfterCheckoutMovesListingOffline(t *testing.T) {
// This test is purely for contract documentation; no behavior is tested yet.
// When implementing auto-archive behavior:
@@ -53,6 +53,7 @@ var refundBizTypes = []string{
"checkout_refund",
"deposit_refund",
"rent_refund",
"arbitration_refund",
}
func NewRepository(db *gorm.DB, configRepo *paymentconfig.Repository, orderRepo *order.Repository, walletRepo *wallet.Repository) *Repository {