diff --git a/backend/internal/router/admin/users.go b/backend/internal/router/admin/users.go index a0261aa..23f92cd 100644 --- a/backend/internal/router/admin/users.go +++ b/backend/internal/router/admin/users.go @@ -49,6 +49,7 @@ func SetupUserRoutes(r *gin.RouterGroup) { appUsers.GET("/:id/devices", handleGetUserDevices) appUsers.DELETE("/:id/devices/:deviceId", HandleUnbindDevice) appUsers.PUT("/:id/expiry", handleUpdateExpiry) + appUsers.POST("/:id/recharge", handleRechargeUser) appUsers.POST("/batch/status", handleBatchUpdateStatus) appUsers.DELETE("/batch", handleBatchDelete) } @@ -885,3 +886,171 @@ func handleBatchDelete(c *gin.Context) { response.Success(c, nil) } + +func handleRechargeUser(c *gin.Context) { + userID := c.GetUint("user_id") + id := c.Param("id") + var req struct { + CardTypeID uint `json:"card_type_id"` + CardQuantity int `json:"card_quantity"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + if req.CardTypeID == 0 { + response.Error(c, 400, "请选择卡密类型") + return + } + + if req.CardQuantity < 1 { + req.CardQuantity = 1 + } + if req.CardQuantity > 100 { + response.Error(c, 400, "卡密数量不能超过100") + return + } + + var appUser model.AppUser + if err := database.DB.First(&appUser, id).Error; err != nil { + response.Error(c, 404, "用户不存在") + return + } + + var app model.Application + if err := database.DB.First(&app, appUser.ApplicationID).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if app.UserID != userID { + var agentApp model.AgentApplication + if err := database.DB.Where("application_id = ? AND agent_id = ? AND is_received = ?", app.ID, userID, true).First(&agentApp).Error; err != nil { + response.Error(c, 403, "无权限为该用户充值") + return + } + } + + if appUser.Balance == -1 { + response.Error(c, 400, "该用户为永久会员,无需充值") + return + } + + var cardType model.CardType + if err := database.DB.Where("id = ? AND application_id = ?", req.CardTypeID, app.ID).First(&cardType).Error; err != nil { + response.Error(c, 400, "卡密类型不存在或不属于该应用") + return + } + + tx := database.DB.Begin() + + now := time.Now() + var cards []model.Card + + for i := 0; i < req.CardQuantity; i++ { + cardKey := "CK" + utils.GenerateRandomString(16) + card := model.Card{ + ApplicationID: app.ID, + CardTypeID: cardType.ID, + CardKey: cardKey, + CreatorID: userID, + AppUserID: &appUser.ID, + Status: "used", + } + card.UsedAt = &now + + if err := tx.Create(&card).Error; err != nil { + tx.Rollback() + response.Error(c, 500, "生成卡密失败") + return + } + + cards = append(cards, card) + } + + appUser.IsTrialUser = false + + if cardType.Value == -1 { + if cardType.RechargeType == "subscription" { + permanentExpiry := time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC) + appUser.ExpiryAt = &permanentExpiry + appUser.Balance = -1 + } else { + appUser.Balance = -1 + appUser.ExpiryAt = nil + } + } else { + switch cardType.RechargeType { + case "subscription": + var baseTime time.Time + if appUser.ExpiryAt != nil && appUser.ExpiryAt.After(now) { + baseTime = *appUser.ExpiryAt + } else { + baseTime = now + } + var duration time.Duration + switch cardType.ValueUnit { + case "minute": + duration = time.Duration(cardType.Value) * time.Minute + case "hour": + duration = time.Duration(cardType.Value) * time.Hour + case "day": + duration = time.Duration(cardType.Value) * 24 * time.Hour + case "month": + duration = time.Duration(cardType.Value) * 30 * 24 * time.Hour + case "year": + duration = time.Duration(cardType.Value) * 365 * 24 * time.Hour + default: + duration = time.Duration(cardType.Value) * time.Second + } + for i := 0; i < req.CardQuantity; i++ { + newExpiry := baseTime.Add(duration) + baseTime = newExpiry + } + appUser.ExpiryAt = &baseTime + case "balance": + appUser.Balance += cardType.Value * float64(req.CardQuantity) + default: + appUser.Balance += cardType.Value * float64(req.CardQuantity) + } + } + + for _, card := range cards { + rechargeRecord := model.RechargeRecord{ + UserID: appUser.ID, + OrderNo: generateUserOrderNo("R"), + CardID: &card.ID, + CardCode: card.CardKey, + Amount: cardType.Price, + Status: "success", + PaymentType: "card", + Remark: fmt.Sprintf("管理员充值 - %s x1", cardType.Name), + } + + if err := tx.Create(&rechargeRecord).Error; err != nil { + tx.Rollback() + response.Error(c, 500, "创建充值记录失败") + return + } + } + + if err := tx.Save(&appUser).Error; err != nil { + tx.Rollback() + response.Error(c, 500, "充值失败") + return + } + + if err := tx.Commit().Error; err != nil { + response.Error(c, 500, "充值失败") + return + } + + service.LogOperation(c, "update", "app_user", &appUser.ID, fmt.Sprintf("为用户充值: %s, 卡密类型: %s x%d", appUser.Username, cardType.Name, req.CardQuantity), nil) + + response.Success(c, gin.H{ + "user": appUser, + "cards": cards, + "card_type": cardType, + }) +} diff --git a/frontend/src/pages/admin/users/[id]/recharge.vue b/frontend/src/pages/admin/users/[id]/recharge.vue new file mode 100644 index 0000000..311fe95 --- /dev/null +++ b/frontend/src/pages/admin/users/[id]/recharge.vue @@ -0,0 +1,327 @@ + + + diff --git a/frontend/src/pages/admin/users/components/columns.ts b/frontend/src/pages/admin/users/components/columns.ts index 6e24571..238ac7d 100644 --- a/frontend/src/pages/admin/users/components/columns.ts +++ b/frontend/src/pages/admin/users/components/columns.ts @@ -1,7 +1,7 @@ import type { ColumnDef } from '@tanstack/vue-table' import type { Composer } from 'vue-i18n' -import { Ban, Monitor, MoreHorizontal, Pencil, Trash2 } from 'lucide-vue-next' +import { Ban, CreditCard, Monitor, MoreHorizontal, Pencil, Trash2 } from 'lucide-vue-next' import { h } from 'vue' import type { User } from '@/pages/admin/users/data/schema' @@ -22,6 +22,7 @@ export function getColumns(actions: { onToggleStatus: (row: User) => void onDelete: (row: User) => void onManageDevices: (row: User) => void + onRecharge: (row: User) => void }, t: Composer['t']): ColumnDef[] { return [ { @@ -203,6 +204,10 @@ export function getColumns(actions: { h(Pencil, { class: 'mr-2 h-4 w-4' }), t('common.edit'), ]), + h(DropdownMenuItem, { onClick: () => actions.onRecharge(user) }, () => [ + h(CreditCard, { class: 'mr-2 h-4 w-4' }), + t('common.recharge'), + ]), h(DropdownMenuItem, { onClick: () => actions.onManageDevices(user) }, () => [ h(Monitor, { class: 'mr-2 h-4 w-4' }), t('admin.users.devices'), diff --git a/frontend/src/pages/admin/users/components/data-table.vue b/frontend/src/pages/admin/users/components/data-table.vue index 3b5e37b..d610d36 100644 --- a/frontend/src/pages/admin/users/components/data-table.vue +++ b/frontend/src/pages/admin/users/components/data-table.vue @@ -20,6 +20,7 @@ const props = defineProps, 'columns'> & { onToggleStatus: (row: User) => void onDelete: (row: User) => void onManageDevices: (row: User) => void + onRecharge: (row: User) => void }>() const emit = defineEmits<{ @@ -38,6 +39,7 @@ const columns = computed(() => [ onToggleStatus: props.onToggleStatus, onDelete: props.onDelete, onManageDevices: props.onManageDevices, + onRecharge: props.onRecharge, }, t), ]) diff --git a/frontend/src/pages/admin/users/index.vue b/frontend/src/pages/admin/users/index.vue index 8ecec37..591b5f1 100644 --- a/frontend/src/pages/admin/users/index.vue +++ b/frontend/src/pages/admin/users/index.vue @@ -138,14 +138,14 @@ async function fetchUsers() { const params = new URLSearchParams() params.append('page', String(currentPage.value)) params.append('page_size', String(pageSize.value)) - + if (appFilter.value) { params.append('application_id', appFilter.value) } if (accountStatusFilter.value) { params.append('status', accountStatusFilter.value) } - + const data = await api.get(`/dev/app-users?${params.toString()}`) users.value = Array.isArray(data?.users) ? data.users : [] total.value = data?.total || 0 @@ -174,6 +174,10 @@ function goToUserDevices(user: User) { router.push(`/admin/devices?user_id=${user.id}`) } +function goToRecharge(user: User) { + router.push(`/admin/users/${user.id}/recharge`) +} + async function toggleUserStatus(user: User) { const newStatus = user.status === 'banned' ? 'active' : 'banned' try { @@ -347,6 +351,7 @@ watch([appFilter, statusFilter, accountStatusFilter, lastLoginStartDate, lastLog :on-toggle-status="toggleUserStatus" :on-delete="confirmDeleteUser" :on-manage-devices="goToUserDevices" + :on-recharge="goToRecharge" @refresh="fetchUsers" @batch-unban="batchToggleStatus(tableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status === 'banned').map((r: any) => r.original.id) || [], 'active')" @batch-ban="batchToggleStatus(tableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status !== 'banned').map((r: any) => r.original.id) || [], 'banned')" diff --git a/frontend/src/plugins/i18n/zh.json b/frontend/src/plugins/i18n/zh.json index 5ee87aa..7818940 100644 --- a/frontend/src/plugins/i18n/zh.json +++ b/frontend/src/plugins/i18n/zh.json @@ -438,7 +438,25 @@ "expired": "已过期", "daysLeft": "天剩余" }, - "devices": "设备管理" + "devices": "设备管理", + "recharge": { + "title": "用户充值", + "pageDesc": "为用户使用卡密充值", + "description": "为用户「{username}」使用卡密充值", + "userInfo": "用户信息", + "userInfoDesc": "当前用户的基本信息", + "selectCard": "选择卡密", + "selectCardDesc": "选择卡密类型和数量", + "cardType": "卡密类型", + "selectCardType": "选择卡密类型", + "noCardTypes": "该应用暂无卡密类型", + "cardTypeRequired": "请选择卡密类型", + "cardQuantity": "充值数量", + "cardValue": "充值内容", + "confirm": "确认充值", + "success": "充值成功", + "failed": "充值失败" + } }, "orders": { "title": "订单管理", diff --git a/frontend/src/router/routes.ts b/frontend/src/router/routes.ts index 8fc2c6e..c474527 100644 --- a/frontend/src/router/routes.ts +++ b/frontend/src/router/routes.ts @@ -155,6 +155,12 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/pages/admin/users/[id].vue'), meta: { title: '编辑用户 - 管理后台' }, }, + { + path: 'users/:id/recharge', + name: 'AdminUserRecharge', + component: () => import('@/pages/admin/users/[id]/recharge.vue'), + meta: { title: '用户充值 - 管理后台' }, + }, { path: 'devices', name: 'AdminDevices', diff --git a/frontend/src/types/route-map.d.ts b/frontend/src/types/route-map.d.ts index 1661a5a..b10dd04 100644 --- a/frontend/src/types/route-map.d.ts +++ b/frontend/src/types/route-map.d.ts @@ -453,6 +453,13 @@ declare module 'vue-router/auto-routes' { '/admin/users/:id', { id: ParamValue }, { id: ParamValue }, + | '/admin/users/[id]/recharge' + >, + '/admin/users/[id]/recharge': RouteRecordInfo< + '/admin/users/[id]/recharge', + '/admin/users/:id/recharge', + { id: ParamValue }, + { id: ParamValue }, | never >, '/admin/users/create': RouteRecordInfo< @@ -1036,6 +1043,13 @@ declare module 'vue-router/auto-routes' { 'src/pages/admin/users/[id].vue': { routes: | '/admin/users/[id]' + | '/admin/users/[id]/recharge' + views: + | 'default' + } + 'src/pages/admin/users/[id]/recharge.vue': { + routes: + | '/admin/users/[id]/recharge' views: | never }