Files
verify/backend/internal/router/admin/devices.go
T
admin 853c22fdbe 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
2026-05-11 10:46:29 +08:00

593 lines
16 KiB
Go

package admin
import (
"fmt"
"log"
"strconv"
"strings"
"time"
"verification-platform-backend/internal/database"
"verification-platform-backend/internal/model"
"verification-platform-backend/internal/service"
"verification-platform-backend/pkg/response"
"github.com/gin-gonic/gin"
)
type DeviceWithDetails struct {
model.UserDevice
OnlineSessions int `json:"online_sessions"`
}
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)
}
sessions := r.Group("/sessions")
{
sessions.GET("", HandleGetSessions)
sessions.DELETE("/:id", HandleDeleteSession)
}
}
func HandleGetDevices(c *gin.Context) {
userID := c.GetUint("user_id")
log.Printf("[DEBUG] handleGetDevices called, userID: %d", userID)
userIDFilter := c.Query("user_id")
deviceIDFilter := c.Query("device_id")
applicationIDFilter := c.Query("application_id")
statusFilter := c.Query("status")
var devices []model.UserDevice
var appHeartbeatTimeoutMap map[uint]int
var ownApps []model.Application
if err := database.DB.Where("user_id = ?", userID).Find(&ownApps).Error; err != nil {
response.Error(c, 500, "获取应用列表失败")
return
}
var agentApps []model.AgentApplication
if err := database.DB.Where("agent_id = ? AND is_received = ?", userID, true).Find(&agentApps).Error; err != nil {
log.Printf("[DEBUG] Failed to get agent apps: %v", err)
}
appHeartbeatTimeoutMap = make(map[uint]int)
var appIDs []uint
for _, app := range ownApps {
timeout := app.HeartbeatTimeout
if timeout == 0 {
timeout = 300
}
appHeartbeatTimeoutMap[app.ID] = timeout
appIDs = append(appIDs, app.ID)
}
for _, agentApp := range agentApps {
var app model.Application
if err := database.DB.First(&app, agentApp.ApplicationID).Error; err != nil {
continue
}
timeout := app.HeartbeatTimeout
if timeout == 0 {
timeout = 300
}
appHeartbeatTimeoutMap[app.ID] = timeout
appIDs = append(appIDs, app.ID)
}
if len(appIDs) == 0 {
response.Success(c, gin.H{"devices": []DeviceWithDetails{}})
return
}
var total int64
countQuery := database.DB.Model(&model.UserDevice{}).Where("application_id IN ?", appIDs)
if userIDFilter != "" {
countQuery = countQuery.Where("user_id = ?", userIDFilter)
}
if deviceIDFilter != "" {
countQuery = countQuery.Where("device_id = ?", deviceIDFilter)
}
if applicationIDFilter != "" {
countQuery = countQuery.Where("application_id = ?", applicationIDFilter)
}
if statusFilter != "" {
countQuery = countQuery.Where("status = ?", statusFilter)
}
countQuery.Count(&total)
var bannedCount int64
database.DB.Model(&model.UserDevice{}).Where("application_id IN ? AND status = ?", appIDs, "banned").Count(&bannedCount)
query := database.DB.Preload("User").Preload("Application").Where("application_id IN ?", appIDs)
if userIDFilter != "" {
query = query.Where("user_id = ?", userIDFilter)
}
if deviceIDFilter != "" {
query = query.Where("device_id = ?", deviceIDFilter)
}
if applicationIDFilter != "" {
query = query.Where("application_id = ?", applicationIDFilter)
}
if statusFilter != "" {
query = query.Where("status = ?", statusFilter)
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
offset := (page - 1) * pageSize
if err := query.Offset(offset).Limit(pageSize).Find(&devices).Error; err != nil {
log.Printf("[ERROR] Failed to get devices: %v", err)
response.Error(c, 500, "获取设备列表失败")
return
}
devicesWithDetails := make([]DeviceWithDetails, 0, len(devices))
deviceIDs := make([]uint, len(devices))
for i, d := range devices {
deviceIDs[i] = d.ID
}
onlineSessionMap := make(map[uint]int)
if len(deviceIDs) > 0 {
type SessionCountResult struct {
DeviceID uint
Count int
}
var sessionCounts []SessionCountResult
database.DB.Model(&model.DeviceSession{}).
Select("device_id, COUNT(*) as count").
Where("device_id IN ? AND last_heartbeat > ?", deviceIDs, time.Now().Add(-time.Duration(300)*time.Second)).
Group("device_id").
Find(&sessionCounts)
for _, sc := range sessionCounts {
onlineSessionMap[sc.DeviceID] = sc.Count
}
}
for _, device := range devices {
_ = appHeartbeatTimeoutMap[device.ApplicationID]
devicesWithDetails = append(devicesWithDetails, DeviceWithDetails{
UserDevice: device,
OnlineSessions: onlineSessionMap[device.ID],
})
}
response.Success(c, gin.H{
"devices": devicesWithDetails,
"total": total,
"page": page,
"page_size": pageSize,
"total_page": (total + int64(pageSize) - 1) / int64(pageSize),
"banned_count": bannedCount,
})
}
func HandleUpdateDeviceStatus(c *gin.Context) {
userID := c.GetUint("user_id")
deviceID := c.Param("id")
var req struct {
Status string `json:"status"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, 400, "参数错误")
return
}
var device model.UserDevice
if err := database.DB.First(&device, deviceID).Error; err != nil {
response.Error(c, 404, "设备不存在")
return
}
var app model.Application
if err := database.DB.First(&app, device.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
}
}
device.Status = req.Status
if err := database.DB.Save(&device).Error; err != nil {
response.Error(c, 500, "更新设备状态失败")
return
}
response.Success(c, device)
}
func HandleUnbindDevice(c *gin.Context) {
userID := c.GetUint("user_id")
deviceID := c.Param("id")
var device model.UserDevice
if err := database.DB.First(&device, deviceID).Error; err != nil {
response.Error(c, 404, "设备不存在")
return
}
var app model.Application
if err := database.DB.First(&app, device.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
}
}
database.DB.Where("device_id = ?", device.ID).Delete(&model.DeviceSession{})
if err := database.DB.Delete(&device).Error; err != nil {
response.Error(c, 500, "解绑设备失败")
return
}
service.LogOperation(c, "unbind", "device", &device.ID, fmt.Sprintf("解绑设备: %s (应用: %s)", device.DeviceID, app.Name), nil)
response.Success(c, nil)
}
func HandleBatchUnbindDevices(c *gin.Context) {
userID := c.GetUint("user_id")
var req struct {
DeviceIDs []uint `json:"device_ids"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, 400, "参数错误")
return
}
var devices []model.UserDevice
if err := database.DB.Where("id IN ?", req.DeviceIDs).Find(&devices).Error; err != nil {
response.Error(c, 500, "获取设备列表失败")
return
}
var validDeviceIDs []uint
for _, device := range devices {
var app model.Application
if err := database.DB.First(&app, device.ApplicationID).Error; err != nil {
continue
}
if app.UserID == userID {
validDeviceIDs = append(validDeviceIDs, device.ID)
} else {
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 {
validDeviceIDs = append(validDeviceIDs, device.ID)
}
}
}
if len(validDeviceIDs) > 0 {
database.DB.Where("device_id IN ?", validDeviceIDs).Delete(&model.DeviceSession{})
if err := database.DB.Where("id IN ?", validDeviceIDs).Delete(&model.UserDevice{}).Error; err != nil {
response.Error(c, 500, "批量解绑失败")
return
}
}
service.LogOperation(c, "batch_unbind", "device", nil, fmt.Sprintf("批量解绑设备: %d个", len(validDeviceIDs)), nil)
response.Success(c, nil)
}
func HandleBatchUpdateDeviceStatus(c *gin.Context) {
userID := c.GetUint("user_id")
var req struct {
DeviceIDs []uint `json:"device_ids"`
Status string `json:"status"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, 400, "参数错误")
return
}
var devices []model.UserDevice
if err := database.DB.Where("id IN ?", req.DeviceIDs).Find(&devices).Error; err != nil {
response.Error(c, 500, "获取设备列表失败")
return
}
var validDeviceIDs []uint
for _, device := range devices {
var app model.Application
if err := database.DB.First(&app, device.ApplicationID).Error; err != nil {
continue
}
if app.UserID == userID {
validDeviceIDs = append(validDeviceIDs, device.ID)
} else {
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 {
validDeviceIDs = append(validDeviceIDs, device.ID)
}
}
}
if len(validDeviceIDs) > 0 {
if err := database.DB.Model(&model.UserDevice{}).Where("id IN ?", validDeviceIDs).Update("status", req.Status).Error; err != nil {
response.Error(c, 500, "批量更新状态失败")
return
}
}
response.Success(c, nil)
}
type SessionWithDetails struct {
model.DeviceSession
DeviceID string `json:"device_identifier"`
DeviceName string `json:"device_name"`
Username string `json:"username"`
AppName string `json:"app_name"`
IsOnline bool `json:"is_online"`
}
func HandleGetSessions(c *gin.Context) {
userID := c.GetUint("user_id")
deviceIDFilter := c.Query("device_id")
appIDFilter := c.Query("app_id")
usernameFilter := c.Query("username")
searchFilter := c.Query("search")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
var ownApps []model.Application
if err := database.DB.Where("user_id = ?", userID).Find(&ownApps).Error; err != nil {
response.Error(c, 500, "获取应用列表失败")
return
}
var agentApps []model.AgentApplication
if err := database.DB.Where("agent_id = ? AND is_received = ?", userID, true).Find(&agentApps).Error; err != nil {
log.Printf("[DEBUG] Failed to get agent apps: %v", err)
}
appHeartbeatTimeoutMap := make(map[uint]int)
var appIDs []uint
for _, app := range ownApps {
timeout := app.HeartbeatTimeout
if timeout == 0 {
timeout = 300
}
appHeartbeatTimeoutMap[app.ID] = timeout
appIDs = append(appIDs, app.ID)
}
for _, agentApp := range agentApps {
var app model.Application
if err := database.DB.First(&app, agentApp.ApplicationID).Error; err != nil {
continue
}
timeout := app.HeartbeatTimeout
if timeout == 0 {
timeout = 300
}
appHeartbeatTimeoutMap[app.ID] = timeout
appIDs = append(appIDs, app.ID)
}
if len(appIDs) == 0 {
response.Success(c, gin.H{"sessions": []SessionWithDetails{}, "total": 0, "page": page, "page_size": pageSize})
return
}
query := database.DB.Model(&model.DeviceSession{}).Where("application_id IN ?", appIDs)
if deviceIDFilter != "" {
query = query.Where("device_id = ?", deviceIDFilter)
}
if appIDFilter != "" {
query = query.Where("application_id = ?", appIDFilter)
}
var total int64
query.Count(&total)
offset := (page - 1) * pageSize
var sessions []model.DeviceSession
if err := query.Offset(offset).Limit(pageSize).Find(&sessions).Error; err != nil {
response.Error(c, 500, "获取会话列表失败")
return
}
sessionsWithDetails := make([]SessionWithDetails, 0, len(sessions))
for _, session := range sessions {
var device model.UserDevice
if err := database.DB.First(&device, session.DeviceID).Error; err != nil {
continue
}
var user model.AppUser
if err := database.DB.First(&user, session.UserID).Error; err != nil {
continue
}
var app model.Application
if err := database.DB.First(&app, session.ApplicationID).Error; err != nil {
continue
}
heartbeatTimeout := appHeartbeatTimeoutMap[session.ApplicationID]
timeoutThreshold := time.Now().Add(-time.Duration(heartbeatTimeout) * time.Second)
isOnline := session.LastHeartbeat != nil && session.LastHeartbeat.After(timeoutThreshold)
if usernameFilter != "" && user.Username != usernameFilter {
continue
}
if searchFilter != "" {
searchLower := strings.ToLower(searchFilter)
instanceIDMatch := strings.Contains(strings.ToLower(session.InstanceID), searchLower)
deviceIDMatch := strings.Contains(strings.ToLower(device.DeviceID), searchLower)
usernameMatch := strings.Contains(strings.ToLower(user.Username), searchLower)
deviceNameMatch := strings.Contains(strings.ToLower(device.DeviceName), searchLower)
if !instanceIDMatch && !deviceIDMatch && !usernameMatch && !deviceNameMatch {
continue
}
}
sessionsWithDetails = append(sessionsWithDetails, SessionWithDetails{
DeviceSession: session,
DeviceID: device.DeviceID,
DeviceName: device.DeviceName,
Username: user.Username,
AppName: app.Name,
IsOnline: isOnline,
})
}
response.Success(c, gin.H{"sessions": sessionsWithDetails, "total": total, "page": page, "page_size": pageSize})
}
func HandleDeleteSession(c *gin.Context) {
userID := c.GetUint("user_id")
sessionID := c.Param("id")
if sessionID == "" {
response.Error(c, 400, "会话ID不能为空")
return
}
var session model.DeviceSession
if err := database.DB.First(&session, sessionID).Error; err != nil {
response.Error(c, 404, "会话不存在")
return
}
var ownApps []model.Application
database.DB.Where("user_id = ?", userID).Find(&ownApps)
ownAppIDs := make([]uint, len(ownApps))
for i, app := range ownApps {
ownAppIDs[i] = app.ID
}
var agentApps []model.AgentApplication
database.DB.Where("agent_id = ? AND is_received = ?", userID, true).Find(&agentApps)
agentAppIDs := make([]uint, 0)
for _, agentApp := range agentApps {
agentAppIDs = append(agentAppIDs, agentApp.ApplicationID)
}
validAppIDs := append(ownAppIDs, agentAppIDs...)
isValid := false
for _, appID := range validAppIDs {
if session.ApplicationID == appID {
isValid = true
break
}
}
if !isValid {
response.Error(c, 403, "无权操作此会话")
return
}
if err := database.DB.Delete(&session).Error; err != nil {
response.Error(c, 500, "删除会话失败")
return
}
response.Success(c, nil)
}
func HandleForceOfflineDevice(c *gin.Context) {
userID := c.GetUint("user_id")
deviceID := c.Param("id")
if deviceID == "" {
response.Error(c, 400, "设备ID不能为空")
return
}
var device model.UserDevice
if err := database.DB.First(&device, deviceID).Error; err != nil {
response.Error(c, 404, "设备不存在")
return
}
var ownApps []model.Application
database.DB.Where("user_id = ?", userID).Find(&ownApps)
ownAppIDs := make([]uint, len(ownApps))
for i, app := range ownApps {
ownAppIDs[i] = app.ID
}
var agentApps []model.AgentApplication
database.DB.Where("agent_id = ? AND is_received = ?", userID, true).Find(&agentApps)
agentAppIDs := make([]uint, 0)
for _, agentApp := range agentApps {
agentAppIDs = append(agentAppIDs, agentApp.ApplicationID)
}
validAppIDs := append(ownAppIDs, agentAppIDs...)
isValid := false
for _, appID := range validAppIDs {
if device.ApplicationID == appID {
isValid = true
break
}
}
if !isValid {
response.Error(c, 403, "无权操作此设备")
return
}
var sessions []model.DeviceSession
if err := database.DB.Where("device_id = ?", device.ID).Find(&sessions).Error; err != nil {
response.Error(c, 500, "获取会话列表失败")
return
}
if len(sessions) == 0 {
response.Success(c, gin.H{"count": 0})
return
}
if err := database.DB.Where("device_id = ?", device.ID).Delete(&model.DeviceSession{}).Error; err != nil {
response.Error(c, 500, "强制离线失败")
return
}
response.Success(c, gin.H{"count": len(sessions)})
}