feat: add balance display, user columns, device mgmt and sessions for agent

- Backend: add balance to agent stats API
- Backend: return full user details (balance, expiry, online_status, device_count, application) in agent users API
- Backend: export admin device/session handlers, add agent routes for devices and sessions
- Backend: add card type permission check and balance deduction for agent user creation
- Backend: extract checkAgentUserPermission helper, add user edit API
- Frontend: add balance card to agent dashboard
- Frontend: add application, online_status, device_count, balance, expiry_at columns to agent user table
- Frontend: add agent devices page (reuse admin data-table component)
- Frontend: add agent sessions page (reuse admin data-table component)
- Frontend: add devices and sessions to agent navigation and routes
- Add i18n keys for agent devices, sessions, user columns, dashboard balance
This commit is contained in:
2026-05-11 10:46:29 +08:00
parent a76a30ee78
commit 853c22fdbe
12 changed files with 848 additions and 32 deletions
+16 -16
View File
@@ -22,22 +22,22 @@ type DeviceWithDetails struct {
func SetupDeviceRoutes(r *gin.RouterGroup) {
devices := r.Group("/devices")
{
devices.GET("", handleGetDevices)
devices.PUT("/:id/status", handleUpdateDeviceStatus)
devices.DELETE("/:id", handleUnbindDevice)
devices.DELETE("/batch", handleBatchUnbindDevices)
devices.POST("/batch/status", handleBatchUpdateDeviceStatus)
devices.POST("/:id/force-offline", handleForceOfflineDevice)
devices.GET("", HandleGetDevices)
devices.PUT("/:id/status", HandleUpdateDeviceStatus)
devices.DELETE("/:id", HandleUnbindDevice)
devices.DELETE("/batch", HandleBatchUnbindDevices)
devices.POST("/batch/status", HandleBatchUpdateDeviceStatus)
devices.POST("/:id/force-offline", HandleForceOfflineDevice)
}
sessions := r.Group("/sessions")
{
sessions.GET("", handleGetSessions)
sessions.DELETE("/:id", handleDeleteSession)
sessions.GET("", HandleGetSessions)
sessions.DELETE("/:id", HandleDeleteSession)
}
}
func handleGetDevices(c *gin.Context) {
func HandleGetDevices(c *gin.Context) {
userID := c.GetUint("user_id")
log.Printf("[DEBUG] handleGetDevices called, userID: %d", userID)
@@ -180,7 +180,7 @@ func handleGetDevices(c *gin.Context) {
})
}
func handleUpdateDeviceStatus(c *gin.Context) {
func HandleUpdateDeviceStatus(c *gin.Context) {
userID := c.GetUint("user_id")
deviceID := c.Param("id")
@@ -221,7 +221,7 @@ func handleUpdateDeviceStatus(c *gin.Context) {
response.Success(c, device)
}
func handleUnbindDevice(c *gin.Context) {
func HandleUnbindDevice(c *gin.Context) {
userID := c.GetUint("user_id")
deviceID := c.Param("id")
@@ -257,7 +257,7 @@ func handleUnbindDevice(c *gin.Context) {
response.Success(c, nil)
}
func handleBatchUnbindDevices(c *gin.Context) {
func HandleBatchUnbindDevices(c *gin.Context) {
userID := c.GetUint("user_id")
var req struct {
DeviceIDs []uint `json:"device_ids"`
@@ -303,7 +303,7 @@ func handleBatchUnbindDevices(c *gin.Context) {
response.Success(c, nil)
}
func handleBatchUpdateDeviceStatus(c *gin.Context) {
func HandleBatchUpdateDeviceStatus(c *gin.Context) {
userID := c.GetUint("user_id")
var req struct {
DeviceIDs []uint `json:"device_ids"`
@@ -356,7 +356,7 @@ type SessionWithDetails struct {
IsOnline bool `json:"is_online"`
}
func handleGetSessions(c *gin.Context) {
func HandleGetSessions(c *gin.Context) {
userID := c.GetUint("user_id")
deviceIDFilter := c.Query("device_id")
appIDFilter := c.Query("app_id")
@@ -478,7 +478,7 @@ func handleGetSessions(c *gin.Context) {
response.Success(c, gin.H{"sessions": sessionsWithDetails, "total": total, "page": page, "page_size": pageSize})
}
func handleDeleteSession(c *gin.Context) {
func HandleDeleteSession(c *gin.Context) {
userID := c.GetUint("user_id")
sessionID := c.Param("id")
@@ -529,7 +529,7 @@ func handleDeleteSession(c *gin.Context) {
response.Success(c, nil)
}
func handleForceOfflineDevice(c *gin.Context) {
func HandleForceOfflineDevice(c *gin.Context) {
userID := c.GetUint("user_id")
deviceID := c.Param("id")
+1 -1
View File
@@ -47,7 +47,7 @@ func SetupUserRoutes(r *gin.RouterGroup) {
appUsers.PUT("/:id", handleUpdateUser)
appUsers.DELETE("/:id", handleDeleteUser)
appUsers.GET("/:id/devices", handleGetUserDevices)
appUsers.DELETE("/:id/devices/:deviceId", handleUnbindDevice)
appUsers.DELETE("/:id/devices/:deviceId", HandleUnbindDevice)
appUsers.PUT("/:id/expiry", handleUpdateExpiry)
appUsers.POST("/batch/status", handleBatchUpdateStatus)
appUsers.DELETE("/batch", handleBatchDelete)
+55 -2
View File
@@ -10,6 +10,7 @@ import (
"time"
"verification-platform-backend/internal/database"
"verification-platform-backend/internal/model"
"verification-platform-backend/internal/router/admin"
"verification-platform-backend/internal/service"
"verification-platform-backend/pkg/response"
"verification-platform-backend/pkg/utils"
@@ -35,6 +36,14 @@ func SetupAgentRoutes(r *gin.RouterGroup) {
r.GET("/cards/export", handleExportCards)
r.GET("/cloud-variables", handleGetCloudVariables)
r.GET("/cloud-variables/:id/records", handleGetCloudVariableRecords)
r.GET("/devices", admin.HandleGetDevices)
r.PUT("/devices/:id/status", admin.HandleUpdateDeviceStatus)
r.DELETE("/devices/:id", admin.HandleUnbindDevice)
r.POST("/devices/:id/force-offline", admin.HandleForceOfflineDevice)
r.GET("/sessions", admin.HandleGetSessions)
r.DELETE("/sessions/:id", admin.HandleDeleteSession)
}
func handleGetStats(c *gin.Context) {
@@ -68,6 +77,9 @@ func handleGetStats(c *gin.Context) {
var todayRevenue float64
database.DB.Model(&model.Card{}).Where("(agent_id = ? OR (agent_id IS NULL AND creator_id = ?)) AND DATE(created_at) = ?", userID, userID, today).Select("COALESCE(SUM(price), 0)").Scan(&todayRevenue)
var agent model.User
database.DB.First(&agent, userID)
response.Success(c, gin.H{
"totalApps": totalApps,
"totalCards": totalCards,
@@ -75,6 +87,7 @@ func handleGetStats(c *gin.Context) {
"totalRevenue": totalRevenue,
"todayCards": todayCards,
"todayRevenue": todayRevenue,
"balance": agent.Balance,
})
}
@@ -482,10 +495,50 @@ func handleGetUsers(c *gin.Context) {
limit = pageSizeInt
}
database.DB.Where("id IN ?", appUserIDs).Order("created_at DESC").Limit(limit).Offset(offset).Find(&users)
database.DB.Preload("Application").Where("id IN ?", appUserIDs).Order("created_at DESC").Limit(limit).Offset(offset).Find(&users)
result := make([]gin.H, 0, len(users))
for _, u := range users {
var deviceCount int64
database.DB.Model(&model.UserDevice{}).Where("user_id = ?", u.ID).Count(&deviceCount)
onlineStatus := "offline"
if u.Status == "banned" {
onlineStatus = "banned"
} else if u.LastHeartbeatAt != nil {
lastHeartbeat := *u.LastHeartbeatAt
if time.Since(lastHeartbeat) < 5*time.Minute {
onlineStatus = "online"
}
}
item := gin.H{
"id": u.ID,
"username": u.Username,
"email": u.Email,
"status": u.Status,
"balance": u.Balance,
"expiry_at": u.ExpiryAt,
"last_login_at": u.LastLoginAt,
"last_heartbeat_at": u.LastHeartbeatAt,
"device_count": deviceCount,
"online_status": onlineStatus,
"application_id": u.ApplicationID,
"is_trial_user": u.IsTrialUser,
"created_at": u.CreatedAt,
}
if u.Application.ID > 0 {
item["application"] = gin.H{
"id": u.Application.ID,
"name": u.Application.Name,
"billing_type": u.Application.BillingType,
}
}
result = append(result, item)
}
response.Success(c, gin.H{
"users": users,
"users": result,
"total": total,
"active_count": activeCount,
"disabled_count": disabledCount,