862 lines
27 KiB
Go
862 lines
27 KiB
Go
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()
|
|
todayStart := time.Now().Truncate(24 * time.Hour).Unix()
|
|
yesterdayStart := todayStart - 86400
|
|
currentMonthStart := time.Date(time.Now().Year(), time.Now().Month(), 1, 0, 0, 0, 0, time.Local).Unix()
|
|
lastMonthStart := time.Date(time.Now().Year(), time.Now().Month()-1, 1, 0, 0, 0, 0, time.Local).Unix()
|
|
|
|
// 1. Basic Counts
|
|
var totalUsers int64
|
|
var totalOrders int64
|
|
var pendingOrders int64
|
|
var pendingTickets int64
|
|
var commissionPendingTotal int64
|
|
var onlineUsers int64
|
|
var onlineNodes int64
|
|
var onlineDevices int64
|
|
|
|
database.DB.Model(&model.User{}).Count(&totalUsers)
|
|
database.DB.Model(&model.Order{}).Count(&totalOrders)
|
|
database.DB.Model(&model.Order{}).Where("status = ?", 0).Count(&pendingOrders)
|
|
database.DB.Model(&model.Order{}).
|
|
Where("status = ? AND commission_status = ?", 3, 0).
|
|
Select("COALESCE(SUM(commission_balance), 0)").
|
|
Scan(&commissionPendingTotal)
|
|
database.DB.Model(&model.Ticket{}).Where("status = ?", 0).Count(&pendingTickets)
|
|
database.DB.Model(&model.Server{}).Where("show = ?", true).Count(&onlineNodes) // Simplified online check
|
|
|
|
// Online status (last 10 mins)
|
|
tenMinsAgo := now - 600
|
|
database.DB.Model(&model.User{}).Where("t >= ?", tenMinsAgo).Count(&onlineUsers)
|
|
database.DB.Model(&model.User{}).Where("t >= ?", tenMinsAgo).Select("COALESCE(SUM(online_count), 0)").Scan(&onlineDevices)
|
|
|
|
// 2. Income Statistics
|
|
var todayIncome int64
|
|
var yesterdayIncome int64
|
|
var currentMonthIncome int64
|
|
var lastMonthIncome int64
|
|
|
|
database.DB.Model(&model.Order{}).Where("status NOT IN ? AND created_at >= ?", []int{0, 2}, todayStart).Select("COALESCE(SUM(total_amount), 0)").Scan(&todayIncome)
|
|
database.DB.Model(&model.Order{}).Where("status NOT IN ? AND created_at >= ? AND created_at < ?", []int{0, 2}, yesterdayStart, todayStart).Select("COALESCE(SUM(total_amount), 0)").Scan(&yesterdayIncome)
|
|
database.DB.Model(&model.Order{}).Where("status NOT IN ? AND created_at >= ?", []int{0, 2}, currentMonthStart).Select("COALESCE(SUM(total_amount), 0)").Scan(¤tMonthIncome)
|
|
database.DB.Model(&model.Order{}).Where("status NOT IN ? AND created_at >= ? AND created_at < ?", []int{0, 2}, lastMonthStart, currentMonthStart).Select("COALESCE(SUM(total_amount), 0)").Scan(&lastMonthIncome)
|
|
|
|
// 3. Traffic Statistics (using StatServer)
|
|
type trafficSum struct {
|
|
U int64 `json:"upload"`
|
|
D int64 `json:"download"`
|
|
Total int64 `json:"total"`
|
|
}
|
|
var todayTraffic trafficSum
|
|
var monthTraffic trafficSum
|
|
var totalTraffic trafficSum
|
|
|
|
database.DB.Model(&model.StatServer{}).Where("record_at >= ?", todayStart).Select("COALESCE(SUM(u), 0) as u, COALESCE(SUM(d), 0) as d, COALESCE(SUM(u+d), 0) as total").Scan(&todayTraffic)
|
|
database.DB.Model(&model.StatServer{}).Where("record_at >= ?", currentMonthStart).Select("COALESCE(SUM(u), 0) as u, COALESCE(SUM(d), 0) as d, COALESCE(SUM(u+d), 0) as total").Scan(&monthTraffic)
|
|
database.DB.Model(&model.StatServer{}).Select("COALESCE(SUM(u), 0) as u, COALESCE(SUM(d), 0) as d, COALESCE(SUM(u+d), 0) as total").Scan(&totalTraffic)
|
|
|
|
// 4. User Growth
|
|
var currentMonthNewUsers int64
|
|
var lastMonthNewUsers int64
|
|
database.DB.Model(&model.User{}).Where("created_at >= ?", currentMonthStart).Count(¤tMonthNewUsers)
|
|
database.DB.Model(&model.User{}).Where("created_at >= ? AND created_at < ?", lastMonthStart, currentMonthStart).Count(&lastMonthNewUsers)
|
|
|
|
// Calculate Growth Rates
|
|
dayIncomeGrowth := calculateGrowth(todayIncome, yesterdayIncome)
|
|
monthIncomeGrowth := calculateGrowth(currentMonthIncome, lastMonthIncome)
|
|
userGrowth := calculateGrowth(currentMonthNewUsers, lastMonthNewUsers)
|
|
|
|
Success(c, gin.H{
|
|
"server_time": now,
|
|
"todayIncome": todayIncome,
|
|
"dayIncomeGrowth": dayIncomeGrowth,
|
|
"currentMonthIncome": currentMonthIncome,
|
|
"lastMonthIncome": lastMonthIncome,
|
|
"monthIncomeGrowth": monthIncomeGrowth,
|
|
"totalOrders": totalOrders,
|
|
"pendingOrders": pendingOrders,
|
|
"currentMonthNewUsers": currentMonthNewUsers,
|
|
"totalUsers": totalUsers,
|
|
"activeUsers": totalUsers, // Placeholder for valid subscription count
|
|
"userGrowth": userGrowth,
|
|
"commissionPendingTotal": commissionPendingTotal,
|
|
"onlineUsers": onlineUsers,
|
|
"onlineDevices": onlineDevices,
|
|
"ticketPendingTotal": pendingTickets,
|
|
"onlineNodes": onlineNodes,
|
|
"todayTraffic": todayTraffic,
|
|
"monthTraffic": monthTraffic,
|
|
"totalTraffic": totalTraffic,
|
|
"secure_path": service.GetAdminSecurePath(),
|
|
"app_name": service.MustGetString("app_name", "XBoard"),
|
|
})
|
|
}
|
|
|
|
func calculateGrowth(current, previous int64) float64 {
|
|
if previous <= 0 {
|
|
if current > 0 {
|
|
return 100.0
|
|
}
|
|
return 0.0
|
|
}
|
|
return float64(current-previous) / float64(previous) * 100.0
|
|
}
|
|
|
|
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) {
|
|
params := getFetchParams(c)
|
|
page := parsePositiveInt(firstString(params["page"], params["current"]), 1)
|
|
perPage := parsePositiveInt(firstString(params["per_page"], params["pageSize"]), 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 != "" {
|
|
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))
|
|
inviteEmails := loadUserEmailMap(extractOrderInviteUserIDs(orders))
|
|
items := make([]gin.H, 0, len(orders))
|
|
for _, order := range orders {
|
|
item := normalizeOrder(order)
|
|
item["user_email"] = userEmails[order.UserID]
|
|
item["user"] = gin.H{
|
|
"id": order.UserID,
|
|
"email": userEmails[order.UserID],
|
|
}
|
|
if order.InviteUserID != nil {
|
|
item["invite_user"] = gin.H{
|
|
"id": *order.InviteUserID,
|
|
"email": inviteEmails[*order.InviteUserID],
|
|
}
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
|
|
Success(c, gin.H{
|
|
"list": items,
|
|
"data": items,
|
|
"total": total,
|
|
"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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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) {
|
|
params := getFetchParams(c)
|
|
page := parsePositiveInt(firstString(params["page"], params["current"]), 1)
|
|
perPage := parsePositiveInt(firstString(params["per_page"], params["pageSize"]), 50)
|
|
keyword := strings.TrimSpace(params["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, 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
|
|
}
|
|
|
|
userIDs := make([]int, 0, len(users))
|
|
for _, user := range users {
|
|
userIDs = append(userIDs, user.ID)
|
|
}
|
|
deviceMap := service.GetUsersDevices(userIDs)
|
|
|
|
realnameStatusByUserID := make(map[int]string, len(userIDs))
|
|
if len(userIDs) > 0 {
|
|
var records []model.RealNameAuth
|
|
if err := database.DB.Select("user_id", "status").Where("user_id IN ?", userIDs).Find(&records).Error; err == nil {
|
|
for _, record := range records {
|
|
realnameStatusByUserID[int(record.UserID)] = record.Status
|
|
}
|
|
}
|
|
}
|
|
|
|
shadowByParentID := make(map[int]model.User, len(userIDs))
|
|
if len(userIDs) > 0 {
|
|
var shadowUsers []model.User
|
|
if err := database.DB.Select("id", "parent_id", "email").Where("parent_id IN ?", userIDs).Find(&shadowUsers).Error; err == nil {
|
|
for _, shadow := range shadowUsers {
|
|
if shadow.ParentID != nil {
|
|
shadowByParentID[*shadow.ParentID] = shadow
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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, ", ")
|
|
}
|
|
|
|
realnameStatus := realnameStatusByUserID[user.ID]
|
|
if realnameStatus == "" {
|
|
realnameStatus = "unverified"
|
|
}
|
|
|
|
ipv6Shadow, hasIPv6Shadow := shadowByParentID[user.ID]
|
|
ipv6ShadowID := 0
|
|
if hasIPv6Shadow {
|
|
ipv6ShadowID = ipv6Shadow.ID
|
|
}
|
|
|
|
items = append(items, gin.H{
|
|
"id": user.ID,
|
|
"email": user.Email,
|
|
"parent_id": intValue(user.ParentID),
|
|
"is_shadow_user": user.ParentID != nil,
|
|
"balance": user.Balance,
|
|
"uuid": user.UUID,
|
|
"token": user.Token,
|
|
"group_id": intValue(user.GroupID),
|
|
"group_name": groupNames[intFromPointer(user.GroupID)],
|
|
"group": gin.H{
|
|
"id": intValue(user.GroupID),
|
|
"name": groupNames[intFromPointer(user.GroupID)],
|
|
},
|
|
"plan_id": intValue(user.PlanID),
|
|
"plan_name": planName(user.Plan),
|
|
"plan": gin.H{
|
|
"id": intValue(user.PlanID),
|
|
"name": planName(user.Plan),
|
|
},
|
|
"transfer_enable": user.TransferEnable,
|
|
"u": user.U,
|
|
"d": user.D,
|
|
"total_used": user.U + 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),
|
|
"next_reset_at": int64Value(user.NextResetAt),
|
|
"last_login_at": int64Value(user.LastLoginAt),
|
|
"last_login_ip": user.LastLoginIP,
|
|
"online_ip": onlineIP,
|
|
"online_ip_count": len(deviceMap[user.ID]),
|
|
"realname_status": realnameStatus,
|
|
"realname_label": realNameStatusLabel(realnameStatus),
|
|
"ipv6_shadow_id": ipv6ShadowID,
|
|
"ipv6_shadow_email": firstString(ipv6Shadow.Email, service.IPv6ShadowEmail(user.Email)),
|
|
"ipv6_enabled": hasIPv6Shadow,
|
|
"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,
|
|
})
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"list": items,
|
|
"data": items,
|
|
"total": total,
|
|
"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) {
|
|
params := getFetchParams(c)
|
|
if ticketID := strings.TrimSpace(params["id"]); ticketID != "" {
|
|
adminTicketDetail(c, ticketID)
|
|
return
|
|
}
|
|
|
|
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 != "" {
|
|
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,
|
|
"data": items,
|
|
"total": total,
|
|
"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 extractOrderInviteUserIDs(orders []model.Order) []int {
|
|
ids := make([]int, 0, len(orders))
|
|
for _, order := range orders {
|
|
if order.InviteUserID != nil {
|
|
ids = append(ids, *order.InviteUserID)
|
|
}
|
|
}
|
|
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
|
|
}
|