基本功能已初步完善
Some checks failed
build / build (api, amd64, linux) (push) Has been cancelled
build / build (api, arm64, linux) (push) Has been cancelled
build / build (api.exe, amd64, windows) (push) Has been cancelled

This commit is contained in:
CN-JS-HuiBai
2026-04-17 20:41:47 +08:00
parent 25fd919477
commit b3435e5ef8
34 changed files with 3495 additions and 429 deletions

View File

@@ -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,