用户管理增加余额调账功能

支持后台加减可用余额并写入资金流水与审计,优化操作列按钮样式。
This commit is contained in:
yml2213
2026-07-13 21:30:38 +08:00
parent a2a9ade68e
commit b96d6141f5
10 changed files with 642 additions and 57 deletions
+11
View File
@@ -12,6 +12,8 @@ type UserDTO struct {
DepositFreeQuotaCent int64 `json:"deposit_free_quota_cent"` DepositFreeQuotaCent int64 `json:"deposit_free_quota_cent"`
DepositFreeUsedCent int64 `json:"deposit_free_used_cent"` DepositFreeUsedCent int64 `json:"deposit_free_used_cent"`
DepositFreeRemainingCent int64 `json:"deposit_free_remaining_cent"` DepositFreeRemainingCent int64 `json:"deposit_free_remaining_cent"`
AvailableBalanceCent int64 `json:"available_balance_cent"`
FrozenBalanceCent int64 `json:"frozen_balance_cent"`
Status string `json:"status"` Status string `json:"status"`
OrderCount int64 `json:"order_count"` OrderCount int64 `json:"order_count"`
ListingCount int64 `json:"listing_count"` ListingCount int64 `json:"listing_count"`
@@ -38,6 +40,15 @@ type DepositFreeQuotaRequest struct {
AmountCent int64 `json:"amount_cent"` 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(精确)。 // ListQuery 用户列表筛选条件。Keyword 同时匹配手机号/昵称(模糊)与用户 ID(精确)。
type ListQuery struct { type ListQuery struct {
Keyword string Keyword string
@@ -163,6 +163,29 @@ func (h *Handler) ManualRealname(c *gin.Context) {
response.OK(c, item) 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) { func currentAdminID(c *gin.Context) (uint64, bool) {
value, ok := c.Get(middleware.ContextAdminID) value, ok := c.Get(middleware.ContextAdminID)
if !ok { if !ok {
@@ -201,6 +224,14 @@ func writeAdminUserError(c *gin.Context, err error) {
response.Error(c, http.StatusConflict, "realname_already_verified", "该用户已完成实名认证,如需修改请先撤销实名") response.Error(c, http.StatusConflict, "realname_already_verified", "该用户已完成实名认证,如需修改请先撤销实名")
case errors.Is(err, ErrRealnameNotRevocable): case errors.Is(err, ErrRealnameNotRevocable):
response.Error(c, http.StatusConflict, "realname_not_revocable", "该用户当前没有可撤销的实名信息") 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): case IsNotFound(err):
response.Error(c, http.StatusNotFound, "not_found", "用户不存在") response.Error(c, http.StatusNotFound, "not_found", "用户不存在")
default: default:
@@ -3,12 +3,14 @@ package adminuser
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"hfb_sys/backend/internal/auditlog" "hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/wallet"
"hfb_sys/backend/pkg/crypto" "hfb_sys/backend/pkg/crypto"
"gorm.io/gorm" "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(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_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 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_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) listTx = applyUserFilter(listTx, query)
err := listTx. err := listTx.
Order("u.id DESC"). 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(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_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 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_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").
Where("u.id = ?", userID). Where("u.id = ?", userID).
First(&row).Error First(&row).Error
if err != nil { if err != nil {
@@ -284,12 +292,96 @@ func (r *Repository) Find(ctx context.Context, userID uint64) (*UserDTO, error)
return &dto, nil 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 { type userRow struct {
model.User model.User
OrderCount int64 OrderCount int64
ListingCount int64 ListingCount int64
DisputeCount int64 DisputeCount int64
DepositFreeUsedCent int64 DepositFreeUsedCent int64
AvailableBalanceCent int64
FrozenBalanceCent int64
} }
func (row userRow) toDTO() UserDTO { func (row userRow) toDTO() UserDTO {
@@ -307,6 +399,8 @@ func (row userRow) toDTO() UserDTO {
DepositFreeQuotaCent: row.DepositFreeQuotaCent, DepositFreeQuotaCent: row.DepositFreeQuotaCent,
DepositFreeUsedCent: row.DepositFreeUsedCent, DepositFreeUsedCent: row.DepositFreeUsedCent,
DepositFreeRemainingCent: remaining, DepositFreeRemainingCent: remaining,
AvailableBalanceCent: row.AvailableBalanceCent,
FrozenBalanceCent: row.FrozenBalanceCent,
Status: row.Status, Status: row.Status,
OrderCount: row.OrderCount, OrderCount: row.OrderCount,
ListingCount: row.ListingCount, 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 { func setupAdminUserTestDB(t *testing.T) *gorm.DB {
t.Helper() t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
@@ -177,6 +263,8 @@ func setupAdminUserTestDB(t *testing.T) *gorm.DB {
&model.RentalOrder{}, &model.RentalOrder{},
&model.RentalListing{}, &model.RentalListing{},
&model.Dispute{}, &model.Dispute{},
&model.WalletAccount{},
&model.WalletLedger{},
); err != nil { ); err != nil {
t.Fatalf("数据库迁移失败:%v", err) t.Fatalf("数据库迁移失败:%v", err)
} }
@@ -13,8 +13,15 @@ var (
ErrInvalidRealnameInput = errors.New("invalid realname input") ErrInvalidRealnameInput = errors.New("invalid realname input")
ErrRealnameAlreadyVerified = errors.New("realname already verified") ErrRealnameAlreadyVerified = errors.New("realname already verified")
ErrRealnameNotRevocable = errors.New("realname not revocable") 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]$`) var manualRealnameIDPattern = regexp.MustCompile(`^\d{17}[\dXx]$`)
type Service struct { 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) 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 { func validManualRealname(name string, idNo string) bool {
name = strings.TrimSpace(name) name = strings.TrimSpace(name)
idNo = strings.TrimSpace(idNo) idNo = strings.TrimSpace(idNo)
@@ -165,6 +165,11 @@ func AppendEntries(tx *gorm.DB, entries ...Entry) error {
} }
func ensureAccount(tx *gorm.DB, userID uint64) 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{ account := model.WalletAccount{
UserID: userID, UserID: userID,
AvailableBalanceCent: 0, AvailableBalanceCent: 0,
+1
View File
@@ -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/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/manual-realname", requirePerm("user:manual_realname"), adminUserHandler.ManualRealname)
adminRoutes.POST("/users/:id/revoke-realname", requirePerm("user:revoke_realname"), adminUserHandler.RevokeRealname) 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("/orders", requirePerm("order:view"), orderHandler.AdminList)
adminRoutes.GET("/listing-orders/:id/latest", requirePerm("order:view"), orderHandler.AdminLatestByListing) adminRoutes.GET("/listing-orders/:id/latest", requirePerm("order:view"), orderHandler.AdminLatestByListing)
adminRoutes.GET("/orders/:id", requirePerm("order:view"), orderHandler.AdminDetail) adminRoutes.GET("/orders/:id", requirePerm("order:view"), orderHandler.AdminDetail)
@@ -0,0 +1,27 @@
-- +goose Up
-- 新增“调整用户余额”权限:线下打款核销、补偿加款等人工余额管理。
INSERT INTO permissions (code, name, resource, action) VALUES
('user:wallet_adjust', '调整用户余额', 'user', 'wallet_adjust')
ON DUPLICATE KEY UPDATE
name = VALUES(name),
resource = VALUES(resource),
action = VALUES(action);
-- super_admin 拥有全部权限,补授新权限。
INSERT IGNORE INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM roles r, permissions p
WHERE r.code = 'super_admin' AND p.code = 'user:wallet_adjust';
-- 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 = 'user:wallet_adjust';
-- +goose Down
DELETE rp FROM role_permissions rp
JOIN permissions p ON p.id = rp.permission_id
WHERE p.code = 'user:wallet_adjust';
DELETE FROM permissions WHERE code = 'user:wallet_adjust';
@@ -13,6 +13,8 @@ export interface AdminUserItem {
deposit_free_quota_cent: number deposit_free_quota_cent: number
deposit_free_used_cent: number deposit_free_used_cent: number
deposit_free_remaining_cent: number deposit_free_remaining_cent: number
available_balance_cent: number
frozen_balance_cent: number
status: UserStatus status: UserStatus
order_count: number order_count: number
listing_count: number listing_count: number
@@ -22,6 +24,13 @@ export interface AdminUserItem {
updated_at: string updated_at: string
} }
export interface AdminWalletAdjustPayload {
direction: 'in' | 'out'
amount_cent: number
reason: string
reference_no?: string
}
export interface AdminUserQuery { export interface AdminUserQuery {
keyword?: string keyword?: string
status?: '' | UserStatus status?: '' | UserStatus
@@ -84,3 +93,16 @@ export async function manualAdminUserRealname(
) )
return data.data return data.data
} }
export async function adjustAdminUserWallet(id: number, payload: AdminWalletAdjustPayload) {
const { data } = await apiClient.post<ApiResponse<AdminUserItem>>(
`/admin/users/${id}/wallet/adjust`,
{
direction: payload.direction,
amount_cent: payload.amount_cent,
reason: payload.reason,
reference_no: payload.reference_no || undefined,
}
)
return data.data
}
@@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { readError } from '@/shared/utils/error' import { readError } from '@/shared/utils/error'
import { ElMessage } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Search } from '@element-plus/icons-vue' import { Search } from '@element-plus/icons-vue'
import { reactive, ref } from 'vue' import { computed, reactive, ref } from 'vue'
import { import {
adjustAdminUserWallet,
fetchAdminUsers, fetchAdminUsers,
freezeAdminUser, freezeAdminUser,
manualAdminUserRealname, manualAdminUserRealname,
@@ -18,13 +19,18 @@ import { centToYuan, formatCent, yuanToCent } from '@/shared/utils/money'
import { useAdminPaginatedTable } from '@/features/admin/composables/useAdminPaginatedTable' import { useAdminPaginatedTable } from '@/features/admin/composables/useAdminPaginatedTable'
import { realnameStatusLabel, userStatusLabel } from '@/shared/utils/statusLabels' import { realnameStatusLabel, userStatusLabel } from '@/shared/utils/statusLabels'
import { formatDateTime } from '@/shared/utils/time' import { formatDateTime } from '@/shared/utils/time'
import { useAdminSessionStore } from '@/stores/adminSession'
import AdminTablePagination from '../components/AdminTablePagination.vue' import AdminTablePagination from '../components/AdminTablePagination.vue'
const adminSession = useAdminSessionStore()
const canAdjustWallet = computed(() => adminSession.hasPermission('user:wallet_adjust'))
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 quotaUser = ref<AdminUserItem | null>(null)
const realnameUser = ref<AdminUserItem | null>(null) const realnameUser = ref<AdminUserItem | null>(null)
const manualRealnameUser = ref<AdminUserItem | null>(null) const manualRealnameUser = ref<AdminUserItem | null>(null)
const walletUser = ref<AdminUserItem | null>(null)
const freezeReason = ref('') const freezeReason = ref('')
const revokeRealnameReason = ref('') const revokeRealnameReason = ref('')
const quotaAmount = ref(0) const quotaAmount = ref(0)
@@ -33,6 +39,12 @@ const manualRealnameForm = reactive({
idNo: '', idNo: '',
reason: '', reason: '',
}) })
const walletForm = reactive({
direction: 'out' as 'in' | 'out',
amountYuan: 0,
reason: '',
referenceNo: '',
})
const filters = reactive<AdminUserQuery>({ const filters = reactive<AdminUserQuery>({
keyword: '', keyword: '',
@@ -73,6 +85,14 @@ function openDepositQuota(row: AdminUserItem) {
quotaAmount.value = centToYuan(row.deposit_free_quota_cent) quotaAmount.value = centToYuan(row.deposit_free_quota_cent)
} }
function openWalletAdjust(row: AdminUserItem) {
walletUser.value = row
walletForm.direction = 'out'
walletForm.amountYuan = 0
walletForm.reason = ''
walletForm.referenceNo = ''
}
async function handleSetDepositQuota() { async function handleSetDepositQuota() {
if (!quotaUser.value) return if (!quotaUser.value) return
submitting.value = true submitting.value = true
@@ -170,6 +190,63 @@ async function handleRevokeRealname() {
} }
} }
async function handleWalletAdjust() {
if (!walletUser.value) return
const amountYuan = Number(walletForm.amountYuan || 0)
if (!(amountYuan > 0)) {
ElMessage.warning('请输入大于 0 的金额')
return
}
if (amountYuan > 100000) {
ElMessage.warning('单笔调账不能超过 10 万元')
return
}
if (walletForm.reason.trim().length < 2) {
ElMessage.warning('请填写至少 2 个字的调账原因')
return
}
if (
walletForm.direction === 'out' &&
yuanToCent(amountYuan) > Number(walletUser.value.available_balance_cent || 0)
) {
ElMessage.warning('扣款金额不能超过可用余额')
return
}
const actionLabel = walletForm.direction === 'in' ? '加款' : '扣款'
const amountText = `¥${amountYuan.toFixed(1)}`
try {
await ElMessageBox.confirm(
`确认为用户 ${walletUser.value.phone || walletUser.value.id} ${actionLabel} ${amountText}\n操作将写入资金流水与审计日志。`,
`确认${actionLabel}`,
{
type: 'warning',
confirmButtonText: `确认${actionLabel}`,
cancelButtonText: '取消',
}
)
} catch {
return
}
submitting.value = true
try {
await adjustAdminUserWallet(walletUser.value.id, {
direction: walletForm.direction,
amount_cent: yuanToCent(amountYuan),
reason: walletForm.reason.trim(),
reference_no: walletForm.referenceNo.trim() || undefined,
})
ElMessage.success(`${actionLabel}成功`)
walletUser.value = null
await loadUsers()
} catch (error) {
ElMessage.error(readError(error, `${actionLabel}失败`))
} finally {
submitting.value = false
}
}
function moneyCent(value: number | string | undefined) { function moneyCent(value: number | string | undefined) {
return formatCent(Number(value || 0)) return formatCent(Number(value || 0))
} }
@@ -181,7 +258,7 @@ function moneyCent(value: number | string | undefined) {
<div class="page-header"> <div class="page-header">
<p class="eyebrow">Users</p> <p class="eyebrow">Users</p>
<h1>用户管理</h1> <h1>用户管理</h1>
<p>查看用户信息和状态处理冻结与解冻</p> <p>查看用户信息与钱包余额处理冻结实名与余额调账</p>
</div> </div>
<el-button @click="loadUsers">刷新</el-button> <el-button @click="loadUsers">刷新</el-button>
</div> </div>
@@ -227,64 +304,94 @@ function moneyCent(value: number | string | undefined) {
<el-table v-loading="loading" class="table-panel" :data="users" empty-text="未找到匹配的用户"> <el-table v-loading="loading" class="table-panel" :data="users" empty-text="未找到匹配的用户">
<el-table-column prop="id" label="用户ID" width="80" /> <el-table-column prop="id" label="用户ID" width="80" />
<el-table-column prop="nickname" label="昵称" min-width="130" /> <el-table-column prop="nickname" label="昵称" min-width="120" />
<el-table-column prop="phone" label="手机号" min-width="130" /> <el-table-column prop="phone" label="手机号" min-width="120" />
<el-table-column label="状态" width="110"> <el-table-column label="状态" width="90">
<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="110"> <el-table-column label="实名状态" width="100">
<template #default="{ row }">{{ realnameStatusLabel(row.realname_status) }}</template> <template #default="{ row }">{{ realnameStatusLabel(row.realname_status) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="免押额度" width="130"> <el-table-column label="可用余额" width="108" align="right">
<template #default="{ row }">
<span class="balance-available">¥{{ moneyCent(row.available_balance_cent) }}</span>
</template>
</el-table-column>
<el-table-column label="冻结余额" width="108" align="right">
<template #default="{ row }">
<span
class="balance-frozen"
:class="{ 'is-positive': Number(row.frozen_balance_cent || 0) > 0 }"
>
¥{{ moneyCent(row.frozen_balance_cent) }}
</span>
</template>
</el-table-column>
<el-table-column label="免押额度" width="100" align="right">
<template #default="{ row }">¥{{ moneyCent(row.deposit_free_quota_cent) }}</template> <template #default="{ row }">¥{{ moneyCent(row.deposit_free_quota_cent) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="已占用" width="120"> <el-table-column label="剩余免押" width="100" align="right">
<template #default="{ row }">¥{{ moneyCent(row.deposit_free_used_cent) }}</template>
</el-table-column>
<el-table-column label="剩余免押" width="120">
<template #default="{ row }">¥{{ moneyCent(row.deposit_free_remaining_cent) }}</template> <template #default="{ row }">¥{{ moneyCent(row.deposit_free_remaining_cent) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="注册时间" min-width="180"> <el-table-column label="注册时间" min-width="160">
<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="400"> <el-table-column label="操作" width="320" fixed="right" align="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button size="small" @click="openDepositQuota(row)">免押</el-button> <div class="table-actions">
<el-button <el-button
v-if="row.realname_status !== 'verified'" v-if="canAdjustWallet"
size="small" text
type="success" type="primary"
:loading="submitting" size="small"
@click="openManualRealname(row)" @click="openWalletAdjust(row)"
> >
人工实名 余额
</el-button> </el-button>
<el-button <el-button text type="primary" size="small" @click="openDepositQuota(row)">
v-if="row.realname_status !== 'unverified'" 免押额度
size="small" </el-button>
type="warning" <el-button
:loading="submitting" v-if="row.realname_status !== 'verified'"
@click="openRevokeRealname(row)" text
> type="success"
撤销实名 size="small"
</el-button> :loading="submitting"
<el-button @click="openManualRealname(row)"
v-if="row.status === 'active'" >
size="small" 实名
type="danger" </el-button>
:loading="submitting" <el-button
@click="openFreeze(row)" v-if="row.realname_status !== 'unverified'"
> text
冻结 type="warning"
</el-button> size="small"
<el-button :loading="submitting"
v-else @click="openRevokeRealname(row)"
size="small" >
type="primary" 撤销实名
:loading="submitting" </el-button>
@click="handleUnfreeze(row)" <el-button
>解冻</el-button v-if="row.status === 'active'"
> text
type="danger"
size="small"
:loading="submitting"
@click="openFreeze(row)"
>
冻结
</el-button>
<el-button
v-else
text
type="primary"
size="small"
:loading="submitting"
@click="handleUnfreeze(row)"
>
解冻
</el-button>
</div>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -426,6 +533,89 @@ function moneyCent(value: number | string | undefined) {
> >
</template> </template>
</el-dialog> </el-dialog>
<el-dialog
:model-value="!!walletUser"
title="余额管理"
width="560px"
@update:model-value="walletUser = null"
>
<div v-if="walletUser" class="dialog-body wallet-dialog">
<p>
<strong>{{ walletUser.phone }}</strong> · {{ walletUser.nickname || '-' }} · ID
{{ walletUser.id }}
</p>
<div class="wallet-balance-cards">
<div>
<span>可用余额</span>
<strong>¥{{ moneyCent(walletUser.available_balance_cent) }}</strong>
</div>
<div>
<span>冻结余额</span>
<strong>¥{{ moneyCent(walletUser.frozen_balance_cent) }}</strong>
</div>
</div>
<el-alert
v-if="Number(walletUser.frozen_balance_cent || 0) > 0"
type="warning"
:closable="false"
show-icon
title="该用户有冻结余额,通常对应进行中的提现单。冻结金额请到「提现审核」处理;此处仅调整可用余额。"
/>
<el-alert
type="info"
:closable="false"
show-icon
title="扣款用于线下已打款核销;加款用于客服补偿。操作会写入资金流水与审计日志。"
/>
<el-form label-position="top" @submit.prevent>
<el-form-item label="调账类型">
<el-radio-group v-model="walletForm.direction">
<el-radio-button value="out">扣款核销</el-radio-button>
<el-radio-button value="in">加款补偿</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="金额(元)">
<el-input-number
v-model="walletForm.amountYuan"
class="full-control"
:min="0.1"
:max="100000"
:precision="1"
:step="10"
/>
</el-form-item>
<el-form-item label="原因(必填,计入审计)">
<el-input
v-model="walletForm.reason"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="例如:用户无法提现,已线下转账核销可用余额"
/>
</el-form-item>
<el-form-item label="外部单号(可选)">
<el-input
v-model="walletForm.referenceNo"
clearable
maxlength="64"
placeholder="线下转账单号 / 客服工单号"
/>
</el-form-item>
</el-form>
</div>
<template #footer>
<el-button :disabled="submitting" @click="walletUser = null">取消</el-button>
<el-button
:type="walletForm.direction === 'out' ? 'danger' : 'success'"
:loading="submitting"
@click="handleWalletAdjust"
>
确认{{ walletForm.direction === 'out' ? '扣款' : '加款' }}
</el-button>
</template>
</el-dialog>
</section> </section>
</template> </template>
@@ -440,4 +630,90 @@ function moneyCent(value: number | string | undefined) {
.user-filter-bar :deep(.el-form-item) { .user-filter-bar :deep(.el-form-item) {
margin-bottom: 16px; margin-bottom: 16px;
} }
.balance-available {
color: #0f172a;
font-variant-numeric: tabular-nums;
font-weight: 700;
}
.balance-frozen {
color: #94a3b8;
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.balance-frozen.is-positive {
color: #c2410c;
}
.table-actions {
display: inline-flex;
flex-wrap: nowrap;
align-items: center;
justify-content: flex-end;
gap: 2px;
white-space: nowrap;
}
.table-actions :deep(.el-button) {
margin: 0;
height: 28px;
padding: 0 8px;
font-weight: 600;
}
.table-actions :deep(.el-button + .el-button) {
margin-left: 0;
}
.table-actions :deep(.el-button.is-text) {
border-radius: 6px;
}
.table-actions :deep(.el-button.is-text:hover) {
background: #f1f5f9;
}
.wallet-dialog {
display: grid;
gap: 14px;
}
.wallet-balance-cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.wallet-balance-cards > div {
display: grid;
gap: 6px;
padding: 12px 14px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f8fafc;
}
.wallet-balance-cards span {
color: #64748b;
font-size: 13px;
font-weight: 700;
}
.wallet-balance-cards strong {
color: #0f172a;
font-size: 22px;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.full-control {
width: 100%;
}
.dialog-body {
display: grid;
gap: 12px;
}
</style> </style>