修复仲裁退款并新增免押额度
This commit is contained in:
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user