From 50dba656fd8bc583a6b125906889f5eebdce2b81 Mon Sep 17 00:00:00 2001 From: dexcoder6 Date: Tue, 23 Dec 2025 16:29:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E4=BD=99=E9=A2=9D=E5=85=85=E5=80=BC/=E9=80=80=E6=AC=BE?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=20(#17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 功能特性 ### 前端 - 在用户列表操作列添加充值和退款按钮 - 实现充值/退款对话框,支持输入金额和备注 - 从编辑用户表单中移除余额字段,防止直接修改 - 添加余额不足验证,实时显示操作后余额 - 优化备注提示词,提供多种场景示例 ### 后端 - 为 redeem_codes 表添加 notes 字段(迁移文件) - 在 UpdateUserBalance 接口添加 notes 参数支持 - 添加余额验证:金额必须大于0,操作后余额不能为负 - UpdateUser 接口移除 balance 字段处理,防止误操作 - 完整的审计日志和缓存管理 ## 安全保护 - 前端:余额不足时禁用提交按钮,实时提示 - 后端:双重验证(输入金额 > 0 + 结果余额 >= 0) - 权限:仅管理员可访问(AdminAuth 中间件) - 审计:所有操作记录到 redeem_codes 表 ## 修改文件 后端: - backend/migrations/004_add_redeem_code_notes.sql - backend/internal/model/redeem_code.go - backend/internal/service/admin_service.go - backend/internal/handler/admin/user_handler.go 前端: - frontend/src/views/admin/UsersView.vue - frontend/src/api/admin/users.ts - frontend/src/i18n/locales/zh.ts - frontend/src/i18n/locales/en.ts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Sonnet 4.5 --- .../internal/handler/admin/user_handler.go | 5 +- backend/internal/model/redeem_code.go | 1 + backend/internal/service/admin_service.go | 81 +++---- .../migrations/004_add_redeem_code_notes.sql | 6 + frontend/src/api/admin/users.ts | 5 +- frontend/src/i18n/locales/en.ts | 20 ++ frontend/src/i18n/locales/zh.ts | 19 ++ frontend/src/views/admin/UsersView.vue | 228 ++++++++++++++++-- 8 files changed, 292 insertions(+), 73 deletions(-) create mode 100644 backend/migrations/004_add_redeem_code_notes.sql diff --git a/backend/internal/handler/admin/user_handler.go b/backend/internal/handler/admin/user_handler.go index f3b8dbd7..5d431cc8 100644 --- a/backend/internal/handler/admin/user_handler.go +++ b/backend/internal/handler/admin/user_handler.go @@ -49,8 +49,9 @@ type UpdateUserRequest struct { // UpdateBalanceRequest represents balance update request type UpdateBalanceRequest struct { - Balance float64 `json:"balance" binding:"required"` + Balance float64 `json:"balance" binding:"required,gt=0"` Operation string `json:"operation" binding:"required,oneof=set add subtract"` + Notes string `json:"notes"` } // List handles listing all users with pagination @@ -183,7 +184,7 @@ func (h *UserHandler) UpdateBalance(c *gin.Context) { return } - user, err := h.adminService.UpdateUserBalance(c.Request.Context(), userID, req.Balance, req.Operation) + user, err := h.adminService.UpdateUserBalance(c.Request.Context(), userID, req.Balance, req.Operation, req.Notes) if err != nil { response.InternalError(c, "Failed to update balance: "+err.Error()) return diff --git a/backend/internal/model/redeem_code.go b/backend/internal/model/redeem_code.go index 602fcbe8..6857c410 100644 --- a/backend/internal/model/redeem_code.go +++ b/backend/internal/model/redeem_code.go @@ -14,6 +14,7 @@ type RedeemCode struct { Status string `gorm:"size:20;default:unused;not null" json:"status"` // unused/used UsedBy *int64 `gorm:"index" json:"used_by"` UsedAt *time.Time `json:"used_at"` + Notes string `gorm:"type:text" json:"notes"` CreatedAt time.Time `gorm:"not null" json:"created_at"` // 订阅类型专用字段 diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 71ee3f1b..51d1ddb8 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -22,7 +22,7 @@ type AdminService interface { CreateUser(ctx context.Context, input *CreateUserInput) (*model.User, error) UpdateUser(ctx context.Context, id int64, input *UpdateUserInput) (*model.User, error) DeleteUser(ctx context.Context, id int64) error - UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string) (*model.User, error) + UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*model.User, error) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int) ([]model.ApiKey, int64, error) GetUserUsageStats(ctx context.Context, userID int64, period string) (any, error) @@ -271,8 +271,6 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda return nil, errors.New("cannot disable admin user") } - // Track balance and concurrency changes for logging - oldBalance := user.Balance oldConcurrency := user.Concurrency if input.Email != "" { @@ -284,7 +282,6 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda } } - // 更新用户字段 if input.Username != nil { user.Username = *input.Username } @@ -295,22 +292,14 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda user.Notes = *input.Notes } - // Role is not allowed to be changed via API to prevent privilege escalation if input.Status != "" { user.Status = input.Status } - // 只在指针非 nil 时更新 Balance(支持设置为 0) - if input.Balance != nil { - user.Balance = *input.Balance - } - - // 只在指针非 nil 时更新 Concurrency(支持设置为任意值) if input.Concurrency != nil { user.Concurrency = *input.Concurrency } - // 只在指针非 nil 时更新 AllowedGroups if input.AllowedGroups != nil { user.AllowedGroups = *input.AllowedGroups } @@ -319,41 +308,6 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda return nil, err } - // 余额变化时失效缓存 - if input.Balance != nil && *input.Balance != oldBalance { - if s.billingCacheService != nil { - go func() { - cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if err := s.billingCacheService.InvalidateUserBalance(cacheCtx, id); err != nil { - log.Printf("invalidate user balance cache failed: user_id=%d err=%v", id, err) - } - }() - } - } - - // Create adjustment records for balance/concurrency changes - balanceDiff := user.Balance - oldBalance - if balanceDiff != 0 { - code, err := model.GenerateRedeemCode() - if err != nil { - log.Printf("failed to generate adjustment redeem code: %v", err) - return user, nil - } - adjustmentRecord := &model.RedeemCode{ - Code: code, - Type: model.AdjustmentTypeAdminBalance, - Value: balanceDiff, - Status: model.StatusUsed, - UsedBy: &user.ID, - } - now := time.Now() - adjustmentRecord.UsedAt = &now - if err := s.redeemCodeRepo.Create(ctx, adjustmentRecord); err != nil { - log.Printf("failed to create balance adjustment redeem code: %v", err) - } - } - concurrencyDiff := user.Concurrency - oldConcurrency if concurrencyDiff != 0 { code, err := model.GenerateRedeemCode() @@ -390,12 +344,14 @@ func (s *adminServiceImpl) DeleteUser(ctx context.Context, id int64) error { return s.userRepo.Delete(ctx, id) } -func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string) (*model.User, error) { +func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*model.User, error) { user, err := s.userRepo.GetByID(ctx, userID) if err != nil { return nil, err } + oldBalance := user.Balance + switch operation { case "set": user.Balance = balance @@ -405,11 +361,14 @@ func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64, user.Balance -= balance } + if user.Balance < 0 { + return nil, fmt.Errorf("balance cannot be negative, current balance: %.2f, requested operation would result in: %.2f", oldBalance, user.Balance) + } + if err := s.userRepo.Update(ctx, user); err != nil { return nil, err } - // 失效余额缓存 if s.billingCacheService != nil { go func() { cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -420,6 +379,30 @@ func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64, }() } + balanceDiff := user.Balance - oldBalance + if balanceDiff != 0 { + code, err := model.GenerateRedeemCode() + if err != nil { + log.Printf("failed to generate adjustment redeem code: %v", err) + return user, nil + } + + adjustmentRecord := &model.RedeemCode{ + Code: code, + Type: model.AdjustmentTypeAdminBalance, + Value: balanceDiff, + Status: model.StatusUsed, + UsedBy: &user.ID, + Notes: notes, + } + now := time.Now() + adjustmentRecord.UsedAt = &now + + if err := s.redeemCodeRepo.Create(ctx, adjustmentRecord); err != nil { + log.Printf("failed to create balance adjustment redeem code: %v", err) + } + } + return user, nil } diff --git a/backend/migrations/004_add_redeem_code_notes.sql b/backend/migrations/004_add_redeem_code_notes.sql new file mode 100644 index 00000000..eeb37b10 --- /dev/null +++ b/backend/migrations/004_add_redeem_code_notes.sql @@ -0,0 +1,6 @@ +-- 为 redeem_codes 表添加备注字段 + +ALTER TABLE redeem_codes +ADD COLUMN IF NOT EXISTS notes TEXT DEFAULT NULL; + +COMMENT ON COLUMN redeem_codes.notes IS '备注说明(管理员调整时的原因说明)'; diff --git a/frontend/src/api/admin/users.ts b/frontend/src/api/admin/users.ts index 8eb11dc8..8aa81775 100644 --- a/frontend/src/api/admin/users.ts +++ b/frontend/src/api/admin/users.ts @@ -84,16 +84,19 @@ export async function deleteUser(id: number): Promise<{ message: string }> { * @param id - User ID * @param balance - New balance * @param operation - Operation type ('set', 'add', 'subtract') + * @param notes - Optional notes for the balance adjustment * @returns Updated user */ export async function updateBalance( id: number, balance: number, - operation: 'set' | 'add' | 'subtract' = 'set' + operation: 'set' | 'add' | 'subtract' = 'set', + notes?: string ): Promise { const { data } = await apiClient.post(`/admin/users/${id}/balance`, { balance, operation, + notes: notes || '', }); return data; } diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index a50d7067..9189234e 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -520,6 +520,26 @@ export default { allowedGroupsUpdated: 'Allowed groups updated successfully', failedToLoadGroups: 'Failed to load groups', failedToUpdateAllowedGroups: 'Failed to update allowed groups', + deposit: 'Deposit', + withdraw: 'Withdraw', + depositAmount: 'Deposit Amount', + withdrawAmount: 'Withdraw Amount', + currentBalance: 'Current Balance', + depositNotesPlaceholder: 'e.g., New user registration bonus, promotional credit, compensation, etc.', + withdrawNotesPlaceholder: 'e.g., Service issue refund, incorrect charge reversal, account closure refund, etc.', + notesOptional: 'Notes are optional but helpful for record keeping', + amountHint: 'Please enter a positive amount', + newBalance: 'New Balance', + depositing: 'Depositing...', + withdrawing: 'Withdrawing...', + confirmDeposit: 'Confirm Deposit', + confirmWithdraw: 'Confirm Withdraw', + depositSuccess: 'Deposit successful', + withdrawSuccess: 'Withdraw successful', + failedToDeposit: 'Failed to deposit', + failedToWithdraw: 'Failed to withdraw', + useDepositWithdrawButtons: 'Please use deposit/withdraw buttons to adjust balance', + insufficientBalance: 'Insufficient balance, balance cannot be negative after withdrawal', }, // Groups diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 4b0b0ac7..c4d92501 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -587,6 +587,25 @@ export default { allowedGroupsUpdated: '允许分组更新成功', failedToLoadGroups: '加载分组列表失败', failedToUpdateAllowedGroups: '更新允许分组失败', + deposit: '充值', + withdraw: '退款', + depositAmount: '充值金额', + withdrawAmount: '退款金额', + depositNotesPlaceholder: '例如:新用户注册奖励、活动充值、补偿充值等', + withdrawNotesPlaceholder: '例如:服务问题退款、错误充值退回、账户注销退款等', + notesOptional: '备注为可选项,有助于未来查账', + amountHint: '请输入正数金额', + newBalance: '操作后余额', + depositing: '充值中...', + withdrawing: '退款中...', + confirmDeposit: '确认充值', + confirmWithdraw: '确认退款', + depositSuccess: '充值成功', + withdrawSuccess: '退款成功', + failedToDeposit: '充值失败', + failedToWithdraw: '退款失败', + useDepositWithdrawButtons: '请使用充值/退款按钮调整余额', + insufficientBalance: '余额不足,退款后余额不能为负数', }, // Groups Management diff --git a/frontend/src/views/admin/UsersView.vue b/frontend/src/views/admin/UsersView.vue index b838c4aa..7340a0cc 100644 --- a/frontend/src/views/admin/UsersView.vue +++ b/frontend/src/views/admin/UsersView.vue @@ -207,6 +207,26 @@ + + + + + + + + + ([]) const loadingGroups = ref(false) const savingAllowedGroups = ref(false) +// Balance (Deposit/Withdraw) modal state +const showBalanceModal = ref(false) +const balanceUser = ref(null) +const balanceOperation = ref<'add' | 'subtract'>('add') +const balanceSubmitting = ref(false) +const balanceForm = reactive({ + amount: 0, + notes: '' +}) + const createForm = reactive({ email: '', password: '', @@ -844,7 +971,6 @@ const editForm = reactive({ username: '', wechat: '', notes: '', - balance: 0, concurrency: 1 }) const editPasswordCopied = ref(false) @@ -997,7 +1123,6 @@ const handleEdit = (user: User) => { editForm.username = user.username || '' editForm.wechat = user.wechat || '' editForm.notes = user.notes || '' - editForm.balance = user.balance editForm.concurrency = user.concurrency editPasswordCopied.value = false showEditModal.value = true @@ -1015,13 +1140,11 @@ const handleUpdateUser = async () => { submitting.value = true try { - // Build update data - only include password if not empty const updateData: Record = { email: editForm.email, username: editForm.username, wechat: editForm.wechat, notes: editForm.notes, - balance: editForm.balance, concurrency: editForm.concurrency } if (editForm.password.trim()) { @@ -1141,6 +1264,69 @@ const confirmDelete = async () => { } } +const handleDeposit = (user: User) => { + balanceUser.value = user + balanceOperation.value = 'add' + balanceForm.amount = 0 + balanceForm.notes = '' + showBalanceModal.value = true +} + +const handleWithdraw = (user: User) => { + balanceUser.value = user + balanceOperation.value = 'subtract' + balanceForm.amount = 0 + balanceForm.notes = '' + showBalanceModal.value = true +} + +const closeBalanceModal = () => { + showBalanceModal.value = false + balanceUser.value = null + balanceForm.amount = 0 + balanceForm.notes = '' +} + +const calculateNewBalance = () => { + if (!balanceUser.value) return 0 + if (balanceOperation.value === 'add') { + return balanceUser.value.balance + balanceForm.amount + } else { + return balanceUser.value.balance - balanceForm.amount + } +} + +const handleBalanceSubmit = async () => { + if (!balanceUser.value || balanceForm.amount <= 0) return + + balanceSubmitting.value = true + try { + await adminAPI.users.updateBalance( + balanceUser.value.id, + balanceForm.amount, + balanceOperation.value, + balanceForm.notes + ) + + const successMsg = balanceOperation.value === 'add' + ? t('admin.users.depositSuccess') + : t('admin.users.withdrawSuccess') + + appStore.showSuccess(successMsg) + closeBalanceModal() + loadUsers() + } catch (error: any) { + const errorMsg = balanceOperation.value === 'add' + ? t('admin.users.failedToDeposit') + : t('admin.users.failedToWithdraw') + + appStore.showError(error.response?.data?.detail || errorMsg) + console.error('Error updating balance:', error) + } finally { + balanceSubmitting.value = false + } +} + onMounted(() => { loadUsers() })