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 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", "20"), 20) if perPage > 1000 { perPage = 1000 } 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 ?", "%"+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 if err := query.Count(&total).Error; err != nil { Fail(c, http.StatusInternalServerError, "failed to count traffic reset logs") return } var logs []model.TrafficResetLog 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 _, 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 } 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{ "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 }