package httpapi import ( "errors" "net/http" "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/sundynix/pets-be/internal/model" "gorm.io/gorm" ) type adminPage struct { Page int `json:"page"` PageSize int `json:"page_size"` Total int64 `json:"total"` Items any `json:"items"` } func pageParams(c *gin.Context) (adminPage, bool) { page, err := strconv.Atoi(c.DefaultQuery("page", "1")) size, sizeErr := strconv.Atoi(c.DefaultQuery("page_size", "10")) if err != nil || sizeErr != nil || page < 1 || page > 1000000 || size < 1 || size > 100 || len([]rune(c.Query("q"))) > 128 { fail(c, http.StatusBadRequest, "分页或搜索参数无效") return adminPage{}, false } return adminPage{Page: page, PageSize: size}, true } func adminRows[T any](c *gin.Context, query *gorm.DB, order string) ([]T, adminPage, bool) { page, valid := pageParams(c) if !valid { return nil, page, false } if err := query.Count(&page.Total).Error; err != nil { fail(c, 500, "读取记录数量失败") return nil, page, false } rows := make([]T, 0) if err := query.Order(order).Offset((page.Page - 1) * page.PageSize).Limit(page.PageSize).Find(&rows).Error; err != nil { fail(c, 500, "读取记录失败") return nil, page, false } return rows, page, true } func search(query *gorm.DB, c *gin.Context, columns ...string) *gorm.DB { q := strings.TrimSpace(c.Query("q")) if q == "" { return query } // Only allow literal substring search. Columns are constants supplied by handlers. value := "%" + strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(q) + "%" clauses := make([]string, 0, len(columns)) args := make([]any, 0, len(columns)) for _, column := range columns { clauses = append(clauses, column+" ILIKE ?") args = append(args, value) } return query.Where("("+strings.Join(clauses, " OR ")+")", args...) } func filterStatus(c *gin.Context, query *gorm.DB, column string, allowed ...string) (*gorm.DB, bool) { status := c.Query("status") if status == "" { return query, true } for _, value := range allowed { if status == value { return query.Where(column+" = ?", status), true } } fail(c, 400, "筛选状态无效") return query, false } func (a *API) userNames(ids []string) (map[string]string, error) { names := map[string]string{} if len(ids) == 0 { return names, nil } var users []model.User err := a.db.Select("id", "nickname").Where("id IN ?", ids).Find(&users).Error for _, user := range users { names[user.ID] = user.Nickname } return names, err } func (a *API) adminStats(c *gin.Context) { counts := gin.H{} for _, pair := range []struct { name string value any }{{"users", &model.User{}}, {"pets", &model.Pet{}}, {"walks", &model.Walk{}}, {"meetups", &model.Meetup{}}, {"tasks", &model.CareTask{}}, {"files", &model.File{}}} { var n int64 if err := a.db.Model(pair.value).Count(&n).Error; err != nil { fail(c, 500, "读取统计失败") return } counts[pair.name] = n } now := time.Now() today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) for _, pair := range []struct { name string query *gorm.DB }{ {"today_walks", a.db.Model(&model.Walk{}).Where("started_at >= ?", today)}, {"active_walks", a.db.Model(&model.Walk{}).Where("status IN ?", []string{"active", "paused"})}, {"upcoming_meetups", a.db.Model(&model.Meetup{}).Where("status = ? AND starts_at > ?", "open", now)}, {"today_completions", a.db.Model(&model.TaskCompletion{}).Where("date = ?", today)}, } { var n int64 if pair.query.Count(&n).Error != nil { fail(c, 500, "读取统计失败") return } counts[pair.name] = n } var metrics struct { Distance float64 Duration int64 Storage int64 } if a.db.Model(&model.Walk{}).Select("COALESCE(SUM(distance_meters),0) AS distance, COALESCE(SUM(duration_seconds),0) AS duration").Scan(&metrics).Error != nil || a.db.Model(&model.File{}).Select("COALESCE(SUM(size),0) AS storage").Scan(&metrics).Error != nil { fail(c, 500, "读取统计失败") return } counts["distance_meters"] = metrics.Distance counts["duration_seconds"] = metrics.Duration counts["storage_bytes"] = metrics.Storage type dayCount struct { Date string `json:"date"` Count int64 `json:"count"` } var grouped []dayCount since := today.AddDate(0, 0, -6) if a.db.Model(&model.Walk{}).Select("to_char(started_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS date, COUNT(*) AS count").Where("started_at >= ?", since).Group("date").Scan(&grouped).Error != nil { fail(c, 500, "读取趋势失败") return } daily := make([]dayCount, 0, 7) for day := since; !day.After(today); day = day.AddDate(0, 0, 1) { entry := dayCount{Date: day.Format("2006-01-02")} for _, row := range grouped { if row.Date == entry.Date { entry.Count = row.Count } } daily = append(daily, entry) } counts["daily_walks"] = daily ok(c, counts) } func (a *API) adminUsers(c *gin.Context) { query := search(a.db.Model(&model.User{}), c, "nickname", "id::text") switch c.Query("status") { case "": case "enabled": query = query.Where("disabled = false") case "disabled": query = query.Where("disabled = true") default: fail(c, 400, "筛选状态无效") return } rows, page, valid := adminRows[model.User](c, query, "created_at DESC, id DESC") if !valid { return } ids := []string{} for _, row := range rows { ids = append(ids, row.ID) } var totals []struct { UserID string Count int64 } if len(ids) > 0 && a.db.Model(&model.Pet{}).Select("user_id, COUNT(*) AS count").Where("user_id IN ?", ids).Group("user_id").Scan(&totals).Error != nil { fail(c, 500, "读取宠物数量失败") return } counts := map[string]int64{} for _, row := range totals { counts[row.UserID] = row.Count } items := make([]gin.H, 0, len(rows)) for _, u := range rows { view := a.userView(u) view["disabled"] = u.Disabled view["pet_count"] = counts[u.ID] items = append(items, view) } page.Items = items ok(c, page) } func (a *API) adminUserPets(c *gin.Context) { id := c.Param("id") if !validID(id) { fail(c, 400, "用户 ID 无效") return } var user model.User if err := a.db.First(&user, "id = ?", id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { fail(c, 404, "用户不存在") } else { fail(c, 500, "读取用户失败") } return } var rows []model.Pet if a.db.Where("user_id = ?", id).Order("archived ASC, created_at DESC, id DESC").Find(&rows).Error != nil { fail(c, 500, "读取宠物失败") return } items := make([]gin.H, 0, len(rows)) for _, pet := range rows { view := a.petView(pet) view["archived"] = pet.Archived view["user_id"] = id view["user_name"] = user.Nickname items = append(items, view) } ok(c, items) } func (a *API) adminPets(c *gin.Context) { query := search(a.db.Model(&model.Pet{}), c, "name", "breed", "id::text", "user_id::text") switch c.Query("status") { case "": case "active": query = query.Where("archived = false") case "archived": query = query.Where("archived = true") default: fail(c, 400, "筛选状态无效") return } rows, page, valid := adminRows[model.Pet](c, query, "created_at DESC, id DESC") if !valid { return } ids := []string{} for _, p := range rows { ids = append(ids, p.UserID) } names, err := a.userNames(ids) if err != nil { fail(c, 500, "读取主人信息失败") return } items := make([]gin.H, 0, len(rows)) for _, p := range rows { v := a.petView(p) v["user_id"] = p.UserID v["user_name"] = names[p.UserID] v["archived"] = p.Archived items = append(items, v) } page.Items = items ok(c, page) } func (a *API) adminWalks(c *gin.Context) { query := search(a.db.Model(&model.Walk{}), c, "id::text", "user_id::text") query, valid := filterStatus(c, query, "status", "active", "paused", "finished") if !valid { return } rows, page, valid := adminRows[model.Walk](c, query, "started_at DESC, id DESC") if !valid { return } ids := []string{} walkIDs := []string{} for _, w := range rows { ids = append(ids, w.UserID) walkIDs = append(walkIDs, w.ID) } names, err := a.userNames(ids) if err != nil { fail(c, 500, "读取用户失败") return } var links []struct { WalkID string PetName string } if len(walkIDs) > 0 && a.db.Model(&model.WalkPet{}).Select("sundynix_walk_pet.walk_id, p.name AS pet_name").Joins("JOIN sundynix_pet p ON p.id = sundynix_walk_pet.pet_id").Where("walk_id IN ?", walkIDs).Order("p.created_at ASC").Scan(&links).Error != nil { fail(c, 500, "读取同行宠物失败") return } pets := map[string][]string{} for _, link := range links { pets[link.WalkID] = append(pets[link.WalkID], link.PetName) } items := make([]gin.H, 0, len(rows)) for _, w := range rows { items = append(items, gin.H{"id": w.ID, "user_id": w.UserID, "user_name": names[w.UserID], "pet_names": pets[w.ID], "status": w.Status, "started_at": w.StartedAt, "ended_at": w.EndedAt, "duration_seconds": w.DurationSeconds, "distance_meters": w.DistanceMeters, "share_enabled": w.ShareEnabled}) } page.Items = items ok(c, page) } func (a *API) adminWalkDetail(c *gin.Context) { if !validID(c.Param("id")) { fail(c, 400, "记录 ID 无效") return } var walk model.Walk if err := a.db.First(&walk, "id = ?", c.Param("id")).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { fail(c, 404, "遛狗记录不存在") } else { fail(c, 500, "读取记录失败") } return } var points []model.WalkPoint if a.db.Where("walk_id = ?", walk.ID).Order("recorded_at ASC, id ASC").Find(&points).Error != nil { fail(c, 500, "读取足迹失败") return } ok(c, gin.H{"id": walk.ID, "points": points}) } func (a *API) adminMeetups(c *gin.Context) { query := search(a.db.Model(&model.Meetup{}), c, "title", "place_name", "id::text") query, valid := filterStatus(c, query, "status", "open", "cancelled") if !valid { return } rows, page, valid := adminRows[model.Meetup](c, query, "created_at DESC, id DESC") if !valid { return } ids := []string{} for _, m := range rows { ids = append(ids, m.HostUserID) } names, err := a.userNames(ids) if err != nil { fail(c, 500, "读取发起人失败") return } items := make([]gin.H, 0, len(rows)) for _, m := range rows { v := a.meetupView(m) v["user_name"] = names[m.HostUserID] items = append(items, v) } page.Items = items ok(c, page) } func (a *API) adminTasks(c *gin.Context) { query := search(a.db.Model(&model.CareTask{}), c, "title", "id::text", "user_id::text", "pet_id::text") query, valid := filterStatus(c, query, "category", "food", "water", "walk", "medicine", "grooming", "other") if !valid { return } rows, page, valid := adminRows[model.CareTask](c, query, "created_at DESC, id DESC") if !valid { return } ids, petIDs, taskIDs := []string{}, []string{}, []string{} for _, t := range rows { ids = append(ids, t.UserID) if t.PetID != nil { petIDs = append(petIDs, *t.PetID) } taskIDs = append(taskIDs, t.ID) } names, err := a.userNames(ids) if err != nil { fail(c, 500, "读取用户失败") return } var pets []model.Pet if len(petIDs) > 0 && a.db.Select("id", "name").Where("id IN ?", petIDs).Find(&pets).Error != nil { fail(c, 500, "读取宠物失败") return } petNames := map[string]string{} for _, p := range pets { petNames[p.ID] = p.Name } var completions []model.TaskCompletion if len(taskIDs) > 0 && a.db.Where("task_id IN ? AND date = ?", taskIDs, time.Now().Format("2006-01-02")).Find(&completions).Error != nil { fail(c, 500, "读取任务完成情况失败") return } done := map[string]bool{} for _, completion := range completions { done[completion.TaskID] = true } items := make([]gin.H, 0, len(rows)) for _, t := range rows { petName := "全部宠物" if t.PetID != nil { petName = petNames[*t.PetID] } items = append(items, gin.H{"id": t.ID, "user_id": t.UserID, "user_name": names[t.UserID], "pet_id": t.PetID, "all_pets": t.PetID == nil, "pet_name": petName, "title": t.Title, "category": t.Category, "time_of_day": t.TimeOfDay, "repeat_rule": t.RepeatRule, "reminder_enabled": t.ReminderEnabled, "completed": done[t.ID], "created_at": t.CreatedAt}) } page.Items = items ok(c, page) } func (a *API) adminFiles(c *gin.Context) { query := search(a.db.Model(&model.File{}), c, "name", "md5", "id::text") rows, page, valid := adminRows[model.File](c, query, "created_at DESC, id DESC") if !valid { return } items := make([]gin.H, 0, len(rows)) for _, file := range rows { v := a.fileView(file) v["created_at"] = file.CreatedAt v["bucket"] = file.Bucket v["object_key"] = file.ObjectKey items = append(items, v) } page.Items = items ok(c, page) } func (a *API) adminSetUserDisabled(c *gin.Context) { var in struct { Disabled *bool `json:"disabled"` } if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil || in.Disabled == nil { fail(c, 400, "参数无效") return } err := a.db.Transaction(func(tx *gorm.DB) error { result := tx.Model(&model.User{}).Where("id = ?", c.Param("id")).Update("disabled", *in.Disabled) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return gorm.ErrRecordNotFound } // Disabling an account also revokes any publicly shared locations. if *in.Disabled { return tx.Model(&model.Walk{}).Where("user_id = ?", c.Param("id")).Updates(map[string]any{"share_enabled": false, "share_expires_at": nil}).Error } return nil }) if errors.Is(err, gorm.ErrRecordNotFound) { fail(c, 404, "用户不存在") return } if err != nil { fail(c, 500, "更新用户失败") return } ok(c, gin.H{"disabled": *in.Disabled}) } func (a *API) adminSetMeetupStatus(c *gin.Context) { var in struct { Status string `json:"status"` } if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil || in.Status != "cancelled" { fail(c, 400, "状态无效") return } result := a.db.Model(&model.Meetup{}).Where("id = ? AND status = ?", c.Param("id"), "open").Update("status", in.Status) if result.Error != nil { fail(c, 500, "更新约遛失败") return } if result.RowsAffected == 0 { fail(c, 409, "约遛不存在或已取消") return } ok(c, gin.H{"status": in.Status}) }