From 0b419f50843205b5d22dab09aaa57a1fd830b667 Mon Sep 17 00:00:00 2001 From: yml Date: Mon, 8 Jun 2026 21:00:39 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BB=B2=E8=A3=81=E9=80=80?= =?UTF-8?q?=E6=AC=BE=E5=B9=B6=E6=96=B0=E5=A2=9E=E5=85=8D=E6=8A=BC=E9=A2=9D?= =?UTF-8?q?=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/model/order.go | 2 + backend/internal/model/user.go | 23 ++--- backend/internal/modules/adminuser/dto.go | 33 ++++--- backend/internal/modules/adminuser/handler.go | 23 +++++ .../internal/modules/adminuser/repository.go | 82 +++++++++++++---- backend/internal/modules/adminuser/service.go | 10 ++ .../internal/modules/dispute/repository.go | 92 +++++++++++++++---- .../modules/dispute/repository_test.go | 78 ++++++++++++++++ backend/internal/modules/order/dto.go | 2 + backend/internal/modules/order/repository.go | 55 ++++++++++- .../internal/modules/order/repository_test.go | 17 ++++ .../internal/modules/payment/repository.go | 1 + backend/internal/router/router.go | 10 ++ backend/migrations/000001_init.sql | 8 +- frontend/src/features/admin/api/adminUsers.ts | 11 +++ .../admin/views/AdminDisputesView.vue | 28 +++++- .../features/admin/views/AdminUsersView.vue | 70 +++++++++++++- frontend/src/features/orders/api/orders.ts | 2 + .../orders/views/MobileOrderDetailView.vue | 17 +++- .../orders/views/MobileOrdersView.vue | 17 +++- .../features/orders/views/OrderDetailView.vue | 25 ++++- .../src/features/orders/views/OrdersView.vue | 25 ++++- 22 files changed, 559 insertions(+), 72 deletions(-) create mode 100644 backend/internal/modules/dispute/repository_test.go diff --git a/backend/internal/model/order.go b/backend/internal/model/order.go index 8291834..c0d995a 100644 --- a/backend/internal/model/order.go +++ b/backend/internal/model/order.go @@ -18,6 +18,8 @@ type RentalOrder struct { RentAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"rent_amount"` OwnerRentAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"owner_rent_amount"` DepositAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"deposit_amount"` + DepositOriginalAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"deposit_original_amount"` + DepositWaivedAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"deposit_waived_amount"` PlatformFee float64 `gorm:"type:decimal(12,2);not null;default:0" json:"platform_fee"` AccountSnapshot datatypes.JSON `json:"account_snapshot"` Status string `gorm:"size:32;not null;default:'pending_payment'" json:"status"` diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go index be6cae2..aee9fe6 100644 --- a/backend/internal/model/user.go +++ b/backend/internal/model/user.go @@ -3,17 +3,18 @@ package model import "time" type User struct { - ID uint64 `gorm:"primaryKey" json:"id"` - Phone string `gorm:"size:32;not null;uniqueIndex" json:"phone"` - Nickname string `gorm:"size:64;not null;default:''" json:"nickname"` - AvatarURL string `gorm:"size:512;not null;default:''" json:"avatar_url"` - RealnameStatus string `gorm:"size:32;not null;default:'unverified'" json:"realname_status"` - RiskStatus string `gorm:"size:32;not null;default:'normal'" json:"risk_status"` - CreditScore int `gorm:"not null;default:100" json:"credit_score"` - Status string `gorm:"size:32;not null;default:'active'" json:"status"` - LastLoginAt *time.Time `json:"last_login_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint64 `gorm:"primaryKey" json:"id"` + Phone string `gorm:"size:32;not null;uniqueIndex" json:"phone"` + Nickname string `gorm:"size:64;not null;default:''" json:"nickname"` + AvatarURL string `gorm:"size:512;not null;default:''" json:"avatar_url"` + RealnameStatus string `gorm:"size:32;not null;default:'unverified'" json:"realname_status"` + RiskStatus string `gorm:"size:32;not null;default:'normal'" json:"risk_status"` + CreditScore int `gorm:"not null;default:100" json:"credit_score"` + DepositFreeQuota float64 `gorm:"type:decimal(12,2);not null;default:0" json:"deposit_free_quota"` + Status string `gorm:"size:32;not null;default:'active'" json:"status"` + LastLoginAt *time.Time `json:"last_login_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (User) TableName() string { diff --git a/backend/internal/modules/adminuser/dto.go b/backend/internal/modules/adminuser/dto.go index cfb9834..6f533fa 100644 --- a/backend/internal/modules/adminuser/dto.go +++ b/backend/internal/modules/adminuser/dto.go @@ -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"` diff --git a/backend/internal/modules/adminuser/handler.go b/backend/internal/modules/adminuser/handler.go index f1be3fa..450091a 100644 --- a/backend/internal/modules/adminuser/handler.go +++ b/backend/internal/modules/adminuser/handler.go @@ -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 { diff --git a/backend/internal/modules/adminuser/repository.go b/backend/internal/modules/adminuser/repository.go index a400347..c21284f 100644 --- a/backend/internal/modules/adminuser/repository.go +++ b/backend/internal/modules/adminuser/repository.go @@ -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 { diff --git a/backend/internal/modules/adminuser/service.go b/backend/internal/modules/adminuser/service.go index 4ade08d..093688b 100644 --- a/backend/internal/modules/adminuser/service.go +++ b/backend/internal/modules/adminuser/service.go @@ -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) +} diff --git a/backend/internal/modules/dispute/repository.go b/backend/internal/modules/dispute/repository.go index 5c14900..4cef63d 100644 --- a/backend/internal/modules/dispute/repository.go +++ b/backend/internal/modules/dispute/repository.go @@ -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) diff --git a/backend/internal/modules/dispute/repository_test.go b/backend/internal/modules/dispute/repository_test.go new file mode 100644 index 0000000..af29cfe --- /dev/null +++ b/backend/internal/modules/dispute/repository_test.go @@ -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) + } +} diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index da3f8e8..a7cb937 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -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"` diff --git a/backend/internal/modules/order/repository.go b/backend/internal/modules/order/repository.go index fcc0182..4c4345d 100644 --- a/backend/internal/modules/order/repository.go +++ b/backend/internal/modules/order/repository.go @@ -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 diff --git a/backend/internal/modules/order/repository_test.go b/backend/internal/modules/order/repository_test.go index 9f335a2..6106ab7 100644 --- a/backend/internal/modules/order/repository_test.go +++ b/backend/internal/modules/order/repository_test.go @@ -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: diff --git a/backend/internal/modules/payment/repository.go b/backend/internal/modules/payment/repository.go index 966b385..202cd36 100644 --- a/backend/internal/modules/payment/repository.go +++ b/backend/internal/modules/payment/repository.go @@ -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 { diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 379e47f..e086f00 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -191,6 +191,15 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { if deps.DB != nil { disputeRepo = dispute.NewRepository(deps.DB) } + if disputeRepo != nil && paymentRepo != nil { + disputeRepo.SetRefundFunc(func(orderID uint64, refundAmountCent int64, bizType string, remark string) (string, error) { + dto, err := paymentRepo.StartRefund(orderID, refundAmountCent, bizType, remark) + if err != nil { + return "", err + } + return dto.Status, nil + }) + } disputeService := dispute.NewService(disputeRepo) disputeHandler := dispute.NewHandler(disputeService) var systemConfigRepo *systemconfig.Repository @@ -397,6 +406,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/users", requirePerm("user:view"), adminUserHandler.List) adminRoutes.POST("/users/:id/freeze", requirePerm("user:freeze"), adminUserHandler.Freeze) adminRoutes.POST("/users/:id/unfreeze", requirePerm("user:unfreeze"), adminUserHandler.Unfreeze) + adminRoutes.POST("/users/:id/deposit-free-quota", requirePerm("user:deposit_free"), adminUserHandler.SetDepositFreeQuota) adminRoutes.GET("/orders", requirePerm("order:view"), orderHandler.AdminList) adminRoutes.GET("/orders/:id", requirePerm("order:view"), orderHandler.AdminDetail) adminRoutes.GET("/orders/:id/handoff-records", requirePerm("order:view"), orderHandler.AdminHandoffRecords) diff --git a/backend/migrations/000001_init.sql b/backend/migrations/000001_init.sql index b8dfa92..c5d0ed8 100644 --- a/backend/migrations/000001_init.sql +++ b/backend/migrations/000001_init.sql @@ -20,6 +20,7 @@ CREATE TABLE IF NOT EXISTS users ( realname_status VARCHAR(32) NOT NULL DEFAULT 'unverified' COMMENT '实名状态: unverified未实名, pending审核中, verified已实名, failed失败', risk_status VARCHAR(32) NOT NULL DEFAULT 'normal' COMMENT '风控状态: normal正常, warning警告, frozen冻结', credit_score INT NOT NULL DEFAULT 100 COMMENT '信用分', + deposit_free_quota DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '免押总额度', status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '账号状态: active活跃, inactive停用, banned封禁', last_login_at DATETIME NULL COMMENT '最后登录时间', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -118,7 +119,9 @@ CREATE TABLE IF NOT EXISTS rental_orders ( estimated_duration_hours INT NOT NULL DEFAULT 24 COMMENT '预计租用时长(小时)', rent_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '租金总额', owner_rent_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '号主实得租金', - deposit_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '押金金额', + deposit_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '实际收取押金金额', + deposit_original_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '商品原始押金金额', + deposit_waived_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '本单免押抵扣金额', platform_fee DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '平台手续费', account_snapshot JSON NULL COMMENT '账号快照(下单时的账号状态)', status VARCHAR(32) NOT NULL DEFAULT 'pending_payment' COMMENT '订单状态: pending_payment待支付, active进行中, completed已完成, cancelled已取消, closed已关闭', @@ -558,6 +561,7 @@ INSERT INTO permissions (code, name, resource, action) VALUES ('dashboard:view', '查看仪表盘', 'dashboard', 'view'), ('user:view', '查看用户', 'user', 'view'), ('user:freeze', '冻结用户', 'user', 'freeze'), +('user:deposit_free', '设置免押额度', 'user', 'deposit_free'), ('user:unfreeze', '解冻用户', 'user', 'unfreeze'), ('order:view', '查看订单', 'order', 'view'), ('order:close', '关闭订单', 'order', 'close'), @@ -619,7 +623,7 @@ WHERE r.code = 'cs' AND p.code IN ('dashboard:view', 'user:view', 'dispute:view' -- ops 角色权限 INSERT IGNORE INTO role_permissions (role_id, permission_id) SELECT r.id, p.id FROM roles r, permissions p -WHERE r.code = 'ops' AND p.code IN ('dashboard:view', 'user:view', 'user:freeze', 'user:unfreeze', 'order:view', 'order:close', 'order:mark_abnormal', 'listing:view', 'listing:approve', 'listing:reject', 'listing:offline', 'dispute:view', 'audit_log:view', 'announcement:view', 'announcement:manage'); +WHERE r.code = 'ops' AND p.code IN ('dashboard:view', 'user:view', 'user:freeze', 'user:unfreeze', 'user:deposit_free', 'order:view', 'order:close', 'order:mark_abnormal', 'listing:view', 'listing:approve', 'listing:reject', 'listing:offline', 'dispute:view', 'audit_log:view', 'announcement:view', 'announcement:manage'); -- finance 角色权限 INSERT IGNORE INTO role_permissions (role_id, permission_id) diff --git a/frontend/src/features/admin/api/adminUsers.ts b/frontend/src/features/admin/api/adminUsers.ts index 6c7573e..6138d40 100644 --- a/frontend/src/features/admin/api/adminUsers.ts +++ b/frontend/src/features/admin/api/adminUsers.ts @@ -10,6 +10,9 @@ export interface AdminUserItem { realname_status: RealnameStatusValue risk_status: RiskStatus credit_score: number + deposit_free_quota: number + deposit_free_used: number + deposit_free_remaining: number status: UserStatus order_count: number listing_count: number @@ -40,3 +43,11 @@ export async function unfreezeAdminUser(id: number) { const { data } = await apiClient.post>(`/admin/users/${id}/unfreeze`) return data.data } + +export async function setAdminUserDepositFreeQuota(id: number, amount: number) { + const { data } = await apiClient.post>( + `/admin/users/${id}/deposit-free-quota`, + { amount } + ) + return data.data +} diff --git a/frontend/src/features/admin/views/AdminDisputesView.vue b/frontend/src/features/admin/views/AdminDisputesView.vue index 2aadde7..504313b 100644 --- a/frontend/src/features/admin/views/AdminDisputesView.vue +++ b/frontend/src/features/admin/views/AdminDisputesView.vue @@ -1,6 +1,6 @@ diff --git a/frontend/src/features/orders/api/orders.ts b/frontend/src/features/orders/api/orders.ts index a9b2931..27df957 100644 --- a/frontend/src/features/orders/api/orders.ts +++ b/frontend/src/features/orders/api/orders.ts @@ -22,6 +22,8 @@ export interface Order { rent_amount?: number owner_rent_amount?: number deposit_amount: number + deposit_original_amount: number + deposit_waived_amount: number platform_fee?: number account_snapshot?: Record listing_snapshot?: string diff --git a/frontend/src/features/orders/views/MobileOrderDetailView.vue b/frontend/src/features/orders/views/MobileOrderDetailView.vue index 5a86db1..8174942 100644 --- a/frontend/src/features/orders/views/MobileOrderDetailView.vue +++ b/frontend/src/features/orders/views/MobileOrderDetailView.vue @@ -664,6 +664,9 @@ async function copyListingCode() {
押金 ¥{{ money(order.deposit_amount) }} + 已免押 ¥{{ money(order.deposit_waived_amount) }}
交接状态 @@ -914,7 +917,13 @@ async function copyListingCode() {

结账结算账单

- +
押金金额 - ¥{{ money(order.deposit_amount) }} + + ¥{{ money(order.deposit_amount) }} + 免 ¥{{ money(order.deposit_waived_amount) }} +
@@ -500,11 +505,21 @@ async function copyListingCode(order: Order) { } .price-val { + display: flex; + flex-direction: column; + align-items: flex-end; font-size: 15px; font-weight: 800; color: #ff5f00; } +.price-val em { + font-style: normal; + font-size: 10px; + font-weight: 600; + color: #2563eb; +} + .price-val.deposit { color: #374151; } diff --git a/frontend/src/features/orders/views/OrderDetailView.vue b/frontend/src/features/orders/views/OrderDetailView.vue index 38dffc3..49a2ddd 100644 --- a/frontend/src/features/orders/views/OrderDetailView.vue +++ b/frontend/src/features/orders/views/OrderDetailView.vue @@ -766,6 +766,9 @@ async function copyListingCode() {
押金 ¥{{ money(order.deposit_amount) }} + 已免押 ¥{{ money(order.deposit_waived_amount) }}
@@ -950,7 +953,12 @@ async function copyListingCode() {
预收押金 - ¥{{ money(order.checkout.deposit_amount) }} + + ¥{{ money(order.checkout.deposit_amount) }} + 已免押 ¥{{ money(order.deposit_waived_amount) }} +
额外消耗品已用 @@ -1441,6 +1449,11 @@ async function copyListingCode() { color: #ff6a00; } +.metric-note { + font-size: 12px; + color: #2563eb; +} + .info-section { margin-bottom: 32px; } @@ -1772,11 +1785,21 @@ async function copyListingCode() { } .summary-row strong { + display: flex; + flex-direction: column; + align-items: flex-end; font-size: 18px; font-weight: 700; color: #1f2937; } +.summary-row strong em { + font-style: normal; + font-size: 12px; + font-weight: 500; + color: #2563eb; +} + .summary-row strong.amount { color: #10b981; font-size: 20px; diff --git a/frontend/src/features/orders/views/OrdersView.vue b/frontend/src/features/orders/views/OrdersView.vue index ed5f060..7626d22 100644 --- a/frontend/src/features/orders/views/OrdersView.vue +++ b/frontend/src/features/orders/views/OrdersView.vue @@ -260,7 +260,14 @@ function getCountdownMinutes(order: Order) { - +