基本功能已初步完善
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
"xboard-go/internal/service"
|
||||
"xboard-go/pkg/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -16,7 +17,7 @@ func PluginUserOnlineDevicesUsers(c *gin.Context) {
|
||||
perPage := parsePositiveInt(c.DefaultQuery("per_page", "20"), 20)
|
||||
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||
|
||||
query := database.DB.Model(&model.User{}).Order("id DESC")
|
||||
query := database.DB.Model(&model.User{}).Preload("Plan").Order("id DESC")
|
||||
if keyword != "" {
|
||||
query = query.Where("email LIKE ? OR id = ?", "%"+keyword+"%", keyword)
|
||||
}
|
||||
@@ -35,33 +36,66 @@ func PluginUserOnlineDevicesUsers(c *gin.Context) {
|
||||
userIDs = append(userIDs, user.ID)
|
||||
}
|
||||
devices := service.GetUsersDevices(userIDs)
|
||||
deviceMetaByUserID := make(map[int]struct {
|
||||
LastSeenAt int64
|
||||
Count int
|
||||
}, len(userIDs))
|
||||
if len(userIDs) > 0 {
|
||||
var rows []struct {
|
||||
UserID int
|
||||
LastSeenAt int64
|
||||
IPCount int
|
||||
}
|
||||
_ = database.DB.Table("v2_user_online_devices").
|
||||
Select("user_id, MAX(last_seen_at) AS last_seen_at, COUNT(DISTINCT ip) AS ip_count").
|
||||
Where("user_id IN ? AND expires_at > ?", userIDs, time.Now().Unix()).
|
||||
Group("user_id").
|
||||
Scan(&rows).Error
|
||||
for _, row := range rows {
|
||||
deviceMetaByUserID[row.UserID] = struct {
|
||||
LastSeenAt int64
|
||||
Count int
|
||||
}{LastSeenAt: row.LastSeenAt, Count: row.IPCount}
|
||||
}
|
||||
}
|
||||
|
||||
list := make([]gin.H, 0, len(users))
|
||||
usersWithOnlineIP := 0
|
||||
totalOnlineIPs := 0
|
||||
for _, user := range users {
|
||||
subscriptionName := "No subscription"
|
||||
if user.PlanID != nil {
|
||||
var plan model.Plan
|
||||
if database.DB.First(&plan, *user.PlanID).Error == nil {
|
||||
subscriptionName = plan.Name
|
||||
}
|
||||
ips := devices[user.ID]
|
||||
meta := deviceMetaByUserID[user.ID]
|
||||
onlineCount := len(ips)
|
||||
if meta.Count > onlineCount {
|
||||
onlineCount = meta.Count
|
||||
}
|
||||
if onlineCount > 0 {
|
||||
usersWithOnlineIP++
|
||||
totalOnlineIPs += onlineCount
|
||||
}
|
||||
|
||||
ips := devices[user.ID]
|
||||
if len(ips) > 0 {
|
||||
usersWithOnlineIP++
|
||||
totalOnlineIPs += len(ips)
|
||||
lastOnlineText := formatTimeValue(user.LastOnlineAt)
|
||||
if meta.LastSeenAt > 0 {
|
||||
lastOnlineText = formatUnixValue(meta.LastSeenAt)
|
||||
}
|
||||
|
||||
status := "offline"
|
||||
statusLabel := "Offline"
|
||||
if onlineCount > 0 {
|
||||
status = "online"
|
||||
statusLabel = "Online"
|
||||
}
|
||||
|
||||
list = append(list, gin.H{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"subscription_name": subscriptionName,
|
||||
"online_count": len(ips),
|
||||
"subscription_name": planName(user.Plan),
|
||||
"online_count": onlineCount,
|
||||
"online_devices": ips,
|
||||
"last_online_text": formatTimeValue(user.LastOnlineAt),
|
||||
"last_online_text": lastOnlineText,
|
||||
"created_text": formatUnixValue(user.CreatedAt),
|
||||
"status": status,
|
||||
"status_label": statusLabel,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -86,6 +120,170 @@ func PluginUserOnlineDevicesUsers(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func AdminIPv6SubscriptionUsers(c *gin.Context) {
|
||||
page := parsePositiveInt(c.DefaultQuery("page", "1"), 1)
|
||||
perPage := parsePositiveInt(c.DefaultQuery("per_page", "20"), 20)
|
||||
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||
|
||||
suffix := service.GetPluginConfigString(service.PluginUserAddIPv6, "email_suffix", "-ipv6")
|
||||
shadowPattern := "%" + suffix + "@%"
|
||||
|
||||
query := database.DB.Model(&model.User{}).
|
||||
Preload("Plan").
|
||||
Where("email NOT LIKE ?", shadowPattern).
|
||||
Order("id DESC")
|
||||
if keyword != "" {
|
||||
query = query.Where("email LIKE ? OR CAST(id AS CHAR) = ?", "%"+keyword+"%", keyword)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
Fail(c, 500, "failed to count users")
|
||||
return
|
||||
}
|
||||
|
||||
var users []model.User
|
||||
if err := query.Offset((page - 1) * perPage).Limit(perPage).Find(&users).Error; err != nil {
|
||||
Fail(c, 500, "failed to fetch users")
|
||||
return
|
||||
}
|
||||
|
||||
userIDs := make([]int, 0, len(users))
|
||||
for _, user := range users {
|
||||
userIDs = append(userIDs, user.ID)
|
||||
}
|
||||
|
||||
subscriptionByUserID := make(map[int]model.UserIPv6Subscription, len(userIDs))
|
||||
if len(userIDs) > 0 {
|
||||
var rows []model.UserIPv6Subscription
|
||||
if err := database.DB.Where("user_id IN ?", userIDs).Find(&rows).Error; err == nil {
|
||||
for _, row := range rows {
|
||||
subscriptionByUserID[row.UserID] = row
|
||||
}
|
||||
}
|
||||
}
|
||||
shadowByParentID := make(map[int]model.User, len(userIDs))
|
||||
if len(userIDs) > 0 {
|
||||
var shadowUsers []model.User
|
||||
if err := database.DB.Where("parent_id IN ?", userIDs).Find(&shadowUsers).Error; err == nil {
|
||||
for _, shadow := range shadowUsers {
|
||||
if shadow.ParentID != nil {
|
||||
shadowByParentID[*shadow.ParentID] = shadow
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
list := make([]gin.H, 0, len(users))
|
||||
for _, user := range users {
|
||||
subscription, hasSubscription := subscriptionByUserID[user.ID]
|
||||
shadowUser, hasShadowUser := shadowByParentID[user.ID]
|
||||
if !hasSubscription && hasShadowUser {
|
||||
subscription = model.UserIPv6Subscription{
|
||||
UserID: user.ID,
|
||||
ShadowUserID: &shadowUser.ID,
|
||||
IPv6Email: shadowUser.Email,
|
||||
Allowed: !user.Banned,
|
||||
Status: "active",
|
||||
UpdatedAt: shadowUser.UpdatedAt,
|
||||
}
|
||||
hasSubscription = true
|
||||
}
|
||||
allowed := service.PluginUserAllowed(&user, user.Plan)
|
||||
status := "not_allowed"
|
||||
statusLabel := "Not eligible"
|
||||
if allowed {
|
||||
status = "eligible"
|
||||
statusLabel = "Ready to enable"
|
||||
}
|
||||
shadowUserID := 0
|
||||
shadowUpdatedAt := int64(0)
|
||||
if hasSubscription {
|
||||
status = firstString(subscription.Status, status)
|
||||
statusLabel = "IPv6 enabled"
|
||||
if subscription.Status != "active" && subscription.Status != "" {
|
||||
statusLabel = strings.ReplaceAll(strings.Title(strings.ReplaceAll(subscription.Status, "_", " ")), "Ipv6", "IPv6")
|
||||
}
|
||||
if subscription.ShadowUserID != nil {
|
||||
shadowUserID = *subscription.ShadowUserID
|
||||
}
|
||||
shadowUpdatedAt = subscription.UpdatedAt
|
||||
} else if allowed {
|
||||
statusLabel = "Ready to enable"
|
||||
}
|
||||
|
||||
list = append(list, gin.H{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"plan_name": planName(user.Plan),
|
||||
"allowed": allowed || hasSubscription && subscription.Allowed,
|
||||
"is_active": hasSubscription && subscription.Status == "active",
|
||||
"status": status,
|
||||
"status_label": statusLabel,
|
||||
"ipv6_email": firstString(subscription.IPv6Email, service.IPv6ShadowEmail(user.Email)),
|
||||
"shadow_user_id": shadowUserID,
|
||||
"updated_at": shadowUpdatedAt,
|
||||
"group_id": user.GroupID,
|
||||
})
|
||||
}
|
||||
|
||||
Success(c, gin.H{
|
||||
"list": list,
|
||||
"pagination": gin.H{
|
||||
"current": page,
|
||||
"last_page": calculateLastPage(total, perPage),
|
||||
"per_page": perPage,
|
||||
"total": total,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func AdminIPv6SubscriptionEnable(c *gin.Context) {
|
||||
userID := parsePositiveInt(c.Param("userId"), 0)
|
||||
if userID == 0 {
|
||||
Fail(c, 400, "invalid user id")
|
||||
return
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := database.DB.Preload("Plan").First(&user, userID).Error; err != nil {
|
||||
Fail(c, 404, "user not found")
|
||||
return
|
||||
}
|
||||
if user.ParentID != nil {
|
||||
Fail(c, 400, "shadow users cannot be enabled again")
|
||||
return
|
||||
}
|
||||
|
||||
if !service.SyncIPv6ShadowAccount(&user) {
|
||||
Fail(c, 403, "user plan does not support ipv6 subscription")
|
||||
return
|
||||
}
|
||||
|
||||
SuccessMessage(c, "IPv6 subscription enabled/synced", true)
|
||||
}
|
||||
|
||||
func AdminIPv6SubscriptionSyncPassword(c *gin.Context) {
|
||||
userID := parsePositiveInt(c.Param("userId"), 0)
|
||||
if userID == 0 {
|
||||
Fail(c, 400, "invalid user id")
|
||||
return
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
Fail(c, 404, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
if !service.SyncIPv6PasswordState(&user) {
|
||||
Fail(c, 404, "IPv6 user not found")
|
||||
return
|
||||
}
|
||||
|
||||
SuccessMessage(c, "Password synced to IPv6 account", true)
|
||||
}
|
||||
|
||||
func PluginUserOnlineDevicesGetIP(c *gin.Context) {
|
||||
|
||||
user, ok := currentUser(c)
|
||||
@@ -136,24 +334,41 @@ func PluginUserAddIPv6Check(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if user.PlanID == nil {
|
||||
Success(c, gin.H{"allowed": false, "reason": "No active plan"})
|
||||
return
|
||||
var plan *model.Plan
|
||||
if user.PlanID != nil {
|
||||
var loadedPlan model.Plan
|
||||
if err := database.DB.First(&loadedPlan, *user.PlanID).Error; err == nil {
|
||||
plan = &loadedPlan
|
||||
}
|
||||
}
|
||||
|
||||
var plan model.Plan
|
||||
if err := database.DB.First(&plan, *user.PlanID).Error; err != nil {
|
||||
Success(c, gin.H{"allowed": false, "reason": "No active plan"})
|
||||
return
|
||||
var subscription model.UserIPv6Subscription
|
||||
hasSubscription := database.DB.Where("user_id = ?", user.ID).First(&subscription).Error == nil
|
||||
allowed := service.PluginUserAllowed(user, plan)
|
||||
status := "not_allowed"
|
||||
statusLabel := "Not eligible"
|
||||
reason := "Current plan or group is not allowed to enable IPv6"
|
||||
if allowed {
|
||||
status = "eligible"
|
||||
statusLabel = "Ready to enable"
|
||||
reason = ""
|
||||
}
|
||||
if hasSubscription {
|
||||
status = firstString(subscription.Status, "active")
|
||||
statusLabel = "IPv6 enabled"
|
||||
reason = ""
|
||||
if subscription.Status != "active" && subscription.Status != "" {
|
||||
statusLabel = strings.ReplaceAll(strings.Title(strings.ReplaceAll(subscription.Status, "_", " ")), "Ipv6", "IPv6")
|
||||
}
|
||||
}
|
||||
|
||||
ipv6Email := service.IPv6ShadowEmail(user.Email)
|
||||
var count int64
|
||||
database.DB.Model(&model.User{}).Where("email = ?", ipv6Email).Count(&count)
|
||||
|
||||
Success(c, gin.H{
|
||||
"allowed": service.PluginPlanAllowed(&plan),
|
||||
"is_active": count > 0,
|
||||
"allowed": allowed || hasSubscription && subscription.Allowed,
|
||||
"is_active": hasSubscription && subscription.Status == "active",
|
||||
"status": status,
|
||||
"status_label": statusLabel,
|
||||
"reason": reason,
|
||||
"ipv6_email": firstString(subscription.IPv6Email, service.IPv6ShadowEmail(user.Email)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -170,7 +385,23 @@ func PluginUserAddIPv6Enable(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
SuccessMessage(c, "IPv6 subscription enabled/synced", true)
|
||||
payload := gin.H{
|
||||
"ipv6_email": service.IPv6ShadowEmail(user.Email),
|
||||
}
|
||||
|
||||
var shadowUser model.User
|
||||
if err := database.DB.Where("parent_id = ? AND email = ?", user.ID, service.IPv6ShadowEmail(user.Email)).First(&shadowUser).Error; err == nil {
|
||||
payload["shadow_user_id"] = shadowUser.ID
|
||||
token, err := utils.GenerateToken(shadowUser.ID, shadowUser.IsAdmin)
|
||||
if err == nil {
|
||||
payload["token"] = token
|
||||
payload["auth_data"] = token
|
||||
service.TrackSession(shadowUser.ID, token, c.ClientIP(), c.GetHeader("User-Agent"))
|
||||
_ = database.DB.Model(&model.User{}).Where("id = ?", shadowUser.ID).Update("last_login_at", time.Now().Unix()).Error
|
||||
}
|
||||
}
|
||||
|
||||
SuccessMessage(c, "IPv6 subscription enabled/synced", payload)
|
||||
}
|
||||
|
||||
func PluginUserAddIPv6SyncPassword(c *gin.Context) {
|
||||
@@ -181,13 +412,7 @@ func PluginUserAddIPv6SyncPassword(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
ipv6Email := service.IPv6ShadowEmail(user.Email)
|
||||
result := database.DB.Model(&model.User{}).Where("email = ?", ipv6Email).Update("password", user.Password)
|
||||
if result.Error != nil {
|
||||
Fail(c, 404, "IPv6 user not found")
|
||||
return
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
if !service.SyncIPv6PasswordState(user) {
|
||||
Fail(c, 404, "IPv6 user not found")
|
||||
return
|
||||
}
|
||||
@@ -195,8 +420,6 @@ func PluginUserAddIPv6SyncPassword(c *gin.Context) {
|
||||
SuccessMessage(c, "Password synced to IPv6 account", true)
|
||||
}
|
||||
|
||||
|
||||
|
||||
func AdminSystemStatus(c *gin.Context) {
|
||||
Success(c, gin.H{
|
||||
"server_time": time.Now().Unix(),
|
||||
@@ -210,7 +433,39 @@ func AdminSystemStatus(c *gin.Context) {
|
||||
// AdminSystemQueueStats returns empty queue stats (Go app has no Horizon/queue workers).
|
||||
// The React frontend polls this endpoint; returning empty data prevents 404 polling errors.
|
||||
func AdminSystemQueueStats(c *gin.Context) {
|
||||
fullPath := c.FullPath()
|
||||
if strings.HasSuffix(fullPath, "/getHorizonFailedJobs") {
|
||||
c.JSON(200, gin.H{
|
||||
"data": []any{},
|
||||
"total": 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(fullPath, "/getQueueWorkload") || strings.HasSuffix(fullPath, "/getQueueMasters") {
|
||||
Success(c, []any{})
|
||||
return
|
||||
}
|
||||
|
||||
Success(c, gin.H{
|
||||
"status": true,
|
||||
"wait": gin.H{"default": 0},
|
||||
"recentJobs": 0,
|
||||
"jobsPerMinute": 0,
|
||||
"failedJobs": 0,
|
||||
"processes": 0,
|
||||
"pausedMasters": 0,
|
||||
"periods": gin.H{
|
||||
"recentJobs": 60,
|
||||
"failedJobs": 168,
|
||||
},
|
||||
"queueWithMaxThroughput": gin.H{
|
||||
"name": "default",
|
||||
"throughput": 0,
|
||||
},
|
||||
"queueWithMaxRuntime": gin.H{
|
||||
"name": "default",
|
||||
"runtime": 0,
|
||||
},
|
||||
"workload": []any{},
|
||||
"masters": []any{},
|
||||
"failed_jobs": 0,
|
||||
|
||||
Reference in New Issue
Block a user