Files
SingBox-Gopanel/internal/handler/admin_resource_api.go
CN-JS-HuiBai b3435e5ef8
Some checks failed
build / build (api, amd64, linux) (push) Has been cancelled
build / build (api, arm64, linux) (push) Has been cancelled
build / build (api.exe, amd64, windows) (push) Has been cancelled
基本功能已初步完善
2026-04-17 20:41:47 +08:00

857 lines
26 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(&currentMonthIncome)
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(&currentMonthNewUsers)
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"])
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
}
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
}