From b96d6141f5ff8bd027dfb11b0f0a52cccaecbb1f Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 13 Jul 2026 21:30:12 +0800 Subject: [PATCH] =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=AE=A1=E7=90=86=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E4=BD=99=E9=A2=9D=E8=B0=83=E8=B4=A6=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支持后台加减可用余额并写入资金流水与审计,优化操作列按钮样式。 --- backend/internal/modules/adminuser/dto.go | 11 + backend/internal/modules/adminuser/handler.go | 31 ++ .../internal/modules/adminuser/repository.go | 108 ++++- .../modules/adminuser/repository_test.go | 88 ++++ backend/internal/modules/adminuser/service.go | 30 ++ backend/internal/modules/wallet/repository.go | 5 + backend/internal/router/router.go | 1 + .../000026_user_wallet_adjust_permission.sql | 27 ++ frontend/src/features/admin/api/adminUsers.ts | 22 + .../features/admin/views/AdminUsersView.vue | 376 +++++++++++++++--- 10 files changed, 642 insertions(+), 57 deletions(-) create mode 100644 backend/migrations/000026_user_wallet_adjust_permission.sql diff --git a/backend/internal/modules/adminuser/dto.go b/backend/internal/modules/adminuser/dto.go index 148a387..b87ec84 100644 --- a/backend/internal/modules/adminuser/dto.go +++ b/backend/internal/modules/adminuser/dto.go @@ -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 diff --git a/backend/internal/modules/adminuser/handler.go b/backend/internal/modules/adminuser/handler.go index a13a94c..c83befd 100644 --- a/backend/internal/modules/adminuser/handler.go +++ b/backend/internal/modules/adminuser/handler.go @@ -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: diff --git a/backend/internal/modules/adminuser/repository.go b/backend/internal/modules/adminuser/repository.go index 6af9470..0e15e84 100644 --- a/backend/internal/modules/adminuser/repository.go +++ b/backend/internal/modules/adminuser/repository.go @@ -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, diff --git a/backend/internal/modules/adminuser/repository_test.go b/backend/internal/modules/adminuser/repository_test.go index 3d45b00..864ed8f 100644 --- a/backend/internal/modules/adminuser/repository_test.go +++ b/backend/internal/modules/adminuser/repository_test.go @@ -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) } diff --git a/backend/internal/modules/adminuser/service.go b/backend/internal/modules/adminuser/service.go index 7f43a2b..d855674 100644 --- a/backend/internal/modules/adminuser/service.go +++ b/backend/internal/modules/adminuser/service.go @@ -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) diff --git a/backend/internal/modules/wallet/repository.go b/backend/internal/modules/wallet/repository.go index 7b20129..43c4114 100644 --- a/backend/internal/modules/wallet/repository.go +++ b/backend/internal/modules/wallet/repository.go @@ -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, diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 28f296f..3c0a85c 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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) diff --git a/backend/migrations/000026_user_wallet_adjust_permission.sql b/backend/migrations/000026_user_wallet_adjust_permission.sql new file mode 100644 index 0000000..5491ed1 --- /dev/null +++ b/backend/migrations/000026_user_wallet_adjust_permission.sql @@ -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'; diff --git a/frontend/src/features/admin/api/adminUsers.ts b/frontend/src/features/admin/api/adminUsers.ts index 341ee7e..3cba6a5 100644 --- a/frontend/src/features/admin/api/adminUsers.ts +++ b/frontend/src/features/admin/api/adminUsers.ts @@ -13,6 +13,8 @@ export interface AdminUserItem { deposit_free_quota_cent: number deposit_free_used_cent: number deposit_free_remaining_cent: number + available_balance_cent: number + frozen_balance_cent: number status: UserStatus order_count: number listing_count: number @@ -22,6 +24,13 @@ export interface AdminUserItem { updated_at: string } +export interface AdminWalletAdjustPayload { + direction: 'in' | 'out' + amount_cent: number + reason: string + reference_no?: string +} + export interface AdminUserQuery { keyword?: string status?: '' | UserStatus @@ -84,3 +93,16 @@ export async function manualAdminUserRealname( ) return data.data } + +export async function adjustAdminUserWallet(id: number, payload: AdminWalletAdjustPayload) { + const { data } = await apiClient.post>( + `/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 +} diff --git a/frontend/src/features/admin/views/AdminUsersView.vue b/frontend/src/features/admin/views/AdminUsersView.vue index 3bee250..61ac3d3 100644 --- a/frontend/src/features/admin/views/AdminUsersView.vue +++ b/frontend/src/features/admin/views/AdminUsersView.vue @@ -1,10 +1,11 @@