修复仲裁退款并新增免押额度
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"`
|
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"`
|
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"`
|
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"`
|
PlatformFee float64 `gorm:"type:decimal(12,2);not null;default:0" json:"platform_fee"`
|
||||||
AccountSnapshot datatypes.JSON `json:"account_snapshot"`
|
AccountSnapshot datatypes.JSON `json:"account_snapshot"`
|
||||||
Status string `gorm:"size:32;not null;default:'pending_payment'" json:"status"`
|
Status string `gorm:"size:32;not null;default:'pending_payment'" json:"status"`
|
||||||
|
|||||||
@@ -3,17 +3,18 @@ package model
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||||
Phone string `gorm:"size:32;not null;uniqueIndex" json:"phone"`
|
Phone string `gorm:"size:32;not null;uniqueIndex" json:"phone"`
|
||||||
Nickname string `gorm:"size:64;not null;default:''" json:"nickname"`
|
Nickname string `gorm:"size:64;not null;default:''" json:"nickname"`
|
||||||
AvatarURL string `gorm:"size:512;not null;default:''" json:"avatar_url"`
|
AvatarURL string `gorm:"size:512;not null;default:''" json:"avatar_url"`
|
||||||
RealnameStatus string `gorm:"size:32;not null;default:'unverified'" json:"realname_status"`
|
RealnameStatus string `gorm:"size:32;not null;default:'unverified'" json:"realname_status"`
|
||||||
RiskStatus string `gorm:"size:32;not null;default:'normal'" json:"risk_status"`
|
RiskStatus string `gorm:"size:32;not null;default:'normal'" json:"risk_status"`
|
||||||
CreditScore int `gorm:"not null;default:100" json:"credit_score"`
|
CreditScore int `gorm:"not null;default:100" json:"credit_score"`
|
||||||
Status string `gorm:"size:32;not null;default:'active'" json:"status"`
|
DepositFreeQuota float64 `gorm:"type:decimal(12,2);not null;default:0" json:"deposit_free_quota"`
|
||||||
LastLoginAt *time.Time `json:"last_login_at"`
|
Status string `gorm:"size:32;not null;default:'active'" json:"status"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
LastLoginAt *time.Time `json:"last_login_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (User) TableName() string {
|
func (User) TableName() string {
|
||||||
|
|||||||
@@ -3,24 +3,31 @@ package adminuser
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type UserDTO struct {
|
type UserDTO struct {
|
||||||
ID uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
RealnameStatus string `json:"realname_status"`
|
RealnameStatus string `json:"realname_status"`
|
||||||
RiskStatus string `json:"risk_status"`
|
RiskStatus string `json:"risk_status"`
|
||||||
CreditScore int `json:"credit_score"`
|
CreditScore int `json:"credit_score"`
|
||||||
Status string `json:"status"`
|
DepositFreeQuota float64 `json:"deposit_free_quota"`
|
||||||
OrderCount int64 `json:"order_count"`
|
DepositFreeUsed float64 `json:"deposit_free_used"`
|
||||||
ListingCount int64 `json:"listing_count"`
|
DepositFreeRemaining float64 `json:"deposit_free_remaining"`
|
||||||
DisputeCount int64 `json:"dispute_count"`
|
Status string `json:"status"`
|
||||||
LastLoginAt *time.Time `json:"last_login_at"`
|
OrderCount int64 `json:"order_count"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
ListingCount int64 `json:"listing_count"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
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 {
|
type FreezeRequest struct {
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DepositFreeQuotaRequest struct {
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
}
|
||||||
|
|
||||||
type PaginatedResult struct {
|
type PaginatedResult struct {
|
||||||
Items interface{} `json:"items"`
|
Items interface{} `json:"items"`
|
||||||
Total int64 `json:"total"`
|
Total int64 `json:"total"`
|
||||||
|
|||||||
@@ -82,6 +82,29 @@ func (h *Handler) Unfreeze(c *gin.Context) {
|
|||||||
response.OK(c, item)
|
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) {
|
func currentAdminID(c *gin.Context) (uint64, bool) {
|
||||||
value, ok := c.Get(middleware.ContextAdminID)
|
value, ok := c.Get(middleware.ContextAdminID)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
|
|
||||||
"hfb_sys/backend/internal/auditlog"
|
"hfb_sys/backend/internal/auditlog"
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
|
"hfb_sys/backend/pkg/money"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
@@ -31,10 +32,12 @@ func (r *Repository) List(page, pageSize int) (*PaginatedResult, error) {
|
|||||||
Select(`u.*,
|
Select(`u.*,
|
||||||
COALESCE(o.order_count, 0) AS order_count,
|
COALESCE(o.order_count, 0) AS order_count,
|
||||||
COALESCE(l.listing_count, 0) AS listing_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 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 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 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").
|
Order("u.id DESC").
|
||||||
Offset(offset).Limit(pageSize).
|
Offset(offset).Limit(pageSize).
|
||||||
Scan(&rows).Error
|
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)
|
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) {
|
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 {
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
var user model.User
|
var user model.User
|
||||||
@@ -90,10 +120,12 @@ func (r *Repository) Find(userID uint64) (*UserDTO, error) {
|
|||||||
Select(`u.*,
|
Select(`u.*,
|
||||||
COALESCE(o.order_count, 0) AS order_count,
|
COALESCE(o.order_count, 0) AS order_count,
|
||||||
COALESCE(l.listing_count, 0) AS listing_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 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 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 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).
|
Where("u.id = ?", userID).
|
||||||
First(&row).Error
|
First(&row).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -105,27 +137,39 @@ func (r *Repository) Find(userID uint64) (*UserDTO, error) {
|
|||||||
|
|
||||||
type userRow struct {
|
type userRow struct {
|
||||||
model.User
|
model.User
|
||||||
OrderCount int64
|
OrderCount int64
|
||||||
ListingCount int64
|
ListingCount int64
|
||||||
DisputeCount int64
|
DisputeCount int64
|
||||||
|
DepositFreeUsed float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (row userRow) toDTO() UserDTO {
|
func (row userRow) toDTO() UserDTO {
|
||||||
return UserDTO{
|
remaining := roundMoney(row.DepositFreeQuota - row.DepositFreeUsed)
|
||||||
ID: row.ID,
|
if remaining < 0 {
|
||||||
Phone: row.Phone,
|
remaining = 0
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
|
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 {
|
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)
|
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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"math"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/auditlog"
|
"hfb_sys/backend/internal/auditlog"
|
||||||
@@ -17,13 +18,28 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Repository struct {
|
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 {
|
func NewRepository(db *gorm.DB) *Repository {
|
||||||
return &Repository{db: db}
|
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) {
|
func (r *Repository) Create(userID uint64, orderID uint64, req CreateRequest) (*DisputeDTO, error) {
|
||||||
var createdID uint64
|
var createdID uint64
|
||||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
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) {
|
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 {
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
var row model.Dispute
|
var row model.Dispute
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, id).Error; err != nil {
|
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
|
beforeSettlementStatus := order.SettlementStatus
|
||||||
beforeListingStatus := listing.Status
|
beforeListingStatus := listing.Status
|
||||||
beforeAccountStatus := account.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 {
|
if err != nil {
|
||||||
return err
|
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 {
|
if err := wallet.AppendEntries(tx, settlement.Entries...); err != nil {
|
||||||
return err
|
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 {
|
if err := tx.Save(&row).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -288,6 +317,7 @@ func (r *Repository) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
r.startRefundBestEffort(refund)
|
||||||
var row disputeRow
|
var row disputeRow
|
||||||
if err := r.baseQuery().Where("d.id = ?", id).First(&row).Error; err != nil {
|
if err := r.baseQuery().Where("d.id = ?", id).First(&row).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -303,7 +333,7 @@ type arbitrationSettlement struct {
|
|||||||
DepositDeductAmount float64
|
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)
|
total := roundMoney(order.RentAmount + order.DepositAmount)
|
||||||
ownerRentAmount := roundMoney(order.OwnerRentAmount)
|
ownerRentAmount := roundMoney(order.OwnerRentAmount)
|
||||||
if ownerRentAmount <= 0 || ownerRentAmount > order.RentAmount {
|
if ownerRentAmount <= 0 || ownerRentAmount > order.RentAmount {
|
||||||
@@ -311,16 +341,17 @@ func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest) (
|
|||||||
}
|
}
|
||||||
settlement := arbitrationSettlement{}
|
settlement := arbitrationSettlement{}
|
||||||
orderID := order.ID
|
orderID := order.ID
|
||||||
if total > 0 {
|
releaseFrozenAmount := minMoney(total, roundMoney(renterFrozenBalance))
|
||||||
|
if releaseFrozenAmount > 0 {
|
||||||
settlement.Entries = append(settlement.Entries, wallet.Entry{
|
settlement.Entries = append(settlement.Entries, wallet.Entry{
|
||||||
UserID: order.RenterID,
|
UserID: order.RenterID,
|
||||||
OrderID: &orderID,
|
OrderID: &orderID,
|
||||||
Direction: "out",
|
Direction: "out",
|
||||||
Amount: total,
|
Amount: releaseFrozenAmount,
|
||||||
BalanceType: "frozen",
|
BalanceType: "frozen",
|
||||||
BizType: "arbitration_release_frozen",
|
BizType: "arbitration_release_frozen",
|
||||||
BizNo: order.OrderNo,
|
BizNo: order.OrderNo,
|
||||||
Remark: "仲裁释放开发态模拟冻结金额",
|
Remark: "仲裁释放冻结金额",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,16 +360,6 @@ func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest) (
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
settlement.RenterRefundAmount += amount
|
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) {
|
addOwnerIncome := func(amount float64, remark string) {
|
||||||
if amount <= 0 {
|
if amount <= 0 {
|
||||||
@@ -391,6 +412,45 @@ func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest) (
|
|||||||
return settlement, nil
|
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 返回较小金额(角精度)
|
// minMoney 返回较小金额(角精度)
|
||||||
func minMoney(a float64, b float64) float64 {
|
func minMoney(a float64, b float64) float64 {
|
||||||
return money.Min(a, b)
|
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"`
|
RentAmount *float64 `json:"rent_amount,omitempty"`
|
||||||
OwnerRentAmount *float64 `json:"owner_rent_amount,omitempty"`
|
OwnerRentAmount *float64 `json:"owner_rent_amount,omitempty"`
|
||||||
DepositAmount float64 `json:"deposit_amount"`
|
DepositAmount float64 `json:"deposit_amount"`
|
||||||
|
DepositOriginalAmount float64 `json:"deposit_original_amount"`
|
||||||
|
DepositWaivedAmount float64 `json:"deposit_waived_amount"`
|
||||||
PlatformFee *float64 `json:"platform_fee,omitempty"`
|
PlatformFee *float64 `json:"platform_fee,omitempty"`
|
||||||
AccountSnapshot datatypes.JSON `json:"account_snapshot"`
|
AccountSnapshot datatypes.JSON `json:"account_snapshot"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
|||||||
@@ -173,6 +173,11 @@ func (r *Repository) Create(renterID uint64, req CreateRequest) (*OrderDTO, erro
|
|||||||
}
|
}
|
||||||
rentHours := internalOrderHours
|
rentHours := internalOrderHours
|
||||||
pricing := buildOrderPricing(listing, account)
|
pricing := buildOrderPricing(listing, account)
|
||||||
|
depositOriginalAmount := roundMoney(listing.DepositAmount)
|
||||||
|
paidDepositAmount, waivedDepositAmount, err := r.depositAmountsForOrder(tx, renterID, depositOriginalAmount)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
order := model.RentalOrder{
|
order := model.RentalOrder{
|
||||||
OrderNo: orderNo,
|
OrderNo: orderNo,
|
||||||
ListingID: listing.ID,
|
ListingID: listing.ID,
|
||||||
@@ -182,7 +187,9 @@ func (r *Repository) Create(renterID uint64, req CreateRequest) (*OrderDTO, erro
|
|||||||
EstimatedDurationHours: rentHours,
|
EstimatedDurationHours: rentHours,
|
||||||
RentAmount: pricing.RentAmount,
|
RentAmount: pricing.RentAmount,
|
||||||
OwnerRentAmount: pricing.OwnerRentAmount,
|
OwnerRentAmount: pricing.OwnerRentAmount,
|
||||||
DepositAmount: listing.DepositAmount,
|
DepositAmount: paidDepositAmount,
|
||||||
|
DepositOriginalAmount: depositOriginalAmount,
|
||||||
|
DepositWaivedAmount: waivedDepositAmount,
|
||||||
PlatformFee: pricing.PlatformFee,
|
PlatformFee: pricing.PlatformFee,
|
||||||
AccountSnapshot: snapshot,
|
AccountSnapshot: snapshot,
|
||||||
Status: "pending_payment",
|
Status: "pending_payment",
|
||||||
@@ -218,6 +225,43 @@ func (r *Repository) Create(renterID uint64, req CreateRequest) (*OrderDTO, erro
|
|||||||
return r.FindForUser(renterID, createdID)
|
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 模块的渠道支付入口。
|
// Pay 保留旧接口兼容,但真实付款必须走 payment 模块的渠道支付入口。
|
||||||
func (r *Repository) Pay(userID uint64, orderID uint64) error {
|
func (r *Repository) Pay(userID uint64, orderID uint64) error {
|
||||||
return ErrChannelPaymentRequired
|
return ErrChannelPaymentRequired
|
||||||
@@ -1424,6 +1468,8 @@ func (row orderRow) toAdminDTO() OrderDTO {
|
|||||||
RentAmount: &rentAmount,
|
RentAmount: &rentAmount,
|
||||||
OwnerRentAmount: &ownerRentAmount,
|
OwnerRentAmount: &ownerRentAmount,
|
||||||
DepositAmount: row.DepositAmount,
|
DepositAmount: row.DepositAmount,
|
||||||
|
DepositOriginalAmount: effectiveDepositOriginalAmount(row.RentalOrder),
|
||||||
|
DepositWaivedAmount: row.DepositWaivedAmount,
|
||||||
PlatformFee: &platformFee,
|
PlatformFee: &platformFee,
|
||||||
AccountSnapshot: row.AccountSnapshot,
|
AccountSnapshot: row.AccountSnapshot,
|
||||||
Status: row.Status,
|
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 {
|
func toCheckoutAdminDTO(checkout model.OrderCheckout) CheckoutDTO {
|
||||||
rentAmount := checkout.RentAmount
|
rentAmount := checkout.RentAmount
|
||||||
ownerRentAmount := checkout.OwnerRentAmount
|
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) {
|
func TestArchiveListingAfterCheckoutMovesListingOffline(t *testing.T) {
|
||||||
// This test is purely for contract documentation; no behavior is tested yet.
|
// This test is purely for contract documentation; no behavior is tested yet.
|
||||||
// When implementing auto-archive behavior:
|
// When implementing auto-archive behavior:
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ var refundBizTypes = []string{
|
|||||||
"checkout_refund",
|
"checkout_refund",
|
||||||
"deposit_refund",
|
"deposit_refund",
|
||||||
"rent_refund",
|
"rent_refund",
|
||||||
|
"arbitration_refund",
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRepository(db *gorm.DB, configRepo *paymentconfig.Repository, orderRepo *order.Repository, walletRepo *wallet.Repository) *Repository {
|
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 {
|
if deps.DB != nil {
|
||||||
disputeRepo = dispute.NewRepository(deps.DB)
|
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)
|
disputeService := dispute.NewService(disputeRepo)
|
||||||
disputeHandler := dispute.NewHandler(disputeService)
|
disputeHandler := dispute.NewHandler(disputeService)
|
||||||
var systemConfigRepo *systemconfig.Repository
|
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.GET("/users", requirePerm("user:view"), adminUserHandler.List)
|
||||||
adminRoutes.POST("/users/:id/freeze", requirePerm("user:freeze"), adminUserHandler.Freeze)
|
adminRoutes.POST("/users/:id/freeze", requirePerm("user:freeze"), adminUserHandler.Freeze)
|
||||||
adminRoutes.POST("/users/:id/unfreeze", requirePerm("user:unfreeze"), adminUserHandler.Unfreeze)
|
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", requirePerm("order:view"), orderHandler.AdminList)
|
||||||
adminRoutes.GET("/orders/:id", requirePerm("order:view"), orderHandler.AdminDetail)
|
adminRoutes.GET("/orders/:id", requirePerm("order:view"), orderHandler.AdminDetail)
|
||||||
adminRoutes.GET("/orders/:id/handoff-records", requirePerm("order:view"), orderHandler.AdminHandoffRecords)
|
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失败',
|
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冻结',
|
risk_status VARCHAR(32) NOT NULL DEFAULT 'normal' COMMENT '风控状态: normal正常, warning警告, frozen冻结',
|
||||||
credit_score INT NOT NULL DEFAULT 100 COMMENT '信用分',
|
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封禁',
|
status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '账号状态: active活跃, inactive停用, banned封禁',
|
||||||
last_login_at DATETIME NULL COMMENT '最后登录时间',
|
last_login_at DATETIME NULL COMMENT '最后登录时间',
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
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 '预计租用时长(小时)',
|
estimated_duration_hours INT NOT NULL DEFAULT 24 COMMENT '预计租用时长(小时)',
|
||||||
rent_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '租金总额',
|
rent_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '租金总额',
|
||||||
owner_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 '平台手续费',
|
platform_fee DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '平台手续费',
|
||||||
account_snapshot JSON NULL COMMENT '账号快照(下单时的账号状态)',
|
account_snapshot JSON NULL COMMENT '账号快照(下单时的账号状态)',
|
||||||
status VARCHAR(32) NOT NULL DEFAULT 'pending_payment' COMMENT '订单状态: pending_payment待支付, active进行中, completed已完成, cancelled已取消, closed已关闭',
|
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'),
|
('dashboard:view', '查看仪表盘', 'dashboard', 'view'),
|
||||||
('user:view', '查看用户', 'user', 'view'),
|
('user:view', '查看用户', 'user', 'view'),
|
||||||
('user:freeze', '冻结用户', 'user', 'freeze'),
|
('user:freeze', '冻结用户', 'user', 'freeze'),
|
||||||
|
('user:deposit_free', '设置免押额度', 'user', 'deposit_free'),
|
||||||
('user:unfreeze', '解冻用户', 'user', 'unfreeze'),
|
('user:unfreeze', '解冻用户', 'user', 'unfreeze'),
|
||||||
('order:view', '查看订单', 'order', 'view'),
|
('order:view', '查看订单', 'order', 'view'),
|
||||||
('order:close', '关闭订单', 'order', 'close'),
|
('order:close', '关闭订单', 'order', 'close'),
|
||||||
@@ -619,7 +623,7 @@ WHERE r.code = 'cs' AND p.code IN ('dashboard:view', 'user:view', 'dispute:view'
|
|||||||
-- ops 角色权限
|
-- ops 角色权限
|
||||||
INSERT IGNORE INTO role_permissions (role_id, permission_id)
|
INSERT IGNORE INTO role_permissions (role_id, permission_id)
|
||||||
SELECT r.id, p.id FROM roles r, permissions p
|
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 角色权限
|
-- finance 角色权限
|
||||||
INSERT IGNORE INTO role_permissions (role_id, permission_id)
|
INSERT IGNORE INTO role_permissions (role_id, permission_id)
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ export interface AdminUserItem {
|
|||||||
realname_status: RealnameStatusValue
|
realname_status: RealnameStatusValue
|
||||||
risk_status: RiskStatus
|
risk_status: RiskStatus
|
||||||
credit_score: number
|
credit_score: number
|
||||||
|
deposit_free_quota: number
|
||||||
|
deposit_free_used: number
|
||||||
|
deposit_free_remaining: number
|
||||||
status: UserStatus
|
status: UserStatus
|
||||||
order_count: number
|
order_count: number
|
||||||
listing_count: number
|
listing_count: number
|
||||||
@@ -40,3 +43,11 @@ export async function unfreezeAdminUser(id: number) {
|
|||||||
const { data } = await apiClient.post<ApiResponse<AdminUserItem>>(`/admin/users/${id}/unfreeze`)
|
const { data } = await apiClient.post<ApiResponse<AdminUserItem>>(`/admin/users/${id}/unfreeze`)
|
||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setAdminUserDepositFreeQuota(id: number, amount: number) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<AdminUserItem>>(
|
||||||
|
`/admin/users/${id}/deposit-free-quota`,
|
||||||
|
{ amount }
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
import { arbitrateDispute, fetchAdminDisputes, type Dispute } from '@/features/disputes'
|
import { arbitrateDispute, fetchAdminDisputes, type Dispute } from '@/features/disputes'
|
||||||
import { fetchAdminFileBlob } from '@/shared/api/files'
|
import { fetchAdminFileBlob } from '@/shared/api/files'
|
||||||
@@ -20,6 +20,13 @@ const amount = ref<number | undefined>()
|
|||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const currentPageSize = ref(20)
|
const currentPageSize = ref(20)
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
|
const isPartialRefund = computed(() => result.value === 'partial_refund')
|
||||||
|
const canSubmitArbitration = computed(() => {
|
||||||
|
if (submitting.value || !activeDispute.value) return false
|
||||||
|
if (!result.value || !remark.value.trim()) return false
|
||||||
|
if (isPartialRefund.value && (!amount.value || amount.value <= 0)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(loadDisputes)
|
onMounted(loadDisputes)
|
||||||
|
|
||||||
@@ -79,11 +86,20 @@ async function openEvidence(url: string) {
|
|||||||
|
|
||||||
async function handleArbitrate() {
|
async function handleArbitrate() {
|
||||||
if (!activeDispute.value) return
|
if (!activeDispute.value) return
|
||||||
|
if (submitting.value) return
|
||||||
|
if (!remark.value.trim()) {
|
||||||
|
ElMessage.warning('请填写客服裁决说明')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isPartialRefund.value && (!amount.value || amount.value <= 0)) {
|
||||||
|
ElMessage.warning('请填写部分退款金额')
|
||||||
|
return
|
||||||
|
}
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
await arbitrateDispute(activeDispute.value.id, {
|
await arbitrateDispute(activeDispute.value.id, {
|
||||||
result: result.value,
|
result: result.value,
|
||||||
remark: remark.value,
|
remark: remark.value.trim(),
|
||||||
amount: amount.value,
|
amount: amount.value,
|
||||||
})
|
})
|
||||||
ElMessage.success('仲裁结果已保存,双方已收到通知')
|
ElMessage.success('仲裁结果已保存,双方已收到通知')
|
||||||
@@ -205,8 +221,12 @@ function readError(error: unknown, fallback: string) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="activeDispute = null">取消</el-button>
|
<el-button :disabled="submitting" @click="activeDispute = null">取消</el-button>
|
||||||
<el-button type="primary" :loading="submitting" @click="handleArbitrate"
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="submitting"
|
||||||
|
:disabled="!canSubmitArbitration"
|
||||||
|
@click="handleArbitrate"
|
||||||
>保存裁决</el-button
|
>保存裁决</el-button
|
||||||
>
|
>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ref } from 'vue'
|
|||||||
import {
|
import {
|
||||||
fetchAdminUsers,
|
fetchAdminUsers,
|
||||||
freezeAdminUser,
|
freezeAdminUser,
|
||||||
|
setAdminUserDepositFreeQuota,
|
||||||
unfreezeAdminUser,
|
unfreezeAdminUser,
|
||||||
type AdminUserItem,
|
type AdminUserItem,
|
||||||
} from '@/features/admin/api/adminUsers'
|
} from '@/features/admin/api/adminUsers'
|
||||||
@@ -15,7 +16,9 @@ import AdminTablePagination from '../components/AdminTablePagination.vue'
|
|||||||
|
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const activeUser = ref<AdminUserItem | null>(null)
|
const activeUser = ref<AdminUserItem | null>(null)
|
||||||
|
const quotaUser = ref<AdminUserItem | null>(null)
|
||||||
const freezeReason = ref('')
|
const freezeReason = ref('')
|
||||||
|
const quotaAmount = ref(0)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
loading,
|
loading,
|
||||||
@@ -33,6 +36,26 @@ function openFreeze(row: AdminUserItem) {
|
|||||||
freezeReason.value = ''
|
freezeReason.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openDepositQuota(row: AdminUserItem) {
|
||||||
|
quotaUser.value = row
|
||||||
|
quotaAmount.value = Number(row.deposit_free_quota || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSetDepositQuota() {
|
||||||
|
if (!quotaUser.value) return
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await setAdminUserDepositFreeQuota(quotaUser.value.id, quotaAmount.value)
|
||||||
|
ElMessage.success('免押额度已更新')
|
||||||
|
quotaUser.value = null
|
||||||
|
await loadUsers()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '设置免押额度失败'))
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleFreeze() {
|
async function handleFreeze() {
|
||||||
if (!activeUser.value) return
|
if (!activeUser.value) return
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
@@ -68,6 +91,10 @@ function readError(error: unknown, fallback: string) {
|
|||||||
}
|
}
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function money(value: number | string | undefined) {
|
||||||
|
return Number(value || 0).toFixed(2)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -88,11 +115,21 @@ function readError(error: unknown, fallback: string) {
|
|||||||
<el-table-column label="状态" width="110">
|
<el-table-column label="状态" width="110">
|
||||||
<template #default="{ row }">{{ userStatusLabel(row.status) }}</template>
|
<template #default="{ row }">{{ userStatusLabel(row.status) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="免押额度" width="130">
|
||||||
|
<template #default="{ row }">¥{{ money(row.deposit_free_quota) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="已占用" width="120">
|
||||||
|
<template #default="{ row }">¥{{ money(row.deposit_free_used) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="剩余免押" width="120">
|
||||||
|
<template #default="{ row }">¥{{ money(row.deposit_free_remaining) }}</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="注册时间" min-width="180">
|
<el-table-column label="注册时间" min-width="180">
|
||||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="150">
|
<el-table-column label="操作" width="220">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" @click="openDepositQuota(row)">免押</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="row.status === 'active'"
|
v-if="row.status === 'active'"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -145,5 +182,36 @@ function readError(error: unknown, fallback: string) {
|
|||||||
<el-button type="danger" :loading="submitting" @click="handleFreeze">确认冻结</el-button>
|
<el-button type="danger" :loading="submitting" @click="handleFreeze">确认冻结</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
:model-value="!!quotaUser"
|
||||||
|
title="设置免押额度"
|
||||||
|
width="520px"
|
||||||
|
@update:model-value="quotaUser = null"
|
||||||
|
>
|
||||||
|
<div v-if="quotaUser" class="dialog-body">
|
||||||
|
<p>
|
||||||
|
<strong>{{ quotaUser.phone }}</strong> · {{ quotaUser.nickname }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
已占用 ¥{{ money(quotaUser.deposit_free_used) }},剩余 ¥{{
|
||||||
|
money(quotaUser.deposit_free_remaining)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<el-input-number
|
||||||
|
v-model="quotaAmount"
|
||||||
|
class="full-control"
|
||||||
|
:min="0"
|
||||||
|
:precision="0"
|
||||||
|
:step="100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button :disabled="submitting" @click="quotaUser = null">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="handleSetDepositQuota"
|
||||||
|
>保存额度</el-button
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export interface Order {
|
|||||||
rent_amount?: number
|
rent_amount?: number
|
||||||
owner_rent_amount?: number
|
owner_rent_amount?: number
|
||||||
deposit_amount: number
|
deposit_amount: number
|
||||||
|
deposit_original_amount: number
|
||||||
|
deposit_waived_amount: number
|
||||||
platform_fee?: number
|
platform_fee?: number
|
||||||
account_snapshot?: Record<string, unknown>
|
account_snapshot?: Record<string, unknown>
|
||||||
listing_snapshot?: string
|
listing_snapshot?: string
|
||||||
|
|||||||
@@ -664,6 +664,9 @@ async function copyListingCode() {
|
|||||||
<div class="meta-item">
|
<div class="meta-item">
|
||||||
<span class="meta-label">押金</span>
|
<span class="meta-label">押金</span>
|
||||||
<strong class="meta-value">¥{{ money(order.deposit_amount) }}</strong>
|
<strong class="meta-value">¥{{ money(order.deposit_amount) }}</strong>
|
||||||
|
<span v-if="order.deposit_waived_amount > 0" class="meta-note"
|
||||||
|
>已免押 ¥{{ money(order.deposit_waived_amount) }}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta-item">
|
<div class="meta-item">
|
||||||
<span class="meta-label">交接状态</span>
|
<span class="meta-label">交接状态</span>
|
||||||
@@ -914,7 +917,13 @@ async function copyListingCode() {
|
|||||||
<h3 class="section-title">结账结算账单</h3>
|
<h3 class="section-title">结账结算账单</h3>
|
||||||
<van-cell-group inset :border="false">
|
<van-cell-group inset :border="false">
|
||||||
<van-cell title="实际结算租金" :value="`¥${money(order.checkout.display_amount)}`" />
|
<van-cell title="实际结算租金" :value="`¥${money(order.checkout.display_amount)}`" />
|
||||||
<van-cell title="押金总额" :value="`¥${money(order.checkout.deposit_amount)}`" />
|
<van-cell
|
||||||
|
title="押金总额"
|
||||||
|
:label="
|
||||||
|
order.deposit_waived_amount > 0 ? `已免押 ¥${money(order.deposit_waived_amount)}` : ''
|
||||||
|
"
|
||||||
|
:value="`¥${money(order.checkout.deposit_amount)}`"
|
||||||
|
/>
|
||||||
<van-cell title="额外消耗品已用" :value="`¥${money(order.checkout.consumable_amount)}`" />
|
<van-cell title="额外消耗品已用" :value="`¥${money(order.checkout.consumable_amount)}`" />
|
||||||
<van-cell
|
<van-cell
|
||||||
title="押金赔付扣除"
|
title="押金赔付扣除"
|
||||||
@@ -1348,6 +1357,12 @@ async function copyListingCode() {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meta-note {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
.action-card {
|
.action-card {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,7 +230,12 @@ async function copyListingCode(order: Order) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="price-item">
|
<div class="price-item">
|
||||||
<span class="price-label">押金金额</span>
|
<span class="price-label">押金金额</span>
|
||||||
<span class="price-val deposit">¥{{ money(order.deposit_amount) }}</span>
|
<span class="price-val deposit">
|
||||||
|
¥{{ money(order.deposit_amount) }}
|
||||||
|
<em v-if="order.deposit_waived_amount > 0"
|
||||||
|
>免 ¥{{ money(order.deposit_waived_amount) }}</em
|
||||||
|
>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -500,11 +505,21 @@ async function copyListingCode(order: Order) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.price-val {
|
.price-val {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #ff5f00;
|
color: #ff5f00;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.price-val em {
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
.price-val.deposit {
|
.price-val.deposit {
|
||||||
color: #374151;
|
color: #374151;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -766,6 +766,9 @@ async function copyListingCode() {
|
|||||||
<div class="metric-card">
|
<div class="metric-card">
|
||||||
<span class="metric-label">押金</span>
|
<span class="metric-label">押金</span>
|
||||||
<strong class="metric-value">¥{{ money(order.deposit_amount) }}</strong>
|
<strong class="metric-value">¥{{ money(order.deposit_amount) }}</strong>
|
||||||
|
<span v-if="order.deposit_waived_amount > 0" class="metric-note"
|
||||||
|
>已免押 ¥{{ money(order.deposit_waived_amount) }}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -950,7 +953,12 @@ async function copyListingCode() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<span>预收押金</span>
|
<span>预收押金</span>
|
||||||
<strong>¥{{ money(order.checkout.deposit_amount) }}</strong>
|
<strong>
|
||||||
|
¥{{ money(order.checkout.deposit_amount) }}
|
||||||
|
<em v-if="order.deposit_waived_amount > 0"
|
||||||
|
>已免押 ¥{{ money(order.deposit_waived_amount) }}</em
|
||||||
|
>
|
||||||
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<span>额外消耗品已用</span>
|
<span>额外消耗品已用</span>
|
||||||
@@ -1441,6 +1449,11 @@ async function copyListingCode() {
|
|||||||
color: #ff6a00;
|
color: #ff6a00;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.metric-note {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
.info-section {
|
.info-section {
|
||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
}
|
}
|
||||||
@@ -1772,11 +1785,21 @@ async function copyListingCode() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.summary-row strong {
|
.summary-row strong {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.summary-row strong em {
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
.summary-row strong.amount {
|
.summary-row strong.amount {
|
||||||
color: #10b981;
|
color: #10b981;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
|
|||||||
@@ -260,7 +260,14 @@ function getCountdownMinutes(order: Order) {
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="押金" width="100">
|
<el-table-column label="押金" width="100">
|
||||||
<template #default="{ row }">¥{{ money(row.deposit_amount) }}</template>
|
<template #default="{ row }">
|
||||||
|
<div class="amount-cell">
|
||||||
|
<span class="amount-value">¥{{ money(row.deposit_amount) }}</span>
|
||||||
|
<span v-if="row.deposit_waived_amount > 0" class="amount-label"
|
||||||
|
>免 ¥{{ money(row.deposit_waived_amount) }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="身份" width="80">
|
<el-table-column label="身份" width="80">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -342,7 +349,12 @@ function getCountdownMinutes(order: Order) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
<span class="meta-label">押金</span>
|
<span class="meta-label">押金</span>
|
||||||
<span class="meta-value">¥{{ money(order.deposit_amount) }}</span>
|
<span class="meta-value">
|
||||||
|
¥{{ money(order.deposit_amount) }}
|
||||||
|
<em v-if="order.deposit_waived_amount > 0"
|
||||||
|
>免 ¥{{ money(order.deposit_waived_amount) }}</em
|
||||||
|
>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
<span class="meta-label">创建时间</span>
|
<span class="meta-label">创建时间</span>
|
||||||
@@ -581,11 +593,20 @@ function getCountdownMinutes(order: Order) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.meta-value {
|
.meta-value {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #374151;
|
color: #374151;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meta-value em {
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
.meta-value.amount {
|
.meta-value.amount {
|
||||||
color: #ff6a00;
|
color: #ff6a00;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
Reference in New Issue
Block a user