853c22fdbe
- 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
593 lines
16 KiB
Go
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)})
|
|
}
|