用户管理增加余额调账功能
支持后台加减可用余额并写入资金流水与审计,优化操作列按钮样式。
This commit is contained in:
@@ -12,6 +12,8 @@ type UserDTO struct {
|
||||
DepositFreeQuotaCent int64 `json:"deposit_free_quota_cent"`
|
||||
DepositFreeUsedCent int64 `json:"deposit_free_used_cent"`
|
||||
DepositFreeRemainingCent int64 `json:"deposit_free_remaining_cent"`
|
||||
AvailableBalanceCent int64 `json:"available_balance_cent"`
|
||||
FrozenBalanceCent int64 `json:"frozen_balance_cent"`
|
||||
Status string `json:"status"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
ListingCount int64 `json:"listing_count"`
|
||||
@@ -38,6 +40,15 @@ type DepositFreeQuotaRequest struct {
|
||||
AmountCent int64 `json:"amount_cent"`
|
||||
}
|
||||
|
||||
// WalletAdjustRequest 后台人工调整用户可用余额。
|
||||
// Direction: in=加款 out=扣款;仅操作 available,不改 frozen。
|
||||
type WalletAdjustRequest struct {
|
||||
Direction string `json:"direction" binding:"required"`
|
||||
AmountCent int64 `json:"amount_cent" binding:"required"`
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
ReferenceNo string `json:"reference_no"`
|
||||
}
|
||||
|
||||
// ListQuery 用户列表筛选条件。Keyword 同时匹配手机号/昵称(模糊)与用户 ID(精确)。
|
||||
type ListQuery struct {
|
||||
Keyword string
|
||||
|
||||
@@ -163,6 +163,29 @@ func (h *Handler) ManualRealname(c *gin.Context) {
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) AdjustWallet(c *gin.Context) {
|
||||
adminID, ok := currentAdminID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少管理员上下文")
|
||||
return
|
||||
}
|
||||
userID, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req WalletAdjustRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "余额调整参数不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.AdjustWallet(c.Request.Context(), 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 {
|
||||
@@ -201,6 +224,14 @@ func writeAdminUserError(c *gin.Context, err error) {
|
||||
response.Error(c, http.StatusConflict, "realname_already_verified", "该用户已完成实名认证,如需修改请先撤销实名")
|
||||
case errors.Is(err, ErrRealnameNotRevocable):
|
||||
response.Error(c, http.StatusConflict, "realname_not_revocable", "该用户当前没有可撤销的实名信息")
|
||||
case errors.Is(err, ErrInvalidWalletDirection):
|
||||
response.BadRequest(c, "调账方向不正确,仅支持加款(in)或扣款(out)")
|
||||
case errors.Is(err, ErrInvalidWalletAmount):
|
||||
response.BadRequest(c, "调账金额不正确,需大于 0 且不超过 10 万元")
|
||||
case errors.Is(err, ErrInvalidWalletReason):
|
||||
response.BadRequest(c, "请填写 2-200 字的调账原因")
|
||||
case errors.Is(err, ErrInsufficientBalance):
|
||||
response.Error(c, http.StatusConflict, "insufficient_balance", "用户可用余额不足,无法扣款")
|
||||
case IsNotFound(err):
|
||||
response.Error(c, http.StatusNotFound, "not_found", "用户不存在")
|
||||
default:
|
||||
|
||||
@@ -3,12 +3,14 @@ package adminuser
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/auditlog"
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/wallet"
|
||||
"hfb_sys/backend/pkg/crypto"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -43,11 +45,14 @@ func (r *Repository) List(ctx context.Context, page, pageSize int, query ListQue
|
||||
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(df.deposit_free_used_cent, 0) AS deposit_free_used_cent`).
|
||||
COALESCE(df.deposit_free_used_cent, 0) AS deposit_free_used_cent,
|
||||
COALESCE(w.available_balance_cent, 0) AS available_balance_cent,
|
||||
COALESCE(w.frozen_balance_cent, 0) AS frozen_balance_cent`).
|
||||
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_cent) AS deposit_free_used_cent FROM rental_orders WHERE status NOT IN ('completed', 'cancelled', 'closed') GROUP BY renter_id) AS df ON df.user_id = u.id")
|
||||
Joins("LEFT JOIN (SELECT renter_id AS user_id, SUM(deposit_waived_amount_cent) AS deposit_free_used_cent FROM rental_orders WHERE status NOT IN ('completed', 'cancelled', 'closed') GROUP BY renter_id) AS df ON df.user_id = u.id").
|
||||
Joins("LEFT JOIN wallet_accounts AS w ON w.user_id = u.id")
|
||||
listTx = applyUserFilter(listTx, query)
|
||||
err := listTx.
|
||||
Order("u.id DESC").
|
||||
@@ -270,11 +275,14 @@ func (r *Repository) Find(ctx context.Context, userID uint64) (*UserDTO, error)
|
||||
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(df.deposit_free_used_cent, 0) AS deposit_free_used_cent`).
|
||||
COALESCE(df.deposit_free_used_cent, 0) AS deposit_free_used_cent,
|
||||
COALESCE(w.available_balance_cent, 0) AS available_balance_cent,
|
||||
COALESCE(w.frozen_balance_cent, 0) AS frozen_balance_cent`).
|
||||
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_cent) AS deposit_free_used_cent FROM rental_orders WHERE status NOT IN ('completed', 'cancelled', 'closed') GROUP BY renter_id) AS df ON df.user_id = u.id").
|
||||
Joins("LEFT JOIN wallet_accounts AS w ON w.user_id = u.id").
|
||||
Where("u.id = ?", userID).
|
||||
First(&row).Error
|
||||
if err != nil {
|
||||
@@ -284,12 +292,96 @@ func (r *Repository) Find(ctx context.Context, userID uint64) (*UserDTO, error)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
// AdjustWallet 人工调整用户可用余额:写 wallet_ledger + 审计日志,同事务提交。
|
||||
func (r *Repository) AdjustWallet(ctx context.Context, adminID uint64, userID uint64, req WalletAdjustRequest, meta AuditMeta) (*UserDTO, error) {
|
||||
direction := req.Direction
|
||||
amountCent := req.AmountCent
|
||||
reason := req.Reason
|
||||
referenceNo := req.ReferenceNo
|
||||
|
||||
bizType := "admin_credit"
|
||||
remarkPrefix := "人工加款"
|
||||
if direction == "out" {
|
||||
bizType = "admin_debit"
|
||||
remarkPrefix = "人工扣款"
|
||||
}
|
||||
bizNo := fmt.Sprintf("ADJ%d%d", adminID, time.Now().UnixNano())
|
||||
remark := remarkPrefix + ": " + reason
|
||||
if referenceNo != "" {
|
||||
remark = remark + " [" + referenceNo + "]"
|
||||
}
|
||||
if len([]rune(remark)) > 255 {
|
||||
runes := []rune(remark)
|
||||
remark = string(runes[:255])
|
||||
}
|
||||
|
||||
err := r.db.WithContext(ctx).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
|
||||
}
|
||||
|
||||
// 确保钱包账户存在并锁定,读取调账前余额
|
||||
if err := wallet.EnsureAccountTx(tx, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
var account model.WalletAccount
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id = ?", userID).First(&account).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
beforeAvailable := account.AvailableBalanceCent
|
||||
beforeFrozen := account.FrozenBalanceCent
|
||||
|
||||
if err := wallet.AppendEntries(tx, wallet.Entry{
|
||||
UserID: userID,
|
||||
Direction: direction,
|
||||
AmountCent: amountCent,
|
||||
BalanceType: "available",
|
||||
BizType: bizType,
|
||||
BizNo: bizNo,
|
||||
Remark: remark,
|
||||
}); err != nil {
|
||||
if errors.Is(err, wallet.ErrInsufficientBalance) {
|
||||
return ErrInsufficientBalance
|
||||
}
|
||||
if errors.Is(err, wallet.ErrInvalidAmount) {
|
||||
return ErrInvalidWalletAmount
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Where("user_id = ?", userID).First(&account).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return appendAuditLog(tx, adminID, "admin_user.wallet_adjust", user.ID, meta, map[string]any{
|
||||
"user_id": user.ID,
|
||||
"direction": direction,
|
||||
"amount_cent": amountCent,
|
||||
"before_available_cent": beforeAvailable,
|
||||
"after_available_cent": account.AvailableBalanceCent,
|
||||
"before_frozen_cent": beforeFrozen,
|
||||
"after_frozen_cent": account.FrozenBalanceCent,
|
||||
"reason": reason,
|
||||
"reference_no": referenceNo,
|
||||
"biz_type": bizType,
|
||||
"biz_no": bizNo,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Find(ctx, userID)
|
||||
}
|
||||
|
||||
type userRow struct {
|
||||
model.User
|
||||
OrderCount int64
|
||||
ListingCount int64
|
||||
DisputeCount int64
|
||||
DepositFreeUsedCent int64
|
||||
OrderCount int64
|
||||
ListingCount int64
|
||||
DisputeCount int64
|
||||
DepositFreeUsedCent int64
|
||||
AvailableBalanceCent int64
|
||||
FrozenBalanceCent int64
|
||||
}
|
||||
|
||||
func (row userRow) toDTO() UserDTO {
|
||||
@@ -307,6 +399,8 @@ func (row userRow) toDTO() UserDTO {
|
||||
DepositFreeQuotaCent: row.DepositFreeQuotaCent,
|
||||
DepositFreeUsedCent: row.DepositFreeUsedCent,
|
||||
DepositFreeRemainingCent: remaining,
|
||||
AvailableBalanceCent: row.AvailableBalanceCent,
|
||||
FrozenBalanceCent: row.FrozenBalanceCent,
|
||||
Status: row.Status,
|
||||
OrderCount: row.OrderCount,
|
||||
ListingCount: row.ListingCount,
|
||||
|
||||
@@ -164,6 +164,92 @@ func TestManualRealnameRejectsVerifiedUser(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdjustWalletDebitAndAudit(t *testing.T) {
|
||||
db := setupAdminUserTestDB(t)
|
||||
user := model.User{
|
||||
Phone: "13900000003",
|
||||
Nickname: "有余额用户",
|
||||
RealnameStatus: "verified",
|
||||
Status: "active",
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("创建用户失败:%v", err)
|
||||
}
|
||||
if err := db.Create(&model.WalletAccount{
|
||||
UserID: user.ID,
|
||||
AvailableBalanceCent: 15000,
|
||||
FrozenBalanceCent: 3000,
|
||||
Status: "active",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("创建钱包失败:%v", err)
|
||||
}
|
||||
|
||||
repo := NewRepository(db)
|
||||
got, err := repo.AdjustWallet(t.Context(), 88, user.ID, WalletAdjustRequest{
|
||||
Direction: "out",
|
||||
AmountCent: 5000,
|
||||
Reason: "线下已转账核销",
|
||||
ReferenceNo: "OFFLINE-001",
|
||||
}, AuditMeta{IP: "127.0.0.1", UserAgent: "test", RequestID: "req-wallet-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("AdjustWallet() error = %v", err)
|
||||
}
|
||||
if got.AvailableBalanceCent != 10000 {
|
||||
t.Fatalf("可用余额 = %d, want 10000", got.AvailableBalanceCent)
|
||||
}
|
||||
if got.FrozenBalanceCent != 3000 {
|
||||
t.Fatalf("冻结余额不应变化,got %d", got.FrozenBalanceCent)
|
||||
}
|
||||
|
||||
var ledger model.WalletLedger
|
||||
if err := db.Where("user_id = ? AND biz_type = ?", user.ID, "admin_debit").First(&ledger).Error; err != nil {
|
||||
t.Fatalf("查询资金流水失败:%v", err)
|
||||
}
|
||||
if ledger.Direction != "out" || ledger.AmountCent != 5000 || ledger.BalanceType != "available" {
|
||||
t.Fatalf("流水不正确:%+v", ledger)
|
||||
}
|
||||
|
||||
var audit model.AuditLog
|
||||
if err := db.Where("action = ?", "admin_user.wallet_adjust").First(&audit).Error; err != nil {
|
||||
t.Fatalf("查询审计日志失败:%v", err)
|
||||
}
|
||||
if audit.ActorID != 88 || audit.BizID == nil || *audit.BizID != user.ID {
|
||||
t.Fatalf("审计主体不正确:actor=%d biz=%v", audit.ActorID, audit.BizID)
|
||||
}
|
||||
detail := string(audit.Detail)
|
||||
if !strings.Contains(detail, "线下已转账核销") || !strings.Contains(detail, "OFFLINE-001") {
|
||||
t.Fatalf("审计详情缺少原因或外部单号:%s", detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdjustWalletInsufficientBalance(t *testing.T) {
|
||||
db := setupAdminUserTestDB(t)
|
||||
user := model.User{
|
||||
Phone: "13900000004",
|
||||
Status: "active",
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("创建用户失败:%v", err)
|
||||
}
|
||||
if err := db.Create(&model.WalletAccount{
|
||||
UserID: user.ID,
|
||||
AvailableBalanceCent: 100,
|
||||
Status: "active",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("创建钱包失败:%v", err)
|
||||
}
|
||||
|
||||
repo := NewRepository(db)
|
||||
_, err := repo.AdjustWallet(t.Context(), 1, user.ID, WalletAdjustRequest{
|
||||
Direction: "out",
|
||||
AmountCent: 200,
|
||||
Reason: "超额扣款",
|
||||
}, AuditMeta{})
|
||||
if !errors.Is(err, ErrInsufficientBalance) {
|
||||
t.Fatalf("AdjustWallet() error = %v, want ErrInsufficientBalance", err)
|
||||
}
|
||||
}
|
||||
|
||||
func setupAdminUserTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
@@ -177,6 +263,8 @@ func setupAdminUserTestDB(t *testing.T) *gorm.DB {
|
||||
&model.RentalOrder{},
|
||||
&model.RentalListing{},
|
||||
&model.Dispute{},
|
||||
&model.WalletAccount{},
|
||||
&model.WalletLedger{},
|
||||
); err != nil {
|
||||
t.Fatalf("数据库迁移失败:%v", err)
|
||||
}
|
||||
|
||||
@@ -13,8 +13,15 @@ var (
|
||||
ErrInvalidRealnameInput = errors.New("invalid realname input")
|
||||
ErrRealnameAlreadyVerified = errors.New("realname already verified")
|
||||
ErrRealnameNotRevocable = errors.New("realname not revocable")
|
||||
ErrInvalidWalletDirection = errors.New("invalid wallet direction")
|
||||
ErrInvalidWalletAmount = errors.New("invalid wallet amount")
|
||||
ErrInvalidWalletReason = errors.New("invalid wallet reason")
|
||||
ErrInsufficientBalance = errors.New("insufficient balance")
|
||||
)
|
||||
|
||||
// 单笔人工调账上限:10 万元,防止误填。
|
||||
const maxWalletAdjustAmountCent int64 = 10_000_000
|
||||
|
||||
var manualRealnameIDPattern = regexp.MustCompile(`^\d{17}[\dXx]$`)
|
||||
|
||||
type Service struct {
|
||||
@@ -85,6 +92,29 @@ func (s *Service) ManualRealname(ctx context.Context, adminID uint64, userID uin
|
||||
return s.repo.ManualRealname(ctx, adminID, userID, req, meta)
|
||||
}
|
||||
|
||||
func (s *Service) AdjustWallet(ctx context.Context, adminID uint64, userID uint64, req WalletAdjustRequest, meta AuditMeta) (*UserDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
if userID == 0 {
|
||||
return nil, ErrInvalidUser
|
||||
}
|
||||
direction := strings.TrimSpace(strings.ToLower(req.Direction))
|
||||
if direction != "in" && direction != "out" {
|
||||
return nil, ErrInvalidWalletDirection
|
||||
}
|
||||
if req.AmountCent <= 0 || req.AmountCent > maxWalletAdjustAmountCent {
|
||||
return nil, ErrInvalidWalletAmount
|
||||
}
|
||||
if reasonLen := len([]rune(strings.TrimSpace(req.Reason))); reasonLen < 2 || reasonLen > 200 {
|
||||
return nil, ErrInvalidWalletReason
|
||||
}
|
||||
req.Direction = direction
|
||||
req.Reason = strings.TrimSpace(req.Reason)
|
||||
req.ReferenceNo = strings.TrimSpace(req.ReferenceNo)
|
||||
return s.repo.AdjustWallet(ctx, adminID, userID, req, meta)
|
||||
}
|
||||
|
||||
func validManualRealname(name string, idNo string) bool {
|
||||
name = strings.TrimSpace(name)
|
||||
idNo = strings.TrimSpace(idNo)
|
||||
|
||||
@@ -165,6 +165,11 @@ func AppendEntries(tx *gorm.DB, entries ...Entry) error {
|
||||
}
|
||||
|
||||
func ensureAccount(tx *gorm.DB, userID uint64) error {
|
||||
return EnsureAccountTx(tx, userID)
|
||||
}
|
||||
|
||||
// EnsureAccountTx 在事务内确保用户钱包账户存在(幂等)。
|
||||
func EnsureAccountTx(tx *gorm.DB, userID uint64) error {
|
||||
account := model.WalletAccount{
|
||||
UserID: userID,
|
||||
AvailableBalanceCent: 0,
|
||||
|
||||
@@ -531,6 +531,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
adminRoutes.POST("/users/:id/deposit-free-quota", requirePerm("user:deposit_free"), adminUserHandler.SetDepositFreeQuota)
|
||||
adminRoutes.POST("/users/:id/manual-realname", requirePerm("user:manual_realname"), adminUserHandler.ManualRealname)
|
||||
adminRoutes.POST("/users/:id/revoke-realname", requirePerm("user:revoke_realname"), adminUserHandler.RevokeRealname)
|
||||
adminRoutes.POST("/users/:id/wallet/adjust", requirePerm("user:wallet_adjust"), adminUserHandler.AdjustWallet)
|
||||
adminRoutes.GET("/orders", requirePerm("order:view"), orderHandler.AdminList)
|
||||
adminRoutes.GET("/listing-orders/:id/latest", requirePerm("order:view"), orderHandler.AdminLatestByListing)
|
||||
adminRoutes.GET("/orders/:id", requirePerm("order:view"), orderHandler.AdminDetail)
|
||||
|
||||
Reference in New Issue
Block a user