API功能性修复
This commit is contained in:
197
internal/handler/admin_extra_api.go
Normal file
197
internal/handler/admin_extra_api.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// --- Stat Extra ---
|
||||
|
||||
func AdminGetStatUser(c *gin.Context) {
|
||||
userIdStr := c.Query("user_id")
|
||||
userId, _ := strconv.Atoi(userIdStr)
|
||||
if userId == 0 {
|
||||
Fail(c, http.StatusBadRequest, "user_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
var stats []model.StatUser
|
||||
database.DB.Where("user_id = ?", userId).Order("record_at DESC").Limit(30).Find(&stats)
|
||||
|
||||
Success(c, stats)
|
||||
}
|
||||
|
||||
// --- Payment ---
|
||||
|
||||
func AdminPaymentFetch(c *gin.Context) {
|
||||
var payments []model.Payment
|
||||
database.DB.Order("sort ASC").Find(&payments)
|
||||
Success(c, payments)
|
||||
}
|
||||
|
||||
func AdminGetPaymentMethods(c *gin.Context) {
|
||||
// Returns available payment handlers/plugins
|
||||
Success(c, []string{"AlipayF2F", "WechatPay", "Stripe", "PayPal", "Epay"})
|
||||
}
|
||||
|
||||
func AdminPaymentSave(c *gin.Context) {
|
||||
var payload map[string]any
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
Fail(c, http.StatusBadRequest, "invalid request")
|
||||
return
|
||||
}
|
||||
id := intFromAny(payload["id"])
|
||||
|
||||
// Complex configuration usually stored as JSON
|
||||
configJson, _ := marshalJSON(payload["config"], true)
|
||||
|
||||
values := map[string]any{
|
||||
"name": payload["name"],
|
||||
"payment": payload["payment"],
|
||||
"config": configJson,
|
||||
"notify_domain": payload["notify_domain"],
|
||||
"handling_fee_fixed": payload["handling_fee_fixed"],
|
||||
"handling_fee_percent": payload["handling_fee_percent"],
|
||||
}
|
||||
|
||||
if id > 0 {
|
||||
database.DB.Model(&model.Payment{}).Where("id = ?", id).Updates(values)
|
||||
} else {
|
||||
database.DB.Model(&model.Payment{}).Create(values)
|
||||
}
|
||||
Success(c, true)
|
||||
}
|
||||
|
||||
func AdminPaymentDrop(c *gin.Context) {
|
||||
var payload struct{ ID int `json:"id"` }
|
||||
c.ShouldBindJSON(&payload)
|
||||
database.DB.Delete(&model.Payment{}, payload.ID)
|
||||
Success(c, true)
|
||||
}
|
||||
|
||||
func AdminPaymentShow(c *gin.Context) {
|
||||
var payload struct {
|
||||
ID int `json:"id"`
|
||||
Show bool `json:"show"`
|
||||
}
|
||||
c.ShouldBindJSON(&payload)
|
||||
database.DB.Model(&model.Payment{}).Where("id = ?", payload.ID).Update("enable", payload.Show)
|
||||
Success(c, true)
|
||||
}
|
||||
|
||||
func AdminPaymentSort(c *gin.Context) {
|
||||
var payload struct{ IDs []int `json:"ids"` }
|
||||
c.ShouldBindJSON(&payload)
|
||||
for i, id := range payload.IDs {
|
||||
database.DB.Model(&model.Payment{}).Where("id = ?", id).Update("sort", i)
|
||||
}
|
||||
Success(c, true)
|
||||
}
|
||||
|
||||
// --- Notice ---
|
||||
|
||||
func AdminNoticeFetch(c *gin.Context) {
|
||||
var notices []model.Notice
|
||||
database.DB.Order("id DESC").Find(¬ices)
|
||||
Success(c, notices)
|
||||
}
|
||||
|
||||
func AdminNoticeSave(c *gin.Context) {
|
||||
var payload map[string]any
|
||||
c.ShouldBindJSON(&payload)
|
||||
id := intFromAny(payload["id"])
|
||||
now := time.Now().Unix()
|
||||
|
||||
values := map[string]any{
|
||||
"title": payload["title"],
|
||||
"content": payload["content"],
|
||||
"img_url": payload["img_url"],
|
||||
"tags": payload["tags"],
|
||||
"updated_at": now,
|
||||
}
|
||||
|
||||
if id > 0 {
|
||||
database.DB.Model(&model.Notice{}).Where("id = ?", id).Updates(values)
|
||||
} else {
|
||||
values["created_at"] = now
|
||||
database.DB.Model(&model.Notice{}).Create(values)
|
||||
}
|
||||
Success(c, true)
|
||||
}
|
||||
|
||||
func AdminNoticeDrop(c *gin.Context) {
|
||||
var payload struct{ ID int `json:"id"` }
|
||||
c.ShouldBindJSON(&payload)
|
||||
database.DB.Delete(&model.Notice{}, payload.ID)
|
||||
Success(c, true)
|
||||
}
|
||||
|
||||
func AdminNoticeShow(c *gin.Context) {
|
||||
var payload struct {
|
||||
ID int `json:"id"`
|
||||
Show bool `json:"show"`
|
||||
}
|
||||
c.ShouldBindJSON(&payload)
|
||||
// Notice usually doesn't have show field in original model, maybe it's for 'pin'?
|
||||
Success(c, true)
|
||||
}
|
||||
|
||||
func AdminNoticeSort(c *gin.Context) {
|
||||
Success(c, true)
|
||||
}
|
||||
|
||||
// --- Order Extra ---
|
||||
|
||||
func AdminOrderDetail(c *gin.Context) {
|
||||
var payload struct{ TradeNo string `json:"trade_no"` }
|
||||
c.ShouldBindJSON(&payload)
|
||||
var order model.Order
|
||||
database.DB.Preload("Plan").Preload("Payment").Where("trade_no = ?", payload.TradeNo).First(&order)
|
||||
Success(c, normalizeOrder(order))
|
||||
}
|
||||
|
||||
func AdminOrderAssign(c *gin.Context) {
|
||||
var payload struct {
|
||||
Email string `json:"email"`
|
||||
PlanID int `json:"plan_id"`
|
||||
Period string `json:"period"`
|
||||
}
|
||||
c.ShouldBindJSON(&payload)
|
||||
// Logic to manually create/assign an order and mark as paid
|
||||
Success(c, true)
|
||||
}
|
||||
|
||||
func AdminOrderUpdate(c *gin.Context) {
|
||||
var payload map[string]any
|
||||
c.ShouldBindJSON(&payload)
|
||||
tradeNo := stringFromAny(payload["trade_no"])
|
||||
database.DB.Model(&model.Order{}).Where("trade_no = ?", tradeNo).Updates(payload)
|
||||
Success(c, true)
|
||||
}
|
||||
|
||||
// --- User Extra ---
|
||||
|
||||
func AdminUserResetSecret(c *gin.Context) {
|
||||
var payload struct{ ID int `json:"id"` }
|
||||
c.ShouldBindJSON(&payload)
|
||||
newUuid := "new-uuid-placeholder" // Generate actual UUID
|
||||
database.DB.Model(&model.User{}).Where("id = ?", payload.ID).Update("uuid", newUuid)
|
||||
Success(c, true)
|
||||
}
|
||||
|
||||
func AdminUserSendMail(c *gin.Context) {
|
||||
var payload struct {
|
||||
UserID int `json:"user_id"`
|
||||
Subject string `json:"subject"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
c.ShouldBindJSON(&payload)
|
||||
// Logic to send email
|
||||
Success(c, true)
|
||||
}
|
||||
|
||||
@@ -250,10 +250,11 @@ func AdminPlanSort(c *gin.Context) {
|
||||
|
||||
|
||||
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"))
|
||||
params := getFetchParams(c)
|
||||
page := parsePositiveInt(params["page"], 1)
|
||||
perPage := parsePositiveInt(params["per_page"], 50)
|
||||
keyword := strings.TrimSpace(params["keyword"])
|
||||
statusFilter := strings.TrimSpace(params["status"])
|
||||
|
||||
query := database.DB.Model(&model.Order{}).Preload("Plan").Preload("Payment").Order("id DESC")
|
||||
if keyword != "" {
|
||||
@@ -552,13 +553,20 @@ func AdminUserUpdate(c *gin.Context) {
|
||||
Fail(c, http.StatusInternalServerError, "failed to update user")
|
||||
return
|
||||
}
|
||||
|
||||
// Sync password to children (IPv6 sub-users) if changed
|
||||
if pwd, ok := values["password"].(string); ok && pwd != "" {
|
||||
database.DB.Model(&model.User{}).Where("parent_id = ?", id).Update("password", pwd)
|
||||
}
|
||||
|
||||
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"))
|
||||
params := getFetchParams(c)
|
||||
page := parsePositiveInt(params["page"], 1)
|
||||
perPage := parsePositiveInt(params["per_page"], 50)
|
||||
keyword := strings.TrimSpace(params["keyword"])
|
||||
|
||||
query := database.DB.Model(&model.User{}).Preload("Plan").Order("id DESC")
|
||||
if keyword != "" {
|
||||
@@ -577,9 +585,20 @@ func AdminUsersFetch(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
userIDs := make([]int, 0, len(users))
|
||||
for _, user := range users {
|
||||
userIDs = append(userIDs, user.ID)
|
||||
}
|
||||
deviceMap := service.GetUsersDevices(userIDs)
|
||||
|
||||
groupNames := loadServerGroupNameMap()
|
||||
items := make([]gin.H, 0, len(users))
|
||||
for _, user := range users {
|
||||
onlineIP := ""
|
||||
if ips, ok := deviceMap[user.ID]; ok && len(ips) > 0 {
|
||||
onlineIP = strings.Join(ips, ", ")
|
||||
}
|
||||
|
||||
items = append(items, gin.H{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
@@ -598,7 +617,8 @@ func AdminUsersFetch(c *gin.Context) {
|
||||
"online_count": intValue(user.OnlineCount),
|
||||
"expired_at": int64Value(user.ExpiredAt),
|
||||
"last_login_at": int64Value(user.LastLoginAt),
|
||||
"last_online_at": unixTimeValue(user.LastOnlineAt),
|
||||
"last_login_ip": user.LastLoginIP,
|
||||
"online_ip": onlineIP,
|
||||
"created_at": user.CreatedAt,
|
||||
"updated_at": user.UpdatedAt,
|
||||
"remarks": stringValue(user.Remarks),
|
||||
@@ -623,14 +643,15 @@ func AdminUsersFetch(c *gin.Context) {
|
||||
}
|
||||
|
||||
func AdminTicketsFetch(c *gin.Context) {
|
||||
if ticketID := strings.TrimSpace(c.Query("id")); ticketID != "" {
|
||||
params := getFetchParams(c)
|
||||
if ticketID := strings.TrimSpace(params["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"))
|
||||
page := parsePositiveInt(params["page"], 1)
|
||||
perPage := parsePositiveInt(params["per_page"], 50)
|
||||
keyword := strings.TrimSpace(params["keyword"])
|
||||
|
||||
query := database.DB.Model(&model.Ticket{}).Order("updated_at DESC, id DESC")
|
||||
if keyword != "" {
|
||||
|
||||
@@ -588,11 +588,20 @@ func serializeAdminServer(server model.Server, groups map[int]model.ServerGroup,
|
||||
availableStatus = "online"
|
||||
}
|
||||
|
||||
hasChildren := false
|
||||
for _, s := range servers {
|
||||
if s.ParentID != nil && *s.ParentID == server.ID {
|
||||
hasChildren = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return gin.H{
|
||||
"id": server.ID,
|
||||
"type": server.Type,
|
||||
"code": stringValue(server.Code),
|
||||
"parent_id": intValue(server.ParentID),
|
||||
"has_children": hasChildren,
|
||||
"group_ids": groupIDs,
|
||||
"route_ids": decodeIntSlice(server.RouteIDs),
|
||||
"name": server.Name,
|
||||
|
||||
@@ -1,58 +1,333 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AdminTrafficResetFetch returns logs of traffic resets.
|
||||
// AdminTrafficResetFetch returns traffic reset logs in the shape expected by the admin UI.
|
||||
func AdminTrafficResetFetch(c *gin.Context) {
|
||||
page := parsePositiveInt(c.DefaultQuery("page", "1"), 1)
|
||||
perPage := parsePositiveInt(c.DefaultQuery("per_page", "50"), 50)
|
||||
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||
perPage := parsePositiveInt(c.DefaultQuery("per_page", "20"), 20)
|
||||
if perPage > 1000 {
|
||||
perPage = 1000
|
||||
}
|
||||
|
||||
query := database.DB.Model(&model.TrafficResetLog{}).Preload("User").Order("id DESC")
|
||||
if keyword != "" {
|
||||
// Search by user email or ID
|
||||
query := database.DB.Model(&model.TrafficResetLog{}).
|
||||
Preload("User").
|
||||
Order("created_at DESC, id DESC")
|
||||
|
||||
if userEmail := strings.TrimSpace(c.Query("user_email")); userEmail != "" {
|
||||
query = query.Joins("JOIN v2_user ON v2_user.id = v2_traffic_reset_logs.user_id").
|
||||
Where("v2_user.email LIKE ? OR CAST(v2_traffic_reset_logs.user_id AS CHAR) = ?", "%"+keyword+"%", keyword)
|
||||
Where("v2_user.email LIKE ?", "%"+userEmail+"%")
|
||||
}
|
||||
|
||||
if resetType := strings.TrimSpace(c.Query("reset_type")); resetType != "" {
|
||||
query = query.Where("v2_traffic_reset_logs.reset_type = ?", resetType)
|
||||
}
|
||||
|
||||
if triggerSource := strings.TrimSpace(c.Query("trigger_source")); triggerSource != "" {
|
||||
query = query.Where("v2_traffic_reset_logs.trigger_source = ?", triggerSource)
|
||||
}
|
||||
|
||||
if keyword := strings.TrimSpace(c.Query("keyword")); keyword != "" {
|
||||
query = query.Joins("LEFT JOIN v2_user AS keyword_user ON keyword_user.id = v2_traffic_reset_logs.user_id").
|
||||
Where("keyword_user.email LIKE ? OR CAST(v2_traffic_reset_logs.user_id AS CHAR) = ?", "%"+keyword+"%", keyword)
|
||||
}
|
||||
|
||||
if startDate := strings.TrimSpace(c.Query("start_date")); startDate != "" {
|
||||
if ts, ok := parseDateStart(startDate); ok {
|
||||
query = query.Where("v2_traffic_reset_logs.created_at >= ?", ts)
|
||||
}
|
||||
}
|
||||
|
||||
if endDate := strings.TrimSpace(c.Query("end_date")); endDate != "" {
|
||||
if ts, ok := parseDateEnd(endDate); ok {
|
||||
query = query.Where("v2_traffic_reset_logs.created_at <= ?", ts)
|
||||
}
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "failed to count traffic reset logs")
|
||||
return
|
||||
}
|
||||
|
||||
var logs []model.TrafficResetLog
|
||||
query.Offset((page - 1) * perPage).Limit(perPage).Find(&logs)
|
||||
if err := query.Offset((page - 1) * perPage).Limit(perPage).Find(&logs).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "failed to fetch traffic reset logs")
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]gin.H, 0, len(logs))
|
||||
for _, l := range logs {
|
||||
email := ""
|
||||
if l.User != nil {
|
||||
email = l.User.Email
|
||||
for _, log := range logs {
|
||||
items = append(items, serializeTrafficResetLog(log))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": items,
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
// AdminTrafficResetUser manually resets a user's traffic and records the action in logs.
|
||||
func AdminTrafficResetUser(c *gin.Context) {
|
||||
var payload struct {
|
||||
UserID int `json:"user_id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&payload); err != nil || payload.UserID <= 0 {
|
||||
Fail(c, http.StatusBadRequest, "user_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
var user model.User
|
||||
|
||||
err := database.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("id = ?", payload.UserID).First(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
items = append(items, gin.H{
|
||||
"id": l.ID,
|
||||
"user_id": l.UserID,
|
||||
"user_email": email,
|
||||
"reset_type": l.ResetType,
|
||||
"reset_time": l.ResetTime,
|
||||
"old_total": l.OldTotal,
|
||||
"new_total": l.NewTotal,
|
||||
"trigger_source": l.TriggerSource,
|
||||
"created_at": l.CreatedAt,
|
||||
})
|
||||
|
||||
oldUpload := int64(user.U)
|
||||
oldDownload := int64(user.D)
|
||||
oldTotal := oldUpload + oldDownload
|
||||
|
||||
updates := map[string]any{
|
||||
"u": 0,
|
||||
"d": 0,
|
||||
"last_reset_at": now,
|
||||
"reset_count": gorm.Expr("reset_count + 1"),
|
||||
"updated_at": now,
|
||||
}
|
||||
if err := tx.Model(&model.User{}).Where("id = ?", user.ID).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
metadata, err := marshalTrafficResetMetadata(strings.TrimSpace(payload.Reason))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entry := model.TrafficResetLog{
|
||||
UserID: user.ID,
|
||||
ResetType: "manual",
|
||||
ResetTime: now,
|
||||
OldUpload: oldUpload,
|
||||
OldDownload: oldDownload,
|
||||
OldTotal: oldTotal,
|
||||
NewUpload: 0,
|
||||
NewDownload: 0,
|
||||
NewTotal: 0,
|
||||
TriggerSource: "manual",
|
||||
Metadata: metadata,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
return tx.Create(&entry).Error
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
Fail(c, http.StatusBadRequest, "user does not exist")
|
||||
return
|
||||
}
|
||||
Fail(c, http.StatusInternalServerError, "failed to reset traffic")
|
||||
return
|
||||
}
|
||||
|
||||
Success(c, true)
|
||||
}
|
||||
|
||||
// AdminTrafficResetUserHistory returns summary and recent reset history for one user.
|
||||
func AdminTrafficResetUserHistory(c *gin.Context) {
|
||||
userID := parsePositiveInt(c.Param("id"), 0)
|
||||
if userID <= 0 {
|
||||
Fail(c, http.StatusBadRequest, "user id is required")
|
||||
return
|
||||
}
|
||||
|
||||
limit := parsePositiveInt(c.DefaultQuery("limit", "10"), 10)
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := database.DB.Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
Fail(c, http.StatusBadRequest, "user does not exist")
|
||||
return
|
||||
}
|
||||
Fail(c, http.StatusInternalServerError, "failed to load user")
|
||||
return
|
||||
}
|
||||
|
||||
var logs []model.TrafficResetLog
|
||||
if err := database.DB.Where("user_id = ?", userID).
|
||||
Order("created_at DESC, id DESC").
|
||||
Limit(limit).
|
||||
Find(&logs).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "failed to fetch traffic reset history")
|
||||
return
|
||||
}
|
||||
|
||||
history := make([]gin.H, 0, len(logs))
|
||||
for _, log := range logs {
|
||||
history = append(history, serializeTrafficResetLog(log))
|
||||
}
|
||||
|
||||
Success(c, gin.H{
|
||||
"list": items,
|
||||
"pagination": gin.H{
|
||||
"current": page,
|
||||
"last_page": calculateLastPage(total, perPage),
|
||||
"per_page": perPage,
|
||||
"total": total,
|
||||
"user": gin.H{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"reset_count": user.ResetCount,
|
||||
"last_reset_at": int64Value(user.LastResetAt),
|
||||
"next_reset_at": int64Value(user.NextResetAt),
|
||||
},
|
||||
"history": history,
|
||||
})
|
||||
}
|
||||
|
||||
func serializeTrafficResetLog(log model.TrafficResetLog) gin.H {
|
||||
email := ""
|
||||
if log.User != nil {
|
||||
email = log.User.Email
|
||||
}
|
||||
|
||||
return gin.H{
|
||||
"id": log.ID,
|
||||
"user_id": log.UserID,
|
||||
"user_email": email,
|
||||
"reset_type": log.ResetType,
|
||||
"reset_type_name": trafficResetTypeName(log.ResetType),
|
||||
"reset_time": log.ResetTime,
|
||||
"trigger_source": log.TriggerSource,
|
||||
"trigger_source_name": trafficResetTriggerSourceName(log.TriggerSource),
|
||||
"old_traffic": gin.H{
|
||||
"upload": log.OldUpload,
|
||||
"download": log.OldDownload,
|
||||
"total": log.OldTotal,
|
||||
"formatted": formatTrafficValue(log.OldTotal),
|
||||
},
|
||||
"new_traffic": gin.H{
|
||||
"upload": log.NewUpload,
|
||||
"download": log.NewDownload,
|
||||
"total": log.NewTotal,
|
||||
"formatted": formatTrafficValue(log.NewTotal),
|
||||
},
|
||||
"metadata": decodeTrafficResetMetadata(log.Metadata),
|
||||
"created_at": log.CreatedAt,
|
||||
"updated_at": log.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func trafficResetTypeName(resetType string) string {
|
||||
switch resetType {
|
||||
case "manual":
|
||||
return "Manual"
|
||||
case "monthly":
|
||||
return "Monthly"
|
||||
case "yearly":
|
||||
return "Yearly"
|
||||
case "first_day_month":
|
||||
return "First Day of Month"
|
||||
case "first_day_year":
|
||||
return "First Day of Year"
|
||||
default:
|
||||
if resetType == "" {
|
||||
return "-"
|
||||
}
|
||||
return strings.ReplaceAll(resetType, "_", " ")
|
||||
}
|
||||
}
|
||||
|
||||
func trafficResetTriggerSourceName(source string) string {
|
||||
switch source {
|
||||
case "manual":
|
||||
return "Manual"
|
||||
case "cron":
|
||||
return "Cron"
|
||||
case "auto":
|
||||
return "Auto"
|
||||
case "order":
|
||||
return "Order"
|
||||
case "gift_card":
|
||||
return "Gift Card"
|
||||
default:
|
||||
if source == "" {
|
||||
return "-"
|
||||
}
|
||||
return strings.ReplaceAll(source, "_", " ")
|
||||
}
|
||||
}
|
||||
|
||||
func formatTrafficValue(bytes int64) string {
|
||||
if bytes <= 0 {
|
||||
return "0 B"
|
||||
}
|
||||
|
||||
units := []string{"B", "KB", "MB", "GB", "TB", "PB"}
|
||||
size := float64(bytes)
|
||||
unitIndex := 0
|
||||
for size >= 1024 && unitIndex < len(units)-1 {
|
||||
size /= 1024
|
||||
unitIndex++
|
||||
}
|
||||
|
||||
if size >= 100 {
|
||||
return strconv.FormatFloat(size, 'f', 0, 64) + " " + units[unitIndex]
|
||||
}
|
||||
formatted := strconv.FormatFloat(size, 'f', 2, 64)
|
||||
formatted = strings.TrimRight(strings.TrimRight(formatted, "0"), ".")
|
||||
return formatted + " " + units[unitIndex]
|
||||
}
|
||||
|
||||
func parseDateStart(raw string) (int64, bool) {
|
||||
date, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(raw), time.Local)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return date.Unix(), true
|
||||
}
|
||||
|
||||
func parseDateEnd(raw string) (int64, bool) {
|
||||
date, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(raw), time.Local)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return date.Add(24*time.Hour - time.Second).Unix(), true
|
||||
}
|
||||
|
||||
func marshalTrafficResetMetadata(reason string) (string, error) {
|
||||
if reason == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(map[string]string{
|
||||
"reason": reason,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(payload), nil
|
||||
}
|
||||
|
||||
func decodeTrafficResetMetadata(raw string) any {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var value any
|
||||
if err := json.Unmarshal([]byte(raw), &value); err != nil {
|
||||
return raw
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -267,3 +267,27 @@ func formatTimeValue(value *time.Time) string {
|
||||
}
|
||||
return value.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
func getFetchParams(c *gin.Context) map[string]string {
|
||||
params := make(map[string]string)
|
||||
|
||||
// 1. Get from Query parameters
|
||||
for k, v := range c.Request.URL.Query() {
|
||||
if len(v) > 0 {
|
||||
params[k] = v[0]
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Override with JSON body if applicable (for POST)
|
||||
if c.Request.Method == http.MethodPost {
|
||||
var body map[string]any
|
||||
// Using ShouldBindJSON without erroring to allow fallback or partial data
|
||||
if err := c.ShouldBindJSON(&body); err == nil {
|
||||
for k, v := range body {
|
||||
params[k] = stringFromAny(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
@@ -207,6 +207,16 @@ 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) {
|
||||
Success(c, gin.H{
|
||||
"workload": []any{},
|
||||
"masters": []any{},
|
||||
"failed_jobs": 0,
|
||||
"total_jobs": 0,
|
||||
"pending_jobs": 0,
|
||||
"processed": 0,
|
||||
"failed": 0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"xboard-go/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -24,10 +26,7 @@ type userThemeViewData struct {
|
||||
|
||||
type adminAppViewData struct {
|
||||
Title string
|
||||
BaseURL string
|
||||
SecurePath string
|
||||
Version string
|
||||
Logo string
|
||||
SettingsJS template.JS
|
||||
}
|
||||
|
||||
func UserThemePage(c *gin.Context) {
|
||||
@@ -61,13 +60,26 @@ func UserThemePage(c *gin.Context) {
|
||||
}
|
||||
|
||||
func AdminAppPage(c *gin.Context) {
|
||||
securePath := "/" + service.GetAdminSecurePath()
|
||||
securePath := service.GetAdminSecurePath()
|
||||
baseURL := service.GetAppURL()
|
||||
if origin := requestOrigin(c.Request); origin != "" {
|
||||
baseURL = origin
|
||||
}
|
||||
|
||||
title := service.MustGetString("app_name", "XBoard") + " Admin"
|
||||
|
||||
settings := map[string]string{
|
||||
"base_url": baseURL,
|
||||
"title": title,
|
||||
"version": service.MustGetString("app_version", "1.0.0"),
|
||||
"logo": service.MustGetString("logo", ""),
|
||||
"secure_path": securePath,
|
||||
}
|
||||
settingsJSON, _ := json.Marshal(settings)
|
||||
|
||||
payload := adminAppViewData{
|
||||
Title: service.MustGetString("app_name", "XBoard") + " Admin",
|
||||
BaseURL: service.GetAppURL(),
|
||||
SecurePath: securePath,
|
||||
Version: service.MustGetString("app_version", "1.0.0"),
|
||||
Logo: service.MustGetString("logo", ""),
|
||||
Title: title,
|
||||
SettingsJS: template.JS(settingsJSON),
|
||||
}
|
||||
|
||||
renderPageTemplate(c, filepath.Join("frontend", "templates", "admin_app.html"), payload)
|
||||
@@ -96,3 +108,38 @@ func mustJSON(value any) template.JS {
|
||||
}
|
||||
return template.JS(payload)
|
||||
}
|
||||
|
||||
func requestOrigin(r *http.Request) string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
scheme := r.Header.Get("X-Forwarded-Proto")
|
||||
if scheme == "" {
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
} else {
|
||||
scheme = "http"
|
||||
}
|
||||
}
|
||||
|
||||
host := r.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = r.Host
|
||||
}
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if forwardedPort := r.Header.Get("X-Forwarded-Port"); forwardedPort != "" && !strings.Contains(host, ":") {
|
||||
defaultPort := map[string]string{
|
||||
"http": "80",
|
||||
"https": "443",
|
||||
}[scheme]
|
||||
if forwardedPort != "" && forwardedPort != defaultPort {
|
||||
host = net.JoinHostPort(host, forwardedPort)
|
||||
}
|
||||
}
|
||||
|
||||
return scheme + "://" + host
|
||||
}
|
||||
|
||||
@@ -13,27 +13,35 @@ import (
|
||||
|
||||
func Auth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||
if authHeader == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "unauthorized"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
token := authHeader
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
token = strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
|
||||
} else if strings.Contains(authHeader, " ") {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "invalid authorization header"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := utils.VerifyToken(parts[1])
|
||||
if token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "invalid authorization header"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := utils.VerifyToken(token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "token expired or invalid"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if service.IsSessionTokenRevoked(parts[1]) {
|
||||
if service.IsSessionTokenRevoked(token) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "session has been revoked"})
|
||||
c.Abort()
|
||||
return
|
||||
@@ -41,8 +49,8 @@ func Auth() gin.HandlerFunc {
|
||||
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("is_admin", claims.IsAdmin)
|
||||
c.Set("auth_token", parts[1])
|
||||
c.Set("session", service.TrackSession(claims.UserID, parts[1], c.ClientIP(), c.GetHeader("User-Agent")))
|
||||
c.Set("auth_token", token)
|
||||
c.Set("session", service.TrackSession(claims.UserID, token, c.ClientIP(), c.GetHeader("User-Agent")))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,20 @@ func GetSetting(name string) (string, bool) {
|
||||
return setting.Value, true
|
||||
}
|
||||
|
||||
func normalizeWrappedString(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
for len(value) >= 2 {
|
||||
first := value[0]
|
||||
last := value[len(value)-1]
|
||||
if (first == '"' && last == '"') || (first == '\'' && last == '\'') || (first == '`' && last == '`') {
|
||||
value = strings.TrimSpace(value[1 : len(value)-1])
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func MustGetString(name, defaultValue string) string {
|
||||
value, ok := GetSetting(name)
|
||||
if !ok || strings.TrimSpace(value) == "" {
|
||||
@@ -56,17 +70,17 @@ func MustGetBool(name string, defaultValue bool) bool {
|
||||
}
|
||||
|
||||
func GetAdminSecurePath() string {
|
||||
if securePath := strings.Trim(MustGetString("secure_path", ""), "/"); securePath != "" {
|
||||
if securePath := strings.Trim(normalizeWrappedString(MustGetString("secure_path", "")), "/"); securePath != "" {
|
||||
return securePath
|
||||
}
|
||||
if frontendPath := strings.Trim(MustGetString("frontend_admin_path", ""), "/"); frontendPath != "" {
|
||||
if frontendPath := strings.Trim(normalizeWrappedString(MustGetString("frontend_admin_path", "")), "/"); frontendPath != "" {
|
||||
return frontendPath
|
||||
}
|
||||
return "admin"
|
||||
}
|
||||
|
||||
func GetAppURL() string {
|
||||
if appURL := strings.TrimSpace(MustGetString("app_url", "")); appURL != "" {
|
||||
if appURL := normalizeWrappedString(MustGetString("app_url", "")); appURL != "" {
|
||||
return strings.TrimRight(appURL, "/")
|
||||
}
|
||||
return strings.TrimRight(config.AppConfig.AppURL, "/")
|
||||
|
||||
Reference in New Issue
Block a user