移除被错误提交的测试用exe文件
All checks were successful
build / build (api, amd64, linux) (push) Successful in -49s
build / build (api, arm64, linux) (push) Successful in -47s
build / build (api.exe, amd64, windows) (push) Successful in -47s

修复前后端逻辑
This commit is contained in:
CN-JS-HuiBai
2026-04-17 10:40:05 +08:00
parent 1ed31b9292
commit d077eae2f6
10 changed files with 1794 additions and 1289 deletions

View File

@@ -0,0 +1,714 @@
package handler
import (
"net/http"
"strings"
"time"
"xboard-go/internal/database"
"xboard-go/internal/model"
"xboard-go/internal/service"
"github.com/gin-gonic/gin"
)
func AdminDashboardSummary(c *gin.Context) {
now := time.Now().Unix()
monthAgo := now - 30*24*60*60
var totalUsers int64
var newUsers int64
var activeUsers int64
var totalOrders int64
var pendingOrders int64
var pendingTickets int64
var totalServers int64
var totalPlans int64
var totalCoupons int64
var totalGroups int64
var totalRoutes int64
var onlineUsers int64
var paidOrderAmount int64
database.DB.Model(&model.User{}).Count(&totalUsers)
database.DB.Model(&model.User{}).Where("created_at >= ?", monthAgo).Count(&newUsers)
database.DB.Model(&model.User{}).Where("last_login_at IS NOT NULL AND last_login_at >= ?", monthAgo).Count(&activeUsers)
database.DB.Model(&model.User{}).Where("online_count IS NOT NULL AND online_count > 0").Count(&onlineUsers)
database.DB.Model(&model.Order{}).Count(&totalOrders)
database.DB.Model(&model.Order{}).Where("status = ?", 0).Count(&pendingOrders)
database.DB.Model(&model.Order{}).Where("status NOT IN ?", []int{0, 2}).Select("COALESCE(SUM(total_amount), 0)").Scan(&paidOrderAmount)
database.DB.Model(&model.Ticket{}).Where("status = ?", 0).Count(&pendingTickets)
database.DB.Model(&model.Server{}).Count(&totalServers)
database.DB.Model(&model.Plan{}).Count(&totalPlans)
database.DB.Model(&model.Coupon{}).Count(&totalCoupons)
database.DB.Model(&model.ServerGroup{}).Count(&totalGroups)
database.DB.Model(&model.ServerRoute{}).Count(&totalRoutes)
Success(c, gin.H{
"server_time": now,
"total_users": totalUsers,
"new_users_30d": newUsers,
"active_users_30d": activeUsers,
"online_users": onlineUsers,
"total_orders": totalOrders,
"pending_orders": pendingOrders,
"paid_order_amount": paidOrderAmount,
"pending_tickets": pendingTickets,
"total_servers": totalServers,
"total_plans": totalPlans,
"total_coupons": totalCoupons,
"total_groups": totalGroups,
"total_routes": totalRoutes,
"secure_path": service.GetAdminSecurePath(),
"app_name": service.MustGetString("app_name", "XBoard"),
"app_url": service.GetAppURL(),
"server_pull_period": service.MustGetInt("server_pull_interval", 60),
"server_push_period": service.MustGetInt("server_push_interval", 60),
})
}
func AdminPlansFetch(c *gin.Context) {
var plans []model.Plan
if err := database.DB.Order("sort ASC, id DESC").Find(&plans).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to fetch plans")
return
}
groupNames := loadServerGroupNameMap()
items := make([]gin.H, 0, len(plans))
for _, plan := range plans {
items = append(items, gin.H{
"id": plan.ID,
"name": plan.Name,
"group_id": intValue(plan.GroupID),
"group_name": groupNames[intFromPointer(plan.GroupID)],
"transfer_enable": intValue(plan.TransferEnable),
"speed_limit": intValue(plan.SpeedLimit),
"show": plan.Show,
"sort": intValue(plan.Sort),
"renew": plan.Renew,
"content": stringValue(plan.Content),
"reset_traffic_method": intValue(plan.ResetTrafficMethod),
"capacity_limit": intValue(plan.CapacityLimit),
"prices": stringValue(plan.Prices),
"sell": plan.Sell,
"device_limit": intValue(plan.DeviceLimit),
"tags": parseStringSlice(plan.Tags),
"created_at": plan.CreatedAt,
"updated_at": plan.UpdatedAt,
})
}
Success(c, items)
}
func AdminPlanSave(c *gin.Context) {
var payload map[string]any
if err := c.ShouldBindJSON(&payload); err != nil {
Fail(c, http.StatusBadRequest, "invalid request body")
return
}
id := intFromAny(payload["id"])
name := strings.TrimSpace(stringFromAny(payload["name"]))
if name == "" && id == 0 {
Fail(c, http.StatusUnprocessableEntity, "plan name is required")
return
}
// For simplicity in this Go implementation, we'll map payload to model.Plan
// In a real app, we'd use a dedicated struct for binding.
now := time.Now().Unix()
tags, _ := marshalJSON(payload["tags"], true)
values := map[string]any{
"name": payload["name"],
"group_id": payload["group_id"],
"transfer_enable": payload["transfer_enable"],
"speed_limit": payload["speed_limit"],
"show": payload["show"],
"sort": payload["sort"],
"renew": payload["renew"],
"content": payload["content"],
"reset_traffic_method": payload["reset_traffic_method"],
"capacity_limit": payload["capacity_limit"],
"prices": payload["prices"],
"sell": payload["sell"],
"device_limit": payload["device_limit"],
"tags": tags,
"updated_at": now,
}
if id > 0 {
if err := database.DB.Model(&model.Plan{}).Where("id = ?", id).Updates(values).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to save plan")
return
}
Success(c, true)
return
}
values["created_at"] = now
if err := database.DB.Model(&model.Plan{}).Create(values).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to create plan")
return
}
Success(c, true)
}
func AdminPlanDrop(c *gin.Context) {
var payload struct {
ID int `json:"id"`
}
if err := c.ShouldBindJSON(&payload); err != nil || payload.ID <= 0 {
Fail(c, http.StatusBadRequest, "plan id is required")
return
}
// Check usage
var orderCount int64
database.DB.Model(&model.Order{}).Where("plan_id = ?", payload.ID).Count(&orderCount)
if orderCount > 0 {
Fail(c, http.StatusBadRequest, "this plan is still used by orders")
return
}
var userCount int64
database.DB.Model(&model.User{}).Where("plan_id = ?", payload.ID).Count(&userCount)
if userCount > 0 {
Fail(c, http.StatusBadRequest, "this plan is still used by users")
return
}
if err := database.DB.Where("id = ?", payload.ID).Delete(&model.Plan{}).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to delete plan")
return
}
Success(c, true)
}
func AdminPlanSort(c *gin.Context) {
var payload struct {
PlanIDs []int `json:"plan_ids"`
}
if err := c.ShouldBindJSON(&payload); err != nil {
Fail(c, http.StatusBadRequest, "invalid request body")
return
}
tx := database.DB.Begin()
for i, id := range payload.PlanIDs {
if err := tx.Model(&model.Plan{}).Where("id = ?", id).Update("sort", i+1).Error; err != nil {
tx.Rollback()
Fail(c, http.StatusInternalServerError, "failed to sort plans")
return
}
}
tx.Commit()
Success(c, true)
}
func AdminOrdersFetch(c *gin.Context) {
page := parsePositiveInt(c.DefaultQuery("page", "1"), 1)
perPage := parsePositiveInt(c.DefaultQuery("per_page", "50"), 50)
keyword := strings.TrimSpace(c.Query("keyword"))
statusFilter := strings.TrimSpace(c.Query("status"))
query := database.DB.Model(&model.Order{}).Preload("Plan").Preload("Payment").Order("id DESC")
if keyword != "" {
query = query.Where("trade_no LIKE ? OR CAST(user_id AS CHAR) = ?", "%"+keyword+"%", keyword)
}
if statusFilter != "" {
query = query.Where("status = ?", statusFilter)
}
var total int64
if err := query.Count(&total).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to count orders")
return
}
var orders []model.Order
if err := query.Offset((page - 1) * perPage).Limit(perPage).Find(&orders).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to fetch orders")
return
}
userEmails := loadUserEmailMap(extractOrderUserIDs(orders))
items := make([]gin.H, 0, len(orders))
for _, order := range orders {
item := normalizeOrder(order)
item["user_email"] = userEmails[order.UserID]
items = append(items, item)
}
Success(c, gin.H{
"list": items,
"filters": gin.H{
"keyword": keyword,
"status": statusFilter,
},
"pagination": gin.H{
"current": page,
"last_page": calculateLastPage(total, perPage),
"per_page": perPage,
"total": total,
},
})
}
func AdminCouponsFetch(c *gin.Context) {
var coupons []model.Coupon
if err := database.DB.Order("id DESC").Find(&coupons).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to fetch coupons")
return
}
items := make([]gin.H, 0, len(coupons))
for _, coupon := range coupons {
items = append(items, gin.H{
"id": coupon.ID,
"name": coupon.Name,
"code": coupon.Code,
"type": coupon.Type,
"value": coupon.Value,
"limit_plan_ids": parseStringSlice(coupon.LimitPlanIDs),
"limit_period": parseStringSlice(coupon.LimitPeriod),
"limit_use": intValue(coupon.LimitUse),
"limit_use_with_user": intValue(coupon.LimitUseWithUser),
"started_at": coupon.StartedAt,
"ended_at": coupon.EndedAt,
"show": coupon.Show,
"created_at": coupon.CreatedAt,
"updated_at": coupon.UpdatedAt,
})
}
Success(c, items)
}
func AdminCouponSave(c *gin.Context) {
var payload map[string]any
if err := c.ShouldBindJSON(&payload); err != nil {
Fail(c, http.StatusBadRequest, "invalid request body")
return
}
id := intFromAny(payload["id"])
now := time.Now().Unix()
limitPlanIDs, _ := marshalJSON(payload["limit_plan_ids"], true)
limitPeriod, _ := marshalJSON(payload["limit_period"], true)
values := map[string]any{
"name": payload["name"],
"code": payload["code"],
"type": payload["type"],
"value": payload["value"],
"limit_plan_ids": limitPlanIDs,
"limit_period": limitPeriod,
"limit_use": payload["limit_use"],
"limit_use_with_user": payload["limit_use_with_user"],
"started_at": payload["started_at"],
"ended_at": payload["ended_at"],
"show": payload["show"],
"updated_at": now,
}
if id > 0 {
if err := database.DB.Model(&model.Coupon{}).Where("id = ?", id).Updates(values).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to save coupon")
return
}
Success(c, true)
return
}
values["created_at"] = now
if err := database.DB.Model(&model.Coupon{}).Create(values).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to create coupon")
return
}
Success(c, true)
}
func AdminCouponDrop(c *gin.Context) {
var payload struct {
ID int `json:"id"`
}
if err := c.ShouldBindJSON(&payload); err != nil || payload.ID <= 0 {
Fail(c, http.StatusBadRequest, "coupon id is required")
return
}
if err := database.DB.Where("id = ?", payload.ID).Delete(&model.Coupon{}).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to delete coupon")
return
}
Success(c, true)
}
func AdminOrderPaid(c *gin.Context) {
var payload struct {
TradeNo string `json:"trade_no"`
}
if err := c.ShouldBindJSON(&payload); err != nil || payload.TradeNo == "" {
Fail(c, http.StatusBadRequest, "trade_no is required")
return
}
var order model.Order
if err := database.DB.Preload("Plan").Where("trade_no = ?", payload.TradeNo).First(&order).Error; err != nil {
Fail(c, http.StatusBadRequest, "order does not exist")
return
}
if order.Status != 0 {
Fail(c, http.StatusBadRequest, "order status is not pending")
return
}
now := time.Now().Unix()
tx := database.DB.Begin()
// Update order
if err := tx.Model(&model.Order{}).Where("id = ?", order.ID).Updates(map[string]any{
"status": 3,
"paid_at": now,
}).Error; err != nil {
tx.Rollback()
Fail(c, http.StatusInternalServerError, "failed to update order")
return
}
// Update user
var user model.User
if err := tx.Where("id = ?", order.ID).First(&user).Error; err == nil {
// Calculate expiration and traffic
// Simplified logic: set plan and transfer_enable
updates := map[string]any{
"plan_id": order.PlanID,
"updated_at": now,
}
if order.Plan != nil {
updates["transfer_enable"] = order.Plan.TransferEnable
}
if err := tx.Model(&model.User{}).Where("id = ?", order.UserID).Updates(updates).Error; err != nil {
tx.Rollback()
Fail(c, http.StatusInternalServerError, "failed to update user")
return
}
}
tx.Commit()
Success(c, true)
}
func AdminOrderCancel(c *gin.Context) {
var payload struct {
TradeNo string `json:"trade_no"`
}
if err := c.ShouldBindJSON(&payload); err != nil || payload.TradeNo == "" {
Fail(c, http.StatusBadRequest, "trade_no is required")
return
}
if err := database.DB.Model(&model.Order{}).Where("trade_no = ?", payload.TradeNo).Update("status", 2).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to cancel order")
return
}
Success(c, true)
}
func AdminUserResetTraffic(c *gin.Context) {
var payload struct {
ID int `json:"id"`
}
if err := c.ShouldBindJSON(&payload); err != nil || payload.ID <= 0 {
Fail(c, http.StatusBadRequest, "user id is required")
return
}
if err := database.DB.Model(&model.User{}).Where("id = ?", payload.ID).Updates(map[string]any{
"u": 0,
"d": 0,
}).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to reset traffic")
return
}
Success(c, true)
}
func AdminUserBan(c *gin.Context) {
var payload struct {
ID int `json:"id"`
Banned bool `json:"banned"`
}
if err := c.ShouldBindJSON(&payload); err != nil || payload.ID <= 0 {
Fail(c, http.StatusBadRequest, "user id is required")
return
}
if err := database.DB.Model(&model.User{}).Where("id = ?", payload.ID).Update("banned", payload.Banned).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to update user status")
return
}
Success(c, true)
}
func AdminUserDelete(c *gin.Context) {
var payload struct {
ID int `json:"id"`
}
if err := c.ShouldBindJSON(&payload); err != nil || payload.ID <= 0 {
Fail(c, http.StatusBadRequest, "user id is required")
return
}
if err := database.DB.Where("id = ?", payload.ID).Delete(&model.User{}).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to delete user")
return
}
Success(c, true)
}
func AdminUserUpdate(c *gin.Context) {
var payload map[string]any
if err := c.ShouldBindJSON(&payload); err != nil {
Fail(c, http.StatusBadRequest, "invalid request body")
return
}
id := intFromAny(payload["id"])
if id <= 0 {
Fail(c, http.StatusBadRequest, "user id is required")
return
}
now := time.Now().Unix()
values := map[string]any{
"email": payload["email"],
"password": payload["password"],
"balance": payload["balance"],
"commission_type": payload["commission_type"],
"commission_rate": payload["commission_rate"],
"commission_balance": payload["commission_balance"],
"group_id": payload["group_id"],
"plan_id": payload["plan_id"],
"speed_limit": payload["speed_limit"],
"device_limit": payload["device_limit"],
"expired_at": payload["expired_at"],
"remarks": payload["remarks"],
"updated_at": now,
}
// Remove nil values to avoid overwriting with defaults if not provided
for k, v := range values {
if v == nil {
delete(values, k)
}
}
if err := database.DB.Model(&model.User{}).Where("id = ?", id).Updates(values).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to update user")
return
}
Success(c, true)
}
func AdminUsersFetch(c *gin.Context) {
page := parsePositiveInt(c.DefaultQuery("page", "1"), 1)
perPage := parsePositiveInt(c.DefaultQuery("per_page", "50"), 50)
keyword := strings.TrimSpace(c.Query("keyword"))
query := database.DB.Model(&model.User{}).Preload("Plan").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, http.StatusInternalServerError, "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, http.StatusInternalServerError, "failed to fetch users")
return
}
groupNames := loadServerGroupNameMap()
items := make([]gin.H, 0, len(users))
for _, user := range users {
items = append(items, gin.H{
"id": user.ID,
"email": user.Email,
"balance": user.Balance,
"group_id": intValue(user.GroupID),
"group_name": groupNames[intFromPointer(user.GroupID)],
"plan_id": intValue(user.PlanID),
"plan_name": planName(user.Plan),
"transfer_enable": user.TransferEnable,
"u": user.U,
"d": user.D,
"banned": user.Banned,
"is_admin": user.IsAdmin,
"is_staff": user.IsStaff,
"device_limit": intValue(user.DeviceLimit),
"online_count": intValue(user.OnlineCount),
"expired_at": int64Value(user.ExpiredAt),
"last_login_at": int64Value(user.LastLoginAt),
"last_online_at": unixTimeValue(user.LastOnlineAt),
"created_at": user.CreatedAt,
"updated_at": user.UpdatedAt,
"remarks": stringValue(user.Remarks),
"commission_type": user.CommissionType,
"commission_rate": intValue(user.CommissionRate),
"commission_balance": user.CommissionBalance,
})
}
Success(c, gin.H{
"list": items,
"filters": gin.H{
"keyword": keyword,
},
"pagination": gin.H{
"current": page,
"last_page": calculateLastPage(total, perPage),
"per_page": perPage,
"total": total,
},
})
}
func AdminTicketsFetch(c *gin.Context) {
if ticketID := strings.TrimSpace(c.Query("id")); ticketID != "" {
adminTicketDetail(c, ticketID)
return
}
page := parsePositiveInt(c.DefaultQuery("page", "1"), 1)
perPage := parsePositiveInt(c.DefaultQuery("per_page", "50"), 50)
keyword := strings.TrimSpace(c.Query("keyword"))
query := database.DB.Model(&model.Ticket{}).Order("updated_at DESC, id DESC")
if keyword != "" {
query = query.Where("subject LIKE ? OR CAST(user_id AS CHAR) = ? OR CAST(id AS CHAR) = ?", "%"+keyword+"%", keyword, keyword)
}
var total int64
if err := query.Count(&total).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to count tickets")
return
}
var tickets []model.Ticket
if err := query.Offset((page - 1) * perPage).Limit(perPage).Find(&tickets).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to fetch tickets")
return
}
userEmails := loadUserEmailMap(extractTicketUserIDs(tickets))
messageCounts := loadTicketMessageCountMap(extractTicketIDs(tickets))
items := make([]gin.H, 0, len(tickets))
for _, ticket := range tickets {
items = append(items, gin.H{
"id": ticket.ID,
"user_id": ticket.UserID,
"user_email": userEmails[ticket.UserID],
"subject": ticket.Subject,
"level": ticket.Level,
"status": ticket.Status,
"reply_status": ticket.ReplyStatus,
"message_count": messageCounts[ticket.ID],
"created_at": ticket.CreatedAt,
"updated_at": ticket.UpdatedAt,
})
}
Success(c, gin.H{
"list": items,
"filters": gin.H{
"keyword": keyword,
},
"pagination": gin.H{
"current": page,
"last_page": calculateLastPage(total, perPage),
"per_page": perPage,
"total": total,
},
})
}
func AdminGiftCardStatus(c *gin.Context) {
Success(c, gin.H{
"supported": false,
"status": "not_integrated",
"message": "Gift card management is not fully integrated in the current Go backend yet.",
})
}
func adminTicketDetail(c *gin.Context, rawID string) {
var ticket model.Ticket
if err := database.DB.Where("id = ?", rawID).First(&ticket).Error; err != nil {
Fail(c, http.StatusNotFound, "ticket not found")
return
}
var messages []model.TicketMessage
if err := database.DB.Where("ticket_id = ?", ticket.ID).Order("id ASC").Find(&messages).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to fetch ticket messages")
return
}
userEmails := loadUserEmailMap([]int{ticket.UserID})
Success(c, gin.H{
"id": ticket.ID,
"user_id": ticket.UserID,
"user_email": userEmails[ticket.UserID],
"subject": ticket.Subject,
"level": ticket.Level,
"status": ticket.Status,
"reply_status": ticket.ReplyStatus,
"created_at": ticket.CreatedAt,
"updated_at": ticket.UpdatedAt,
"messages": buildTicketMessages(messages, 0),
})
}
func extractOrderUserIDs(orders []model.Order) []int {
ids := make([]int, 0, len(orders))
for _, order := range orders {
ids = append(ids, order.UserID)
}
return ids
}
func extractTicketUserIDs(tickets []model.Ticket) []int {
ids := make([]int, 0, len(tickets))
for _, ticket := range tickets {
ids = append(ids, ticket.UserID)
}
return ids
}
func extractTicketIDs(tickets []model.Ticket) []int {
ids := make([]int, 0, len(tickets))
for _, ticket := range tickets {
ids = append(ids, ticket.ID)
}
return ids
}
func intFromPointer(value *int) int {
if value == nil {
return 0
}
return *value
}
func planName(plan *model.Plan) string {
if plan == nil {
return ""
}
return plan.Name
}

View File

@@ -4,7 +4,6 @@ import (
"encoding/json"
"errors"
"net/http"
"sort"
"strconv"
"strings"
"time"
@@ -771,96 +770,6 @@ func decodeStringSlice(raw *string) []string {
return filterNonEmptyStrings(strings.Split(*raw, ","))
}
func marshalJSON(value any, fallbackEmptyArray bool) (*string, error) {
if value == nil {
if fallbackEmptyArray {
empty := "[]"
return &empty, nil
}
return nil, nil
}
if text, ok := value.(string); ok {
text = strings.TrimSpace(text)
if text == "" {
if fallbackEmptyArray {
empty := "[]"
return &empty, nil
}
return nil, nil
}
if json.Valid([]byte(text)) {
return &text, nil
}
}
payload, err := json.Marshal(value)
if err != nil {
return nil, err
}
text := string(payload)
return &text, nil
}
func stringFromAny(value any) string {
switch typed := value.(type) {
case string:
return typed
case json.Number:
return typed.String()
case float64:
return strconv.FormatFloat(typed, 'f', -1, 64)
case float32:
return strconv.FormatFloat(float64(typed), 'f', -1, 32)
case int:
return strconv.Itoa(typed)
case int64:
return strconv.FormatInt(typed, 10)
case bool:
if typed {
return "true"
}
return "false"
default:
return ""
}
}
func nullableString(value any) any {
text := strings.TrimSpace(stringFromAny(value))
if text == "" {
return nil
}
return text
}
func intFromAny(value any) int {
switch typed := value.(type) {
case int:
return typed
case int8:
return int(typed)
case int16:
return int(typed)
case int32:
return int(typed)
case int64:
return int(typed)
case float32:
return int(typed)
case float64:
return int(typed)
case json.Number:
parsed, _ := typed.Int64()
return int(parsed)
case string:
parsed, _ := strconv.Atoi(strings.TrimSpace(typed))
return parsed
default:
return 0
}
}
func float32FromAny(value any) (float32, bool) {
switch typed := value.(type) {
case float32:
@@ -891,64 +800,19 @@ func nullableInt(value any) any {
}
func nullableInt64(value any) any {
switch typed := value.(type) {
case int64:
if typed > 0 {
return typed
}
case int:
if typed > 0 {
return int64(typed)
}
case float64:
if typed > 0 {
return int64(typed)
}
case string:
if parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64); err == nil && parsed > 0 {
return parsed
}
}
return nil
}
func boolFromAny(value any) bool {
switch typed := value.(type) {
case bool:
return typed
case int:
return typed != 0
case int64:
return typed != 0
case float64:
return typed != 0
case string:
text := strings.TrimSpace(strings.ToLower(typed))
return text == "1" || text == "true" || text == "yes" || text == "on"
default:
return false
}
}
func stringValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func intValue(value *int) any {
if value == nil {
parsed := intFromAny(value)
if parsed <= 0 {
return nil
}
return *value
return int64(parsed)
}
func int64Value(value *int64) any {
if value == nil {
func nullableString(value any) any {
text := strings.TrimSpace(stringFromAny(value))
if text == "" {
return nil
}
return *value
return text
}
func filterNonEmptyStrings(values any) []string {
@@ -972,23 +836,6 @@ func filterNonEmptyStrings(values any) []string {
return result
}
func sanitizePositiveIDs(ids []int) []int {
unique := make(map[int]struct{}, len(ids))
result := make([]int, 0, len(ids))
for _, id := range ids {
if id <= 0 {
continue
}
if _, exists := unique[id]; exists {
continue
}
unique[id] = struct{}{}
result = append(result, id)
}
sort.Ints(result)
return result
}
func intSliceContains(values []int, target int) bool {
for _, value := range values {
if value == target {
@@ -1007,9 +854,3 @@ func isAllowedRouteAction(action string) bool {
}
}
func unixTimeValue(value *time.Time) any {
if value == nil {
return nil
}
return value.Unix()
}

View File

@@ -1,7 +1,14 @@
package handler
import (
"encoding/json"
"net/http"
"sort"
"strconv"
"strings"
"time"
"xboard-go/internal/database"
"xboard-go/internal/model"
"github.com/gin-gonic/gin"
)
@@ -29,3 +36,201 @@ func NotImplemented(endpoint string) gin.HandlerFunc {
})
}
}
func intFromAny(value any) int {
switch typed := value.(type) {
case int:
return typed
case int8:
return int(typed)
case int16:
return int(typed)
case int32:
return int(typed)
case int64:
return int(typed)
case float32:
return int(typed)
case float64:
return int(typed)
case json.Number:
parsed, _ := typed.Int64()
return int(parsed)
case string:
parsed, _ := strconv.Atoi(strings.TrimSpace(typed))
return parsed
default:
return 0
}
}
func stringFromAny(value any) string {
switch typed := value.(type) {
case string:
return typed
case json.Number:
return typed.String()
case float64:
return strconv.FormatFloat(typed, 'f', -1, 64)
case float32:
return strconv.FormatFloat(float64(typed), 'f', -1, 32)
case int:
return strconv.Itoa(typed)
case int64:
return strconv.FormatInt(typed, 10)
case bool:
if typed {
return "true"
}
return "false"
default:
return ""
}
}
func boolFromAny(value any) bool {
switch typed := value.(type) {
case bool:
return typed
case int:
return typed != 0
case int64:
return typed != 0
case float64:
return typed != 0
case string:
text := strings.TrimSpace(strings.ToLower(typed))
return text == "1" || text == "true" || text == "yes" || text == "on"
default:
return false
}
}
func intValue(value *int) any {
if value == nil {
return nil
}
return *value
}
func int64Value(value *int64) any {
if value == nil {
return nil
}
return *value
}
func stringValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func unixTimeValue(value *time.Time) any {
if value == nil {
return nil
}
return value.Unix()
}
func marshalJSON(value any, fallbackEmptyArray bool) (*string, error) {
if value == nil {
if fallbackEmptyArray {
empty := "[]"
return &empty, nil
}
return nil, nil
}
if text, ok := value.(string); ok {
text = strings.TrimSpace(text)
if text == "" {
if fallbackEmptyArray {
empty := "[]"
return &empty, nil
}
return nil, nil
}
if json.Valid([]byte(text)) {
return &text, nil
}
}
payload, err := json.Marshal(value)
if err != nil {
return nil, err
}
text := string(payload)
return &text, nil
}
func sanitizePositiveIDs(ids []int) []int {
unique := make(map[int]struct{}, len(ids))
result := make([]int, 0, len(ids))
for _, id := range ids {
if id <= 0 {
continue
}
if _, exists := unique[id]; exists {
continue
}
unique[id] = struct{}{}
result = append(result, id)
}
sort.Ints(result)
return result
}
func loadServerGroupNameMap() map[int]string {
var groups []model.ServerGroup
_ = database.DB.Find(&groups).Error
result := make(map[int]string, len(groups))
for _, group := range groups {
result[group.ID] = group.Name
}
return result
}
func loadUserEmailMap(userIDs []int) map[int]string {
result := make(map[int]string)
if len(userIDs) == 0 {
return result
}
var users []model.User
if err := database.DB.Select("id", "email").Where("id IN ?", sanitizePositiveIDs(userIDs)).Find(&users).Error; err != nil {
return result
}
for _, user := range users {
result[user.ID] = user.Email
}
return result
}
func loadTicketMessageCountMap(ticketIDs []int) map[int]int64 {
result := make(map[int]int64)
if len(ticketIDs) == 0 {
return result
}
type ticketCount struct {
TicketID int
Total int64
}
var counts []ticketCount
if err := database.DB.Model(&model.TicketMessage{}).
Select("ticket_id, COUNT(*) AS total").
Where("ticket_id IN ?", sanitizePositiveIDs(ticketIDs)).
Group("ticket_id").
Scan(&counts).Error; err != nil {
return result
}
for _, item := range counts {
result[item.TicketID] = item.Total
}
return result
}

View File

@@ -12,10 +12,6 @@ import (
)
func PluginUserOnlineDevicesUsers(c *gin.Context) {
if !service.IsPluginEnabled(service.PluginUserOnlineDevices) {
Fail(c, 400, "plugin is not enabled")
return
}
page := parsePositiveInt(c.DefaultQuery("page", "1"), 1)
perPage := parsePositiveInt(c.DefaultQuery("per_page", "20"), 20)
@@ -92,10 +88,6 @@ func PluginUserOnlineDevicesUsers(c *gin.Context) {
}
func PluginUserOnlineDevicesGetIP(c *gin.Context) {
if !service.IsPluginEnabled(service.PluginUserOnlineDevices) {
Fail(c, 400, "plugin is not enabled")
return
}
user, ok := currentUser(c)
if !ok {
@@ -138,10 +130,6 @@ func PluginUserOnlineDevicesGetIP(c *gin.Context) {
}
func PluginUserAddIPv6Check(c *gin.Context) {
if !service.IsPluginEnabled(service.PluginUserAddIPv6) {
Fail(c, 400, "plugin is not enabled")
return
}
user, ok := currentUser(c)
if !ok {
@@ -171,10 +159,6 @@ func PluginUserAddIPv6Check(c *gin.Context) {
}
func PluginUserAddIPv6Enable(c *gin.Context) {
if !service.IsPluginEnabled(service.PluginUserAddIPv6) {
Fail(c, 400, "plugin is not enabled")
return
}
user, ok := currentUser(c)
if !ok {
@@ -191,10 +175,6 @@ func PluginUserAddIPv6Enable(c *gin.Context) {
}
func PluginUserAddIPv6SyncPassword(c *gin.Context) {
if !service.IsPluginEnabled(service.PluginUserAddIPv6) {
Fail(c, 400, "plugin is not enabled")
return
}
user, ok := currentUser(c)
if !ok {
@@ -234,41 +214,6 @@ func AdminSystemStatus(c *gin.Context) {
})
}
func AdminPluginsList(c *gin.Context) {
var plugins []model.Plugin
if err := database.DB.Order("id ASC").Find(&plugins).Error; err != nil {
Fail(c, 500, "failed to fetch plugins")
return
}
Success(c, plugins)
}
func AdminPluginTypes(c *gin.Context) {
Success(c, []string{"feature", "payment"})
}
func AdminPluginIntegrationStatus(c *gin.Context) {
Success(c, gin.H{
"user_online_devices": gin.H{
"enabled": service.IsPluginEnabled(service.PluginUserOnlineDevices),
"status": "complete",
"summary": "用户侧在线设备概览与后台设备监控已接入 Go 后端。",
"endpoints": []string{"/api/v1/user/user-online-devices/get-ip", "/api/v1/" + service.GetAdminSecurePath() + "/user-online-devices/users"},
},
"real_name_verification": gin.H{
"enabled": service.IsPluginEnabled(service.PluginRealNameVerification),
"status": "complete",
"summary": "实名状态查询、提交、后台审核与批量同步均已整合,并补齐 auto_approve/allow_resubmit_after_reject 行为。",
"endpoints": []string{"/api/v1/user/real-name-verification/status", "/api/v1/user/real-name-verification/submit", "/api/v1/" + service.GetAdminSecurePath() + "/realname/records"},
},
"user_add_ipv6_subscription": gin.H{
"enabled": service.IsPluginEnabled(service.PluginUserAddIPv6),
"status": "integrated_with_runtime_sync",
"summary": "用户启用、密码同步与运行时影子账号同步已接入;订单生命周期自动钩子仍依赖后续完整订单流重构。",
"endpoints": []string{"/api/v1/user/user-add-ipv6-subscription/check", "/api/v1/user/user-add-ipv6-subscription/enable", "/api/v1/user/user-add-ipv6-subscription/sync-password"},
},
})
}
func parsePositiveInt(raw string, defaultValue int) int {
value, err := strconv.Atoi(strings.TrimSpace(raw))

View File

@@ -14,11 +14,6 @@ import (
const unverifiedExpiration = int64(946684800)
func PluginRealNameStatus(c *gin.Context) {
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
Fail(c, 400, "plugin is not enabled")
return
}
user, ok := currentUser(c)
if !ok {
Fail(c, 401, "unauthorized")
@@ -54,11 +49,6 @@ func PluginRealNameStatus(c *gin.Context) {
}
func PluginRealNameSubmit(c *gin.Context) {
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
Fail(c, 400, "plugin is not enabled")
return
}
user, ok := currentUser(c)
if !ok {
Fail(c, 401, "unauthorized")
@@ -126,11 +116,6 @@ func PluginRealNameSubmit(c *gin.Context) {
}
func PluginRealNameRecords(c *gin.Context) {
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
Fail(c, 400, "plugin is not enabled")
return
}
page := parsePositiveInt(c.DefaultQuery("page", "1"), 1)
perPage := parsePositiveInt(c.DefaultQuery("per_page", "20"), 20)
keyword := strings.TrimSpace(c.Query("keyword"))
@@ -192,11 +177,6 @@ func PluginRealNameRecords(c *gin.Context) {
}
func PluginRealNameReview(c *gin.Context) {
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
Fail(c, 400, "plugin is not enabled")
return
}
userID := parsePositiveInt(c.Param("userId"), 0)
if userID == 0 {
Fail(c, 400, "invalid user id")
@@ -239,11 +219,6 @@ func PluginRealNameReview(c *gin.Context) {
}
func PluginRealNameReset(c *gin.Context) {
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
Fail(c, 400, "plugin is not enabled")
return
}
userID := parsePositiveInt(c.Param("userId"), 0)
if userID == 0 {
Fail(c, 400, "invalid user id")
@@ -256,18 +231,10 @@ func PluginRealNameReset(c *gin.Context) {
}
func PluginRealNameSyncAll(c *gin.Context) {
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
Fail(c, 400, "plugin is not enabled")
return
}
SuccessMessage(c, "sync completed", performGlobalRealNameSync())
}
func PluginRealNameApproveAll(c *gin.Context) {
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
Fail(c, 400, "plugin is not enabled")
return
}
var users []model.User
database.DB.Where("email NOT LIKE ?", "%-ipv6@%").Find(&users)

View File

@@ -63,12 +63,19 @@ func AdminAppPage(c *gin.Context) {
"title": service.MustGetString("app_name", "XBoard") + " Admin",
"securePath": securePath,
"api": map[string]string{
"adminConfig": "/api/v2/" + securePath + "/config/fetch",
"systemStatus": "/api/v2/" + securePath + "/system/getSystemStatus",
"plugins": "/api/v2/" + securePath + "/plugin/getPlugins",
"integration": "/api/v2/" + securePath + "/plugin/integration-status",
"realnameBase": "/api/v1/" + securePath + "/realname",
"onlineDevices": "/api/v1/" + securePath + "/user-online-devices/users",
"adminConfig": "/api/v2/" + securePath + "/config/fetch",
"saveConfig": "/api/v2/" + securePath + "/config/save",
"dashboardSummary": "/api/v2/" + securePath + "/dashboard/summary",
"systemStatus": "/api/v2/" + securePath + "/system/getSystemStatus",
"serverNodeSort": "/api/v2/" + securePath + "/server/manage/sort",
"plans": "/api/v2/" + securePath + "/plan/fetch",
"orders": "/api/v2/" + securePath + "/order/fetch",
"coupons": "/api/v2/" + securePath + "/coupon/fetch",
"users": "/api/v2/" + securePath + "/user/fetch",
"tickets": "/api/v2/" + securePath + "/ticket/fetch",
"giftCardStatus": "/api/v2/" + securePath + "/gift-card/status",
"realnameBase": "/api/v2/" + securePath + "/realname",
"onlineDevices": "/api/v2/" + securePath + "/user-online-devices/users",
},
}
payload := adminAppViewData{